commit 94f99c4f8db449eea22fac2665148a4ccffacb64 Author: gitea-actions Date: Sat Aug 22 10:28:21 2026 +0000 docs: publish site from 5fede123 diff --git a/.nojekyll b/.nojekyll new file mode 100644 index 00000000..f1731109 --- /dev/null +++ b/.nojekyll @@ -0,0 +1 @@ +This file makes sure that Github Pages doesn't process mdBook's output. diff --git a/404.html b/404.html new file mode 100644 index 00000000..c4ced1f4 --- /dev/null +++ b/404.html @@ -0,0 +1,221 @@ + + + + + + Page not found - JellyTau Documentation + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+ + + + + + + + + + + + + + + + + + + +
+ +
+ + + + + + + + +
+
+

Document not found (404)

+

This URL is invalid, sorry. Please use the navigation bar or search to continue.

+ +
+ + +
+
+ + + +
+ + + + + + + + + + + + + + + + + + +
+ + diff --git a/FontAwesome/css/font-awesome.css b/FontAwesome/css/font-awesome.css new file mode 100644 index 00000000..540440ce --- /dev/null +++ b/FontAwesome/css/font-awesome.css @@ -0,0 +1,4 @@ +/*! + * Font Awesome 4.7.0 by @davegandy - http://fontawesome.io - @fontawesome + * License - http://fontawesome.io/license (Font: SIL OFL 1.1, CSS: MIT License) + */@font-face{font-family:'FontAwesome';src:url('../fonts/fontawesome-webfont.eot?v=4.7.0');src:url('../fonts/fontawesome-webfont.eot?#iefix&v=4.7.0') format('embedded-opentype'),url('../fonts/fontawesome-webfont.woff2?v=4.7.0') format('woff2'),url('../fonts/fontawesome-webfont.woff?v=4.7.0') format('woff'),url('../fonts/fontawesome-webfont.ttf?v=4.7.0') format('truetype'),url('../fonts/fontawesome-webfont.svg?v=4.7.0#fontawesomeregular') format('svg');font-weight:normal;font-style:normal}.fa{display:inline-block;font:normal normal normal 14px/1 FontAwesome;font-size:inherit;text-rendering:auto;-webkit-font-smoothing:antialiased;-moz-osx-font-smoothing:grayscale}.fa-lg{font-size:1.33333333em;line-height:.75em;vertical-align:-15%}.fa-2x{font-size:2em}.fa-3x{font-size:3em}.fa-4x{font-size:4em}.fa-5x{font-size:5em}.fa-fw{width:1.28571429em;text-align:center}.fa-ul{padding-left:0;margin-left:2.14285714em;list-style-type:none}.fa-ul>li{position:relative}.fa-li{position:absolute;left:-2.14285714em;width:2.14285714em;top:.14285714em;text-align:center}.fa-li.fa-lg{left:-1.85714286em}.fa-border{padding:.2em .25em .15em;border:solid .08em #eee;border-radius:.1em}.fa-pull-left{float:left}.fa-pull-right{float:right}.fa.fa-pull-left{margin-right:.3em}.fa.fa-pull-right{margin-left:.3em}.pull-right{float:right}.pull-left{float:left}.fa.pull-left{margin-right:.3em}.fa.pull-right{margin-left:.3em}.fa-spin{-webkit-animation:fa-spin 2s infinite linear;animation:fa-spin 2s infinite linear}.fa-pulse{-webkit-animation:fa-spin 1s infinite steps(8);animation:fa-spin 1s infinite steps(8)}@-webkit-keyframes fa-spin{0%{-webkit-transform:rotate(0deg);transform:rotate(0deg)}100%{-webkit-transform:rotate(359deg);transform:rotate(359deg)}}@keyframes fa-spin{0%{-webkit-transform:rotate(0deg);transform:rotate(0deg)}100%{-webkit-transform:rotate(359deg);transform:rotate(359deg)}}.fa-rotate-90{-ms-filter:"progid:DXImageTransform.Microsoft.BasicImage(rotation=1)";-webkit-transform:rotate(90deg);-ms-transform:rotate(90deg);transform:rotate(90deg)}.fa-rotate-180{-ms-filter:"progid:DXImageTransform.Microsoft.BasicImage(rotation=2)";-webkit-transform:rotate(180deg);-ms-transform:rotate(180deg);transform:rotate(180deg)}.fa-rotate-270{-ms-filter:"progid:DXImageTransform.Microsoft.BasicImage(rotation=3)";-webkit-transform:rotate(270deg);-ms-transform:rotate(270deg);transform:rotate(270deg)}.fa-flip-horizontal{-ms-filter:"progid:DXImageTransform.Microsoft.BasicImage(rotation=0, mirror=1)";-webkit-transform:scale(-1, 1);-ms-transform:scale(-1, 1);transform:scale(-1, 1)}.fa-flip-vertical{-ms-filter:"progid:DXImageTransform.Microsoft.BasicImage(rotation=2, mirror=1)";-webkit-transform:scale(1, -1);-ms-transform:scale(1, -1);transform:scale(1, -1)}:root .fa-rotate-90,:root .fa-rotate-180,:root .fa-rotate-270,:root .fa-flip-horizontal,:root .fa-flip-vertical{filter:none}.fa-stack{position:relative;display:inline-block;width:2em;height:2em;line-height:2em;vertical-align:middle}.fa-stack-1x,.fa-stack-2x{position:absolute;left:0;width:100%;text-align:center}.fa-stack-1x{line-height:inherit}.fa-stack-2x{font-size:2em}.fa-inverse{color:#fff}.fa-glass:before{content:"\f000"}.fa-music:before{content:"\f001"}.fa-search:before{content:"\f002"}.fa-envelope-o:before{content:"\f003"}.fa-heart:before{content:"\f004"}.fa-star:before{content:"\f005"}.fa-star-o:before{content:"\f006"}.fa-user:before{content:"\f007"}.fa-film:before{content:"\f008"}.fa-th-large:before{content:"\f009"}.fa-th:before{content:"\f00a"}.fa-th-list:before{content:"\f00b"}.fa-check:before{content:"\f00c"}.fa-remove:before,.fa-close:before,.fa-times:before{content:"\f00d"}.fa-search-plus:before{content:"\f00e"}.fa-search-minus:before{content:"\f010"}.fa-power-off:before{content:"\f011"}.fa-signal:before{content:"\f012"}.fa-gear:before,.fa-cog:before{content:"\f013"}.fa-trash-o:before{content:"\f014"}.fa-home:before{content:"\f015"}.fa-file-o:before{content:"\f016"}.fa-clock-o:before{content:"\f017"}.fa-road:before{content:"\f018"}.fa-download:before{content:"\f019"}.fa-arrow-circle-o-down:before{content:"\f01a"}.fa-arrow-circle-o-up:before{content:"\f01b"}.fa-inbox:before{content:"\f01c"}.fa-play-circle-o:before{content:"\f01d"}.fa-rotate-right:before,.fa-repeat:before{content:"\f01e"}.fa-refresh:before{content:"\f021"}.fa-list-alt:before{content:"\f022"}.fa-lock:before{content:"\f023"}.fa-flag:before{content:"\f024"}.fa-headphones:before{content:"\f025"}.fa-volume-off:before{content:"\f026"}.fa-volume-down:before{content:"\f027"}.fa-volume-up:before{content:"\f028"}.fa-qrcode:before{content:"\f029"}.fa-barcode:before{content:"\f02a"}.fa-tag:before{content:"\f02b"}.fa-tags:before{content:"\f02c"}.fa-book:before{content:"\f02d"}.fa-bookmark:before{content:"\f02e"}.fa-print:before{content:"\f02f"}.fa-camera:before{content:"\f030"}.fa-font:before{content:"\f031"}.fa-bold:before{content:"\f032"}.fa-italic:before{content:"\f033"}.fa-text-height:before{content:"\f034"}.fa-text-width:before{content:"\f035"}.fa-align-left:before{content:"\f036"}.fa-align-center:before{content:"\f037"}.fa-align-right:before{content:"\f038"}.fa-align-justify:before{content:"\f039"}.fa-list:before{content:"\f03a"}.fa-dedent:before,.fa-outdent:before{content:"\f03b"}.fa-indent:before{content:"\f03c"}.fa-video-camera:before{content:"\f03d"}.fa-photo:before,.fa-image:before,.fa-picture-o:before{content:"\f03e"}.fa-pencil:before{content:"\f040"}.fa-map-marker:before{content:"\f041"}.fa-adjust:before{content:"\f042"}.fa-tint:before{content:"\f043"}.fa-edit:before,.fa-pencil-square-o:before{content:"\f044"}.fa-share-square-o:before{content:"\f045"}.fa-check-square-o:before{content:"\f046"}.fa-arrows:before{content:"\f047"}.fa-step-backward:before{content:"\f048"}.fa-fast-backward:before{content:"\f049"}.fa-backward:before{content:"\f04a"}.fa-play:before{content:"\f04b"}.fa-pause:before{content:"\f04c"}.fa-stop:before{content:"\f04d"}.fa-forward:before{content:"\f04e"}.fa-fast-forward:before{content:"\f050"}.fa-step-forward:before{content:"\f051"}.fa-eject:before{content:"\f052"}.fa-chevron-left:before{content:"\f053"}.fa-chevron-right:before{content:"\f054"}.fa-plus-circle:before{content:"\f055"}.fa-minus-circle:before{content:"\f056"}.fa-times-circle:before{content:"\f057"}.fa-check-circle:before{content:"\f058"}.fa-question-circle:before{content:"\f059"}.fa-info-circle:before{content:"\f05a"}.fa-crosshairs:before{content:"\f05b"}.fa-times-circle-o:before{content:"\f05c"}.fa-check-circle-o:before{content:"\f05d"}.fa-ban:before{content:"\f05e"}.fa-arrow-left:before{content:"\f060"}.fa-arrow-right:before{content:"\f061"}.fa-arrow-up:before{content:"\f062"}.fa-arrow-down:before{content:"\f063"}.fa-mail-forward:before,.fa-share:before{content:"\f064"}.fa-expand:before{content:"\f065"}.fa-compress:before{content:"\f066"}.fa-plus:before{content:"\f067"}.fa-minus:before{content:"\f068"}.fa-asterisk:before{content:"\f069"}.fa-exclamation-circle:before{content:"\f06a"}.fa-gift:before{content:"\f06b"}.fa-leaf:before{content:"\f06c"}.fa-fire:before{content:"\f06d"}.fa-eye:before{content:"\f06e"}.fa-eye-slash:before{content:"\f070"}.fa-warning:before,.fa-exclamation-triangle:before{content:"\f071"}.fa-plane:before{content:"\f072"}.fa-calendar:before{content:"\f073"}.fa-random:before{content:"\f074"}.fa-comment:before{content:"\f075"}.fa-magnet:before{content:"\f076"}.fa-chevron-up:before{content:"\f077"}.fa-chevron-down:before{content:"\f078"}.fa-retweet:before{content:"\f079"}.fa-shopping-cart:before{content:"\f07a"}.fa-folder:before{content:"\f07b"}.fa-folder-open:before{content:"\f07c"}.fa-arrows-v:before{content:"\f07d"}.fa-arrows-h:before{content:"\f07e"}.fa-bar-chart-o:before,.fa-bar-chart:before{content:"\f080"}.fa-twitter-square:before{content:"\f081"}.fa-facebook-square:before{content:"\f082"}.fa-camera-retro:before{content:"\f083"}.fa-key:before{content:"\f084"}.fa-gears:before,.fa-cogs:before{content:"\f085"}.fa-comments:before{content:"\f086"}.fa-thumbs-o-up:before{content:"\f087"}.fa-thumbs-o-down:before{content:"\f088"}.fa-star-half:before{content:"\f089"}.fa-heart-o:before{content:"\f08a"}.fa-sign-out:before{content:"\f08b"}.fa-linkedin-square:before{content:"\f08c"}.fa-thumb-tack:before{content:"\f08d"}.fa-external-link:before{content:"\f08e"}.fa-sign-in:before{content:"\f090"}.fa-trophy:before{content:"\f091"}.fa-github-square:before{content:"\f092"}.fa-upload:before{content:"\f093"}.fa-lemon-o:before{content:"\f094"}.fa-phone:before{content:"\f095"}.fa-square-o:before{content:"\f096"}.fa-bookmark-o:before{content:"\f097"}.fa-phone-square:before{content:"\f098"}.fa-twitter:before{content:"\f099"}.fa-facebook-f:before,.fa-facebook:before{content:"\f09a"}.fa-github:before{content:"\f09b"}.fa-unlock:before{content:"\f09c"}.fa-credit-card:before{content:"\f09d"}.fa-feed:before,.fa-rss:before{content:"\f09e"}.fa-hdd-o:before{content:"\f0a0"}.fa-bullhorn:before{content:"\f0a1"}.fa-bell:before{content:"\f0f3"}.fa-certificate:before{content:"\f0a3"}.fa-hand-o-right:before{content:"\f0a4"}.fa-hand-o-left:before{content:"\f0a5"}.fa-hand-o-up:before{content:"\f0a6"}.fa-hand-o-down:before{content:"\f0a7"}.fa-arrow-circle-left:before{content:"\f0a8"}.fa-arrow-circle-right:before{content:"\f0a9"}.fa-arrow-circle-up:before{content:"\f0aa"}.fa-arrow-circle-down:before{content:"\f0ab"}.fa-globe:before{content:"\f0ac"}.fa-wrench:before{content:"\f0ad"}.fa-tasks:before{content:"\f0ae"}.fa-filter:before{content:"\f0b0"}.fa-briefcase:before{content:"\f0b1"}.fa-arrows-alt:before{content:"\f0b2"}.fa-group:before,.fa-users:before{content:"\f0c0"}.fa-chain:before,.fa-link:before{content:"\f0c1"}.fa-cloud:before{content:"\f0c2"}.fa-flask:before{content:"\f0c3"}.fa-cut:before,.fa-scissors:before{content:"\f0c4"}.fa-copy:before,.fa-files-o:before{content:"\f0c5"}.fa-paperclip:before{content:"\f0c6"}.fa-save:before,.fa-floppy-o:before{content:"\f0c7"}.fa-square:before{content:"\f0c8"}.fa-navicon:before,.fa-reorder:before,.fa-bars:before{content:"\f0c9"}.fa-list-ul:before{content:"\f0ca"}.fa-list-ol:before{content:"\f0cb"}.fa-strikethrough:before{content:"\f0cc"}.fa-underline:before{content:"\f0cd"}.fa-table:before{content:"\f0ce"}.fa-magic:before{content:"\f0d0"}.fa-truck:before{content:"\f0d1"}.fa-pinterest:before{content:"\f0d2"}.fa-pinterest-square:before{content:"\f0d3"}.fa-google-plus-square:before{content:"\f0d4"}.fa-google-plus:before{content:"\f0d5"}.fa-money:before{content:"\f0d6"}.fa-caret-down:before{content:"\f0d7"}.fa-caret-up:before{content:"\f0d8"}.fa-caret-left:before{content:"\f0d9"}.fa-caret-right:before{content:"\f0da"}.fa-columns:before{content:"\f0db"}.fa-unsorted:before,.fa-sort:before{content:"\f0dc"}.fa-sort-down:before,.fa-sort-desc:before{content:"\f0dd"}.fa-sort-up:before,.fa-sort-asc:before{content:"\f0de"}.fa-envelope:before{content:"\f0e0"}.fa-linkedin:before{content:"\f0e1"}.fa-rotate-left:before,.fa-undo:before{content:"\f0e2"}.fa-legal:before,.fa-gavel:before{content:"\f0e3"}.fa-dashboard:before,.fa-tachometer:before{content:"\f0e4"}.fa-comment-o:before{content:"\f0e5"}.fa-comments-o:before{content:"\f0e6"}.fa-flash:before,.fa-bolt:before{content:"\f0e7"}.fa-sitemap:before{content:"\f0e8"}.fa-umbrella:before{content:"\f0e9"}.fa-paste:before,.fa-clipboard:before{content:"\f0ea"}.fa-lightbulb-o:before{content:"\f0eb"}.fa-exchange:before{content:"\f0ec"}.fa-cloud-download:before{content:"\f0ed"}.fa-cloud-upload:before{content:"\f0ee"}.fa-user-md:before{content:"\f0f0"}.fa-stethoscope:before{content:"\f0f1"}.fa-suitcase:before{content:"\f0f2"}.fa-bell-o:before{content:"\f0a2"}.fa-coffee:before{content:"\f0f4"}.fa-cutlery:before{content:"\f0f5"}.fa-file-text-o:before{content:"\f0f6"}.fa-building-o:before{content:"\f0f7"}.fa-hospital-o:before{content:"\f0f8"}.fa-ambulance:before{content:"\f0f9"}.fa-medkit:before{content:"\f0fa"}.fa-fighter-jet:before{content:"\f0fb"}.fa-beer:before{content:"\f0fc"}.fa-h-square:before{content:"\f0fd"}.fa-plus-square:before{content:"\f0fe"}.fa-angle-double-left:before{content:"\f100"}.fa-angle-double-right:before{content:"\f101"}.fa-angle-double-up:before{content:"\f102"}.fa-angle-double-down:before{content:"\f103"}.fa-angle-left:before{content:"\f104"}.fa-angle-right:before{content:"\f105"}.fa-angle-up:before{content:"\f106"}.fa-angle-down:before{content:"\f107"}.fa-desktop:before{content:"\f108"}.fa-laptop:before{content:"\f109"}.fa-tablet:before{content:"\f10a"}.fa-mobile-phone:before,.fa-mobile:before{content:"\f10b"}.fa-circle-o:before{content:"\f10c"}.fa-quote-left:before{content:"\f10d"}.fa-quote-right:before{content:"\f10e"}.fa-spinner:before{content:"\f110"}.fa-circle:before{content:"\f111"}.fa-mail-reply:before,.fa-reply:before{content:"\f112"}.fa-github-alt:before{content:"\f113"}.fa-folder-o:before{content:"\f114"}.fa-folder-open-o:before{content:"\f115"}.fa-smile-o:before{content:"\f118"}.fa-frown-o:before{content:"\f119"}.fa-meh-o:before{content:"\f11a"}.fa-gamepad:before{content:"\f11b"}.fa-keyboard-o:before{content:"\f11c"}.fa-flag-o:before{content:"\f11d"}.fa-flag-checkered:before{content:"\f11e"}.fa-terminal:before{content:"\f120"}.fa-code:before{content:"\f121"}.fa-mail-reply-all:before,.fa-reply-all:before{content:"\f122"}.fa-star-half-empty:before,.fa-star-half-full:before,.fa-star-half-o:before{content:"\f123"}.fa-location-arrow:before{content:"\f124"}.fa-crop:before{content:"\f125"}.fa-code-fork:before{content:"\f126"}.fa-unlink:before,.fa-chain-broken:before{content:"\f127"}.fa-question:before{content:"\f128"}.fa-info:before{content:"\f129"}.fa-exclamation:before{content:"\f12a"}.fa-superscript:before{content:"\f12b"}.fa-subscript:before{content:"\f12c"}.fa-eraser:before{content:"\f12d"}.fa-puzzle-piece:before{content:"\f12e"}.fa-microphone:before{content:"\f130"}.fa-microphone-slash:before{content:"\f131"}.fa-shield:before{content:"\f132"}.fa-calendar-o:before{content:"\f133"}.fa-fire-extinguisher:before{content:"\f134"}.fa-rocket:before{content:"\f135"}.fa-maxcdn:before{content:"\f136"}.fa-chevron-circle-left:before{content:"\f137"}.fa-chevron-circle-right:before{content:"\f138"}.fa-chevron-circle-up:before{content:"\f139"}.fa-chevron-circle-down:before{content:"\f13a"}.fa-html5:before{content:"\f13b"}.fa-css3:before{content:"\f13c"}.fa-anchor:before{content:"\f13d"}.fa-unlock-alt:before{content:"\f13e"}.fa-bullseye:before{content:"\f140"}.fa-ellipsis-h:before{content:"\f141"}.fa-ellipsis-v:before{content:"\f142"}.fa-rss-square:before{content:"\f143"}.fa-play-circle:before{content:"\f144"}.fa-ticket:before{content:"\f145"}.fa-minus-square:before{content:"\f146"}.fa-minus-square-o:before{content:"\f147"}.fa-level-up:before{content:"\f148"}.fa-level-down:before{content:"\f149"}.fa-check-square:before{content:"\f14a"}.fa-pencil-square:before{content:"\f14b"}.fa-external-link-square:before{content:"\f14c"}.fa-share-square:before{content:"\f14d"}.fa-compass:before{content:"\f14e"}.fa-toggle-down:before,.fa-caret-square-o-down:before{content:"\f150"}.fa-toggle-up:before,.fa-caret-square-o-up:before{content:"\f151"}.fa-toggle-right:before,.fa-caret-square-o-right:before{content:"\f152"}.fa-euro:before,.fa-eur:before{content:"\f153"}.fa-gbp:before{content:"\f154"}.fa-dollar:before,.fa-usd:before{content:"\f155"}.fa-rupee:before,.fa-inr:before{content:"\f156"}.fa-cny:before,.fa-rmb:before,.fa-yen:before,.fa-jpy:before{content:"\f157"}.fa-ruble:before,.fa-rouble:before,.fa-rub:before{content:"\f158"}.fa-won:before,.fa-krw:before{content:"\f159"}.fa-bitcoin:before,.fa-btc:before{content:"\f15a"}.fa-file:before{content:"\f15b"}.fa-file-text:before{content:"\f15c"}.fa-sort-alpha-asc:before{content:"\f15d"}.fa-sort-alpha-desc:before{content:"\f15e"}.fa-sort-amount-asc:before{content:"\f160"}.fa-sort-amount-desc:before{content:"\f161"}.fa-sort-numeric-asc:before{content:"\f162"}.fa-sort-numeric-desc:before{content:"\f163"}.fa-thumbs-up:before{content:"\f164"}.fa-thumbs-down:before{content:"\f165"}.fa-youtube-square:before{content:"\f166"}.fa-youtube:before{content:"\f167"}.fa-xing:before{content:"\f168"}.fa-xing-square:before{content:"\f169"}.fa-youtube-play:before{content:"\f16a"}.fa-dropbox:before{content:"\f16b"}.fa-stack-overflow:before{content:"\f16c"}.fa-instagram:before{content:"\f16d"}.fa-flickr:before{content:"\f16e"}.fa-adn:before{content:"\f170"}.fa-bitbucket:before{content:"\f171"}.fa-bitbucket-square:before{content:"\f172"}.fa-tumblr:before{content:"\f173"}.fa-tumblr-square:before{content:"\f174"}.fa-long-arrow-down:before{content:"\f175"}.fa-long-arrow-up:before{content:"\f176"}.fa-long-arrow-left:before{content:"\f177"}.fa-long-arrow-right:before{content:"\f178"}.fa-apple:before{content:"\f179"}.fa-windows:before{content:"\f17a"}.fa-android:before{content:"\f17b"}.fa-linux:before{content:"\f17c"}.fa-dribbble:before{content:"\f17d"}.fa-skype:before{content:"\f17e"}.fa-foursquare:before{content:"\f180"}.fa-trello:before{content:"\f181"}.fa-female:before{content:"\f182"}.fa-male:before{content:"\f183"}.fa-gittip:before,.fa-gratipay:before{content:"\f184"}.fa-sun-o:before{content:"\f185"}.fa-moon-o:before{content:"\f186"}.fa-archive:before{content:"\f187"}.fa-bug:before{content:"\f188"}.fa-vk:before{content:"\f189"}.fa-weibo:before{content:"\f18a"}.fa-renren:before{content:"\f18b"}.fa-pagelines:before{content:"\f18c"}.fa-stack-exchange:before{content:"\f18d"}.fa-arrow-circle-o-right:before{content:"\f18e"}.fa-arrow-circle-o-left:before{content:"\f190"}.fa-toggle-left:before,.fa-caret-square-o-left:before{content:"\f191"}.fa-dot-circle-o:before{content:"\f192"}.fa-wheelchair:before{content:"\f193"}.fa-vimeo-square:before{content:"\f194"}.fa-turkish-lira:before,.fa-try:before{content:"\f195"}.fa-plus-square-o:before{content:"\f196"}.fa-space-shuttle:before{content:"\f197"}.fa-slack:before{content:"\f198"}.fa-envelope-square:before{content:"\f199"}.fa-wordpress:before{content:"\f19a"}.fa-openid:before{content:"\f19b"}.fa-institution:before,.fa-bank:before,.fa-university:before{content:"\f19c"}.fa-mortar-board:before,.fa-graduation-cap:before{content:"\f19d"}.fa-yahoo:before{content:"\f19e"}.fa-google:before{content:"\f1a0"}.fa-reddit:before{content:"\f1a1"}.fa-reddit-square:before{content:"\f1a2"}.fa-stumbleupon-circle:before{content:"\f1a3"}.fa-stumbleupon:before{content:"\f1a4"}.fa-delicious:before{content:"\f1a5"}.fa-digg:before{content:"\f1a6"}.fa-pied-piper-pp:before{content:"\f1a7"}.fa-pied-piper-alt:before{content:"\f1a8"}.fa-drupal:before{content:"\f1a9"}.fa-joomla:before{content:"\f1aa"}.fa-language:before{content:"\f1ab"}.fa-fax:before{content:"\f1ac"}.fa-building:before{content:"\f1ad"}.fa-child:before{content:"\f1ae"}.fa-paw:before{content:"\f1b0"}.fa-spoon:before{content:"\f1b1"}.fa-cube:before{content:"\f1b2"}.fa-cubes:before{content:"\f1b3"}.fa-behance:before{content:"\f1b4"}.fa-behance-square:before{content:"\f1b5"}.fa-steam:before{content:"\f1b6"}.fa-steam-square:before{content:"\f1b7"}.fa-recycle:before{content:"\f1b8"}.fa-automobile:before,.fa-car:before{content:"\f1b9"}.fa-cab:before,.fa-taxi:before{content:"\f1ba"}.fa-tree:before{content:"\f1bb"}.fa-spotify:before{content:"\f1bc"}.fa-deviantart:before{content:"\f1bd"}.fa-soundcloud:before{content:"\f1be"}.fa-database:before{content:"\f1c0"}.fa-file-pdf-o:before{content:"\f1c1"}.fa-file-word-o:before{content:"\f1c2"}.fa-file-excel-o:before{content:"\f1c3"}.fa-file-powerpoint-o:before{content:"\f1c4"}.fa-file-photo-o:before,.fa-file-picture-o:before,.fa-file-image-o:before{content:"\f1c5"}.fa-file-zip-o:before,.fa-file-archive-o:before{content:"\f1c6"}.fa-file-sound-o:before,.fa-file-audio-o:before{content:"\f1c7"}.fa-file-movie-o:before,.fa-file-video-o:before{content:"\f1c8"}.fa-file-code-o:before{content:"\f1c9"}.fa-vine:before{content:"\f1ca"}.fa-codepen:before{content:"\f1cb"}.fa-jsfiddle:before{content:"\f1cc"}.fa-life-bouy:before,.fa-life-buoy:before,.fa-life-saver:before,.fa-support:before,.fa-life-ring:before{content:"\f1cd"}.fa-circle-o-notch:before{content:"\f1ce"}.fa-ra:before,.fa-resistance:before,.fa-rebel:before{content:"\f1d0"}.fa-ge:before,.fa-empire:before{content:"\f1d1"}.fa-git-square:before{content:"\f1d2"}.fa-git:before{content:"\f1d3"}.fa-y-combinator-square:before,.fa-yc-square:before,.fa-hacker-news:before{content:"\f1d4"}.fa-tencent-weibo:before{content:"\f1d5"}.fa-qq:before{content:"\f1d6"}.fa-wechat:before,.fa-weixin:before{content:"\f1d7"}.fa-send:before,.fa-paper-plane:before{content:"\f1d8"}.fa-send-o:before,.fa-paper-plane-o:before{content:"\f1d9"}.fa-history:before{content:"\f1da"}.fa-circle-thin:before{content:"\f1db"}.fa-header:before{content:"\f1dc"}.fa-paragraph:before{content:"\f1dd"}.fa-sliders:before{content:"\f1de"}.fa-share-alt:before{content:"\f1e0"}.fa-share-alt-square:before{content:"\f1e1"}.fa-bomb:before{content:"\f1e2"}.fa-soccer-ball-o:before,.fa-futbol-o:before{content:"\f1e3"}.fa-tty:before{content:"\f1e4"}.fa-binoculars:before{content:"\f1e5"}.fa-plug:before{content:"\f1e6"}.fa-slideshare:before{content:"\f1e7"}.fa-twitch:before{content:"\f1e8"}.fa-yelp:before{content:"\f1e9"}.fa-newspaper-o:before{content:"\f1ea"}.fa-wifi:before{content:"\f1eb"}.fa-calculator:before{content:"\f1ec"}.fa-paypal:before{content:"\f1ed"}.fa-google-wallet:before{content:"\f1ee"}.fa-cc-visa:before{content:"\f1f0"}.fa-cc-mastercard:before{content:"\f1f1"}.fa-cc-discover:before{content:"\f1f2"}.fa-cc-amex:before{content:"\f1f3"}.fa-cc-paypal:before{content:"\f1f4"}.fa-cc-stripe:before{content:"\f1f5"}.fa-bell-slash:before{content:"\f1f6"}.fa-bell-slash-o:before{content:"\f1f7"}.fa-trash:before{content:"\f1f8"}.fa-copyright:before{content:"\f1f9"}.fa-at:before{content:"\f1fa"}.fa-eyedropper:before{content:"\f1fb"}.fa-paint-brush:before{content:"\f1fc"}.fa-birthday-cake:before{content:"\f1fd"}.fa-area-chart:before{content:"\f1fe"}.fa-pie-chart:before{content:"\f200"}.fa-line-chart:before{content:"\f201"}.fa-lastfm:before{content:"\f202"}.fa-lastfm-square:before{content:"\f203"}.fa-toggle-off:before{content:"\f204"}.fa-toggle-on:before{content:"\f205"}.fa-bicycle:before{content:"\f206"}.fa-bus:before{content:"\f207"}.fa-ioxhost:before{content:"\f208"}.fa-angellist:before{content:"\f209"}.fa-cc:before{content:"\f20a"}.fa-shekel:before,.fa-sheqel:before,.fa-ils:before{content:"\f20b"}.fa-meanpath:before{content:"\f20c"}.fa-buysellads:before{content:"\f20d"}.fa-connectdevelop:before{content:"\f20e"}.fa-dashcube:before{content:"\f210"}.fa-forumbee:before{content:"\f211"}.fa-leanpub:before{content:"\f212"}.fa-sellsy:before{content:"\f213"}.fa-shirtsinbulk:before{content:"\f214"}.fa-simplybuilt:before{content:"\f215"}.fa-skyatlas:before{content:"\f216"}.fa-cart-plus:before{content:"\f217"}.fa-cart-arrow-down:before{content:"\f218"}.fa-diamond:before{content:"\f219"}.fa-ship:before{content:"\f21a"}.fa-user-secret:before{content:"\f21b"}.fa-motorcycle:before{content:"\f21c"}.fa-street-view:before{content:"\f21d"}.fa-heartbeat:before{content:"\f21e"}.fa-venus:before{content:"\f221"}.fa-mars:before{content:"\f222"}.fa-mercury:before{content:"\f223"}.fa-intersex:before,.fa-transgender:before{content:"\f224"}.fa-transgender-alt:before{content:"\f225"}.fa-venus-double:before{content:"\f226"}.fa-mars-double:before{content:"\f227"}.fa-venus-mars:before{content:"\f228"}.fa-mars-stroke:before{content:"\f229"}.fa-mars-stroke-v:before{content:"\f22a"}.fa-mars-stroke-h:before{content:"\f22b"}.fa-neuter:before{content:"\f22c"}.fa-genderless:before{content:"\f22d"}.fa-facebook-official:before{content:"\f230"}.fa-pinterest-p:before{content:"\f231"}.fa-whatsapp:before{content:"\f232"}.fa-server:before{content:"\f233"}.fa-user-plus:before{content:"\f234"}.fa-user-times:before{content:"\f235"}.fa-hotel:before,.fa-bed:before{content:"\f236"}.fa-viacoin:before{content:"\f237"}.fa-train:before{content:"\f238"}.fa-subway:before{content:"\f239"}.fa-medium:before{content:"\f23a"}.fa-yc:before,.fa-y-combinator:before{content:"\f23b"}.fa-optin-monster:before{content:"\f23c"}.fa-opencart:before{content:"\f23d"}.fa-expeditedssl:before{content:"\f23e"}.fa-battery-4:before,.fa-battery:before,.fa-battery-full:before{content:"\f240"}.fa-battery-3:before,.fa-battery-three-quarters:before{content:"\f241"}.fa-battery-2:before,.fa-battery-half:before{content:"\f242"}.fa-battery-1:before,.fa-battery-quarter:before{content:"\f243"}.fa-battery-0:before,.fa-battery-empty:before{content:"\f244"}.fa-mouse-pointer:before{content:"\f245"}.fa-i-cursor:before{content:"\f246"}.fa-object-group:before{content:"\f247"}.fa-object-ungroup:before{content:"\f248"}.fa-sticky-note:before{content:"\f249"}.fa-sticky-note-o:before{content:"\f24a"}.fa-cc-jcb:before{content:"\f24b"}.fa-cc-diners-club:before{content:"\f24c"}.fa-clone:before{content:"\f24d"}.fa-balance-scale:before{content:"\f24e"}.fa-hourglass-o:before{content:"\f250"}.fa-hourglass-1:before,.fa-hourglass-start:before{content:"\f251"}.fa-hourglass-2:before,.fa-hourglass-half:before{content:"\f252"}.fa-hourglass-3:before,.fa-hourglass-end:before{content:"\f253"}.fa-hourglass:before{content:"\f254"}.fa-hand-grab-o:before,.fa-hand-rock-o:before{content:"\f255"}.fa-hand-stop-o:before,.fa-hand-paper-o:before{content:"\f256"}.fa-hand-scissors-o:before{content:"\f257"}.fa-hand-lizard-o:before{content:"\f258"}.fa-hand-spock-o:before{content:"\f259"}.fa-hand-pointer-o:before{content:"\f25a"}.fa-hand-peace-o:before{content:"\f25b"}.fa-trademark:before{content:"\f25c"}.fa-registered:before{content:"\f25d"}.fa-creative-commons:before{content:"\f25e"}.fa-gg:before{content:"\f260"}.fa-gg-circle:before{content:"\f261"}.fa-tripadvisor:before{content:"\f262"}.fa-odnoklassniki:before{content:"\f263"}.fa-odnoklassniki-square:before{content:"\f264"}.fa-get-pocket:before{content:"\f265"}.fa-wikipedia-w:before{content:"\f266"}.fa-safari:before{content:"\f267"}.fa-chrome:before{content:"\f268"}.fa-firefox:before{content:"\f269"}.fa-opera:before{content:"\f26a"}.fa-internet-explorer:before{content:"\f26b"}.fa-tv:before,.fa-television:before{content:"\f26c"}.fa-contao:before{content:"\f26d"}.fa-500px:before{content:"\f26e"}.fa-amazon:before{content:"\f270"}.fa-calendar-plus-o:before{content:"\f271"}.fa-calendar-minus-o:before{content:"\f272"}.fa-calendar-times-o:before{content:"\f273"}.fa-calendar-check-o:before{content:"\f274"}.fa-industry:before{content:"\f275"}.fa-map-pin:before{content:"\f276"}.fa-map-signs:before{content:"\f277"}.fa-map-o:before{content:"\f278"}.fa-map:before{content:"\f279"}.fa-commenting:before{content:"\f27a"}.fa-commenting-o:before{content:"\f27b"}.fa-houzz:before{content:"\f27c"}.fa-vimeo:before{content:"\f27d"}.fa-black-tie:before{content:"\f27e"}.fa-fonticons:before{content:"\f280"}.fa-reddit-alien:before{content:"\f281"}.fa-edge:before{content:"\f282"}.fa-credit-card-alt:before{content:"\f283"}.fa-codiepie:before{content:"\f284"}.fa-modx:before{content:"\f285"}.fa-fort-awesome:before{content:"\f286"}.fa-usb:before{content:"\f287"}.fa-product-hunt:before{content:"\f288"}.fa-mixcloud:before{content:"\f289"}.fa-scribd:before{content:"\f28a"}.fa-pause-circle:before{content:"\f28b"}.fa-pause-circle-o:before{content:"\f28c"}.fa-stop-circle:before{content:"\f28d"}.fa-stop-circle-o:before{content:"\f28e"}.fa-shopping-bag:before{content:"\f290"}.fa-shopping-basket:before{content:"\f291"}.fa-hashtag:before{content:"\f292"}.fa-bluetooth:before{content:"\f293"}.fa-bluetooth-b:before{content:"\f294"}.fa-percent:before{content:"\f295"}.fa-gitlab:before{content:"\f296"}.fa-wpbeginner:before{content:"\f297"}.fa-wpforms:before{content:"\f298"}.fa-envira:before{content:"\f299"}.fa-universal-access:before{content:"\f29a"}.fa-wheelchair-alt:before{content:"\f29b"}.fa-question-circle-o:before{content:"\f29c"}.fa-blind:before{content:"\f29d"}.fa-audio-description:before{content:"\f29e"}.fa-volume-control-phone:before{content:"\f2a0"}.fa-braille:before{content:"\f2a1"}.fa-assistive-listening-systems:before{content:"\f2a2"}.fa-asl-interpreting:before,.fa-american-sign-language-interpreting:before{content:"\f2a3"}.fa-deafness:before,.fa-hard-of-hearing:before,.fa-deaf:before{content:"\f2a4"}.fa-glide:before{content:"\f2a5"}.fa-glide-g:before{content:"\f2a6"}.fa-signing:before,.fa-sign-language:before{content:"\f2a7"}.fa-low-vision:before{content:"\f2a8"}.fa-viadeo:before{content:"\f2a9"}.fa-viadeo-square:before{content:"\f2aa"}.fa-snapchat:before{content:"\f2ab"}.fa-snapchat-ghost:before{content:"\f2ac"}.fa-snapchat-square:before{content:"\f2ad"}.fa-pied-piper:before{content:"\f2ae"}.fa-first-order:before{content:"\f2b0"}.fa-yoast:before{content:"\f2b1"}.fa-themeisle:before{content:"\f2b2"}.fa-google-plus-circle:before,.fa-google-plus-official:before{content:"\f2b3"}.fa-fa:before,.fa-font-awesome:before{content:"\f2b4"}.fa-handshake-o:before{content:"\f2b5"}.fa-envelope-open:before{content:"\f2b6"}.fa-envelope-open-o:before{content:"\f2b7"}.fa-linode:before{content:"\f2b8"}.fa-address-book:before{content:"\f2b9"}.fa-address-book-o:before{content:"\f2ba"}.fa-vcard:before,.fa-address-card:before{content:"\f2bb"}.fa-vcard-o:before,.fa-address-card-o:before{content:"\f2bc"}.fa-user-circle:before{content:"\f2bd"}.fa-user-circle-o:before{content:"\f2be"}.fa-user-o:before{content:"\f2c0"}.fa-id-badge:before{content:"\f2c1"}.fa-drivers-license:before,.fa-id-card:before{content:"\f2c2"}.fa-drivers-license-o:before,.fa-id-card-o:before{content:"\f2c3"}.fa-quora:before{content:"\f2c4"}.fa-free-code-camp:before{content:"\f2c5"}.fa-telegram:before{content:"\f2c6"}.fa-thermometer-4:before,.fa-thermometer:before,.fa-thermometer-full:before{content:"\f2c7"}.fa-thermometer-3:before,.fa-thermometer-three-quarters:before{content:"\f2c8"}.fa-thermometer-2:before,.fa-thermometer-half:before{content:"\f2c9"}.fa-thermometer-1:before,.fa-thermometer-quarter:before{content:"\f2ca"}.fa-thermometer-0:before,.fa-thermometer-empty:before{content:"\f2cb"}.fa-shower:before{content:"\f2cc"}.fa-bathtub:before,.fa-s15:before,.fa-bath:before{content:"\f2cd"}.fa-podcast:before{content:"\f2ce"}.fa-window-maximize:before{content:"\f2d0"}.fa-window-minimize:before{content:"\f2d1"}.fa-window-restore:before{content:"\f2d2"}.fa-times-rectangle:before,.fa-window-close:before{content:"\f2d3"}.fa-times-rectangle-o:before,.fa-window-close-o:before{content:"\f2d4"}.fa-bandcamp:before{content:"\f2d5"}.fa-grav:before{content:"\f2d6"}.fa-etsy:before{content:"\f2d7"}.fa-imdb:before{content:"\f2d8"}.fa-ravelry:before{content:"\f2d9"}.fa-eercast:before{content:"\f2da"}.fa-microchip:before{content:"\f2db"}.fa-snowflake-o:before{content:"\f2dc"}.fa-superpowers:before{content:"\f2dd"}.fa-wpexplorer:before{content:"\f2de"}.fa-meetup:before{content:"\f2e0"}.sr-only{position:absolute;width:1px;height:1px;padding:0;margin:-1px;overflow:hidden;clip:rect(0, 0, 0, 0);border:0}.sr-only-focusable:active,.sr-only-focusable:focus{position:static;width:auto;height:auto;margin:0;overflow:visible;clip:auto} diff --git a/FontAwesome/fonts/FontAwesome.ttf b/FontAwesome/fonts/FontAwesome.ttf new file mode 100644 index 00000000..35acda2f Binary files /dev/null and b/FontAwesome/fonts/FontAwesome.ttf differ diff --git a/FontAwesome/fonts/fontawesome-webfont.eot b/FontAwesome/fonts/fontawesome-webfont.eot new file mode 100644 index 00000000..e9f60ca9 Binary files /dev/null and b/FontAwesome/fonts/fontawesome-webfont.eot differ diff --git a/FontAwesome/fonts/fontawesome-webfont.svg b/FontAwesome/fonts/fontawesome-webfont.svg new file mode 100644 index 00000000..855c845e --- /dev/null +++ b/FontAwesome/fonts/fontawesome-webfont.svg @@ -0,0 +1,2671 @@ + + + + +Created by FontForge 20120731 at Mon Oct 24 17:37:40 2016 + By ,,, +Copyright Dave Gandy 2016. All rights reserved. + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/FontAwesome/fonts/fontawesome-webfont.ttf b/FontAwesome/fonts/fontawesome-webfont.ttf new file mode 100644 index 00000000..35acda2f Binary files /dev/null and b/FontAwesome/fonts/fontawesome-webfont.ttf differ diff --git a/FontAwesome/fonts/fontawesome-webfont.woff b/FontAwesome/fonts/fontawesome-webfont.woff new file mode 100644 index 00000000..400014a4 Binary files /dev/null and b/FontAwesome/fonts/fontawesome-webfont.woff differ diff --git a/FontAwesome/fonts/fontawesome-webfont.woff2 b/FontAwesome/fonts/fontawesome-webfont.woff2 new file mode 100644 index 00000000..4d13fc60 Binary files /dev/null and b/FontAwesome/fonts/fontawesome-webfont.woff2 differ diff --git a/api-redirect.html b/api-redirect.html new file mode 100644 index 00000000..8485d602 --- /dev/null +++ b/api-redirect.html @@ -0,0 +1,230 @@ + + + + + + Rust API Reference (rustdoc) - JellyTau Documentation + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+ + + + + + + + + + + + + + + + + + + +
+ +
+ + + + + + + + +
+
+

Rust API Reference

+

The full backend API reference is generated by cargo doc (rustdoc).

+

👉 Open the Rust API Reference

+ +
+ + +
+
+ + + +
+ + + + + + + + + + + + + + + + + + +
+ + diff --git a/api/.lock b/api/.lock new file mode 100644 index 00000000..e69de29b diff --git a/api/crates.js b/api/crates.js new file mode 100644 index 00000000..4ddcb1f9 --- /dev/null +++ b/api/crates.js @@ -0,0 +1,2 @@ +window.ALL_CRATES = ["jellytau","jellytau_lib"]; +//{"start":21,"fragment_lengths":[10,15]} \ No newline at end of file diff --git a/api/help.html b/api/help.html new file mode 100644 index 00000000..78604a32 --- /dev/null +++ b/api/help.html @@ -0,0 +1 @@ +HelpSkip to main content

All

Rustdoc help

Back
\ No newline at end of file diff --git a/api/index.html b/api/index.html new file mode 100644 index 00000000..0aeca803 --- /dev/null +++ b/api/index.html @@ -0,0 +1 @@ + diff --git a/api/jellytau/all.html b/api/jellytau/all.html new file mode 100644 index 00000000..3c238fd9 --- /dev/null +++ b/api/jellytau/all.html @@ -0,0 +1 @@ +List of all items in this crateSkip to main content

All

List of all items

Functions

\ No newline at end of file diff --git a/api/jellytau/fn.main.html b/api/jellytau/fn.main.html new file mode 100644 index 00000000..1ec1cf3f --- /dev/null +++ b/api/jellytau/fn.main.html @@ -0,0 +1 @@ +main in jellytau - RustSkip to main content

main

Function main 

Source
pub(crate) fn main()
\ No newline at end of file diff --git a/api/jellytau/index.html b/api/jellytau/index.html new file mode 100644 index 00000000..3a3f8782 --- /dev/null +++ b/api/jellytau/index.html @@ -0,0 +1 @@ +jellytau - RustSkip to main content

Crate jellytau

Crate jellytau 

Source

Functions§

main 🔒
\ No newline at end of file diff --git a/api/jellytau/sidebar-items.js b/api/jellytau/sidebar-items.js new file mode 100644 index 00000000..9b556a05 --- /dev/null +++ b/api/jellytau/sidebar-items.js @@ -0,0 +1 @@ +window.SIDEBAR_ITEMS = {"fn":["main"]}; \ No newline at end of file diff --git a/api/jellytau_lib/all.html b/api/jellytau_lib/all.html new file mode 100644 index 00000000..76fa28d0 --- /dev/null +++ b/api/jellytau_lib/all.html @@ -0,0 +1 @@ +List of all items in this crateSkip to main content

All

List of all items

Structs

Enums

Traits

Functions

Type Aliases

Statics

Constants

\ No newline at end of file diff --git a/api/jellytau_lib/auth/index.html b/api/jellytau_lib/auth/index.html new file mode 100644 index 00000000..64285e45 --- /dev/null +++ b/api/jellytau_lib/auth/index.html @@ -0,0 +1 @@ +jellytau_lib::auth - RustSkip to main content

Module auth

Module auth 

Source

Re-exports§

pub use session_verifier::SessionVerifier;

Modules§

session_verifier

Structs§

AuthManager
Authentication manager
AuthResult
Authentication result
AuthenticateByNameResponse 🔒
JellyfinUser 🔒
PublicSystemInfo 🔒
ServerInfo
Server information returned from Jellyfin
Session
Active session for restoration
User
User information
\ No newline at end of file diff --git a/api/jellytau_lib/auth/session_verifier/constant.VERIFICATION_INTERVAL_MS.html b/api/jellytau_lib/auth/session_verifier/constant.VERIFICATION_INTERVAL_MS.html new file mode 100644 index 00000000..6fa701f1 --- /dev/null +++ b/api/jellytau_lib/auth/session_verifier/constant.VERIFICATION_INTERVAL_MS.html @@ -0,0 +1 @@ +VERIFICATION_INTERVAL_MS in jellytau_lib::auth::session_verifier - RustSkip to main content

VERIFICATION_INTERVAL_MS

Constant VERIFICATION_INTERVAL_MS 

Source
const VERIFICATION_INTERVAL_MS: u64 = 300000;
\ No newline at end of file diff --git a/api/jellytau_lib/auth/session_verifier/enum.SessionVerificationEvent.html b/api/jellytau_lib/auth/session_verifier/enum.SessionVerificationEvent.html new file mode 100644 index 00000000..f26297cc --- /dev/null +++ b/api/jellytau_lib/auth/session_verifier/enum.SessionVerificationEvent.html @@ -0,0 +1,44 @@ +SessionVerificationEvent in jellytau_lib::auth::session_verifier - RustSkip to main content

SessionVerificationEvent

Enum SessionVerificationEvent 

Source
pub enum SessionVerificationEvent {
+    Verified {
+        user: User,
+    },
+    NeedsReauth {
+        reason: String,
+    },
+    NetworkError {
+        message: String,
+    },
+}
Expand description

Session verification result event emitted to frontend

+

Variants§

§

Verified

Fields

§user: User
§

NeedsReauth

Fields

§reason: String
§

NetworkError

Fields

§message: String

Trait Implementations§

Source§

impl Clone for SessionVerificationEvent

Source§

fn clone(&self) -> SessionVerificationEvent

Returns a duplicate of the value. Read more
1.0.0 (const: unstable) · Source§

fn clone_from(&mut self, source: &Self)

Performs copy-assignment from source. Read more
Source§

impl Debug for SessionVerificationEvent

Source§

fn fmt(&self, f: &mut Formatter<'_>) -> Result

Formats the value using the given formatter. Read more
Source§

impl Serialize for SessionVerificationEvent

Source§

fn serialize<__S>(&self, __serializer: __S) -> Result<__S::Ok, __S::Error>
where + __S: Serializer,

Serialize this value into the given Serde serializer. Read more

Auto Trait Implementations§

Blanket Implementations§

Source§

impl<T> Any for T
where + T: 'static + ?Sized,

Source§

fn type_id(&self) -> TypeId

Gets the TypeId of self. Read more
Source§

impl<T> Borrow<T> for T
where + T: ?Sized,

Source§

fn borrow(&self) -> &T

Immutably borrows from an owned value. Read more
Source§

impl<T> BorrowMut<T> for T
where + T: ?Sized,

Source§

fn borrow_mut(&mut self) -> &mut T

Mutably borrows from an owned value. Read more
Source§

impl<T> CloneToUninit for T
where + T: Clone,

Source§

unsafe fn clone_to_uninit(&self, dest: *mut u8)

🔬This is a nightly-only experimental API. (clone_to_uninit)
Performs copy-assignment from self to dest. Read more
Source§

impl<T> From<T> for T

Source§

fn from(t: T) -> T

Returns the argument unchanged.

+
§

impl<T> Instrument for T

§

fn instrument(self, span: Span) -> Instrumented<Self>

Instruments this type with the provided [Span], returning an +Instrumented wrapper. Read more
§

fn in_current_span(self) -> Instrumented<Self>

Instruments this type with the current Span, returning an +Instrumented wrapper. Read more
Source§

impl<T, U> Into<U> for T
where + U: From<T>,

Source§

fn into(self) -> U

Calls U::from(self).

+

That is, this conversion is whatever the implementation of +From<T> for U chooses to do.

+
§

impl<T> IpcResponse for T
where + T: Serialize,

§

fn body(self) -> Result<InvokeResponseBody, Error>

Resolve the IPC response body.
§

impl<T> PolicyExt for T
where + T: ?Sized,

§

fn and<P, B, E>(self, other: P) -> And<T, P>
where + T: Policy<B, E>, + P: Policy<B, E>,

Create a new Policy that returns [Action::Follow] only if self and other return +Action::Follow. Read more
§

fn or<P, B, E>(self, other: P) -> Or<T, P>
where + T: Policy<B, E>, + P: Policy<B, E>,

Create a new Policy that returns [Action::Follow] if either self or other returns +Action::Follow. Read more
Source§

impl<T> Same for T

Source§

type Output = T

Should always be Self
Source§

impl<T> Serialize for T
where + T: Serialize + ?Sized,

Source§

fn erased_serialize(&self, serializer: &mut dyn Serializer) -> Result<(), Error>

Source§

fn do_erased_serialize( + &self, + serializer: &mut dyn Serializer, +) -> Result<(), ErrorImpl>

Source§

impl<T> ToOwned for T
where + T: Clone,

Source§

type Owned = T

The resulting type after obtaining ownership.
Source§

fn to_owned(&self) -> T

Creates owned data from borrowed data, usually by cloning. Read more
Source§

fn clone_into(&self, target: &mut T)

Uses borrowed data to replace owned data, usually by cloning. Read more
Source§

impl<T, U> TryFrom<U> for T
where + U: Into<T>,

Source§

type Error = Infallible

The type returned in the event of a conversion error.
Source§

fn try_from(value: U) -> Result<T, <T as TryFrom<U>>::Error>

Performs the conversion.
Source§

impl<T, U> TryInto<U> for T
where + U: TryFrom<T>,

Source§

type Error = <U as TryFrom<T>>::Error

The type returned in the event of a conversion error.
Source§

fn try_into(self) -> Result<U, <U as TryFrom<T>>::Error>

Performs the conversion.
§

impl<V, T> VZip<V> for T
where + V: MultiLane<T>,

§

fn vzip(self) -> V

§

impl<T> WithSubscriber for T

§

fn with_subscriber<S>(self, subscriber: S) -> WithDispatch<Self>
where + S: Into<Dispatch>,

Attaches the provided Subscriber to this type, returning a +[WithDispatch] wrapper. Read more
§

fn with_current_subscriber(self) -> WithDispatch<Self>

Attaches the current default Subscriber to this type, returning a +[WithDispatch] wrapper. Read more
§

impl<T> UserEvent for T
where + T: Debug + Clone + Send + 'static,

\ No newline at end of file diff --git a/api/jellytau_lib/auth/session_verifier/index.html b/api/jellytau_lib/auth/session_verifier/index.html new file mode 100644 index 00000000..01d99a6b --- /dev/null +++ b/api/jellytau_lib/auth/session_verifier/index.html @@ -0,0 +1 @@ +jellytau_lib::auth::session_verifier - RustSkip to main content

Module session_verifier

Module session_verifier 

Source

Structs§

SessionVerifier
Background session verifier

Enums§

SessionVerificationEvent
Session verification result event emitted to frontend

Constants§

VERIFICATION_INTERVAL_MS 🔒
\ No newline at end of file diff --git a/api/jellytau_lib/auth/session_verifier/sidebar-items.js b/api/jellytau_lib/auth/session_verifier/sidebar-items.js new file mode 100644 index 00000000..7f4b4c86 --- /dev/null +++ b/api/jellytau_lib/auth/session_verifier/sidebar-items.js @@ -0,0 +1 @@ +window.SIDEBAR_ITEMS = {"constant":["VERIFICATION_INTERVAL_MS"],"enum":["SessionVerificationEvent"],"struct":["SessionVerifier"]}; \ No newline at end of file diff --git a/api/jellytau_lib/auth/session_verifier/struct.SessionVerifier.html b/api/jellytau_lib/auth/session_verifier/struct.SessionVerifier.html new file mode 100644 index 00000000..b6e13435 --- /dev/null +++ b/api/jellytau_lib/auth/session_verifier/struct.SessionVerifier.html @@ -0,0 +1,34 @@ +SessionVerifier in jellytau_lib::auth::session_verifier - RustSkip to main content

SessionVerifier

Struct SessionVerifier 

Source
pub struct SessionVerifier {
+    auth_manager: Arc<AuthManager>,
+    is_running: Arc<AtomicBool>,
+    device_id: String,
+    app_handle: Option<AppHandle>,
+}
Expand description

Background session verifier

+

Fields§

§auth_manager: Arc<AuthManager>§is_running: Arc<AtomicBool>§device_id: String§app_handle: Option<AppHandle>

Implementations§

Source§

impl SessionVerifier

Source

pub fn new(auth_manager: Arc<AuthManager>, device_id: String) -> Self

Create a new session verifier

+
Source

pub fn set_app_handle(&mut self, app_handle: AppHandle)

Set the Tauri app handle for event emission

+
Source

pub async fn start(&self)

Start periodic session verification

+
Source

pub fn stop(&self)

Stop periodic verification

+

Auto Trait Implementations§

Blanket Implementations§

Source§

impl<T> Any for T
where + T: 'static + ?Sized,

Source§

fn type_id(&self) -> TypeId

Gets the TypeId of self. Read more
Source§

impl<T> Borrow<T> for T
where + T: ?Sized,

Source§

fn borrow(&self) -> &T

Immutably borrows from an owned value. Read more
Source§

impl<T> BorrowMut<T> for T
where + T: ?Sized,

Source§

fn borrow_mut(&mut self) -> &mut T

Mutably borrows from an owned value. Read more
Source§

impl<T> From<T> for T

Source§

fn from(t: T) -> T

Returns the argument unchanged.

+
§

impl<T> Instrument for T

§

fn instrument(self, span: Span) -> Instrumented<Self>

Instruments this type with the provided [Span], returning an +Instrumented wrapper. Read more
§

fn in_current_span(self) -> Instrumented<Self>

Instruments this type with the current Span, returning an +Instrumented wrapper. Read more
Source§

impl<T, U> Into<U> for T
where + U: From<T>,

Source§

fn into(self) -> U

Calls U::from(self).

+

That is, this conversion is whatever the implementation of +From<T> for U chooses to do.

+
§

impl<T> PolicyExt for T
where + T: ?Sized,

§

fn and<P, B, E>(self, other: P) -> And<T, P>
where + T: Policy<B, E>, + P: Policy<B, E>,

Create a new Policy that returns [Action::Follow] only if self and other return +Action::Follow. Read more
§

fn or<P, B, E>(self, other: P) -> Or<T, P>
where + T: Policy<B, E>, + P: Policy<B, E>,

Create a new Policy that returns [Action::Follow] if either self or other returns +Action::Follow. Read more
Source§

impl<T> Same for T

Source§

type Output = T

Should always be Self
Source§

impl<T, U> TryFrom<U> for T
where + U: Into<T>,

Source§

type Error = Infallible

The type returned in the event of a conversion error.
Source§

fn try_from(value: U) -> Result<T, <T as TryFrom<U>>::Error>

Performs the conversion.
Source§

impl<T, U> TryInto<U> for T
where + U: TryFrom<T>,

Source§

type Error = <U as TryFrom<T>>::Error

The type returned in the event of a conversion error.
Source§

fn try_into(self) -> Result<U, <U as TryFrom<T>>::Error>

Performs the conversion.
§

impl<V, T> VZip<V> for T
where + V: MultiLane<T>,

§

fn vzip(self) -> V

§

impl<T> WithSubscriber for T

§

fn with_subscriber<S>(self, subscriber: S) -> WithDispatch<Self>
where + S: Into<Dispatch>,

Attaches the provided Subscriber to this type, returning a +[WithDispatch] wrapper. Read more
§

fn with_current_subscriber(self) -> WithDispatch<Self>

Attaches the current default Subscriber to this type, returning a +[WithDispatch] wrapper. Read more
\ No newline at end of file diff --git a/api/jellytau_lib/auth/sidebar-items.js b/api/jellytau_lib/auth/sidebar-items.js new file mode 100644 index 00000000..1cc99094 --- /dev/null +++ b/api/jellytau_lib/auth/sidebar-items.js @@ -0,0 +1 @@ +window.SIDEBAR_ITEMS = {"mod":["session_verifier"],"struct":["AuthManager","AuthResult","AuthenticateByNameResponse","JellyfinUser","PublicSystemInfo","ServerInfo","Session","User"]}; \ No newline at end of file diff --git a/api/jellytau_lib/auth/struct.AuthManager.html b/api/jellytau_lib/auth/struct.AuthManager.html new file mode 100644 index 00000000..4faab0a0 --- /dev/null +++ b/api/jellytau_lib/auth/struct.AuthManager.html @@ -0,0 +1,68 @@ +AuthManager in jellytau_lib::auth - RustSkip to main content

AuthManager

Struct AuthManager 

Source
pub struct AuthManager {
+    http_client: Arc<HttpClient>,
+    current_session: Arc<RwLock<Option<Session>>>,
+    connectivity_monitor: Option<Arc<Mutex<ConnectivityMonitor>>>,
+}
Expand description

Authentication manager

+

Fields§

§http_client: Arc<HttpClient>§current_session: Arc<RwLock<Option<Session>>>§connectivity_monitor: Option<Arc<Mutex<ConnectivityMonitor>>>

Implementations§

Source§

impl AuthManager

Source

pub fn new(http_client: HttpClient) -> Self

Create a new auth manager

+
Source

pub fn set_connectivity_monitor( + &mut self, + monitor: Arc<Mutex<ConnectivityMonitor>>, +)

Set the connectivity monitor (for marking server reachability)

+
Source

pub fn normalize_url(url: &str) -> Result<String, String>

Normalize and validate server URL. +Enforces HTTPS — plain HTTP is rejected for security.

+
Source

pub fn normalize_username(username: &str) -> String

Normalize a username before it goes to the server.

+

Only surrounding whitespace is stripped — interior spaces are legal in +Jellyfin usernames. Without this, a trailing space from a soft keyboard’s +autocorrect makes the server report an unknown user, which surfaces as a +401 that looks exactly like a wrong password.

+

TRACES: UR-042 | DR-054

+
Source

pub async fn connect_to_server( + &self, + server_url: &str, +) -> Result<ServerInfo, String>

Connect to server and get server info

+
Source

pub async fn login( + &self, + server_url: &str, + username: &str, + password: &str, + device_id: &str, +) -> Result<AuthResult, String>

Authenticate by username and password

+
Source

pub async fn verify_session( + &self, + server_url: &str, + user_id: &str, + access_token: &str, + device_id: &str, +) -> Result<User, String>

Verify current session by fetching user info

+
Source

pub async fn logout( + &self, + server_url: &str, + access_token: &str, + device_id: &str, +) -> Result<(), String>

Logout (call Jellyfin logout endpoint)

+
Source

pub async fn get_session(&self) -> Option<Session>

Get current session

+
Source

pub async fn set_session(&self, session: Option<Session>)

Set current session

+

Auto Trait Implementations§

Blanket Implementations§

Source§

impl<T> Any for T
where + T: 'static + ?Sized,

Source§

fn type_id(&self) -> TypeId

Gets the TypeId of self. Read more
Source§

impl<T> Borrow<T> for T
where + T: ?Sized,

Source§

fn borrow(&self) -> &T

Immutably borrows from an owned value. Read more
Source§

impl<T> BorrowMut<T> for T
where + T: ?Sized,

Source§

fn borrow_mut(&mut self) -> &mut T

Mutably borrows from an owned value. Read more
Source§

impl<T> From<T> for T

Source§

fn from(t: T) -> T

Returns the argument unchanged.

+
§

impl<T> Instrument for T

§

fn instrument(self, span: Span) -> Instrumented<Self>

Instruments this type with the provided [Span], returning an +Instrumented wrapper. Read more
§

fn in_current_span(self) -> Instrumented<Self>

Instruments this type with the current Span, returning an +Instrumented wrapper. Read more
Source§

impl<T, U> Into<U> for T
where + U: From<T>,

Source§

fn into(self) -> U

Calls U::from(self).

+

That is, this conversion is whatever the implementation of +From<T> for U chooses to do.

+
§

impl<T> PolicyExt for T
where + T: ?Sized,

§

fn and<P, B, E>(self, other: P) -> And<T, P>
where + T: Policy<B, E>, + P: Policy<B, E>,

Create a new Policy that returns [Action::Follow] only if self and other return +Action::Follow. Read more
§

fn or<P, B, E>(self, other: P) -> Or<T, P>
where + T: Policy<B, E>, + P: Policy<B, E>,

Create a new Policy that returns [Action::Follow] if either self or other returns +Action::Follow. Read more
Source§

impl<T> Same for T

Source§

type Output = T

Should always be Self
Source§

impl<T, U> TryFrom<U> for T
where + U: Into<T>,

Source§

type Error = Infallible

The type returned in the event of a conversion error.
Source§

fn try_from(value: U) -> Result<T, <T as TryFrom<U>>::Error>

Performs the conversion.
Source§

impl<T, U> TryInto<U> for T
where + U: TryFrom<T>,

Source§

type Error = <U as TryFrom<T>>::Error

The type returned in the event of a conversion error.
Source§

fn try_into(self) -> Result<U, <U as TryFrom<T>>::Error>

Performs the conversion.
§

impl<V, T> VZip<V> for T
where + V: MultiLane<T>,

§

fn vzip(self) -> V

§

impl<T> WithSubscriber for T

§

fn with_subscriber<S>(self, subscriber: S) -> WithDispatch<Self>
where + S: Into<Dispatch>,

Attaches the provided Subscriber to this type, returning a +[WithDispatch] wrapper. Read more
§

fn with_current_subscriber(self) -> WithDispatch<Self>

Attaches the current default Subscriber to this type, returning a +[WithDispatch] wrapper. Read more
\ No newline at end of file diff --git a/api/jellytau_lib/auth/struct.AuthResult.html b/api/jellytau_lib/auth/struct.AuthResult.html new file mode 100644 index 00000000..cf52bd27 --- /dev/null +++ b/api/jellytau_lib/auth/struct.AuthResult.html @@ -0,0 +1,54 @@ +AuthResult in jellytau_lib::auth - RustSkip to main content

AuthResult

Struct AuthResult 

Source
pub struct AuthResult {
+    pub user: User,
+    pub access_token: String,
+    pub server_id: String,
+}
Expand description

Authentication result

+

Fields§

§user: User§access_token: String§server_id: String

Trait Implementations§

Source§

impl Clone for AuthResult

Source§

fn clone(&self) -> AuthResult

Returns a duplicate of the value. Read more
1.0.0 (const: unstable) · Source§

fn clone_from(&mut self, source: &Self)

Performs copy-assignment from source. Read more
Source§

impl Debug for AuthResult

Source§

fn fmt(&self, f: &mut Formatter<'_>) -> Result

Formats the value using the given formatter. Read more
Source§

impl<'de> Deserialize<'de> for AuthResult

Source§

fn deserialize<__D>(__deserializer: __D) -> Result<Self, __D::Error>
where + __D: Deserializer<'de>,

Deserialize this value from the given Serde deserializer. Read more
Source§

impl NamedType for AuthResult

Source§

fn sid() -> SpectaID

Source§

fn named_data_type( + type_map: &mut TypeCollection, + generics: &[DataType], +) -> NamedDataType

this is equivalent to [Type::inline] but returns a [NamedDataType] instead.
Source§

fn definition_named_data_type(type_map: &mut TypeCollection) -> NamedDataType

this is equivalent to [Type::definition] but returns a [NamedDataType] instead.
Source§

impl Serialize for AuthResult

Source§

fn serialize<__S>(&self, __serializer: __S) -> Result<__S::Ok, __S::Error>
where + __S: Serializer,

Serialize this value into the given Serde serializer. Read more
Source§

impl Type for AuthResult

Source§

fn inline(type_map: &mut TypeCollection, generics: Generics<'_>) -> DataType

Returns the definition of a type using the provided generics. Read more
Source§

fn reference(type_map: &mut TypeCollection, generics: &[DataType]) -> Reference

Generates a datatype corresponding to a reference to this type, +as determined by its category. Getting a reference to a type implies that +it should belong in the type map (since it has to be referenced from somewhere), +so the output of definition will be put into the type map.
Source§

impl Flatten for AuthResult

Auto Trait Implementations§

Blanket Implementations§

Source§

impl<T> Any for T
where + T: 'static + ?Sized,

Source§

fn type_id(&self) -> TypeId

Gets the TypeId of self. Read more
Source§

impl<T> Borrow<T> for T
where + T: ?Sized,

Source§

fn borrow(&self) -> &T

Immutably borrows from an owned value. Read more
Source§

impl<T> BorrowMut<T> for T
where + T: ?Sized,

Source§

fn borrow_mut(&mut self) -> &mut T

Mutably borrows from an owned value. Read more
Source§

impl<T> CloneToUninit for T
where + T: Clone,

Source§

unsafe fn clone_to_uninit(&self, dest: *mut u8)

🔬This is a nightly-only experimental API. (clone_to_uninit)
Performs copy-assignment from self to dest. Read more
§

impl<'de, D, R> CommandArg<'de, R> for D
where + D: Deserialize<'de>, + R: Runtime,

§

fn from_command(command: CommandItem<'de, R>) -> Result<D, InvokeError>

Derives an instance of Self from the [CommandItem]. Read more
Source§

impl<T> From<T> for T

Source§

fn from(t: T) -> T

Returns the argument unchanged.

+
§

impl<T> FunctionArg for T
where + T: Type,

§

fn to_datatype(type_map: &mut TypeCollection) -> Option<DataType>

Gets the type of an argument as a [DataType]. Read more
§

impl<T> Instrument for T

§

fn instrument(self, span: Span) -> Instrumented<Self>

Instruments this type with the provided [Span], returning an +Instrumented wrapper. Read more
§

fn in_current_span(self) -> Instrumented<Self>

Instruments this type with the current Span, returning an +Instrumented wrapper. Read more
Source§

impl<T, U> Into<U> for T
where + U: From<T>,

Source§

fn into(self) -> U

Calls U::from(self).

+

That is, this conversion is whatever the implementation of +From<T> for U chooses to do.

+
§

impl<T> IpcResponse for T
where + T: Serialize,

§

fn body(self) -> Result<InvokeResponseBody, Error>

Resolve the IPC response body.
§

impl<T> PolicyExt for T
where + T: ?Sized,

§

fn and<P, B, E>(self, other: P) -> And<T, P>
where + T: Policy<B, E>, + P: Policy<B, E>,

Create a new Policy that returns [Action::Follow] only if self and other return +Action::Follow. Read more
§

fn or<P, B, E>(self, other: P) -> Or<T, P>
where + T: Policy<B, E>, + P: Policy<B, E>,

Create a new Policy that returns [Action::Follow] if either self or other returns +Action::Follow. Read more
Source§

impl<T> Same for T

Source§

type Output = T

Should always be Self
§

impl<T> ScopeObject for T
where + T: Send + Sync + Debug + DeserializeOwned + 'static,

§

type Error = Error

The error type.
§

fn deserialize<R>( + _app: &AppHandle<R>, + raw: Value, +) -> Result<T, <T as ScopeObject>::Error>
where + R: Runtime,

Deserialize the raw scope value.
Source§

impl<T> Serialize for T
where + T: Serialize + ?Sized,

Source§

fn erased_serialize(&self, serializer: &mut dyn Serializer) -> Result<(), Error>

Source§

fn do_erased_serialize( + &self, + serializer: &mut dyn Serializer, +) -> Result<(), ErrorImpl>

Source§

impl<T> ToOwned for T
where + T: Clone,

Source§

type Owned = T

The resulting type after obtaining ownership.
Source§

fn to_owned(&self) -> T

Creates owned data from borrowed data, usually by cloning. Read more
Source§

fn clone_into(&self, target: &mut T)

Uses borrowed data to replace owned data, usually by cloning. Read more
Source§

impl<T, U> TryFrom<U> for T
where + U: Into<T>,

Source§

type Error = Infallible

The type returned in the event of a conversion error.
Source§

fn try_from(value: U) -> Result<T, <T as TryFrom<U>>::Error>

Performs the conversion.
Source§

impl<T, U> TryInto<U> for T
where + U: TryFrom<T>,

Source§

type Error = <U as TryFrom<T>>::Error

The type returned in the event of a conversion error.
Source§

fn try_into(self) -> Result<U, <U as TryFrom<T>>::Error>

Performs the conversion.
§

impl<V, T> VZip<V> for T
where + V: MultiLane<T>,

§

fn vzip(self) -> V

§

impl<T> WithSubscriber for T

§

fn with_subscriber<S>(self, subscriber: S) -> WithDispatch<Self>
where + S: Into<Dispatch>,

Attaches the provided Subscriber to this type, returning a +[WithDispatch] wrapper. Read more
§

fn with_current_subscriber(self) -> WithDispatch<Self>

Attaches the current default Subscriber to this type, returning a +[WithDispatch] wrapper. Read more
Source§

impl<T> DeserializeOwned for T
where + T: for<'de> Deserialize<'de>,

§

impl<T> UserEvent for T
where + T: Debug + Clone + Send + 'static,

\ No newline at end of file diff --git a/api/jellytau_lib/auth/struct.AuthenticateByNameResponse.html b/api/jellytau_lib/auth/struct.AuthenticateByNameResponse.html new file mode 100644 index 00000000..b3396d3c --- /dev/null +++ b/api/jellytau_lib/auth/struct.AuthenticateByNameResponse.html @@ -0,0 +1,44 @@ +AuthenticateByNameResponse in jellytau_lib::auth - RustSkip to main content

AuthenticateByNameResponse

Struct AuthenticateByNameResponse 

Source
struct AuthenticateByNameResponse {
+    user: JellyfinUser,
+    access_token: String,
+    server_id: String,
+}

Fields§

§user: JellyfinUser§access_token: String§server_id: String

Trait Implementations§

Source§

impl Debug for AuthenticateByNameResponse

Source§

fn fmt(&self, f: &mut Formatter<'_>) -> Result

Formats the value using the given formatter. Read more
Source§

impl<'de> Deserialize<'de> for AuthenticateByNameResponse

Source§

fn deserialize<__D>(__deserializer: __D) -> Result<Self, __D::Error>
where + __D: Deserializer<'de>,

Deserialize this value from the given Serde deserializer. Read more
Source§

impl NamedType for AuthenticateByNameResponse

Source§

fn sid() -> SpectaID

Source§

fn named_data_type( + type_map: &mut TypeCollection, + generics: &[DataType], +) -> NamedDataType

this is equivalent to [Type::inline] but returns a [NamedDataType] instead.
Source§

fn definition_named_data_type(type_map: &mut TypeCollection) -> NamedDataType

this is equivalent to [Type::definition] but returns a [NamedDataType] instead.
Source§

impl Type for AuthenticateByNameResponse

Source§

fn inline(type_map: &mut TypeCollection, generics: Generics<'_>) -> DataType

Returns the definition of a type using the provided generics. Read more
Source§

fn reference(type_map: &mut TypeCollection, generics: &[DataType]) -> Reference

Generates a datatype corresponding to a reference to this type, +as determined by its category. Getting a reference to a type implies that +it should belong in the type map (since it has to be referenced from somewhere), +so the output of definition will be put into the type map.
Source§

impl Flatten for AuthenticateByNameResponse

Auto Trait Implementations§

Blanket Implementations§

Source§

impl<T> Any for T
where + T: 'static + ?Sized,

Source§

fn type_id(&self) -> TypeId

Gets the TypeId of self. Read more
Source§

impl<T> Borrow<T> for T
where + T: ?Sized,

Source§

fn borrow(&self) -> &T

Immutably borrows from an owned value. Read more
Source§

impl<T> BorrowMut<T> for T
where + T: ?Sized,

Source§

fn borrow_mut(&mut self) -> &mut T

Mutably borrows from an owned value. Read more
§

impl<'de, D, R> CommandArg<'de, R> for D
where + D: Deserialize<'de>, + R: Runtime,

§

fn from_command(command: CommandItem<'de, R>) -> Result<D, InvokeError>

Derives an instance of Self from the [CommandItem]. Read more
Source§

impl<T> From<T> for T

Source§

fn from(t: T) -> T

Returns the argument unchanged.

+
§

impl<T> FunctionArg for T
where + T: Type,

§

fn to_datatype(type_map: &mut TypeCollection) -> Option<DataType>

Gets the type of an argument as a [DataType]. Read more
§

impl<T> Instrument for T

§

fn instrument(self, span: Span) -> Instrumented<Self>

Instruments this type with the provided [Span], returning an +Instrumented wrapper. Read more
§

fn in_current_span(self) -> Instrumented<Self>

Instruments this type with the current Span, returning an +Instrumented wrapper. Read more
Source§

impl<T, U> Into<U> for T
where + U: From<T>,

Source§

fn into(self) -> U

Calls U::from(self).

+

That is, this conversion is whatever the implementation of +From<T> for U chooses to do.

+
§

impl<T> PolicyExt for T
where + T: ?Sized,

§

fn and<P, B, E>(self, other: P) -> And<T, P>
where + T: Policy<B, E>, + P: Policy<B, E>,

Create a new Policy that returns [Action::Follow] only if self and other return +Action::Follow. Read more
§

fn or<P, B, E>(self, other: P) -> Or<T, P>
where + T: Policy<B, E>, + P: Policy<B, E>,

Create a new Policy that returns [Action::Follow] if either self or other returns +Action::Follow. Read more
Source§

impl<T> Same for T

Source§

type Output = T

Should always be Self
§

impl<T> ScopeObject for T
where + T: Send + Sync + Debug + DeserializeOwned + 'static,

§

type Error = Error

The error type.
§

fn deserialize<R>( + _app: &AppHandle<R>, + raw: Value, +) -> Result<T, <T as ScopeObject>::Error>
where + R: Runtime,

Deserialize the raw scope value.
Source§

impl<T, U> TryFrom<U> for T
where + U: Into<T>,

Source§

type Error = Infallible

The type returned in the event of a conversion error.
Source§

fn try_from(value: U) -> Result<T, <T as TryFrom<U>>::Error>

Performs the conversion.
Source§

impl<T, U> TryInto<U> for T
where + U: TryFrom<T>,

Source§

type Error = <U as TryFrom<T>>::Error

The type returned in the event of a conversion error.
Source§

fn try_into(self) -> Result<U, <U as TryFrom<T>>::Error>

Performs the conversion.
§

impl<V, T> VZip<V> for T
where + V: MultiLane<T>,

§

fn vzip(self) -> V

§

impl<T> WithSubscriber for T

§

fn with_subscriber<S>(self, subscriber: S) -> WithDispatch<Self>
where + S: Into<Dispatch>,

Attaches the provided Subscriber to this type, returning a +[WithDispatch] wrapper. Read more
§

fn with_current_subscriber(self) -> WithDispatch<Self>

Attaches the current default Subscriber to this type, returning a +[WithDispatch] wrapper. Read more
Source§

impl<T> DeserializeOwned for T
where + T: for<'de> Deserialize<'de>,

\ No newline at end of file diff --git a/api/jellytau_lib/auth/struct.JellyfinUser.html b/api/jellytau_lib/auth/struct.JellyfinUser.html new file mode 100644 index 00000000..10429419 --- /dev/null +++ b/api/jellytau_lib/auth/struct.JellyfinUser.html @@ -0,0 +1,45 @@ +JellyfinUser in jellytau_lib::auth - RustSkip to main content

JellyfinUser

Struct JellyfinUser 

Source
struct JellyfinUser {
+    id: String,
+    name: String,
+    server_id: String,
+    primary_image_tag: Option<String>,
+}

Fields§

§id: String§name: String§server_id: String§primary_image_tag: Option<String>

Trait Implementations§

Source§

impl Debug for JellyfinUser

Source§

fn fmt(&self, f: &mut Formatter<'_>) -> Result

Formats the value using the given formatter. Read more
Source§

impl<'de> Deserialize<'de> for JellyfinUser

Source§

fn deserialize<__D>(__deserializer: __D) -> Result<Self, __D::Error>
where + __D: Deserializer<'de>,

Deserialize this value from the given Serde deserializer. Read more
Source§

impl NamedType for JellyfinUser

Source§

fn sid() -> SpectaID

Source§

fn named_data_type( + type_map: &mut TypeCollection, + generics: &[DataType], +) -> NamedDataType

this is equivalent to [Type::inline] but returns a [NamedDataType] instead.
Source§

fn definition_named_data_type(type_map: &mut TypeCollection) -> NamedDataType

this is equivalent to [Type::definition] but returns a [NamedDataType] instead.
Source§

impl Type for JellyfinUser

Source§

fn inline(type_map: &mut TypeCollection, generics: Generics<'_>) -> DataType

Returns the definition of a type using the provided generics. Read more
Source§

fn reference(type_map: &mut TypeCollection, generics: &[DataType]) -> Reference

Generates a datatype corresponding to a reference to this type, +as determined by its category. Getting a reference to a type implies that +it should belong in the type map (since it has to be referenced from somewhere), +so the output of definition will be put into the type map.
Source§

impl Flatten for JellyfinUser

Auto Trait Implementations§

Blanket Implementations§

Source§

impl<T> Any for T
where + T: 'static + ?Sized,

Source§

fn type_id(&self) -> TypeId

Gets the TypeId of self. Read more
Source§

impl<T> Borrow<T> for T
where + T: ?Sized,

Source§

fn borrow(&self) -> &T

Immutably borrows from an owned value. Read more
Source§

impl<T> BorrowMut<T> for T
where + T: ?Sized,

Source§

fn borrow_mut(&mut self) -> &mut T

Mutably borrows from an owned value. Read more
§

impl<'de, D, R> CommandArg<'de, R> for D
where + D: Deserialize<'de>, + R: Runtime,

§

fn from_command(command: CommandItem<'de, R>) -> Result<D, InvokeError>

Derives an instance of Self from the [CommandItem]. Read more
Source§

impl<T> From<T> for T

Source§

fn from(t: T) -> T

Returns the argument unchanged.

+
§

impl<T> FunctionArg for T
where + T: Type,

§

fn to_datatype(type_map: &mut TypeCollection) -> Option<DataType>

Gets the type of an argument as a [DataType]. Read more
§

impl<T> Instrument for T

§

fn instrument(self, span: Span) -> Instrumented<Self>

Instruments this type with the provided [Span], returning an +Instrumented wrapper. Read more
§

fn in_current_span(self) -> Instrumented<Self>

Instruments this type with the current Span, returning an +Instrumented wrapper. Read more
Source§

impl<T, U> Into<U> for T
where + U: From<T>,

Source§

fn into(self) -> U

Calls U::from(self).

+

That is, this conversion is whatever the implementation of +From<T> for U chooses to do.

+
§

impl<T> PolicyExt for T
where + T: ?Sized,

§

fn and<P, B, E>(self, other: P) -> And<T, P>
where + T: Policy<B, E>, + P: Policy<B, E>,

Create a new Policy that returns [Action::Follow] only if self and other return +Action::Follow. Read more
§

fn or<P, B, E>(self, other: P) -> Or<T, P>
where + T: Policy<B, E>, + P: Policy<B, E>,

Create a new Policy that returns [Action::Follow] if either self or other returns +Action::Follow. Read more
Source§

impl<T> Same for T

Source§

type Output = T

Should always be Self
§

impl<T> ScopeObject for T
where + T: Send + Sync + Debug + DeserializeOwned + 'static,

§

type Error = Error

The error type.
§

fn deserialize<R>( + _app: &AppHandle<R>, + raw: Value, +) -> Result<T, <T as ScopeObject>::Error>
where + R: Runtime,

Deserialize the raw scope value.
Source§

impl<T, U> TryFrom<U> for T
where + U: Into<T>,

Source§

type Error = Infallible

The type returned in the event of a conversion error.
Source§

fn try_from(value: U) -> Result<T, <T as TryFrom<U>>::Error>

Performs the conversion.
Source§

impl<T, U> TryInto<U> for T
where + U: TryFrom<T>,

Source§

type Error = <U as TryFrom<T>>::Error

The type returned in the event of a conversion error.
Source§

fn try_into(self) -> Result<U, <U as TryFrom<T>>::Error>

Performs the conversion.
§

impl<V, T> VZip<V> for T
where + V: MultiLane<T>,

§

fn vzip(self) -> V

§

impl<T> WithSubscriber for T

§

fn with_subscriber<S>(self, subscriber: S) -> WithDispatch<Self>
where + S: Into<Dispatch>,

Attaches the provided Subscriber to this type, returning a +[WithDispatch] wrapper. Read more
§

fn with_current_subscriber(self) -> WithDispatch<Self>

Attaches the current default Subscriber to this type, returning a +[WithDispatch] wrapper. Read more
Source§

impl<T> DeserializeOwned for T
where + T: for<'de> Deserialize<'de>,

\ No newline at end of file diff --git a/api/jellytau_lib/auth/struct.PublicSystemInfo.html b/api/jellytau_lib/auth/struct.PublicSystemInfo.html new file mode 100644 index 00000000..cc8cafa7 --- /dev/null +++ b/api/jellytau_lib/auth/struct.PublicSystemInfo.html @@ -0,0 +1,44 @@ +PublicSystemInfo in jellytau_lib::auth - RustSkip to main content

PublicSystemInfo

Struct PublicSystemInfo 

Source
struct PublicSystemInfo {
+    server_name: String,
+    version: String,
+    id: String,
+}

Fields§

§server_name: String§version: String§id: String

Trait Implementations§

Source§

impl Debug for PublicSystemInfo

Source§

fn fmt(&self, f: &mut Formatter<'_>) -> Result

Formats the value using the given formatter. Read more
Source§

impl<'de> Deserialize<'de> for PublicSystemInfo

Source§

fn deserialize<__D>(__deserializer: __D) -> Result<Self, __D::Error>
where + __D: Deserializer<'de>,

Deserialize this value from the given Serde deserializer. Read more
Source§

impl NamedType for PublicSystemInfo

Source§

fn sid() -> SpectaID

Source§

fn named_data_type( + type_map: &mut TypeCollection, + generics: &[DataType], +) -> NamedDataType

this is equivalent to [Type::inline] but returns a [NamedDataType] instead.
Source§

fn definition_named_data_type(type_map: &mut TypeCollection) -> NamedDataType

this is equivalent to [Type::definition] but returns a [NamedDataType] instead.
Source§

impl Type for PublicSystemInfo

Source§

fn inline(type_map: &mut TypeCollection, generics: Generics<'_>) -> DataType

Returns the definition of a type using the provided generics. Read more
Source§

fn reference(type_map: &mut TypeCollection, generics: &[DataType]) -> Reference

Generates a datatype corresponding to a reference to this type, +as determined by its category. Getting a reference to a type implies that +it should belong in the type map (since it has to be referenced from somewhere), +so the output of definition will be put into the type map.
Source§

impl Flatten for PublicSystemInfo

Auto Trait Implementations§

Blanket Implementations§

Source§

impl<T> Any for T
where + T: 'static + ?Sized,

Source§

fn type_id(&self) -> TypeId

Gets the TypeId of self. Read more
Source§

impl<T> Borrow<T> for T
where + T: ?Sized,

Source§

fn borrow(&self) -> &T

Immutably borrows from an owned value. Read more
Source§

impl<T> BorrowMut<T> for T
where + T: ?Sized,

Source§

fn borrow_mut(&mut self) -> &mut T

Mutably borrows from an owned value. Read more
§

impl<'de, D, R> CommandArg<'de, R> for D
where + D: Deserialize<'de>, + R: Runtime,

§

fn from_command(command: CommandItem<'de, R>) -> Result<D, InvokeError>

Derives an instance of Self from the [CommandItem]. Read more
Source§

impl<T> From<T> for T

Source§

fn from(t: T) -> T

Returns the argument unchanged.

+
§

impl<T> FunctionArg for T
where + T: Type,

§

fn to_datatype(type_map: &mut TypeCollection) -> Option<DataType>

Gets the type of an argument as a [DataType]. Read more
§

impl<T> Instrument for T

§

fn instrument(self, span: Span) -> Instrumented<Self>

Instruments this type with the provided [Span], returning an +Instrumented wrapper. Read more
§

fn in_current_span(self) -> Instrumented<Self>

Instruments this type with the current Span, returning an +Instrumented wrapper. Read more
Source§

impl<T, U> Into<U> for T
where + U: From<T>,

Source§

fn into(self) -> U

Calls U::from(self).

+

That is, this conversion is whatever the implementation of +From<T> for U chooses to do.

+
§

impl<T> PolicyExt for T
where + T: ?Sized,

§

fn and<P, B, E>(self, other: P) -> And<T, P>
where + T: Policy<B, E>, + P: Policy<B, E>,

Create a new Policy that returns [Action::Follow] only if self and other return +Action::Follow. Read more
§

fn or<P, B, E>(self, other: P) -> Or<T, P>
where + T: Policy<B, E>, + P: Policy<B, E>,

Create a new Policy that returns [Action::Follow] if either self or other returns +Action::Follow. Read more
Source§

impl<T> Same for T

Source§

type Output = T

Should always be Self
§

impl<T> ScopeObject for T
where + T: Send + Sync + Debug + DeserializeOwned + 'static,

§

type Error = Error

The error type.
§

fn deserialize<R>( + _app: &AppHandle<R>, + raw: Value, +) -> Result<T, <T as ScopeObject>::Error>
where + R: Runtime,

Deserialize the raw scope value.
Source§

impl<T, U> TryFrom<U> for T
where + U: Into<T>,

Source§

type Error = Infallible

The type returned in the event of a conversion error.
Source§

fn try_from(value: U) -> Result<T, <T as TryFrom<U>>::Error>

Performs the conversion.
Source§

impl<T, U> TryInto<U> for T
where + U: TryFrom<T>,

Source§

type Error = <U as TryFrom<T>>::Error

The type returned in the event of a conversion error.
Source§

fn try_into(self) -> Result<U, <U as TryFrom<T>>::Error>

Performs the conversion.
§

impl<V, T> VZip<V> for T
where + V: MultiLane<T>,

§

fn vzip(self) -> V

§

impl<T> WithSubscriber for T

§

fn with_subscriber<S>(self, subscriber: S) -> WithDispatch<Self>
where + S: Into<Dispatch>,

Attaches the provided Subscriber to this type, returning a +[WithDispatch] wrapper. Read more
§

fn with_current_subscriber(self) -> WithDispatch<Self>

Attaches the current default Subscriber to this type, returning a +[WithDispatch] wrapper. Read more
Source§

impl<T> DeserializeOwned for T
where + T: for<'de> Deserialize<'de>,

\ No newline at end of file diff --git a/api/jellytau_lib/auth/struct.ServerInfo.html b/api/jellytau_lib/auth/struct.ServerInfo.html new file mode 100644 index 00000000..e99a306e --- /dev/null +++ b/api/jellytau_lib/auth/struct.ServerInfo.html @@ -0,0 +1,56 @@ +ServerInfo in jellytau_lib::auth - RustSkip to main content

ServerInfo

Struct ServerInfo 

Source
pub struct ServerInfo {
+    pub name: String,
+    pub version: String,
+    pub id: String,
+    pub normalized_url: String,
+}
Expand description

Server information returned from Jellyfin

+

Fields§

§name: String§version: String§id: String§normalized_url: String

Normalized server URL with protocol and no trailing slash

+

Trait Implementations§

Source§

impl Clone for ServerInfo

Source§

fn clone(&self) -> ServerInfo

Returns a duplicate of the value. Read more
1.0.0 (const: unstable) · Source§

fn clone_from(&mut self, source: &Self)

Performs copy-assignment from source. Read more
Source§

impl Debug for ServerInfo

Source§

fn fmt(&self, f: &mut Formatter<'_>) -> Result

Formats the value using the given formatter. Read more
Source§

impl<'de> Deserialize<'de> for ServerInfo

Source§

fn deserialize<__D>(__deserializer: __D) -> Result<Self, __D::Error>
where + __D: Deserializer<'de>,

Deserialize this value from the given Serde deserializer. Read more
Source§

impl NamedType for ServerInfo

Source§

fn sid() -> SpectaID

Source§

fn named_data_type( + type_map: &mut TypeCollection, + generics: &[DataType], +) -> NamedDataType

this is equivalent to [Type::inline] but returns a [NamedDataType] instead.
Source§

fn definition_named_data_type(type_map: &mut TypeCollection) -> NamedDataType

this is equivalent to [Type::definition] but returns a [NamedDataType] instead.
Source§

impl Serialize for ServerInfo

Source§

fn serialize<__S>(&self, __serializer: __S) -> Result<__S::Ok, __S::Error>
where + __S: Serializer,

Serialize this value into the given Serde serializer. Read more
Source§

impl Type for ServerInfo

Source§

fn inline(type_map: &mut TypeCollection, generics: Generics<'_>) -> DataType

Returns the definition of a type using the provided generics. Read more
Source§

fn reference(type_map: &mut TypeCollection, generics: &[DataType]) -> Reference

Generates a datatype corresponding to a reference to this type, +as determined by its category. Getting a reference to a type implies that +it should belong in the type map (since it has to be referenced from somewhere), +so the output of definition will be put into the type map.
Source§

impl Flatten for ServerInfo

Auto Trait Implementations§

Blanket Implementations§

Source§

impl<T> Any for T
where + T: 'static + ?Sized,

Source§

fn type_id(&self) -> TypeId

Gets the TypeId of self. Read more
Source§

impl<T> Borrow<T> for T
where + T: ?Sized,

Source§

fn borrow(&self) -> &T

Immutably borrows from an owned value. Read more
Source§

impl<T> BorrowMut<T> for T
where + T: ?Sized,

Source§

fn borrow_mut(&mut self) -> &mut T

Mutably borrows from an owned value. Read more
Source§

impl<T> CloneToUninit for T
where + T: Clone,

Source§

unsafe fn clone_to_uninit(&self, dest: *mut u8)

🔬This is a nightly-only experimental API. (clone_to_uninit)
Performs copy-assignment from self to dest. Read more
§

impl<'de, D, R> CommandArg<'de, R> for D
where + D: Deserialize<'de>, + R: Runtime,

§

fn from_command(command: CommandItem<'de, R>) -> Result<D, InvokeError>

Derives an instance of Self from the [CommandItem]. Read more
Source§

impl<T> From<T> for T

Source§

fn from(t: T) -> T

Returns the argument unchanged.

+
§

impl<T> FunctionArg for T
where + T: Type,

§

fn to_datatype(type_map: &mut TypeCollection) -> Option<DataType>

Gets the type of an argument as a [DataType]. Read more
§

impl<T> Instrument for T

§

fn instrument(self, span: Span) -> Instrumented<Self>

Instruments this type with the provided [Span], returning an +Instrumented wrapper. Read more
§

fn in_current_span(self) -> Instrumented<Self>

Instruments this type with the current Span, returning an +Instrumented wrapper. Read more
Source§

impl<T, U> Into<U> for T
where + U: From<T>,

Source§

fn into(self) -> U

Calls U::from(self).

+

That is, this conversion is whatever the implementation of +From<T> for U chooses to do.

+
§

impl<T> IpcResponse for T
where + T: Serialize,

§

fn body(self) -> Result<InvokeResponseBody, Error>

Resolve the IPC response body.
§

impl<T> PolicyExt for T
where + T: ?Sized,

§

fn and<P, B, E>(self, other: P) -> And<T, P>
where + T: Policy<B, E>, + P: Policy<B, E>,

Create a new Policy that returns [Action::Follow] only if self and other return +Action::Follow. Read more
§

fn or<P, B, E>(self, other: P) -> Or<T, P>
where + T: Policy<B, E>, + P: Policy<B, E>,

Create a new Policy that returns [Action::Follow] if either self or other returns +Action::Follow. Read more
Source§

impl<T> Same for T

Source§

type Output = T

Should always be Self
§

impl<T> ScopeObject for T
where + T: Send + Sync + Debug + DeserializeOwned + 'static,

§

type Error = Error

The error type.
§

fn deserialize<R>( + _app: &AppHandle<R>, + raw: Value, +) -> Result<T, <T as ScopeObject>::Error>
where + R: Runtime,

Deserialize the raw scope value.
Source§

impl<T> Serialize for T
where + T: Serialize + ?Sized,

Source§

fn erased_serialize(&self, serializer: &mut dyn Serializer) -> Result<(), Error>

Source§

fn do_erased_serialize( + &self, + serializer: &mut dyn Serializer, +) -> Result<(), ErrorImpl>

Source§

impl<T> ToOwned for T
where + T: Clone,

Source§

type Owned = T

The resulting type after obtaining ownership.
Source§

fn to_owned(&self) -> T

Creates owned data from borrowed data, usually by cloning. Read more
Source§

fn clone_into(&self, target: &mut T)

Uses borrowed data to replace owned data, usually by cloning. Read more
Source§

impl<T, U> TryFrom<U> for T
where + U: Into<T>,

Source§

type Error = Infallible

The type returned in the event of a conversion error.
Source§

fn try_from(value: U) -> Result<T, <T as TryFrom<U>>::Error>

Performs the conversion.
Source§

impl<T, U> TryInto<U> for T
where + U: TryFrom<T>,

Source§

type Error = <U as TryFrom<T>>::Error

The type returned in the event of a conversion error.
Source§

fn try_into(self) -> Result<U, <U as TryFrom<T>>::Error>

Performs the conversion.
§

impl<V, T> VZip<V> for T
where + V: MultiLane<T>,

§

fn vzip(self) -> V

§

impl<T> WithSubscriber for T

§

fn with_subscriber<S>(self, subscriber: S) -> WithDispatch<Self>
where + S: Into<Dispatch>,

Attaches the provided Subscriber to this type, returning a +[WithDispatch] wrapper. Read more
§

fn with_current_subscriber(self) -> WithDispatch<Self>

Attaches the current default Subscriber to this type, returning a +[WithDispatch] wrapper. Read more
Source§

impl<T> DeserializeOwned for T
where + T: for<'de> Deserialize<'de>,

§

impl<T> UserEvent for T
where + T: Debug + Clone + Send + 'static,

\ No newline at end of file diff --git a/api/jellytau_lib/auth/struct.Session.html b/api/jellytau_lib/auth/struct.Session.html new file mode 100644 index 00000000..39006bf7 --- /dev/null +++ b/api/jellytau_lib/auth/struct.Session.html @@ -0,0 +1,59 @@ +Session in jellytau_lib::auth - RustSkip to main content

Session

Struct Session 

Source
pub struct Session {
+    pub user_id: String,
+    pub username: String,
+    pub server_id: String,
+    pub server_url: String,
+    pub server_name: String,
+    pub access_token: String,
+    pub verified: bool,
+    pub needs_reauth: bool,
+}
Expand description

Active session for restoration

+

Fields§

§user_id: String§username: String§server_id: String§server_url: String§server_name: String§access_token: String§verified: bool§needs_reauth: bool

Trait Implementations§

Source§

impl Clone for Session

Source§

fn clone(&self) -> Session

Returns a duplicate of the value. Read more
1.0.0 (const: unstable) · Source§

fn clone_from(&mut self, source: &Self)

Performs copy-assignment from source. Read more
Source§

impl Debug for Session

Source§

fn fmt(&self, f: &mut Formatter<'_>) -> Result

Formats the value using the given formatter. Read more
Source§

impl<'de> Deserialize<'de> for Session

Source§

fn deserialize<__D>(__deserializer: __D) -> Result<Self, __D::Error>
where + __D: Deserializer<'de>,

Deserialize this value from the given Serde deserializer. Read more
Source§

impl NamedType for Session

Source§

fn sid() -> SpectaID

Source§

fn named_data_type( + type_map: &mut TypeCollection, + generics: &[DataType], +) -> NamedDataType

this is equivalent to [Type::inline] but returns a [NamedDataType] instead.
Source§

fn definition_named_data_type(type_map: &mut TypeCollection) -> NamedDataType

this is equivalent to [Type::definition] but returns a [NamedDataType] instead.
Source§

impl Serialize for Session

Source§

fn serialize<__S>(&self, __serializer: __S) -> Result<__S::Ok, __S::Error>
where + __S: Serializer,

Serialize this value into the given Serde serializer. Read more
Source§

impl Type for Session

Source§

fn inline(type_map: &mut TypeCollection, generics: Generics<'_>) -> DataType

Returns the definition of a type using the provided generics. Read more
Source§

fn reference(type_map: &mut TypeCollection, generics: &[DataType]) -> Reference

Generates a datatype corresponding to a reference to this type, +as determined by its category. Getting a reference to a type implies that +it should belong in the type map (since it has to be referenced from somewhere), +so the output of definition will be put into the type map.
Source§

impl Flatten for Session

Auto Trait Implementations§

Blanket Implementations§

Source§

impl<T> Any for T
where + T: 'static + ?Sized,

Source§

fn type_id(&self) -> TypeId

Gets the TypeId of self. Read more
Source§

impl<T> Borrow<T> for T
where + T: ?Sized,

Source§

fn borrow(&self) -> &T

Immutably borrows from an owned value. Read more
Source§

impl<T> BorrowMut<T> for T
where + T: ?Sized,

Source§

fn borrow_mut(&mut self) -> &mut T

Mutably borrows from an owned value. Read more
Source§

impl<T> CloneToUninit for T
where + T: Clone,

Source§

unsafe fn clone_to_uninit(&self, dest: *mut u8)

🔬This is a nightly-only experimental API. (clone_to_uninit)
Performs copy-assignment from self to dest. Read more
§

impl<'de, D, R> CommandArg<'de, R> for D
where + D: Deserialize<'de>, + R: Runtime,

§

fn from_command(command: CommandItem<'de, R>) -> Result<D, InvokeError>

Derives an instance of Self from the [CommandItem]. Read more
Source§

impl<T> From<T> for T

Source§

fn from(t: T) -> T

Returns the argument unchanged.

+
§

impl<T> FunctionArg for T
where + T: Type,

§

fn to_datatype(type_map: &mut TypeCollection) -> Option<DataType>

Gets the type of an argument as a [DataType]. Read more
§

impl<T> Instrument for T

§

fn instrument(self, span: Span) -> Instrumented<Self>

Instruments this type with the provided [Span], returning an +Instrumented wrapper. Read more
§

fn in_current_span(self) -> Instrumented<Self>

Instruments this type with the current Span, returning an +Instrumented wrapper. Read more
Source§

impl<T, U> Into<U> for T
where + U: From<T>,

Source§

fn into(self) -> U

Calls U::from(self).

+

That is, this conversion is whatever the implementation of +From<T> for U chooses to do.

+
§

impl<T> IpcResponse for T
where + T: Serialize,

§

fn body(self) -> Result<InvokeResponseBody, Error>

Resolve the IPC response body.
§

impl<T> PolicyExt for T
where + T: ?Sized,

§

fn and<P, B, E>(self, other: P) -> And<T, P>
where + T: Policy<B, E>, + P: Policy<B, E>,

Create a new Policy that returns [Action::Follow] only if self and other return +Action::Follow. Read more
§

fn or<P, B, E>(self, other: P) -> Or<T, P>
where + T: Policy<B, E>, + P: Policy<B, E>,

Create a new Policy that returns [Action::Follow] if either self or other returns +Action::Follow. Read more
Source§

impl<T> Same for T

Source§

type Output = T

Should always be Self
§

impl<T> ScopeObject for T
where + T: Send + Sync + Debug + DeserializeOwned + 'static,

§

type Error = Error

The error type.
§

fn deserialize<R>( + _app: &AppHandle<R>, + raw: Value, +) -> Result<T, <T as ScopeObject>::Error>
where + R: Runtime,

Deserialize the raw scope value.
Source§

impl<T> Serialize for T
where + T: Serialize + ?Sized,

Source§

fn erased_serialize(&self, serializer: &mut dyn Serializer) -> Result<(), Error>

Source§

fn do_erased_serialize( + &self, + serializer: &mut dyn Serializer, +) -> Result<(), ErrorImpl>

Source§

impl<T> ToOwned for T
where + T: Clone,

Source§

type Owned = T

The resulting type after obtaining ownership.
Source§

fn to_owned(&self) -> T

Creates owned data from borrowed data, usually by cloning. Read more
Source§

fn clone_into(&self, target: &mut T)

Uses borrowed data to replace owned data, usually by cloning. Read more
Source§

impl<T, U> TryFrom<U> for T
where + U: Into<T>,

Source§

type Error = Infallible

The type returned in the event of a conversion error.
Source§

fn try_from(value: U) -> Result<T, <T as TryFrom<U>>::Error>

Performs the conversion.
Source§

impl<T, U> TryInto<U> for T
where + U: TryFrom<T>,

Source§

type Error = <U as TryFrom<T>>::Error

The type returned in the event of a conversion error.
Source§

fn try_into(self) -> Result<U, <U as TryFrom<T>>::Error>

Performs the conversion.
§

impl<V, T> VZip<V> for T
where + V: MultiLane<T>,

§

fn vzip(self) -> V

§

impl<T> WithSubscriber for T

§

fn with_subscriber<S>(self, subscriber: S) -> WithDispatch<Self>
where + S: Into<Dispatch>,

Attaches the provided Subscriber to this type, returning a +[WithDispatch] wrapper. Read more
§

fn with_current_subscriber(self) -> WithDispatch<Self>

Attaches the current default Subscriber to this type, returning a +[WithDispatch] wrapper. Read more
Source§

impl<T> DeserializeOwned for T
where + T: for<'de> Deserialize<'de>,

§

impl<T> UserEvent for T
where + T: Debug + Clone + Send + 'static,

\ No newline at end of file diff --git a/api/jellytau_lib/auth/struct.User.html b/api/jellytau_lib/auth/struct.User.html new file mode 100644 index 00000000..0c0289cd --- /dev/null +++ b/api/jellytau_lib/auth/struct.User.html @@ -0,0 +1,55 @@ +User in jellytau_lib::auth - RustSkip to main content

User

Struct User 

Source
pub struct User {
+    pub id: String,
+    pub name: String,
+    pub server_id: String,
+    pub primary_image_tag: Option<String>,
+}
Expand description

User information

+

Fields§

§id: String§name: String§server_id: String§primary_image_tag: Option<String>

Trait Implementations§

Source§

impl Clone for User

Source§

fn clone(&self) -> User

Returns a duplicate of the value. Read more
1.0.0 (const: unstable) · Source§

fn clone_from(&mut self, source: &Self)

Performs copy-assignment from source. Read more
Source§

impl Debug for User

Source§

fn fmt(&self, f: &mut Formatter<'_>) -> Result

Formats the value using the given formatter. Read more
Source§

impl<'de> Deserialize<'de> for User

Source§

fn deserialize<__D>(__deserializer: __D) -> Result<Self, __D::Error>
where + __D: Deserializer<'de>,

Deserialize this value from the given Serde deserializer. Read more
Source§

impl NamedType for User

Source§

fn sid() -> SpectaID

Source§

fn named_data_type( + type_map: &mut TypeCollection, + generics: &[DataType], +) -> NamedDataType

this is equivalent to [Type::inline] but returns a [NamedDataType] instead.
Source§

fn definition_named_data_type(type_map: &mut TypeCollection) -> NamedDataType

this is equivalent to [Type::definition] but returns a [NamedDataType] instead.
Source§

impl Serialize for User

Source§

fn serialize<__S>(&self, __serializer: __S) -> Result<__S::Ok, __S::Error>
where + __S: Serializer,

Serialize this value into the given Serde serializer. Read more
Source§

impl Type for User

Source§

fn inline(type_map: &mut TypeCollection, generics: Generics<'_>) -> DataType

Returns the definition of a type using the provided generics. Read more
Source§

fn reference(type_map: &mut TypeCollection, generics: &[DataType]) -> Reference

Generates a datatype corresponding to a reference to this type, +as determined by its category. Getting a reference to a type implies that +it should belong in the type map (since it has to be referenced from somewhere), +so the output of definition will be put into the type map.
Source§

impl Flatten for User

Auto Trait Implementations§

§

impl Freeze for User

§

impl RefUnwindSafe for User

§

impl Send for User

§

impl Sync for User

§

impl Unpin for User

§

impl UnsafeUnpin for User

§

impl UnwindSafe for User

Blanket Implementations§

Source§

impl<T> Any for T
where + T: 'static + ?Sized,

Source§

fn type_id(&self) -> TypeId

Gets the TypeId of self. Read more
Source§

impl<T> Borrow<T> for T
where + T: ?Sized,

Source§

fn borrow(&self) -> &T

Immutably borrows from an owned value. Read more
Source§

impl<T> BorrowMut<T> for T
where + T: ?Sized,

Source§

fn borrow_mut(&mut self) -> &mut T

Mutably borrows from an owned value. Read more
Source§

impl<T> CloneToUninit for T
where + T: Clone,

Source§

unsafe fn clone_to_uninit(&self, dest: *mut u8)

🔬This is a nightly-only experimental API. (clone_to_uninit)
Performs copy-assignment from self to dest. Read more
§

impl<'de, D, R> CommandArg<'de, R> for D
where + D: Deserialize<'de>, + R: Runtime,

§

fn from_command(command: CommandItem<'de, R>) -> Result<D, InvokeError>

Derives an instance of Self from the [CommandItem]. Read more
Source§

impl<T> From<T> for T

Source§

fn from(t: T) -> T

Returns the argument unchanged.

+
§

impl<T> FunctionArg for T
where + T: Type,

§

fn to_datatype(type_map: &mut TypeCollection) -> Option<DataType>

Gets the type of an argument as a [DataType]. Read more
§

impl<T> Instrument for T

§

fn instrument(self, span: Span) -> Instrumented<Self>

Instruments this type with the provided [Span], returning an +Instrumented wrapper. Read more
§

fn in_current_span(self) -> Instrumented<Self>

Instruments this type with the current Span, returning an +Instrumented wrapper. Read more
Source§

impl<T, U> Into<U> for T
where + U: From<T>,

Source§

fn into(self) -> U

Calls U::from(self).

+

That is, this conversion is whatever the implementation of +From<T> for U chooses to do.

+
§

impl<T> IpcResponse for T
where + T: Serialize,

§

fn body(self) -> Result<InvokeResponseBody, Error>

Resolve the IPC response body.
§

impl<T> PolicyExt for T
where + T: ?Sized,

§

fn and<P, B, E>(self, other: P) -> And<T, P>
where + T: Policy<B, E>, + P: Policy<B, E>,

Create a new Policy that returns [Action::Follow] only if self and other return +Action::Follow. Read more
§

fn or<P, B, E>(self, other: P) -> Or<T, P>
where + T: Policy<B, E>, + P: Policy<B, E>,

Create a new Policy that returns [Action::Follow] if either self or other returns +Action::Follow. Read more
Source§

impl<T> Same for T

Source§

type Output = T

Should always be Self
§

impl<T> ScopeObject for T
where + T: Send + Sync + Debug + DeserializeOwned + 'static,

§

type Error = Error

The error type.
§

fn deserialize<R>( + _app: &AppHandle<R>, + raw: Value, +) -> Result<T, <T as ScopeObject>::Error>
where + R: Runtime,

Deserialize the raw scope value.
Source§

impl<T> Serialize for T
where + T: Serialize + ?Sized,

Source§

fn erased_serialize(&self, serializer: &mut dyn Serializer) -> Result<(), Error>

Source§

fn do_erased_serialize( + &self, + serializer: &mut dyn Serializer, +) -> Result<(), ErrorImpl>

Source§

impl<T> ToOwned for T
where + T: Clone,

Source§

type Owned = T

The resulting type after obtaining ownership.
Source§

fn to_owned(&self) -> T

Creates owned data from borrowed data, usually by cloning. Read more
Source§

fn clone_into(&self, target: &mut T)

Uses borrowed data to replace owned data, usually by cloning. Read more
Source§

impl<T, U> TryFrom<U> for T
where + U: Into<T>,

Source§

type Error = Infallible

The type returned in the event of a conversion error.
Source§

fn try_from(value: U) -> Result<T, <T as TryFrom<U>>::Error>

Performs the conversion.
Source§

impl<T, U> TryInto<U> for T
where + U: TryFrom<T>,

Source§

type Error = <U as TryFrom<T>>::Error

The type returned in the event of a conversion error.
Source§

fn try_into(self) -> Result<U, <U as TryFrom<T>>::Error>

Performs the conversion.
§

impl<V, T> VZip<V> for T
where + V: MultiLane<T>,

§

fn vzip(self) -> V

§

impl<T> WithSubscriber for T

§

fn with_subscriber<S>(self, subscriber: S) -> WithDispatch<Self>
where + S: Into<Dispatch>,

Attaches the provided Subscriber to this type, returning a +[WithDispatch] wrapper. Read more
§

fn with_current_subscriber(self) -> WithDispatch<Self>

Attaches the current default Subscriber to this type, returning a +[WithDispatch] wrapper. Read more
Source§

impl<T> DeserializeOwned for T
where + T: for<'de> Deserialize<'de>,

§

impl<T> UserEvent for T
where + T: Debug + Clone + Send + 'static,

\ No newline at end of file diff --git a/api/jellytau_lib/commands/auth/fn.auth_connect_to_server.html b/api/jellytau_lib/commands/auth/fn.auth_connect_to_server.html new file mode 100644 index 00000000..fb6196de --- /dev/null +++ b/api/jellytau_lib/commands/auth/fn.auth_connect_to_server.html @@ -0,0 +1,5 @@ +auth_connect_to_server in jellytau_lib::commands::auth - RustSkip to main content

auth_connect_to_server

Function auth_connect_to_server 

Source
pub async fn auth_connect_to_server(
+    server_url: String,
+    auth_manager: State<'_, AuthManagerWrapper>,
+) -> Result<ServerInfo, String>
Expand description

Connect to a Jellyfin server and get server info

+
\ No newline at end of file diff --git a/api/jellytau_lib/commands/auth/fn.auth_get_session.html b/api/jellytau_lib/commands/auth/fn.auth_get_session.html new file mode 100644 index 00000000..9c3392d9 --- /dev/null +++ b/api/jellytau_lib/commands/auth/fn.auth_get_session.html @@ -0,0 +1,4 @@ +auth_get_session in jellytau_lib::commands::auth - RustSkip to main content

auth_get_session

Function auth_get_session 

Source
pub async fn auth_get_session(
+    auth_manager: State<'_, AuthManagerWrapper>,
+) -> Result<Option<Session>, String>
Expand description

Get current session

+
\ No newline at end of file diff --git a/api/jellytau_lib/commands/auth/fn.auth_initialize.html b/api/jellytau_lib/commands/auth/fn.auth_initialize.html new file mode 100644 index 00000000..bc201bcd --- /dev/null +++ b/api/jellytau_lib/commands/auth/fn.auth_initialize.html @@ -0,0 +1,7 @@ +auth_initialize in jellytau_lib::commands::auth - RustSkip to main content

auth_initialize

Function auth_initialize 

Source
pub async fn auth_initialize(
+    auth_manager: State<'_, AuthManagerWrapper>,
+    database: State<'_, DatabaseWrapper>,
+    credentials: State<'_, CredentialStoreWrapper>,
+) -> Result<Option<Session>, String>
Expand description

Initialize the auth manager (call on app startup) +Restores session from storage if available

+
\ No newline at end of file diff --git a/api/jellytau_lib/commands/auth/fn.auth_login.html b/api/jellytau_lib/commands/auth/fn.auth_login.html new file mode 100644 index 00000000..3edff6ae --- /dev/null +++ b/api/jellytau_lib/commands/auth/fn.auth_login.html @@ -0,0 +1,8 @@ +auth_login in jellytau_lib::commands::auth - RustSkip to main content

auth_login

Function auth_login 

Source
pub async fn auth_login(
+    server_url: String,
+    username: String,
+    password: String,
+    device_id: String,
+    auth_manager: State<'_, AuthManagerWrapper>,
+) -> Result<AuthResult, String>
Expand description

Login with username and password

+
\ No newline at end of file diff --git a/api/jellytau_lib/commands/auth/fn.auth_logout.html b/api/jellytau_lib/commands/auth/fn.auth_logout.html new file mode 100644 index 00000000..b4b6be1c --- /dev/null +++ b/api/jellytau_lib/commands/auth/fn.auth_logout.html @@ -0,0 +1,8 @@ +auth_logout in jellytau_lib::commands::auth - RustSkip to main content

auth_logout

Function auth_logout 

Source
pub async fn auth_logout(
+    server_url: String,
+    access_token: String,
+    device_id: String,
+    auth_manager: State<'_, AuthManagerWrapper>,
+    session_verifier: State<'_, SessionVerifierWrapper>,
+) -> Result<(), String>
Expand description

Logout (clear session and call Jellyfin logout endpoint)

+
\ No newline at end of file diff --git a/api/jellytau_lib/commands/auth/fn.auth_reauthenticate.html b/api/jellytau_lib/commands/auth/fn.auth_reauthenticate.html new file mode 100644 index 00000000..9b5312d7 --- /dev/null +++ b/api/jellytau_lib/commands/auth/fn.auth_reauthenticate.html @@ -0,0 +1,6 @@ +auth_reauthenticate in jellytau_lib::commands::auth - RustSkip to main content

auth_reauthenticate

Function auth_reauthenticate 

Source
pub async fn auth_reauthenticate(
+    password: String,
+    device_id: String,
+    auth_manager: State<'_, AuthManagerWrapper>,
+) -> Result<AuthResult, String>
Expand description

Re-authenticate with password (when session expired)

+
\ No newline at end of file diff --git a/api/jellytau_lib/commands/auth/fn.auth_set_session.html b/api/jellytau_lib/commands/auth/fn.auth_set_session.html new file mode 100644 index 00000000..56c9ff0b --- /dev/null +++ b/api/jellytau_lib/commands/auth/fn.auth_set_session.html @@ -0,0 +1,5 @@ +auth_set_session in jellytau_lib::commands::auth - RustSkip to main content

auth_set_session

Function auth_set_session 

Source
pub async fn auth_set_session(
+    session: Option<Session>,
+    auth_manager: State<'_, AuthManagerWrapper>,
+) -> Result<(), String>
Expand description

Set current session (for restoration from storage)

+
\ No newline at end of file diff --git a/api/jellytau_lib/commands/auth/fn.auth_start_verification.html b/api/jellytau_lib/commands/auth/fn.auth_start_verification.html new file mode 100644 index 00000000..abbe3001 --- /dev/null +++ b/api/jellytau_lib/commands/auth/fn.auth_start_verification.html @@ -0,0 +1,7 @@ +auth_start_verification in jellytau_lib::commands::auth - RustSkip to main content

auth_start_verification

Function auth_start_verification 

Source
pub async fn auth_start_verification(
+    device_id: String,
+    app_handle: AppHandle,
+    auth_manager: State<'_, AuthManagerWrapper>,
+    session_verifier: State<'_, SessionVerifierWrapper>,
+) -> Result<(), String>
Expand description

Start background session verification

+
\ No newline at end of file diff --git a/api/jellytau_lib/commands/auth/fn.auth_stop_verification.html b/api/jellytau_lib/commands/auth/fn.auth_stop_verification.html new file mode 100644 index 00000000..34e203f5 --- /dev/null +++ b/api/jellytau_lib/commands/auth/fn.auth_stop_verification.html @@ -0,0 +1,4 @@ +auth_stop_verification in jellytau_lib::commands::auth - RustSkip to main content

auth_stop_verification

Function auth_stop_verification 

Source
pub async fn auth_stop_verification(
+    session_verifier: State<'_, SessionVerifierWrapper>,
+) -> Result<(), String>
Expand description

Stop background session verification

+
\ No newline at end of file diff --git a/api/jellytau_lib/commands/auth/fn.auth_verify_session.html b/api/jellytau_lib/commands/auth/fn.auth_verify_session.html new file mode 100644 index 00000000..4698cb39 --- /dev/null +++ b/api/jellytau_lib/commands/auth/fn.auth_verify_session.html @@ -0,0 +1,8 @@ +auth_verify_session in jellytau_lib::commands::auth - RustSkip to main content

auth_verify_session

Function auth_verify_session 

Source
pub async fn auth_verify_session(
+    server_url: String,
+    user_id: String,
+    access_token: String,
+    device_id: String,
+    auth_manager: State<'_, AuthManagerWrapper>,
+) -> Result<bool, String>
Expand description

Verify current session

+
\ No newline at end of file diff --git a/api/jellytau_lib/commands/auth/index.html b/api/jellytau_lib/commands/auth/index.html new file mode 100644 index 00000000..496716c6 --- /dev/null +++ b/api/jellytau_lib/commands/auth/index.html @@ -0,0 +1,4 @@ +jellytau_lib::commands::auth - RustSkip to main content

Module auth

Module auth 

Source
Expand description

Authentication and session-lifecycle commands.

+

TRACES: UR-042 | IR-009, IR-014, JA-002 | DR-054

+

Structs§

AuthManagerWrapper
Wrapper for AuthManager to manage in Tauri state
SessionVerifierWrapper
Wrapper for SessionVerifier to manage in Tauri state

Functions§

auth_connect_to_server
Connect to a Jellyfin server and get server info
auth_get_session
Get current session
auth_initialize
Initialize the auth manager (call on app startup) +Restores session from storage if available
auth_login
Login with username and password
auth_logout
Logout (clear session and call Jellyfin logout endpoint)
auth_reauthenticate
Re-authenticate with password (when session expired)
auth_set_session
Set current session (for restoration from storage)
auth_start_verification
Start background session verification
auth_stop_verification
Stop background session verification
auth_verify_session
Verify current session
\ No newline at end of file diff --git a/api/jellytau_lib/commands/auth/sidebar-items.js b/api/jellytau_lib/commands/auth/sidebar-items.js new file mode 100644 index 00000000..e3ad5edb --- /dev/null +++ b/api/jellytau_lib/commands/auth/sidebar-items.js @@ -0,0 +1 @@ +window.SIDEBAR_ITEMS = {"fn":["auth_connect_to_server","auth_get_session","auth_initialize","auth_login","auth_logout","auth_reauthenticate","auth_set_session","auth_start_verification","auth_stop_verification","auth_verify_session"],"struct":["AuthManagerWrapper","SessionVerifierWrapper"]}; \ No newline at end of file diff --git a/api/jellytau_lib/commands/auth/struct.AuthManagerWrapper.html b/api/jellytau_lib/commands/auth/struct.AuthManagerWrapper.html new file mode 100644 index 00000000..ea6cf0df --- /dev/null +++ b/api/jellytau_lib/commands/auth/struct.AuthManagerWrapper.html @@ -0,0 +1,25 @@ +AuthManagerWrapper in jellytau_lib::commands::auth - RustSkip to main content

AuthManagerWrapper

Struct AuthManagerWrapper 

Source
pub struct AuthManagerWrapper(pub Arc<AuthManager>);
Expand description

Wrapper for AuthManager to manage in Tauri state

+

Tuple Fields§

§0: Arc<AuthManager>

Auto Trait Implementations§

Blanket Implementations§

Source§

impl<T> Any for T
where + T: 'static + ?Sized,

Source§

fn type_id(&self) -> TypeId

Gets the TypeId of self. Read more
Source§

impl<T> Borrow<T> for T
where + T: ?Sized,

Source§

fn borrow(&self) -> &T

Immutably borrows from an owned value. Read more
Source§

impl<T> BorrowMut<T> for T
where + T: ?Sized,

Source§

fn borrow_mut(&mut self) -> &mut T

Mutably borrows from an owned value. Read more
Source§

impl<T> From<T> for T

Source§

fn from(t: T) -> T

Returns the argument unchanged.

+
§

impl<T> Instrument for T

§

fn instrument(self, span: Span) -> Instrumented<Self>

Instruments this type with the provided [Span], returning an +Instrumented wrapper. Read more
§

fn in_current_span(self) -> Instrumented<Self>

Instruments this type with the current Span, returning an +Instrumented wrapper. Read more
Source§

impl<T, U> Into<U> for T
where + U: From<T>,

Source§

fn into(self) -> U

Calls U::from(self).

+

That is, this conversion is whatever the implementation of +From<T> for U chooses to do.

+
§

impl<T> PolicyExt for T
where + T: ?Sized,

§

fn and<P, B, E>(self, other: P) -> And<T, P>
where + T: Policy<B, E>, + P: Policy<B, E>,

Create a new Policy that returns [Action::Follow] only if self and other return +Action::Follow. Read more
§

fn or<P, B, E>(self, other: P) -> Or<T, P>
where + T: Policy<B, E>, + P: Policy<B, E>,

Create a new Policy that returns [Action::Follow] if either self or other returns +Action::Follow. Read more
Source§

impl<T> Same for T

Source§

type Output = T

Should always be Self
Source§

impl<T, U> TryFrom<U> for T
where + U: Into<T>,

Source§

type Error = Infallible

The type returned in the event of a conversion error.
Source§

fn try_from(value: U) -> Result<T, <T as TryFrom<U>>::Error>

Performs the conversion.
Source§

impl<T, U> TryInto<U> for T
where + U: TryFrom<T>,

Source§

type Error = <U as TryFrom<T>>::Error

The type returned in the event of a conversion error.
Source§

fn try_into(self) -> Result<U, <U as TryFrom<T>>::Error>

Performs the conversion.
§

impl<V, T> VZip<V> for T
where + V: MultiLane<T>,

§

fn vzip(self) -> V

§

impl<T> WithSubscriber for T

§

fn with_subscriber<S>(self, subscriber: S) -> WithDispatch<Self>
where + S: Into<Dispatch>,

Attaches the provided Subscriber to this type, returning a +[WithDispatch] wrapper. Read more
§

fn with_current_subscriber(self) -> WithDispatch<Self>

Attaches the current default Subscriber to this type, returning a +[WithDispatch] wrapper. Read more
\ No newline at end of file diff --git a/api/jellytau_lib/commands/auth/struct.SessionVerifierWrapper.html b/api/jellytau_lib/commands/auth/struct.SessionVerifierWrapper.html new file mode 100644 index 00000000..5e5af3e9 --- /dev/null +++ b/api/jellytau_lib/commands/auth/struct.SessionVerifierWrapper.html @@ -0,0 +1,25 @@ +SessionVerifierWrapper in jellytau_lib::commands::auth - RustSkip to main content

SessionVerifierWrapper

Struct SessionVerifierWrapper 

Source
pub struct SessionVerifierWrapper(pub Arc<Mutex<Option<SessionVerifier>>>);
Expand description

Wrapper for SessionVerifier to manage in Tauri state

+

Tuple Fields§

§0: Arc<Mutex<Option<SessionVerifier>>>

Auto Trait Implementations§

Blanket Implementations§

Source§

impl<T> Any for T
where + T: 'static + ?Sized,

Source§

fn type_id(&self) -> TypeId

Gets the TypeId of self. Read more
Source§

impl<T> Borrow<T> for T
where + T: ?Sized,

Source§

fn borrow(&self) -> &T

Immutably borrows from an owned value. Read more
Source§

impl<T> BorrowMut<T> for T
where + T: ?Sized,

Source§

fn borrow_mut(&mut self) -> &mut T

Mutably borrows from an owned value. Read more
Source§

impl<T> From<T> for T

Source§

fn from(t: T) -> T

Returns the argument unchanged.

+
§

impl<T> Instrument for T

§

fn instrument(self, span: Span) -> Instrumented<Self>

Instruments this type with the provided [Span], returning an +Instrumented wrapper. Read more
§

fn in_current_span(self) -> Instrumented<Self>

Instruments this type with the current Span, returning an +Instrumented wrapper. Read more
Source§

impl<T, U> Into<U> for T
where + U: From<T>,

Source§

fn into(self) -> U

Calls U::from(self).

+

That is, this conversion is whatever the implementation of +From<T> for U chooses to do.

+
§

impl<T> PolicyExt for T
where + T: ?Sized,

§

fn and<P, B, E>(self, other: P) -> And<T, P>
where + T: Policy<B, E>, + P: Policy<B, E>,

Create a new Policy that returns [Action::Follow] only if self and other return +Action::Follow. Read more
§

fn or<P, B, E>(self, other: P) -> Or<T, P>
where + T: Policy<B, E>, + P: Policy<B, E>,

Create a new Policy that returns [Action::Follow] if either self or other returns +Action::Follow. Read more
Source§

impl<T> Same for T

Source§

type Output = T

Should always be Self
Source§

impl<T, U> TryFrom<U> for T
where + U: Into<T>,

Source§

type Error = Infallible

The type returned in the event of a conversion error.
Source§

fn try_from(value: U) -> Result<T, <T as TryFrom<U>>::Error>

Performs the conversion.
Source§

impl<T, U> TryInto<U> for T
where + U: TryFrom<T>,

Source§

type Error = <U as TryFrom<T>>::Error

The type returned in the event of a conversion error.
Source§

fn try_into(self) -> Result<U, <U as TryFrom<T>>::Error>

Performs the conversion.
§

impl<V, T> VZip<V> for T
where + V: MultiLane<T>,

§

fn vzip(self) -> V

§

impl<T> WithSubscriber for T

§

fn with_subscriber<S>(self, subscriber: S) -> WithDispatch<Self>
where + S: Into<Dispatch>,

Attaches the provided Subscriber to this type, returning a +[WithDispatch] wrapper. Read more
§

fn with_current_subscriber(self) -> WithDispatch<Self>

Attaches the current default Subscriber to this type, returning a +[WithDispatch] wrapper. Read more
\ No newline at end of file diff --git a/api/jellytau_lib/commands/catalog/constant.CATALOG_INDEX_EVENT.html b/api/jellytau_lib/commands/catalog/constant.CATALOG_INDEX_EVENT.html new file mode 100644 index 00000000..056d13b2 --- /dev/null +++ b/api/jellytau_lib/commands/catalog/constant.CATALOG_INDEX_EVENT.html @@ -0,0 +1,2 @@ +CATALOG_INDEX_EVENT in jellytau_lib::commands::catalog - RustSkip to main content

CATALOG_INDEX_EVENT

Constant CATALOG_INDEX_EVENT 

Source
pub const CATALOG_INDEX_EVENT: &str = "catalog-index-event";
Expand description

Kebab-case, per the project’s event convention.

+
\ No newline at end of file diff --git a/api/jellytau_lib/commands/catalog/constant.CATALOG_INDEX_FIRST_CHECK.html b/api/jellytau_lib/commands/catalog/constant.CATALOG_INDEX_FIRST_CHECK.html new file mode 100644 index 00000000..77bb76a4 --- /dev/null +++ b/api/jellytau_lib/commands/catalog/constant.CATALOG_INDEX_FIRST_CHECK.html @@ -0,0 +1,5 @@ +CATALOG_INDEX_FIRST_CHECK in jellytau_lib::commands::catalog - RustSkip to main content

CATALOG_INDEX_FIRST_CHECK

Constant CATALOG_INDEX_FIRST_CHECK 

Source
const CATALOG_INDEX_FIRST_CHECK: Duration;
Expand description

Delay before the first staleness check, to let sign-in complete and the +repository be registered. Without it the first check runs against an empty +repository manager and a fresh install would sit unindexed until the next +tick.

+
\ No newline at end of file diff --git a/api/jellytau_lib/commands/catalog/constant.CATALOG_INDEX_TICK.html b/api/jellytau_lib/commands/catalog/constant.CATALOG_INDEX_TICK.html new file mode 100644 index 00000000..478dfc02 --- /dev/null +++ b/api/jellytau_lib/commands/catalog/constant.CATALOG_INDEX_TICK.html @@ -0,0 +1,6 @@ +CATALOG_INDEX_TICK in jellytau_lib::commands::catalog - RustSkip to main content

CATALOG_INDEX_TICK

Constant CATALOG_INDEX_TICK 

Source
const CATALOG_INDEX_TICK: Duration;
Expand description

How often the scheduler wakes to check staleness. Far shorter than the TTL +because a tick is nearly free — one indexed app_settings lookup — and it is +what makes the indexer responsive to events it cannot subscribe to: signing +in, and coming back online. The TTL, not the tick, decides whether a crawl +actually happens.

+
\ No newline at end of file diff --git a/api/jellytau_lib/commands/catalog/constant.CATALOG_INDEX_TTL.html b/api/jellytau_lib/commands/catalog/constant.CATALOG_INDEX_TTL.html new file mode 100644 index 00000000..444f4a16 --- /dev/null +++ b/api/jellytau_lib/commands/catalog/constant.CATALOG_INDEX_TTL.html @@ -0,0 +1,7 @@ +CATALOG_INDEX_TTL in jellytau_lib::commands::catalog - RustSkip to main content

CATALOG_INDEX_TTL

Constant CATALOG_INDEX_TTL 

Source
const CATALOG_INDEX_TTL: Duration;
Expand description

How long an index stays fresh before a re-index is due.

+

This lives in Rust rather than being a frontend constant because it decides +whether the local cache is authoritative — the same class of decision as +include_catalog_browse, and squarely the “sync policy” the spec review +checklist keeps out of the presentation layer. If it later becomes +user-configurable it stays a Rust-owned setting edited through a command.

+
\ No newline at end of file diff --git a/api/jellytau_lib/commands/catalog/constant.CATALOG_ITEM_TYPES.html b/api/jellytau_lib/commands/catalog/constant.CATALOG_ITEM_TYPES.html new file mode 100644 index 00000000..d727e571 --- /dev/null +++ b/api/jellytau_lib/commands/catalog/constant.CATALOG_ITEM_TYPES.html @@ -0,0 +1,9 @@ +CATALOG_ITEM_TYPES in jellytau_lib::commands::catalog - RustSkip to main content

CATALOG_ITEM_TYPES

Constant CATALOG_ITEM_TYPES 

Source
const CATALOG_ITEM_TYPES: &[&str];
Expand description

Item types worth caching for offline browsing: containers the library +landing pages render plus the playable leaves users queue for download. +MusicArtist and Playlist are here because search groups results by them +(UR-060’s Artists group). Without them in the crawl, the local index can +never answer an artist query and those groups can only ever be filled by the +server leg. Keep this in step with what prune_stale_catalog is allowed to +sweep — the crawl is only authoritative for the types it asks for.

+

TRACES: UR-065, UR-060 | DR-111

+
\ No newline at end of file diff --git a/api/jellytau_lib/commands/catalog/constant.LAST_CATALOG_SYNC_KEY.html b/api/jellytau_lib/commands/catalog/constant.LAST_CATALOG_SYNC_KEY.html new file mode 100644 index 00000000..bd09f1d6 --- /dev/null +++ b/api/jellytau_lib/commands/catalog/constant.LAST_CATALOG_SYNC_KEY.html @@ -0,0 +1,3 @@ +LAST_CATALOG_SYNC_KEY in jellytau_lib::commands::catalog - RustSkip to main content

LAST_CATALOG_SYNC_KEY

Constant LAST_CATALOG_SYNC_KEY 

Source
const LAST_CATALOG_SYNC_KEY: &str = "last_catalog_sync";
Expand description

app_settings key holding the RFC-3339 timestamp of the last successful +full-catalog sync.

+
\ No newline at end of file diff --git a/api/jellytau_lib/commands/catalog/constant.VIDEO_ITEM_TYPES.html b/api/jellytau_lib/commands/catalog/constant.VIDEO_ITEM_TYPES.html new file mode 100644 index 00000000..f5df0089 --- /dev/null +++ b/api/jellytau_lib/commands/catalog/constant.VIDEO_ITEM_TYPES.html @@ -0,0 +1,6 @@ +VIDEO_ITEM_TYPES in jellytau_lib::commands::catalog - RustSkip to main content

VIDEO_ITEM_TYPES

Constant VIDEO_ITEM_TYPES 

Source
const VIDEO_ITEM_TYPES: &[&str];
Expand description

Jellyfin item types whose download is a video stream rather than an audio +one. The download queue stores an opaque media_type (‘audio’/‘video’); this +is where the taxonomy that produces it lives, so the frontend never has to +know which item types are video.

+

TRACES: UR-071 | DR-135

+
\ No newline at end of file diff --git a/api/jellytau_lib/commands/catalog/fn.catalog_sync_status.html b/api/jellytau_lib/commands/catalog/fn.catalog_sync_status.html new file mode 100644 index 00000000..17e59f1a --- /dev/null +++ b/api/jellytau_lib/commands/catalog/fn.catalog_sync_status.html @@ -0,0 +1,5 @@ +catalog_sync_status in jellytau_lib::commands::catalog - RustSkip to main content

catalog_sync_status

Function catalog_sync_status 

Source
pub async fn catalog_sync_status(
+    db: State<'_, DatabaseWrapper>,
+) -> Result<CatalogSyncStatus, String>
Expand description

Report the last-synced timestamp so the UI can show a hint / decide whether +to trigger a fresh sync.

+
\ No newline at end of file diff --git a/api/jellytau_lib/commands/catalog/fn.index_is_due.html b/api/jellytau_lib/commands/catalog/fn.index_is_due.html new file mode 100644 index 00000000..d8d3a25c --- /dev/null +++ b/api/jellytau_lib/commands/catalog/fn.index_is_due.html @@ -0,0 +1,10 @@ +index_is_due in jellytau_lib::commands::catalog - RustSkip to main content

index_is_due

Function index_is_due 

Source
pub(crate) fn index_is_due(
+    last_synced_at: Option<&str>,
+    now: DateTime<Utc>,
+    ttl: Duration,
+) -> bool
Expand description

Whether an index pass is due, given when one last completed.

+

Pure so the policy is unit-testable without a clock, a server, or a database. +None (never indexed) and an unparseable stored value both mean “due” — a +corrupt timestamp should trigger a re-index, not silently freeze the catalog.

+

TRACES: UR-065 | DR-109 | UT-115

+
\ No newline at end of file diff --git a/api/jellytau_lib/commands/catalog/fn.maybe_run_scheduled_pass.html b/api/jellytau_lib/commands/catalog/fn.maybe_run_scheduled_pass.html new file mode 100644 index 00000000..cd534fd7 --- /dev/null +++ b/api/jellytau_lib/commands/catalog/fn.maybe_run_scheduled_pass.html @@ -0,0 +1,2 @@ +maybe_run_scheduled_pass in jellytau_lib::commands::catalog - RustSkip to main content

maybe_run_scheduled_pass

Function maybe_run_scheduled_pass 

Source
async fn maybe_run_scheduled_pass(app: &AppHandle) -> Result<(), String>
Expand description

One scheduler tick: check the preconditions, then index if due.

+
\ No newline at end of file diff --git a/api/jellytau_lib/commands/catalog/fn.read_last_sync.html b/api/jellytau_lib/commands/catalog/fn.read_last_sync.html new file mode 100644 index 00000000..b8ea9071 --- /dev/null +++ b/api/jellytau_lib/commands/catalog/fn.read_last_sync.html @@ -0,0 +1,2 @@ +read_last_sync in jellytau_lib::commands::catalog - RustSkip to main content

read_last_sync

Function read_last_sync 

Source
async fn read_last_sync(db_service: &Arc<RusqliteService>) -> Option<String>
Expand description

Read the last-sync timestamp straight from app_settings.

+
\ No newline at end of file diff --git a/api/jellytau_lib/commands/catalog/fn.requeue_mistyped_video_downloads.html b/api/jellytau_lib/commands/catalog/fn.requeue_mistyped_video_downloads.html new file mode 100644 index 00000000..9ae874d5 --- /dev/null +++ b/api/jellytau_lib/commands/catalog/fn.requeue_mistyped_video_downloads.html @@ -0,0 +1,15 @@ +requeue_mistyped_video_downloads in jellytau_lib::commands::catalog - RustSkip to main content

requeue_mistyped_video_downloads

Function requeue_mistyped_video_downloads 

Source
pub(crate) async fn requeue_mistyped_video_downloads(
+    db_service: &Arc<RusqliteService>,
+) -> Result<usize, String>
Expand description

Requeue video downloads that were fetched as audio.

+

Before resolve_pending_download_urls consulted the item’s type, a row +with no media_type — which is every row queued from a media card, since +download_item does not record one — resolved against +get_audio_stream_url. A movie queued that way completed with an audio-only +transcode on disk, so playing it offline could only ever fail. Those rows are +identifiable after the fact (no media_type, but a video item), so reset them +to pending with no URL and let the resolver fetch the real video.

+

Rows carrying an explicit media_type were resolved correctly and are left +alone, as are genuine audio downloads.

+

Returns the number of rows requeued.

+

TRACES: UR-071 | DR-136 | UT-126

+
\ No newline at end of file diff --git a/api/jellytau_lib/commands/catalog/fn.resolve_pending_download_urls.html b/api/jellytau_lib/commands/catalog/fn.resolve_pending_download_urls.html new file mode 100644 index 00000000..7e3b39ba --- /dev/null +++ b/api/jellytau_lib/commands/catalog/fn.resolve_pending_download_urls.html @@ -0,0 +1,16 @@ +resolve_pending_download_urls in jellytau_lib::commands::catalog - RustSkip to main content

resolve_pending_download_urls

Function resolve_pending_download_urls 

Source
pub(crate) async fn resolve_pending_download_urls<F, Fut>(
+    db_service: &Arc<RusqliteService>,
+    target_dir: &str,
+    only_ids: Option<&[i64]>,
+    resolve: F,
+) -> Result<ResumeQueuedResult, String>
where + F: Fn(String, String, String) -> Fut, + Fut: Future<Output = Option<String>>,
Expand description

Core of resume_queued_downloads, factored out for testing: select every +pending/stream_url IS NULL row, resolve each via resolve (returning +None leaves the row pending), and heal the row so the pump can start it. +The resolve closure receives (item_id, media_type, quality_preset).

+

only_ids restricts the sweep to specific download rows. Reconnect passes +None and heals everything; a bulk enqueue (an album, say) passes the rows +it just created, so clicking download on one album cannot also start every +unrelated row that has been sitting pending.

+
\ No newline at end of file diff --git a/api/jellytau_lib/commands/catalog/fn.resume_queued_downloads.html b/api/jellytau_lib/commands/catalog/fn.resume_queued_downloads.html new file mode 100644 index 00000000..8752ac62 --- /dev/null +++ b/api/jellytau_lib/commands/catalog/fn.resume_queued_downloads.html @@ -0,0 +1,14 @@ +resume_queued_downloads in jellytau_lib::commands::catalog - RustSkip to main content

resume_queued_downloads

Function resume_queued_downloads 

Source
pub async fn resume_queued_downloads(
+    repository: State<'_, RepositoryManagerWrapper>,
+    db: State<'_, DatabaseWrapper>,
+    download_manager: State<'_, DownloadManagerWrapper>,
+    app: AppHandle,
+    handle: String,
+) -> Result<ResumeQueuedResult, String>
Expand description

Resolve the stream URL for every download row that was queued while offline +(status = 'pending' AND stream_url IS NULL), then pump the queue so they +start. Call this on reconnect.

+

Audio rows resolve via get_audio_stream_url; video rows (media_type = +‘video’) via the pure get_video_download_url builder using the row’s stored +quality_preset — mirroring enqueue_video_downloads. Rows whose URL can’t +be resolved are left pending (they retry on the next reconnect).

+
\ No newline at end of file diff --git a/api/jellytau_lib/commands/catalog/fn.run_index_pass.html b/api/jellytau_lib/commands/catalog/fn.run_index_pass.html new file mode 100644 index 00000000..c02a8bc1 --- /dev/null +++ b/api/jellytau_lib/commands/catalog/fn.run_index_pass.html @@ -0,0 +1,8 @@ +run_index_pass in jellytau_lib::commands::catalog - RustSkip to main content

run_index_pass

Function run_index_pass 

Source
pub(crate) async fn run_index_pass(
+    repo: Arc<HybridRepository>,
+    db_service: Arc<RusqliteService>,
+) -> Result<CatalogSyncResult, String>
Expand description

One full-catalog indexing pass, shared by the sync_full_catalog command +and the background scheduler (DR-109) so there is exactly one implementation +and one concurrency guard.

+

TRACES: UR-065 | DR-109, DR-110

+
\ No newline at end of file diff --git a/api/jellytau_lib/commands/catalog/fn.set_show_server_catalog.html b/api/jellytau_lib/commands/catalog/fn.set_show_server_catalog.html new file mode 100644 index 00000000..56643ff6 --- /dev/null +++ b/api/jellytau_lib/commands/catalog/fn.set_show_server_catalog.html @@ -0,0 +1,8 @@ +set_show_server_catalog in jellytau_lib::commands::catalog - RustSkip to main content

set_show_server_catalog

Function set_show_server_catalog 

Source
pub fn set_show_server_catalog(show: bool)
Expand description

Control whether offline library queries reveal the full synced catalog +(greyed-out, non-downloaded media) or only downloaded/local media.

+

The frontend calls this from the “Show all server media” toggle: pass true +when online, or when offline with the toggle on; pass false when offline +with the toggle off so library pages show downloaded media only. Fixes the +bug where offline library pages showed every server item regardless of the +toggle.

+
\ No newline at end of file diff --git a/api/jellytau_lib/commands/catalog/fn.spawn_catalog_indexer.html b/api/jellytau_lib/commands/catalog/fn.spawn_catalog_indexer.html new file mode 100644 index 00000000..25b84206 --- /dev/null +++ b/api/jellytau_lib/commands/catalog/fn.spawn_catalog_indexer.html @@ -0,0 +1,9 @@ +spawn_catalog_indexer in jellytau_lib::commands::catalog - RustSkip to main content

spawn_catalog_indexer

Function spawn_catalog_indexer 

Source
pub fn spawn_catalog_indexer(app: AppHandle)
Expand description

Start the background catalog indexer.

+

Replaces the frontend’s startup-only syncCatalog() call: index freshness is +sync policy and belongs in Rust (see the layer assignment in +docs/architecture/03-data-flow.md, “Search Flow”). Ticks every +CATALOG_INDEX_TICK and +runs a pass when a repository exists, the server is reachable, and the index +is older than CATALOG_INDEX_TTL.

+

TRACES: UR-065 | DR-109, IR-030

+
\ No newline at end of file diff --git a/api/jellytau_lib/commands/catalog/fn.sync_full_catalog.html b/api/jellytau_lib/commands/catalog/fn.sync_full_catalog.html new file mode 100644 index 00000000..d514ba7b --- /dev/null +++ b/api/jellytau_lib/commands/catalog/fn.sync_full_catalog.html @@ -0,0 +1,11 @@ +sync_full_catalog in jellytau_lib::commands::catalog - RustSkip to main content

sync_full_catalog

Function sync_full_catalog 

Source
pub async fn sync_full_catalog(
+    repository: State<'_, RepositoryManagerWrapper>,
+    db: State<'_, DatabaseWrapper>,
+    handle: String,
+) -> Result<CatalogSyncResult, String>
Expand description

Walk every library on the server and persist all items to the offline cache +so the full catalog is browsable offline (greyed out when not downloaded).

+

Best-effort: a library that fails to fetch is counted and skipped rather than +aborting the whole sync. Runs libraries sequentially to avoid hammering the +server. Uses Recursive=true so a single request per library returns the +containers and their playable children.

+
\ No newline at end of file diff --git a/api/jellytau_lib/commands/catalog/index.html b/api/jellytau_lib/commands/catalog/index.html new file mode 100644 index 00000000..f5887789 --- /dev/null +++ b/api/jellytau_lib/commands/catalog/index.html @@ -0,0 +1,46 @@ +jellytau_lib::commands::catalog - RustSkip to main content

Module catalog

Module catalog 

Source
Expand description

Tauri commands for the offline “browse & queue” feature.

+

TRACES: UR-002, UR-007, UR-024 | JA-004, JA-016 | DR-012, DR-027

+

Two backend pieces support browsing the full server catalog while offline +and queueing downloads that fire on reconnect:

+
    +
  • sync_full_catalog walks every library while online and persists all +items to the offline cache so the whole catalog is browsable (greyed out) +offline. It reuses [HybridRepository::cache_items_from_server], which in +turn reuses OfflineRepository::save_to_cache (sets synced_at, which is +what get_items branch 3 serves offline).
  • +
  • resume_queued_downloads resolves and pumps the pending download rows +that were queued offline (they have stream_url IS NULL), mirroring the +heal-and-pump pattern in player_preload_upcoming.
  • +
+

Structs§

CatalogIndexEvent
Progress of a background index pass, for the staleness hint in the UI.
CatalogSyncResult
CatalogSyncStatus
IndexPassGuard 🔒
Clears INDEX_IN_PROGRESS however the pass leaves — including on the ? +early return when get_libraries fails, which a plain store at the end of +the function would leak.
ResumeQueuedResult

Constants§

CATALOG_INDEX_EVENT
Kebab-case, per the project’s event convention.
CATALOG_INDEX_FIRST_CHECK 🔒
Delay before the first staleness check, to let sign-in complete and the +repository be registered. Without it the first check runs against an empty +repository manager and a fresh install would sit unindexed until the next +tick.
CATALOG_INDEX_TICK 🔒
How often the scheduler wakes to check staleness. Far shorter than the TTL +because a tick is nearly free — one indexed app_settings lookup — and it is +what makes the indexer responsive to events it cannot subscribe to: signing +in, and coming back online. The TTL, not the tick, decides whether a crawl +actually happens.
CATALOG_INDEX_TTL 🔒
How long an index stays fresh before a re-index is due.
CATALOG_ITEM_TYPES 🔒
Item types worth caching for offline browsing: containers the library +landing pages render plus the playable leaves users queue for download. +MusicArtist and Playlist are here because search groups results by them +(UR-060’s Artists group). Without them in the crawl, the local index can +never answer an artist query and those groups can only ever be filled by the +server leg. Keep this in step with what prune_stale_catalog is allowed to +sweep — the crawl is only authoritative for the types it asks for.
LAST_CATALOG_SYNC_KEY 🔒
app_settings key holding the RFC-3339 timestamp of the last successful +full-catalog sync.
VIDEO_ITEM_TYPES 🔒
Jellyfin item types whose download is a video stream rather than an audio +one. The download queue stores an opaque media_type (‘audio’/‘video’); this +is where the taxonomy that produces it lives, so the frontend never has to +know which item types are video.

Statics§

INDEX_IN_PROGRESS 🔒
Guards against two passes running at once. Replaces the frontend’s +syncInProgress boolean in offlineCatalog.ts, which could not see a pass +started by the scheduler.

Functions§

catalog_sync_status
Report the last-synced timestamp so the UI can show a hint / decide whether +to trigger a fresh sync.
index_is_due 🔒
Whether an index pass is due, given when one last completed.
maybe_run_scheduled_pass 🔒
One scheduler tick: check the preconditions, then index if due.
read_last_sync 🔒
Read the last-sync timestamp straight from app_settings.
requeue_mistyped_video_downloads 🔒
Requeue video downloads that were fetched as audio.
resolve_pending_download_urls 🔒
Core of resume_queued_downloads, factored out for testing: select every +pending/stream_url IS NULL row, resolve each via resolve (returning +None leaves the row pending), and heal the row so the pump can start it. +The resolve closure receives (item_id, media_type, quality_preset).
resume_queued_downloads
Resolve the stream URL for every download row that was queued while offline +(status = 'pending' AND stream_url IS NULL), then pump the queue so they +start. Call this on reconnect.
run_index_pass 🔒
One full-catalog indexing pass, shared by the sync_full_catalog command +and the background scheduler (DR-109) so there is exactly one implementation +and one concurrency guard.
set_show_server_catalog
Control whether offline library queries reveal the full synced catalog +(greyed-out, non-downloaded media) or only downloaded/local media.
spawn_catalog_indexer
Start the background catalog indexer.
sync_full_catalog
Walk every library on the server and persist all items to the offline cache +so the full catalog is browsable offline (greyed out when not downloaded).
\ No newline at end of file diff --git a/api/jellytau_lib/commands/catalog/sidebar-items.js b/api/jellytau_lib/commands/catalog/sidebar-items.js new file mode 100644 index 00000000..69ac9e02 --- /dev/null +++ b/api/jellytau_lib/commands/catalog/sidebar-items.js @@ -0,0 +1 @@ +window.SIDEBAR_ITEMS = {"constant":["CATALOG_INDEX_EVENT","CATALOG_INDEX_FIRST_CHECK","CATALOG_INDEX_TICK","CATALOG_INDEX_TTL","CATALOG_ITEM_TYPES","LAST_CATALOG_SYNC_KEY","VIDEO_ITEM_TYPES"],"fn":["catalog_sync_status","index_is_due","maybe_run_scheduled_pass","read_last_sync","requeue_mistyped_video_downloads","resolve_pending_download_urls","resume_queued_downloads","run_index_pass","set_show_server_catalog","spawn_catalog_indexer","sync_full_catalog"],"static":["INDEX_IN_PROGRESS"],"struct":["CatalogIndexEvent","CatalogSyncResult","CatalogSyncStatus","IndexPassGuard","ResumeQueuedResult"]}; \ No newline at end of file diff --git a/api/jellytau_lib/commands/catalog/static.INDEX_IN_PROGRESS.html b/api/jellytau_lib/commands/catalog/static.INDEX_IN_PROGRESS.html new file mode 100644 index 00000000..ab284f78 --- /dev/null +++ b/api/jellytau_lib/commands/catalog/static.INDEX_IN_PROGRESS.html @@ -0,0 +1,4 @@ +INDEX_IN_PROGRESS in jellytau_lib::commands::catalog - RustSkip to main content

INDEX_IN_PROGRESS

Static INDEX_IN_PROGRESS 

Source
static INDEX_IN_PROGRESS: AtomicBool
Expand description

Guards against two passes running at once. Replaces the frontend’s +syncInProgress boolean in offlineCatalog.ts, which could not see a pass +started by the scheduler.

+
\ No newline at end of file diff --git a/api/jellytau_lib/commands/catalog/struct.CatalogIndexEvent.html b/api/jellytau_lib/commands/catalog/struct.CatalogIndexEvent.html new file mode 100644 index 00000000..b989feec --- /dev/null +++ b/api/jellytau_lib/commands/catalog/struct.CatalogIndexEvent.html @@ -0,0 +1,58 @@ +CatalogIndexEvent in jellytau_lib::commands::catalog - RustSkip to main content

CatalogIndexEvent

Struct CatalogIndexEvent 

Source
pub struct CatalogIndexEvent {
+    pub state: String,
+    pub items_cached: usize,
+    pub items_pruned: usize,
+    pub libraries_failed: usize,
+    pub error: Option<String>,
+}
Expand description

Progress of a background index pass, for the staleness hint in the UI.

+

Fields§

§state: String

started | finished | failed

+
§items_cached: usize§items_pruned: usize§libraries_failed: usize§error: Option<String>

Present on failed.

+

Trait Implementations§

Source§

impl Clone for CatalogIndexEvent

Source§

fn clone(&self) -> CatalogIndexEvent

Returns a duplicate of the value. Read more
1.0.0 (const: unstable) · Source§

fn clone_from(&mut self, source: &Self)

Performs copy-assignment from source. Read more
Source§

impl Debug for CatalogIndexEvent

Source§

fn fmt(&self, f: &mut Formatter<'_>) -> Result

Formats the value using the given formatter. Read more
Source§

impl<'de> Deserialize<'de> for CatalogIndexEvent

Source§

fn deserialize<__D>(__deserializer: __D) -> Result<Self, __D::Error>
where + __D: Deserializer<'de>,

Deserialize this value from the given Serde deserializer. Read more
Source§

impl NamedType for CatalogIndexEvent

Source§

fn sid() -> SpectaID

Source§

fn named_data_type( + type_map: &mut TypeCollection, + generics: &[DataType], +) -> NamedDataType

this is equivalent to [Type::inline] but returns a [NamedDataType] instead.
Source§

fn definition_named_data_type(type_map: &mut TypeCollection) -> NamedDataType

this is equivalent to [Type::definition] but returns a [NamedDataType] instead.
Source§

impl Serialize for CatalogIndexEvent

Source§

fn serialize<__S>(&self, __serializer: __S) -> Result<__S::Ok, __S::Error>
where + __S: Serializer,

Serialize this value into the given Serde serializer. Read more
Source§

impl Type for CatalogIndexEvent

Source§

fn inline(type_map: &mut TypeCollection, generics: Generics<'_>) -> DataType

Returns the definition of a type using the provided generics. Read more
Source§

fn reference(type_map: &mut TypeCollection, generics: &[DataType]) -> Reference

Generates a datatype corresponding to a reference to this type, +as determined by its category. Getting a reference to a type implies that +it should belong in the type map (since it has to be referenced from somewhere), +so the output of definition will be put into the type map.
Source§

impl Flatten for CatalogIndexEvent

Auto Trait Implementations§

Blanket Implementations§

Source§

impl<T> Any for T
where + T: 'static + ?Sized,

Source§

fn type_id(&self) -> TypeId

Gets the TypeId of self. Read more
Source§

impl<T> Borrow<T> for T
where + T: ?Sized,

Source§

fn borrow(&self) -> &T

Immutably borrows from an owned value. Read more
Source§

impl<T> BorrowMut<T> for T
where + T: ?Sized,

Source§

fn borrow_mut(&mut self) -> &mut T

Mutably borrows from an owned value. Read more
Source§

impl<T> CloneToUninit for T
where + T: Clone,

Source§

unsafe fn clone_to_uninit(&self, dest: *mut u8)

🔬This is a nightly-only experimental API. (clone_to_uninit)
Performs copy-assignment from self to dest. Read more
§

impl<'de, D, R> CommandArg<'de, R> for D
where + D: Deserialize<'de>, + R: Runtime,

§

fn from_command(command: CommandItem<'de, R>) -> Result<D, InvokeError>

Derives an instance of Self from the [CommandItem]. Read more
Source§

impl<T> From<T> for T

Source§

fn from(t: T) -> T

Returns the argument unchanged.

+
§

impl<T> FunctionArg for T
where + T: Type,

§

fn to_datatype(type_map: &mut TypeCollection) -> Option<DataType>

Gets the type of an argument as a [DataType]. Read more
§

impl<T> Instrument for T

§

fn instrument(self, span: Span) -> Instrumented<Self>

Instruments this type with the provided [Span], returning an +Instrumented wrapper. Read more
§

fn in_current_span(self) -> Instrumented<Self>

Instruments this type with the current Span, returning an +Instrumented wrapper. Read more
Source§

impl<T, U> Into<U> for T
where + U: From<T>,

Source§

fn into(self) -> U

Calls U::from(self).

+

That is, this conversion is whatever the implementation of +From<T> for U chooses to do.

+
§

impl<T> IpcResponse for T
where + T: Serialize,

§

fn body(self) -> Result<InvokeResponseBody, Error>

Resolve the IPC response body.
§

impl<T> PolicyExt for T
where + T: ?Sized,

§

fn and<P, B, E>(self, other: P) -> And<T, P>
where + T: Policy<B, E>, + P: Policy<B, E>,

Create a new Policy that returns [Action::Follow] only if self and other return +Action::Follow. Read more
§

fn or<P, B, E>(self, other: P) -> Or<T, P>
where + T: Policy<B, E>, + P: Policy<B, E>,

Create a new Policy that returns [Action::Follow] if either self or other returns +Action::Follow. Read more
Source§

impl<T> Same for T

Source§

type Output = T

Should always be Self
§

impl<T> ScopeObject for T
where + T: Send + Sync + Debug + DeserializeOwned + 'static,

§

type Error = Error

The error type.
§

fn deserialize<R>( + _app: &AppHandle<R>, + raw: Value, +) -> Result<T, <T as ScopeObject>::Error>
where + R: Runtime,

Deserialize the raw scope value.
Source§

impl<T> Serialize for T
where + T: Serialize + ?Sized,

Source§

fn erased_serialize(&self, serializer: &mut dyn Serializer) -> Result<(), Error>

Source§

fn do_erased_serialize( + &self, + serializer: &mut dyn Serializer, +) -> Result<(), ErrorImpl>

Source§

impl<T> ToOwned for T
where + T: Clone,

Source§

type Owned = T

The resulting type after obtaining ownership.
Source§

fn to_owned(&self) -> T

Creates owned data from borrowed data, usually by cloning. Read more
Source§

fn clone_into(&self, target: &mut T)

Uses borrowed data to replace owned data, usually by cloning. Read more
Source§

impl<T, U> TryFrom<U> for T
where + U: Into<T>,

Source§

type Error = Infallible

The type returned in the event of a conversion error.
Source§

fn try_from(value: U) -> Result<T, <T as TryFrom<U>>::Error>

Performs the conversion.
Source§

impl<T, U> TryInto<U> for T
where + U: TryFrom<T>,

Source§

type Error = <U as TryFrom<T>>::Error

The type returned in the event of a conversion error.
Source§

fn try_into(self) -> Result<U, <U as TryFrom<T>>::Error>

Performs the conversion.
§

impl<V, T> VZip<V> for T
where + V: MultiLane<T>,

§

fn vzip(self) -> V

§

impl<T> WithSubscriber for T

§

fn with_subscriber<S>(self, subscriber: S) -> WithDispatch<Self>
where + S: Into<Dispatch>,

Attaches the provided Subscriber to this type, returning a +[WithDispatch] wrapper. Read more
§

fn with_current_subscriber(self) -> WithDispatch<Self>

Attaches the current default Subscriber to this type, returning a +[WithDispatch] wrapper. Read more
Source§

impl<T> DeserializeOwned for T
where + T: for<'de> Deserialize<'de>,

§

impl<T> UserEvent for T
where + T: Debug + Clone + Send + 'static,

\ No newline at end of file diff --git a/api/jellytau_lib/commands/catalog/struct.CatalogSyncResult.html b/api/jellytau_lib/commands/catalog/struct.CatalogSyncResult.html new file mode 100644 index 00000000..ee2e4792 --- /dev/null +++ b/api/jellytau_lib/commands/catalog/struct.CatalogSyncResult.html @@ -0,0 +1,57 @@ +CatalogSyncResult in jellytau_lib::commands::catalog - RustSkip to main content

CatalogSyncResult

Struct CatalogSyncResult 

Source
pub struct CatalogSyncResult {
+    pub items_cached: usize,
+    pub libraries_failed: usize,
+    pub items_pruned: usize,
+}

Fields§

§items_cached: usize

Total items persisted to the offline cache across all libraries.

+
§libraries_failed: usize

Libraries that failed to sync (e.g. server hiccup); best-effort.

+
§items_pruned: usize

Entries removed because the server no longer has them. Always 0 when any +library failed, since a partial crawl cannot prove an item is gone.

+

Trait Implementations§

Source§

impl Clone for CatalogSyncResult

Source§

fn clone(&self) -> CatalogSyncResult

Returns a duplicate of the value. Read more
1.0.0 (const: unstable) · Source§

fn clone_from(&mut self, source: &Self)

Performs copy-assignment from source. Read more
Source§

impl Debug for CatalogSyncResult

Source§

fn fmt(&self, f: &mut Formatter<'_>) -> Result

Formats the value using the given formatter. Read more
Source§

impl<'de> Deserialize<'de> for CatalogSyncResult

Source§

fn deserialize<__D>(__deserializer: __D) -> Result<Self, __D::Error>
where + __D: Deserializer<'de>,

Deserialize this value from the given Serde deserializer. Read more
Source§

impl NamedType for CatalogSyncResult

Source§

fn sid() -> SpectaID

Source§

fn named_data_type( + type_map: &mut TypeCollection, + generics: &[DataType], +) -> NamedDataType

this is equivalent to [Type::inline] but returns a [NamedDataType] instead.
Source§

fn definition_named_data_type(type_map: &mut TypeCollection) -> NamedDataType

this is equivalent to [Type::definition] but returns a [NamedDataType] instead.
Source§

impl Serialize for CatalogSyncResult

Source§

fn serialize<__S>(&self, __serializer: __S) -> Result<__S::Ok, __S::Error>
where + __S: Serializer,

Serialize this value into the given Serde serializer. Read more
Source§

impl Type for CatalogSyncResult

Source§

fn inline(type_map: &mut TypeCollection, generics: Generics<'_>) -> DataType

Returns the definition of a type using the provided generics. Read more
Source§

fn reference(type_map: &mut TypeCollection, generics: &[DataType]) -> Reference

Generates a datatype corresponding to a reference to this type, +as determined by its category. Getting a reference to a type implies that +it should belong in the type map (since it has to be referenced from somewhere), +so the output of definition will be put into the type map.
Source§

impl Flatten for CatalogSyncResult

Auto Trait Implementations§

Blanket Implementations§

Source§

impl<T> Any for T
where + T: 'static + ?Sized,

Source§

fn type_id(&self) -> TypeId

Gets the TypeId of self. Read more
Source§

impl<T> Borrow<T> for T
where + T: ?Sized,

Source§

fn borrow(&self) -> &T

Immutably borrows from an owned value. Read more
Source§

impl<T> BorrowMut<T> for T
where + T: ?Sized,

Source§

fn borrow_mut(&mut self) -> &mut T

Mutably borrows from an owned value. Read more
Source§

impl<T> CloneToUninit for T
where + T: Clone,

Source§

unsafe fn clone_to_uninit(&self, dest: *mut u8)

🔬This is a nightly-only experimental API. (clone_to_uninit)
Performs copy-assignment from self to dest. Read more
§

impl<'de, D, R> CommandArg<'de, R> for D
where + D: Deserialize<'de>, + R: Runtime,

§

fn from_command(command: CommandItem<'de, R>) -> Result<D, InvokeError>

Derives an instance of Self from the [CommandItem]. Read more
Source§

impl<T> From<T> for T

Source§

fn from(t: T) -> T

Returns the argument unchanged.

+
§

impl<T> FunctionArg for T
where + T: Type,

§

fn to_datatype(type_map: &mut TypeCollection) -> Option<DataType>

Gets the type of an argument as a [DataType]. Read more
§

impl<T> Instrument for T

§

fn instrument(self, span: Span) -> Instrumented<Self>

Instruments this type with the provided [Span], returning an +Instrumented wrapper. Read more
§

fn in_current_span(self) -> Instrumented<Self>

Instruments this type with the current Span, returning an +Instrumented wrapper. Read more
Source§

impl<T, U> Into<U> for T
where + U: From<T>,

Source§

fn into(self) -> U

Calls U::from(self).

+

That is, this conversion is whatever the implementation of +From<T> for U chooses to do.

+
§

impl<T> IpcResponse for T
where + T: Serialize,

§

fn body(self) -> Result<InvokeResponseBody, Error>

Resolve the IPC response body.
§

impl<T> PolicyExt for T
where + T: ?Sized,

§

fn and<P, B, E>(self, other: P) -> And<T, P>
where + T: Policy<B, E>, + P: Policy<B, E>,

Create a new Policy that returns [Action::Follow] only if self and other return +Action::Follow. Read more
§

fn or<P, B, E>(self, other: P) -> Or<T, P>
where + T: Policy<B, E>, + P: Policy<B, E>,

Create a new Policy that returns [Action::Follow] if either self or other returns +Action::Follow. Read more
Source§

impl<T> Same for T

Source§

type Output = T

Should always be Self
§

impl<T> ScopeObject for T
where + T: Send + Sync + Debug + DeserializeOwned + 'static,

§

type Error = Error

The error type.
§

fn deserialize<R>( + _app: &AppHandle<R>, + raw: Value, +) -> Result<T, <T as ScopeObject>::Error>
where + R: Runtime,

Deserialize the raw scope value.
Source§

impl<T> Serialize for T
where + T: Serialize + ?Sized,

Source§

fn erased_serialize(&self, serializer: &mut dyn Serializer) -> Result<(), Error>

Source§

fn do_erased_serialize( + &self, + serializer: &mut dyn Serializer, +) -> Result<(), ErrorImpl>

Source§

impl<T> ToOwned for T
where + T: Clone,

Source§

type Owned = T

The resulting type after obtaining ownership.
Source§

fn to_owned(&self) -> T

Creates owned data from borrowed data, usually by cloning. Read more
Source§

fn clone_into(&self, target: &mut T)

Uses borrowed data to replace owned data, usually by cloning. Read more
Source§

impl<T, U> TryFrom<U> for T
where + U: Into<T>,

Source§

type Error = Infallible

The type returned in the event of a conversion error.
Source§

fn try_from(value: U) -> Result<T, <T as TryFrom<U>>::Error>

Performs the conversion.
Source§

impl<T, U> TryInto<U> for T
where + U: TryFrom<T>,

Source§

type Error = <U as TryFrom<T>>::Error

The type returned in the event of a conversion error.
Source§

fn try_into(self) -> Result<U, <U as TryFrom<T>>::Error>

Performs the conversion.
§

impl<V, T> VZip<V> for T
where + V: MultiLane<T>,

§

fn vzip(self) -> V

§

impl<T> WithSubscriber for T

§

fn with_subscriber<S>(self, subscriber: S) -> WithDispatch<Self>
where + S: Into<Dispatch>,

Attaches the provided Subscriber to this type, returning a +[WithDispatch] wrapper. Read more
§

fn with_current_subscriber(self) -> WithDispatch<Self>

Attaches the current default Subscriber to this type, returning a +[WithDispatch] wrapper. Read more
Source§

impl<T> DeserializeOwned for T
where + T: for<'de> Deserialize<'de>,

§

impl<T> UserEvent for T
where + T: Debug + Clone + Send + 'static,

\ No newline at end of file diff --git a/api/jellytau_lib/commands/catalog/struct.CatalogSyncStatus.html b/api/jellytau_lib/commands/catalog/struct.CatalogSyncStatus.html new file mode 100644 index 00000000..b687ec95 --- /dev/null +++ b/api/jellytau_lib/commands/catalog/struct.CatalogSyncStatus.html @@ -0,0 +1,52 @@ +CatalogSyncStatus in jellytau_lib::commands::catalog - RustSkip to main content

CatalogSyncStatus

Struct CatalogSyncStatus 

Source
pub struct CatalogSyncStatus {
+    pub last_synced_at: Option<String>,
+}

Fields§

§last_synced_at: Option<String>

RFC-3339 timestamp of the last successful sync, if any.

+

Trait Implementations§

Source§

impl Clone for CatalogSyncStatus

Source§

fn clone(&self) -> CatalogSyncStatus

Returns a duplicate of the value. Read more
1.0.0 (const: unstable) · Source§

fn clone_from(&mut self, source: &Self)

Performs copy-assignment from source. Read more
Source§

impl Debug for CatalogSyncStatus

Source§

fn fmt(&self, f: &mut Formatter<'_>) -> Result

Formats the value using the given formatter. Read more
Source§

impl<'de> Deserialize<'de> for CatalogSyncStatus

Source§

fn deserialize<__D>(__deserializer: __D) -> Result<Self, __D::Error>
where + __D: Deserializer<'de>,

Deserialize this value from the given Serde deserializer. Read more
Source§

impl NamedType for CatalogSyncStatus

Source§

fn sid() -> SpectaID

Source§

fn named_data_type( + type_map: &mut TypeCollection, + generics: &[DataType], +) -> NamedDataType

this is equivalent to [Type::inline] but returns a [NamedDataType] instead.
Source§

fn definition_named_data_type(type_map: &mut TypeCollection) -> NamedDataType

this is equivalent to [Type::definition] but returns a [NamedDataType] instead.
Source§

impl Serialize for CatalogSyncStatus

Source§

fn serialize<__S>(&self, __serializer: __S) -> Result<__S::Ok, __S::Error>
where + __S: Serializer,

Serialize this value into the given Serde serializer. Read more
Source§

impl Type for CatalogSyncStatus

Source§

fn inline(type_map: &mut TypeCollection, generics: Generics<'_>) -> DataType

Returns the definition of a type using the provided generics. Read more
Source§

fn reference(type_map: &mut TypeCollection, generics: &[DataType]) -> Reference

Generates a datatype corresponding to a reference to this type, +as determined by its category. Getting a reference to a type implies that +it should belong in the type map (since it has to be referenced from somewhere), +so the output of definition will be put into the type map.
Source§

impl Flatten for CatalogSyncStatus

Auto Trait Implementations§

Blanket Implementations§

Source§

impl<T> Any for T
where + T: 'static + ?Sized,

Source§

fn type_id(&self) -> TypeId

Gets the TypeId of self. Read more
Source§

impl<T> Borrow<T> for T
where + T: ?Sized,

Source§

fn borrow(&self) -> &T

Immutably borrows from an owned value. Read more
Source§

impl<T> BorrowMut<T> for T
where + T: ?Sized,

Source§

fn borrow_mut(&mut self) -> &mut T

Mutably borrows from an owned value. Read more
Source§

impl<T> CloneToUninit for T
where + T: Clone,

Source§

unsafe fn clone_to_uninit(&self, dest: *mut u8)

🔬This is a nightly-only experimental API. (clone_to_uninit)
Performs copy-assignment from self to dest. Read more
§

impl<'de, D, R> CommandArg<'de, R> for D
where + D: Deserialize<'de>, + R: Runtime,

§

fn from_command(command: CommandItem<'de, R>) -> Result<D, InvokeError>

Derives an instance of Self from the [CommandItem]. Read more
Source§

impl<T> From<T> for T

Source§

fn from(t: T) -> T

Returns the argument unchanged.

+
§

impl<T> FunctionArg for T
where + T: Type,

§

fn to_datatype(type_map: &mut TypeCollection) -> Option<DataType>

Gets the type of an argument as a [DataType]. Read more
§

impl<T> Instrument for T

§

fn instrument(self, span: Span) -> Instrumented<Self>

Instruments this type with the provided [Span], returning an +Instrumented wrapper. Read more
§

fn in_current_span(self) -> Instrumented<Self>

Instruments this type with the current Span, returning an +Instrumented wrapper. Read more
Source§

impl<T, U> Into<U> for T
where + U: From<T>,

Source§

fn into(self) -> U

Calls U::from(self).

+

That is, this conversion is whatever the implementation of +From<T> for U chooses to do.

+
§

impl<T> IpcResponse for T
where + T: Serialize,

§

fn body(self) -> Result<InvokeResponseBody, Error>

Resolve the IPC response body.
§

impl<T> PolicyExt for T
where + T: ?Sized,

§

fn and<P, B, E>(self, other: P) -> And<T, P>
where + T: Policy<B, E>, + P: Policy<B, E>,

Create a new Policy that returns [Action::Follow] only if self and other return +Action::Follow. Read more
§

fn or<P, B, E>(self, other: P) -> Or<T, P>
where + T: Policy<B, E>, + P: Policy<B, E>,

Create a new Policy that returns [Action::Follow] if either self or other returns +Action::Follow. Read more
Source§

impl<T> Same for T

Source§

type Output = T

Should always be Self
§

impl<T> ScopeObject for T
where + T: Send + Sync + Debug + DeserializeOwned + 'static,

§

type Error = Error

The error type.
§

fn deserialize<R>( + _app: &AppHandle<R>, + raw: Value, +) -> Result<T, <T as ScopeObject>::Error>
where + R: Runtime,

Deserialize the raw scope value.
Source§

impl<T> Serialize for T
where + T: Serialize + ?Sized,

Source§

fn erased_serialize(&self, serializer: &mut dyn Serializer) -> Result<(), Error>

Source§

fn do_erased_serialize( + &self, + serializer: &mut dyn Serializer, +) -> Result<(), ErrorImpl>

Source§

impl<T> ToOwned for T
where + T: Clone,

Source§

type Owned = T

The resulting type after obtaining ownership.
Source§

fn to_owned(&self) -> T

Creates owned data from borrowed data, usually by cloning. Read more
Source§

fn clone_into(&self, target: &mut T)

Uses borrowed data to replace owned data, usually by cloning. Read more
Source§

impl<T, U> TryFrom<U> for T
where + U: Into<T>,

Source§

type Error = Infallible

The type returned in the event of a conversion error.
Source§

fn try_from(value: U) -> Result<T, <T as TryFrom<U>>::Error>

Performs the conversion.
Source§

impl<T, U> TryInto<U> for T
where + U: TryFrom<T>,

Source§

type Error = <U as TryFrom<T>>::Error

The type returned in the event of a conversion error.
Source§

fn try_into(self) -> Result<U, <U as TryFrom<T>>::Error>

Performs the conversion.
§

impl<V, T> VZip<V> for T
where + V: MultiLane<T>,

§

fn vzip(self) -> V

§

impl<T> WithSubscriber for T

§

fn with_subscriber<S>(self, subscriber: S) -> WithDispatch<Self>
where + S: Into<Dispatch>,

Attaches the provided Subscriber to this type, returning a +[WithDispatch] wrapper. Read more
§

fn with_current_subscriber(self) -> WithDispatch<Self>

Attaches the current default Subscriber to this type, returning a +[WithDispatch] wrapper. Read more
Source§

impl<T> DeserializeOwned for T
where + T: for<'de> Deserialize<'de>,

§

impl<T> UserEvent for T
where + T: Debug + Clone + Send + 'static,

\ No newline at end of file diff --git a/api/jellytau_lib/commands/catalog/struct.IndexPassGuard.html b/api/jellytau_lib/commands/catalog/struct.IndexPassGuard.html new file mode 100644 index 00000000..f2417e2f --- /dev/null +++ b/api/jellytau_lib/commands/catalog/struct.IndexPassGuard.html @@ -0,0 +1,28 @@ +IndexPassGuard in jellytau_lib::commands::catalog - RustSkip to main content

IndexPassGuard

Struct IndexPassGuard 

Source
struct IndexPassGuard;
Expand description

Clears INDEX_IN_PROGRESS however the pass leaves — including on the ? +early return when get_libraries fails, which a plain store at the end of +the function would leak.

+

Trait Implementations§

Source§

impl Drop for IndexPassGuard

Source§

fn drop(&mut self)

Executes the destructor for this type. Read more
Source§

fn pin_drop(self: Pin<&mut Self>)

🔬This is a nightly-only experimental API. (pin_ergonomics)
Execute the destructor for this type, but different to Drop::drop, it requires self +to be pinned. Read more

Auto Trait Implementations§

Blanket Implementations§

Source§

impl<T> Any for T
where + T: 'static + ?Sized,

Source§

fn type_id(&self) -> TypeId

Gets the TypeId of self. Read more
Source§

impl<T> Borrow<T> for T
where + T: ?Sized,

Source§

fn borrow(&self) -> &T

Immutably borrows from an owned value. Read more
Source§

impl<T> BorrowMut<T> for T
where + T: ?Sized,

Source§

fn borrow_mut(&mut self) -> &mut T

Mutably borrows from an owned value. Read more
Source§

impl<T> From<T> for T

Source§

fn from(t: T) -> T

Returns the argument unchanged.

+
§

impl<T> Instrument for T

§

fn instrument(self, span: Span) -> Instrumented<Self>

Instruments this type with the provided [Span], returning an +Instrumented wrapper. Read more
§

fn in_current_span(self) -> Instrumented<Self>

Instruments this type with the current Span, returning an +Instrumented wrapper. Read more
Source§

impl<T, U> Into<U> for T
where + U: From<T>,

Source§

fn into(self) -> U

Calls U::from(self).

+

That is, this conversion is whatever the implementation of +From<T> for U chooses to do.

+
§

impl<T> PolicyExt for T
where + T: ?Sized,

§

fn and<P, B, E>(self, other: P) -> And<T, P>
where + T: Policy<B, E>, + P: Policy<B, E>,

Create a new Policy that returns [Action::Follow] only if self and other return +Action::Follow. Read more
§

fn or<P, B, E>(self, other: P) -> Or<T, P>
where + T: Policy<B, E>, + P: Policy<B, E>,

Create a new Policy that returns [Action::Follow] if either self or other returns +Action::Follow. Read more
Source§

impl<T> Same for T

Source§

type Output = T

Should always be Self
Source§

impl<T, U> TryFrom<U> for T
where + U: Into<T>,

Source§

type Error = Infallible

The type returned in the event of a conversion error.
Source§

fn try_from(value: U) -> Result<T, <T as TryFrom<U>>::Error>

Performs the conversion.
Source§

impl<T, U> TryInto<U> for T
where + U: TryFrom<T>,

Source§

type Error = <U as TryFrom<T>>::Error

The type returned in the event of a conversion error.
Source§

fn try_into(self) -> Result<U, <U as TryFrom<T>>::Error>

Performs the conversion.
§

impl<V, T> VZip<V> for T
where + V: MultiLane<T>,

§

fn vzip(self) -> V

§

impl<T> WithSubscriber for T

§

fn with_subscriber<S>(self, subscriber: S) -> WithDispatch<Self>
where + S: Into<Dispatch>,

Attaches the provided Subscriber to this type, returning a +[WithDispatch] wrapper. Read more
§

fn with_current_subscriber(self) -> WithDispatch<Self>

Attaches the current default Subscriber to this type, returning a +[WithDispatch] wrapper. Read more
\ No newline at end of file diff --git a/api/jellytau_lib/commands/catalog/struct.ResumeQueuedResult.html b/api/jellytau_lib/commands/catalog/struct.ResumeQueuedResult.html new file mode 100644 index 00000000..74be6e44 --- /dev/null +++ b/api/jellytau_lib/commands/catalog/struct.ResumeQueuedResult.html @@ -0,0 +1,54 @@ +ResumeQueuedResult in jellytau_lib::commands::catalog - RustSkip to main content

ResumeQueuedResult

Struct ResumeQueuedResult 

Source
pub struct ResumeQueuedResult {
+    pub resolved: usize,
+    pub failed: usize,
+}

Fields§

§resolved: usize

Rows whose stream URL was resolved and are now pump-eligible.

+
§failed: usize

Rows that couldn’t be resolved (item metadata / URL lookup failed).

+

Trait Implementations§

Source§

impl Clone for ResumeQueuedResult

Source§

fn clone(&self) -> ResumeQueuedResult

Returns a duplicate of the value. Read more
1.0.0 (const: unstable) · Source§

fn clone_from(&mut self, source: &Self)

Performs copy-assignment from source. Read more
Source§

impl Debug for ResumeQueuedResult

Source§

fn fmt(&self, f: &mut Formatter<'_>) -> Result

Formats the value using the given formatter. Read more
Source§

impl<'de> Deserialize<'de> for ResumeQueuedResult

Source§

fn deserialize<__D>(__deserializer: __D) -> Result<Self, __D::Error>
where + __D: Deserializer<'de>,

Deserialize this value from the given Serde deserializer. Read more
Source§

impl NamedType for ResumeQueuedResult

Source§

fn sid() -> SpectaID

Source§

fn named_data_type( + type_map: &mut TypeCollection, + generics: &[DataType], +) -> NamedDataType

this is equivalent to [Type::inline] but returns a [NamedDataType] instead.
Source§

fn definition_named_data_type(type_map: &mut TypeCollection) -> NamedDataType

this is equivalent to [Type::definition] but returns a [NamedDataType] instead.
Source§

impl Serialize for ResumeQueuedResult

Source§

fn serialize<__S>(&self, __serializer: __S) -> Result<__S::Ok, __S::Error>
where + __S: Serializer,

Serialize this value into the given Serde serializer. Read more
Source§

impl Type for ResumeQueuedResult

Source§

fn inline(type_map: &mut TypeCollection, generics: Generics<'_>) -> DataType

Returns the definition of a type using the provided generics. Read more
Source§

fn reference(type_map: &mut TypeCollection, generics: &[DataType]) -> Reference

Generates a datatype corresponding to a reference to this type, +as determined by its category. Getting a reference to a type implies that +it should belong in the type map (since it has to be referenced from somewhere), +so the output of definition will be put into the type map.
Source§

impl Flatten for ResumeQueuedResult

Auto Trait Implementations§

Blanket Implementations§

Source§

impl<T> Any for T
where + T: 'static + ?Sized,

Source§

fn type_id(&self) -> TypeId

Gets the TypeId of self. Read more
Source§

impl<T> Borrow<T> for T
where + T: ?Sized,

Source§

fn borrow(&self) -> &T

Immutably borrows from an owned value. Read more
Source§

impl<T> BorrowMut<T> for T
where + T: ?Sized,

Source§

fn borrow_mut(&mut self) -> &mut T

Mutably borrows from an owned value. Read more
Source§

impl<T> CloneToUninit for T
where + T: Clone,

Source§

unsafe fn clone_to_uninit(&self, dest: *mut u8)

🔬This is a nightly-only experimental API. (clone_to_uninit)
Performs copy-assignment from self to dest. Read more
§

impl<'de, D, R> CommandArg<'de, R> for D
where + D: Deserialize<'de>, + R: Runtime,

§

fn from_command(command: CommandItem<'de, R>) -> Result<D, InvokeError>

Derives an instance of Self from the [CommandItem]. Read more
Source§

impl<T> From<T> for T

Source§

fn from(t: T) -> T

Returns the argument unchanged.

+
§

impl<T> FunctionArg for T
where + T: Type,

§

fn to_datatype(type_map: &mut TypeCollection) -> Option<DataType>

Gets the type of an argument as a [DataType]. Read more
§

impl<T> Instrument for T

§

fn instrument(self, span: Span) -> Instrumented<Self>

Instruments this type with the provided [Span], returning an +Instrumented wrapper. Read more
§

fn in_current_span(self) -> Instrumented<Self>

Instruments this type with the current Span, returning an +Instrumented wrapper. Read more
Source§

impl<T, U> Into<U> for T
where + U: From<T>,

Source§

fn into(self) -> U

Calls U::from(self).

+

That is, this conversion is whatever the implementation of +From<T> for U chooses to do.

+
§

impl<T> IpcResponse for T
where + T: Serialize,

§

fn body(self) -> Result<InvokeResponseBody, Error>

Resolve the IPC response body.
§

impl<T> PolicyExt for T
where + T: ?Sized,

§

fn and<P, B, E>(self, other: P) -> And<T, P>
where + T: Policy<B, E>, + P: Policy<B, E>,

Create a new Policy that returns [Action::Follow] only if self and other return +Action::Follow. Read more
§

fn or<P, B, E>(self, other: P) -> Or<T, P>
where + T: Policy<B, E>, + P: Policy<B, E>,

Create a new Policy that returns [Action::Follow] if either self or other returns +Action::Follow. Read more
Source§

impl<T> Same for T

Source§

type Output = T

Should always be Self
§

impl<T> ScopeObject for T
where + T: Send + Sync + Debug + DeserializeOwned + 'static,

§

type Error = Error

The error type.
§

fn deserialize<R>( + _app: &AppHandle<R>, + raw: Value, +) -> Result<T, <T as ScopeObject>::Error>
where + R: Runtime,

Deserialize the raw scope value.
Source§

impl<T> Serialize for T
where + T: Serialize + ?Sized,

Source§

fn erased_serialize(&self, serializer: &mut dyn Serializer) -> Result<(), Error>

Source§

fn do_erased_serialize( + &self, + serializer: &mut dyn Serializer, +) -> Result<(), ErrorImpl>

Source§

impl<T> ToOwned for T
where + T: Clone,

Source§

type Owned = T

The resulting type after obtaining ownership.
Source§

fn to_owned(&self) -> T

Creates owned data from borrowed data, usually by cloning. Read more
Source§

fn clone_into(&self, target: &mut T)

Uses borrowed data to replace owned data, usually by cloning. Read more
Source§

impl<T, U> TryFrom<U> for T
where + U: Into<T>,

Source§

type Error = Infallible

The type returned in the event of a conversion error.
Source§

fn try_from(value: U) -> Result<T, <T as TryFrom<U>>::Error>

Performs the conversion.
Source§

impl<T, U> TryInto<U> for T
where + U: TryFrom<T>,

Source§

type Error = <U as TryFrom<T>>::Error

The type returned in the event of a conversion error.
Source§

fn try_into(self) -> Result<U, <U as TryFrom<T>>::Error>

Performs the conversion.
§

impl<V, T> VZip<V> for T
where + V: MultiLane<T>,

§

fn vzip(self) -> V

§

impl<T> WithSubscriber for T

§

fn with_subscriber<S>(self, subscriber: S) -> WithDispatch<Self>
where + S: Into<Dispatch>,

Attaches the provided Subscriber to this type, returning a +[WithDispatch] wrapper. Read more
§

fn with_current_subscriber(self) -> WithDispatch<Self>

Attaches the current default Subscriber to this type, returning a +[WithDispatch] wrapper. Read more
Source§

impl<T> DeserializeOwned for T
where + T: for<'de> Deserialize<'de>,

§

impl<T> UserEvent for T
where + T: Debug + Clone + Send + 'static,

\ No newline at end of file diff --git a/api/jellytau_lib/commands/connectivity/fn.connectivity_check_server.html b/api/jellytau_lib/commands/connectivity/fn.connectivity_check_server.html new file mode 100644 index 00000000..5a10a5b1 --- /dev/null +++ b/api/jellytau_lib/commands/connectivity/fn.connectivity_check_server.html @@ -0,0 +1,4 @@ +connectivity_check_server in jellytau_lib::commands::connectivity - RustSkip to main content

connectivity_check_server

Function connectivity_check_server 

Source
pub async fn connectivity_check_server(
+    state: State<'_, ConnectivityMonitorWrapper>,
+) -> Result<bool, String>
Expand description

Check if the server is currently reachable

+
\ No newline at end of file diff --git a/api/jellytau_lib/commands/connectivity/fn.connectivity_get_status.html b/api/jellytau_lib/commands/connectivity/fn.connectivity_get_status.html new file mode 100644 index 00000000..09c115fe --- /dev/null +++ b/api/jellytau_lib/commands/connectivity/fn.connectivity_get_status.html @@ -0,0 +1,4 @@ +connectivity_get_status in jellytau_lib::commands::connectivity - RustSkip to main content

connectivity_get_status

Function connectivity_get_status 

Source
pub async fn connectivity_get_status(
+    state: State<'_, ConnectivityMonitorWrapper>,
+) -> Result<ConnectivityStatus, String>
Expand description

Get the current connectivity status

+
\ No newline at end of file diff --git a/api/jellytau_lib/commands/connectivity/fn.connectivity_mark_reachable.html b/api/jellytau_lib/commands/connectivity/fn.connectivity_mark_reachable.html new file mode 100644 index 00000000..4900fa0a --- /dev/null +++ b/api/jellytau_lib/commands/connectivity/fn.connectivity_mark_reachable.html @@ -0,0 +1,4 @@ +connectivity_mark_reachable in jellytau_lib::commands::connectivity - RustSkip to main content

connectivity_mark_reachable

Function connectivity_mark_reachable 

Source
pub async fn connectivity_mark_reachable(
+    state: State<'_, ConnectivityMonitorWrapper>,
+) -> Result<(), String>
Expand description

Mark the server as reachable (called after successful API calls)

+
\ No newline at end of file diff --git a/api/jellytau_lib/commands/connectivity/fn.connectivity_mark_unreachable.html b/api/jellytau_lib/commands/connectivity/fn.connectivity_mark_unreachable.html new file mode 100644 index 00000000..bef22162 --- /dev/null +++ b/api/jellytau_lib/commands/connectivity/fn.connectivity_mark_unreachable.html @@ -0,0 +1,5 @@ +connectivity_mark_unreachable in jellytau_lib::commands::connectivity - RustSkip to main content

connectivity_mark_unreachable

Function connectivity_mark_unreachable 

Source
pub async fn connectivity_mark_unreachable(
+    error: Option<String>,
+    state: State<'_, ConnectivityMonitorWrapper>,
+) -> Result<(), String>
Expand description

Mark the server as unreachable (called after failed API calls)

+
\ No newline at end of file diff --git a/api/jellytau_lib/commands/connectivity/fn.connectivity_set_server_url.html b/api/jellytau_lib/commands/connectivity/fn.connectivity_set_server_url.html new file mode 100644 index 00000000..7b9d476d --- /dev/null +++ b/api/jellytau_lib/commands/connectivity/fn.connectivity_set_server_url.html @@ -0,0 +1,5 @@ +connectivity_set_server_url in jellytau_lib::commands::connectivity - RustSkip to main content

connectivity_set_server_url

Function connectivity_set_server_url 

Source
pub async fn connectivity_set_server_url(
+    url: String,
+    state: State<'_, ConnectivityMonitorWrapper>,
+) -> Result<(), String>
Expand description

Set the server URL and trigger an immediate check

+
\ No newline at end of file diff --git a/api/jellytau_lib/commands/connectivity/fn.connectivity_start_monitoring.html b/api/jellytau_lib/commands/connectivity/fn.connectivity_start_monitoring.html new file mode 100644 index 00000000..2ca1591c --- /dev/null +++ b/api/jellytau_lib/commands/connectivity/fn.connectivity_start_monitoring.html @@ -0,0 +1,4 @@ +connectivity_start_monitoring in jellytau_lib::commands::connectivity - RustSkip to main content

connectivity_start_monitoring

Function connectivity_start_monitoring 

Source
pub async fn connectivity_start_monitoring(
+    state: State<'_, ConnectivityMonitorWrapper>,
+) -> Result<(), String>
Expand description

Start monitoring connectivity with adaptive polling

+
\ No newline at end of file diff --git a/api/jellytau_lib/commands/connectivity/fn.connectivity_stop_monitoring.html b/api/jellytau_lib/commands/connectivity/fn.connectivity_stop_monitoring.html new file mode 100644 index 00000000..d75c5cc6 --- /dev/null +++ b/api/jellytau_lib/commands/connectivity/fn.connectivity_stop_monitoring.html @@ -0,0 +1,4 @@ +connectivity_stop_monitoring in jellytau_lib::commands::connectivity - RustSkip to main content

connectivity_stop_monitoring

Function connectivity_stop_monitoring 

Source
pub async fn connectivity_stop_monitoring(
+    state: State<'_, ConnectivityMonitorWrapper>,
+) -> Result<(), String>
Expand description

Stop monitoring connectivity

+
\ No newline at end of file diff --git a/api/jellytau_lib/commands/connectivity/index.html b/api/jellytau_lib/commands/connectivity/index.html new file mode 100644 index 00000000..68397e1d --- /dev/null +++ b/api/jellytau_lib/commands/connectivity/index.html @@ -0,0 +1,3 @@ +jellytau_lib::commands::connectivity - RustSkip to main content

Module connectivity

Module connectivity 

Source
Expand description

Server-reachability / connectivity commands.

+

TRACES: UR-043 | IR-027 | DR-055

+

Structs§

ConnectivityMonitorWrapper
Wrapper for ConnectivityMonitor managed state

Functions§

connectivity_check_server
Check if the server is currently reachable
connectivity_get_status
Get the current connectivity status
connectivity_mark_reachable
Mark the server as reachable (called after successful API calls)
connectivity_mark_unreachable
Mark the server as unreachable (called after failed API calls)
connectivity_set_server_url
Set the server URL and trigger an immediate check
connectivity_start_monitoring
Start monitoring connectivity with adaptive polling
connectivity_stop_monitoring
Stop monitoring connectivity
\ No newline at end of file diff --git a/api/jellytau_lib/commands/connectivity/sidebar-items.js b/api/jellytau_lib/commands/connectivity/sidebar-items.js new file mode 100644 index 00000000..0d99481a --- /dev/null +++ b/api/jellytau_lib/commands/connectivity/sidebar-items.js @@ -0,0 +1 @@ +window.SIDEBAR_ITEMS = {"fn":["connectivity_check_server","connectivity_get_status","connectivity_mark_reachable","connectivity_mark_unreachable","connectivity_set_server_url","connectivity_start_monitoring","connectivity_stop_monitoring"],"struct":["ConnectivityMonitorWrapper"]}; \ No newline at end of file diff --git a/api/jellytau_lib/commands/connectivity/struct.ConnectivityMonitorWrapper.html b/api/jellytau_lib/commands/connectivity/struct.ConnectivityMonitorWrapper.html new file mode 100644 index 00000000..6ec1ecb7 --- /dev/null +++ b/api/jellytau_lib/commands/connectivity/struct.ConnectivityMonitorWrapper.html @@ -0,0 +1,25 @@ +ConnectivityMonitorWrapper in jellytau_lib::commands::connectivity - RustSkip to main content

ConnectivityMonitorWrapper

Struct ConnectivityMonitorWrapper 

Source
pub struct ConnectivityMonitorWrapper(pub Arc<Mutex<ConnectivityMonitor>>);
Expand description

Wrapper for ConnectivityMonitor managed state

+

Tuple Fields§

§0: Arc<Mutex<ConnectivityMonitor>>

Auto Trait Implementations§

Blanket Implementations§

Source§

impl<T> Any for T
where + T: 'static + ?Sized,

Source§

fn type_id(&self) -> TypeId

Gets the TypeId of self. Read more
Source§

impl<T> Borrow<T> for T
where + T: ?Sized,

Source§

fn borrow(&self) -> &T

Immutably borrows from an owned value. Read more
Source§

impl<T> BorrowMut<T> for T
where + T: ?Sized,

Source§

fn borrow_mut(&mut self) -> &mut T

Mutably borrows from an owned value. Read more
Source§

impl<T> From<T> for T

Source§

fn from(t: T) -> T

Returns the argument unchanged.

+
§

impl<T> Instrument for T

§

fn instrument(self, span: Span) -> Instrumented<Self>

Instruments this type with the provided [Span], returning an +Instrumented wrapper. Read more
§

fn in_current_span(self) -> Instrumented<Self>

Instruments this type with the current Span, returning an +Instrumented wrapper. Read more
Source§

impl<T, U> Into<U> for T
where + U: From<T>,

Source§

fn into(self) -> U

Calls U::from(self).

+

That is, this conversion is whatever the implementation of +From<T> for U chooses to do.

+
§

impl<T> PolicyExt for T
where + T: ?Sized,

§

fn and<P, B, E>(self, other: P) -> And<T, P>
where + T: Policy<B, E>, + P: Policy<B, E>,

Create a new Policy that returns [Action::Follow] only if self and other return +Action::Follow. Read more
§

fn or<P, B, E>(self, other: P) -> Or<T, P>
where + T: Policy<B, E>, + P: Policy<B, E>,

Create a new Policy that returns [Action::Follow] if either self or other returns +Action::Follow. Read more
Source§

impl<T> Same for T

Source§

type Output = T

Should always be Self
Source§

impl<T, U> TryFrom<U> for T
where + U: Into<T>,

Source§

type Error = Infallible

The type returned in the event of a conversion error.
Source§

fn try_from(value: U) -> Result<T, <T as TryFrom<U>>::Error>

Performs the conversion.
Source§

impl<T, U> TryInto<U> for T
where + U: TryFrom<T>,

Source§

type Error = <U as TryFrom<T>>::Error

The type returned in the event of a conversion error.
Source§

fn try_into(self) -> Result<U, <U as TryFrom<T>>::Error>

Performs the conversion.
§

impl<V, T> VZip<V> for T
where + V: MultiLane<T>,

§

fn vzip(self) -> V

§

impl<T> WithSubscriber for T

§

fn with_subscriber<S>(self, subscriber: S) -> WithDispatch<Self>
where + S: Into<Dispatch>,

Attaches the provided Subscriber to this type, returning a +[WithDispatch] wrapper. Read more
§

fn with_current_subscriber(self) -> WithDispatch<Self>

Attaches the current default Subscriber to this type, returning a +[WithDispatch] wrapper. Read more
\ No newline at end of file diff --git a/api/jellytau_lib/commands/conversions/fn.calc_progress.html b/api/jellytau_lib/commands/conversions/fn.calc_progress.html new file mode 100644 index 00000000..470d853e --- /dev/null +++ b/api/jellytau_lib/commands/conversions/fn.calc_progress.html @@ -0,0 +1,9 @@ +calc_progress in jellytau_lib::commands::conversions - RustSkip to main content

calc_progress

Function calc_progress 

Source
pub fn calc_progress(position: f64, duration: f64) -> f64
Expand description

Calculate progress percentage from position and duration

+

§Arguments

+
    +
  • position - Current position in seconds
  • +
  • duration - Total duration in seconds
  • +
+

§Returns

+

Progress as percentage (0.0 to 100.0)

+
\ No newline at end of file diff --git a/api/jellytau_lib/commands/conversions/fn.convert_percent_to_volume.html b/api/jellytau_lib/commands/conversions/fn.convert_percent_to_volume.html new file mode 100644 index 00000000..b2610116 --- /dev/null +++ b/api/jellytau_lib/commands/conversions/fn.convert_percent_to_volume.html @@ -0,0 +1,8 @@ +convert_percent_to_volume in jellytau_lib::commands::conversions - RustSkip to main content

convert_percent_to_volume

Function convert_percent_to_volume 

Source
pub fn convert_percent_to_volume(percent: f64) -> f64
Expand description

Convert percentage volume (0-100) to normalized (0.0-1.0)

+

§Arguments

+
    +
  • percent - Volume as percentage (0 to 100)
  • +
+

§Returns

+

Normalized volume (0.0 to 1.0)

+
\ No newline at end of file diff --git a/api/jellytau_lib/commands/conversions/fn.convert_ticks_to_seconds.html b/api/jellytau_lib/commands/conversions/fn.convert_ticks_to_seconds.html new file mode 100644 index 00000000..057e2bf0 --- /dev/null +++ b/api/jellytau_lib/commands/conversions/fn.convert_ticks_to_seconds.html @@ -0,0 +1,8 @@ +convert_ticks_to_seconds in jellytau_lib::commands::conversions - RustSkip to main content

convert_ticks_to_seconds

Function convert_ticks_to_seconds 

Source
pub fn convert_ticks_to_seconds(ticks: i64) -> f64
Expand description

Convert Jellyfin ticks to seconds

+

§Arguments

+
    +
  • ticks - Time in Jellyfin ticks (10,000,000 ticks = 1 second)
  • +
+

§Returns

+

Time in seconds

+
\ No newline at end of file diff --git a/api/jellytau_lib/commands/conversions/fn.format_time_seconds.html b/api/jellytau_lib/commands/conversions/fn.format_time_seconds.html new file mode 100644 index 00000000..063ab09d --- /dev/null +++ b/api/jellytau_lib/commands/conversions/fn.format_time_seconds.html @@ -0,0 +1,8 @@ +format_time_seconds in jellytau_lib::commands::conversions - RustSkip to main content

format_time_seconds

Function format_time_seconds 

Source
pub fn format_time_seconds(seconds: f64) -> String
Expand description

Format time in seconds to MM:SS display string

+

§Arguments

+
    +
  • seconds - Time in seconds
  • +
+

§Returns

+

Formatted string like “3:45” or “12:09”

+
\ No newline at end of file diff --git a/api/jellytau_lib/commands/conversions/fn.format_time_seconds_long.html b/api/jellytau_lib/commands/conversions/fn.format_time_seconds_long.html new file mode 100644 index 00000000..04aa22e5 --- /dev/null +++ b/api/jellytau_lib/commands/conversions/fn.format_time_seconds_long.html @@ -0,0 +1,13 @@ +format_time_seconds_long in jellytau_lib::commands::conversions - RustSkip to main content

format_time_seconds_long

Function format_time_seconds_long 

Source
pub fn format_time_seconds_long(seconds: f64) -> String
Expand description

Format time in seconds to HH:MM:SS or MM:SS display string

+

Automatically chooses format based on duration:

+
    +
  • Less than 1 hour: Returns MM:SS format
  • +
  • 1 hour or more: Returns HH:MM:SS format
  • +
+

§Arguments

+
    +
  • seconds - Time in seconds
  • +
+

§Returns

+

Formatted string like “1:23:45” or “3:45”

+
\ No newline at end of file diff --git a/api/jellytau_lib/commands/conversions/index.html b/api/jellytau_lib/commands/conversions/index.html new file mode 100644 index 00000000..1d9e208d --- /dev/null +++ b/api/jellytau_lib/commands/conversions/index.html @@ -0,0 +1,5 @@ +jellytau_lib::commands::conversions - RustSkip to main content

Module conversions

Module conversions 

Source
Expand description

Tauri commands for unit conversions and formatting

+

TRACES: UR-005 | DR-009

+

These commands expose conversion utilities to the frontend, +allowing centralized conversion logic in Rust.

+

Functions§

calc_progress
Calculate progress percentage from position and duration
convert_percent_to_volume
Convert percentage volume (0-100) to normalized (0.0-1.0)
convert_ticks_to_seconds
Convert Jellyfin ticks to seconds
format_time_seconds
Format time in seconds to MM:SS display string
format_time_seconds_long
Format time in seconds to HH:MM:SS or MM:SS display string
\ No newline at end of file diff --git a/api/jellytau_lib/commands/conversions/sidebar-items.js b/api/jellytau_lib/commands/conversions/sidebar-items.js new file mode 100644 index 00000000..40c7e689 --- /dev/null +++ b/api/jellytau_lib/commands/conversions/sidebar-items.js @@ -0,0 +1 @@ +window.SIDEBAR_ITEMS = {"fn":["calc_progress","convert_percent_to_volume","convert_ticks_to_seconds","format_time_seconds","format_time_seconds_long"]}; \ No newline at end of file diff --git a/api/jellytau_lib/commands/device/fn.device_get_id.html b/api/jellytau_lib/commands/device/fn.device_get_id.html new file mode 100644 index 00000000..9793394e --- /dev/null +++ b/api/jellytau_lib/commands/device/fn.device_get_id.html @@ -0,0 +1,13 @@ +device_get_id in jellytau_lib::commands::device - RustSkip to main content

device_get_id

Function device_get_id 

Source
pub async fn device_get_id(
+    db: State<'_, DatabaseWrapper>,
+) -> Result<String, String>
Expand description

Get or create the device ID. +Device ID is a UUID v4 that persists across app restarts. +On first call, generates and stores a new UUID. +On subsequent calls, retrieves the stored UUID.

+

§Returns

+
    +
  • Ok(String) - The device ID (UUID v4)
  • +
  • Err(String) - If database operation fails
  • +
+

TRACES: UR-009 | DR-011

+
\ No newline at end of file diff --git a/api/jellytau_lib/commands/device/fn.device_set_id.html b/api/jellytau_lib/commands/device/fn.device_set_id.html new file mode 100644 index 00000000..ea6ed726 --- /dev/null +++ b/api/jellytau_lib/commands/device/fn.device_set_id.html @@ -0,0 +1,16 @@ +device_set_id in jellytau_lib::commands::device - RustSkip to main content

device_set_id

Function device_set_id 

Source
pub async fn device_set_id(
+    device_id: String,
+    db: State<'_, DatabaseWrapper>,
+) -> Result<(), String>
Expand description

Set the device ID (primarily for testing or recovery). +Overwrites any existing device ID.

+

§Arguments

+
    +
  • device_id - The device ID to store (should be UUID v4 format)
  • +
+

§Returns

+
    +
  • Ok(()) - If device ID was stored successfully
  • +
  • Err(String) - If database operation fails
  • +
+

TRACES: UR-009 | DR-011

+
\ No newline at end of file diff --git a/api/jellytau_lib/commands/device/index.html b/api/jellytau_lib/commands/device/index.html new file mode 100644 index 00000000..dcfef88e --- /dev/null +++ b/api/jellytau_lib/commands/device/index.html @@ -0,0 +1,8 @@ +jellytau_lib::commands::device - RustSkip to main content

Module device

Module device 

Source
Expand description

Device identification commands

+

Handles persistent device ID generation and retrieval for Jellyfin server communication. +TRACES: UR-009 | DR-011

+

Functions§

device_get_id
Get or create the device ID. +Device ID is a UUID v4 that persists across app restarts. +On first call, generates and stores a new UUID. +On subsequent calls, retrieves the stored UUID.
device_set_id
Set the device ID (primarily for testing or recovery). +Overwrites any existing device ID.
\ No newline at end of file diff --git a/api/jellytau_lib/commands/device/sidebar-items.js b/api/jellytau_lib/commands/device/sidebar-items.js new file mode 100644 index 00000000..289c906e --- /dev/null +++ b/api/jellytau_lib/commands/device/sidebar-items.js @@ -0,0 +1 @@ +window.SIDEBAR_ITEMS = {"fn":["device_get_id","device_set_id"]}; \ No newline at end of file diff --git a/api/jellytau_lib/commands/diagnostics/constant.LEVEL_FILE.html b/api/jellytau_lib/commands/diagnostics/constant.LEVEL_FILE.html new file mode 100644 index 00000000..34819ca1 --- /dev/null +++ b/api/jellytau_lib/commands/diagnostics/constant.LEVEL_FILE.html @@ -0,0 +1,2 @@ +LEVEL_FILE in jellytau_lib::commands::diagnostics - RustSkip to main content

LEVEL_FILE

Constant LEVEL_FILE 

Source
const LEVEL_FILE: &str = "log-level";
Expand description

Name of the file holding the user’s chosen level, in the app config dir.

+
\ No newline at end of file diff --git a/api/jellytau_lib/commands/diagnostics/fn.diagnostics_export.html b/api/jellytau_lib/commands/diagnostics/fn.diagnostics_export.html new file mode 100644 index 00000000..a343d153 --- /dev/null +++ b/api/jellytau_lib/commands/diagnostics/fn.diagnostics_export.html @@ -0,0 +1,10 @@ +diagnostics_export in jellytau_lib::commands::diagnostics - RustSkip to main content

diagnostics_export

Function diagnostics_export 

Source
pub async fn diagnostics_export(
+    app: AppHandle,
+    server_url: Option<String>,
+) -> Result<DiagnosticsBundle, String>
Expand description

Write a redacted diagnostics archive and return where it went.

+

§Blocking I/O

+

This reads and rewrites every log file. It is an async command so it does +not block the IPC thread, but it must never be called from a player event +callback — see the deadlock note in CLAUDE.md.

+

TRACES: UR-078 | DR-218

+
\ No newline at end of file diff --git a/api/jellytau_lib/commands/diagnostics/fn.diagnostics_get_info.html b/api/jellytau_lib/commands/diagnostics/fn.diagnostics_get_info.html new file mode 100644 index 00000000..4014e34d --- /dev/null +++ b/api/jellytau_lib/commands/diagnostics/fn.diagnostics_get_info.html @@ -0,0 +1,5 @@ +diagnostics_get_info in jellytau_lib::commands::diagnostics - RustSkip to main content

diagnostics_get_info

Function diagnostics_get_info 

Source
pub async fn diagnostics_get_info(
+    app: AppHandle,
+) -> Result<DiagnosticsInfo, String>
Expand description

Current log level and where the files are.

+

TRACES: UR-078 | DR-218

+
\ No newline at end of file diff --git a/api/jellytau_lib/commands/diagnostics/fn.diagnostics_set_level.html b/api/jellytau_lib/commands/diagnostics/fn.diagnostics_set_level.html new file mode 100644 index 00000000..568155f0 --- /dev/null +++ b/api/jellytau_lib/commands/diagnostics/fn.diagnostics_set_level.html @@ -0,0 +1,6 @@ +diagnostics_set_level in jellytau_lib::commands::diagnostics - RustSkip to main content

diagnostics_set_level

Function diagnostics_set_level 

Source
pub async fn diagnostics_set_level(
+    app: AppHandle,
+    level: String,
+) -> Result<String, String>
Expand description

Set the log level, for this session and the next.

+

TRACES: UR-078 | DR-218

+
\ No newline at end of file diff --git a/api/jellytau_lib/commands/diagnostics/fn.environment_summary.html b/api/jellytau_lib/commands/diagnostics/fn.environment_summary.html new file mode 100644 index 00000000..4f1cf91e --- /dev/null +++ b/api/jellytau_lib/commands/diagnostics/fn.environment_summary.html @@ -0,0 +1,5 @@ +environment_summary in jellytau_lib::commands::diagnostics - RustSkip to main content

environment_summary

Function environment_summary 

Source
fn environment_summary(app: &AppHandle, server_url: Option<&str>) -> String
Expand description

A short, non-identifying description of the environment.

+

Deliberately excludes the token, the username, and any path outside the +app’s own directories. The server URL is reduced to scheme and host, which is +diagnostic (https? LAN address? reverse proxy?) without being a credential.

+
\ No newline at end of file diff --git a/api/jellytau_lib/commands/diagnostics/fn.level_name.html b/api/jellytau_lib/commands/diagnostics/fn.level_name.html new file mode 100644 index 00000000..92bdc68b --- /dev/null +++ b/api/jellytau_lib/commands/diagnostics/fn.level_name.html @@ -0,0 +1 @@ +level_name in jellytau_lib::commands::diagnostics - RustSkip to main content

level_name

Function level_name 

Source
fn level_name(level: LevelFilter) -> &'static str
\ No newline at end of file diff --git a/api/jellytau_lib/commands/diagnostics/fn.log_files.html b/api/jellytau_lib/commands/diagnostics/fn.log_files.html new file mode 100644 index 00000000..bc0f1d0f --- /dev/null +++ b/api/jellytau_lib/commands/diagnostics/fn.log_files.html @@ -0,0 +1,2 @@ +log_files in jellytau_lib::commands::diagnostics - RustSkip to main content

log_files

Function log_files 

Source
fn log_files(log_dir: &Path) -> Vec<PathBuf>
Expand description

Collect every log file in the log directory, newest first.

+
\ No newline at end of file diff --git a/api/jellytau_lib/commands/diagnostics/fn.parse_level.html b/api/jellytau_lib/commands/diagnostics/fn.parse_level.html new file mode 100644 index 00000000..47574e00 --- /dev/null +++ b/api/jellytau_lib/commands/diagnostics/fn.parse_level.html @@ -0,0 +1,4 @@ +parse_level in jellytau_lib::commands::diagnostics - RustSkip to main content

parse_level

Function parse_level 

Source
pub fn parse_level(raw: &str) -> LevelFilter
Expand description

Parse a stored/user-supplied level name.

+

Unknown values fall back to Info rather than erroring: this is read at +startup, and a corrupt one-line file must not stop the app from launching.

+
\ No newline at end of file diff --git a/api/jellytau_lib/commands/diagnostics/fn.stored_level.html b/api/jellytau_lib/commands/diagnostics/fn.stored_level.html new file mode 100644 index 00000000..16132eda --- /dev/null +++ b/api/jellytau_lib/commands/diagnostics/fn.stored_level.html @@ -0,0 +1,4 @@ +stored_level in jellytau_lib::commands::diagnostics - RustSkip to main content

stored_level

Function stored_level 

Source
pub fn stored_level(config_dir: &Path) -> Option<LevelFilter>
Expand description

Read the persisted level, if the user has ever set one.

+

Persisted rather than session-only on purpose: somebody reproducing a bug +needs debug logging to survive the restart that reproduces it.

+
\ No newline at end of file diff --git a/api/jellytau_lib/commands/diagnostics/index.html b/api/jellytau_lib/commands/diagnostics/index.html new file mode 100644 index 00000000..469aa8a3 --- /dev/null +++ b/api/jellytau_lib/commands/diagnostics/index.html @@ -0,0 +1,13 @@ +jellytau_lib::commands::diagnostics - RustSkip to main content

Module diagnostics

Module diagnostics 

Source
Expand description

Diagnostics: log level control and the exportable bug-report bundle.

+

TRACES: UR-078 | DR-218

+

The app used to forget everything it did the moment it exited. A user +reporting “the episode randomly restarted” was reporting the symptom of a +race whose evidence had been discarded microseconds later, and the only way +to recover it was to talk them through adb logcat — which is how several +bugs in this project’s history actually got diagnosed.

+

This module is the other half of that: the log is on disk, it survives a +crash, and the user can hand the whole thing over as one file.

+

Everything written here has been through crate::utils::diagnostics::redact +twice — once in the log formatter, and again on the way into the archive, so +files written by a build that predates the formatter pass are covered too.

+

Structs§

DiagnosticsBundle
Where an export landed, so the UI can tell the user where to find it.
DiagnosticsInfo
Where logs live and how verbose they currently are.

Constants§

LEVEL_FILE 🔒
Name of the file holding the user’s chosen level, in the app config dir.

Functions§

diagnostics_export
Write a redacted diagnostics archive and return where it went.
diagnostics_get_info
Current log level and where the files are.
diagnostics_set_level
Set the log level, for this session and the next.
environment_summary 🔒
A short, non-identifying description of the environment.
level_name 🔒
log_files 🔒
Collect every log file in the log directory, newest first.
parse_level
Parse a stored/user-supplied level name.
stored_level
Read the persisted level, if the user has ever set one.
\ No newline at end of file diff --git a/api/jellytau_lib/commands/diagnostics/sidebar-items.js b/api/jellytau_lib/commands/diagnostics/sidebar-items.js new file mode 100644 index 00000000..204f4cda --- /dev/null +++ b/api/jellytau_lib/commands/diagnostics/sidebar-items.js @@ -0,0 +1 @@ +window.SIDEBAR_ITEMS = {"constant":["LEVEL_FILE"],"fn":["diagnostics_export","diagnostics_get_info","diagnostics_set_level","environment_summary","level_name","log_files","parse_level","stored_level"],"struct":["DiagnosticsBundle","DiagnosticsInfo"]}; \ No newline at end of file diff --git a/api/jellytau_lib/commands/diagnostics/struct.DiagnosticsBundle.html b/api/jellytau_lib/commands/diagnostics/struct.DiagnosticsBundle.html new file mode 100644 index 00000000..2ad21a8b --- /dev/null +++ b/api/jellytau_lib/commands/diagnostics/struct.DiagnosticsBundle.html @@ -0,0 +1,56 @@ +DiagnosticsBundle in jellytau_lib::commands::diagnostics - RustSkip to main content

DiagnosticsBundle

Struct DiagnosticsBundle 

Source
pub struct DiagnosticsBundle {
+    pub path: String,
+    pub size_bytes: u64,
+    pub file_count: usize,
+}
Expand description

Where an export landed, so the UI can tell the user where to find it.

+

Fields§

§path: String

Absolute path to the written archive.

+
§size_bytes: u64§file_count: usize

How many log files went in, excluding the environment summary.

+

Trait Implementations§

Source§

impl Clone for DiagnosticsBundle

Source§

fn clone(&self) -> DiagnosticsBundle

Returns a duplicate of the value. Read more
1.0.0 (const: unstable) · Source§

fn clone_from(&mut self, source: &Self)

Performs copy-assignment from source. Read more
Source§

impl Debug for DiagnosticsBundle

Source§

fn fmt(&self, f: &mut Formatter<'_>) -> Result

Formats the value using the given formatter. Read more
Source§

impl<'de> Deserialize<'de> for DiagnosticsBundle

Source§

fn deserialize<__D>(__deserializer: __D) -> Result<Self, __D::Error>
where + __D: Deserializer<'de>,

Deserialize this value from the given Serde deserializer. Read more
Source§

impl NamedType for DiagnosticsBundle

Source§

fn sid() -> SpectaID

Source§

fn named_data_type( + type_map: &mut TypeCollection, + generics: &[DataType], +) -> NamedDataType

this is equivalent to [Type::inline] but returns a [NamedDataType] instead.
Source§

fn definition_named_data_type(type_map: &mut TypeCollection) -> NamedDataType

this is equivalent to [Type::definition] but returns a [NamedDataType] instead.
Source§

impl Serialize for DiagnosticsBundle

Source§

fn serialize<__S>(&self, __serializer: __S) -> Result<__S::Ok, __S::Error>
where + __S: Serializer,

Serialize this value into the given Serde serializer. Read more
Source§

impl Type for DiagnosticsBundle

Source§

fn inline(type_map: &mut TypeCollection, generics: Generics<'_>) -> DataType

Returns the definition of a type using the provided generics. Read more
Source§

fn reference(type_map: &mut TypeCollection, generics: &[DataType]) -> Reference

Generates a datatype corresponding to a reference to this type, +as determined by its category. Getting a reference to a type implies that +it should belong in the type map (since it has to be referenced from somewhere), +so the output of definition will be put into the type map.
Source§

impl Flatten for DiagnosticsBundle

Auto Trait Implementations§

Blanket Implementations§

Source§

impl<T> Any for T
where + T: 'static + ?Sized,

Source§

fn type_id(&self) -> TypeId

Gets the TypeId of self. Read more
Source§

impl<T> Borrow<T> for T
where + T: ?Sized,

Source§

fn borrow(&self) -> &T

Immutably borrows from an owned value. Read more
Source§

impl<T> BorrowMut<T> for T
where + T: ?Sized,

Source§

fn borrow_mut(&mut self) -> &mut T

Mutably borrows from an owned value. Read more
Source§

impl<T> CloneToUninit for T
where + T: Clone,

Source§

unsafe fn clone_to_uninit(&self, dest: *mut u8)

🔬This is a nightly-only experimental API. (clone_to_uninit)
Performs copy-assignment from self to dest. Read more
§

impl<'de, D, R> CommandArg<'de, R> for D
where + D: Deserialize<'de>, + R: Runtime,

§

fn from_command(command: CommandItem<'de, R>) -> Result<D, InvokeError>

Derives an instance of Self from the [CommandItem]. Read more
Source§

impl<T> From<T> for T

Source§

fn from(t: T) -> T

Returns the argument unchanged.

+
§

impl<T> FunctionArg for T
where + T: Type,

§

fn to_datatype(type_map: &mut TypeCollection) -> Option<DataType>

Gets the type of an argument as a [DataType]. Read more
§

impl<T> Instrument for T

§

fn instrument(self, span: Span) -> Instrumented<Self>

Instruments this type with the provided [Span], returning an +Instrumented wrapper. Read more
§

fn in_current_span(self) -> Instrumented<Self>

Instruments this type with the current Span, returning an +Instrumented wrapper. Read more
Source§

impl<T, U> Into<U> for T
where + U: From<T>,

Source§

fn into(self) -> U

Calls U::from(self).

+

That is, this conversion is whatever the implementation of +From<T> for U chooses to do.

+
§

impl<T> IpcResponse for T
where + T: Serialize,

§

fn body(self) -> Result<InvokeResponseBody, Error>

Resolve the IPC response body.
§

impl<T> PolicyExt for T
where + T: ?Sized,

§

fn and<P, B, E>(self, other: P) -> And<T, P>
where + T: Policy<B, E>, + P: Policy<B, E>,

Create a new Policy that returns [Action::Follow] only if self and other return +Action::Follow. Read more
§

fn or<P, B, E>(self, other: P) -> Or<T, P>
where + T: Policy<B, E>, + P: Policy<B, E>,

Create a new Policy that returns [Action::Follow] if either self or other returns +Action::Follow. Read more
Source§

impl<T> Same for T

Source§

type Output = T

Should always be Self
§

impl<T> ScopeObject for T
where + T: Send + Sync + Debug + DeserializeOwned + 'static,

§

type Error = Error

The error type.
§

fn deserialize<R>( + _app: &AppHandle<R>, + raw: Value, +) -> Result<T, <T as ScopeObject>::Error>
where + R: Runtime,

Deserialize the raw scope value.
Source§

impl<T> Serialize for T
where + T: Serialize + ?Sized,

Source§

fn erased_serialize(&self, serializer: &mut dyn Serializer) -> Result<(), Error>

Source§

fn do_erased_serialize( + &self, + serializer: &mut dyn Serializer, +) -> Result<(), ErrorImpl>

Source§

impl<T> ToOwned for T
where + T: Clone,

Source§

type Owned = T

The resulting type after obtaining ownership.
Source§

fn to_owned(&self) -> T

Creates owned data from borrowed data, usually by cloning. Read more
Source§

fn clone_into(&self, target: &mut T)

Uses borrowed data to replace owned data, usually by cloning. Read more
Source§

impl<T, U> TryFrom<U> for T
where + U: Into<T>,

Source§

type Error = Infallible

The type returned in the event of a conversion error.
Source§

fn try_from(value: U) -> Result<T, <T as TryFrom<U>>::Error>

Performs the conversion.
Source§

impl<T, U> TryInto<U> for T
where + U: TryFrom<T>,

Source§

type Error = <U as TryFrom<T>>::Error

The type returned in the event of a conversion error.
Source§

fn try_into(self) -> Result<U, <U as TryFrom<T>>::Error>

Performs the conversion.
§

impl<V, T> VZip<V> for T
where + V: MultiLane<T>,

§

fn vzip(self) -> V

§

impl<T> WithSubscriber for T

§

fn with_subscriber<S>(self, subscriber: S) -> WithDispatch<Self>
where + S: Into<Dispatch>,

Attaches the provided Subscriber to this type, returning a +[WithDispatch] wrapper. Read more
§

fn with_current_subscriber(self) -> WithDispatch<Self>

Attaches the current default Subscriber to this type, returning a +[WithDispatch] wrapper. Read more
Source§

impl<T> DeserializeOwned for T
where + T: for<'de> Deserialize<'de>,

§

impl<T> UserEvent for T
where + T: Debug + Clone + Send + 'static,

\ No newline at end of file diff --git a/api/jellytau_lib/commands/diagnostics/struct.DiagnosticsInfo.html b/api/jellytau_lib/commands/diagnostics/struct.DiagnosticsInfo.html new file mode 100644 index 00000000..fae7714d --- /dev/null +++ b/api/jellytau_lib/commands/diagnostics/struct.DiagnosticsInfo.html @@ -0,0 +1,57 @@ +DiagnosticsInfo in jellytau_lib::commands::diagnostics - RustSkip to main content

DiagnosticsInfo

Struct DiagnosticsInfo 

Source
pub struct DiagnosticsInfo {
+    pub log_dir: String,
+    pub level: String,
+    pub total_size_bytes: u64,
+}
Expand description

Where logs live and how verbose they currently are.

+

Fields§

§log_dir: String

Directory holding the rotating log files.

+
§level: String

Active level, lowercase: “error” | “warn” | “info” | “debug” | “trace”.

+
§total_size_bytes: u64

Total bytes currently held by log files.

+

Trait Implementations§

Source§

impl Clone for DiagnosticsInfo

Source§

fn clone(&self) -> DiagnosticsInfo

Returns a duplicate of the value. Read more
1.0.0 (const: unstable) · Source§

fn clone_from(&mut self, source: &Self)

Performs copy-assignment from source. Read more
Source§

impl Debug for DiagnosticsInfo

Source§

fn fmt(&self, f: &mut Formatter<'_>) -> Result

Formats the value using the given formatter. Read more
Source§

impl<'de> Deserialize<'de> for DiagnosticsInfo

Source§

fn deserialize<__D>(__deserializer: __D) -> Result<Self, __D::Error>
where + __D: Deserializer<'de>,

Deserialize this value from the given Serde deserializer. Read more
Source§

impl NamedType for DiagnosticsInfo

Source§

fn sid() -> SpectaID

Source§

fn named_data_type( + type_map: &mut TypeCollection, + generics: &[DataType], +) -> NamedDataType

this is equivalent to [Type::inline] but returns a [NamedDataType] instead.
Source§

fn definition_named_data_type(type_map: &mut TypeCollection) -> NamedDataType

this is equivalent to [Type::definition] but returns a [NamedDataType] instead.
Source§

impl Serialize for DiagnosticsInfo

Source§

fn serialize<__S>(&self, __serializer: __S) -> Result<__S::Ok, __S::Error>
where + __S: Serializer,

Serialize this value into the given Serde serializer. Read more
Source§

impl Type for DiagnosticsInfo

Source§

fn inline(type_map: &mut TypeCollection, generics: Generics<'_>) -> DataType

Returns the definition of a type using the provided generics. Read more
Source§

fn reference(type_map: &mut TypeCollection, generics: &[DataType]) -> Reference

Generates a datatype corresponding to a reference to this type, +as determined by its category. Getting a reference to a type implies that +it should belong in the type map (since it has to be referenced from somewhere), +so the output of definition will be put into the type map.
Source§

impl Flatten for DiagnosticsInfo

Auto Trait Implementations§

Blanket Implementations§

Source§

impl<T> Any for T
where + T: 'static + ?Sized,

Source§

fn type_id(&self) -> TypeId

Gets the TypeId of self. Read more
Source§

impl<T> Borrow<T> for T
where + T: ?Sized,

Source§

fn borrow(&self) -> &T

Immutably borrows from an owned value. Read more
Source§

impl<T> BorrowMut<T> for T
where + T: ?Sized,

Source§

fn borrow_mut(&mut self) -> &mut T

Mutably borrows from an owned value. Read more
Source§

impl<T> CloneToUninit for T
where + T: Clone,

Source§

unsafe fn clone_to_uninit(&self, dest: *mut u8)

🔬This is a nightly-only experimental API. (clone_to_uninit)
Performs copy-assignment from self to dest. Read more
§

impl<'de, D, R> CommandArg<'de, R> for D
where + D: Deserialize<'de>, + R: Runtime,

§

fn from_command(command: CommandItem<'de, R>) -> Result<D, InvokeError>

Derives an instance of Self from the [CommandItem]. Read more
Source§

impl<T> From<T> for T

Source§

fn from(t: T) -> T

Returns the argument unchanged.

+
§

impl<T> FunctionArg for T
where + T: Type,

§

fn to_datatype(type_map: &mut TypeCollection) -> Option<DataType>

Gets the type of an argument as a [DataType]. Read more
§

impl<T> Instrument for T

§

fn instrument(self, span: Span) -> Instrumented<Self>

Instruments this type with the provided [Span], returning an +Instrumented wrapper. Read more
§

fn in_current_span(self) -> Instrumented<Self>

Instruments this type with the current Span, returning an +Instrumented wrapper. Read more
Source§

impl<T, U> Into<U> for T
where + U: From<T>,

Source§

fn into(self) -> U

Calls U::from(self).

+

That is, this conversion is whatever the implementation of +From<T> for U chooses to do.

+
§

impl<T> IpcResponse for T
where + T: Serialize,

§

fn body(self) -> Result<InvokeResponseBody, Error>

Resolve the IPC response body.
§

impl<T> PolicyExt for T
where + T: ?Sized,

§

fn and<P, B, E>(self, other: P) -> And<T, P>
where + T: Policy<B, E>, + P: Policy<B, E>,

Create a new Policy that returns [Action::Follow] only if self and other return +Action::Follow. Read more
§

fn or<P, B, E>(self, other: P) -> Or<T, P>
where + T: Policy<B, E>, + P: Policy<B, E>,

Create a new Policy that returns [Action::Follow] if either self or other returns +Action::Follow. Read more
Source§

impl<T> Same for T

Source§

type Output = T

Should always be Self
§

impl<T> ScopeObject for T
where + T: Send + Sync + Debug + DeserializeOwned + 'static,

§

type Error = Error

The error type.
§

fn deserialize<R>( + _app: &AppHandle<R>, + raw: Value, +) -> Result<T, <T as ScopeObject>::Error>
where + R: Runtime,

Deserialize the raw scope value.
Source§

impl<T> Serialize for T
where + T: Serialize + ?Sized,

Source§

fn erased_serialize(&self, serializer: &mut dyn Serializer) -> Result<(), Error>

Source§

fn do_erased_serialize( + &self, + serializer: &mut dyn Serializer, +) -> Result<(), ErrorImpl>

Source§

impl<T> ToOwned for T
where + T: Clone,

Source§

type Owned = T

The resulting type after obtaining ownership.
Source§

fn to_owned(&self) -> T

Creates owned data from borrowed data, usually by cloning. Read more
Source§

fn clone_into(&self, target: &mut T)

Uses borrowed data to replace owned data, usually by cloning. Read more
Source§

impl<T, U> TryFrom<U> for T
where + U: Into<T>,

Source§

type Error = Infallible

The type returned in the event of a conversion error.
Source§

fn try_from(value: U) -> Result<T, <T as TryFrom<U>>::Error>

Performs the conversion.
Source§

impl<T, U> TryInto<U> for T
where + U: TryFrom<T>,

Source§

type Error = <U as TryFrom<T>>::Error

The type returned in the event of a conversion error.
Source§

fn try_into(self) -> Result<U, <U as TryFrom<T>>::Error>

Performs the conversion.
§

impl<V, T> VZip<V> for T
where + V: MultiLane<T>,

§

fn vzip(self) -> V

§

impl<T> WithSubscriber for T

§

fn with_subscriber<S>(self, subscriber: S) -> WithDispatch<Self>
where + S: Into<Dispatch>,

Attaches the provided Subscriber to this type, returning a +[WithDispatch] wrapper. Read more
§

fn with_current_subscriber(self) -> WithDispatch<Self>

Attaches the current default Subscriber to this type, returning a +[WithDispatch] wrapper. Read more
Source§

impl<T> DeserializeOwned for T
where + T: for<'de> Deserialize<'de>,

§

impl<T> UserEvent for T
where + T: Debug + Clone + Send + 'static,

\ No newline at end of file diff --git a/api/jellytau_lib/commands/download/fn.album_file_names.html b/api/jellytau_lib/commands/download/fn.album_file_names.html new file mode 100644 index 00000000..84c330b6 --- /dev/null +++ b/api/jellytau_lib/commands/download/fn.album_file_names.html @@ -0,0 +1,19 @@ +album_file_names in jellytau_lib::commands::download - RustSkip to main content

album_file_names

Function album_file_names 

Source
pub(crate) fn album_file_names(tracks: &[AlbumTrack]) -> Vec<String>
Expand description

Queue one download row per track and link every track to its album.

+

The linkage is the half that is easy to miss: offline browsing joins a track +to its album on items.album_id (see OfflineRepository::get_items), so a +track whose cached row lacks it stays invisible under the album even after +its file is on disk. Queuing a track is the statement that it belongs to +this album, so the link is written here rather than hoped for from whichever +listing endpoint happened to cache the row.

+

Idempotent: re-queuing an album fills in what is missing and returns the same +row ids, in the order the tracks were given.

+

A file name per track, unique within the album.

+

A title is not a unique name inside its own album: a deluxe edition carries +the album version and a demo of the same song, and a two-disc set repeats +titles across discs. Naming files after the title alone gave those tracks one +path, and each download overwrote the previous one — an album that quietly +ends up short by however many titles it repeats. The track number +disambiguates the ordinary case; anything still colliding falls back to the +item id, which is unique by construction.

+

TRACES: UR-018, UR-055 | DR-173 | UT-172

+
\ No newline at end of file diff --git a/api/jellytau_lib/commands/download/fn.cached_album_tracks.html b/api/jellytau_lib/commands/download/fn.cached_album_tracks.html new file mode 100644 index 00000000..4bc5e5e3 --- /dev/null +++ b/api/jellytau_lib/commands/download/fn.cached_album_tracks.html @@ -0,0 +1,9 @@ +cached_album_tracks in jellytau_lib::commands::download - RustSkip to main content

cached_album_tracks

Function cached_album_tracks 

Source
pub(crate) async fn cached_album_tracks(
+    db_service: &Arc<RusqliteService>,
+    album_id: &str,
+) -> Result<Vec<AlbumTrack>, String>
Expand description

The album’s tracks as the local catalog cache knows them.

+

Only a fallback for download_album: the cache links a track to its album +through items.album_id, which Jellyfin does not populate on every listing +endpoint, so this can legitimately return fewer tracks than the album has.

+

TRACES: UR-018, UR-055 | DR-173

+
\ No newline at end of file diff --git a/api/jellytau_lib/commands/download/fn.cancel_download.html b/api/jellytau_lib/commands/download/fn.cancel_download.html new file mode 100644 index 00000000..8152ee4e --- /dev/null +++ b/api/jellytau_lib/commands/download/fn.cancel_download.html @@ -0,0 +1,6 @@ +cancel_download in jellytau_lib::commands::download - RustSkip to main content

cancel_download

Function cancel_download 

Source
pub async fn cancel_download(
+    db: State<'_, DatabaseWrapper>,
+    download_manager: State<'_, DownloadManagerWrapper>,
+    download_id: i64,
+) -> Result<(), String>
Expand description

Cancel a download

+
\ No newline at end of file diff --git a/api/jellytau_lib/commands/download/fn.clear_stale_downloads.html b/api/jellytau_lib/commands/download/fn.clear_stale_downloads.html new file mode 100644 index 00000000..b1cadd2e --- /dev/null +++ b/api/jellytau_lib/commands/download/fn.clear_stale_downloads.html @@ -0,0 +1,5 @@ +clear_stale_downloads in jellytau_lib::commands::download - RustSkip to main content

clear_stale_downloads

Function clear_stale_downloads 

Source
pub async fn clear_stale_downloads(
+    db: State<'_, DatabaseWrapper>,
+    user_id: String,
+) -> Result<i64, String>
Expand description

Clear all stale pending/failed/paused downloads

+
\ No newline at end of file diff --git a/api/jellytau_lib/commands/download/fn.compute_download_stats.html b/api/jellytau_lib/commands/download/fn.compute_download_stats.html new file mode 100644 index 00000000..5f3b4659 --- /dev/null +++ b/api/jellytau_lib/commands/download/fn.compute_download_stats.html @@ -0,0 +1,2 @@ +compute_download_stats in jellytau_lib::commands::download - RustSkip to main content

compute_download_stats

Function compute_download_stats 

Source
fn compute_download_stats(downloads: &[DownloadInfo]) -> DownloadStats
Expand description

Helper to compute download statistics from a list of downloads

+
\ No newline at end of file diff --git a/api/jellytau_lib/commands/download/fn.confine_queued_path.html b/api/jellytau_lib/commands/download/fn.confine_queued_path.html new file mode 100644 index 00000000..5e4b559b --- /dev/null +++ b/api/jellytau_lib/commands/download/fn.confine_queued_path.html @@ -0,0 +1,7 @@ +confine_queued_path in jellytau_lib::commands::download - RustSkip to main content

confine_queued_path

Function confine_queued_path 

Source
fn confine_queued_path(root: &Path, file_path: &str) -> Result<String, String>
Expand description

Sanitize a queued download’s path and confine it to the download directory.

+

Every path the app builds for itself comes back unchanged — files on disk and +downloads rows point at these exact spellings — and sanitize_filename +is idempotent, so the already-safe name download_item_and_start passes in +is not sanitized into a second, different one.

+

TRACES: DR-211 | UT-205

+
\ No newline at end of file diff --git a/api/jellytau_lib/commands/download/fn.confine_to_root.html b/api/jellytau_lib/commands/download/fn.confine_to_root.html new file mode 100644 index 00000000..365d79ef --- /dev/null +++ b/api/jellytau_lib/commands/download/fn.confine_to_root.html @@ -0,0 +1,8 @@ +confine_to_root in jellytau_lib::commands::download - RustSkip to main content

confine_to_root

Function confine_to_root 

Source
fn confine_to_root(root: &Path, candidate: &Path) -> Result<PathBuf, String>
Expand description

Fold .. out of candidate and require what is left to sit inside root.

+

Lexical rather than canonicalize, the same way media_server::resolve_path +does it: the file usually does not exist yet, so canonicalising would fail on +the ordinary case. The check has to come after the caller’s join, because +Path::join drops the base when the joined half is absolute — such a path is +not folded, it is obeyed, and only the starts_with below catches it.

+

TRACES: DR-211 | UT-205

+
\ No newline at end of file diff --git a/api/jellytau_lib/commands/download/fn.delete_album_downloads.html b/api/jellytau_lib/commands/download/fn.delete_album_downloads.html new file mode 100644 index 00000000..017bc533 --- /dev/null +++ b/api/jellytau_lib/commands/download/fn.delete_album_downloads.html @@ -0,0 +1,6 @@ +delete_album_downloads in jellytau_lib::commands::download - RustSkip to main content

delete_album_downloads

Function delete_album_downloads 

Source
pub async fn delete_album_downloads(
+    db: State<'_, DatabaseWrapper>,
+    album_id: String,
+    user_id: String,
+) -> Result<i64, String>
Expand description

Delete all downloads for a specific album

+
\ No newline at end of file diff --git a/api/jellytau_lib/commands/download/fn.delete_all_downloads.html b/api/jellytau_lib/commands/download/fn.delete_all_downloads.html new file mode 100644 index 00000000..e7f116c5 --- /dev/null +++ b/api/jellytau_lib/commands/download/fn.delete_all_downloads.html @@ -0,0 +1,5 @@ +delete_all_downloads in jellytau_lib::commands::download - RustSkip to main content

delete_all_downloads

Function delete_all_downloads 

Source
pub async fn delete_all_downloads(
+    db: State<'_, DatabaseWrapper>,
+    user_id: String,
+) -> Result<i64, String>
Expand description

Delete all downloads for a user

+
\ No newline at end of file diff --git a/api/jellytau_lib/commands/download/fn.delete_download.html b/api/jellytau_lib/commands/download/fn.delete_download.html new file mode 100644 index 00000000..0f85532d --- /dev/null +++ b/api/jellytau_lib/commands/download/fn.delete_download.html @@ -0,0 +1,5 @@ +delete_download in jellytau_lib::commands::download - RustSkip to main content

delete_download

Function delete_download 

Source
pub async fn delete_download(
+    db: State<'_, DatabaseWrapper>,
+    download_id: i64,
+) -> Result<(), String>
Expand description

Delete a completed download

+
\ No newline at end of file diff --git a/api/jellytau_lib/commands/download/fn.delete_downloads_under.html b/api/jellytau_lib/commands/download/fn.delete_downloads_under.html new file mode 100644 index 00000000..43adabf3 --- /dev/null +++ b/api/jellytau_lib/commands/download/fn.delete_downloads_under.html @@ -0,0 +1,11 @@ +delete_downloads_under in jellytau_lib::commands::download - RustSkip to main content

delete_downloads_under

Function delete_downloads_under 

Source
pub async fn delete_downloads_under(
+    db: State<'_, DatabaseWrapper>,
+    item_id: String,
+    user_id: String,
+) -> Result<i64, String>
Expand description

Remove every completed download at or under a container item.

+

Works at any level of the Downloaded browse: a leaf (removes just that +download), an album/season/series (removes all downloaded descendants linked +via album_id/season_id/series_id/parent_id). Deletes the DB rows and the +on-disk files. Returns the number of downloads removed. Idempotent.

+

TRACES: UR-055 | DR-083

+
\ No newline at end of file diff --git a/api/jellytau_lib/commands/download/fn.download_album.html b/api/jellytau_lib/commands/download/fn.download_album.html new file mode 100644 index 00000000..afc4aeb1 --- /dev/null +++ b/api/jellytau_lib/commands/download/fn.download_album.html @@ -0,0 +1,21 @@ +download_album in jellytau_lib::commands::download - RustSkip to main content

download_album

Function download_album 

Source
pub async fn download_album(
+    db: State<'_, DatabaseWrapper>,
+    repository: State<'_, RepositoryManagerWrapper>,
+    download_manager: State<'_, DownloadManagerWrapper>,
+    app: AppHandle,
+    handle: String,
+    album_id: String,
+    user_id: String,
+    base_path: String,
+) -> Result<Vec<i64>, String>
Expand description

Queue an entire album for download.

+

Owns the whole operation: the album’s track list comes from the server (the +only place that knows all of it), every track is queued and linked to its +album, each row’s stream URL is resolved here, and the queue is pumped.

+

The frontend used to do the second half — resolve one URL per track and pair +it with the returned ids by position. That pairing had no basis: the ids +came back in the backend’s own order over a different set of rows, so +whenever the two lists disagreed a row was handed another track’s URL, and +any track past the end of the shorter list was never started at all. Nothing +crosses the boundary now except the album id.

+

TRACES: UR-018, UR-055 | DR-173 | UT-170

+
\ No newline at end of file diff --git a/api/jellytau_lib/commands/download/fn.download_item.html b/api/jellytau_lib/commands/download/fn.download_item.html new file mode 100644 index 00000000..a2d9e4d2 --- /dev/null +++ b/api/jellytau_lib/commands/download/fn.download_item.html @@ -0,0 +1,6 @@ +download_item in jellytau_lib::commands::download - RustSkip to main content

download_item

Function download_item 

Source
pub async fn download_item(
+    db: State<'_, DatabaseWrapper>,
+    smart_cache: State<'_, SmartCacheWrapper>,
+    request: DownloadItemRequest,
+) -> Result<i64, String>
Expand description

Queue a media item for download

+
\ No newline at end of file diff --git a/api/jellytau_lib/commands/download/fn.download_item_and_start.html b/api/jellytau_lib/commands/download/fn.download_item_and_start.html new file mode 100644 index 00000000..81baf396 --- /dev/null +++ b/api/jellytau_lib/commands/download/fn.download_item_and_start.html @@ -0,0 +1,9 @@ +download_item_and_start in jellytau_lib::commands::download - RustSkip to main content

download_item_and_start

Function download_item_and_start 

Source
pub async fn download_item_and_start(
+    db: State<'_, DatabaseWrapper>,
+    smart_cache: State<'_, SmartCacheWrapper>,
+    download_manager: State<'_, DownloadManagerWrapper>,
+    app: AppHandle,
+    request: DownloadItemAndStartRequest,
+) -> Result<i64, String>
Expand description

Queue and start a download in a single atomic operation +This simplifies the frontend flow by combining multiple steps

+
\ No newline at end of file diff --git a/api/jellytau_lib/commands/download/fn.download_root.html b/api/jellytau_lib/commands/download/fn.download_root.html new file mode 100644 index 00000000..2338196e --- /dev/null +++ b/api/jellytau_lib/commands/download/fn.download_root.html @@ -0,0 +1,4 @@ +download_root in jellytau_lib::commands::download - RustSkip to main content

download_root

Function download_root 

Source
fn download_root(db: &DatabaseWrapper) -> Result<PathBuf, String>
Expand description

The directory every download has to stay inside: the storage root +storage_get_path hands the frontend, which is the database’s parent.

+

TRACES: DR-211 | UT-205

+
\ No newline at end of file diff --git a/api/jellytau_lib/commands/download/fn.download_season.html b/api/jellytau_lib/commands/download/fn.download_season.html new file mode 100644 index 00000000..9daa6bfb --- /dev/null +++ b/api/jellytau_lib/commands/download/fn.download_season.html @@ -0,0 +1,11 @@ +download_season in jellytau_lib::commands::download - RustSkip to main content

download_season

Function download_season 

Source
pub async fn download_season(
+    db: State<'_, DatabaseWrapper>,
+    season_id: String,
+    series_name: String,
+    season_name: String,
+    season_number: i32,
+    user_id: String,
+    base_path: String,
+    quality_preset: Option<String>,
+) -> Result<Vec<i64>, String>
Expand description

Queue all episodes of a specific season for download

+
\ No newline at end of file diff --git a/api/jellytau_lib/commands/download/fn.download_series.html b/api/jellytau_lib/commands/download/fn.download_series.html new file mode 100644 index 00000000..9e574a4e --- /dev/null +++ b/api/jellytau_lib/commands/download/fn.download_series.html @@ -0,0 +1,9 @@ +download_series in jellytau_lib::commands::download - RustSkip to main content

download_series

Function download_series 

Source
pub async fn download_series(
+    db: State<'_, DatabaseWrapper>,
+    series_id: String,
+    series_name: String,
+    user_id: String,
+    base_path: String,
+    quality_preset: Option<String>,
+) -> Result<Vec<i64>, String>
Expand description

Queue all episodes of a series for download

+
\ No newline at end of file diff --git a/api/jellytau_lib/commands/download/fn.download_video.html b/api/jellytau_lib/commands/download/fn.download_video.html new file mode 100644 index 00000000..d7a403b3 --- /dev/null +++ b/api/jellytau_lib/commands/download/fn.download_video.html @@ -0,0 +1,5 @@ +download_video in jellytau_lib::commands::download - RustSkip to main content

download_video

Function download_video 

Source
pub async fn download_video(
+    db: State<'_, DatabaseWrapper>,
+    request: DownloadVideoRequest,
+) -> Result<i64, String>
Expand description

Queue a video item (movie or episode) for download with quality preset

+
\ No newline at end of file diff --git a/api/jellytau_lib/commands/download/fn.downloads_allowed_on_current_network.html b/api/jellytau_lib/commands/download/fn.downloads_allowed_on_current_network.html new file mode 100644 index 00000000..d336213e --- /dev/null +++ b/api/jellytau_lib/commands/download/fn.downloads_allowed_on_current_network.html @@ -0,0 +1,10 @@ +downloads_allowed_on_current_network in jellytau_lib::commands::download - RustSkip to main content

downloads_allowed_on_current_network

Function downloads_allowed_on_current_network 

Source
pub(crate) async fn downloads_allowed_on_current_network(
+    app: &AppHandle,
+) -> bool
Expand description

Whether the current network permits downloads, given the user’s WiFi-only +preference.

+

Reads wifi_only from the SmartCache config (the single home of the +setting) and checks it against the transport reported by the platform. On +desktop the transport defaults to unmetered ethernet, so this is always +true there.

+

TRACES: UR-053 | DR-074

+
\ No newline at end of file diff --git a/api/jellytau_lib/commands/download/fn.enqueue_download.html b/api/jellytau_lib/commands/download/fn.enqueue_download.html new file mode 100644 index 00000000..35fd84a2 --- /dev/null +++ b/api/jellytau_lib/commands/download/fn.enqueue_download.html @@ -0,0 +1,14 @@ +enqueue_download in jellytau_lib::commands::download - RustSkip to main content

enqueue_download

Function enqueue_download 

Source
pub async fn enqueue_download(
+    db: State<'_, DatabaseWrapper>,
+    download_manager: State<'_, DownloadManagerWrapper>,
+    app: AppHandle,
+    download_id: i64,
+    stream_url: String,
+    target_dir: String,
+) -> Result<(), String>
Expand description

Enqueue a download with its resolved stream URL, then let the queue pump +start it (or a higher-priority pending item) when a slot is free.

+

Unlike start_download, this never errors when the concurrency limit is +reached: the URL is persisted on the row and the pump will pick it up once a +slot frees. This is the path bulk operations (album/series/season) use so +every queued item eventually downloads without the frontend re-issuing it.

+
\ No newline at end of file diff --git a/api/jellytau_lib/commands/download/fn.enqueue_video_downloads.html b/api/jellytau_lib/commands/download/fn.enqueue_video_downloads.html new file mode 100644 index 00000000..80e2771c --- /dev/null +++ b/api/jellytau_lib/commands/download/fn.enqueue_video_downloads.html @@ -0,0 +1,16 @@ +enqueue_video_downloads in jellytau_lib::commands::download - RustSkip to main content

enqueue_video_downloads

Function enqueue_video_downloads 

Source
pub async fn enqueue_video_downloads(
+    db: State<'_, DatabaseWrapper>,
+    download_manager: State<'_, DownloadManagerWrapper>,
+    repository: State<'_, RepositoryManagerWrapper>,
+    app: AppHandle,
+    handle: String,
+    download_ids: Vec<i64>,
+    target_dir: String,
+) -> Result<(), String>
Expand description

Enqueue a batch of already-queued video downloads, resolving each one’s +transcode URL from the repository using the quality_preset stored on the +row. Then let the pump start them subject to the concurrency limit.

+

This is the bulk video path (series/season): download_series/ +download_season insert the rows, then this resolves URLs and enqueues them +so they actually start. Resolving server-side avoids round-tripping every +episode URL through the frontend.

+
\ No newline at end of file diff --git a/api/jellytau_lib/commands/download/fn.get_download_manager_stats.html b/api/jellytau_lib/commands/download/fn.get_download_manager_stats.html new file mode 100644 index 00000000..8404209d --- /dev/null +++ b/api/jellytau_lib/commands/download/fn.get_download_manager_stats.html @@ -0,0 +1,4 @@ +get_download_manager_stats in jellytau_lib::commands::download - RustSkip to main content

get_download_manager_stats

Function get_download_manager_stats 

Source
pub async fn get_download_manager_stats(
+    download_manager: State<'_, DownloadManagerWrapper>,
+) -> Result<DownloadManagerStats, String>
Expand description

Get download manager statistics

+
\ No newline at end of file diff --git a/api/jellytau_lib/commands/download/fn.get_download_storage_stats.html b/api/jellytau_lib/commands/download/fn.get_download_storage_stats.html new file mode 100644 index 00000000..17c09b6a --- /dev/null +++ b/api/jellytau_lib/commands/download/fn.get_download_storage_stats.html @@ -0,0 +1,5 @@ +get_download_storage_stats in jellytau_lib::commands::download - RustSkip to main content

get_download_storage_stats

Function get_download_storage_stats 

Source
pub async fn get_download_storage_stats(
+    db: State<'_, DatabaseWrapper>,
+    user_id: String,
+) -> Result<StorageStats, String>
Expand description

Get storage statistics for downloads

+
\ No newline at end of file diff --git a/api/jellytau_lib/commands/download/fn.get_downloads.html b/api/jellytau_lib/commands/download/fn.get_downloads.html new file mode 100644 index 00000000..2f86ff68 --- /dev/null +++ b/api/jellytau_lib/commands/download/fn.get_downloads.html @@ -0,0 +1,6 @@ +get_downloads in jellytau_lib::commands::download - RustSkip to main content

get_downloads

Function get_downloads 

Source
pub async fn get_downloads(
+    db: State<'_, DatabaseWrapper>,
+    user_id: String,
+    status_filter: Option<Vec<String>>,
+) -> Result<DownloadsResponse, String>
Expand description

Get all downloads for a user, optionally filtered by status

+
\ No newline at end of file diff --git a/api/jellytau_lib/commands/download/fn.get_downloads_allowed.html b/api/jellytau_lib/commands/download/fn.get_downloads_allowed.html new file mode 100644 index 00000000..c73f2eda --- /dev/null +++ b/api/jellytau_lib/commands/download/fn.get_downloads_allowed.html @@ -0,0 +1,5 @@ +get_downloads_allowed in jellytau_lib::commands::download - RustSkip to main content

get_downloads_allowed

Function get_downloads_allowed 

Source
pub async fn get_downloads_allowed(app: AppHandle) -> Result<bool, String>
Expand description

Whether downloads are currently permitted by the WiFi-only gate.

+

The downloads UI uses this to render “Waiting for WiFi” on pending rows +rather than leaving them looking silently stuck.

+

TRACES: UR-053 | DR-074

+
\ No newline at end of file diff --git a/api/jellytau_lib/commands/download/fn.map_download_row.html b/api/jellytau_lib/commands/download/fn.map_download_row.html new file mode 100644 index 00000000..fbf8b05a --- /dev/null +++ b/api/jellytau_lib/commands/download/fn.map_download_row.html @@ -0,0 +1,2 @@ +map_download_row in jellytau_lib::commands::download - RustSkip to main content

map_download_row

Function map_download_row 

Source
fn map_download_row(row: &Row<'_>) -> Result<DownloadInfo>
Expand description

Helper function to map database row to DownloadInfo

+
\ No newline at end of file diff --git a/api/jellytau_lib/commands/download/fn.mark_download_completed.html b/api/jellytau_lib/commands/download/fn.mark_download_completed.html new file mode 100644 index 00000000..b007d5a6 --- /dev/null +++ b/api/jellytau_lib/commands/download/fn.mark_download_completed.html @@ -0,0 +1,7 @@ +mark_download_completed in jellytau_lib::commands::download - RustSkip to main content

mark_download_completed

Function mark_download_completed 

Source
pub async fn mark_download_completed(
+    db: State<'_, DatabaseWrapper>,
+    download_id: i64,
+    bytes_downloaded: i64,
+    file_path: String,
+) -> Result<(), String>
Expand description

Mark a download as completed

+
\ No newline at end of file diff --git a/api/jellytau_lib/commands/download/fn.mark_download_failed.html b/api/jellytau_lib/commands/download/fn.mark_download_failed.html new file mode 100644 index 00000000..27617264 --- /dev/null +++ b/api/jellytau_lib/commands/download/fn.mark_download_failed.html @@ -0,0 +1,6 @@ +mark_download_failed in jellytau_lib::commands::download - RustSkip to main content

mark_download_failed

Function mark_download_failed 

Source
pub async fn mark_download_failed(
+    db: State<'_, DatabaseWrapper>,
+    download_id: i64,
+    error_message: String,
+) -> Result<(), String>
Expand description

Mark a download as failed

+
\ No newline at end of file diff --git a/api/jellytau_lib/commands/download/fn.pause_download.html b/api/jellytau_lib/commands/download/fn.pause_download.html new file mode 100644 index 00000000..e45e5fa3 --- /dev/null +++ b/api/jellytau_lib/commands/download/fn.pause_download.html @@ -0,0 +1,14 @@ +pause_download in jellytau_lib::commands::download - RustSkip to main content

pause_download

Function pause_download 

Source
pub async fn pause_download(
+    db: State<'_, DatabaseWrapper>,
+    download_id: i64,
+) -> Result<(), String>
Expand description

Pause a download.

+

Writing status = 'paused' is only half of it, and used to be all of it: the +streaming task knew nothing about the row and kept running, then overwrote it +with completed/failed when it finished. The row flicked to “paused” and +undid itself — the reported “pause does not work”. Signalling the worker is +what actually stops the bytes; it leaves the .part file in place so +resume_download can continue from it.

+

A queued (not yet started) download has no worker to signal, and the status +write alone is enough — the pump skips anything that is not pending.

+

TRACES: UR-055 | DR-168

+
\ No newline at end of file diff --git a/api/jellytau_lib/commands/download/fn.pump_download_queue.html b/api/jellytau_lib/commands/download/fn.pump_download_queue.html new file mode 100644 index 00000000..32b95590 --- /dev/null +++ b/api/jellytau_lib/commands/download/fn.pump_download_queue.html @@ -0,0 +1,10 @@ +pump_download_queue in jellytau_lib::commands::download - RustSkip to main content

pump_download_queue

Function pump_download_queue 

Source
pub(crate) async fn pump_download_queue(
+    app: AppHandle,
+    db_service: Arc<RusqliteService>,
+    active_downloads: Arc<Mutex<HashSet<i64>>>,
+)
Expand description

Start as many pending downloads as there are free concurrency slots.

+

Picks the highest-priority pending rows that have a persisted stream_url +(FIFO within a priority), registers each, flips it to downloading, and +spawns a worker. Each spawned worker calls this again on completion/failure, +so the queue drains itself without any frontend involvement.

+
\ No newline at end of file diff --git a/api/jellytau_lib/commands/download/fn.queue_album_tracks.html b/api/jellytau_lib/commands/download/fn.queue_album_tracks.html new file mode 100644 index 00000000..af6bc33a --- /dev/null +++ b/api/jellytau_lib/commands/download/fn.queue_album_tracks.html @@ -0,0 +1,8 @@ +queue_album_tracks in jellytau_lib::commands::download - RustSkip to main content

queue_album_tracks

Function queue_album_tracks 

Source
pub(crate) async fn queue_album_tracks(
+    db_service: &Arc<RusqliteService>,
+    album_id: &str,
+    tracks: &[AlbumTrack],
+    user_id: &str,
+    base_path: &str,
+) -> Result<Vec<i64>, String>
Expand description

TRACES: UR-018, UR-055 | DR-173 | UT-170

+
\ No newline at end of file diff --git a/api/jellytau_lib/commands/download/fn.resume_download.html b/api/jellytau_lib/commands/download/fn.resume_download.html new file mode 100644 index 00000000..a5e69631 --- /dev/null +++ b/api/jellytau_lib/commands/download/fn.resume_download.html @@ -0,0 +1,12 @@ +resume_download in jellytau_lib::commands::download - RustSkip to main content

resume_download

Function resume_download 

Source
pub async fn resume_download(
+    app: AppHandle,
+    db: State<'_, DatabaseWrapper>,
+    download_manager: State<'_, DownloadManagerWrapper>,
+    download_id: i64,
+) -> Result<(), String>
Expand description

Resume a paused download.

+

Flipping the row back to pending is likewise not enough on its own: the +pump is not a poller, it runs when something calls it, so a resumed download +sat untouched until some unrelated event happened to pump the queue. That is +the other half of “resume does not work”.

+

TRACES: UR-055 | DR-168

+
\ No newline at end of file diff --git a/api/jellytau_lib/commands/download/fn.sanitize_filename.html b/api/jellytau_lib/commands/download/fn.sanitize_filename.html new file mode 100644 index 00000000..e46cd29d --- /dev/null +++ b/api/jellytau_lib/commands/download/fn.sanitize_filename.html @@ -0,0 +1,2 @@ +sanitize_filename in jellytau_lib::commands::download - RustSkip to main content

sanitize_filename

Function sanitize_filename 

Source
fn sanitize_filename(name: &str) -> String
Expand description

Sanitize filename by removing invalid characters

+
\ No newline at end of file diff --git a/api/jellytau_lib/commands/download/fn.set_max_concurrent_downloads.html b/api/jellytau_lib/commands/download/fn.set_max_concurrent_downloads.html new file mode 100644 index 00000000..83e4b6f8 --- /dev/null +++ b/api/jellytau_lib/commands/download/fn.set_max_concurrent_downloads.html @@ -0,0 +1,5 @@ +set_max_concurrent_downloads in jellytau_lib::commands::download - RustSkip to main content

set_max_concurrent_downloads

Function set_max_concurrent_downloads 

Source
pub async fn set_max_concurrent_downloads(
+    download_manager: State<'_, DownloadManagerWrapper>,
+    max: usize,
+) -> Result<(), String>
Expand description

Set the maximum concurrent downloads

+
\ No newline at end of file diff --git a/api/jellytau_lib/commands/download/fn.set_network_state.html b/api/jellytau_lib/commands/download/fn.set_network_state.html new file mode 100644 index 00000000..6ff8df2e --- /dev/null +++ b/api/jellytau_lib/commands/download/fn.set_network_state.html @@ -0,0 +1,11 @@ +set_network_state in jellytau_lib::commands::download - RustSkip to main content

set_network_state

Function set_network_state 

Source
pub async fn set_network_state(
+    app: AppHandle,
+    network: NetworkStateWrapperArg,
+    db: State<'_, DatabaseWrapper>,
+    download_manager: State<'_, DownloadManagerWrapper>,
+) -> Result<(), String>
Expand description

Report the device’s current network transport (Android → Rust).

+

The frontend calls this on startup and whenever the native network callback +fires. Updating to an acceptable network re-pumps the download queue, so a +queue parked on “waiting for WiFi” drains itself without user action.

+

TRACES: UR-053 | DR-074

+
\ No newline at end of file diff --git a/api/jellytau_lib/commands/download/fn.spawn_download_worker.html b/api/jellytau_lib/commands/download/fn.spawn_download_worker.html new file mode 100644 index 00000000..e2ee2dda --- /dev/null +++ b/api/jellytau_lib/commands/download/fn.spawn_download_worker.html @@ -0,0 +1,11 @@ +spawn_download_worker in jellytau_lib::commands::download - RustSkip to main content

spawn_download_worker

Function spawn_download_worker 

Source
fn spawn_download_worker(
+    app: AppHandle,
+    download_id: i64,
+    item_id: String,
+    stream_url: String,
+    target_path: PathBuf,
+    active_downloads: Arc<Mutex<HashSet<i64>>>,
+)
Expand description

Spawn the background worker for one download. On completion or failure it +unregisters the slot, emits the terminal event, and pumps the queue so the +next pending download starts automatically.

+
\ No newline at end of file diff --git a/api/jellytau_lib/commands/download/fn.start_download.html b/api/jellytau_lib/commands/download/fn.start_download.html new file mode 100644 index 00000000..ffaad147 --- /dev/null +++ b/api/jellytau_lib/commands/download/fn.start_download.html @@ -0,0 +1,10 @@ +start_download in jellytau_lib::commands::download - RustSkip to main content

start_download

Function start_download 

Source
pub async fn start_download(
+    db: State<'_, DatabaseWrapper>,
+    download_manager: State<'_, DownloadManagerWrapper>,
+    app: AppHandle,
+    download_id: i64,
+    stream_url: String,
+    target_dir: String,
+) -> Result<(), String>
Expand description

Start downloading a file immediately +This command actually downloads the file using the worker

+
\ No newline at end of file diff --git a/api/jellytau_lib/commands/download/index.html b/api/jellytau_lib/commands/download/index.html new file mode 100644 index 00000000..b087acd9 --- /dev/null +++ b/api/jellytau_lib/commands/download/index.html @@ -0,0 +1,13 @@ +jellytau_lib::commands::download - RustSkip to main content

Module download

Module download 

Source
Expand description

Tauri commands for download operations

+

Re-exports§

pub use pinning::*;
pub use smart_cache::*;

Modules§

pinning 🔒
Pinning commands - protect an item’s cached metadata from cache clearing.
smart_cache 🔒
Smart-cache statistics/config and album recommendation commands.

Structs§

AlbumStorageInfo
Storage info for a single album
AlbumTrack 🔒
One track of an album, as the album-download path queues it.
DownloadItemAndStartRequest
Request payload for download_item_and_start (bundled to stay within specta’s +10-argument command limit).
DownloadItemRequest
Request payload for download_item.
DownloadManagerStats
Download manager statistics
DownloadManagerWrapper
Wrapper for DownloadManager to be used as Tauri state
DownloadStats
Download statistics computed server-side
DownloadVideoRequest
Request payload for download_video.
DownloadsResponse
Enhanced response with pre-computed stats
NetworkStateWrapper
Wrapper for the current network transport, used by the WiFi-only gate.
NetworkStateWrapperArg
Argument struct for set_network_state.
StorageStats
Storage statistics for downloads

Functions§

album_file_names 🔒
Queue one download row per track and link every track to its album.
cached_album_tracks 🔒
The album’s tracks as the local catalog cache knows them.
cancel_download
Cancel a download
clear_stale_downloads
Clear all stale pending/failed/paused downloads
compute_download_stats 🔒
Helper to compute download statistics from a list of downloads
confine_queued_path 🔒
Sanitize a queued download’s path and confine it to the download directory.
confine_to_root 🔒
Fold .. out of candidate and require what is left to sit inside root.
delete_album_downloads
Delete all downloads for a specific album
delete_all_downloads
Delete all downloads for a user
delete_download
Delete a completed download
delete_downloads_under
Remove every completed download at or under a container item.
download_album
Queue an entire album for download.
download_item
Queue a media item for download
download_item_and_start
Queue and start a download in a single atomic operation +This simplifies the frontend flow by combining multiple steps
download_root 🔒
The directory every download has to stay inside: the storage root +storage_get_path hands the frontend, which is the database’s parent.
download_season
Queue all episodes of a specific season for download
download_series
Queue all episodes of a series for download
download_video
Queue a video item (movie or episode) for download with quality preset
downloads_allowed_on_current_network 🔒
Whether the current network permits downloads, given the user’s WiFi-only +preference.
enqueue_download
Enqueue a download with its resolved stream URL, then let the queue pump +start it (or a higher-priority pending item) when a slot is free.
enqueue_video_downloads
Enqueue a batch of already-queued video downloads, resolving each one’s +transcode URL from the repository using the quality_preset stored on the +row. Then let the pump start them subject to the concurrency limit.
get_download_manager_stats
Get download manager statistics
get_download_storage_stats
Get storage statistics for downloads
get_downloads
Get all downloads for a user, optionally filtered by status
get_downloads_allowed
Whether downloads are currently permitted by the WiFi-only gate.
map_download_row 🔒
Helper function to map database row to DownloadInfo
mark_download_completed
Mark a download as completed
mark_download_failed
Mark a download as failed
pause_download
Pause a download.
pump_download_queue 🔒
Start as many pending downloads as there are free concurrency slots.
queue_album_tracks 🔒
TRACES: UR-018, UR-055 | DR-173 | UT-170
resume_download
Resume a paused download.
sanitize_filename 🔒
Sanitize filename by removing invalid characters
set_max_concurrent_downloads
Set the maximum concurrent downloads
set_network_state
Report the device’s current network transport (Android → Rust).
spawn_download_worker 🔒
Spawn the background worker for one download. On completion or failure it +unregisters the slot, emits the terminal event, and pumps the queue so the +next pending download starts automatically.
start_download
Start downloading a file immediately +This command actually downloads the file using the worker

Type Aliases§

EpisodeRow 🔒
One row of the series episode listing used when queueing a whole series: +(id, name, season_name, index_number, parent_index_number).
\ No newline at end of file diff --git a/api/jellytau_lib/commands/download/pinning/fn.is_item_pinned.html b/api/jellytau_lib/commands/download/pinning/fn.is_item_pinned.html new file mode 100644 index 00000000..2016c490 --- /dev/null +++ b/api/jellytau_lib/commands/download/pinning/fn.is_item_pinned.html @@ -0,0 +1,5 @@ +is_item_pinned in jellytau_lib::commands::download::pinning - RustSkip to main content

is_item_pinned

Function is_item_pinned 

Source
pub async fn is_item_pinned(
+    db: State<'_, DatabaseWrapper>,
+    item_id: String,
+) -> Result<bool, String>
Expand description

Check if an item is pinned

+
\ No newline at end of file diff --git a/api/jellytau_lib/commands/download/pinning/fn.pin_item.html b/api/jellytau_lib/commands/download/pinning/fn.pin_item.html new file mode 100644 index 00000000..da530d91 --- /dev/null +++ b/api/jellytau_lib/commands/download/pinning/fn.pin_item.html @@ -0,0 +1,5 @@ +pin_item in jellytau_lib::commands::download::pinning - RustSkip to main content

pin_item

Function pin_item 

Source
pub async fn pin_item(
+    db: State<'_, DatabaseWrapper>,
+    item_id: String,
+) -> Result<(), String>
Expand description

Pin an item’s metadata (protects from cache clear)

+
\ No newline at end of file diff --git a/api/jellytau_lib/commands/download/pinning/fn.unpin_item.html b/api/jellytau_lib/commands/download/pinning/fn.unpin_item.html new file mode 100644 index 00000000..c6c50662 --- /dev/null +++ b/api/jellytau_lib/commands/download/pinning/fn.unpin_item.html @@ -0,0 +1,5 @@ +unpin_item in jellytau_lib::commands::download::pinning - RustSkip to main content

unpin_item

Function unpin_item 

Source
pub async fn unpin_item(
+    db: State<'_, DatabaseWrapper>,
+    item_id: String,
+) -> Result<(), String>
Expand description

Unpin an item’s metadata

+
\ No newline at end of file diff --git a/api/jellytau_lib/commands/download/pinning/index.html b/api/jellytau_lib/commands/download/pinning/index.html new file mode 100644 index 00000000..7adddfc6 --- /dev/null +++ b/api/jellytau_lib/commands/download/pinning/index.html @@ -0,0 +1,3 @@ +jellytau_lib::commands::download::pinning - RustSkip to main content

Module pinning

Module pinning 

Source
Expand description

Pinning commands - protect an item’s cached metadata from cache clearing.

+

TRACES: UR-044 | DR-056

+

Functions§

is_item_pinned
Check if an item is pinned
pin_item
Pin an item’s metadata (protects from cache clear)
unpin_item
Unpin an item’s metadata
\ No newline at end of file diff --git a/api/jellytau_lib/commands/download/pinning/sidebar-items.js b/api/jellytau_lib/commands/download/pinning/sidebar-items.js new file mode 100644 index 00000000..a1530a90 --- /dev/null +++ b/api/jellytau_lib/commands/download/pinning/sidebar-items.js @@ -0,0 +1 @@ +window.SIDEBAR_ITEMS = {"fn":["is_item_pinned","pin_item","unpin_item"]}; \ No newline at end of file diff --git a/api/jellytau_lib/commands/download/sidebar-items.js b/api/jellytau_lib/commands/download/sidebar-items.js new file mode 100644 index 00000000..08757cf4 --- /dev/null +++ b/api/jellytau_lib/commands/download/sidebar-items.js @@ -0,0 +1 @@ +window.SIDEBAR_ITEMS = {"fn":["album_file_names","cached_album_tracks","cancel_download","clear_stale_downloads","compute_download_stats","confine_queued_path","confine_to_root","delete_album_downloads","delete_all_downloads","delete_download","delete_downloads_under","download_album","download_item","download_item_and_start","download_root","download_season","download_series","download_video","downloads_allowed_on_current_network","enqueue_download","enqueue_video_downloads","get_download_manager_stats","get_download_storage_stats","get_downloads","get_downloads_allowed","map_download_row","mark_download_completed","mark_download_failed","pause_download","pump_download_queue","queue_album_tracks","resume_download","sanitize_filename","set_max_concurrent_downloads","set_network_state","spawn_download_worker","start_download"],"mod":["pinning","smart_cache"],"struct":["AlbumStorageInfo","AlbumTrack","DownloadItemAndStartRequest","DownloadItemRequest","DownloadManagerStats","DownloadManagerWrapper","DownloadStats","DownloadVideoRequest","DownloadsResponse","NetworkStateWrapper","NetworkStateWrapperArg","StorageStats"],"type":["EpisodeRow"]}; \ No newline at end of file diff --git a/api/jellytau_lib/commands/download/smart_cache/fn.get_album_affinity_status.html b/api/jellytau_lib/commands/download/smart_cache/fn.get_album_affinity_status.html new file mode 100644 index 00000000..7a1c6b24 --- /dev/null +++ b/api/jellytau_lib/commands/download/smart_cache/fn.get_album_affinity_status.html @@ -0,0 +1,5 @@ +get_album_affinity_status in jellytau_lib::commands::download::smart_cache - RustSkip to main content

get_album_affinity_status

Function get_album_affinity_status 

Source
pub fn get_album_affinity_status(
+    smart_cache: State<'_, SmartCacheWrapper>,
+) -> Result<Vec<AlbumAffinityStatus>, String>
Expand description

Get album affinity status for all tracked albums +This shows the SmartCache’s internal play history and threshold status

+
\ No newline at end of file diff --git a/api/jellytau_lib/commands/download/smart_cache/fn.get_album_recommendations.html b/api/jellytau_lib/commands/download/smart_cache/fn.get_album_recommendations.html new file mode 100644 index 00000000..18ea8fa5 --- /dev/null +++ b/api/jellytau_lib/commands/download/smart_cache/fn.get_album_recommendations.html @@ -0,0 +1,6 @@ +get_album_recommendations in jellytau_lib::commands::download::smart_cache - RustSkip to main content

get_album_recommendations

Function get_album_recommendations 

Source
pub async fn get_album_recommendations(
+    db: State<'_, DatabaseWrapper>,
+    smart_cache: State<'_, SmartCacheWrapper>,
+    user_id: String,
+) -> Result<Vec<AlbumRecommendation>, String>
Expand description

Get album recommendations based on play history

+
\ No newline at end of file diff --git a/api/jellytau_lib/commands/download/smart_cache/fn.get_smart_cache_config.html b/api/jellytau_lib/commands/download/smart_cache/fn.get_smart_cache_config.html new file mode 100644 index 00000000..19e7deb2 --- /dev/null +++ b/api/jellytau_lib/commands/download/smart_cache/fn.get_smart_cache_config.html @@ -0,0 +1,4 @@ +get_smart_cache_config in jellytau_lib::commands::download::smart_cache - RustSkip to main content

get_smart_cache_config

Function get_smart_cache_config 

Source
pub async fn get_smart_cache_config(
+    smart_cache: State<'_, SmartCacheWrapper>,
+) -> Result<CacheConfig, String>
Expand description

Get SmartCache configuration

+
\ No newline at end of file diff --git a/api/jellytau_lib/commands/download/smart_cache/fn.get_smart_cache_stats.html b/api/jellytau_lib/commands/download/smart_cache/fn.get_smart_cache_stats.html new file mode 100644 index 00000000..1762b586 --- /dev/null +++ b/api/jellytau_lib/commands/download/smart_cache/fn.get_smart_cache_stats.html @@ -0,0 +1,6 @@ +get_smart_cache_stats in jellytau_lib::commands::download::smart_cache - RustSkip to main content

get_smart_cache_stats

Function get_smart_cache_stats 

Source
pub async fn get_smart_cache_stats(
+    db: State<'_, DatabaseWrapper>,
+    smart_cache: State<'_, SmartCacheWrapper>,
+    user_id: String,
+) -> Result<SmartCacheStats, String>
Expand description

Get SmartCache statistics

+
\ No newline at end of file diff --git a/api/jellytau_lib/commands/download/smart_cache/fn.update_smart_cache_config.html b/api/jellytau_lib/commands/download/smart_cache/fn.update_smart_cache_config.html new file mode 100644 index 00000000..13292e20 --- /dev/null +++ b/api/jellytau_lib/commands/download/smart_cache/fn.update_smart_cache_config.html @@ -0,0 +1,5 @@ +update_smart_cache_config in jellytau_lib::commands::download::smart_cache - RustSkip to main content

update_smart_cache_config

Function update_smart_cache_config 

Source
pub async fn update_smart_cache_config(
+    smart_cache: State<'_, SmartCacheWrapper>,
+    config: CacheConfig,
+) -> Result<(), String>
Expand description

Update SmartCache configuration

+
\ No newline at end of file diff --git a/api/jellytau_lib/commands/download/smart_cache/index.html b/api/jellytau_lib/commands/download/smart_cache/index.html new file mode 100644 index 00000000..bb9b6003 --- /dev/null +++ b/api/jellytau_lib/commands/download/smart_cache/index.html @@ -0,0 +1,4 @@ +jellytau_lib::commands::download::smart_cache - RustSkip to main content

Module smart_cache

Module smart_cache 

Source
Expand description

Smart-cache statistics/config and album recommendation commands.

+

TRACES: UR-045 | DR-057

+

Structs§

AlbumAffinityStatus
Album affinity status info
AlbumRecommendation
Album recommendation info
SmartCacheStats
SmartCache statistics

Functions§

get_album_affinity_status
Get album affinity status for all tracked albums +This shows the SmartCache’s internal play history and threshold status
get_album_recommendations
Get album recommendations based on play history
get_smart_cache_config
Get SmartCache configuration
get_smart_cache_stats
Get SmartCache statistics
update_smart_cache_config
Update SmartCache configuration
\ No newline at end of file diff --git a/api/jellytau_lib/commands/download/smart_cache/sidebar-items.js b/api/jellytau_lib/commands/download/smart_cache/sidebar-items.js new file mode 100644 index 00000000..60d2a61f --- /dev/null +++ b/api/jellytau_lib/commands/download/smart_cache/sidebar-items.js @@ -0,0 +1 @@ +window.SIDEBAR_ITEMS = {"fn":["get_album_affinity_status","get_album_recommendations","get_smart_cache_config","get_smart_cache_stats","update_smart_cache_config"],"struct":["AlbumAffinityStatus","AlbumRecommendation","SmartCacheStats"]}; \ No newline at end of file diff --git a/api/jellytau_lib/commands/download/smart_cache/struct.AlbumAffinityStatus.html b/api/jellytau_lib/commands/download/smart_cache/struct.AlbumAffinityStatus.html new file mode 100644 index 00000000..7fa5ede3 --- /dev/null +++ b/api/jellytau_lib/commands/download/smart_cache/struct.AlbumAffinityStatus.html @@ -0,0 +1,46 @@ +AlbumAffinityStatus in jellytau_lib::commands::download::smart_cache - RustSkip to main content

AlbumAffinityStatus

Struct AlbumAffinityStatus 

Source
pub struct AlbumAffinityStatus {
+    pub album_id: String,
+    pub unique_tracks_played: usize,
+    pub threshold: usize,
+    pub threshold_reached: bool,
+}
Expand description

Album affinity status info

+

Fields§

§album_id: String§unique_tracks_played: usize§threshold: usize§threshold_reached: bool

Trait Implementations§

Source§

impl Clone for AlbumAffinityStatus

Source§

fn clone(&self) -> AlbumAffinityStatus

Returns a duplicate of the value. Read more
1.0.0 (const: unstable) · Source§

fn clone_from(&mut self, source: &Self)

Performs copy-assignment from source. Read more
Source§

impl Debug for AlbumAffinityStatus

Source§

fn fmt(&self, f: &mut Formatter<'_>) -> Result

Formats the value using the given formatter. Read more
Source§

impl NamedType for AlbumAffinityStatus

Source§

fn sid() -> SpectaID

Source§

fn named_data_type( + type_map: &mut TypeCollection, + generics: &[DataType], +) -> NamedDataType

this is equivalent to [Type::inline] but returns a [NamedDataType] instead.
Source§

fn definition_named_data_type(type_map: &mut TypeCollection) -> NamedDataType

this is equivalent to [Type::definition] but returns a [NamedDataType] instead.
Source§

impl Serialize for AlbumAffinityStatus

Source§

fn serialize<__S>(&self, __serializer: __S) -> Result<__S::Ok, __S::Error>
where + __S: Serializer,

Serialize this value into the given Serde serializer. Read more
Source§

impl Type for AlbumAffinityStatus

Source§

fn inline(type_map: &mut TypeCollection, generics: Generics<'_>) -> DataType

Returns the definition of a type using the provided generics. Read more
Source§

fn reference(type_map: &mut TypeCollection, generics: &[DataType]) -> Reference

Generates a datatype corresponding to a reference to this type, +as determined by its category. Getting a reference to a type implies that +it should belong in the type map (since it has to be referenced from somewhere), +so the output of definition will be put into the type map.
Source§

impl Flatten for AlbumAffinityStatus

Auto Trait Implementations§

Blanket Implementations§

Source§

impl<T> Any for T
where + T: 'static + ?Sized,

Source§

fn type_id(&self) -> TypeId

Gets the TypeId of self. Read more
Source§

impl<T> Borrow<T> for T
where + T: ?Sized,

Source§

fn borrow(&self) -> &T

Immutably borrows from an owned value. Read more
Source§

impl<T> BorrowMut<T> for T
where + T: ?Sized,

Source§

fn borrow_mut(&mut self) -> &mut T

Mutably borrows from an owned value. Read more
Source§

impl<T> CloneToUninit for T
where + T: Clone,

Source§

unsafe fn clone_to_uninit(&self, dest: *mut u8)

🔬This is a nightly-only experimental API. (clone_to_uninit)
Performs copy-assignment from self to dest. Read more
Source§

impl<T> From<T> for T

Source§

fn from(t: T) -> T

Returns the argument unchanged.

+
§

impl<T> FunctionArg for T
where + T: Type,

§

fn to_datatype(type_map: &mut TypeCollection) -> Option<DataType>

Gets the type of an argument as a [DataType]. Read more
§

impl<T> Instrument for T

§

fn instrument(self, span: Span) -> Instrumented<Self>

Instruments this type with the provided [Span], returning an +Instrumented wrapper. Read more
§

fn in_current_span(self) -> Instrumented<Self>

Instruments this type with the current Span, returning an +Instrumented wrapper. Read more
Source§

impl<T, U> Into<U> for T
where + U: From<T>,

Source§

fn into(self) -> U

Calls U::from(self).

+

That is, this conversion is whatever the implementation of +From<T> for U chooses to do.

+
§

impl<T> IpcResponse for T
where + T: Serialize,

§

fn body(self) -> Result<InvokeResponseBody, Error>

Resolve the IPC response body.
§

impl<T> PolicyExt for T
where + T: ?Sized,

§

fn and<P, B, E>(self, other: P) -> And<T, P>
where + T: Policy<B, E>, + P: Policy<B, E>,

Create a new Policy that returns [Action::Follow] only if self and other return +Action::Follow. Read more
§

fn or<P, B, E>(self, other: P) -> Or<T, P>
where + T: Policy<B, E>, + P: Policy<B, E>,

Create a new Policy that returns [Action::Follow] if either self or other returns +Action::Follow. Read more
Source§

impl<T> Same for T

Source§

type Output = T

Should always be Self
Source§

impl<T> Serialize for T
where + T: Serialize + ?Sized,

Source§

fn erased_serialize(&self, serializer: &mut dyn Serializer) -> Result<(), Error>

Source§

fn do_erased_serialize( + &self, + serializer: &mut dyn Serializer, +) -> Result<(), ErrorImpl>

Source§

impl<T> ToOwned for T
where + T: Clone,

Source§

type Owned = T

The resulting type after obtaining ownership.
Source§

fn to_owned(&self) -> T

Creates owned data from borrowed data, usually by cloning. Read more
Source§

fn clone_into(&self, target: &mut T)

Uses borrowed data to replace owned data, usually by cloning. Read more
Source§

impl<T, U> TryFrom<U> for T
where + U: Into<T>,

Source§

type Error = Infallible

The type returned in the event of a conversion error.
Source§

fn try_from(value: U) -> Result<T, <T as TryFrom<U>>::Error>

Performs the conversion.
Source§

impl<T, U> TryInto<U> for T
where + U: TryFrom<T>,

Source§

type Error = <U as TryFrom<T>>::Error

The type returned in the event of a conversion error.
Source§

fn try_into(self) -> Result<U, <U as TryFrom<T>>::Error>

Performs the conversion.
§

impl<V, T> VZip<V> for T
where + V: MultiLane<T>,

§

fn vzip(self) -> V

§

impl<T> WithSubscriber for T

§

fn with_subscriber<S>(self, subscriber: S) -> WithDispatch<Self>
where + S: Into<Dispatch>,

Attaches the provided Subscriber to this type, returning a +[WithDispatch] wrapper. Read more
§

fn with_current_subscriber(self) -> WithDispatch<Self>

Attaches the current default Subscriber to this type, returning a +[WithDispatch] wrapper. Read more
§

impl<T> UserEvent for T
where + T: Debug + Clone + Send + 'static,

\ No newline at end of file diff --git a/api/jellytau_lib/commands/download/smart_cache/struct.AlbumRecommendation.html b/api/jellytau_lib/commands/download/smart_cache/struct.AlbumRecommendation.html new file mode 100644 index 00000000..32c3b162 --- /dev/null +++ b/api/jellytau_lib/commands/download/smart_cache/struct.AlbumRecommendation.html @@ -0,0 +1,47 @@ +AlbumRecommendation in jellytau_lib::commands::download::smart_cache - RustSkip to main content

AlbumRecommendation

Struct AlbumRecommendation 

Source
pub struct AlbumRecommendation {
+    pub album_id: String,
+    pub album_name: String,
+    pub tracks_played: usize,
+    pub total_tracks: usize,
+    pub should_download: bool,
+}
Expand description

Album recommendation info

+

Fields§

§album_id: String§album_name: String§tracks_played: usize§total_tracks: usize§should_download: bool

Trait Implementations§

Source§

impl Clone for AlbumRecommendation

Source§

fn clone(&self) -> AlbumRecommendation

Returns a duplicate of the value. Read more
1.0.0 (const: unstable) · Source§

fn clone_from(&mut self, source: &Self)

Performs copy-assignment from source. Read more
Source§

impl Debug for AlbumRecommendation

Source§

fn fmt(&self, f: &mut Formatter<'_>) -> Result

Formats the value using the given formatter. Read more
Source§

impl NamedType for AlbumRecommendation

Source§

fn sid() -> SpectaID

Source§

fn named_data_type( + type_map: &mut TypeCollection, + generics: &[DataType], +) -> NamedDataType

this is equivalent to [Type::inline] but returns a [NamedDataType] instead.
Source§

fn definition_named_data_type(type_map: &mut TypeCollection) -> NamedDataType

this is equivalent to [Type::definition] but returns a [NamedDataType] instead.
Source§

impl Serialize for AlbumRecommendation

Source§

fn serialize<__S>(&self, __serializer: __S) -> Result<__S::Ok, __S::Error>
where + __S: Serializer,

Serialize this value into the given Serde serializer. Read more
Source§

impl Type for AlbumRecommendation

Source§

fn inline(type_map: &mut TypeCollection, generics: Generics<'_>) -> DataType

Returns the definition of a type using the provided generics. Read more
Source§

fn reference(type_map: &mut TypeCollection, generics: &[DataType]) -> Reference

Generates a datatype corresponding to a reference to this type, +as determined by its category. Getting a reference to a type implies that +it should belong in the type map (since it has to be referenced from somewhere), +so the output of definition will be put into the type map.
Source§

impl Flatten for AlbumRecommendation

Auto Trait Implementations§

Blanket Implementations§

Source§

impl<T> Any for T
where + T: 'static + ?Sized,

Source§

fn type_id(&self) -> TypeId

Gets the TypeId of self. Read more
Source§

impl<T> Borrow<T> for T
where + T: ?Sized,

Source§

fn borrow(&self) -> &T

Immutably borrows from an owned value. Read more
Source§

impl<T> BorrowMut<T> for T
where + T: ?Sized,

Source§

fn borrow_mut(&mut self) -> &mut T

Mutably borrows from an owned value. Read more
Source§

impl<T> CloneToUninit for T
where + T: Clone,

Source§

unsafe fn clone_to_uninit(&self, dest: *mut u8)

🔬This is a nightly-only experimental API. (clone_to_uninit)
Performs copy-assignment from self to dest. Read more
Source§

impl<T> From<T> for T

Source§

fn from(t: T) -> T

Returns the argument unchanged.

+
§

impl<T> FunctionArg for T
where + T: Type,

§

fn to_datatype(type_map: &mut TypeCollection) -> Option<DataType>

Gets the type of an argument as a [DataType]. Read more
§

impl<T> Instrument for T

§

fn instrument(self, span: Span) -> Instrumented<Self>

Instruments this type with the provided [Span], returning an +Instrumented wrapper. Read more
§

fn in_current_span(self) -> Instrumented<Self>

Instruments this type with the current Span, returning an +Instrumented wrapper. Read more
Source§

impl<T, U> Into<U> for T
where + U: From<T>,

Source§

fn into(self) -> U

Calls U::from(self).

+

That is, this conversion is whatever the implementation of +From<T> for U chooses to do.

+
§

impl<T> IpcResponse for T
where + T: Serialize,

§

fn body(self) -> Result<InvokeResponseBody, Error>

Resolve the IPC response body.
§

impl<T> PolicyExt for T
where + T: ?Sized,

§

fn and<P, B, E>(self, other: P) -> And<T, P>
where + T: Policy<B, E>, + P: Policy<B, E>,

Create a new Policy that returns [Action::Follow] only if self and other return +Action::Follow. Read more
§

fn or<P, B, E>(self, other: P) -> Or<T, P>
where + T: Policy<B, E>, + P: Policy<B, E>,

Create a new Policy that returns [Action::Follow] if either self or other returns +Action::Follow. Read more
Source§

impl<T> Same for T

Source§

type Output = T

Should always be Self
Source§

impl<T> Serialize for T
where + T: Serialize + ?Sized,

Source§

fn erased_serialize(&self, serializer: &mut dyn Serializer) -> Result<(), Error>

Source§

fn do_erased_serialize( + &self, + serializer: &mut dyn Serializer, +) -> Result<(), ErrorImpl>

Source§

impl<T> ToOwned for T
where + T: Clone,

Source§

type Owned = T

The resulting type after obtaining ownership.
Source§

fn to_owned(&self) -> T

Creates owned data from borrowed data, usually by cloning. Read more
Source§

fn clone_into(&self, target: &mut T)

Uses borrowed data to replace owned data, usually by cloning. Read more
Source§

impl<T, U> TryFrom<U> for T
where + U: Into<T>,

Source§

type Error = Infallible

The type returned in the event of a conversion error.
Source§

fn try_from(value: U) -> Result<T, <T as TryFrom<U>>::Error>

Performs the conversion.
Source§

impl<T, U> TryInto<U> for T
where + U: TryFrom<T>,

Source§

type Error = <U as TryFrom<T>>::Error

The type returned in the event of a conversion error.
Source§

fn try_into(self) -> Result<U, <U as TryFrom<T>>::Error>

Performs the conversion.
§

impl<V, T> VZip<V> for T
where + V: MultiLane<T>,

§

fn vzip(self) -> V

§

impl<T> WithSubscriber for T

§

fn with_subscriber<S>(self, subscriber: S) -> WithDispatch<Self>
where + S: Into<Dispatch>,

Attaches the provided Subscriber to this type, returning a +[WithDispatch] wrapper. Read more
§

fn with_current_subscriber(self) -> WithDispatch<Self>

Attaches the current default Subscriber to this type, returning a +[WithDispatch] wrapper. Read more
§

impl<T> UserEvent for T
where + T: Debug + Clone + Send + 'static,

\ No newline at end of file diff --git a/api/jellytau_lib/commands/download/smart_cache/struct.SmartCacheStats.html b/api/jellytau_lib/commands/download/smart_cache/struct.SmartCacheStats.html new file mode 100644 index 00000000..d733cfe5 --- /dev/null +++ b/api/jellytau_lib/commands/download/smart_cache/struct.SmartCacheStats.html @@ -0,0 +1,47 @@ +SmartCacheStats in jellytau_lib::commands::download::smart_cache - RustSkip to main content

SmartCacheStats

Struct SmartCacheStats 

Source
pub struct SmartCacheStats {
+    pub total_size: u64,
+    pub storage_limit: u64,
+    pub available_space: u64,
+    pub items_count: i64,
+    pub config: CacheConfig,
+}
Expand description

SmartCache statistics

+

Fields§

§total_size: u64§storage_limit: u64§available_space: u64§items_count: i64§config: CacheConfig

Trait Implementations§

Source§

impl Clone for SmartCacheStats

Source§

fn clone(&self) -> SmartCacheStats

Returns a duplicate of the value. Read more
1.0.0 (const: unstable) · Source§

fn clone_from(&mut self, source: &Self)

Performs copy-assignment from source. Read more
Source§

impl Debug for SmartCacheStats

Source§

fn fmt(&self, f: &mut Formatter<'_>) -> Result

Formats the value using the given formatter. Read more
Source§

impl NamedType for SmartCacheStats

Source§

fn sid() -> SpectaID

Source§

fn named_data_type( + type_map: &mut TypeCollection, + generics: &[DataType], +) -> NamedDataType

this is equivalent to [Type::inline] but returns a [NamedDataType] instead.
Source§

fn definition_named_data_type(type_map: &mut TypeCollection) -> NamedDataType

this is equivalent to [Type::definition] but returns a [NamedDataType] instead.
Source§

impl Serialize for SmartCacheStats

Source§

fn serialize<__S>(&self, __serializer: __S) -> Result<__S::Ok, __S::Error>
where + __S: Serializer,

Serialize this value into the given Serde serializer. Read more
Source§

impl Type for SmartCacheStats

Source§

fn inline(type_map: &mut TypeCollection, generics: Generics<'_>) -> DataType

Returns the definition of a type using the provided generics. Read more
Source§

fn reference(type_map: &mut TypeCollection, generics: &[DataType]) -> Reference

Generates a datatype corresponding to a reference to this type, +as determined by its category. Getting a reference to a type implies that +it should belong in the type map (since it has to be referenced from somewhere), +so the output of definition will be put into the type map.
Source§

impl Flatten for SmartCacheStats

Auto Trait Implementations§

Blanket Implementations§

Source§

impl<T> Any for T
where + T: 'static + ?Sized,

Source§

fn type_id(&self) -> TypeId

Gets the TypeId of self. Read more
Source§

impl<T> Borrow<T> for T
where + T: ?Sized,

Source§

fn borrow(&self) -> &T

Immutably borrows from an owned value. Read more
Source§

impl<T> BorrowMut<T> for T
where + T: ?Sized,

Source§

fn borrow_mut(&mut self) -> &mut T

Mutably borrows from an owned value. Read more
Source§

impl<T> CloneToUninit for T
where + T: Clone,

Source§

unsafe fn clone_to_uninit(&self, dest: *mut u8)

🔬This is a nightly-only experimental API. (clone_to_uninit)
Performs copy-assignment from self to dest. Read more
Source§

impl<T> From<T> for T

Source§

fn from(t: T) -> T

Returns the argument unchanged.

+
§

impl<T> FunctionArg for T
where + T: Type,

§

fn to_datatype(type_map: &mut TypeCollection) -> Option<DataType>

Gets the type of an argument as a [DataType]. Read more
§

impl<T> Instrument for T

§

fn instrument(self, span: Span) -> Instrumented<Self>

Instruments this type with the provided [Span], returning an +Instrumented wrapper. Read more
§

fn in_current_span(self) -> Instrumented<Self>

Instruments this type with the current Span, returning an +Instrumented wrapper. Read more
Source§

impl<T, U> Into<U> for T
where + U: From<T>,

Source§

fn into(self) -> U

Calls U::from(self).

+

That is, this conversion is whatever the implementation of +From<T> for U chooses to do.

+
§

impl<T> IpcResponse for T
where + T: Serialize,

§

fn body(self) -> Result<InvokeResponseBody, Error>

Resolve the IPC response body.
§

impl<T> PolicyExt for T
where + T: ?Sized,

§

fn and<P, B, E>(self, other: P) -> And<T, P>
where + T: Policy<B, E>, + P: Policy<B, E>,

Create a new Policy that returns [Action::Follow] only if self and other return +Action::Follow. Read more
§

fn or<P, B, E>(self, other: P) -> Or<T, P>
where + T: Policy<B, E>, + P: Policy<B, E>,

Create a new Policy that returns [Action::Follow] if either self or other returns +Action::Follow. Read more
Source§

impl<T> Same for T

Source§

type Output = T

Should always be Self
Source§

impl<T> Serialize for T
where + T: Serialize + ?Sized,

Source§

fn erased_serialize(&self, serializer: &mut dyn Serializer) -> Result<(), Error>

Source§

fn do_erased_serialize( + &self, + serializer: &mut dyn Serializer, +) -> Result<(), ErrorImpl>

Source§

impl<T> ToOwned for T
where + T: Clone,

Source§

type Owned = T

The resulting type after obtaining ownership.
Source§

fn to_owned(&self) -> T

Creates owned data from borrowed data, usually by cloning. Read more
Source§

fn clone_into(&self, target: &mut T)

Uses borrowed data to replace owned data, usually by cloning. Read more
Source§

impl<T, U> TryFrom<U> for T
where + U: Into<T>,

Source§

type Error = Infallible

The type returned in the event of a conversion error.
Source§

fn try_from(value: U) -> Result<T, <T as TryFrom<U>>::Error>

Performs the conversion.
Source§

impl<T, U> TryInto<U> for T
where + U: TryFrom<T>,

Source§

type Error = <U as TryFrom<T>>::Error

The type returned in the event of a conversion error.
Source§

fn try_into(self) -> Result<U, <U as TryFrom<T>>::Error>

Performs the conversion.
§

impl<V, T> VZip<V> for T
where + V: MultiLane<T>,

§

fn vzip(self) -> V

§

impl<T> WithSubscriber for T

§

fn with_subscriber<S>(self, subscriber: S) -> WithDispatch<Self>
where + S: Into<Dispatch>,

Attaches the provided Subscriber to this type, returning a +[WithDispatch] wrapper. Read more
§

fn with_current_subscriber(self) -> WithDispatch<Self>

Attaches the current default Subscriber to this type, returning a +[WithDispatch] wrapper. Read more
§

impl<T> UserEvent for T
where + T: Debug + Clone + Send + 'static,

\ No newline at end of file diff --git a/api/jellytau_lib/commands/download/struct.AlbumStorageInfo.html b/api/jellytau_lib/commands/download/struct.AlbumStorageInfo.html new file mode 100644 index 00000000..3831bd2f --- /dev/null +++ b/api/jellytau_lib/commands/download/struct.AlbumStorageInfo.html @@ -0,0 +1,47 @@ +AlbumStorageInfo in jellytau_lib::commands::download - RustSkip to main content

AlbumStorageInfo

Struct AlbumStorageInfo 

Source
pub struct AlbumStorageInfo {
+    pub album_id: String,
+    pub album_name: String,
+    pub artist_name: Option<String>,
+    pub bytes_used: i64,
+    pub track_count: i64,
+}
Expand description

Storage info for a single album

+

Fields§

§album_id: String§album_name: String§artist_name: Option<String>§bytes_used: i64§track_count: i64

Trait Implementations§

Source§

impl Clone for AlbumStorageInfo

Source§

fn clone(&self) -> AlbumStorageInfo

Returns a duplicate of the value. Read more
1.0.0 (const: unstable) · Source§

fn clone_from(&mut self, source: &Self)

Performs copy-assignment from source. Read more
Source§

impl Debug for AlbumStorageInfo

Source§

fn fmt(&self, f: &mut Formatter<'_>) -> Result

Formats the value using the given formatter. Read more
Source§

impl NamedType for AlbumStorageInfo

Source§

fn sid() -> SpectaID

Source§

fn named_data_type( + type_map: &mut TypeCollection, + generics: &[DataType], +) -> NamedDataType

this is equivalent to [Type::inline] but returns a [NamedDataType] instead.
Source§

fn definition_named_data_type(type_map: &mut TypeCollection) -> NamedDataType

this is equivalent to [Type::definition] but returns a [NamedDataType] instead.
Source§

impl Serialize for AlbumStorageInfo

Source§

fn serialize<__S>(&self, __serializer: __S) -> Result<__S::Ok, __S::Error>
where + __S: Serializer,

Serialize this value into the given Serde serializer. Read more
Source§

impl Type for AlbumStorageInfo

Source§

fn inline(type_map: &mut TypeCollection, generics: Generics<'_>) -> DataType

Returns the definition of a type using the provided generics. Read more
Source§

fn reference(type_map: &mut TypeCollection, generics: &[DataType]) -> Reference

Generates a datatype corresponding to a reference to this type, +as determined by its category. Getting a reference to a type implies that +it should belong in the type map (since it has to be referenced from somewhere), +so the output of definition will be put into the type map.
Source§

impl Flatten for AlbumStorageInfo

Auto Trait Implementations§

Blanket Implementations§

Source§

impl<T> Any for T
where + T: 'static + ?Sized,

Source§

fn type_id(&self) -> TypeId

Gets the TypeId of self. Read more
Source§

impl<T> Borrow<T> for T
where + T: ?Sized,

Source§

fn borrow(&self) -> &T

Immutably borrows from an owned value. Read more
Source§

impl<T> BorrowMut<T> for T
where + T: ?Sized,

Source§

fn borrow_mut(&mut self) -> &mut T

Mutably borrows from an owned value. Read more
Source§

impl<T> CloneToUninit for T
where + T: Clone,

Source§

unsafe fn clone_to_uninit(&self, dest: *mut u8)

🔬This is a nightly-only experimental API. (clone_to_uninit)
Performs copy-assignment from self to dest. Read more
Source§

impl<T> From<T> for T

Source§

fn from(t: T) -> T

Returns the argument unchanged.

+
§

impl<T> FunctionArg for T
where + T: Type,

§

fn to_datatype(type_map: &mut TypeCollection) -> Option<DataType>

Gets the type of an argument as a [DataType]. Read more
§

impl<T> Instrument for T

§

fn instrument(self, span: Span) -> Instrumented<Self>

Instruments this type with the provided [Span], returning an +Instrumented wrapper. Read more
§

fn in_current_span(self) -> Instrumented<Self>

Instruments this type with the current Span, returning an +Instrumented wrapper. Read more
Source§

impl<T, U> Into<U> for T
where + U: From<T>,

Source§

fn into(self) -> U

Calls U::from(self).

+

That is, this conversion is whatever the implementation of +From<T> for U chooses to do.

+
§

impl<T> IpcResponse for T
where + T: Serialize,

§

fn body(self) -> Result<InvokeResponseBody, Error>

Resolve the IPC response body.
§

impl<T> PolicyExt for T
where + T: ?Sized,

§

fn and<P, B, E>(self, other: P) -> And<T, P>
where + T: Policy<B, E>, + P: Policy<B, E>,

Create a new Policy that returns [Action::Follow] only if self and other return +Action::Follow. Read more
§

fn or<P, B, E>(self, other: P) -> Or<T, P>
where + T: Policy<B, E>, + P: Policy<B, E>,

Create a new Policy that returns [Action::Follow] if either self or other returns +Action::Follow. Read more
Source§

impl<T> Same for T

Source§

type Output = T

Should always be Self
Source§

impl<T> Serialize for T
where + T: Serialize + ?Sized,

Source§

fn erased_serialize(&self, serializer: &mut dyn Serializer) -> Result<(), Error>

Source§

fn do_erased_serialize( + &self, + serializer: &mut dyn Serializer, +) -> Result<(), ErrorImpl>

Source§

impl<T> ToOwned for T
where + T: Clone,

Source§

type Owned = T

The resulting type after obtaining ownership.
Source§

fn to_owned(&self) -> T

Creates owned data from borrowed data, usually by cloning. Read more
Source§

fn clone_into(&self, target: &mut T)

Uses borrowed data to replace owned data, usually by cloning. Read more
Source§

impl<T, U> TryFrom<U> for T
where + U: Into<T>,

Source§

type Error = Infallible

The type returned in the event of a conversion error.
Source§

fn try_from(value: U) -> Result<T, <T as TryFrom<U>>::Error>

Performs the conversion.
Source§

impl<T, U> TryInto<U> for T
where + U: TryFrom<T>,

Source§

type Error = <U as TryFrom<T>>::Error

The type returned in the event of a conversion error.
Source§

fn try_into(self) -> Result<U, <U as TryFrom<T>>::Error>

Performs the conversion.
§

impl<V, T> VZip<V> for T
where + V: MultiLane<T>,

§

fn vzip(self) -> V

§

impl<T> WithSubscriber for T

§

fn with_subscriber<S>(self, subscriber: S) -> WithDispatch<Self>
where + S: Into<Dispatch>,

Attaches the provided Subscriber to this type, returning a +[WithDispatch] wrapper. Read more
§

fn with_current_subscriber(self) -> WithDispatch<Self>

Attaches the current default Subscriber to this type, returning a +[WithDispatch] wrapper. Read more
§

impl<T> UserEvent for T
where + T: Debug + Clone + Send + 'static,

\ No newline at end of file diff --git a/api/jellytau_lib/commands/download/struct.AlbumTrack.html b/api/jellytau_lib/commands/download/struct.AlbumTrack.html new file mode 100644 index 00000000..82672d82 --- /dev/null +++ b/api/jellytau_lib/commands/download/struct.AlbumTrack.html @@ -0,0 +1,39 @@ +AlbumTrack in jellytau_lib::commands::download - RustSkip to main content

AlbumTrack

Struct AlbumTrack 

Source
pub(crate) struct AlbumTrack {
+    pub id: String,
+    pub name: String,
+    pub artist_name: Option<String>,
+    pub album_name: Option<String>,
+    pub index_number: Option<i32>,
+}
Expand description

One track of an album, as the album-download path queues it.

+

artist_name carries whatever the catalog holds for the track’s artists (a +JSON array, as stored on items.artists); it is display metadata for the +downloads list, not a lookup key.

+

TRACES: UR-018, UR-055 | DR-173

+

Fields§

§id: String§name: String§artist_name: Option<String>§album_name: Option<String>§index_number: Option<i32>

Trait Implementations§

Source§

impl Clone for AlbumTrack

Source§

fn clone(&self) -> AlbumTrack

Returns a duplicate of the value. Read more
1.0.0 (const: unstable) · Source§

fn clone_from(&mut self, source: &Self)

Performs copy-assignment from source. Read more
Source§

impl Debug for AlbumTrack

Source§

fn fmt(&self, f: &mut Formatter<'_>) -> Result

Formats the value using the given formatter. Read more
Source§

impl From<&MediaItem> for AlbumTrack

Source§

fn from(item: &MediaItem) -> Self

Converts to this type from the input type.
Source§

impl PartialEq for AlbumTrack

Source§

fn eq(&self, other: &AlbumTrack) -> bool

Tests for self and other values to be equal, and is used by ==.
1.0.0 (const: unstable) · Source§

fn ne(&self, other: &Rhs) -> bool

Tests for !=. The default implementation is almost always sufficient, +and should not be overridden without very good reason.
Source§

impl StructuralPartialEq for AlbumTrack

Auto Trait Implementations§

Blanket Implementations§

Source§

impl<T> Any for T
where + T: 'static + ?Sized,

Source§

fn type_id(&self) -> TypeId

Gets the TypeId of self. Read more
Source§

impl<T> Borrow<T> for T
where + T: ?Sized,

Source§

fn borrow(&self) -> &T

Immutably borrows from an owned value. Read more
Source§

impl<T> BorrowMut<T> for T
where + T: ?Sized,

Source§

fn borrow_mut(&mut self) -> &mut T

Mutably borrows from an owned value. Read more
Source§

impl<T> CloneToUninit for T
where + T: Clone,

Source§

unsafe fn clone_to_uninit(&self, dest: *mut u8)

🔬This is a nightly-only experimental API. (clone_to_uninit)
Performs copy-assignment from self to dest. Read more
Source§

impl<T> From<T> for T

Source§

fn from(t: T) -> T

Returns the argument unchanged.

+
§

impl<T> Instrument for T

§

fn instrument(self, span: Span) -> Instrumented<Self>

Instruments this type with the provided [Span], returning an +Instrumented wrapper. Read more
§

fn in_current_span(self) -> Instrumented<Self>

Instruments this type with the current Span, returning an +Instrumented wrapper. Read more
Source§

impl<T, U> Into<U> for T
where + U: From<T>,

Source§

fn into(self) -> U

Calls U::from(self).

+

That is, this conversion is whatever the implementation of +From<T> for U chooses to do.

+
§

impl<T> PolicyExt for T
where + T: ?Sized,

§

fn and<P, B, E>(self, other: P) -> And<T, P>
where + T: Policy<B, E>, + P: Policy<B, E>,

Create a new Policy that returns [Action::Follow] only if self and other return +Action::Follow. Read more
§

fn or<P, B, E>(self, other: P) -> Or<T, P>
where + T: Policy<B, E>, + P: Policy<B, E>,

Create a new Policy that returns [Action::Follow] if either self or other returns +Action::Follow. Read more
Source§

impl<T> Same for T

Source§

type Output = T

Should always be Self
Source§

impl<T> ToOwned for T
where + T: Clone,

Source§

type Owned = T

The resulting type after obtaining ownership.
Source§

fn to_owned(&self) -> T

Creates owned data from borrowed data, usually by cloning. Read more
Source§

fn clone_into(&self, target: &mut T)

Uses borrowed data to replace owned data, usually by cloning. Read more
Source§

impl<T, U> TryFrom<U> for T
where + U: Into<T>,

Source§

type Error = Infallible

The type returned in the event of a conversion error.
Source§

fn try_from(value: U) -> Result<T, <T as TryFrom<U>>::Error>

Performs the conversion.
Source§

impl<T, U> TryInto<U> for T
where + U: TryFrom<T>,

Source§

type Error = <U as TryFrom<T>>::Error

The type returned in the event of a conversion error.
Source§

fn try_into(self) -> Result<U, <U as TryFrom<T>>::Error>

Performs the conversion.
§

impl<V, T> VZip<V> for T
where + V: MultiLane<T>,

§

fn vzip(self) -> V

§

impl<T> WithSubscriber for T

§

fn with_subscriber<S>(self, subscriber: S) -> WithDispatch<Self>
where + S: Into<Dispatch>,

Attaches the provided Subscriber to this type, returning a +[WithDispatch] wrapper. Read more
§

fn with_current_subscriber(self) -> WithDispatch<Self>

Attaches the current default Subscriber to this type, returning a +[WithDispatch] wrapper. Read more
§

impl<T> UserEvent for T
where + T: Debug + Clone + Send + 'static,

\ No newline at end of file diff --git a/api/jellytau_lib/commands/download/struct.DownloadItemAndStartRequest.html b/api/jellytau_lib/commands/download/struct.DownloadItemAndStartRequest.html new file mode 100644 index 00000000..384931be --- /dev/null +++ b/api/jellytau_lib/commands/download/struct.DownloadItemAndStartRequest.html @@ -0,0 +1,50 @@ +DownloadItemAndStartRequest in jellytau_lib::commands::download - RustSkip to main content

DownloadItemAndStartRequest

Struct DownloadItemAndStartRequest 

Source
pub struct DownloadItemAndStartRequest {
+    pub item_id: String,
+    pub user_id: String,
+    pub stream_url: String,
+    pub target_dir: String,
+    pub item_name: Option<String>,
+    pub artist_name: Option<String>,
+    pub album_name: Option<String>,
+}
Expand description

Request payload for download_item_and_start (bundled to stay within specta’s +10-argument command limit).

+

Fields§

§item_id: String§user_id: String§stream_url: String§target_dir: String§item_name: Option<String>§artist_name: Option<String>§album_name: Option<String>

Trait Implementations§

Source§

impl Debug for DownloadItemAndStartRequest

Source§

fn fmt(&self, f: &mut Formatter<'_>) -> Result

Formats the value using the given formatter. Read more
Source§

impl<'de> Deserialize<'de> for DownloadItemAndStartRequest

Source§

fn deserialize<__D>(__deserializer: __D) -> Result<Self, __D::Error>
where + __D: Deserializer<'de>,

Deserialize this value from the given Serde deserializer. Read more
Source§

impl NamedType for DownloadItemAndStartRequest

Source§

fn sid() -> SpectaID

Source§

fn named_data_type( + type_map: &mut TypeCollection, + generics: &[DataType], +) -> NamedDataType

this is equivalent to [Type::inline] but returns a [NamedDataType] instead.
Source§

fn definition_named_data_type(type_map: &mut TypeCollection) -> NamedDataType

this is equivalent to [Type::definition] but returns a [NamedDataType] instead.
Source§

impl Type for DownloadItemAndStartRequest

Source§

fn inline(type_map: &mut TypeCollection, generics: Generics<'_>) -> DataType

Returns the definition of a type using the provided generics. Read more
Source§

fn reference(type_map: &mut TypeCollection, generics: &[DataType]) -> Reference

Generates a datatype corresponding to a reference to this type, +as determined by its category. Getting a reference to a type implies that +it should belong in the type map (since it has to be referenced from somewhere), +so the output of definition will be put into the type map.
Source§

impl Flatten for DownloadItemAndStartRequest

Auto Trait Implementations§

Blanket Implementations§

Source§

impl<T> Any for T
where + T: 'static + ?Sized,

Source§

fn type_id(&self) -> TypeId

Gets the TypeId of self. Read more
Source§

impl<T> Borrow<T> for T
where + T: ?Sized,

Source§

fn borrow(&self) -> &T

Immutably borrows from an owned value. Read more
Source§

impl<T> BorrowMut<T> for T
where + T: ?Sized,

Source§

fn borrow_mut(&mut self) -> &mut T

Mutably borrows from an owned value. Read more
§

impl<'de, D, R> CommandArg<'de, R> for D
where + D: Deserialize<'de>, + R: Runtime,

§

fn from_command(command: CommandItem<'de, R>) -> Result<D, InvokeError>

Derives an instance of Self from the [CommandItem]. Read more
Source§

impl<T> From<T> for T

Source§

fn from(t: T) -> T

Returns the argument unchanged.

+
§

impl<T> FunctionArg for T
where + T: Type,

§

fn to_datatype(type_map: &mut TypeCollection) -> Option<DataType>

Gets the type of an argument as a [DataType]. Read more
§

impl<T> Instrument for T

§

fn instrument(self, span: Span) -> Instrumented<Self>

Instruments this type with the provided [Span], returning an +Instrumented wrapper. Read more
§

fn in_current_span(self) -> Instrumented<Self>

Instruments this type with the current Span, returning an +Instrumented wrapper. Read more
Source§

impl<T, U> Into<U> for T
where + U: From<T>,

Source§

fn into(self) -> U

Calls U::from(self).

+

That is, this conversion is whatever the implementation of +From<T> for U chooses to do.

+
§

impl<T> PolicyExt for T
where + T: ?Sized,

§

fn and<P, B, E>(self, other: P) -> And<T, P>
where + T: Policy<B, E>, + P: Policy<B, E>,

Create a new Policy that returns [Action::Follow] only if self and other return +Action::Follow. Read more
§

fn or<P, B, E>(self, other: P) -> Or<T, P>
where + T: Policy<B, E>, + P: Policy<B, E>,

Create a new Policy that returns [Action::Follow] if either self or other returns +Action::Follow. Read more
Source§

impl<T> Same for T

Source§

type Output = T

Should always be Self
§

impl<T> ScopeObject for T
where + T: Send + Sync + Debug + DeserializeOwned + 'static,

§

type Error = Error

The error type.
§

fn deserialize<R>( + _app: &AppHandle<R>, + raw: Value, +) -> Result<T, <T as ScopeObject>::Error>
where + R: Runtime,

Deserialize the raw scope value.
Source§

impl<T, U> TryFrom<U> for T
where + U: Into<T>,

Source§

type Error = Infallible

The type returned in the event of a conversion error.
Source§

fn try_from(value: U) -> Result<T, <T as TryFrom<U>>::Error>

Performs the conversion.
Source§

impl<T, U> TryInto<U> for T
where + U: TryFrom<T>,

Source§

type Error = <U as TryFrom<T>>::Error

The type returned in the event of a conversion error.
Source§

fn try_into(self) -> Result<U, <U as TryFrom<T>>::Error>

Performs the conversion.
§

impl<V, T> VZip<V> for T
where + V: MultiLane<T>,

§

fn vzip(self) -> V

§

impl<T> WithSubscriber for T

§

fn with_subscriber<S>(self, subscriber: S) -> WithDispatch<Self>
where + S: Into<Dispatch>,

Attaches the provided Subscriber to this type, returning a +[WithDispatch] wrapper. Read more
§

fn with_current_subscriber(self) -> WithDispatch<Self>

Attaches the current default Subscriber to this type, returning a +[WithDispatch] wrapper. Read more
Source§

impl<T> DeserializeOwned for T
where + T: for<'de> Deserialize<'de>,

\ No newline at end of file diff --git a/api/jellytau_lib/commands/download/struct.DownloadItemRequest.html b/api/jellytau_lib/commands/download/struct.DownloadItemRequest.html new file mode 100644 index 00000000..1c8de736 --- /dev/null +++ b/api/jellytau_lib/commands/download/struct.DownloadItemRequest.html @@ -0,0 +1,51 @@ +DownloadItemRequest in jellytau_lib::commands::download - RustSkip to main content

DownloadItemRequest

Struct DownloadItemRequest 

Source
pub struct DownloadItemRequest {
+    pub item_id: String,
+    pub user_id: String,
+    pub file_path: String,
+    pub mime_type: Option<String>,
+    pub priority: Option<i32>,
+    pub item_name: Option<String>,
+    pub artist_name: Option<String>,
+    pub album_name: Option<String>,
+    pub expected_size: Option<i64>,
+}
Expand description

Request payload for download_item.

+

Fields§

§item_id: String§user_id: String§file_path: String§mime_type: Option<String>§priority: Option<i32>§item_name: Option<String>§artist_name: Option<String>§album_name: Option<String>§expected_size: Option<i64>

Trait Implementations§

Source§

impl Debug for DownloadItemRequest

Source§

fn fmt(&self, f: &mut Formatter<'_>) -> Result

Formats the value using the given formatter. Read more
Source§

impl<'de> Deserialize<'de> for DownloadItemRequest

Source§

fn deserialize<__D>(__deserializer: __D) -> Result<Self, __D::Error>
where + __D: Deserializer<'de>,

Deserialize this value from the given Serde deserializer. Read more
Source§

impl NamedType for DownloadItemRequest

Source§

fn sid() -> SpectaID

Source§

fn named_data_type( + type_map: &mut TypeCollection, + generics: &[DataType], +) -> NamedDataType

this is equivalent to [Type::inline] but returns a [NamedDataType] instead.
Source§

fn definition_named_data_type(type_map: &mut TypeCollection) -> NamedDataType

this is equivalent to [Type::definition] but returns a [NamedDataType] instead.
Source§

impl Type for DownloadItemRequest

Source§

fn inline(type_map: &mut TypeCollection, generics: Generics<'_>) -> DataType

Returns the definition of a type using the provided generics. Read more
Source§

fn reference(type_map: &mut TypeCollection, generics: &[DataType]) -> Reference

Generates a datatype corresponding to a reference to this type, +as determined by its category. Getting a reference to a type implies that +it should belong in the type map (since it has to be referenced from somewhere), +so the output of definition will be put into the type map.
Source§

impl Flatten for DownloadItemRequest

Auto Trait Implementations§

Blanket Implementations§

Source§

impl<T> Any for T
where + T: 'static + ?Sized,

Source§

fn type_id(&self) -> TypeId

Gets the TypeId of self. Read more
Source§

impl<T> Borrow<T> for T
where + T: ?Sized,

Source§

fn borrow(&self) -> &T

Immutably borrows from an owned value. Read more
Source§

impl<T> BorrowMut<T> for T
where + T: ?Sized,

Source§

fn borrow_mut(&mut self) -> &mut T

Mutably borrows from an owned value. Read more
§

impl<'de, D, R> CommandArg<'de, R> for D
where + D: Deserialize<'de>, + R: Runtime,

§

fn from_command(command: CommandItem<'de, R>) -> Result<D, InvokeError>

Derives an instance of Self from the [CommandItem]. Read more
Source§

impl<T> From<T> for T

Source§

fn from(t: T) -> T

Returns the argument unchanged.

+
§

impl<T> FunctionArg for T
where + T: Type,

§

fn to_datatype(type_map: &mut TypeCollection) -> Option<DataType>

Gets the type of an argument as a [DataType]. Read more
§

impl<T> Instrument for T

§

fn instrument(self, span: Span) -> Instrumented<Self>

Instruments this type with the provided [Span], returning an +Instrumented wrapper. Read more
§

fn in_current_span(self) -> Instrumented<Self>

Instruments this type with the current Span, returning an +Instrumented wrapper. Read more
Source§

impl<T, U> Into<U> for T
where + U: From<T>,

Source§

fn into(self) -> U

Calls U::from(self).

+

That is, this conversion is whatever the implementation of +From<T> for U chooses to do.

+
§

impl<T> PolicyExt for T
where + T: ?Sized,

§

fn and<P, B, E>(self, other: P) -> And<T, P>
where + T: Policy<B, E>, + P: Policy<B, E>,

Create a new Policy that returns [Action::Follow] only if self and other return +Action::Follow. Read more
§

fn or<P, B, E>(self, other: P) -> Or<T, P>
where + T: Policy<B, E>, + P: Policy<B, E>,

Create a new Policy that returns [Action::Follow] if either self or other returns +Action::Follow. Read more
Source§

impl<T> Same for T

Source§

type Output = T

Should always be Self
§

impl<T> ScopeObject for T
where + T: Send + Sync + Debug + DeserializeOwned + 'static,

§

type Error = Error

The error type.
§

fn deserialize<R>( + _app: &AppHandle<R>, + raw: Value, +) -> Result<T, <T as ScopeObject>::Error>
where + R: Runtime,

Deserialize the raw scope value.
Source§

impl<T, U> TryFrom<U> for T
where + U: Into<T>,

Source§

type Error = Infallible

The type returned in the event of a conversion error.
Source§

fn try_from(value: U) -> Result<T, <T as TryFrom<U>>::Error>

Performs the conversion.
Source§

impl<T, U> TryInto<U> for T
where + U: TryFrom<T>,

Source§

type Error = <U as TryFrom<T>>::Error

The type returned in the event of a conversion error.
Source§

fn try_into(self) -> Result<U, <U as TryFrom<T>>::Error>

Performs the conversion.
§

impl<V, T> VZip<V> for T
where + V: MultiLane<T>,

§

fn vzip(self) -> V

§

impl<T> WithSubscriber for T

§

fn with_subscriber<S>(self, subscriber: S) -> WithDispatch<Self>
where + S: Into<Dispatch>,

Attaches the provided Subscriber to this type, returning a +[WithDispatch] wrapper. Read more
§

fn with_current_subscriber(self) -> WithDispatch<Self>

Attaches the current default Subscriber to this type, returning a +[WithDispatch] wrapper. Read more
Source§

impl<T> DeserializeOwned for T
where + T: for<'de> Deserialize<'de>,

\ No newline at end of file diff --git a/api/jellytau_lib/commands/download/struct.DownloadManagerStats.html b/api/jellytau_lib/commands/download/struct.DownloadManagerStats.html new file mode 100644 index 00000000..7c264178 --- /dev/null +++ b/api/jellytau_lib/commands/download/struct.DownloadManagerStats.html @@ -0,0 +1,45 @@ +DownloadManagerStats in jellytau_lib::commands::download - RustSkip to main content

DownloadManagerStats

Struct DownloadManagerStats 

Source
pub struct DownloadManagerStats {
+    pub max_concurrent: usize,
+    pub active_count: usize,
+    pub available_slots: usize,
+}
Expand description

Download manager statistics

+

Fields§

§max_concurrent: usize§active_count: usize§available_slots: usize

Trait Implementations§

Source§

impl Clone for DownloadManagerStats

Source§

fn clone(&self) -> DownloadManagerStats

Returns a duplicate of the value. Read more
1.0.0 (const: unstable) · Source§

fn clone_from(&mut self, source: &Self)

Performs copy-assignment from source. Read more
Source§

impl Debug for DownloadManagerStats

Source§

fn fmt(&self, f: &mut Formatter<'_>) -> Result

Formats the value using the given formatter. Read more
Source§

impl NamedType for DownloadManagerStats

Source§

fn sid() -> SpectaID

Source§

fn named_data_type( + type_map: &mut TypeCollection, + generics: &[DataType], +) -> NamedDataType

this is equivalent to [Type::inline] but returns a [NamedDataType] instead.
Source§

fn definition_named_data_type(type_map: &mut TypeCollection) -> NamedDataType

this is equivalent to [Type::definition] but returns a [NamedDataType] instead.
Source§

impl Serialize for DownloadManagerStats

Source§

fn serialize<__S>(&self, __serializer: __S) -> Result<__S::Ok, __S::Error>
where + __S: Serializer,

Serialize this value into the given Serde serializer. Read more
Source§

impl Type for DownloadManagerStats

Source§

fn inline(type_map: &mut TypeCollection, generics: Generics<'_>) -> DataType

Returns the definition of a type using the provided generics. Read more
Source§

fn reference(type_map: &mut TypeCollection, generics: &[DataType]) -> Reference

Generates a datatype corresponding to a reference to this type, +as determined by its category. Getting a reference to a type implies that +it should belong in the type map (since it has to be referenced from somewhere), +so the output of definition will be put into the type map.
Source§

impl Flatten for DownloadManagerStats

Auto Trait Implementations§

Blanket Implementations§

Source§

impl<T> Any for T
where + T: 'static + ?Sized,

Source§

fn type_id(&self) -> TypeId

Gets the TypeId of self. Read more
Source§

impl<T> Borrow<T> for T
where + T: ?Sized,

Source§

fn borrow(&self) -> &T

Immutably borrows from an owned value. Read more
Source§

impl<T> BorrowMut<T> for T
where + T: ?Sized,

Source§

fn borrow_mut(&mut self) -> &mut T

Mutably borrows from an owned value. Read more
Source§

impl<T> CloneToUninit for T
where + T: Clone,

Source§

unsafe fn clone_to_uninit(&self, dest: *mut u8)

🔬This is a nightly-only experimental API. (clone_to_uninit)
Performs copy-assignment from self to dest. Read more
Source§

impl<T> From<T> for T

Source§

fn from(t: T) -> T

Returns the argument unchanged.

+
§

impl<T> FunctionArg for T
where + T: Type,

§

fn to_datatype(type_map: &mut TypeCollection) -> Option<DataType>

Gets the type of an argument as a [DataType]. Read more
§

impl<T> Instrument for T

§

fn instrument(self, span: Span) -> Instrumented<Self>

Instruments this type with the provided [Span], returning an +Instrumented wrapper. Read more
§

fn in_current_span(self) -> Instrumented<Self>

Instruments this type with the current Span, returning an +Instrumented wrapper. Read more
Source§

impl<T, U> Into<U> for T
where + U: From<T>,

Source§

fn into(self) -> U

Calls U::from(self).

+

That is, this conversion is whatever the implementation of +From<T> for U chooses to do.

+
§

impl<T> IpcResponse for T
where + T: Serialize,

§

fn body(self) -> Result<InvokeResponseBody, Error>

Resolve the IPC response body.
§

impl<T> PolicyExt for T
where + T: ?Sized,

§

fn and<P, B, E>(self, other: P) -> And<T, P>
where + T: Policy<B, E>, + P: Policy<B, E>,

Create a new Policy that returns [Action::Follow] only if self and other return +Action::Follow. Read more
§

fn or<P, B, E>(self, other: P) -> Or<T, P>
where + T: Policy<B, E>, + P: Policy<B, E>,

Create a new Policy that returns [Action::Follow] if either self or other returns +Action::Follow. Read more
Source§

impl<T> Same for T

Source§

type Output = T

Should always be Self
Source§

impl<T> Serialize for T
where + T: Serialize + ?Sized,

Source§

fn erased_serialize(&self, serializer: &mut dyn Serializer) -> Result<(), Error>

Source§

fn do_erased_serialize( + &self, + serializer: &mut dyn Serializer, +) -> Result<(), ErrorImpl>

Source§

impl<T> ToOwned for T
where + T: Clone,

Source§

type Owned = T

The resulting type after obtaining ownership.
Source§

fn to_owned(&self) -> T

Creates owned data from borrowed data, usually by cloning. Read more
Source§

fn clone_into(&self, target: &mut T)

Uses borrowed data to replace owned data, usually by cloning. Read more
Source§

impl<T, U> TryFrom<U> for T
where + U: Into<T>,

Source§

type Error = Infallible

The type returned in the event of a conversion error.
Source§

fn try_from(value: U) -> Result<T, <T as TryFrom<U>>::Error>

Performs the conversion.
Source§

impl<T, U> TryInto<U> for T
where + U: TryFrom<T>,

Source§

type Error = <U as TryFrom<T>>::Error

The type returned in the event of a conversion error.
Source§

fn try_into(self) -> Result<U, <U as TryFrom<T>>::Error>

Performs the conversion.
§

impl<V, T> VZip<V> for T
where + V: MultiLane<T>,

§

fn vzip(self) -> V

§

impl<T> WithSubscriber for T

§

fn with_subscriber<S>(self, subscriber: S) -> WithDispatch<Self>
where + S: Into<Dispatch>,

Attaches the provided Subscriber to this type, returning a +[WithDispatch] wrapper. Read more
§

fn with_current_subscriber(self) -> WithDispatch<Self>

Attaches the current default Subscriber to this type, returning a +[WithDispatch] wrapper. Read more
§

impl<T> UserEvent for T
where + T: Debug + Clone + Send + 'static,

\ No newline at end of file diff --git a/api/jellytau_lib/commands/download/struct.DownloadManagerWrapper.html b/api/jellytau_lib/commands/download/struct.DownloadManagerWrapper.html new file mode 100644 index 00000000..cec2d70f --- /dev/null +++ b/api/jellytau_lib/commands/download/struct.DownloadManagerWrapper.html @@ -0,0 +1,25 @@ +DownloadManagerWrapper in jellytau_lib::commands::download - RustSkip to main content

DownloadManagerWrapper

Struct DownloadManagerWrapper 

Source
pub struct DownloadManagerWrapper(pub Mutex<DownloadManager>);
Expand description

Wrapper for DownloadManager to be used as Tauri state

+

Tuple Fields§

§0: Mutex<DownloadManager>

Auto Trait Implementations§

Blanket Implementations§

Source§

impl<T> Any for T
where + T: 'static + ?Sized,

Source§

fn type_id(&self) -> TypeId

Gets the TypeId of self. Read more
Source§

impl<T> Borrow<T> for T
where + T: ?Sized,

Source§

fn borrow(&self) -> &T

Immutably borrows from an owned value. Read more
Source§

impl<T> BorrowMut<T> for T
where + T: ?Sized,

Source§

fn borrow_mut(&mut self) -> &mut T

Mutably borrows from an owned value. Read more
Source§

impl<T> From<T> for T

Source§

fn from(t: T) -> T

Returns the argument unchanged.

+
§

impl<T> Instrument for T

§

fn instrument(self, span: Span) -> Instrumented<Self>

Instruments this type with the provided [Span], returning an +Instrumented wrapper. Read more
§

fn in_current_span(self) -> Instrumented<Self>

Instruments this type with the current Span, returning an +Instrumented wrapper. Read more
Source§

impl<T, U> Into<U> for T
where + U: From<T>,

Source§

fn into(self) -> U

Calls U::from(self).

+

That is, this conversion is whatever the implementation of +From<T> for U chooses to do.

+
§

impl<T> PolicyExt for T
where + T: ?Sized,

§

fn and<P, B, E>(self, other: P) -> And<T, P>
where + T: Policy<B, E>, + P: Policy<B, E>,

Create a new Policy that returns [Action::Follow] only if self and other return +Action::Follow. Read more
§

fn or<P, B, E>(self, other: P) -> Or<T, P>
where + T: Policy<B, E>, + P: Policy<B, E>,

Create a new Policy that returns [Action::Follow] if either self or other returns +Action::Follow. Read more
Source§

impl<T> Same for T

Source§

type Output = T

Should always be Self
Source§

impl<T, U> TryFrom<U> for T
where + U: Into<T>,

Source§

type Error = Infallible

The type returned in the event of a conversion error.
Source§

fn try_from(value: U) -> Result<T, <T as TryFrom<U>>::Error>

Performs the conversion.
Source§

impl<T, U> TryInto<U> for T
where + U: TryFrom<T>,

Source§

type Error = <U as TryFrom<T>>::Error

The type returned in the event of a conversion error.
Source§

fn try_into(self) -> Result<U, <U as TryFrom<T>>::Error>

Performs the conversion.
§

impl<V, T> VZip<V> for T
where + V: MultiLane<T>,

§

fn vzip(self) -> V

§

impl<T> WithSubscriber for T

§

fn with_subscriber<S>(self, subscriber: S) -> WithDispatch<Self>
where + S: Into<Dispatch>,

Attaches the provided Subscriber to this type, returning a +[WithDispatch] wrapper. Read more
§

fn with_current_subscriber(self) -> WithDispatch<Self>

Attaches the current default Subscriber to this type, returning a +[WithDispatch] wrapper. Read more
\ No newline at end of file diff --git a/api/jellytau_lib/commands/download/struct.DownloadStats.html b/api/jellytau_lib/commands/download/struct.DownloadStats.html new file mode 100644 index 00000000..ebfc7ba2 --- /dev/null +++ b/api/jellytau_lib/commands/download/struct.DownloadStats.html @@ -0,0 +1,57 @@ +DownloadStats in jellytau_lib::commands::download - RustSkip to main content

DownloadStats

Struct DownloadStats 

Source
pub struct DownloadStats {
+    pub total: usize,
+    pub active_count: usize,
+    pub queued_count: usize,
+    pub completed_count: usize,
+    pub failed_count: usize,
+    pub paused_count: usize,
+}
Expand description

Download statistics computed server-side

+

Fields§

§total: usize§active_count: usize§queued_count: usize§completed_count: usize§failed_count: usize§paused_count: usize

Trait Implementations§

Source§

impl Clone for DownloadStats

Source§

fn clone(&self) -> DownloadStats

Returns a duplicate of the value. Read more
1.0.0 (const: unstable) · Source§

fn clone_from(&mut self, source: &Self)

Performs copy-assignment from source. Read more
Source§

impl Debug for DownloadStats

Source§

fn fmt(&self, f: &mut Formatter<'_>) -> Result

Formats the value using the given formatter. Read more
Source§

impl<'de> Deserialize<'de> for DownloadStats

Source§

fn deserialize<__D>(__deserializer: __D) -> Result<Self, __D::Error>
where + __D: Deserializer<'de>,

Deserialize this value from the given Serde deserializer. Read more
Source§

impl NamedType for DownloadStats

Source§

fn sid() -> SpectaID

Source§

fn named_data_type( + type_map: &mut TypeCollection, + generics: &[DataType], +) -> NamedDataType

this is equivalent to [Type::inline] but returns a [NamedDataType] instead.
Source§

fn definition_named_data_type(type_map: &mut TypeCollection) -> NamedDataType

this is equivalent to [Type::definition] but returns a [NamedDataType] instead.
Source§

impl Serialize for DownloadStats

Source§

fn serialize<__S>(&self, __serializer: __S) -> Result<__S::Ok, __S::Error>
where + __S: Serializer,

Serialize this value into the given Serde serializer. Read more
Source§

impl Type for DownloadStats

Source§

fn inline(type_map: &mut TypeCollection, generics: Generics<'_>) -> DataType

Returns the definition of a type using the provided generics. Read more
Source§

fn reference(type_map: &mut TypeCollection, generics: &[DataType]) -> Reference

Generates a datatype corresponding to a reference to this type, +as determined by its category. Getting a reference to a type implies that +it should belong in the type map (since it has to be referenced from somewhere), +so the output of definition will be put into the type map.
Source§

impl Flatten for DownloadStats

Auto Trait Implementations§

Blanket Implementations§

Source§

impl<T> Any for T
where + T: 'static + ?Sized,

Source§

fn type_id(&self) -> TypeId

Gets the TypeId of self. Read more
Source§

impl<T> Borrow<T> for T
where + T: ?Sized,

Source§

fn borrow(&self) -> &T

Immutably borrows from an owned value. Read more
Source§

impl<T> BorrowMut<T> for T
where + T: ?Sized,

Source§

fn borrow_mut(&mut self) -> &mut T

Mutably borrows from an owned value. Read more
Source§

impl<T> CloneToUninit for T
where + T: Clone,

Source§

unsafe fn clone_to_uninit(&self, dest: *mut u8)

🔬This is a nightly-only experimental API. (clone_to_uninit)
Performs copy-assignment from self to dest. Read more
§

impl<'de, D, R> CommandArg<'de, R> for D
where + D: Deserialize<'de>, + R: Runtime,

§

fn from_command(command: CommandItem<'de, R>) -> Result<D, InvokeError>

Derives an instance of Self from the [CommandItem]. Read more
Source§

impl<T> From<T> for T

Source§

fn from(t: T) -> T

Returns the argument unchanged.

+
§

impl<T> FunctionArg for T
where + T: Type,

§

fn to_datatype(type_map: &mut TypeCollection) -> Option<DataType>

Gets the type of an argument as a [DataType]. Read more
§

impl<T> Instrument for T

§

fn instrument(self, span: Span) -> Instrumented<Self>

Instruments this type with the provided [Span], returning an +Instrumented wrapper. Read more
§

fn in_current_span(self) -> Instrumented<Self>

Instruments this type with the current Span, returning an +Instrumented wrapper. Read more
Source§

impl<T, U> Into<U> for T
where + U: From<T>,

Source§

fn into(self) -> U

Calls U::from(self).

+

That is, this conversion is whatever the implementation of +From<T> for U chooses to do.

+
§

impl<T> IpcResponse for T
where + T: Serialize,

§

fn body(self) -> Result<InvokeResponseBody, Error>

Resolve the IPC response body.
§

impl<T> PolicyExt for T
where + T: ?Sized,

§

fn and<P, B, E>(self, other: P) -> And<T, P>
where + T: Policy<B, E>, + P: Policy<B, E>,

Create a new Policy that returns [Action::Follow] only if self and other return +Action::Follow. Read more
§

fn or<P, B, E>(self, other: P) -> Or<T, P>
where + T: Policy<B, E>, + P: Policy<B, E>,

Create a new Policy that returns [Action::Follow] if either self or other returns +Action::Follow. Read more
Source§

impl<T> Same for T

Source§

type Output = T

Should always be Self
§

impl<T> ScopeObject for T
where + T: Send + Sync + Debug + DeserializeOwned + 'static,

§

type Error = Error

The error type.
§

fn deserialize<R>( + _app: &AppHandle<R>, + raw: Value, +) -> Result<T, <T as ScopeObject>::Error>
where + R: Runtime,

Deserialize the raw scope value.
Source§

impl<T> Serialize for T
where + T: Serialize + ?Sized,

Source§

fn erased_serialize(&self, serializer: &mut dyn Serializer) -> Result<(), Error>

Source§

fn do_erased_serialize( + &self, + serializer: &mut dyn Serializer, +) -> Result<(), ErrorImpl>

Source§

impl<T> ToOwned for T
where + T: Clone,

Source§

type Owned = T

The resulting type after obtaining ownership.
Source§

fn to_owned(&self) -> T

Creates owned data from borrowed data, usually by cloning. Read more
Source§

fn clone_into(&self, target: &mut T)

Uses borrowed data to replace owned data, usually by cloning. Read more
Source§

impl<T, U> TryFrom<U> for T
where + U: Into<T>,

Source§

type Error = Infallible

The type returned in the event of a conversion error.
Source§

fn try_from(value: U) -> Result<T, <T as TryFrom<U>>::Error>

Performs the conversion.
Source§

impl<T, U> TryInto<U> for T
where + U: TryFrom<T>,

Source§

type Error = <U as TryFrom<T>>::Error

The type returned in the event of a conversion error.
Source§

fn try_into(self) -> Result<U, <U as TryFrom<T>>::Error>

Performs the conversion.
§

impl<V, T> VZip<V> for T
where + V: MultiLane<T>,

§

fn vzip(self) -> V

§

impl<T> WithSubscriber for T

§

fn with_subscriber<S>(self, subscriber: S) -> WithDispatch<Self>
where + S: Into<Dispatch>,

Attaches the provided Subscriber to this type, returning a +[WithDispatch] wrapper. Read more
§

fn with_current_subscriber(self) -> WithDispatch<Self>

Attaches the current default Subscriber to this type, returning a +[WithDispatch] wrapper. Read more
Source§

impl<T> DeserializeOwned for T
where + T: for<'de> Deserialize<'de>,

§

impl<T> UserEvent for T
where + T: Debug + Clone + Send + 'static,

\ No newline at end of file diff --git a/api/jellytau_lib/commands/download/struct.DownloadVideoRequest.html b/api/jellytau_lib/commands/download/struct.DownloadVideoRequest.html new file mode 100644 index 00000000..b0ebcb18 --- /dev/null +++ b/api/jellytau_lib/commands/download/struct.DownloadVideoRequest.html @@ -0,0 +1,53 @@ +DownloadVideoRequest in jellytau_lib::commands::download - RustSkip to main content

DownloadVideoRequest

Struct DownloadVideoRequest 

Source
pub struct DownloadVideoRequest {
+    pub item_id: String,
+    pub user_id: String,
+    pub file_path: String,
+    pub mime_type: Option<String>,
+    pub priority: Option<i32>,
+    pub item_name: Option<String>,
+    pub quality_preset: Option<String>,
+    pub series_name: Option<String>,
+    pub season_name: Option<String>,
+    pub episode_number: Option<i32>,
+    pub season_number: Option<i32>,
+}
Expand description

Request payload for download_video.

+

Fields§

§item_id: String§user_id: String§file_path: String§mime_type: Option<String>§priority: Option<i32>§item_name: Option<String>§quality_preset: Option<String>§series_name: Option<String>§season_name: Option<String>§episode_number: Option<i32>§season_number: Option<i32>

Trait Implementations§

Source§

impl Debug for DownloadVideoRequest

Source§

fn fmt(&self, f: &mut Formatter<'_>) -> Result

Formats the value using the given formatter. Read more
Source§

impl<'de> Deserialize<'de> for DownloadVideoRequest

Source§

fn deserialize<__D>(__deserializer: __D) -> Result<Self, __D::Error>
where + __D: Deserializer<'de>,

Deserialize this value from the given Serde deserializer. Read more
Source§

impl NamedType for DownloadVideoRequest

Source§

fn sid() -> SpectaID

Source§

fn named_data_type( + type_map: &mut TypeCollection, + generics: &[DataType], +) -> NamedDataType

this is equivalent to [Type::inline] but returns a [NamedDataType] instead.
Source§

fn definition_named_data_type(type_map: &mut TypeCollection) -> NamedDataType

this is equivalent to [Type::definition] but returns a [NamedDataType] instead.
Source§

impl Type for DownloadVideoRequest

Source§

fn inline(type_map: &mut TypeCollection, generics: Generics<'_>) -> DataType

Returns the definition of a type using the provided generics. Read more
Source§

fn reference(type_map: &mut TypeCollection, generics: &[DataType]) -> Reference

Generates a datatype corresponding to a reference to this type, +as determined by its category. Getting a reference to a type implies that +it should belong in the type map (since it has to be referenced from somewhere), +so the output of definition will be put into the type map.
Source§

impl Flatten for DownloadVideoRequest

Auto Trait Implementations§

Blanket Implementations§

Source§

impl<T> Any for T
where + T: 'static + ?Sized,

Source§

fn type_id(&self) -> TypeId

Gets the TypeId of self. Read more
Source§

impl<T> Borrow<T> for T
where + T: ?Sized,

Source§

fn borrow(&self) -> &T

Immutably borrows from an owned value. Read more
Source§

impl<T> BorrowMut<T> for T
where + T: ?Sized,

Source§

fn borrow_mut(&mut self) -> &mut T

Mutably borrows from an owned value. Read more
§

impl<'de, D, R> CommandArg<'de, R> for D
where + D: Deserialize<'de>, + R: Runtime,

§

fn from_command(command: CommandItem<'de, R>) -> Result<D, InvokeError>

Derives an instance of Self from the [CommandItem]. Read more
Source§

impl<T> From<T> for T

Source§

fn from(t: T) -> T

Returns the argument unchanged.

+
§

impl<T> FunctionArg for T
where + T: Type,

§

fn to_datatype(type_map: &mut TypeCollection) -> Option<DataType>

Gets the type of an argument as a [DataType]. Read more
§

impl<T> Instrument for T

§

fn instrument(self, span: Span) -> Instrumented<Self>

Instruments this type with the provided [Span], returning an +Instrumented wrapper. Read more
§

fn in_current_span(self) -> Instrumented<Self>

Instruments this type with the current Span, returning an +Instrumented wrapper. Read more
Source§

impl<T, U> Into<U> for T
where + U: From<T>,

Source§

fn into(self) -> U

Calls U::from(self).

+

That is, this conversion is whatever the implementation of +From<T> for U chooses to do.

+
§

impl<T> PolicyExt for T
where + T: ?Sized,

§

fn and<P, B, E>(self, other: P) -> And<T, P>
where + T: Policy<B, E>, + P: Policy<B, E>,

Create a new Policy that returns [Action::Follow] only if self and other return +Action::Follow. Read more
§

fn or<P, B, E>(self, other: P) -> Or<T, P>
where + T: Policy<B, E>, + P: Policy<B, E>,

Create a new Policy that returns [Action::Follow] if either self or other returns +Action::Follow. Read more
Source§

impl<T> Same for T

Source§

type Output = T

Should always be Self
§

impl<T> ScopeObject for T
where + T: Send + Sync + Debug + DeserializeOwned + 'static,

§

type Error = Error

The error type.
§

fn deserialize<R>( + _app: &AppHandle<R>, + raw: Value, +) -> Result<T, <T as ScopeObject>::Error>
where + R: Runtime,

Deserialize the raw scope value.
Source§

impl<T, U> TryFrom<U> for T
where + U: Into<T>,

Source§

type Error = Infallible

The type returned in the event of a conversion error.
Source§

fn try_from(value: U) -> Result<T, <T as TryFrom<U>>::Error>

Performs the conversion.
Source§

impl<T, U> TryInto<U> for T
where + U: TryFrom<T>,

Source§

type Error = <U as TryFrom<T>>::Error

The type returned in the event of a conversion error.
Source§

fn try_into(self) -> Result<U, <U as TryFrom<T>>::Error>

Performs the conversion.
§

impl<V, T> VZip<V> for T
where + V: MultiLane<T>,

§

fn vzip(self) -> V

§

impl<T> WithSubscriber for T

§

fn with_subscriber<S>(self, subscriber: S) -> WithDispatch<Self>
where + S: Into<Dispatch>,

Attaches the provided Subscriber to this type, returning a +[WithDispatch] wrapper. Read more
§

fn with_current_subscriber(self) -> WithDispatch<Self>

Attaches the current default Subscriber to this type, returning a +[WithDispatch] wrapper. Read more
Source§

impl<T> DeserializeOwned for T
where + T: for<'de> Deserialize<'de>,

\ No newline at end of file diff --git a/api/jellytau_lib/commands/download/struct.DownloadsResponse.html b/api/jellytau_lib/commands/download/struct.DownloadsResponse.html new file mode 100644 index 00000000..c662d2df --- /dev/null +++ b/api/jellytau_lib/commands/download/struct.DownloadsResponse.html @@ -0,0 +1,53 @@ +DownloadsResponse in jellytau_lib::commands::download - RustSkip to main content

DownloadsResponse

Struct DownloadsResponse 

Source
pub struct DownloadsResponse {
+    pub downloads: Vec<DownloadInfo>,
+    pub stats: DownloadStats,
+}
Expand description

Enhanced response with pre-computed stats

+

Fields§

§downloads: Vec<DownloadInfo>§stats: DownloadStats

Trait Implementations§

Source§

impl Clone for DownloadsResponse

Source§

fn clone(&self) -> DownloadsResponse

Returns a duplicate of the value. Read more
1.0.0 (const: unstable) · Source§

fn clone_from(&mut self, source: &Self)

Performs copy-assignment from source. Read more
Source§

impl Debug for DownloadsResponse

Source§

fn fmt(&self, f: &mut Formatter<'_>) -> Result

Formats the value using the given formatter. Read more
Source§

impl<'de> Deserialize<'de> for DownloadsResponse

Source§

fn deserialize<__D>(__deserializer: __D) -> Result<Self, __D::Error>
where + __D: Deserializer<'de>,

Deserialize this value from the given Serde deserializer. Read more
Source§

impl NamedType for DownloadsResponse

Source§

fn sid() -> SpectaID

Source§

fn named_data_type( + type_map: &mut TypeCollection, + generics: &[DataType], +) -> NamedDataType

this is equivalent to [Type::inline] but returns a [NamedDataType] instead.
Source§

fn definition_named_data_type(type_map: &mut TypeCollection) -> NamedDataType

this is equivalent to [Type::definition] but returns a [NamedDataType] instead.
Source§

impl Serialize for DownloadsResponse

Source§

fn serialize<__S>(&self, __serializer: __S) -> Result<__S::Ok, __S::Error>
where + __S: Serializer,

Serialize this value into the given Serde serializer. Read more
Source§

impl Type for DownloadsResponse

Source§

fn inline(type_map: &mut TypeCollection, generics: Generics<'_>) -> DataType

Returns the definition of a type using the provided generics. Read more
Source§

fn reference(type_map: &mut TypeCollection, generics: &[DataType]) -> Reference

Generates a datatype corresponding to a reference to this type, +as determined by its category. Getting a reference to a type implies that +it should belong in the type map (since it has to be referenced from somewhere), +so the output of definition will be put into the type map.
Source§

impl Flatten for DownloadsResponse

Auto Trait Implementations§

Blanket Implementations§

Source§

impl<T> Any for T
where + T: 'static + ?Sized,

Source§

fn type_id(&self) -> TypeId

Gets the TypeId of self. Read more
Source§

impl<T> Borrow<T> for T
where + T: ?Sized,

Source§

fn borrow(&self) -> &T

Immutably borrows from an owned value. Read more
Source§

impl<T> BorrowMut<T> for T
where + T: ?Sized,

Source§

fn borrow_mut(&mut self) -> &mut T

Mutably borrows from an owned value. Read more
Source§

impl<T> CloneToUninit for T
where + T: Clone,

Source§

unsafe fn clone_to_uninit(&self, dest: *mut u8)

🔬This is a nightly-only experimental API. (clone_to_uninit)
Performs copy-assignment from self to dest. Read more
§

impl<'de, D, R> CommandArg<'de, R> for D
where + D: Deserialize<'de>, + R: Runtime,

§

fn from_command(command: CommandItem<'de, R>) -> Result<D, InvokeError>

Derives an instance of Self from the [CommandItem]. Read more
Source§

impl<T> From<T> for T

Source§

fn from(t: T) -> T

Returns the argument unchanged.

+
§

impl<T> FunctionArg for T
where + T: Type,

§

fn to_datatype(type_map: &mut TypeCollection) -> Option<DataType>

Gets the type of an argument as a [DataType]. Read more
§

impl<T> Instrument for T

§

fn instrument(self, span: Span) -> Instrumented<Self>

Instruments this type with the provided [Span], returning an +Instrumented wrapper. Read more
§

fn in_current_span(self) -> Instrumented<Self>

Instruments this type with the current Span, returning an +Instrumented wrapper. Read more
Source§

impl<T, U> Into<U> for T
where + U: From<T>,

Source§

fn into(self) -> U

Calls U::from(self).

+

That is, this conversion is whatever the implementation of +From<T> for U chooses to do.

+
§

impl<T> IpcResponse for T
where + T: Serialize,

§

fn body(self) -> Result<InvokeResponseBody, Error>

Resolve the IPC response body.
§

impl<T> PolicyExt for T
where + T: ?Sized,

§

fn and<P, B, E>(self, other: P) -> And<T, P>
where + T: Policy<B, E>, + P: Policy<B, E>,

Create a new Policy that returns [Action::Follow] only if self and other return +Action::Follow. Read more
§

fn or<P, B, E>(self, other: P) -> Or<T, P>
where + T: Policy<B, E>, + P: Policy<B, E>,

Create a new Policy that returns [Action::Follow] if either self or other returns +Action::Follow. Read more
Source§

impl<T> Same for T

Source§

type Output = T

Should always be Self
§

impl<T> ScopeObject for T
where + T: Send + Sync + Debug + DeserializeOwned + 'static,

§

type Error = Error

The error type.
§

fn deserialize<R>( + _app: &AppHandle<R>, + raw: Value, +) -> Result<T, <T as ScopeObject>::Error>
where + R: Runtime,

Deserialize the raw scope value.
Source§

impl<T> Serialize for T
where + T: Serialize + ?Sized,

Source§

fn erased_serialize(&self, serializer: &mut dyn Serializer) -> Result<(), Error>

Source§

fn do_erased_serialize( + &self, + serializer: &mut dyn Serializer, +) -> Result<(), ErrorImpl>

Source§

impl<T> ToOwned for T
where + T: Clone,

Source§

type Owned = T

The resulting type after obtaining ownership.
Source§

fn to_owned(&self) -> T

Creates owned data from borrowed data, usually by cloning. Read more
Source§

fn clone_into(&self, target: &mut T)

Uses borrowed data to replace owned data, usually by cloning. Read more
Source§

impl<T, U> TryFrom<U> for T
where + U: Into<T>,

Source§

type Error = Infallible

The type returned in the event of a conversion error.
Source§

fn try_from(value: U) -> Result<T, <T as TryFrom<U>>::Error>

Performs the conversion.
Source§

impl<T, U> TryInto<U> for T
where + U: TryFrom<T>,

Source§

type Error = <U as TryFrom<T>>::Error

The type returned in the event of a conversion error.
Source§

fn try_into(self) -> Result<U, <U as TryFrom<T>>::Error>

Performs the conversion.
§

impl<V, T> VZip<V> for T
where + V: MultiLane<T>,

§

fn vzip(self) -> V

§

impl<T> WithSubscriber for T

§

fn with_subscriber<S>(self, subscriber: S) -> WithDispatch<Self>
where + S: Into<Dispatch>,

Attaches the provided Subscriber to this type, returning a +[WithDispatch] wrapper. Read more
§

fn with_current_subscriber(self) -> WithDispatch<Self>

Attaches the current default Subscriber to this type, returning a +[WithDispatch] wrapper. Read more
Source§

impl<T> DeserializeOwned for T
where + T: for<'de> Deserialize<'de>,

§

impl<T> UserEvent for T
where + T: Debug + Clone + Send + 'static,

\ No newline at end of file diff --git a/api/jellytau_lib/commands/download/struct.NetworkStateWrapper.html b/api/jellytau_lib/commands/download/struct.NetworkStateWrapper.html new file mode 100644 index 00000000..62ac3baf --- /dev/null +++ b/api/jellytau_lib/commands/download/struct.NetworkStateWrapper.html @@ -0,0 +1,26 @@ +NetworkStateWrapper in jellytau_lib::commands::download - RustSkip to main content

NetworkStateWrapper

Struct NetworkStateWrapper 

Source
pub struct NetworkStateWrapper(pub NetworkStateHandle);
Expand description

Wrapper for the current network transport, used by the WiFi-only gate.

+

TRACES: UR-053 | DR-074

+

Tuple Fields§

§0: NetworkStateHandle

Auto Trait Implementations§

Blanket Implementations§

Source§

impl<T> Any for T
where + T: 'static + ?Sized,

Source§

fn type_id(&self) -> TypeId

Gets the TypeId of self. Read more
Source§

impl<T> Borrow<T> for T
where + T: ?Sized,

Source§

fn borrow(&self) -> &T

Immutably borrows from an owned value. Read more
Source§

impl<T> BorrowMut<T> for T
where + T: ?Sized,

Source§

fn borrow_mut(&mut self) -> &mut T

Mutably borrows from an owned value. Read more
Source§

impl<T> From<T> for T

Source§

fn from(t: T) -> T

Returns the argument unchanged.

+
§

impl<T> Instrument for T

§

fn instrument(self, span: Span) -> Instrumented<Self>

Instruments this type with the provided [Span], returning an +Instrumented wrapper. Read more
§

fn in_current_span(self) -> Instrumented<Self>

Instruments this type with the current Span, returning an +Instrumented wrapper. Read more
Source§

impl<T, U> Into<U> for T
where + U: From<T>,

Source§

fn into(self) -> U

Calls U::from(self).

+

That is, this conversion is whatever the implementation of +From<T> for U chooses to do.

+
§

impl<T> PolicyExt for T
where + T: ?Sized,

§

fn and<P, B, E>(self, other: P) -> And<T, P>
where + T: Policy<B, E>, + P: Policy<B, E>,

Create a new Policy that returns [Action::Follow] only if self and other return +Action::Follow. Read more
§

fn or<P, B, E>(self, other: P) -> Or<T, P>
where + T: Policy<B, E>, + P: Policy<B, E>,

Create a new Policy that returns [Action::Follow] if either self or other returns +Action::Follow. Read more
Source§

impl<T> Same for T

Source§

type Output = T

Should always be Self
Source§

impl<T, U> TryFrom<U> for T
where + U: Into<T>,

Source§

type Error = Infallible

The type returned in the event of a conversion error.
Source§

fn try_from(value: U) -> Result<T, <T as TryFrom<U>>::Error>

Performs the conversion.
Source§

impl<T, U> TryInto<U> for T
where + U: TryFrom<T>,

Source§

type Error = <U as TryFrom<T>>::Error

The type returned in the event of a conversion error.
Source§

fn try_into(self) -> Result<U, <U as TryFrom<T>>::Error>

Performs the conversion.
§

impl<V, T> VZip<V> for T
where + V: MultiLane<T>,

§

fn vzip(self) -> V

§

impl<T> WithSubscriber for T

§

fn with_subscriber<S>(self, subscriber: S) -> WithDispatch<Self>
where + S: Into<Dispatch>,

Attaches the provided Subscriber to this type, returning a +[WithDispatch] wrapper. Read more
§

fn with_current_subscriber(self) -> WithDispatch<Self>

Attaches the current default Subscriber to this type, returning a +[WithDispatch] wrapper. Read more
\ No newline at end of file diff --git a/api/jellytau_lib/commands/download/struct.NetworkStateWrapperArg.html b/api/jellytau_lib/commands/download/struct.NetworkStateWrapperArg.html new file mode 100644 index 00000000..361f3b7a --- /dev/null +++ b/api/jellytau_lib/commands/download/struct.NetworkStateWrapperArg.html @@ -0,0 +1,54 @@ +NetworkStateWrapperArg in jellytau_lib::commands::download - RustSkip to main content

NetworkStateWrapperArg

Struct NetworkStateWrapperArg 

Source
pub struct NetworkStateWrapperArg {
+    pub network_type: NetworkType,
+    pub unmetered: bool,
+}
Expand description

Argument struct for set_network_state.

+

TRACES: UR-053 | DR-074

+

Fields§

§network_type: NetworkType§unmetered: bool

Trait Implementations§

Source§

impl Clone for NetworkStateWrapperArg

Source§

fn clone(&self) -> NetworkStateWrapperArg

Returns a duplicate of the value. Read more
1.0.0 (const: unstable) · Source§

fn clone_from(&mut self, source: &Self)

Performs copy-assignment from source. Read more
Source§

impl Debug for NetworkStateWrapperArg

Source§

fn fmt(&self, f: &mut Formatter<'_>) -> Result

Formats the value using the given formatter. Read more
Source§

impl<'de> Deserialize<'de> for NetworkStateWrapperArg

Source§

fn deserialize<__D>(__deserializer: __D) -> Result<Self, __D::Error>
where + __D: Deserializer<'de>,

Deserialize this value from the given Serde deserializer. Read more
Source§

impl NamedType for NetworkStateWrapperArg

Source§

fn sid() -> SpectaID

Source§

fn named_data_type( + type_map: &mut TypeCollection, + generics: &[DataType], +) -> NamedDataType

this is equivalent to [Type::inline] but returns a [NamedDataType] instead.
Source§

fn definition_named_data_type(type_map: &mut TypeCollection) -> NamedDataType

this is equivalent to [Type::definition] but returns a [NamedDataType] instead.
Source§

impl Serialize for NetworkStateWrapperArg

Source§

fn serialize<__S>(&self, __serializer: __S) -> Result<__S::Ok, __S::Error>
where + __S: Serializer,

Serialize this value into the given Serde serializer. Read more
Source§

impl Type for NetworkStateWrapperArg

Source§

fn inline(type_map: &mut TypeCollection, generics: Generics<'_>) -> DataType

Returns the definition of a type using the provided generics. Read more
Source§

fn reference(type_map: &mut TypeCollection, generics: &[DataType]) -> Reference

Generates a datatype corresponding to a reference to this type, +as determined by its category. Getting a reference to a type implies that +it should belong in the type map (since it has to be referenced from somewhere), +so the output of definition will be put into the type map.
Source§

impl Flatten for NetworkStateWrapperArg

Auto Trait Implementations§

Blanket Implementations§

Source§

impl<T> Any for T
where + T: 'static + ?Sized,

Source§

fn type_id(&self) -> TypeId

Gets the TypeId of self. Read more
Source§

impl<T> Borrow<T> for T
where + T: ?Sized,

Source§

fn borrow(&self) -> &T

Immutably borrows from an owned value. Read more
Source§

impl<T> BorrowMut<T> for T
where + T: ?Sized,

Source§

fn borrow_mut(&mut self) -> &mut T

Mutably borrows from an owned value. Read more
Source§

impl<T> CloneToUninit for T
where + T: Clone,

Source§

unsafe fn clone_to_uninit(&self, dest: *mut u8)

🔬This is a nightly-only experimental API. (clone_to_uninit)
Performs copy-assignment from self to dest. Read more
§

impl<'de, D, R> CommandArg<'de, R> for D
where + D: Deserialize<'de>, + R: Runtime,

§

fn from_command(command: CommandItem<'de, R>) -> Result<D, InvokeError>

Derives an instance of Self from the [CommandItem]. Read more
Source§

impl<T> From<T> for T

Source§

fn from(t: T) -> T

Returns the argument unchanged.

+
§

impl<T> FunctionArg for T
where + T: Type,

§

fn to_datatype(type_map: &mut TypeCollection) -> Option<DataType>

Gets the type of an argument as a [DataType]. Read more
§

impl<T> Instrument for T

§

fn instrument(self, span: Span) -> Instrumented<Self>

Instruments this type with the provided [Span], returning an +Instrumented wrapper. Read more
§

fn in_current_span(self) -> Instrumented<Self>

Instruments this type with the current Span, returning an +Instrumented wrapper. Read more
Source§

impl<T, U> Into<U> for T
where + U: From<T>,

Source§

fn into(self) -> U

Calls U::from(self).

+

That is, this conversion is whatever the implementation of +From<T> for U chooses to do.

+
§

impl<T> IpcResponse for T
where + T: Serialize,

§

fn body(self) -> Result<InvokeResponseBody, Error>

Resolve the IPC response body.
§

impl<T> PolicyExt for T
where + T: ?Sized,

§

fn and<P, B, E>(self, other: P) -> And<T, P>
where + T: Policy<B, E>, + P: Policy<B, E>,

Create a new Policy that returns [Action::Follow] only if self and other return +Action::Follow. Read more
§

fn or<P, B, E>(self, other: P) -> Or<T, P>
where + T: Policy<B, E>, + P: Policy<B, E>,

Create a new Policy that returns [Action::Follow] if either self or other returns +Action::Follow. Read more
Source§

impl<T> Same for T

Source§

type Output = T

Should always be Self
§

impl<T> ScopeObject for T
where + T: Send + Sync + Debug + DeserializeOwned + 'static,

§

type Error = Error

The error type.
§

fn deserialize<R>( + _app: &AppHandle<R>, + raw: Value, +) -> Result<T, <T as ScopeObject>::Error>
where + R: Runtime,

Deserialize the raw scope value.
Source§

impl<T> Serialize for T
where + T: Serialize + ?Sized,

Source§

fn erased_serialize(&self, serializer: &mut dyn Serializer) -> Result<(), Error>

Source§

fn do_erased_serialize( + &self, + serializer: &mut dyn Serializer, +) -> Result<(), ErrorImpl>

Source§

impl<T> ToOwned for T
where + T: Clone,

Source§

type Owned = T

The resulting type after obtaining ownership.
Source§

fn to_owned(&self) -> T

Creates owned data from borrowed data, usually by cloning. Read more
Source§

fn clone_into(&self, target: &mut T)

Uses borrowed data to replace owned data, usually by cloning. Read more
Source§

impl<T, U> TryFrom<U> for T
where + U: Into<T>,

Source§

type Error = Infallible

The type returned in the event of a conversion error.
Source§

fn try_from(value: U) -> Result<T, <T as TryFrom<U>>::Error>

Performs the conversion.
Source§

impl<T, U> TryInto<U> for T
where + U: TryFrom<T>,

Source§

type Error = <U as TryFrom<T>>::Error

The type returned in the event of a conversion error.
Source§

fn try_into(self) -> Result<U, <U as TryFrom<T>>::Error>

Performs the conversion.
§

impl<V, T> VZip<V> for T
where + V: MultiLane<T>,

§

fn vzip(self) -> V

§

impl<T> WithSubscriber for T

§

fn with_subscriber<S>(self, subscriber: S) -> WithDispatch<Self>
where + S: Into<Dispatch>,

Attaches the provided Subscriber to this type, returning a +[WithDispatch] wrapper. Read more
§

fn with_current_subscriber(self) -> WithDispatch<Self>

Attaches the current default Subscriber to this type, returning a +[WithDispatch] wrapper. Read more
Source§

impl<T> DeserializeOwned for T
where + T: for<'de> Deserialize<'de>,

§

impl<T> UserEvent for T
where + T: Debug + Clone + Send + 'static,

\ No newline at end of file diff --git a/api/jellytau_lib/commands/download/struct.StorageStats.html b/api/jellytau_lib/commands/download/struct.StorageStats.html new file mode 100644 index 00000000..ce038c6f --- /dev/null +++ b/api/jellytau_lib/commands/download/struct.StorageStats.html @@ -0,0 +1,45 @@ +StorageStats in jellytau_lib::commands::download - RustSkip to main content

StorageStats

Struct StorageStats 

Source
pub struct StorageStats {
+    pub total_bytes: i64,
+    pub total_items: i64,
+    pub albums: Vec<AlbumStorageInfo>,
+}
Expand description

Storage statistics for downloads

+

Fields§

§total_bytes: i64§total_items: i64§albums: Vec<AlbumStorageInfo>

Trait Implementations§

Source§

impl Clone for StorageStats

Source§

fn clone(&self) -> StorageStats

Returns a duplicate of the value. Read more
1.0.0 (const: unstable) · Source§

fn clone_from(&mut self, source: &Self)

Performs copy-assignment from source. Read more
Source§

impl Debug for StorageStats

Source§

fn fmt(&self, f: &mut Formatter<'_>) -> Result

Formats the value using the given formatter. Read more
Source§

impl NamedType for StorageStats

Source§

fn sid() -> SpectaID

Source§

fn named_data_type( + type_map: &mut TypeCollection, + generics: &[DataType], +) -> NamedDataType

this is equivalent to [Type::inline] but returns a [NamedDataType] instead.
Source§

fn definition_named_data_type(type_map: &mut TypeCollection) -> NamedDataType

this is equivalent to [Type::definition] but returns a [NamedDataType] instead.
Source§

impl Serialize for StorageStats

Source§

fn serialize<__S>(&self, __serializer: __S) -> Result<__S::Ok, __S::Error>
where + __S: Serializer,

Serialize this value into the given Serde serializer. Read more
Source§

impl Type for StorageStats

Source§

fn inline(type_map: &mut TypeCollection, generics: Generics<'_>) -> DataType

Returns the definition of a type using the provided generics. Read more
Source§

fn reference(type_map: &mut TypeCollection, generics: &[DataType]) -> Reference

Generates a datatype corresponding to a reference to this type, +as determined by its category. Getting a reference to a type implies that +it should belong in the type map (since it has to be referenced from somewhere), +so the output of definition will be put into the type map.
Source§

impl Flatten for StorageStats

Auto Trait Implementations§

Blanket Implementations§

Source§

impl<T> Any for T
where + T: 'static + ?Sized,

Source§

fn type_id(&self) -> TypeId

Gets the TypeId of self. Read more
Source§

impl<T> Borrow<T> for T
where + T: ?Sized,

Source§

fn borrow(&self) -> &T

Immutably borrows from an owned value. Read more
Source§

impl<T> BorrowMut<T> for T
where + T: ?Sized,

Source§

fn borrow_mut(&mut self) -> &mut T

Mutably borrows from an owned value. Read more
Source§

impl<T> CloneToUninit for T
where + T: Clone,

Source§

unsafe fn clone_to_uninit(&self, dest: *mut u8)

🔬This is a nightly-only experimental API. (clone_to_uninit)
Performs copy-assignment from self to dest. Read more
Source§

impl<T> From<T> for T

Source§

fn from(t: T) -> T

Returns the argument unchanged.

+
§

impl<T> FunctionArg for T
where + T: Type,

§

fn to_datatype(type_map: &mut TypeCollection) -> Option<DataType>

Gets the type of an argument as a [DataType]. Read more
§

impl<T> Instrument for T

§

fn instrument(self, span: Span) -> Instrumented<Self>

Instruments this type with the provided [Span], returning an +Instrumented wrapper. Read more
§

fn in_current_span(self) -> Instrumented<Self>

Instruments this type with the current Span, returning an +Instrumented wrapper. Read more
Source§

impl<T, U> Into<U> for T
where + U: From<T>,

Source§

fn into(self) -> U

Calls U::from(self).

+

That is, this conversion is whatever the implementation of +From<T> for U chooses to do.

+
§

impl<T> IpcResponse for T
where + T: Serialize,

§

fn body(self) -> Result<InvokeResponseBody, Error>

Resolve the IPC response body.
§

impl<T> PolicyExt for T
where + T: ?Sized,

§

fn and<P, B, E>(self, other: P) -> And<T, P>
where + T: Policy<B, E>, + P: Policy<B, E>,

Create a new Policy that returns [Action::Follow] only if self and other return +Action::Follow. Read more
§

fn or<P, B, E>(self, other: P) -> Or<T, P>
where + T: Policy<B, E>, + P: Policy<B, E>,

Create a new Policy that returns [Action::Follow] if either self or other returns +Action::Follow. Read more
Source§

impl<T> Same for T

Source§

type Output = T

Should always be Self
Source§

impl<T> Serialize for T
where + T: Serialize + ?Sized,

Source§

fn erased_serialize(&self, serializer: &mut dyn Serializer) -> Result<(), Error>

Source§

fn do_erased_serialize( + &self, + serializer: &mut dyn Serializer, +) -> Result<(), ErrorImpl>

Source§

impl<T> ToOwned for T
where + T: Clone,

Source§

type Owned = T

The resulting type after obtaining ownership.
Source§

fn to_owned(&self) -> T

Creates owned data from borrowed data, usually by cloning. Read more
Source§

fn clone_into(&self, target: &mut T)

Uses borrowed data to replace owned data, usually by cloning. Read more
Source§

impl<T, U> TryFrom<U> for T
where + U: Into<T>,

Source§

type Error = Infallible

The type returned in the event of a conversion error.
Source§

fn try_from(value: U) -> Result<T, <T as TryFrom<U>>::Error>

Performs the conversion.
Source§

impl<T, U> TryInto<U> for T
where + U: TryFrom<T>,

Source§

type Error = <U as TryFrom<T>>::Error

The type returned in the event of a conversion error.
Source§

fn try_into(self) -> Result<U, <U as TryFrom<T>>::Error>

Performs the conversion.
§

impl<V, T> VZip<V> for T
where + V: MultiLane<T>,

§

fn vzip(self) -> V

§

impl<T> WithSubscriber for T

§

fn with_subscriber<S>(self, subscriber: S) -> WithDispatch<Self>
where + S: Into<Dispatch>,

Attaches the provided Subscriber to this type, returning a +[WithDispatch] wrapper. Read more
§

fn with_current_subscriber(self) -> WithDispatch<Self>

Attaches the current default Subscriber to this type, returning a +[WithDispatch] wrapper. Read more
§

impl<T> UserEvent for T
where + T: Debug + Clone + Send + 'static,

\ No newline at end of file diff --git a/api/jellytau_lib/commands/download/type.EpisodeRow.html b/api/jellytau_lib/commands/download/type.EpisodeRow.html new file mode 100644 index 00000000..4af14a0d --- /dev/null +++ b/api/jellytau_lib/commands/download/type.EpisodeRow.html @@ -0,0 +1,3 @@ +EpisodeRow in jellytau_lib::commands::download - RustSkip to main content

EpisodeRow

Type Alias EpisodeRow 

Source
type EpisodeRow = (String, String, Option<String>, Option<i32>, Option<i32>);
Expand description

One row of the series episode listing used when queueing a whole series: +(id, name, season_name, index_number, parent_index_number).

+
\ No newline at end of file diff --git a/api/jellytau_lib/commands/favorites/fn.drain_pending_favorites.html b/api/jellytau_lib/commands/favorites/fn.drain_pending_favorites.html new file mode 100644 index 00000000..e446077a --- /dev/null +++ b/api/jellytau_lib/commands/favorites/fn.drain_pending_favorites.html @@ -0,0 +1,10 @@ +drain_pending_favorites in jellytau_lib::commands::favorites - RustSkip to main content

drain_pending_favorites

Function drain_pending_favorites 

Source
pub async fn drain_pending_favorites(
+    db: &Arc<RusqliteService>,
+    sink: &dyn FavoriteSink,
+    user_id: &str,
+) -> Result<Vec<String>, String>
Expand description

Push pending favourite changes to the server and clear their flags.

+

Returns the ids that reached the server, for the favorites-changed event. +A row whose push fails keeps pending_sync = 1 and is retried on the next +reconnect rather than being dropped.

+

TRACES: UR-069 | DR-120 | UT-103

+
\ No newline at end of file diff --git a/api/jellytau_lib/commands/favorites/fn.read_pending.html b/api/jellytau_lib/commands/favorites/fn.read_pending.html new file mode 100644 index 00000000..045e24ee --- /dev/null +++ b/api/jellytau_lib/commands/favorites/fn.read_pending.html @@ -0,0 +1,5 @@ +read_pending in jellytau_lib::commands::favorites - RustSkip to main content

read_pending

Function read_pending 

Source
async fn read_pending(
+    db: &Arc<RusqliteService>,
+    user_id: &str,
+) -> Result<Vec<PendingFavorite>, String>
Expand description

Read every favourite change this user has pending.

+
\ No newline at end of file diff --git a/api/jellytau_lib/commands/favorites/fn.run_drain.html b/api/jellytau_lib/commands/favorites/fn.run_drain.html new file mode 100644 index 00000000..e93f8ab4 --- /dev/null +++ b/api/jellytau_lib/commands/favorites/fn.run_drain.html @@ -0,0 +1 @@ +run_drain in jellytau_lib::commands::favorites - RustSkip to main content

run_drain

Function run_drain 

Source
async fn run_drain(app: &AppHandle) -> Result<(), String>
\ No newline at end of file diff --git a/api/jellytau_lib/commands/favorites/fn.spawn_favorites_drain.html b/api/jellytau_lib/commands/favorites/fn.spawn_favorites_drain.html new file mode 100644 index 00000000..c4d27c58 --- /dev/null +++ b/api/jellytau_lib/commands/favorites/fn.spawn_favorites_drain.html @@ -0,0 +1,6 @@ +spawn_favorites_drain in jellytau_lib::commands::favorites - RustSkip to main content

spawn_favorites_drain

Function spawn_favorites_drain 

Source
pub fn spawn_favorites_drain(app: AppHandle)
Expand description

Drain on every offline→online transition.

+

Hooks the connectivity:reconnected event the ConnectivityMonitor +already emits, rather than polling — reachability is derived from real +traffic (DR-055) and this just reacts to it.

+

TRACES: UR-069 | DR-120

+
\ No newline at end of file diff --git a/api/jellytau_lib/commands/favorites/index.html b/api/jellytau_lib/commands/favorites/index.html new file mode 100644 index 00000000..3e781c2c --- /dev/null +++ b/api/jellytau_lib/commands/favorites/index.html @@ -0,0 +1,10 @@ +jellytau_lib::commands::favorites - RustSkip to main content

Module favorites

Module favorites 

Source
Expand description

Pushing favourite toggles made while the server was unreachable.

+

Favouriting works offline: storage_toggle_favorite writes the local +user_data row and sets pending_sync = 1. Until DR-120 nothing ever +cleared that flag — the offline mark_favorite/unmark_favorite are no-ops +and syncService.queueFavorite had no callers — so an offline toggle was +silently lost.

+

The drain lives in Rust, not the frontend, because it must run whether or +not any view is mounted; a drain started by a component dies with it.

+

TRACES: UR-069 | DR-120 | UT-103

+

Structs§

PendingFavorite
A local favourite change still waiting to reach the server.

Traits§

FavoriteSink
The subset of the repository the drain needs.

Functions§

drain_pending_favorites
Push pending favourite changes to the server and clear their flags.
read_pending 🔒
Read every favourite change this user has pending.
run_drain 🔒
spawn_favorites_drain
Drain on every offline→online transition.
\ No newline at end of file diff --git a/api/jellytau_lib/commands/favorites/sidebar-items.js b/api/jellytau_lib/commands/favorites/sidebar-items.js new file mode 100644 index 00000000..c8c8dd29 --- /dev/null +++ b/api/jellytau_lib/commands/favorites/sidebar-items.js @@ -0,0 +1 @@ +window.SIDEBAR_ITEMS = {"fn":["drain_pending_favorites","read_pending","run_drain","spawn_favorites_drain"],"struct":["PendingFavorite"],"trait":["FavoriteSink"]}; \ No newline at end of file diff --git a/api/jellytau_lib/commands/favorites/struct.PendingFavorite.html b/api/jellytau_lib/commands/favorites/struct.PendingFavorite.html new file mode 100644 index 00000000..40475cb2 --- /dev/null +++ b/api/jellytau_lib/commands/favorites/struct.PendingFavorite.html @@ -0,0 +1,38 @@ +PendingFavorite in jellytau_lib::commands::favorites - RustSkip to main content

PendingFavorite

Struct PendingFavorite 

Source
pub struct PendingFavorite {
+    pub item_id: String,
+    pub is_favorite: bool,
+}
Expand description

A local favourite change still waiting to reach the server.

+

Fields§

§item_id: String§is_favorite: bool

Trait Implementations§

Source§

impl Clone for PendingFavorite

Source§

fn clone(&self) -> PendingFavorite

Returns a duplicate of the value. Read more
1.0.0 (const: unstable) · Source§

fn clone_from(&mut self, source: &Self)

Performs copy-assignment from source. Read more
Source§

impl Debug for PendingFavorite

Source§

fn fmt(&self, f: &mut Formatter<'_>) -> Result

Formats the value using the given formatter. Read more
Source§

impl PartialEq for PendingFavorite

Source§

fn eq(&self, other: &PendingFavorite) -> bool

Tests for self and other values to be equal, and is used by ==.
1.0.0 (const: unstable) · Source§

fn ne(&self, other: &Rhs) -> bool

Tests for !=. The default implementation is almost always sufficient, +and should not be overridden without very good reason.
Source§

impl Eq for PendingFavorite

Source§

impl StructuralPartialEq for PendingFavorite

Auto Trait Implementations§

Blanket Implementations§

Source§

impl<T> Any for T
where + T: 'static + ?Sized,

Source§

fn type_id(&self) -> TypeId

Gets the TypeId of self. Read more
Source§

impl<T> Borrow<T> for T
where + T: ?Sized,

Source§

fn borrow(&self) -> &T

Immutably borrows from an owned value. Read more
Source§

impl<T> BorrowMut<T> for T
where + T: ?Sized,

Source§

fn borrow_mut(&mut self) -> &mut T

Mutably borrows from an owned value. Read more
Source§

impl<T> CloneToUninit for T
where + T: Clone,

Source§

unsafe fn clone_to_uninit(&self, dest: *mut u8)

🔬This is a nightly-only experimental API. (clone_to_uninit)
Performs copy-assignment from self to dest. Read more
§

impl<Q, K> Equivalent<K> for Q
where + Q: Eq + ?Sized, + K: Borrow<Q> + ?Sized,

§

fn equivalent(&self, key: &K) -> bool

Checks if this value is equivalent to the given key. Read more
§

impl<Q, K> Equivalent<K> for Q
where + Q: Eq + ?Sized, + K: Borrow<Q> + ?Sized,

§

fn equivalent(&self, key: &K) -> bool

Compare self to key and return true if they are equal.
§

impl<Q, K> Equivalent<K> for Q
where + Q: Eq + ?Sized, + K: Borrow<Q> + ?Sized,

§

fn equivalent(&self, key: &K) -> bool

Checks if this value is equivalent to the given key. Read more
Source§

impl<T> From<T> for T

Source§

fn from(t: T) -> T

Returns the argument unchanged.

+
§

impl<T> Instrument for T

§

fn instrument(self, span: Span) -> Instrumented<Self>

Instruments this type with the provided [Span], returning an +Instrumented wrapper. Read more
§

fn in_current_span(self) -> Instrumented<Self>

Instruments this type with the current Span, returning an +Instrumented wrapper. Read more
Source§

impl<T, U> Into<U> for T
where + U: From<T>,

Source§

fn into(self) -> U

Calls U::from(self).

+

That is, this conversion is whatever the implementation of +From<T> for U chooses to do.

+
§

impl<T> PolicyExt for T
where + T: ?Sized,

§

fn and<P, B, E>(self, other: P) -> And<T, P>
where + T: Policy<B, E>, + P: Policy<B, E>,

Create a new Policy that returns [Action::Follow] only if self and other return +Action::Follow. Read more
§

fn or<P, B, E>(self, other: P) -> Or<T, P>
where + T: Policy<B, E>, + P: Policy<B, E>,

Create a new Policy that returns [Action::Follow] if either self or other returns +Action::Follow. Read more
Source§

impl<T> Same for T

Source§

type Output = T

Should always be Self
Source§

impl<T> ToOwned for T
where + T: Clone,

Source§

type Owned = T

The resulting type after obtaining ownership.
Source§

fn to_owned(&self) -> T

Creates owned data from borrowed data, usually by cloning. Read more
Source§

fn clone_into(&self, target: &mut T)

Uses borrowed data to replace owned data, usually by cloning. Read more
Source§

impl<T, U> TryFrom<U> for T
where + U: Into<T>,

Source§

type Error = Infallible

The type returned in the event of a conversion error.
Source§

fn try_from(value: U) -> Result<T, <T as TryFrom<U>>::Error>

Performs the conversion.
Source§

impl<T, U> TryInto<U> for T
where + U: TryFrom<T>,

Source§

type Error = <U as TryFrom<T>>::Error

The type returned in the event of a conversion error.
Source§

fn try_into(self) -> Result<U, <U as TryFrom<T>>::Error>

Performs the conversion.
§

impl<V, T> VZip<V> for T
where + V: MultiLane<T>,

§

fn vzip(self) -> V

§

impl<T> WithSubscriber for T

§

fn with_subscriber<S>(self, subscriber: S) -> WithDispatch<Self>
where + S: Into<Dispatch>,

Attaches the provided Subscriber to this type, returning a +[WithDispatch] wrapper. Read more
§

fn with_current_subscriber(self) -> WithDispatch<Self>

Attaches the current default Subscriber to this type, returning a +[WithDispatch] wrapper. Read more
§

impl<T> UserEvent for T
where + T: Debug + Clone + Send + 'static,

\ No newline at end of file diff --git a/api/jellytau_lib/commands/favorites/trait.FavoriteSink.html b/api/jellytau_lib/commands/favorites/trait.FavoriteSink.html new file mode 100644 index 00000000..d65cd93d --- /dev/null +++ b/api/jellytau_lib/commands/favorites/trait.FavoriteSink.html @@ -0,0 +1,21 @@ +FavoriteSink in jellytau_lib::commands::favorites - RustSkip to main content

FavoriteSink

Trait FavoriteSink 

Source
pub trait FavoriteSink: Send + Sync {
+    // Required method
+    fn push_favorite<'life0, 'life1, 'async_trait>(
+        &'life0 self,
+        item_id: &'life1 str,
+        is_favorite: bool,
+    ) -> Pin<Box<dyn Future<Output = Result<(), RepoError>> + Send + 'async_trait>>
+       where Self: 'async_trait,
+             'life0: 'async_trait,
+             'life1: 'async_trait;
+}
Expand description

The subset of the repository the drain needs.

+

Narrow on purpose: a test double for MediaRepository would be forty +unimplemented methods, which is how a drain ends up untested.

+

Required Methods§

Source

fn push_favorite<'life0, 'life1, 'async_trait>( + &'life0 self, + item_id: &'life1 str, + is_favorite: bool, +) -> Pin<Box<dyn Future<Output = Result<(), RepoError>> + Send + 'async_trait>>
where + Self: 'async_trait, + 'life0: 'async_trait, + 'life1: 'async_trait,

Implementors§

\ No newline at end of file diff --git a/api/jellytau_lib/commands/index.html b/api/jellytau_lib/commands/index.html new file mode 100644 index 00000000..fa16a5ed --- /dev/null +++ b/api/jellytau_lib/commands/index.html @@ -0,0 +1,3 @@ +jellytau_lib::commands - RustSkip to main content

Module commands

Module commands 

Source

Re-exports§

pub use repository::RepositoryManager;
pub use repository::RepositoryManagerWrapper;
pub use auth::*;
pub use catalog::*;
pub use connectivity::*;
pub use conversions::*;
pub use device::*;
pub use diagnostics::*;
pub use download::*;
pub use library::*;
pub use offline::*;
pub use playback_mode::*;
pub use playback_reporting::*;
pub use player::*;
pub use playlist::*;
pub use repository::*;
pub use sessions::*;
pub use storage::*;
pub use sync::*;
pub use sync_drain::*;

Modules§

auth
Authentication and session-lifecycle commands.
catalog
Tauri commands for the offline “browse & queue” feature.
connectivity
Server-reachability / connectivity commands.
conversions
Tauri commands for unit conversions and formatting
device
Device identification commands
diagnostics
Diagnostics: log level control and the exportable bug-report bundle.
download
Tauri commands for download operations
favorites
Pushing favourite toggles made while the server was unreachable.
library
Library browsing preferences — currently, which folders are hidden.
offline
Tauri commands for offline data access
playback_mode
Playback-mode transfer commands (local ↔ remote).
playback_reporting
Tauri commands for playback reporting operations
player
TRACES: UR-003, UR-004, UR-005, UR-010, UR-020, UR-021 | JA-022, JA-023, JA-024, JA-025, JA-026 | DR-001
playlist
Tauri commands for playlist management +Uses handle-based system: UUID -> Arc
repository
Tauri commands for repository access +Uses handle-based system: UUID -> Arc
sessions
TRACES: UR-010 | JA-021 | DR-037
storage
Tauri commands for database/storage operations
sync
Tauri commands for sync queue operations
sync_drain
Draining the offline mutation queue (sync_queue) to the server.
\ No newline at end of file diff --git a/api/jellytau_lib/commands/library/constant.CANDIDATE_SCAN_LIMIT.html b/api/jellytau_lib/commands/library/constant.CANDIDATE_SCAN_LIMIT.html new file mode 100644 index 00000000..50a1e9f4 --- /dev/null +++ b/api/jellytau_lib/commands/library/constant.CANDIDATE_SCAN_LIMIT.html @@ -0,0 +1,5 @@ +CANDIDATE_SCAN_LIMIT in jellytau_lib::commands::library - RustSkip to main content

CANDIDATE_SCAN_LIMIT

Constant CANDIDATE_SCAN_LIMIT 

Source
const CANDIDATE_SCAN_LIMIT: usize = 500;
Expand description

How many immediate children of a library the picker will consider.

+

A music library’s root listing is folders and (on some layouts) artists, not +the whole catalog, so this is generous. It exists to stop a pathological +library from turning the settings page into an unbounded fetch.

+
\ No newline at end of file diff --git a/api/jellytau_lib/commands/library/constant.LIBRARY_SETTINGS_KEY.html b/api/jellytau_lib/commands/library/constant.LIBRARY_SETTINGS_KEY.html new file mode 100644 index 00000000..b181d4b7 --- /dev/null +++ b/api/jellytau_lib/commands/library/constant.LIBRARY_SETTINGS_KEY.html @@ -0,0 +1,5 @@ +LIBRARY_SETTINGS_KEY in jellytau_lib::commands::library - RustSkip to main content

LIBRARY_SETTINGS_KEY

Constant LIBRARY_SETTINGS_KEY 

Source
const LIBRARY_SETTINGS_KEY: &str = "library_settings";
Expand description

app_settings key holding the persisted library preferences (JSON).

+

Persisted for the same reason the streaming cap is: a hidden folder that +silently comes back on the next launch is a setting the user has to keep +re-applying, and they would have no way to tell it had been forgotten.

+
\ No newline at end of file diff --git a/api/jellytau_lib/commands/library/fn.library_get_exclusion_candidates.html b/api/jellytau_lib/commands/library/fn.library_get_exclusion_candidates.html new file mode 100644 index 00000000..532021f3 --- /dev/null +++ b/api/jellytau_lib/commands/library/fn.library_get_exclusion_candidates.html @@ -0,0 +1,14 @@ +library_get_exclusion_candidates in jellytau_lib::commands::library - RustSkip to main content

library_get_exclusion_candidates

Function library_get_exclusion_candidates 

Source
pub async fn library_get_exclusion_candidates(
+    manager: State<'_, RepositoryManagerWrapper>,
+    handle: String,
+) -> Result<Vec<ExclusionCandidate>, String>
Expand description

The folders the user may choose to hide.

+

Offers each music library and the folders directly inside it. Music is the +only scope offered because it is the one where a foreign folder — podcasts, +audiobooks, sound effects — routinely shares a library with the media the +user actually browses; the scope is decided here rather than in the UI so the +collection-type table stays out of the frontend +(see SearchScope::for_collection_type).

+

Reads through HybridRepository::get_items_unfiltered so folders that are +already hidden still appear — otherwise the setting could never be undone.

+

TRACES: UR-076 | DR-209

+
\ No newline at end of file diff --git a/api/jellytau_lib/commands/library/fn.library_get_settings.html b/api/jellytau_lib/commands/library/fn.library_get_settings.html new file mode 100644 index 00000000..0b600913 --- /dev/null +++ b/api/jellytau_lib/commands/library/fn.library_get_settings.html @@ -0,0 +1,6 @@ +library_get_settings in jellytau_lib::commands::library - RustSkip to main content

library_get_settings

Function library_get_settings 

Source
pub async fn library_get_settings() -> Result<LibrarySettings, String>
Expand description

The library preferences currently in force.

+

Read from the in-memory exclusion set rather than the database: that set is +what queries actually consult, so reading it is the only answer that cannot +disagree with what the user is seeing.

+

TRACES: UR-076 | DR-209

+
\ No newline at end of file diff --git a/api/jellytau_lib/commands/library/fn.library_set_settings.html b/api/jellytau_lib/commands/library/fn.library_set_settings.html new file mode 100644 index 00000000..58e1e0c2 --- /dev/null +++ b/api/jellytau_lib/commands/library/fn.library_set_settings.html @@ -0,0 +1,9 @@ +library_set_settings in jellytau_lib::commands::library - RustSkip to main content

library_set_settings

Function library_set_settings 

Source
pub async fn library_set_settings(
+    db: State<'_, DatabaseWrapper>,
+    settings: LibrarySettings,
+) -> Result<LibrarySettings, String>
Expand description

Replace the library preferences: apply them to every subsequent query and +persist them.

+

Returns the sanitised value actually applied, so the picker shows what was +stored rather than what it sent.

+

TRACES: UR-076 | DR-209

+
\ No newline at end of file diff --git a/api/jellytau_lib/commands/library/fn.persist_library_settings.html b/api/jellytau_lib/commands/library/fn.persist_library_settings.html new file mode 100644 index 00000000..4dc6c08b --- /dev/null +++ b/api/jellytau_lib/commands/library/fn.persist_library_settings.html @@ -0,0 +1,9 @@ +persist_library_settings in jellytau_lib::commands::library - RustSkip to main content

persist_library_settings

Function persist_library_settings 

Source
async fn persist_library_settings(
+    db: &State<'_, DatabaseWrapper>,
+    settings: &LibrarySettings,
+)
Expand description

Write the preferences to app_settings.

+

Failure is logged, not returned: the setting has already been applied in +memory, and failing the whole call because the write failed would leave the +picker showing a state that is in force.

+

TRACES: UR-076 | DR-209

+
\ No newline at end of file diff --git a/api/jellytau_lib/commands/library/fn.restore_library_settings.html b/api/jellytau_lib/commands/library/fn.restore_library_settings.html new file mode 100644 index 00000000..5439007c --- /dev/null +++ b/api/jellytau_lib/commands/library/fn.restore_library_settings.html @@ -0,0 +1,7 @@ +restore_library_settings in jellytau_lib::commands::library - RustSkip to main content

restore_library_settings

Function restore_library_settings 

Source
pub async fn restore_library_settings(app: &AppHandle)
Expand description

Restore the persisted preferences at startup, into the exclusion set the +repository consults.

+

Called from the Tauri setup hook. A missing or unreadable row leaves the +default — nothing hidden — in place, so a database problem shows the user +more than they asked for rather than less.

+

TRACES: UR-076 | DR-209

+
\ No newline at end of file diff --git a/api/jellytau_lib/commands/library/index.html b/api/jellytau_lib/commands/library/index.html new file mode 100644 index 00000000..ffefcecf --- /dev/null +++ b/api/jellytau_lib/commands/library/index.html @@ -0,0 +1,12 @@ +jellytau_lib::commands::library - RustSkip to main content

Module library

Module library 

Source
Expand description

Library browsing preferences — currently, which folders are hidden.

+

The setting replaces a hardcoded frontend filter that dropped any item +literally named “Podcasts”, which was one user’s folder layout keyed on an +English string and shipped to everyone. What is hidden is now a user choice +made of stable ids, applied in the repository layer +(repository::exclusions) so every query path agrees; the frontend only +renders a picker over the candidates this module serves.

+

TRACES: UR-076 | DR-209

+

Structs§

ExclusionCandidate
Something the user may choose to hide: a library, or a folder directly +inside one.

Constants§

CANDIDATE_SCAN_LIMIT 🔒
How many immediate children of a library the picker will consider.
LIBRARY_SETTINGS_KEY 🔒
app_settings key holding the persisted library preferences (JSON).

Functions§

library_get_exclusion_candidates
The folders the user may choose to hide.
library_get_settings
The library preferences currently in force.
library_set_settings
Replace the library preferences: apply them to every subsequent query and +persist them.
persist_library_settings 🔒
Write the preferences to app_settings.
restore_library_settings
Restore the persisted preferences at startup, into the exclusion set the +repository consults.
\ No newline at end of file diff --git a/api/jellytau_lib/commands/library/sidebar-items.js b/api/jellytau_lib/commands/library/sidebar-items.js new file mode 100644 index 00000000..85c2f41c --- /dev/null +++ b/api/jellytau_lib/commands/library/sidebar-items.js @@ -0,0 +1 @@ +window.SIDEBAR_ITEMS = {"constant":["CANDIDATE_SCAN_LIMIT","LIBRARY_SETTINGS_KEY"],"fn":["library_get_exclusion_candidates","library_get_settings","library_set_settings","persist_library_settings","restore_library_settings"],"struct":["ExclusionCandidate"]}; \ No newline at end of file diff --git a/api/jellytau_lib/commands/library/struct.ExclusionCandidate.html b/api/jellytau_lib/commands/library/struct.ExclusionCandidate.html new file mode 100644 index 00000000..65133701 --- /dev/null +++ b/api/jellytau_lib/commands/library/struct.ExclusionCandidate.html @@ -0,0 +1,65 @@ +ExclusionCandidate in jellytau_lib::commands::library - RustSkip to main content

ExclusionCandidate

Struct ExclusionCandidate 

Source
pub struct ExclusionCandidate {
+    pub id: String,
+    pub name: String,
+    pub library_name: String,
+    pub is_library: bool,
+}
Expand description

Something the user may choose to hide: a library, or a folder directly +inside one.

+

Which containers are offerable is a domain question (it depends on the +library’s Jellyfin collection type and on what counts as a folder), so the +list is assembled here and the frontend renders it verbatim.

+

TRACES: UR-076 | DR-209

+

Fields§

§id: String

Stable Jellyfin item id — what gets stored when the user picks it.

+
§name: String

Display name of the folder (or of the library, for a whole-library entry).

+
§library_name: String

Library this candidate lives in, so the picker can group and disambiguate +two folders that share a name.

+
§is_library: bool

True when the candidate is a library rather than a folder inside one.

+

Trait Implementations§

Source§

impl Clone for ExclusionCandidate

Source§

fn clone(&self) -> ExclusionCandidate

Returns a duplicate of the value. Read more
1.0.0 (const: unstable) · Source§

fn clone_from(&mut self, source: &Self)

Performs copy-assignment from source. Read more
Source§

impl Debug for ExclusionCandidate

Source§

fn fmt(&self, f: &mut Formatter<'_>) -> Result

Formats the value using the given formatter. Read more
Source§

impl<'de> Deserialize<'de> for ExclusionCandidate

Source§

fn deserialize<__D>(__deserializer: __D) -> Result<Self, __D::Error>
where + __D: Deserializer<'de>,

Deserialize this value from the given Serde deserializer. Read more
Source§

impl NamedType for ExclusionCandidate

Source§

fn sid() -> SpectaID

Source§

fn named_data_type( + type_map: &mut TypeCollection, + generics: &[DataType], +) -> NamedDataType

this is equivalent to [Type::inline] but returns a [NamedDataType] instead.
Source§

fn definition_named_data_type(type_map: &mut TypeCollection) -> NamedDataType

this is equivalent to [Type::definition] but returns a [NamedDataType] instead.
Source§

impl Serialize for ExclusionCandidate

Source§

fn serialize<__S>(&self, __serializer: __S) -> Result<__S::Ok, __S::Error>
where + __S: Serializer,

Serialize this value into the given Serde serializer. Read more
Source§

impl Type for ExclusionCandidate

Source§

fn inline(type_map: &mut TypeCollection, generics: Generics<'_>) -> DataType

Returns the definition of a type using the provided generics. Read more
Source§

fn reference(type_map: &mut TypeCollection, generics: &[DataType]) -> Reference

Generates a datatype corresponding to a reference to this type, +as determined by its category. Getting a reference to a type implies that +it should belong in the type map (since it has to be referenced from somewhere), +so the output of definition will be put into the type map.
Source§

impl Flatten for ExclusionCandidate

Auto Trait Implementations§

Blanket Implementations§

Source§

impl<T> Any for T
where + T: 'static + ?Sized,

Source§

fn type_id(&self) -> TypeId

Gets the TypeId of self. Read more
Source§

impl<T> Borrow<T> for T
where + T: ?Sized,

Source§

fn borrow(&self) -> &T

Immutably borrows from an owned value. Read more
Source§

impl<T> BorrowMut<T> for T
where + T: ?Sized,

Source§

fn borrow_mut(&mut self) -> &mut T

Mutably borrows from an owned value. Read more
Source§

impl<T> CloneToUninit for T
where + T: Clone,

Source§

unsafe fn clone_to_uninit(&self, dest: *mut u8)

🔬This is a nightly-only experimental API. (clone_to_uninit)
Performs copy-assignment from self to dest. Read more
§

impl<'de, D, R> CommandArg<'de, R> for D
where + D: Deserialize<'de>, + R: Runtime,

§

fn from_command(command: CommandItem<'de, R>) -> Result<D, InvokeError>

Derives an instance of Self from the [CommandItem]. Read more
Source§

impl<T> From<T> for T

Source§

fn from(t: T) -> T

Returns the argument unchanged.

+
§

impl<T> FunctionArg for T
where + T: Type,

§

fn to_datatype(type_map: &mut TypeCollection) -> Option<DataType>

Gets the type of an argument as a [DataType]. Read more
§

impl<T> Instrument for T

§

fn instrument(self, span: Span) -> Instrumented<Self>

Instruments this type with the provided [Span], returning an +Instrumented wrapper. Read more
§

fn in_current_span(self) -> Instrumented<Self>

Instruments this type with the current Span, returning an +Instrumented wrapper. Read more
Source§

impl<T, U> Into<U> for T
where + U: From<T>,

Source§

fn into(self) -> U

Calls U::from(self).

+

That is, this conversion is whatever the implementation of +From<T> for U chooses to do.

+
§

impl<T> IpcResponse for T
where + T: Serialize,

§

fn body(self) -> Result<InvokeResponseBody, Error>

Resolve the IPC response body.
§

impl<T> PolicyExt for T
where + T: ?Sized,

§

fn and<P, B, E>(self, other: P) -> And<T, P>
where + T: Policy<B, E>, + P: Policy<B, E>,

Create a new Policy that returns [Action::Follow] only if self and other return +Action::Follow. Read more
§

fn or<P, B, E>(self, other: P) -> Or<T, P>
where + T: Policy<B, E>, + P: Policy<B, E>,

Create a new Policy that returns [Action::Follow] if either self or other returns +Action::Follow. Read more
Source§

impl<T> Same for T

Source§

type Output = T

Should always be Self
§

impl<T> ScopeObject for T
where + T: Send + Sync + Debug + DeserializeOwned + 'static,

§

type Error = Error

The error type.
§

fn deserialize<R>( + _app: &AppHandle<R>, + raw: Value, +) -> Result<T, <T as ScopeObject>::Error>
where + R: Runtime,

Deserialize the raw scope value.
Source§

impl<T> Serialize for T
where + T: Serialize + ?Sized,

Source§

fn erased_serialize(&self, serializer: &mut dyn Serializer) -> Result<(), Error>

Source§

fn do_erased_serialize( + &self, + serializer: &mut dyn Serializer, +) -> Result<(), ErrorImpl>

Source§

impl<T> ToOwned for T
where + T: Clone,

Source§

type Owned = T

The resulting type after obtaining ownership.
Source§

fn to_owned(&self) -> T

Creates owned data from borrowed data, usually by cloning. Read more
Source§

fn clone_into(&self, target: &mut T)

Uses borrowed data to replace owned data, usually by cloning. Read more
Source§

impl<T, U> TryFrom<U> for T
where + U: Into<T>,

Source§

type Error = Infallible

The type returned in the event of a conversion error.
Source§

fn try_from(value: U) -> Result<T, <T as TryFrom<U>>::Error>

Performs the conversion.
Source§

impl<T, U> TryInto<U> for T
where + U: TryFrom<T>,

Source§

type Error = <U as TryFrom<T>>::Error

The type returned in the event of a conversion error.
Source§

fn try_into(self) -> Result<U, <U as TryFrom<T>>::Error>

Performs the conversion.
§

impl<V, T> VZip<V> for T
where + V: MultiLane<T>,

§

fn vzip(self) -> V

§

impl<T> WithSubscriber for T

§

fn with_subscriber<S>(self, subscriber: S) -> WithDispatch<Self>
where + S: Into<Dispatch>,

Attaches the provided Subscriber to this type, returning a +[WithDispatch] wrapper. Read more
§

fn with_current_subscriber(self) -> WithDispatch<Self>

Attaches the current default Subscriber to this type, returning a +[WithDispatch] wrapper. Read more
Source§

impl<T> DeserializeOwned for T
where + T: for<'de> Deserialize<'de>,

§

impl<T> UserEvent for T
where + T: Debug + Clone + Send + 'static,

\ No newline at end of file diff --git a/api/jellytau_lib/commands/offline/fn.offline_get_items.html b/api/jellytau_lib/commands/offline/fn.offline_get_items.html new file mode 100644 index 00000000..4cfacbc4 --- /dev/null +++ b/api/jellytau_lib/commands/offline/fn.offline_get_items.html @@ -0,0 +1,5 @@ +offline_get_items in jellytau_lib::commands::offline - RustSkip to main content

offline_get_items

Function offline_get_items 

Source
pub async fn offline_get_items(
+    db: State<'_, DatabaseWrapper>,
+    user_id: String,
+) -> Result<Vec<OfflineItem>, String>
Expand description

Get all offline items for a user

+
\ No newline at end of file diff --git a/api/jellytau_lib/commands/offline/fn.offline_is_available.html b/api/jellytau_lib/commands/offline/fn.offline_is_available.html new file mode 100644 index 00000000..c7063c91 --- /dev/null +++ b/api/jellytau_lib/commands/offline/fn.offline_is_available.html @@ -0,0 +1,5 @@ +offline_is_available in jellytau_lib::commands::offline - RustSkip to main content

offline_is_available

Function offline_is_available 

Source
pub async fn offline_is_available(
+    db: State<'_, DatabaseWrapper>,
+    item_id: String,
+) -> Result<bool, String>
Expand description

Check if an item is available offline

+
\ No newline at end of file diff --git a/api/jellytau_lib/commands/offline/fn.offline_search.html b/api/jellytau_lib/commands/offline/fn.offline_search.html new file mode 100644 index 00000000..7a95ba97 --- /dev/null +++ b/api/jellytau_lib/commands/offline/fn.offline_search.html @@ -0,0 +1,6 @@ +offline_search in jellytau_lib::commands::offline - RustSkip to main content

offline_search

Function offline_search 

Source
pub async fn offline_search(
+    db: State<'_, DatabaseWrapper>,
+    user_id: String,
+    query: String,
+) -> Result<Vec<OfflineItem>, String>
Expand description

Search offline items

+
\ No newline at end of file diff --git a/api/jellytau_lib/commands/offline/index.html b/api/jellytau_lib/commands/offline/index.html new file mode 100644 index 00000000..020ba455 --- /dev/null +++ b/api/jellytau_lib/commands/offline/index.html @@ -0,0 +1,2 @@ +jellytau_lib::commands::offline - RustSkip to main content

Module offline

Module offline 

Source
Expand description

Tauri commands for offline data access

+

Structs§

OfflineItem

Functions§

offline_get_items
Get all offline items for a user
offline_is_available
Check if an item is available offline
offline_search
Search offline items
\ No newline at end of file diff --git a/api/jellytau_lib/commands/offline/sidebar-items.js b/api/jellytau_lib/commands/offline/sidebar-items.js new file mode 100644 index 00000000..2dbf5089 --- /dev/null +++ b/api/jellytau_lib/commands/offline/sidebar-items.js @@ -0,0 +1 @@ +window.SIDEBAR_ITEMS = {"fn":["offline_get_items","offline_is_available","offline_search"],"struct":["OfflineItem"]}; \ No newline at end of file diff --git a/api/jellytau_lib/commands/offline/struct.OfflineItem.html b/api/jellytau_lib/commands/offline/struct.OfflineItem.html new file mode 100644 index 00000000..ccf1d7be --- /dev/null +++ b/api/jellytau_lib/commands/offline/struct.OfflineItem.html @@ -0,0 +1,55 @@ +OfflineItem in jellytau_lib::commands::offline - RustSkip to main content

OfflineItem

Struct OfflineItem 

Source
pub struct OfflineItem {
+    pub id: String,
+    pub name: String,
+    pub item_type: String,
+    pub album_id: Option<String>,
+    pub album_name: Option<String>,
+    pub artists: Option<String>,
+    pub runtime_ticks: Option<i64>,
+    pub primary_image_tag: Option<String>,
+}

Fields§

§id: String§name: String§item_type: String§album_id: Option<String>§album_name: Option<String>§artists: Option<String>§runtime_ticks: Option<i64>§primary_image_tag: Option<String>

Trait Implementations§

Source§

impl Debug for OfflineItem

Source§

fn fmt(&self, f: &mut Formatter<'_>) -> Result

Formats the value using the given formatter. Read more
Source§

impl<'de> Deserialize<'de> for OfflineItem

Source§

fn deserialize<__D>(__deserializer: __D) -> Result<Self, __D::Error>
where + __D: Deserializer<'de>,

Deserialize this value from the given Serde deserializer. Read more
Source§

impl NamedType for OfflineItem

Source§

fn sid() -> SpectaID

Source§

fn named_data_type( + type_map: &mut TypeCollection, + generics: &[DataType], +) -> NamedDataType

this is equivalent to [Type::inline] but returns a [NamedDataType] instead.
Source§

fn definition_named_data_type(type_map: &mut TypeCollection) -> NamedDataType

this is equivalent to [Type::definition] but returns a [NamedDataType] instead.
Source§

impl Serialize for OfflineItem

Source§

fn serialize<__S>(&self, __serializer: __S) -> Result<__S::Ok, __S::Error>
where + __S: Serializer,

Serialize this value into the given Serde serializer. Read more
Source§

impl Type for OfflineItem

Source§

fn inline(type_map: &mut TypeCollection, generics: Generics<'_>) -> DataType

Returns the definition of a type using the provided generics. Read more
Source§

fn reference(type_map: &mut TypeCollection, generics: &[DataType]) -> Reference

Generates a datatype corresponding to a reference to this type, +as determined by its category. Getting a reference to a type implies that +it should belong in the type map (since it has to be referenced from somewhere), +so the output of definition will be put into the type map.
Source§

impl Flatten for OfflineItem

Auto Trait Implementations§

Blanket Implementations§

Source§

impl<T> Any for T
where + T: 'static + ?Sized,

Source§

fn type_id(&self) -> TypeId

Gets the TypeId of self. Read more
Source§

impl<T> Borrow<T> for T
where + T: ?Sized,

Source§

fn borrow(&self) -> &T

Immutably borrows from an owned value. Read more
Source§

impl<T> BorrowMut<T> for T
where + T: ?Sized,

Source§

fn borrow_mut(&mut self) -> &mut T

Mutably borrows from an owned value. Read more
§

impl<'de, D, R> CommandArg<'de, R> for D
where + D: Deserialize<'de>, + R: Runtime,

§

fn from_command(command: CommandItem<'de, R>) -> Result<D, InvokeError>

Derives an instance of Self from the [CommandItem]. Read more
Source§

impl<T> From<T> for T

Source§

fn from(t: T) -> T

Returns the argument unchanged.

+
§

impl<T> FunctionArg for T
where + T: Type,

§

fn to_datatype(type_map: &mut TypeCollection) -> Option<DataType>

Gets the type of an argument as a [DataType]. Read more
§

impl<T> Instrument for T

§

fn instrument(self, span: Span) -> Instrumented<Self>

Instruments this type with the provided [Span], returning an +Instrumented wrapper. Read more
§

fn in_current_span(self) -> Instrumented<Self>

Instruments this type with the current Span, returning an +Instrumented wrapper. Read more
Source§

impl<T, U> Into<U> for T
where + U: From<T>,

Source§

fn into(self) -> U

Calls U::from(self).

+

That is, this conversion is whatever the implementation of +From<T> for U chooses to do.

+
§

impl<T> IpcResponse for T
where + T: Serialize,

§

fn body(self) -> Result<InvokeResponseBody, Error>

Resolve the IPC response body.
§

impl<T> PolicyExt for T
where + T: ?Sized,

§

fn and<P, B, E>(self, other: P) -> And<T, P>
where + T: Policy<B, E>, + P: Policy<B, E>,

Create a new Policy that returns [Action::Follow] only if self and other return +Action::Follow. Read more
§

fn or<P, B, E>(self, other: P) -> Or<T, P>
where + T: Policy<B, E>, + P: Policy<B, E>,

Create a new Policy that returns [Action::Follow] if either self or other returns +Action::Follow. Read more
Source§

impl<T> Same for T

Source§

type Output = T

Should always be Self
§

impl<T> ScopeObject for T
where + T: Send + Sync + Debug + DeserializeOwned + 'static,

§

type Error = Error

The error type.
§

fn deserialize<R>( + _app: &AppHandle<R>, + raw: Value, +) -> Result<T, <T as ScopeObject>::Error>
where + R: Runtime,

Deserialize the raw scope value.
Source§

impl<T> Serialize for T
where + T: Serialize + ?Sized,

Source§

fn erased_serialize(&self, serializer: &mut dyn Serializer) -> Result<(), Error>

Source§

fn do_erased_serialize( + &self, + serializer: &mut dyn Serializer, +) -> Result<(), ErrorImpl>

Source§

impl<T, U> TryFrom<U> for T
where + U: Into<T>,

Source§

type Error = Infallible

The type returned in the event of a conversion error.
Source§

fn try_from(value: U) -> Result<T, <T as TryFrom<U>>::Error>

Performs the conversion.
Source§

impl<T, U> TryInto<U> for T
where + U: TryFrom<T>,

Source§

type Error = <U as TryFrom<T>>::Error

The type returned in the event of a conversion error.
Source§

fn try_into(self) -> Result<U, <U as TryFrom<T>>::Error>

Performs the conversion.
§

impl<V, T> VZip<V> for T
where + V: MultiLane<T>,

§

fn vzip(self) -> V

§

impl<T> WithSubscriber for T

§

fn with_subscriber<S>(self, subscriber: S) -> WithDispatch<Self>
where + S: Into<Dispatch>,

Attaches the provided Subscriber to this type, returning a +[WithDispatch] wrapper. Read more
§

fn with_current_subscriber(self) -> WithDispatch<Self>

Attaches the current default Subscriber to this type, returning a +[WithDispatch] wrapper. Read more
Source§

impl<T> DeserializeOwned for T
where + T: for<'de> Deserialize<'de>,

\ No newline at end of file diff --git a/api/jellytau_lib/commands/playback_mode/fn.playback_mode_get_current.html b/api/jellytau_lib/commands/playback_mode/fn.playback_mode_get_current.html new file mode 100644 index 00000000..ba1f753b --- /dev/null +++ b/api/jellytau_lib/commands/playback_mode/fn.playback_mode_get_current.html @@ -0,0 +1,4 @@ +playback_mode_get_current in jellytau_lib::commands::playback_mode - RustSkip to main content

playback_mode_get_current

Function playback_mode_get_current 

Source
pub fn playback_mode_get_current(
+    manager: State<'_, PlaybackModeManagerWrapper>,
+) -> Result<PlaybackMode, String>
Expand description

Get the current playback mode

+
\ No newline at end of file diff --git a/api/jellytau_lib/commands/playback_mode/fn.playback_mode_get_remote_status.html b/api/jellytau_lib/commands/playback_mode/fn.playback_mode_get_remote_status.html new file mode 100644 index 00000000..cdf57542 --- /dev/null +++ b/api/jellytau_lib/commands/playback_mode/fn.playback_mode_get_remote_status.html @@ -0,0 +1,5 @@ +playback_mode_get_remote_status in jellytau_lib::commands::playback_mode - RustSkip to main content

playback_mode_get_remote_status

Function playback_mode_get_remote_status 

Source
pub async fn playback_mode_get_remote_status(
+    manager: State<'_, PlaybackModeManagerWrapper>,
+    player: State<'_, PlayerStateWrapper>,
+) -> Result<RemoteSessionStatus, String>
Expand description

Get remote session status (for polling position/duration)

+
\ No newline at end of file diff --git a/api/jellytau_lib/commands/playback_mode/fn.playback_mode_is_transferring.html b/api/jellytau_lib/commands/playback_mode/fn.playback_mode_is_transferring.html new file mode 100644 index 00000000..e4d683c5 --- /dev/null +++ b/api/jellytau_lib/commands/playback_mode/fn.playback_mode_is_transferring.html @@ -0,0 +1,4 @@ +playback_mode_is_transferring in jellytau_lib::commands::playback_mode - RustSkip to main content

playback_mode_is_transferring

Function playback_mode_is_transferring 

Source
pub fn playback_mode_is_transferring(
+    manager: State<'_, PlaybackModeManagerWrapper>,
+) -> Result<bool, String>
Expand description

Check if currently transferring between playback modes

+
\ No newline at end of file diff --git a/api/jellytau_lib/commands/playback_mode/fn.playback_mode_set.html b/api/jellytau_lib/commands/playback_mode/fn.playback_mode_set.html new file mode 100644 index 00000000..04677c2c --- /dev/null +++ b/api/jellytau_lib/commands/playback_mode/fn.playback_mode_set.html @@ -0,0 +1,5 @@ +playback_mode_set in jellytau_lib::commands::playback_mode - RustSkip to main content

playback_mode_set

Function playback_mode_set 

Source
pub fn playback_mode_set(
+    manager: State<'_, PlaybackModeManagerWrapper>,
+    mode: PlaybackMode,
+) -> Result<(), String>
Expand description

Set the playback mode (internal/testing use)

+
\ No newline at end of file diff --git a/api/jellytau_lib/commands/playback_mode/fn.playback_mode_set_transferring.html b/api/jellytau_lib/commands/playback_mode/fn.playback_mode_set_transferring.html new file mode 100644 index 00000000..7605179a --- /dev/null +++ b/api/jellytau_lib/commands/playback_mode/fn.playback_mode_set_transferring.html @@ -0,0 +1,9 @@ +playback_mode_set_transferring in jellytau_lib::commands::playback_mode - RustSkip to main content

playback_mode_set_transferring

Function playback_mode_set_transferring 

Source
pub async fn playback_mode_set_transferring(
+    manager: State<'_, PlaybackModeManagerWrapper>,
+    transferring: bool,
+) -> Result<(), String>
Expand description

Set the transferring flag on the playback mode manager.

+

Used by the frontend remote->local flow to mark the whole two-step sequence +as a transfer, so player_play_tracks starts LOCAL playback instead of +casting back to the remote session it’s leaving. Always pair true with a +later false (including on error) so the flag can’t stick.

+
\ No newline at end of file diff --git a/api/jellytau_lib/commands/playback_mode/fn.playback_mode_transfer_to_local.html b/api/jellytau_lib/commands/playback_mode/fn.playback_mode_transfer_to_local.html new file mode 100644 index 00000000..fdf4ee47 --- /dev/null +++ b/api/jellytau_lib/commands/playback_mode/fn.playback_mode_transfer_to_local.html @@ -0,0 +1,11 @@ +playback_mode_transfer_to_local in jellytau_lib::commands::playback_mode - RustSkip to main content

playback_mode_transfer_to_local

Function playback_mode_transfer_to_local 

Source
pub async fn playback_mode_transfer_to_local(
+    manager: State<'_, PlaybackModeManagerWrapper>,
+    current_item_id: String,
+    position_ticks: i64,
+) -> Result<(), String>
Expand description

Transfer playback from remote session back to local device

+

Parameters:

+
    +
  • current_item_id: The Jellyfin item ID currently playing on remote
  • +
  • position_ticks: Current playback position in ticks (10,000 ticks = 1ms)
  • +
+
\ No newline at end of file diff --git a/api/jellytau_lib/commands/playback_mode/fn.playback_mode_transfer_to_remote.html b/api/jellytau_lib/commands/playback_mode/fn.playback_mode_transfer_to_remote.html new file mode 100644 index 00000000..661b93d1 --- /dev/null +++ b/api/jellytau_lib/commands/playback_mode/fn.playback_mode_transfer_to_remote.html @@ -0,0 +1,6 @@ +playback_mode_transfer_to_remote in jellytau_lib::commands::playback_mode - RustSkip to main content

playback_mode_transfer_to_remote

Function playback_mode_transfer_to_remote 

Source
pub async fn playback_mode_transfer_to_remote(
+    manager: State<'_, PlaybackModeManagerWrapper>,
+    session_id: String,
+    position: Option<f64>,
+) -> Result<(), String>
Expand description

Transfer playback from local device to a remote Jellyfin session

+
\ No newline at end of file diff --git a/api/jellytau_lib/commands/playback_mode/index.html b/api/jellytau_lib/commands/playback_mode/index.html new file mode 100644 index 00000000..067f4450 --- /dev/null +++ b/api/jellytau_lib/commands/playback_mode/index.html @@ -0,0 +1,3 @@ +jellytau_lib::commands::playback_mode - RustSkip to main content

Module playback_mode

Module playback_mode 

Source
Expand description

Playback-mode transfer commands (local ↔ remote).

+

TRACES: UR-010 | DR-059

+

Structs§

PlaybackModeManagerWrapper
Wrapper for PlaybackModeManager to manage in Tauri state
RemoteSessionStatus
Remote session status for UI updates

Functions§

playback_mode_get_current
Get the current playback mode
playback_mode_get_remote_status
Get remote session status (for polling position/duration)
playback_mode_is_transferring
Check if currently transferring between playback modes
playback_mode_set
Set the playback mode (internal/testing use)
playback_mode_set_transferring
Set the transferring flag on the playback mode manager.
playback_mode_transfer_to_local
Transfer playback from remote session back to local device
playback_mode_transfer_to_remote
Transfer playback from local device to a remote Jellyfin session
\ No newline at end of file diff --git a/api/jellytau_lib/commands/playback_mode/sidebar-items.js b/api/jellytau_lib/commands/playback_mode/sidebar-items.js new file mode 100644 index 00000000..47ec52b5 --- /dev/null +++ b/api/jellytau_lib/commands/playback_mode/sidebar-items.js @@ -0,0 +1 @@ +window.SIDEBAR_ITEMS = {"fn":["playback_mode_get_current","playback_mode_get_remote_status","playback_mode_is_transferring","playback_mode_set","playback_mode_set_transferring","playback_mode_transfer_to_local","playback_mode_transfer_to_remote"],"struct":["PlaybackModeManagerWrapper","RemoteSessionStatus"]}; \ No newline at end of file diff --git a/api/jellytau_lib/commands/playback_mode/struct.PlaybackModeManagerWrapper.html b/api/jellytau_lib/commands/playback_mode/struct.PlaybackModeManagerWrapper.html new file mode 100644 index 00000000..046b8f59 --- /dev/null +++ b/api/jellytau_lib/commands/playback_mode/struct.PlaybackModeManagerWrapper.html @@ -0,0 +1,25 @@ +PlaybackModeManagerWrapper in jellytau_lib::commands::playback_mode - RustSkip to main content

PlaybackModeManagerWrapper

Struct PlaybackModeManagerWrapper 

Source
pub struct PlaybackModeManagerWrapper(pub Arc<PlaybackModeManager>);
Expand description

Wrapper for PlaybackModeManager to manage in Tauri state

+

Tuple Fields§

§0: Arc<PlaybackModeManager>

Auto Trait Implementations§

Blanket Implementations§

Source§

impl<T> Any for T
where + T: 'static + ?Sized,

Source§

fn type_id(&self) -> TypeId

Gets the TypeId of self. Read more
Source§

impl<T> Borrow<T> for T
where + T: ?Sized,

Source§

fn borrow(&self) -> &T

Immutably borrows from an owned value. Read more
Source§

impl<T> BorrowMut<T> for T
where + T: ?Sized,

Source§

fn borrow_mut(&mut self) -> &mut T

Mutably borrows from an owned value. Read more
Source§

impl<T> From<T> for T

Source§

fn from(t: T) -> T

Returns the argument unchanged.

+
§

impl<T> Instrument for T

§

fn instrument(self, span: Span) -> Instrumented<Self>

Instruments this type with the provided [Span], returning an +Instrumented wrapper. Read more
§

fn in_current_span(self) -> Instrumented<Self>

Instruments this type with the current Span, returning an +Instrumented wrapper. Read more
Source§

impl<T, U> Into<U> for T
where + U: From<T>,

Source§

fn into(self) -> U

Calls U::from(self).

+

That is, this conversion is whatever the implementation of +From<T> for U chooses to do.

+
§

impl<T> PolicyExt for T
where + T: ?Sized,

§

fn and<P, B, E>(self, other: P) -> And<T, P>
where + T: Policy<B, E>, + P: Policy<B, E>,

Create a new Policy that returns [Action::Follow] only if self and other return +Action::Follow. Read more
§

fn or<P, B, E>(self, other: P) -> Or<T, P>
where + T: Policy<B, E>, + P: Policy<B, E>,

Create a new Policy that returns [Action::Follow] if either self or other returns +Action::Follow. Read more
Source§

impl<T> Same for T

Source§

type Output = T

Should always be Self
Source§

impl<T, U> TryFrom<U> for T
where + U: Into<T>,

Source§

type Error = Infallible

The type returned in the event of a conversion error.
Source§

fn try_from(value: U) -> Result<T, <T as TryFrom<U>>::Error>

Performs the conversion.
Source§

impl<T, U> TryInto<U> for T
where + U: TryFrom<T>,

Source§

type Error = <U as TryFrom<T>>::Error

The type returned in the event of a conversion error.
Source§

fn try_into(self) -> Result<U, <U as TryFrom<T>>::Error>

Performs the conversion.
§

impl<V, T> VZip<V> for T
where + V: MultiLane<T>,

§

fn vzip(self) -> V

§

impl<T> WithSubscriber for T

§

fn with_subscriber<S>(self, subscriber: S) -> WithDispatch<Self>
where + S: Into<Dispatch>,

Attaches the provided Subscriber to this type, returning a +[WithDispatch] wrapper. Read more
§

fn with_current_subscriber(self) -> WithDispatch<Self>

Attaches the current default Subscriber to this type, returning a +[WithDispatch] wrapper. Read more
\ No newline at end of file diff --git a/api/jellytau_lib/commands/playback_mode/struct.RemoteSessionStatus.html b/api/jellytau_lib/commands/playback_mode/struct.RemoteSessionStatus.html new file mode 100644 index 00000000..d7c6706f --- /dev/null +++ b/api/jellytau_lib/commands/playback_mode/struct.RemoteSessionStatus.html @@ -0,0 +1,43 @@ +RemoteSessionStatus in jellytau_lib::commands::playback_mode - RustSkip to main content

RemoteSessionStatus

Struct RemoteSessionStatus 

Source
pub struct RemoteSessionStatus {
+    pub position: f64,
+    pub duration: Option<f64>,
+    pub is_playing: bool,
+    pub now_playing_item: Option<NowPlayingItem>,
+}
Expand description

Remote session status for UI updates

+

Fields§

§position: f64§duration: Option<f64>§is_playing: bool§now_playing_item: Option<NowPlayingItem>

Trait Implementations§

Source§

impl NamedType for RemoteSessionStatus

Source§

fn sid() -> SpectaID

Source§

fn named_data_type( + type_map: &mut TypeCollection, + generics: &[DataType], +) -> NamedDataType

this is equivalent to [Type::inline] but returns a [NamedDataType] instead.
Source§

fn definition_named_data_type(type_map: &mut TypeCollection) -> NamedDataType

this is equivalent to [Type::definition] but returns a [NamedDataType] instead.
Source§

impl Serialize for RemoteSessionStatus

Source§

fn serialize<__S>(&self, __serializer: __S) -> Result<__S::Ok, __S::Error>
where + __S: Serializer,

Serialize this value into the given Serde serializer. Read more
Source§

impl Type for RemoteSessionStatus

Source§

fn inline(type_map: &mut TypeCollection, generics: Generics<'_>) -> DataType

Returns the definition of a type using the provided generics. Read more
Source§

fn reference(type_map: &mut TypeCollection, generics: &[DataType]) -> Reference

Generates a datatype corresponding to a reference to this type, +as determined by its category. Getting a reference to a type implies that +it should belong in the type map (since it has to be referenced from somewhere), +so the output of definition will be put into the type map.
Source§

impl Flatten for RemoteSessionStatus

Auto Trait Implementations§

Blanket Implementations§

Source§

impl<T> Any for T
where + T: 'static + ?Sized,

Source§

fn type_id(&self) -> TypeId

Gets the TypeId of self. Read more
Source§

impl<T> Borrow<T> for T
where + T: ?Sized,

Source§

fn borrow(&self) -> &T

Immutably borrows from an owned value. Read more
Source§

impl<T> BorrowMut<T> for T
where + T: ?Sized,

Source§

fn borrow_mut(&mut self) -> &mut T

Mutably borrows from an owned value. Read more
Source§

impl<T> From<T> for T

Source§

fn from(t: T) -> T

Returns the argument unchanged.

+
§

impl<T> FunctionArg for T
where + T: Type,

§

fn to_datatype(type_map: &mut TypeCollection) -> Option<DataType>

Gets the type of an argument as a [DataType]. Read more
§

impl<T> Instrument for T

§

fn instrument(self, span: Span) -> Instrumented<Self>

Instruments this type with the provided [Span], returning an +Instrumented wrapper. Read more
§

fn in_current_span(self) -> Instrumented<Self>

Instruments this type with the current Span, returning an +Instrumented wrapper. Read more
Source§

impl<T, U> Into<U> for T
where + U: From<T>,

Source§

fn into(self) -> U

Calls U::from(self).

+

That is, this conversion is whatever the implementation of +From<T> for U chooses to do.

+
§

impl<T> IpcResponse for T
where + T: Serialize,

§

fn body(self) -> Result<InvokeResponseBody, Error>

Resolve the IPC response body.
§

impl<T> PolicyExt for T
where + T: ?Sized,

§

fn and<P, B, E>(self, other: P) -> And<T, P>
where + T: Policy<B, E>, + P: Policy<B, E>,

Create a new Policy that returns [Action::Follow] only if self and other return +Action::Follow. Read more
§

fn or<P, B, E>(self, other: P) -> Or<T, P>
where + T: Policy<B, E>, + P: Policy<B, E>,

Create a new Policy that returns [Action::Follow] if either self or other returns +Action::Follow. Read more
Source§

impl<T> Same for T

Source§

type Output = T

Should always be Self
Source§

impl<T> Serialize for T
where + T: Serialize + ?Sized,

Source§

fn erased_serialize(&self, serializer: &mut dyn Serializer) -> Result<(), Error>

Source§

fn do_erased_serialize( + &self, + serializer: &mut dyn Serializer, +) -> Result<(), ErrorImpl>

Source§

impl<T, U> TryFrom<U> for T
where + U: Into<T>,

Source§

type Error = Infallible

The type returned in the event of a conversion error.
Source§

fn try_from(value: U) -> Result<T, <T as TryFrom<U>>::Error>

Performs the conversion.
Source§

impl<T, U> TryInto<U> for T
where + U: TryFrom<T>,

Source§

type Error = <U as TryFrom<T>>::Error

The type returned in the event of a conversion error.
Source§

fn try_into(self) -> Result<U, <U as TryFrom<T>>::Error>

Performs the conversion.
§

impl<V, T> VZip<V> for T
where + V: MultiLane<T>,

§

fn vzip(self) -> V

§

impl<T> WithSubscriber for T

§

fn with_subscriber<S>(self, subscriber: S) -> WithDispatch<Self>
where + S: Into<Dispatch>,

Attaches the provided Subscriber to this type, returning a +[WithDispatch] wrapper. Read more
§

fn with_current_subscriber(self) -> WithDispatch<Self>

Attaches the current default Subscriber to this type, returning a +[WithDispatch] wrapper. Read more
\ No newline at end of file diff --git a/api/jellytau_lib/commands/playback_reporting/fn.playback_mark_played.html b/api/jellytau_lib/commands/playback_reporting/fn.playback_mark_played.html new file mode 100644 index 00000000..9e44cdd1 --- /dev/null +++ b/api/jellytau_lib/commands/playback_reporting/fn.playback_mark_played.html @@ -0,0 +1,6 @@ +playback_mark_played in jellytau_lib::commands::playback_reporting - RustSkip to main content

playback_mark_played

Function playback_mark_played 

Source
pub async fn playback_mark_played(
+    reporter: State<'_, PlaybackReporterWrapper>,
+    connectivity: State<'_, ConnectivityMonitorWrapper>,
+    item_id: String,
+) -> Result<(), String>
Expand description

Mark item as played

+
\ No newline at end of file diff --git a/api/jellytau_lib/commands/playback_reporting/fn.playback_report_progress.html b/api/jellytau_lib/commands/playback_reporting/fn.playback_report_progress.html new file mode 100644 index 00000000..c4517420 --- /dev/null +++ b/api/jellytau_lib/commands/playback_reporting/fn.playback_report_progress.html @@ -0,0 +1,8 @@ +playback_report_progress in jellytau_lib::commands::playback_reporting - RustSkip to main content

playback_report_progress

Function playback_report_progress 

Source
pub async fn playback_report_progress(
+    reporter: State<'_, PlaybackReporterWrapper>,
+    connectivity: State<'_, ConnectivityMonitorWrapper>,
+    item_id: String,
+    position_seconds: f64,
+    is_paused: bool,
+) -> Result<(), String>
Expand description

Report playback progress

+
\ No newline at end of file diff --git a/api/jellytau_lib/commands/playback_reporting/fn.playback_report_start.html b/api/jellytau_lib/commands/playback_reporting/fn.playback_report_start.html new file mode 100644 index 00000000..cff396ee --- /dev/null +++ b/api/jellytau_lib/commands/playback_reporting/fn.playback_report_start.html @@ -0,0 +1,9 @@ +playback_report_start in jellytau_lib::commands::playback_reporting - RustSkip to main content

playback_report_start

Function playback_report_start 

Source
pub async fn playback_report_start(
+    reporter: State<'_, PlaybackReporterWrapper>,
+    connectivity: State<'_, ConnectivityMonitorWrapper>,
+    item_id: String,
+    position_seconds: f64,
+    context_type: Option<String>,
+    context_id: Option<String>,
+) -> Result<(), String>
Expand description

Report playback start

+
\ No newline at end of file diff --git a/api/jellytau_lib/commands/playback_reporting/fn.playback_report_stopped.html b/api/jellytau_lib/commands/playback_reporting/fn.playback_report_stopped.html new file mode 100644 index 00000000..146a394f --- /dev/null +++ b/api/jellytau_lib/commands/playback_reporting/fn.playback_report_stopped.html @@ -0,0 +1,7 @@ +playback_report_stopped in jellytau_lib::commands::playback_reporting - RustSkip to main content

playback_report_stopped

Function playback_report_stopped 

Source
pub async fn playback_report_stopped(
+    reporter: State<'_, PlaybackReporterWrapper>,
+    connectivity: State<'_, ConnectivityMonitorWrapper>,
+    item_id: String,
+    position_seconds: f64,
+) -> Result<(), String>
Expand description

Report playback stopped

+
\ No newline at end of file diff --git a/api/jellytau_lib/commands/playback_reporting/fn.playback_reporter_destroy.html b/api/jellytau_lib/commands/playback_reporting/fn.playback_reporter_destroy.html new file mode 100644 index 00000000..95e075ad --- /dev/null +++ b/api/jellytau_lib/commands/playback_reporting/fn.playback_reporter_destroy.html @@ -0,0 +1,4 @@ +playback_reporter_destroy in jellytau_lib::commands::playback_reporting - RustSkip to main content

playback_reporter_destroy

Function playback_reporter_destroy 

Source
pub async fn playback_reporter_destroy(
+    reporter_wrapper: State<'_, PlaybackReporterWrapper>,
+) -> Result<(), String>
Expand description

Destroy playback reporter (called on logout)

+
\ No newline at end of file diff --git a/api/jellytau_lib/commands/playback_reporting/fn.playback_reporter_init.html b/api/jellytau_lib/commands/playback_reporting/fn.playback_reporter_init.html new file mode 100644 index 00000000..42064b0a --- /dev/null +++ b/api/jellytau_lib/commands/playback_reporting/fn.playback_reporter_init.html @@ -0,0 +1,9 @@ +playback_reporter_init in jellytau_lib::commands::playback_reporting - RustSkip to main content

playback_reporter_init

Function playback_reporter_init 

Source
pub async fn playback_reporter_init(
+    reporter_wrapper: State<'_, PlaybackReporterWrapper>,
+    db: State<'_, DatabaseWrapper>,
+    server_url: String,
+    user_id: String,
+    access_token: String,
+    device_id: String,
+) -> Result<(), String>
Expand description

Initialize playback reporter (called after login)

+
\ No newline at end of file diff --git a/api/jellytau_lib/commands/playback_reporting/index.html b/api/jellytau_lib/commands/playback_reporting/index.html new file mode 100644 index 00000000..711d654b --- /dev/null +++ b/api/jellytau_lib/commands/playback_reporting/index.html @@ -0,0 +1,7 @@ +jellytau_lib::commands::playback_reporting - RustSkip to main content

Module playback_reporting

Module playback_reporting 

Source
Expand description

Tauri commands for playback reporting operations

+

TRACES: UR-025, UR-019 | IR-015, JA-010, JA-011, JA-012 | DR-028

+

These commands provide frontend access to the Rust playback reporting system, +replacing the TypeScript implementation with native Rust reporting.

+

Commands are registered but not yet called from the frontend. +Dead code warnings are suppressed until frontend migration is complete.

+

Structs§

PlaybackReporterWrapper
Tauri state wrapper for PlaybackReporter

Functions§

playback_mark_played
Mark item as played
playback_report_progress
Report playback progress
playback_report_start
Report playback start
playback_report_stopped
Report playback stopped
playback_reporter_destroy
Destroy playback reporter (called on logout)
playback_reporter_init
Initialize playback reporter (called after login)
\ No newline at end of file diff --git a/api/jellytau_lib/commands/playback_reporting/sidebar-items.js b/api/jellytau_lib/commands/playback_reporting/sidebar-items.js new file mode 100644 index 00000000..ed2843b4 --- /dev/null +++ b/api/jellytau_lib/commands/playback_reporting/sidebar-items.js @@ -0,0 +1 @@ +window.SIDEBAR_ITEMS = {"fn":["playback_mark_played","playback_report_progress","playback_report_start","playback_report_stopped","playback_reporter_destroy","playback_reporter_init"],"struct":["PlaybackReporterWrapper"]}; \ No newline at end of file diff --git a/api/jellytau_lib/commands/playback_reporting/struct.PlaybackReporterWrapper.html b/api/jellytau_lib/commands/playback_reporting/struct.PlaybackReporterWrapper.html new file mode 100644 index 00000000..8eef9ab1 --- /dev/null +++ b/api/jellytau_lib/commands/playback_reporting/struct.PlaybackReporterWrapper.html @@ -0,0 +1,25 @@ +PlaybackReporterWrapper in jellytau_lib::commands::playback_reporting - RustSkip to main content

PlaybackReporterWrapper

Struct PlaybackReporterWrapper 

Source
pub struct PlaybackReporterWrapper(pub Arc<Mutex<Option<PlaybackReporter>>>);
Expand description

Tauri state wrapper for PlaybackReporter

+

Tuple Fields§

§0: Arc<Mutex<Option<PlaybackReporter>>>

Auto Trait Implementations§

Blanket Implementations§

Source§

impl<T> Any for T
where + T: 'static + ?Sized,

Source§

fn type_id(&self) -> TypeId

Gets the TypeId of self. Read more
Source§

impl<T> Borrow<T> for T
where + T: ?Sized,

Source§

fn borrow(&self) -> &T

Immutably borrows from an owned value. Read more
Source§

impl<T> BorrowMut<T> for T
where + T: ?Sized,

Source§

fn borrow_mut(&mut self) -> &mut T

Mutably borrows from an owned value. Read more
Source§

impl<T> From<T> for T

Source§

fn from(t: T) -> T

Returns the argument unchanged.

+
§

impl<T> Instrument for T

§

fn instrument(self, span: Span) -> Instrumented<Self>

Instruments this type with the provided [Span], returning an +Instrumented wrapper. Read more
§

fn in_current_span(self) -> Instrumented<Self>

Instruments this type with the current Span, returning an +Instrumented wrapper. Read more
Source§

impl<T, U> Into<U> for T
where + U: From<T>,

Source§

fn into(self) -> U

Calls U::from(self).

+

That is, this conversion is whatever the implementation of +From<T> for U chooses to do.

+
§

impl<T> PolicyExt for T
where + T: ?Sized,

§

fn and<P, B, E>(self, other: P) -> And<T, P>
where + T: Policy<B, E>, + P: Policy<B, E>,

Create a new Policy that returns [Action::Follow] only if self and other return +Action::Follow. Read more
§

fn or<P, B, E>(self, other: P) -> Or<T, P>
where + T: Policy<B, E>, + P: Policy<B, E>,

Create a new Policy that returns [Action::Follow] if either self or other returns +Action::Follow. Read more
Source§

impl<T> Same for T

Source§

type Output = T

Should always be Self
Source§

impl<T, U> TryFrom<U> for T
where + U: Into<T>,

Source§

type Error = Infallible

The type returned in the event of a conversion error.
Source§

fn try_from(value: U) -> Result<T, <T as TryFrom<U>>::Error>

Performs the conversion.
Source§

impl<T, U> TryInto<U> for T
where + U: TryFrom<T>,

Source§

type Error = <U as TryFrom<T>>::Error

The type returned in the event of a conversion error.
Source§

fn try_into(self) -> Result<U, <U as TryFrom<T>>::Error>

Performs the conversion.
§

impl<V, T> VZip<V> for T
where + V: MultiLane<T>,

§

fn vzip(self) -> V

§

impl<T> WithSubscriber for T

§

fn with_subscriber<S>(self, subscriber: S) -> WithDispatch<Self>
where + S: Into<Dispatch>,

Attaches the provided Subscriber to this type, returning a +[WithDispatch] wrapper. Read more
§

fn with_current_subscriber(self) -> WithDispatch<Self>

Attaches the current default Subscriber to this type, returning a +[WithDispatch] wrapper. Read more
\ No newline at end of file diff --git a/api/jellytau_lib/commands/player/enum.AudioTrackSwitchResponse.html b/api/jellytau_lib/commands/player/enum.AudioTrackSwitchResponse.html new file mode 100644 index 00000000..e3a7e4b2 --- /dev/null +++ b/api/jellytau_lib/commands/player/enum.AudioTrackSwitchResponse.html @@ -0,0 +1,51 @@ +AudioTrackSwitchResponse in jellytau_lib::commands::player - RustSkip to main content

AudioTrackSwitchResponse

Enum AudioTrackSwitchResponse 

Source
pub enum AudioTrackSwitchResponse {
+    Native {
+        success: bool,
+    },
+    ReloadStream {
+        new_url: String,
+        position: f64,
+    },
+}
Expand description

Response for audio track switching operations

+

Variants§

§

Native

Native backend handled it (Android ExoPlayer)

+

Fields

§success: bool

Confirmation message

+
§

ReloadStream

HTML5 needs to reload stream with new audio track

+

Fields

§new_url: String

New stream URL with selected audio track

+
§position: f64

Current position to resume from

+

Trait Implementations§

Source§

impl Debug for AudioTrackSwitchResponse

Source§

fn fmt(&self, f: &mut Formatter<'_>) -> Result

Formats the value using the given formatter. Read more
Source§

impl NamedType for AudioTrackSwitchResponse

Source§

fn sid() -> SpectaID

Source§

fn named_data_type( + type_map: &mut TypeCollection, + generics: &[DataType], +) -> NamedDataType

this is equivalent to [Type::inline] but returns a [NamedDataType] instead.
Source§

fn definition_named_data_type(type_map: &mut TypeCollection) -> NamedDataType

this is equivalent to [Type::definition] but returns a [NamedDataType] instead.
Source§

impl Serialize for AudioTrackSwitchResponse

Source§

fn serialize<__S>(&self, __serializer: __S) -> Result<__S::Ok, __S::Error>
where + __S: Serializer,

Serialize this value into the given Serde serializer. Read more
Source§

impl Type for AudioTrackSwitchResponse

Source§

fn inline(type_map: &mut TypeCollection, generics: Generics<'_>) -> DataType

Returns the definition of a type using the provided generics. Read more
Source§

fn reference(type_map: &mut TypeCollection, generics: &[DataType]) -> Reference

Generates a datatype corresponding to a reference to this type, +as determined by its category. Getting a reference to a type implies that +it should belong in the type map (since it has to be referenced from somewhere), +so the output of definition will be put into the type map.
Source§

impl Flatten for AudioTrackSwitchResponse

Auto Trait Implementations§

Blanket Implementations§

Source§

impl<T> Any for T
where + T: 'static + ?Sized,

Source§

fn type_id(&self) -> TypeId

Gets the TypeId of self. Read more
Source§

impl<T> Borrow<T> for T
where + T: ?Sized,

Source§

fn borrow(&self) -> &T

Immutably borrows from an owned value. Read more
Source§

impl<T> BorrowMut<T> for T
where + T: ?Sized,

Source§

fn borrow_mut(&mut self) -> &mut T

Mutably borrows from an owned value. Read more
Source§

impl<T> From<T> for T

Source§

fn from(t: T) -> T

Returns the argument unchanged.

+
§

impl<T> FunctionArg for T
where + T: Type,

§

fn to_datatype(type_map: &mut TypeCollection) -> Option<DataType>

Gets the type of an argument as a [DataType]. Read more
§

impl<T> Instrument for T

§

fn instrument(self, span: Span) -> Instrumented<Self>

Instruments this type with the provided [Span], returning an +Instrumented wrapper. Read more
§

fn in_current_span(self) -> Instrumented<Self>

Instruments this type with the current Span, returning an +Instrumented wrapper. Read more
Source§

impl<T, U> Into<U> for T
where + U: From<T>,

Source§

fn into(self) -> U

Calls U::from(self).

+

That is, this conversion is whatever the implementation of +From<T> for U chooses to do.

+
§

impl<T> IpcResponse for T
where + T: Serialize,

§

fn body(self) -> Result<InvokeResponseBody, Error>

Resolve the IPC response body.
§

impl<T> PolicyExt for T
where + T: ?Sized,

§

fn and<P, B, E>(self, other: P) -> And<T, P>
where + T: Policy<B, E>, + P: Policy<B, E>,

Create a new Policy that returns [Action::Follow] only if self and other return +Action::Follow. Read more
§

fn or<P, B, E>(self, other: P) -> Or<T, P>
where + T: Policy<B, E>, + P: Policy<B, E>,

Create a new Policy that returns [Action::Follow] if either self or other returns +Action::Follow. Read more
Source§

impl<T> Same for T

Source§

type Output = T

Should always be Self
Source§

impl<T> Serialize for T
where + T: Serialize + ?Sized,

Source§

fn erased_serialize(&self, serializer: &mut dyn Serializer) -> Result<(), Error>

Source§

fn do_erased_serialize( + &self, + serializer: &mut dyn Serializer, +) -> Result<(), ErrorImpl>

Source§

impl<T, U> TryFrom<U> for T
where + U: Into<T>,

Source§

type Error = Infallible

The type returned in the event of a conversion error.
Source§

fn try_from(value: U) -> Result<T, <T as TryFrom<U>>::Error>

Performs the conversion.
Source§

impl<T, U> TryInto<U> for T
where + U: TryFrom<T>,

Source§

type Error = <U as TryFrom<T>>::Error

The type returned in the event of a conversion error.
Source§

fn try_into(self) -> Result<U, <U as TryFrom<T>>::Error>

Performs the conversion.
§

impl<V, T> VZip<V> for T
where + V: MultiLane<T>,

§

fn vzip(self) -> V

§

impl<T> WithSubscriber for T

§

fn with_subscriber<S>(self, subscriber: S) -> WithDispatch<Self>
where + S: Into<Dispatch>,

Attaches the provided Subscriber to this type, returning a +[WithDispatch] wrapper. Read more
§

fn with_current_subscriber(self) -> WithDispatch<Self>

Attaches the current default Subscriber to this type, returning a +[WithDispatch] wrapper. Read more
\ No newline at end of file diff --git a/api/jellytau_lib/commands/player/enum.PlayQueueContext.html b/api/jellytau_lib/commands/player/enum.PlayQueueContext.html new file mode 100644 index 00000000..abcfcd26 --- /dev/null +++ b/api/jellytau_lib/commands/player/enum.PlayQueueContext.html @@ -0,0 +1,54 @@ +PlayQueueContext in jellytau_lib::commands::player - RustSkip to main content

PlayQueueContext

Enum PlayQueueContext 

Source
pub enum PlayQueueContext {
+    Album {
+        album_id: String,
+        album_name: String,
+    },
+    Playlist {
+        playlist_id: String,
+        playlist_name: String,
+    },
+    Custom,
+}
Expand description

Queue context for remote transfer - what type of queue is this?

+

Variants§

§

Album

Playing from a specific album

+

Fields

§album_id: String
§album_name: String
§

Playlist

Playing from a specific playlist

+

Fields

§playlist_id: String
§playlist_name: String
§

Custom

Custom queue (search results, manual queue, etc.)

+

Trait Implementations§

Source§

impl Debug for PlayQueueContext

Source§

fn fmt(&self, f: &mut Formatter<'_>) -> Result

Formats the value using the given formatter. Read more
Source§

impl<'de> Deserialize<'de> for PlayQueueContext

Source§

fn deserialize<__D>(__deserializer: __D) -> Result<Self, __D::Error>
where + __D: Deserializer<'de>,

Deserialize this value from the given Serde deserializer. Read more
Source§

impl NamedType for PlayQueueContext

Source§

fn sid() -> SpectaID

Source§

fn named_data_type( + type_map: &mut TypeCollection, + generics: &[DataType], +) -> NamedDataType

this is equivalent to [Type::inline] but returns a [NamedDataType] instead.
Source§

fn definition_named_data_type(type_map: &mut TypeCollection) -> NamedDataType

this is equivalent to [Type::definition] but returns a [NamedDataType] instead.
Source§

impl Type for PlayQueueContext

Source§

fn inline(type_map: &mut TypeCollection, generics: Generics<'_>) -> DataType

Returns the definition of a type using the provided generics. Read more
Source§

fn reference(type_map: &mut TypeCollection, generics: &[DataType]) -> Reference

Generates a datatype corresponding to a reference to this type, +as determined by its category. Getting a reference to a type implies that +it should belong in the type map (since it has to be referenced from somewhere), +so the output of definition will be put into the type map.
Source§

impl Flatten for PlayQueueContext

Auto Trait Implementations§

Blanket Implementations§

Source§

impl<T> Any for T
where + T: 'static + ?Sized,

Source§

fn type_id(&self) -> TypeId

Gets the TypeId of self. Read more
Source§

impl<T> Borrow<T> for T
where + T: ?Sized,

Source§

fn borrow(&self) -> &T

Immutably borrows from an owned value. Read more
Source§

impl<T> BorrowMut<T> for T
where + T: ?Sized,

Source§

fn borrow_mut(&mut self) -> &mut T

Mutably borrows from an owned value. Read more
§

impl<'de, D, R> CommandArg<'de, R> for D
where + D: Deserialize<'de>, + R: Runtime,

§

fn from_command(command: CommandItem<'de, R>) -> Result<D, InvokeError>

Derives an instance of Self from the [CommandItem]. Read more
Source§

impl<T> From<T> for T

Source§

fn from(t: T) -> T

Returns the argument unchanged.

+
§

impl<T> FunctionArg for T
where + T: Type,

§

fn to_datatype(type_map: &mut TypeCollection) -> Option<DataType>

Gets the type of an argument as a [DataType]. Read more
§

impl<T> Instrument for T

§

fn instrument(self, span: Span) -> Instrumented<Self>

Instruments this type with the provided [Span], returning an +Instrumented wrapper. Read more
§

fn in_current_span(self) -> Instrumented<Self>

Instruments this type with the current Span, returning an +Instrumented wrapper. Read more
Source§

impl<T, U> Into<U> for T
where + U: From<T>,

Source§

fn into(self) -> U

Calls U::from(self).

+

That is, this conversion is whatever the implementation of +From<T> for U chooses to do.

+
§

impl<T> PolicyExt for T
where + T: ?Sized,

§

fn and<P, B, E>(self, other: P) -> And<T, P>
where + T: Policy<B, E>, + P: Policy<B, E>,

Create a new Policy that returns [Action::Follow] only if self and other return +Action::Follow. Read more
§

fn or<P, B, E>(self, other: P) -> Or<T, P>
where + T: Policy<B, E>, + P: Policy<B, E>,

Create a new Policy that returns [Action::Follow] if either self or other returns +Action::Follow. Read more
Source§

impl<T> Same for T

Source§

type Output = T

Should always be Self
§

impl<T> ScopeObject for T
where + T: Send + Sync + Debug + DeserializeOwned + 'static,

§

type Error = Error

The error type.
§

fn deserialize<R>( + _app: &AppHandle<R>, + raw: Value, +) -> Result<T, <T as ScopeObject>::Error>
where + R: Runtime,

Deserialize the raw scope value.
Source§

impl<T, U> TryFrom<U> for T
where + U: Into<T>,

Source§

type Error = Infallible

The type returned in the event of a conversion error.
Source§

fn try_from(value: U) -> Result<T, <T as TryFrom<U>>::Error>

Performs the conversion.
Source§

impl<T, U> TryInto<U> for T
where + U: TryFrom<T>,

Source§

type Error = <U as TryFrom<T>>::Error

The type returned in the event of a conversion error.
Source§

fn try_into(self) -> Result<U, <U as TryFrom<T>>::Error>

Performs the conversion.
§

impl<V, T> VZip<V> for T
where + V: MultiLane<T>,

§

fn vzip(self) -> V

§

impl<T> WithSubscriber for T

§

fn with_subscriber<S>(self, subscriber: S) -> WithDispatch<Self>
where + S: Into<Dispatch>,

Attaches the provided Subscriber to this type, returning a +[WithDispatch] wrapper. Read more
§

fn with_current_subscriber(self) -> WithDispatch<Self>

Attaches the current default Subscriber to this type, returning a +[WithDispatch] wrapper. Read more
Source§

impl<T> DeserializeOwned for T
where + T: for<'de> Deserialize<'de>,

\ No newline at end of file diff --git a/api/jellytau_lib/commands/player/enum.PlayTracksContext.html b/api/jellytau_lib/commands/player/enum.PlayTracksContext.html new file mode 100644 index 00000000..794b8e32 --- /dev/null +++ b/api/jellytau_lib/commands/player/enum.PlayTracksContext.html @@ -0,0 +1,52 @@ +PlayTracksContext in jellytau_lib::commands::player - RustSkip to main content

PlayTracksContext

Enum PlayTracksContext 

Source
pub enum PlayTracksContext {
+    Playlist {
+        playlist_id: String,
+        playlist_name: String,
+    },
+    Search {
+        search_query: String,
+    },
+    Custom {
+        label: Option<String>,
+    },
+}
Expand description

Context information for track playback

+

Variants§

§

Playlist

Fields

§playlist_id: String
§playlist_name: String
§

Search

Fields

§search_query: String
§

Custom

Fields

Trait Implementations§

Source§

impl Debug for PlayTracksContext

Source§

fn fmt(&self, f: &mut Formatter<'_>) -> Result

Formats the value using the given formatter. Read more
Source§

impl<'de> Deserialize<'de> for PlayTracksContext

Source§

fn deserialize<__D>(__deserializer: __D) -> Result<Self, __D::Error>
where + __D: Deserializer<'de>,

Deserialize this value from the given Serde deserializer. Read more
Source§

impl NamedType for PlayTracksContext

Source§

fn sid() -> SpectaID

Source§

fn named_data_type( + type_map: &mut TypeCollection, + generics: &[DataType], +) -> NamedDataType

this is equivalent to [Type::inline] but returns a [NamedDataType] instead.
Source§

fn definition_named_data_type(type_map: &mut TypeCollection) -> NamedDataType

this is equivalent to [Type::definition] but returns a [NamedDataType] instead.
Source§

impl Type for PlayTracksContext

Source§

fn inline(type_map: &mut TypeCollection, generics: Generics<'_>) -> DataType

Returns the definition of a type using the provided generics. Read more
Source§

fn reference(type_map: &mut TypeCollection, generics: &[DataType]) -> Reference

Generates a datatype corresponding to a reference to this type, +as determined by its category. Getting a reference to a type implies that +it should belong in the type map (since it has to be referenced from somewhere), +so the output of definition will be put into the type map.
Source§

impl Flatten for PlayTracksContext

Auto Trait Implementations§

Blanket Implementations§

Source§

impl<T> Any for T
where + T: 'static + ?Sized,

Source§

fn type_id(&self) -> TypeId

Gets the TypeId of self. Read more
Source§

impl<T> Borrow<T> for T
where + T: ?Sized,

Source§

fn borrow(&self) -> &T

Immutably borrows from an owned value. Read more
Source§

impl<T> BorrowMut<T> for T
where + T: ?Sized,

Source§

fn borrow_mut(&mut self) -> &mut T

Mutably borrows from an owned value. Read more
§

impl<'de, D, R> CommandArg<'de, R> for D
where + D: Deserialize<'de>, + R: Runtime,

§

fn from_command(command: CommandItem<'de, R>) -> Result<D, InvokeError>

Derives an instance of Self from the [CommandItem]. Read more
Source§

impl<T> From<T> for T

Source§

fn from(t: T) -> T

Returns the argument unchanged.

+
§

impl<T> FunctionArg for T
where + T: Type,

§

fn to_datatype(type_map: &mut TypeCollection) -> Option<DataType>

Gets the type of an argument as a [DataType]. Read more
§

impl<T> Instrument for T

§

fn instrument(self, span: Span) -> Instrumented<Self>

Instruments this type with the provided [Span], returning an +Instrumented wrapper. Read more
§

fn in_current_span(self) -> Instrumented<Self>

Instruments this type with the current Span, returning an +Instrumented wrapper. Read more
Source§

impl<T, U> Into<U> for T
where + U: From<T>,

Source§

fn into(self) -> U

Calls U::from(self).

+

That is, this conversion is whatever the implementation of +From<T> for U chooses to do.

+
§

impl<T> PolicyExt for T
where + T: ?Sized,

§

fn and<P, B, E>(self, other: P) -> And<T, P>
where + T: Policy<B, E>, + P: Policy<B, E>,

Create a new Policy that returns [Action::Follow] only if self and other return +Action::Follow. Read more
§

fn or<P, B, E>(self, other: P) -> Or<T, P>
where + T: Policy<B, E>, + P: Policy<B, E>,

Create a new Policy that returns [Action::Follow] if either self or other returns +Action::Follow. Read more
Source§

impl<T> Same for T

Source§

type Output = T

Should always be Self
§

impl<T> ScopeObject for T
where + T: Send + Sync + Debug + DeserializeOwned + 'static,

§

type Error = Error

The error type.
§

fn deserialize<R>( + _app: &AppHandle<R>, + raw: Value, +) -> Result<T, <T as ScopeObject>::Error>
where + R: Runtime,

Deserialize the raw scope value.
Source§

impl<T, U> TryFrom<U> for T
where + U: Into<T>,

Source§

type Error = Infallible

The type returned in the event of a conversion error.
Source§

fn try_from(value: U) -> Result<T, <T as TryFrom<U>>::Error>

Performs the conversion.
Source§

impl<T, U> TryInto<U> for T
where + U: TryFrom<T>,

Source§

type Error = <U as TryFrom<T>>::Error

The type returned in the event of a conversion error.
Source§

fn try_into(self) -> Result<U, <U as TryFrom<T>>::Error>

Performs the conversion.
§

impl<V, T> VZip<V> for T
where + V: MultiLane<T>,

§

fn vzip(self) -> V

§

impl<T> WithSubscriber for T

§

fn with_subscriber<S>(self, subscriber: S) -> WithDispatch<Self>
where + S: Into<Dispatch>,

Attaches the provided Subscriber to this type, returning a +[WithDispatch] wrapper. Read more
§

fn with_current_subscriber(self) -> WithDispatch<Self>

Attaches the current default Subscriber to this type, returning a +[WithDispatch] wrapper. Read more
Source§

impl<T> DeserializeOwned for T
where + T: for<'de> Deserialize<'de>,

\ No newline at end of file diff --git a/api/jellytau_lib/commands/player/enum.StreamQualityResponse.html b/api/jellytau_lib/commands/player/enum.StreamQualityResponse.html new file mode 100644 index 00000000..24cf4eb4 --- /dev/null +++ b/api/jellytau_lib/commands/player/enum.StreamQualityResponse.html @@ -0,0 +1,54 @@ +StreamQualityResponse in jellytau_lib::commands::player - RustSkip to main content

StreamQualityResponse

Enum StreamQualityResponse 

Source
pub enum StreamQualityResponse {
+    Native {
+        position: f64,
+    },
+    ReloadStream {
+        new_url: String,
+        position: f64,
+    },
+}
Expand description

Response for a mid-playback streaming-quality change.

+

Mirrors AudioTrackSwitchResponse: the backend decides whether the caller +has to reload anything, so no strategy branch lives in the UI.

+

TRACES: UR-074 | DR-162

+

Variants§

§

Native

The native backend was reloaded here; nothing left for the frontend.

+

Fields

§position: f64

Position playback resumed at.

+
§

ReloadStream

HTML5 must reload its element with this URL.

+

Fields

§new_url: String

New stream URL, already transcoded to the requested ceiling.

+
§position: f64

Position to resume from.

+

Trait Implementations§

Source§

impl Debug for StreamQualityResponse

Source§

fn fmt(&self, f: &mut Formatter<'_>) -> Result

Formats the value using the given formatter. Read more
Source§

impl NamedType for StreamQualityResponse

Source§

fn sid() -> SpectaID

Source§

fn named_data_type( + type_map: &mut TypeCollection, + generics: &[DataType], +) -> NamedDataType

this is equivalent to [Type::inline] but returns a [NamedDataType] instead.
Source§

fn definition_named_data_type(type_map: &mut TypeCollection) -> NamedDataType

this is equivalent to [Type::definition] but returns a [NamedDataType] instead.
Source§

impl Serialize for StreamQualityResponse

Source§

fn serialize<__S>(&self, __serializer: __S) -> Result<__S::Ok, __S::Error>
where + __S: Serializer,

Serialize this value into the given Serde serializer. Read more
Source§

impl Type for StreamQualityResponse

Source§

fn inline(type_map: &mut TypeCollection, generics: Generics<'_>) -> DataType

Returns the definition of a type using the provided generics. Read more
Source§

fn reference(type_map: &mut TypeCollection, generics: &[DataType]) -> Reference

Generates a datatype corresponding to a reference to this type, +as determined by its category. Getting a reference to a type implies that +it should belong in the type map (since it has to be referenced from somewhere), +so the output of definition will be put into the type map.
Source§

impl Flatten for StreamQualityResponse

Auto Trait Implementations§

Blanket Implementations§

Source§

impl<T> Any for T
where + T: 'static + ?Sized,

Source§

fn type_id(&self) -> TypeId

Gets the TypeId of self. Read more
Source§

impl<T> Borrow<T> for T
where + T: ?Sized,

Source§

fn borrow(&self) -> &T

Immutably borrows from an owned value. Read more
Source§

impl<T> BorrowMut<T> for T
where + T: ?Sized,

Source§

fn borrow_mut(&mut self) -> &mut T

Mutably borrows from an owned value. Read more
Source§

impl<T> From<T> for T

Source§

fn from(t: T) -> T

Returns the argument unchanged.

+
§

impl<T> FunctionArg for T
where + T: Type,

§

fn to_datatype(type_map: &mut TypeCollection) -> Option<DataType>

Gets the type of an argument as a [DataType]. Read more
§

impl<T> Instrument for T

§

fn instrument(self, span: Span) -> Instrumented<Self>

Instruments this type with the provided [Span], returning an +Instrumented wrapper. Read more
§

fn in_current_span(self) -> Instrumented<Self>

Instruments this type with the current Span, returning an +Instrumented wrapper. Read more
Source§

impl<T, U> Into<U> for T
where + U: From<T>,

Source§

fn into(self) -> U

Calls U::from(self).

+

That is, this conversion is whatever the implementation of +From<T> for U chooses to do.

+
§

impl<T> IpcResponse for T
where + T: Serialize,

§

fn body(self) -> Result<InvokeResponseBody, Error>

Resolve the IPC response body.
§

impl<T> PolicyExt for T
where + T: ?Sized,

§

fn and<P, B, E>(self, other: P) -> And<T, P>
where + T: Policy<B, E>, + P: Policy<B, E>,

Create a new Policy that returns [Action::Follow] only if self and other return +Action::Follow. Read more
§

fn or<P, B, E>(self, other: P) -> Or<T, P>
where + T: Policy<B, E>, + P: Policy<B, E>,

Create a new Policy that returns [Action::Follow] if either self or other returns +Action::Follow. Read more
Source§

impl<T> Same for T

Source§

type Output = T

Should always be Self
Source§

impl<T> Serialize for T
where + T: Serialize + ?Sized,

Source§

fn erased_serialize(&self, serializer: &mut dyn Serializer) -> Result<(), Error>

Source§

fn do_erased_serialize( + &self, + serializer: &mut dyn Serializer, +) -> Result<(), ErrorImpl>

Source§

impl<T, U> TryFrom<U> for T
where + U: Into<T>,

Source§

type Error = Infallible

The type returned in the event of a conversion error.
Source§

fn try_from(value: U) -> Result<T, <T as TryFrom<U>>::Error>

Performs the conversion.
Source§

impl<T, U> TryInto<U> for T
where + U: TryFrom<T>,

Source§

type Error = <U as TryFrom<T>>::Error

The type returned in the event of a conversion error.
Source§

fn try_into(self) -> Result<U, <U as TryFrom<T>>::Error>

Performs the conversion.
§

impl<V, T> VZip<V> for T
where + V: MultiLane<T>,

§

fn vzip(self) -> V

§

impl<T> WithSubscriber for T

§

fn with_subscriber<S>(self, subscriber: S) -> WithDispatch<Self>
where + S: Into<Dispatch>,

Attaches the provided Subscriber to this type, returning a +[WithDispatch] wrapper. Read more
§

fn with_current_subscriber(self) -> WithDispatch<Self>

Attaches the current default Subscriber to this type, returning a +[WithDispatch] wrapper. Read more
\ No newline at end of file diff --git a/api/jellytau_lib/commands/player/enum.VideoBackend.html b/api/jellytau_lib/commands/player/enum.VideoBackend.html new file mode 100644 index 00000000..19bd7f5f --- /dev/null +++ b/api/jellytau_lib/commands/player/enum.VideoBackend.html @@ -0,0 +1,43 @@ +VideoBackend in jellytau_lib::commands::player - RustSkip to main content

VideoBackend

Enum VideoBackend 

Source
pub enum VideoBackend {
+    Native,
+    Html5,
+}
Expand description

Backend type for video playback

+

Variants§

§

Native

Native backend (ExoPlayer on Android, libmpv on Linux)

+
§

Html5

HTML5 video element fallback

+

Trait Implementations§

Source§

impl Debug for VideoBackend

Source§

fn fmt(&self, f: &mut Formatter<'_>) -> Result

Formats the value using the given formatter. Read more
Source§

impl NamedType for VideoBackend

Source§

fn sid() -> SpectaID

Source§

fn named_data_type( + type_map: &mut TypeCollection, + generics: &[DataType], +) -> NamedDataType

this is equivalent to [Type::inline] but returns a [NamedDataType] instead.
Source§

fn definition_named_data_type(type_map: &mut TypeCollection) -> NamedDataType

this is equivalent to [Type::definition] but returns a [NamedDataType] instead.
Source§

impl Serialize for VideoBackend

Source§

fn serialize<__S>(&self, __serializer: __S) -> Result<__S::Ok, __S::Error>
where + __S: Serializer,

Serialize this value into the given Serde serializer. Read more
Source§

impl Type for VideoBackend

Source§

fn inline(type_map: &mut TypeCollection, generics: Generics<'_>) -> DataType

Returns the definition of a type using the provided generics. Read more
Source§

fn reference(type_map: &mut TypeCollection, generics: &[DataType]) -> Reference

Generates a datatype corresponding to a reference to this type, +as determined by its category. Getting a reference to a type implies that +it should belong in the type map (since it has to be referenced from somewhere), +so the output of definition will be put into the type map.

Auto Trait Implementations§

Blanket Implementations§

Source§

impl<T> Any for T
where + T: 'static + ?Sized,

Source§

fn type_id(&self) -> TypeId

Gets the TypeId of self. Read more
Source§

impl<T> Borrow<T> for T
where + T: ?Sized,

Source§

fn borrow(&self) -> &T

Immutably borrows from an owned value. Read more
Source§

impl<T> BorrowMut<T> for T
where + T: ?Sized,

Source§

fn borrow_mut(&mut self) -> &mut T

Mutably borrows from an owned value. Read more
Source§

impl<T> From<T> for T

Source§

fn from(t: T) -> T

Returns the argument unchanged.

+
§

impl<T> FunctionArg for T
where + T: Type,

§

fn to_datatype(type_map: &mut TypeCollection) -> Option<DataType>

Gets the type of an argument as a [DataType]. Read more
§

impl<T> Instrument for T

§

fn instrument(self, span: Span) -> Instrumented<Self>

Instruments this type with the provided [Span], returning an +Instrumented wrapper. Read more
§

fn in_current_span(self) -> Instrumented<Self>

Instruments this type with the current Span, returning an +Instrumented wrapper. Read more
Source§

impl<T, U> Into<U> for T
where + U: From<T>,

Source§

fn into(self) -> U

Calls U::from(self).

+

That is, this conversion is whatever the implementation of +From<T> for U chooses to do.

+
§

impl<T> IpcResponse for T
where + T: Serialize,

§

fn body(self) -> Result<InvokeResponseBody, Error>

Resolve the IPC response body.
§

impl<T> PolicyExt for T
where + T: ?Sized,

§

fn and<P, B, E>(self, other: P) -> And<T, P>
where + T: Policy<B, E>, + P: Policy<B, E>,

Create a new Policy that returns [Action::Follow] only if self and other return +Action::Follow. Read more
§

fn or<P, B, E>(self, other: P) -> Or<T, P>
where + T: Policy<B, E>, + P: Policy<B, E>,

Create a new Policy that returns [Action::Follow] if either self or other returns +Action::Follow. Read more
Source§

impl<T> Same for T

Source§

type Output = T

Should always be Self
Source§

impl<T> Serialize for T
where + T: Serialize + ?Sized,

Source§

fn erased_serialize(&self, serializer: &mut dyn Serializer) -> Result<(), Error>

Source§

fn do_erased_serialize( + &self, + serializer: &mut dyn Serializer, +) -> Result<(), ErrorImpl>

Source§

impl<T, U> TryFrom<U> for T
where + U: Into<T>,

Source§

type Error = Infallible

The type returned in the event of a conversion error.
Source§

fn try_from(value: U) -> Result<T, <T as TryFrom<U>>::Error>

Performs the conversion.
Source§

impl<T, U> TryInto<U> for T
where + U: TryFrom<T>,

Source§

type Error = <U as TryFrom<T>>::Error

The type returned in the event of a conversion error.
Source§

fn try_into(self) -> Result<U, <U as TryFrom<T>>::Error>

Performs the conversion.
§

impl<V, T> VZip<V> for T
where + V: MultiLane<T>,

§

fn vzip(self) -> V

§

impl<T> WithSubscriber for T

§

fn with_subscriber<S>(self, subscriber: S) -> WithDispatch<Self>
where + S: Into<Dispatch>,

Attaches the provided Subscriber to this type, returning a +[WithDispatch] wrapper. Read more
§

fn with_current_subscriber(self) -> WithDispatch<Self>

Attaches the current default Subscriber to this type, returning a +[WithDispatch] wrapper. Read more
\ No newline at end of file diff --git a/api/jellytau_lib/commands/player/enum.VideoSeekResponse.html b/api/jellytau_lib/commands/player/enum.VideoSeekResponse.html new file mode 100644 index 00000000..ce2ea2f9 --- /dev/null +++ b/api/jellytau_lib/commands/player/enum.VideoSeekResponse.html @@ -0,0 +1,51 @@ +VideoSeekResponse in jellytau_lib::commands::player - RustSkip to main content

VideoSeekResponse

Enum VideoSeekResponse 

Source
pub enum VideoSeekResponse {
+    Native {
+        position: f64,
+    },
+    ReloadStream {
+        new_url: String,
+        seek_offset: f64,
+    },
+}
Expand description

Response for video seek operations

+

Variants§

§

Native

Use native seeking (HLS or direct stream)

+

Fields

§position: f64

Confirmed position after seek

+
§

ReloadStream

Reload stream from new position (transcoded non-HLS)

+

Fields

§new_url: String

New stream URL starting at seek position

+
§seek_offset: f64

Position offset to track (for display purposes)

+

Trait Implementations§

Source§

impl Debug for VideoSeekResponse

Source§

fn fmt(&self, f: &mut Formatter<'_>) -> Result

Formats the value using the given formatter. Read more
Source§

impl NamedType for VideoSeekResponse

Source§

fn sid() -> SpectaID

Source§

fn named_data_type( + type_map: &mut TypeCollection, + generics: &[DataType], +) -> NamedDataType

this is equivalent to [Type::inline] but returns a [NamedDataType] instead.
Source§

fn definition_named_data_type(type_map: &mut TypeCollection) -> NamedDataType

this is equivalent to [Type::definition] but returns a [NamedDataType] instead.
Source§

impl Serialize for VideoSeekResponse

Source§

fn serialize<__S>(&self, __serializer: __S) -> Result<__S::Ok, __S::Error>
where + __S: Serializer,

Serialize this value into the given Serde serializer. Read more
Source§

impl Type for VideoSeekResponse

Source§

fn inline(type_map: &mut TypeCollection, generics: Generics<'_>) -> DataType

Returns the definition of a type using the provided generics. Read more
Source§

fn reference(type_map: &mut TypeCollection, generics: &[DataType]) -> Reference

Generates a datatype corresponding to a reference to this type, +as determined by its category. Getting a reference to a type implies that +it should belong in the type map (since it has to be referenced from somewhere), +so the output of definition will be put into the type map.
Source§

impl Flatten for VideoSeekResponse

Auto Trait Implementations§

Blanket Implementations§

Source§

impl<T> Any for T
where + T: 'static + ?Sized,

Source§

fn type_id(&self) -> TypeId

Gets the TypeId of self. Read more
Source§

impl<T> Borrow<T> for T
where + T: ?Sized,

Source§

fn borrow(&self) -> &T

Immutably borrows from an owned value. Read more
Source§

impl<T> BorrowMut<T> for T
where + T: ?Sized,

Source§

fn borrow_mut(&mut self) -> &mut T

Mutably borrows from an owned value. Read more
Source§

impl<T> From<T> for T

Source§

fn from(t: T) -> T

Returns the argument unchanged.

+
§

impl<T> FunctionArg for T
where + T: Type,

§

fn to_datatype(type_map: &mut TypeCollection) -> Option<DataType>

Gets the type of an argument as a [DataType]. Read more
§

impl<T> Instrument for T

§

fn instrument(self, span: Span) -> Instrumented<Self>

Instruments this type with the provided [Span], returning an +Instrumented wrapper. Read more
§

fn in_current_span(self) -> Instrumented<Self>

Instruments this type with the current Span, returning an +Instrumented wrapper. Read more
Source§

impl<T, U> Into<U> for T
where + U: From<T>,

Source§

fn into(self) -> U

Calls U::from(self).

+

That is, this conversion is whatever the implementation of +From<T> for U chooses to do.

+
§

impl<T> IpcResponse for T
where + T: Serialize,

§

fn body(self) -> Result<InvokeResponseBody, Error>

Resolve the IPC response body.
§

impl<T> PolicyExt for T
where + T: ?Sized,

§

fn and<P, B, E>(self, other: P) -> And<T, P>
where + T: Policy<B, E>, + P: Policy<B, E>,

Create a new Policy that returns [Action::Follow] only if self and other return +Action::Follow. Read more
§

fn or<P, B, E>(self, other: P) -> Or<T, P>
where + T: Policy<B, E>, + P: Policy<B, E>,

Create a new Policy that returns [Action::Follow] if either self or other returns +Action::Follow. Read more
Source§

impl<T> Same for T

Source§

type Output = T

Should always be Self
Source§

impl<T> Serialize for T
where + T: Serialize + ?Sized,

Source§

fn erased_serialize(&self, serializer: &mut dyn Serializer) -> Result<(), Error>

Source§

fn do_erased_serialize( + &self, + serializer: &mut dyn Serializer, +) -> Result<(), ErrorImpl>

Source§

impl<T, U> TryFrom<U> for T
where + U: Into<T>,

Source§

type Error = Infallible

The type returned in the event of a conversion error.
Source§

fn try_from(value: U) -> Result<T, <T as TryFrom<U>>::Error>

Performs the conversion.
Source§

impl<T, U> TryInto<U> for T
where + U: TryFrom<T>,

Source§

type Error = <U as TryFrom<T>>::Error

The type returned in the event of a conversion error.
Source§

fn try_into(self) -> Result<U, <U as TryFrom<T>>::Error>

Performs the conversion.
§

impl<V, T> VZip<V> for T
where + V: MultiLane<T>,

§

fn vzip(self) -> V

§

impl<T> WithSubscriber for T

§

fn with_subscriber<S>(self, subscriber: S) -> WithDispatch<Self>
where + S: Into<Dispatch>,

Attaches the provided Subscriber to this type, returning a +[WithDispatch] wrapper. Read more
§

fn with_current_subscriber(self) -> WithDispatch<Self>

Attaches the current default Subscriber to this type, returning a +[WithDispatch] wrapper. Read more
\ No newline at end of file diff --git a/api/jellytau_lib/commands/player/fn.background_audio_plan.html b/api/jellytau_lib/commands/player/fn.background_audio_plan.html new file mode 100644 index 00000000..24b2b1f6 --- /dev/null +++ b/api/jellytau_lib/commands/player/fn.background_audio_plan.html @@ -0,0 +1,13 @@ +background_audio_plan in jellytau_lib::commands::player - RustSkip to main content

background_audio_plan

Function background_audio_plan 

Source
pub(super) fn background_audio_plan(
+    is_local_file: bool,
+    position_seconds: f64,
+) -> BackgroundAudioPlan
Expand description

Decide the base and the seek for a handoff at position_seconds.

+

The two sources start in different places. An audio-only stream is built +with StartTimeTicks, so the server makes the handoff point that stream’s +zero: the base is the handoff position, and seeking would skip past the +content by that much again. A downloaded file has no such parameter and +begins at the episode’s own zero, so it needs the opposite — no base, and a +real seek. Treating a file like a stream is why backgrounding a downloaded +episode restarted it from 0:00 while the lockscreen showed the right time.

+

TRACES: UR-040, UR-071 | DR-180 | UT-181

+
\ No newline at end of file diff --git a/api/jellytau_lib/commands/player/fn.background_audio_source.html b/api/jellytau_lib/commands/player/fn.background_audio_source.html new file mode 100644 index 00000000..e02f9be6 --- /dev/null +++ b/api/jellytau_lib/commands/player/fn.background_audio_source.html @@ -0,0 +1,14 @@ +background_audio_source in jellytau_lib::commands::player - RustSkip to main content

background_audio_source

Function background_audio_source 

Source
pub(super) fn background_audio_source(
+    local_path: Option<String>,
+    stream_url: String,
+    item_id: &str,
+) -> MediaSource
Expand description

Pick the source for an audio-only handoff.

+

A downloaded file wins over the audio-only stream URL. No transcode or audio +extraction is involved or wanted: the native backends already play a video +container without decoding its video — the Linux MPV backend is configured +with video: no, and ExoPlayer simply has no surface to render to when the +item is MediaType::Audio. Producing a separate audio-only file would cost +CPU and battery, need an encoder the project does not ship, and leave a +second artifact to keep in step with the first.

+

TRACES: UR-071 | DR-128 | UT-119

+
\ No newline at end of file diff --git a/api/jellytau_lib/commands/player/fn.check_for_local_download.html b/api/jellytau_lib/commands/player/fn.check_for_local_download.html new file mode 100644 index 00000000..f579b94d --- /dev/null +++ b/api/jellytau_lib/commands/player/fn.check_for_local_download.html @@ -0,0 +1,5 @@ +check_for_local_download in jellytau_lib::commands::player - RustSkip to main content

check_for_local_download

Function check_for_local_download 

Source
pub(super) async fn check_for_local_download(
+    db: &DatabaseWrapper,
+    item_id: &str,
+) -> Result<Option<String>, String>
Expand description

Check if an item has a completed download

+
\ No newline at end of file diff --git a/api/jellytau_lib/commands/player/fn.create_media_item.html b/api/jellytau_lib/commands/player/fn.create_media_item.html new file mode 100644 index 00000000..1225f285 --- /dev/null +++ b/api/jellytau_lib/commands/player/fn.create_media_item.html @@ -0,0 +1,7 @@ +create_media_item in jellytau_lib::commands::player - RustSkip to main content

create_media_item

Function create_media_item 

Source
pub(super) async fn create_media_item(
+    req: PlayItemRequest,
+    db: Option<&DatabaseWrapper>,
+) -> Result<MediaItem, String>
Expand description

Helper function to create MediaItem from video request

+

PlayItemRequest is now video-only, so we create a video MediaItem. +Audio playback uses player_play_tracks which fetches full metadata from backend.

+
\ No newline at end of file diff --git a/api/jellytau_lib/commands/player/fn.get_player_status.html b/api/jellytau_lib/commands/player/fn.get_player_status.html new file mode 100644 index 00000000..25fa000a --- /dev/null +++ b/api/jellytau_lib/commands/player/fn.get_player_status.html @@ -0,0 +1 @@ +get_player_status in jellytau_lib::commands::player - RustSkip to main content

get_player_status

Function get_player_status 

Source
pub(super) fn get_player_status(controller: &PlayerController) -> PlayerStatus
\ No newline at end of file diff --git a/api/jellytau_lib/commands/player/fn.get_queue_status.html b/api/jellytau_lib/commands/player/fn.get_queue_status.html new file mode 100644 index 00000000..d30aa440 --- /dev/null +++ b/api/jellytau_lib/commands/player/fn.get_queue_status.html @@ -0,0 +1 @@ +get_queue_status in jellytau_lib::commands::player - RustSkip to main content

get_queue_status

Function get_queue_status 

Source
pub(super) fn get_queue_status(controller: &PlayerController) -> QueueStatus
\ No newline at end of file diff --git a/api/jellytau_lib/commands/player/fn.normalize_volume.html b/api/jellytau_lib/commands/player/fn.normalize_volume.html new file mode 100644 index 00000000..dfa4531b --- /dev/null +++ b/api/jellytau_lib/commands/player/fn.normalize_volume.html @@ -0,0 +1,8 @@ +normalize_volume in jellytau_lib::commands::player - RustSkip to main content

normalize_volume

Function normalize_volume 

Source
fn normalize_volume(volume: f32) -> f32
Expand description

Normalise a volume arriving over IPC to the 0.0..=1.0 range every backend +works in.

+

NaN is handled before the clamp rather than by it: f32::clamp returns NaN +for a NaN input (it only panics on NaN bounds), and NaN then survives every +comparison downstream, so a backend clamp cannot catch it either. It is +treated as “no volume asked for” and floored to 0.0.

+

TRACES: DR-212 | UT-206

+
\ No newline at end of file diff --git a/api/jellytau_lib/commands/player/fn.play_selection_on_remote.html b/api/jellytau_lib/commands/player/fn.play_selection_on_remote.html new file mode 100644 index 00000000..6ff00bf0 --- /dev/null +++ b/api/jellytau_lib/commands/player/fn.play_selection_on_remote.html @@ -0,0 +1,15 @@ +play_selection_on_remote in jellytau_lib::commands::player - RustSkip to main content

play_selection_on_remote

Function play_selection_on_remote 

Source
async fn play_selection_on_remote(
+    controller: &PlayerController,
+    session_id: &str,
+    media_items: &[MediaItem],
+    start_index: usize,
+) -> Result<(), String>
Expand description

Start a freshly-built queue on the active remote session.

+

Used by the “play tracks”/“play album track” commands when we’re in remote +mode: instead of starting local MPV playback, we cast the selected tracks to +the remote device. Mirrors PlaybackModeManager::transfer_to_remote’s +play_on_session call, but for a brand-new selection (so there’s no resume +position - playback starts from the chosen track’s beginning).

+

Local-only items (no Jellyfin ID) can’t be cast, so they’re filtered out and +the start index is adjusted to the remaining Jellyfin items. Returns an error +if the selected track itself has no Jellyfin ID.

+
\ No newline at end of file diff --git a/api/jellytau_lib/commands/player/fn.player_background_action.html b/api/jellytau_lib/commands/player/fn.player_background_action.html new file mode 100644 index 00000000..483ceb76 --- /dev/null +++ b/api/jellytau_lib/commands/player/fn.player_background_action.html @@ -0,0 +1,20 @@ +player_background_action in jellytau_lib::commands::player - RustSkip to main content

player_background_action

Function player_background_action 

Source
pub async fn player_background_action(
+    player: State<'_, PlayerStateWrapper>,
+    background_audio_armed: bool,
+    in_picture_in_picture: bool,
+) -> Result<BackgroundAction, String>
Expand description

Exit background-audio mode: stop the native audio player and return its final +position so the frontend can reload the WebView <video> there (UR-040).

+

Returns the position in seconds. The sleep timer is intentionally left +untouched — if it fired while backgrounded, playback is already stopped and +this simply reports the last position.

+

What playback should do now that the app is no longer visible.

+

The caller supplies only what it alone knows – whether the per-player +toggle is armed, and whether Android put the window into picture-in-picture. +Everything else (what is playing, and therefore whether there is a picture to +lose) is read here, because it is domain state.

+

The rule itself is in player::background_policy; this command is the wire. +Returning KeepPlaying for an empty queue is deliberate: with nothing +playing there is nothing to pause, and an error would make the frontend +handle a case that is not a failure.

+

TRACES: UR-040, UR-041 | DR-224 | UT-211

+
\ No newline at end of file diff --git a/api/jellytau_lib/commands/player/fn.player_configure_jellyfin.html b/api/jellytau_lib/commands/player/fn.player_configure_jellyfin.html new file mode 100644 index 00000000..f66d14df --- /dev/null +++ b/api/jellytau_lib/commands/player/fn.player_configure_jellyfin.html @@ -0,0 +1,9 @@ +player_configure_jellyfin in jellytau_lib::commands::player - RustSkip to main content

player_configure_jellyfin

Function player_configure_jellyfin 

Source
pub async fn player_configure_jellyfin(
+    player: State<'_, PlayerStateWrapper>,
+    db: State<'_, DatabaseWrapper>,
+    server_url: String,
+    access_token: String,
+    user_id: String,
+    device_id: String,
+) -> Result<(), String>
Expand description

Configure Jellyfin API client for automatic playback reporting

+
\ No newline at end of file diff --git a/api/jellytau_lib/commands/player/fn.player_cycle_repeat.html b/api/jellytau_lib/commands/player/fn.player_cycle_repeat.html new file mode 100644 index 00000000..2a74b246 --- /dev/null +++ b/api/jellytau_lib/commands/player/fn.player_cycle_repeat.html @@ -0,0 +1,3 @@ +player_cycle_repeat in jellytau_lib::commands::player - RustSkip to main content

player_cycle_repeat

Function player_cycle_repeat 

Source
pub async fn player_cycle_repeat(
+    player: State<'_, PlayerStateWrapper>,
+) -> Result<QueueStatus, String>
\ No newline at end of file diff --git a/api/jellytau_lib/commands/player/fn.player_disable_jellyfin.html b/api/jellytau_lib/commands/player/fn.player_disable_jellyfin.html new file mode 100644 index 00000000..19aed817 --- /dev/null +++ b/api/jellytau_lib/commands/player/fn.player_disable_jellyfin.html @@ -0,0 +1,4 @@ +player_disable_jellyfin in jellytau_lib::commands::player - RustSkip to main content

player_disable_jellyfin

Function player_disable_jellyfin 

Source
pub async fn player_disable_jellyfin(
+    player: State<'_, PlayerStateWrapper>,
+) -> Result<(), String>
Expand description

Disable Jellyfin automatic playback reporting

+
\ No newline at end of file diff --git a/api/jellytau_lib/commands/player/fn.player_enter_background_audio.html b/api/jellytau_lib/commands/player/fn.player_enter_background_audio.html new file mode 100644 index 00000000..6fd8fcbb --- /dev/null +++ b/api/jellytau_lib/commands/player/fn.player_enter_background_audio.html @@ -0,0 +1,20 @@ +player_enter_background_audio in jellytau_lib::commands::player - RustSkip to main content

player_enter_background_audio

Function player_enter_background_audio 

Source
pub async fn player_enter_background_audio(
+    player: State<'_, PlayerStateWrapper>,
+    session: State<'_, MediaSessionManagerWrapper>,
+    db: State<'_, DatabaseWrapper>,
+    item: PlayItemRequest,
+    position_seconds: f64,
+) -> Result<PlayerStatus, String>
Expand description

Enter background-audio mode: hand playback of the currently-watched video off +to the native ExoPlayer audio path so the audio keeps playing while the app +is backgrounded/locked, with no client-side video decode (UR-040).

+

stream_url MUST be an audio-only URL (see +get_audio_only_stream_url_for_video). The item is created as +MediaType::Audio so it starts an audio session and loads into the native +backend with mediaType="audio" — the WebView <video> is torn down on the +frontend side, so exactly one audio source is ever active.

+

This deliberately goes through the queue-based play_item path (NOT a +side-channel) so end-of-track lands in on_playback_ended, which already +honors the sleep timer (Time/Episodes/EndOfTrack) and drives autoplay-next. +The sleep-timer state is intentionally left untouched by the handoff.

+

TRACES: UR-040 | DR-052 | UT-061, IT-013

+
\ No newline at end of file diff --git a/api/jellytau_lib/commands/player/fn.player_exit_background_audio.html b/api/jellytau_lib/commands/player/fn.player_exit_background_audio.html new file mode 100644 index 00000000..77c9b142 --- /dev/null +++ b/api/jellytau_lib/commands/player/fn.player_exit_background_audio.html @@ -0,0 +1,4 @@ +player_exit_background_audio in jellytau_lib::commands::player - RustSkip to main content

player_exit_background_audio

Function player_exit_background_audio 

Source
pub async fn player_exit_background_audio(
+    player: State<'_, PlayerStateWrapper>,
+) -> Result<f64, String>
Expand description

TRACES: UR-040 | DR-052 | UT-061, IT-013

+
\ No newline at end of file diff --git a/api/jellytau_lib/commands/player/fn.player_get_cache_config.html b/api/jellytau_lib/commands/player/fn.player_get_cache_config.html new file mode 100644 index 00000000..44e8bf01 --- /dev/null +++ b/api/jellytau_lib/commands/player/fn.player_get_cache_config.html @@ -0,0 +1,4 @@ +player_get_cache_config in jellytau_lib::commands::player - RustSkip to main content

player_get_cache_config

Function player_get_cache_config 

Source
pub async fn player_get_cache_config(
+    smart_cache: State<'_, SmartCacheWrapper>,
+) -> Result<CacheConfig, String>
Expand description

Get current SmartCache configuration

+
\ No newline at end of file diff --git a/api/jellytau_lib/commands/player/fn.player_get_capabilities.html b/api/jellytau_lib/commands/player/fn.player_get_capabilities.html new file mode 100644 index 00000000..c1720bcc --- /dev/null +++ b/api/jellytau_lib/commands/player/fn.player_get_capabilities.html @@ -0,0 +1,3 @@ +player_get_capabilities in jellytau_lib::commands::player - RustSkip to main content

player_get_capabilities

Function player_get_capabilities 

Source
pub async fn player_get_capabilities() -> Result<PlaybackCapabilities, String>
Expand description

Report this platform’s playback capabilities to the frontend.

+

TRACES: UR-003, UR-005 | DR-004, DR-023, DR-024

+
\ No newline at end of file diff --git a/api/jellytau_lib/commands/player/fn.player_get_queue.html b/api/jellytau_lib/commands/player/fn.player_get_queue.html new file mode 100644 index 00000000..284b7c30 --- /dev/null +++ b/api/jellytau_lib/commands/player/fn.player_get_queue.html @@ -0,0 +1,3 @@ +player_get_queue in jellytau_lib::commands::player - RustSkip to main content

player_get_queue

Function player_get_queue 

Source
pub async fn player_get_queue(
+    player: State<'_, PlayerStateWrapper>,
+) -> Result<QueueStatus, String>
\ No newline at end of file diff --git a/api/jellytau_lib/commands/player/fn.player_get_status.html b/api/jellytau_lib/commands/player/fn.player_get_status.html new file mode 100644 index 00000000..4db0ec87 --- /dev/null +++ b/api/jellytau_lib/commands/player/fn.player_get_status.html @@ -0,0 +1,4 @@ +player_get_status in jellytau_lib::commands::player - RustSkip to main content

player_get_status

Function player_get_status 

Source
pub async fn player_get_status(
+    player: State<'_, PlayerStateWrapper>,
+    playback_mode: State<'_, PlaybackModeManagerWrapper>,
+) -> Result<PlayerStatus, String>
\ No newline at end of file diff --git a/api/jellytau_lib/commands/player/fn.player_local_media_path.html b/api/jellytau_lib/commands/player/fn.player_local_media_path.html new file mode 100644 index 00000000..20cdd61f --- /dev/null +++ b/api/jellytau_lib/commands/player/fn.player_local_media_path.html @@ -0,0 +1,13 @@ +player_local_media_path in jellytau_lib::commands::player - RustSkip to main content

player_local_media_path

Function player_local_media_path 

Source
pub async fn player_local_media_path(
+    db: State<'_, DatabaseWrapper>,
+    item_id: String,
+) -> Result<Option<String>, String>
Expand description

The on-disk path for a downloaded item, for playback surfaces that resolve +their own source rather than going through the queue.

+

The video player is the reason this exists: audio has preferred local files +since queue construction, but video asks the repository for a stream URL and +never consults downloads, so a downloaded film was still streamed — costing +bandwidth that had already been spent and failing outright when offline.

+

Returns None when nothing is downloaded or the file is missing, so the +caller falls back to streaming.

+

TRACES: UR-071 | DR-123 | UT-116

+
\ No newline at end of file diff --git a/api/jellytau_lib/commands/player/fn.player_next.html b/api/jellytau_lib/commands/player/fn.player_next.html new file mode 100644 index 00000000..9f64afd9 --- /dev/null +++ b/api/jellytau_lib/commands/player/fn.player_next.html @@ -0,0 +1,6 @@ +player_next in jellytau_lib::commands::player - RustSkip to main content

player_next

Function player_next 

Source
pub async fn player_next(
+    player: State<'_, PlayerStateWrapper>,
+    session: State<'_, MediaSessionManagerWrapper>,
+    playback_mode: State<'_, PlaybackModeManagerWrapper>,
+    db: State<'_, DatabaseWrapper>,
+) -> Result<PlayerStatus, String>
\ No newline at end of file diff --git a/api/jellytau_lib/commands/player/fn.player_pause.html b/api/jellytau_lib/commands/player/fn.player_pause.html new file mode 100644 index 00000000..8f921dce --- /dev/null +++ b/api/jellytau_lib/commands/player/fn.player_pause.html @@ -0,0 +1,4 @@ +player_pause in jellytau_lib::commands::player - RustSkip to main content

player_pause

Function player_pause 

Source
pub async fn player_pause(
+    player: State<'_, PlayerStateWrapper>,
+    playback_mode: State<'_, PlaybackModeManagerWrapper>,
+) -> Result<PlayerStatus, String>
\ No newline at end of file diff --git a/api/jellytau_lib/commands/player/fn.player_play.html b/api/jellytau_lib/commands/player/fn.player_play.html new file mode 100644 index 00000000..bf3c85fc --- /dev/null +++ b/api/jellytau_lib/commands/player/fn.player_play.html @@ -0,0 +1,4 @@ +player_play in jellytau_lib::commands::player - RustSkip to main content

player_play

Function player_play 

Source
pub async fn player_play(
+    player: State<'_, PlayerStateWrapper>,
+    playback_mode: State<'_, PlaybackModeManagerWrapper>,
+) -> Result<PlayerStatus, String>
\ No newline at end of file diff --git a/api/jellytau_lib/commands/player/fn.player_play_album_track.html b/api/jellytau_lib/commands/player/fn.player_play_album_track.html new file mode 100644 index 00000000..518be165 --- /dev/null +++ b/api/jellytau_lib/commands/player/fn.player_play_album_track.html @@ -0,0 +1,10 @@ +player_play_album_track in jellytau_lib::commands::player - RustSkip to main content

player_play_album_track

Function player_play_album_track 

Source
pub async fn player_play_album_track(
+    player: State<'_, PlayerStateWrapper>,
+    session: State<'_, MediaSessionManagerWrapper>,
+    db: State<'_, DatabaseWrapper>,
+    repository_manager: State<'_, RepositoryManagerWrapper>,
+    playback_mode: State<'_, PlaybackModeManagerWrapper>,
+    repository_handle: String,
+    request: PlayAlbumTrackRequest,
+) -> Result<PlayerStatus, String>
Expand description

Play a track from an album - backend fetches all album tracks and builds queue

+
\ No newline at end of file diff --git a/api/jellytau_lib/commands/player/fn.player_play_item.html b/api/jellytau_lib/commands/player/fn.player_play_item.html new file mode 100644 index 00000000..519370c4 --- /dev/null +++ b/api/jellytau_lib/commands/player/fn.player_play_item.html @@ -0,0 +1,13 @@ +player_play_item in jellytau_lib::commands::player - RustSkip to main content

player_play_item

Function player_play_item 

Source
pub async fn player_play_item(
+    player: State<'_, PlayerStateWrapper>,
+    session: State<'_, MediaSessionManagerWrapper>,
+    db: State<'_, DatabaseWrapper>,
+    item: PlayItemRequest,
+) -> Result<PlayerStatus, String>
Expand description

Play a single media item (audio or video)

+

Accepts a PlayItemRequest with all optional fields properly defaulted. +This avoids Tauri’s Android serialization issues with complex objects.

+

@req: UR-003 - Play videos +@req: UR-004 - Play audio uninterrupted +@req: UR-005 - Control media playback (play operation) +@req: DR-009 - Audio player UI

+
\ No newline at end of file diff --git a/api/jellytau_lib/commands/player/fn.player_play_queue.html b/api/jellytau_lib/commands/player/fn.player_play_queue.html new file mode 100644 index 00000000..d43eba0c --- /dev/null +++ b/api/jellytau_lib/commands/player/fn.player_play_queue.html @@ -0,0 +1,11 @@ +player_play_queue in jellytau_lib::commands::player - RustSkip to main content

player_play_queue

Function player_play_queue 

Source
pub async fn player_play_queue(
+    player: State<'_, PlayerStateWrapper>,
+    session: State<'_, MediaSessionManagerWrapper>,
+    db: State<'_, DatabaseWrapper>,
+    request: PlayQueueRequest,
+) -> Result<PlayerStatus, String>
Expand description

Play a queue of media items

+

@req: UR-004 - Play audio uninterrupted +@req: UR-005 - Control media playback (queue playback) +@req: UR-015 - View and manage current audio queue +@req: DR-005 - Queue manager with shuffle, repeat, history

+
\ No newline at end of file diff --git a/api/jellytau_lib/commands/player/fn.player_play_tracks.html b/api/jellytau_lib/commands/player/fn.player_play_tracks.html new file mode 100644 index 00000000..9d94db08 --- /dev/null +++ b/api/jellytau_lib/commands/player/fn.player_play_tracks.html @@ -0,0 +1,10 @@ +player_play_tracks in jellytau_lib::commands::player - RustSkip to main content

player_play_tracks

Function player_play_tracks 

Source
pub async fn player_play_tracks(
+    player: State<'_, PlayerStateWrapper>,
+    session: State<'_, MediaSessionManagerWrapper>,
+    db: State<'_, DatabaseWrapper>,
+    repository_manager: State<'_, RepositoryManagerWrapper>,
+    playback_mode: State<'_, PlaybackModeManagerWrapper>,
+    repository_handle: String,
+    request: PlayTracksRequest,
+) -> Result<PlayerStatus, String>
Expand description

Play tracks by ID - backend fetches all metadata

+
\ No newline at end of file diff --git a/api/jellytau_lib/commands/player/fn.player_preload_upcoming.html b/api/jellytau_lib/commands/player/fn.player_preload_upcoming.html new file mode 100644 index 00000000..a8955f74 --- /dev/null +++ b/api/jellytau_lib/commands/player/fn.player_preload_upcoming.html @@ -0,0 +1,11 @@ +player_preload_upcoming in jellytau_lib::commands::player - RustSkip to main content

player_preload_upcoming

Function player_preload_upcoming 

Source
pub async fn player_preload_upcoming(
+    player: State<'_, PlayerStateWrapper>,
+    db: State<'_, DatabaseWrapper>,
+    smart_cache: State<'_, SmartCacheWrapper>,
+    download_manager: State<'_, DownloadManagerWrapper>,
+    app: AppHandle,
+    user_id: String,
+    _download_base_path: String,
+) -> Result<PreloadResult, String>
Expand description

Preload upcoming tracks from the queue +This queues background downloads for the next N tracks that aren’t already downloaded

+
\ No newline at end of file diff --git a/api/jellytau_lib/commands/player/fn.player_previous.html b/api/jellytau_lib/commands/player/fn.player_previous.html new file mode 100644 index 00000000..fa206d45 --- /dev/null +++ b/api/jellytau_lib/commands/player/fn.player_previous.html @@ -0,0 +1,6 @@ +player_previous in jellytau_lib::commands::player - RustSkip to main content

player_previous

Function player_previous 

Source
pub async fn player_previous(
+    player: State<'_, PlayerStateWrapper>,
+    session: State<'_, MediaSessionManagerWrapper>,
+    playback_mode: State<'_, PlaybackModeManagerWrapper>,
+    db: State<'_, DatabaseWrapper>,
+) -> Result<PlayerStatus, String>
\ No newline at end of file diff --git a/api/jellytau_lib/commands/player/fn.player_seek.html b/api/jellytau_lib/commands/player/fn.player_seek.html new file mode 100644 index 00000000..627e2420 --- /dev/null +++ b/api/jellytau_lib/commands/player/fn.player_seek.html @@ -0,0 +1,5 @@ +player_seek in jellytau_lib::commands::player - RustSkip to main content

player_seek

Function player_seek 

Source
pub async fn player_seek(
+    player: State<'_, PlayerStateWrapper>,
+    playback_mode: State<'_, PlaybackModeManagerWrapper>,
+    position: f64,
+) -> Result<PlayerStatus, String>
\ No newline at end of file diff --git a/api/jellytau_lib/commands/player/fn.player_seek_video.html b/api/jellytau_lib/commands/player/fn.player_seek_video.html new file mode 100644 index 00000000..0d865e00 --- /dev/null +++ b/api/jellytau_lib/commands/player/fn.player_seek_video.html @@ -0,0 +1,19 @@ +player_seek_video in jellytau_lib::commands::player - RustSkip to main content

player_seek_video

Function player_seek_video 

Source
pub async fn player_seek_video(
+    player: State<'_, PlayerStateWrapper>,
+    repository_manager: State<'_, RepositoryManagerWrapper>,
+    repository_handle: String,
+    position: f64,
+    media_source_id: Option<String>,
+    audio_stream_index: Option<i32>,
+    use_html5: bool,
+) -> Result<VideoSeekResponse, String>
Expand description

Smart video seeking that decides between native and server-side seeking

+

This command analyzes the current video stream and automatically chooses +the best seeking strategy:

+
    +
  • HLS streams (.m3u8): Use native seeking
  • +
  • Direct play streams: Use native seeking
  • +
  • Transcoded non-HLS: Request new stream URL from server starting at seek position
  • +
+

For native (non-HTML5) backends, this command handles the entire stream reload +internally. For HTML5 backends, it returns the new URL for the frontend to handle.

+
\ No newline at end of file diff --git a/api/jellytau_lib/commands/player/fn.player_set_audio_track.html b/api/jellytau_lib/commands/player/fn.player_set_audio_track.html new file mode 100644 index 00000000..dc3baa24 --- /dev/null +++ b/api/jellytau_lib/commands/player/fn.player_set_audio_track.html @@ -0,0 +1,6 @@ +player_set_audio_track in jellytau_lib::commands::player - RustSkip to main content

player_set_audio_track

Function player_set_audio_track 

Source
pub async fn player_set_audio_track(
+    player: State<'_, PlayerStateWrapper>,
+    stream_index: i32,
+) -> Result<PlayerStatus, String>
Expand description

Set the active audio track on a native backend directly.

+

TRACES: UR-021 | IR-019, DR-024

+
\ No newline at end of file diff --git a/api/jellytau_lib/commands/player/fn.player_set_cache_config.html b/api/jellytau_lib/commands/player/fn.player_set_cache_config.html new file mode 100644 index 00000000..7651db1e --- /dev/null +++ b/api/jellytau_lib/commands/player/fn.player_set_cache_config.html @@ -0,0 +1,5 @@ +player_set_cache_config in jellytau_lib::commands::player - RustSkip to main content

player_set_cache_config

Function player_set_cache_config 

Source
pub async fn player_set_cache_config(
+    smart_cache: State<'_, SmartCacheWrapper>,
+    config: CacheConfig,
+) -> Result<(), String>
Expand description

Update SmartCache configuration

+
\ No newline at end of file diff --git a/api/jellytau_lib/commands/player/fn.player_set_stream_quality.html b/api/jellytau_lib/commands/player/fn.player_set_stream_quality.html new file mode 100644 index 00000000..7a5a64e5 --- /dev/null +++ b/api/jellytau_lib/commands/player/fn.player_set_stream_quality.html @@ -0,0 +1,24 @@ +player_set_stream_quality in jellytau_lib::commands::player - RustSkip to main content

player_set_stream_quality

Function player_set_stream_quality 

Source
pub async fn player_set_stream_quality(
+    player: State<'_, PlayerStateWrapper>,
+    repository_manager: State<'_, RepositoryManagerWrapper>,
+    video_settings: State<'_, VideoSettingsWrapper>,
+    repository_handle: String,
+    quality: StreamingQuality,
+    use_html5: bool,
+    current_position: Option<f64>,
+    media_source_id: Option<String>,
+    audio_stream_index: Option<i32>,
+) -> Result<StreamQualityResponse, String>
Expand description

Change the bandwidth ceiling of the video that is playing right now.

+

A cap is a property of the stream the server is producing, so unlike a volume +change it cannot be applied to a stream already in flight — the stream has to +be re-opened at the new quality and resumed at the current position. That is +the same reload the transcoded-seek and audio-track paths use, and the same +two-sided split: HTML5 gets the URL back and reloads its own element, while a +native backend is reloaded here.

+

The change applies to this playback and to everything started afterwards +(it sets the process-wide ceiling), but it is deliberately not persisted: +the in-player picker is a “this film, this connection” control, and the +durable default belongs to Settings. player_set_video_settings is the one +that writes to the database.

+

TRACES: UR-074 | DR-162

+
\ No newline at end of file diff --git a/api/jellytau_lib/commands/player/fn.player_set_subtitle_track.html b/api/jellytau_lib/commands/player/fn.player_set_subtitle_track.html new file mode 100644 index 00000000..ec941cb9 --- /dev/null +++ b/api/jellytau_lib/commands/player/fn.player_set_subtitle_track.html @@ -0,0 +1,10 @@ +player_set_subtitle_track in jellytau_lib::commands::player - RustSkip to main content

player_set_subtitle_track

Function player_set_subtitle_track 

Source
pub async fn player_set_subtitle_track(
+    player: State<'_, PlayerStateWrapper>,
+    stream_index: Option<i32>,
+) -> Result<PlayerStatus, String>
Expand description

Set (or clear, with None) the active subtitle track on a native backend.

+

On Android this indexes ExoPlayer’s text track groups — i.e. the position +of the sideloaded MediaItem.SubtitleConfiguration, not the Jellyfin stream +index. The HTML5 path never reaches here; it toggles its own <track> +children. libmpv implements neither, leaving the trait default in place.

+

TRACES: UR-020 | IR-018, DR-023

+
\ No newline at end of file diff --git a/api/jellytau_lib/commands/player/fn.player_set_volume.html b/api/jellytau_lib/commands/player/fn.player_set_volume.html new file mode 100644 index 00000000..e71d589a --- /dev/null +++ b/api/jellytau_lib/commands/player/fn.player_set_volume.html @@ -0,0 +1,5 @@ +player_set_volume in jellytau_lib::commands::player - RustSkip to main content

player_set_volume

Function player_set_volume 

Source
pub async fn player_set_volume(
+    player: State<'_, PlayerStateWrapper>,
+    playback_mode: State<'_, PlaybackModeManagerWrapper>,
+    volume: f32,
+) -> Result<PlayerStatus, String>
\ No newline at end of file diff --git a/api/jellytau_lib/commands/player/fn.player_stop.html b/api/jellytau_lib/commands/player/fn.player_stop.html new file mode 100644 index 00000000..f735675a --- /dev/null +++ b/api/jellytau_lib/commands/player/fn.player_stop.html @@ -0,0 +1,5 @@ +player_stop in jellytau_lib::commands::player - RustSkip to main content

player_stop

Function player_stop 

Source
pub async fn player_stop(
+    player: State<'_, PlayerStateWrapper>,
+    session: State<'_, MediaSessionManagerWrapper>,
+    playback_mode: State<'_, PlaybackModeManagerWrapper>,
+) -> Result<PlayerStatus, String>
\ No newline at end of file diff --git a/api/jellytau_lib/commands/player/fn.player_switch_audio_track.html b/api/jellytau_lib/commands/player/fn.player_switch_audio_track.html new file mode 100644 index 00000000..81d041cb --- /dev/null +++ b/api/jellytau_lib/commands/player/fn.player_switch_audio_track.html @@ -0,0 +1,20 @@ +player_switch_audio_track in jellytau_lib::commands::player - RustSkip to main content

player_switch_audio_track

Function player_switch_audio_track 

Source
pub async fn player_switch_audio_track(
+    player: State<'_, PlayerStateWrapper>,
+    repository_manager: State<'_, RepositoryManagerWrapper>,
+    repository_handle: String,
+    stream_index: i32,
+    array_index: i32,
+    use_html5: bool,
+    current_position: Option<f64>,
+    media_source_id: Option<String>,
+) -> Result<AudioTrackSwitchResponse, String>
Expand description

Switch audio track - handles both HTML5 (stream reload) and native (direct switch) +Note: Frontend should handle saving series preferences after this command succeeds

+

The split is the requirement: an HTML5 <video> element cannot be told to +change audio track, so the stream is re-opened at the chosen +AudioStreamIndex and the frontend seeks the reloaded element back to +position; a native backend (ExoPlayer) switches in place by track-group +index. libmpv implements neither — it is the audio-only backend here and +leaves PlayerBackend::set_audio_track at its not_implemented() default, +which is why IR-019 is met by these two paths rather than by MPV.

+

TRACES: UR-021 | IR-019, DR-024

+
\ No newline at end of file diff --git a/api/jellytau_lib/commands/player/fn.player_toggle.html b/api/jellytau_lib/commands/player/fn.player_toggle.html new file mode 100644 index 00000000..57630435 --- /dev/null +++ b/api/jellytau_lib/commands/player/fn.player_toggle.html @@ -0,0 +1,4 @@ +player_toggle in jellytau_lib::commands::player - RustSkip to main content

player_toggle

Function player_toggle 

Source
pub async fn player_toggle(
+    player: State<'_, PlayerStateWrapper>,
+    playback_mode: State<'_, PlaybackModeManagerWrapper>,
+) -> Result<PlayerStatus, String>
\ No newline at end of file diff --git a/api/jellytau_lib/commands/player/fn.player_toggle_mute.html b/api/jellytau_lib/commands/player/fn.player_toggle_mute.html new file mode 100644 index 00000000..0942280f --- /dev/null +++ b/api/jellytau_lib/commands/player/fn.player_toggle_mute.html @@ -0,0 +1,4 @@ +player_toggle_mute in jellytau_lib::commands::player - RustSkip to main content

player_toggle_mute

Function player_toggle_mute 

Source
pub async fn player_toggle_mute(
+    player: State<'_, PlayerStateWrapper>,
+    playback_mode: State<'_, PlaybackModeManagerWrapper>,
+) -> Result<PlayerStatus, String>
\ No newline at end of file diff --git a/api/jellytau_lib/commands/player/fn.player_toggle_shuffle.html b/api/jellytau_lib/commands/player/fn.player_toggle_shuffle.html new file mode 100644 index 00000000..e852ce4d --- /dev/null +++ b/api/jellytau_lib/commands/player/fn.player_toggle_shuffle.html @@ -0,0 +1,3 @@ +player_toggle_shuffle in jellytau_lib::commands::player - RustSkip to main content

player_toggle_shuffle

Function player_toggle_shuffle 

Source
pub async fn player_toggle_shuffle(
+    player: State<'_, PlayerStateWrapper>,
+) -> Result<QueueStatus, String>
\ No newline at end of file diff --git a/api/jellytau_lib/commands/player/fn.refresh_queue_local_sources.html b/api/jellytau_lib/commands/player/fn.refresh_queue_local_sources.html new file mode 100644 index 00000000..380784d2 --- /dev/null +++ b/api/jellytau_lib/commands/player/fn.refresh_queue_local_sources.html @@ -0,0 +1,10 @@ +refresh_queue_local_sources in jellytau_lib::commands::player - RustSkip to main content

refresh_queue_local_sources

Function refresh_queue_local_sources 

Source
pub(super) async fn refresh_queue_local_sources(
+    controller: &PlayerController,
+    db: &DatabaseWrapper,
+) -> Result<usize, String>
Expand description

Re-point queued streaming items at completed local downloads.

+

Sources are resolved once when the queue is built, so downloads that finish +while it plays (preloaded upcoming tracks) — or that existed before the +connection dropped — would otherwise keep streaming. Called before advancing +so the next track always prefers the on-disk copy.

+

Returns the number of items switched to a local source.

+
\ No newline at end of file diff --git a/api/jellytau_lib/commands/player/fn.resolve_local_media_path.html b/api/jellytau_lib/commands/player/fn.resolve_local_media_path.html new file mode 100644 index 00000000..ba1f0263 --- /dev/null +++ b/api/jellytau_lib/commands/player/fn.resolve_local_media_path.html @@ -0,0 +1,12 @@ +resolve_local_media_path in jellytau_lib::commands::player - RustSkip to main content

resolve_local_media_path

Function resolve_local_media_path 

Source
pub(super) async fn resolve_local_media_path<S: DatabaseService>(
+    db_service: &Arc<S>,
+    item_id: &str,
+) -> Result<Option<String>, String>
Expand description

Resolve the on-disk file backing a completed download, if there is one.

+

A downloads row is not proof of a file: it can outlive the bytes (manual +deletion, a cleared cache directory, a restored database). Every caller wants +“can I play this from disk right now”, so existence is checked here rather +than trusted from the row.

+

Split out from check_for_local_download so the resolution is testable +without a DatabaseWrapper, and reusable by the video path.

+

TRACES: UR-071 | DR-123 | UT-116

+
\ No newline at end of file diff --git a/api/jellytau_lib/commands/player/fn.sanitize_filename.html b/api/jellytau_lib/commands/player/fn.sanitize_filename.html new file mode 100644 index 00000000..07957133 --- /dev/null +++ b/api/jellytau_lib/commands/player/fn.sanitize_filename.html @@ -0,0 +1,2 @@ +sanitize_filename in jellytau_lib::commands::player - RustSkip to main content

sanitize_filename

Function sanitize_filename 

Source
fn sanitize_filename(name: &str) -> String
Expand description

Sanitize filename by removing invalid characters

+
\ No newline at end of file diff --git a/api/jellytau_lib/commands/player/index.html b/api/jellytau_lib/commands/player/index.html new file mode 100644 index 00000000..da12849d --- /dev/null +++ b/api/jellytau_lib/commands/player/index.html @@ -0,0 +1,11 @@ +jellytau_lib::commands::player - RustSkip to main content

Module player

Module player 

Source
Expand description

TRACES: UR-003, UR-004, UR-005, UR-010, UR-020, UR-021 | JA-022, JA-023, JA-024, JA-025, JA-026 | DR-001

+

Re-exports§

pub use queue::*;
pub use remote::*;
pub use session::*;
pub use settings::*;
pub use timers::*;

Modules§

queue 🔒
Queue manipulation commands (add / remove / move / skip).
remote 🔒
Remote Jellyfin session control commands (casting to another device).
session 🔒
Media session state commands.
settings 🔒
Audio and video playback settings commands.
timers 🔒
Sleep-timer and autoplay commands.

Structs§

BackgroundAudioPlan 🔒
How a background-audio handoff must start playback, given where its audio +actually begins.
MediaSessionManagerWrapper
Media session manager wrapper for Tauri state management
MergedMediaItem
Lightweight media item for merged playback state +Converts from both local MediaItem and remote NowPlayingItem
PlayAlbumTrackRequest
Request to play a track from an album (backend fetches all tracks)
PlayItemRequest
Request to play a single video item
PlayQueueRequest
Request to play a queue of items
PlayTracksRequest
Request to play tracks by ID (backend fetches metadata)
PlaybackCapabilities
What playback facilities this platform’s backend actually provides.
PlayerStateWrapper
Player state wrapper for Tauri.
PlayerStatus
Response for player state queries
PreloadResult
Response for preload operation
QueueStatus
Response for queue queries
SmartCacheWrapper
SmartCache wrapper for Tauri state management
VideoSettingsWrapper
Video settings state wrapper for Tauri

Enums§

AudioTrackSwitchResponse
Response for audio track switching operations
PlayQueueContext
Queue context for remote transfer - what type of queue is this?
PlayTracksContext
Context information for track playback
StreamQualityResponse
Response for a mid-playback streaming-quality change.
VideoBackend
Backend type for video playback
VideoSeekResponse
Response for video seek operations

Functions§

background_audio_plan 🔒
Decide the base and the seek for a handoff at position_seconds.
background_audio_source 🔒
Pick the source for an audio-only handoff.
check_for_local_download 🔒
Check if an item has a completed download
create_media_item 🔒
Helper function to create MediaItem from video request
get_player_status 🔒
get_queue_status 🔒
normalize_volume 🔒
Normalise a volume arriving over IPC to the 0.0..=1.0 range every backend +works in.
play_selection_on_remote 🔒
Start a freshly-built queue on the active remote session.
player_background_action
Exit background-audio mode: stop the native audio player and return its final +position so the frontend can reload the WebView <video> there (UR-040).
player_configure_jellyfin
Configure Jellyfin API client for automatic playback reporting
player_cycle_repeat
player_disable_jellyfin
Disable Jellyfin automatic playback reporting
player_enter_background_audio
Enter background-audio mode: hand playback of the currently-watched video off +to the native ExoPlayer audio path so the audio keeps playing while the app +is backgrounded/locked, with no client-side video decode (UR-040).
player_exit_background_audio
TRACES: UR-040 | DR-052 | UT-061, IT-013
player_get_cache_config
Get current SmartCache configuration
player_get_capabilities
Report this platform’s playback capabilities to the frontend.
player_get_queue
player_get_status
player_local_media_path
The on-disk path for a downloaded item, for playback surfaces that resolve +their own source rather than going through the queue.
player_next
player_pause
player_play
player_play_album_track
Play a track from an album - backend fetches all album tracks and builds queue
player_play_item
Play a single media item (audio or video)
player_play_queue
Play a queue of media items
player_play_tracks
Play tracks by ID - backend fetches all metadata
player_preload_upcoming
Preload upcoming tracks from the queue +This queues background downloads for the next N tracks that aren’t already downloaded
player_previous
player_seek
player_seek_video
Smart video seeking that decides between native and server-side seeking
player_set_audio_track
Set the active audio track on a native backend directly.
player_set_cache_config
Update SmartCache configuration
player_set_stream_quality
Change the bandwidth ceiling of the video that is playing right now.
player_set_subtitle_track
Set (or clear, with None) the active subtitle track on a native backend.
player_set_volume
player_stop
player_switch_audio_track
Switch audio track - handles both HTML5 (stream reload) and native (direct switch) +Note: Frontend should handle saving series preferences after this command succeeds
player_toggle
player_toggle_mute
player_toggle_shuffle
refresh_queue_local_sources 🔒
Re-point queued streaming items at completed local downloads.
resolve_local_media_path 🔒
Resolve the on-disk file backing a completed download, if there is one.
sanitize_filename 🔒
Sanitize filename by removing invalid characters
\ No newline at end of file diff --git a/api/jellytau_lib/commands/player/queue/fn.player_add_to_queue.html b/api/jellytau_lib/commands/player/queue/fn.player_add_to_queue.html new file mode 100644 index 00000000..e5055cba --- /dev/null +++ b/api/jellytau_lib/commands/player/queue/fn.player_add_to_queue.html @@ -0,0 +1,5 @@ +player_add_to_queue in jellytau_lib::commands::player::queue - RustSkip to main content

player_add_to_queue

Function player_add_to_queue 

Source
pub async fn player_add_to_queue(
+    player: State<'_, PlayerStateWrapper>,
+    db: State<'_, DatabaseWrapper>,
+    request: AddToQueueRequest,
+) -> Result<QueueStatus, String>
\ No newline at end of file diff --git a/api/jellytau_lib/commands/player/queue/fn.player_add_track_by_id.html b/api/jellytau_lib/commands/player/queue/fn.player_add_track_by_id.html new file mode 100644 index 00000000..ddffdd32 --- /dev/null +++ b/api/jellytau_lib/commands/player/queue/fn.player_add_track_by_id.html @@ -0,0 +1,8 @@ +player_add_track_by_id in jellytau_lib::commands::player::queue - RustSkip to main content

player_add_track_by_id

Function player_add_track_by_id 

Source
pub async fn player_add_track_by_id(
+    player: State<'_, PlayerStateWrapper>,
+    db: State<'_, DatabaseWrapper>,
+    repository_manager: State<'_, RepositoryManagerWrapper>,
+    repository_handle: String,
+    request: AddTrackByIdRequest,
+) -> Result<QueueStatus, String>
Expand description

Add a track to queue by ID - backend fetches metadata and constructs URLs

+
\ No newline at end of file diff --git a/api/jellytau_lib/commands/player/queue/fn.player_add_tracks_by_ids.html b/api/jellytau_lib/commands/player/queue/fn.player_add_tracks_by_ids.html new file mode 100644 index 00000000..90084a0d --- /dev/null +++ b/api/jellytau_lib/commands/player/queue/fn.player_add_tracks_by_ids.html @@ -0,0 +1,8 @@ +player_add_tracks_by_ids in jellytau_lib::commands::player::queue - RustSkip to main content

player_add_tracks_by_ids

Function player_add_tracks_by_ids 

Source
pub async fn player_add_tracks_by_ids(
+    player: State<'_, PlayerStateWrapper>,
+    db: State<'_, DatabaseWrapper>,
+    repository_manager: State<'_, RepositoryManagerWrapper>,
+    repository_handle: String,
+    request: AddTracksByIdsRequest,
+) -> Result<QueueStatus, String>
Expand description

Add multiple tracks to queue by IDs - backend fetches metadata and constructs URLs

+
\ No newline at end of file diff --git a/api/jellytau_lib/commands/player/queue/fn.player_move_in_queue.html b/api/jellytau_lib/commands/player/queue/fn.player_move_in_queue.html new file mode 100644 index 00000000..3f35c342 --- /dev/null +++ b/api/jellytau_lib/commands/player/queue/fn.player_move_in_queue.html @@ -0,0 +1,5 @@ +player_move_in_queue in jellytau_lib::commands::player::queue - RustSkip to main content

player_move_in_queue

Function player_move_in_queue 

Source
pub async fn player_move_in_queue(
+    player: State<'_, PlayerStateWrapper>,
+    from_index: usize,
+    to_index: usize,
+) -> Result<QueueStatus, String>
\ No newline at end of file diff --git a/api/jellytau_lib/commands/player/queue/fn.player_remove_from_queue.html b/api/jellytau_lib/commands/player/queue/fn.player_remove_from_queue.html new file mode 100644 index 00000000..75d2bffc --- /dev/null +++ b/api/jellytau_lib/commands/player/queue/fn.player_remove_from_queue.html @@ -0,0 +1,4 @@ +player_remove_from_queue in jellytau_lib::commands::player::queue - RustSkip to main content

player_remove_from_queue

Function player_remove_from_queue 

Source
pub async fn player_remove_from_queue(
+    player: State<'_, PlayerStateWrapper>,
+    index: usize,
+) -> Result<QueueStatus, String>
\ No newline at end of file diff --git a/api/jellytau_lib/commands/player/queue/fn.player_skip_to.html b/api/jellytau_lib/commands/player/queue/fn.player_skip_to.html new file mode 100644 index 00000000..a9de622f --- /dev/null +++ b/api/jellytau_lib/commands/player/queue/fn.player_skip_to.html @@ -0,0 +1,5 @@ +player_skip_to in jellytau_lib::commands::player::queue - RustSkip to main content

player_skip_to

Function player_skip_to 

Source
pub async fn player_skip_to(
+    player: State<'_, PlayerStateWrapper>,
+    db: State<'_, DatabaseWrapper>,
+    index: usize,
+) -> Result<PlayerStatus, String>
\ No newline at end of file diff --git a/api/jellytau_lib/commands/player/queue/index.html b/api/jellytau_lib/commands/player/queue/index.html new file mode 100644 index 00000000..4373e102 --- /dev/null +++ b/api/jellytau_lib/commands/player/queue/index.html @@ -0,0 +1,3 @@ +jellytau_lib::commands::player::queue - RustSkip to main content

Module queue

Module queue 

Source
Expand description

Queue manipulation commands (add / remove / move / skip).

+

TRACES: UR-015 | DR-005, DR-020

+

Structs§

AddToQueueRequest
Request to add items to queue
AddTrackByIdRequest
Request to add a track by ID - backend fetches metadata
AddTracksByIdsRequest
Request to add multiple tracks by IDs - backend fetches metadata

Functions§

player_add_to_queue
player_add_track_by_id
Add a track to queue by ID - backend fetches metadata and constructs URLs
player_add_tracks_by_ids
Add multiple tracks to queue by IDs - backend fetches metadata and constructs URLs
player_move_in_queue
player_remove_from_queue
player_skip_to
\ No newline at end of file diff --git a/api/jellytau_lib/commands/player/queue/sidebar-items.js b/api/jellytau_lib/commands/player/queue/sidebar-items.js new file mode 100644 index 00000000..fed1bec9 --- /dev/null +++ b/api/jellytau_lib/commands/player/queue/sidebar-items.js @@ -0,0 +1 @@ +window.SIDEBAR_ITEMS = {"fn":["player_add_to_queue","player_add_track_by_id","player_add_tracks_by_ids","player_move_in_queue","player_remove_from_queue","player_skip_to"],"struct":["AddToQueueRequest","AddTrackByIdRequest","AddTracksByIdsRequest"]}; \ No newline at end of file diff --git a/api/jellytau_lib/commands/player/queue/struct.AddToQueueRequest.html b/api/jellytau_lib/commands/player/queue/struct.AddToQueueRequest.html new file mode 100644 index 00000000..6e36bdf6 --- /dev/null +++ b/api/jellytau_lib/commands/player/queue/struct.AddToQueueRequest.html @@ -0,0 +1,44 @@ +AddToQueueRequest in jellytau_lib::commands::player::queue - RustSkip to main content

AddToQueueRequest

Struct AddToQueueRequest 

Source
pub struct AddToQueueRequest {
+    pub items: Vec<PlayItemRequest>,
+    pub position: String,
+}
Expand description

Request to add items to queue

+

Fields§

§items: Vec<PlayItemRequest>§position: String

Trait Implementations§

Source§

impl Debug for AddToQueueRequest

Source§

fn fmt(&self, f: &mut Formatter<'_>) -> Result

Formats the value using the given formatter. Read more
Source§

impl<'de> Deserialize<'de> for AddToQueueRequest

Source§

fn deserialize<__D>(__deserializer: __D) -> Result<Self, __D::Error>
where + __D: Deserializer<'de>,

Deserialize this value from the given Serde deserializer. Read more
Source§

impl NamedType for AddToQueueRequest

Source§

fn sid() -> SpectaID

Source§

fn named_data_type( + type_map: &mut TypeCollection, + generics: &[DataType], +) -> NamedDataType

this is equivalent to [Type::inline] but returns a [NamedDataType] instead.
Source§

fn definition_named_data_type(type_map: &mut TypeCollection) -> NamedDataType

this is equivalent to [Type::definition] but returns a [NamedDataType] instead.
Source§

impl Type for AddToQueueRequest

Source§

fn inline(type_map: &mut TypeCollection, generics: Generics<'_>) -> DataType

Returns the definition of a type using the provided generics. Read more
Source§

fn reference(type_map: &mut TypeCollection, generics: &[DataType]) -> Reference

Generates a datatype corresponding to a reference to this type, +as determined by its category. Getting a reference to a type implies that +it should belong in the type map (since it has to be referenced from somewhere), +so the output of definition will be put into the type map.
Source§

impl Flatten for AddToQueueRequest

Auto Trait Implementations§

Blanket Implementations§

Source§

impl<T> Any for T
where + T: 'static + ?Sized,

Source§

fn type_id(&self) -> TypeId

Gets the TypeId of self. Read more
Source§

impl<T> Borrow<T> for T
where + T: ?Sized,

Source§

fn borrow(&self) -> &T

Immutably borrows from an owned value. Read more
Source§

impl<T> BorrowMut<T> for T
where + T: ?Sized,

Source§

fn borrow_mut(&mut self) -> &mut T

Mutably borrows from an owned value. Read more
§

impl<'de, D, R> CommandArg<'de, R> for D
where + D: Deserialize<'de>, + R: Runtime,

§

fn from_command(command: CommandItem<'de, R>) -> Result<D, InvokeError>

Derives an instance of Self from the [CommandItem]. Read more
Source§

impl<T> From<T> for T

Source§

fn from(t: T) -> T

Returns the argument unchanged.

+
§

impl<T> FunctionArg for T
where + T: Type,

§

fn to_datatype(type_map: &mut TypeCollection) -> Option<DataType>

Gets the type of an argument as a [DataType]. Read more
§

impl<T> Instrument for T

§

fn instrument(self, span: Span) -> Instrumented<Self>

Instruments this type with the provided [Span], returning an +Instrumented wrapper. Read more
§

fn in_current_span(self) -> Instrumented<Self>

Instruments this type with the current Span, returning an +Instrumented wrapper. Read more
Source§

impl<T, U> Into<U> for T
where + U: From<T>,

Source§

fn into(self) -> U

Calls U::from(self).

+

That is, this conversion is whatever the implementation of +From<T> for U chooses to do.

+
§

impl<T> PolicyExt for T
where + T: ?Sized,

§

fn and<P, B, E>(self, other: P) -> And<T, P>
where + T: Policy<B, E>, + P: Policy<B, E>,

Create a new Policy that returns [Action::Follow] only if self and other return +Action::Follow. Read more
§

fn or<P, B, E>(self, other: P) -> Or<T, P>
where + T: Policy<B, E>, + P: Policy<B, E>,

Create a new Policy that returns [Action::Follow] if either self or other returns +Action::Follow. Read more
Source§

impl<T> Same for T

Source§

type Output = T

Should always be Self
§

impl<T> ScopeObject for T
where + T: Send + Sync + Debug + DeserializeOwned + 'static,

§

type Error = Error

The error type.
§

fn deserialize<R>( + _app: &AppHandle<R>, + raw: Value, +) -> Result<T, <T as ScopeObject>::Error>
where + R: Runtime,

Deserialize the raw scope value.
Source§

impl<T, U> TryFrom<U> for T
where + U: Into<T>,

Source§

type Error = Infallible

The type returned in the event of a conversion error.
Source§

fn try_from(value: U) -> Result<T, <T as TryFrom<U>>::Error>

Performs the conversion.
Source§

impl<T, U> TryInto<U> for T
where + U: TryFrom<T>,

Source§

type Error = <U as TryFrom<T>>::Error

The type returned in the event of a conversion error.
Source§

fn try_into(self) -> Result<U, <U as TryFrom<T>>::Error>

Performs the conversion.
§

impl<V, T> VZip<V> for T
where + V: MultiLane<T>,

§

fn vzip(self) -> V

§

impl<T> WithSubscriber for T

§

fn with_subscriber<S>(self, subscriber: S) -> WithDispatch<Self>
where + S: Into<Dispatch>,

Attaches the provided Subscriber to this type, returning a +[WithDispatch] wrapper. Read more
§

fn with_current_subscriber(self) -> WithDispatch<Self>

Attaches the current default Subscriber to this type, returning a +[WithDispatch] wrapper. Read more
Source§

impl<T> DeserializeOwned for T
where + T: for<'de> Deserialize<'de>,

\ No newline at end of file diff --git a/api/jellytau_lib/commands/player/queue/struct.AddTrackByIdRequest.html b/api/jellytau_lib/commands/player/queue/struct.AddTrackByIdRequest.html new file mode 100644 index 00000000..025177f5 --- /dev/null +++ b/api/jellytau_lib/commands/player/queue/struct.AddTrackByIdRequest.html @@ -0,0 +1,44 @@ +AddTrackByIdRequest in jellytau_lib::commands::player::queue - RustSkip to main content

AddTrackByIdRequest

Struct AddTrackByIdRequest 

Source
pub struct AddTrackByIdRequest {
+    pub track_id: String,
+    pub position: String,
+}
Expand description

Request to add a track by ID - backend fetches metadata

+

Fields§

§track_id: String§position: String

Trait Implementations§

Source§

impl Debug for AddTrackByIdRequest

Source§

fn fmt(&self, f: &mut Formatter<'_>) -> Result

Formats the value using the given formatter. Read more
Source§

impl<'de> Deserialize<'de> for AddTrackByIdRequest

Source§

fn deserialize<__D>(__deserializer: __D) -> Result<Self, __D::Error>
where + __D: Deserializer<'de>,

Deserialize this value from the given Serde deserializer. Read more
Source§

impl NamedType for AddTrackByIdRequest

Source§

fn sid() -> SpectaID

Source§

fn named_data_type( + type_map: &mut TypeCollection, + generics: &[DataType], +) -> NamedDataType

this is equivalent to [Type::inline] but returns a [NamedDataType] instead.
Source§

fn definition_named_data_type(type_map: &mut TypeCollection) -> NamedDataType

this is equivalent to [Type::definition] but returns a [NamedDataType] instead.
Source§

impl Type for AddTrackByIdRequest

Source§

fn inline(type_map: &mut TypeCollection, generics: Generics<'_>) -> DataType

Returns the definition of a type using the provided generics. Read more
Source§

fn reference(type_map: &mut TypeCollection, generics: &[DataType]) -> Reference

Generates a datatype corresponding to a reference to this type, +as determined by its category. Getting a reference to a type implies that +it should belong in the type map (since it has to be referenced from somewhere), +so the output of definition will be put into the type map.
Source§

impl Flatten for AddTrackByIdRequest

Auto Trait Implementations§

Blanket Implementations§

Source§

impl<T> Any for T
where + T: 'static + ?Sized,

Source§

fn type_id(&self) -> TypeId

Gets the TypeId of self. Read more
Source§

impl<T> Borrow<T> for T
where + T: ?Sized,

Source§

fn borrow(&self) -> &T

Immutably borrows from an owned value. Read more
Source§

impl<T> BorrowMut<T> for T
where + T: ?Sized,

Source§

fn borrow_mut(&mut self) -> &mut T

Mutably borrows from an owned value. Read more
§

impl<'de, D, R> CommandArg<'de, R> for D
where + D: Deserialize<'de>, + R: Runtime,

§

fn from_command(command: CommandItem<'de, R>) -> Result<D, InvokeError>

Derives an instance of Self from the [CommandItem]. Read more
Source§

impl<T> From<T> for T

Source§

fn from(t: T) -> T

Returns the argument unchanged.

+
§

impl<T> FunctionArg for T
where + T: Type,

§

fn to_datatype(type_map: &mut TypeCollection) -> Option<DataType>

Gets the type of an argument as a [DataType]. Read more
§

impl<T> Instrument for T

§

fn instrument(self, span: Span) -> Instrumented<Self>

Instruments this type with the provided [Span], returning an +Instrumented wrapper. Read more
§

fn in_current_span(self) -> Instrumented<Self>

Instruments this type with the current Span, returning an +Instrumented wrapper. Read more
Source§

impl<T, U> Into<U> for T
where + U: From<T>,

Source§

fn into(self) -> U

Calls U::from(self).

+

That is, this conversion is whatever the implementation of +From<T> for U chooses to do.

+
§

impl<T> PolicyExt for T
where + T: ?Sized,

§

fn and<P, B, E>(self, other: P) -> And<T, P>
where + T: Policy<B, E>, + P: Policy<B, E>,

Create a new Policy that returns [Action::Follow] only if self and other return +Action::Follow. Read more
§

fn or<P, B, E>(self, other: P) -> Or<T, P>
where + T: Policy<B, E>, + P: Policy<B, E>,

Create a new Policy that returns [Action::Follow] if either self or other returns +Action::Follow. Read more
Source§

impl<T> Same for T

Source§

type Output = T

Should always be Self
§

impl<T> ScopeObject for T
where + T: Send + Sync + Debug + DeserializeOwned + 'static,

§

type Error = Error

The error type.
§

fn deserialize<R>( + _app: &AppHandle<R>, + raw: Value, +) -> Result<T, <T as ScopeObject>::Error>
where + R: Runtime,

Deserialize the raw scope value.
Source§

impl<T, U> TryFrom<U> for T
where + U: Into<T>,

Source§

type Error = Infallible

The type returned in the event of a conversion error.
Source§

fn try_from(value: U) -> Result<T, <T as TryFrom<U>>::Error>

Performs the conversion.
Source§

impl<T, U> TryInto<U> for T
where + U: TryFrom<T>,

Source§

type Error = <U as TryFrom<T>>::Error

The type returned in the event of a conversion error.
Source§

fn try_into(self) -> Result<U, <U as TryFrom<T>>::Error>

Performs the conversion.
§

impl<V, T> VZip<V> for T
where + V: MultiLane<T>,

§

fn vzip(self) -> V

§

impl<T> WithSubscriber for T

§

fn with_subscriber<S>(self, subscriber: S) -> WithDispatch<Self>
where + S: Into<Dispatch>,

Attaches the provided Subscriber to this type, returning a +[WithDispatch] wrapper. Read more
§

fn with_current_subscriber(self) -> WithDispatch<Self>

Attaches the current default Subscriber to this type, returning a +[WithDispatch] wrapper. Read more
Source§

impl<T> DeserializeOwned for T
where + T: for<'de> Deserialize<'de>,

\ No newline at end of file diff --git a/api/jellytau_lib/commands/player/queue/struct.AddTracksByIdsRequest.html b/api/jellytau_lib/commands/player/queue/struct.AddTracksByIdsRequest.html new file mode 100644 index 00000000..dd300276 --- /dev/null +++ b/api/jellytau_lib/commands/player/queue/struct.AddTracksByIdsRequest.html @@ -0,0 +1,44 @@ +AddTracksByIdsRequest in jellytau_lib::commands::player::queue - RustSkip to main content

AddTracksByIdsRequest

Struct AddTracksByIdsRequest 

Source
pub struct AddTracksByIdsRequest {
+    pub track_ids: Vec<String>,
+    pub position: String,
+}
Expand description

Request to add multiple tracks by IDs - backend fetches metadata

+

Fields§

§track_ids: Vec<String>§position: String

Trait Implementations§

Source§

impl Debug for AddTracksByIdsRequest

Source§

fn fmt(&self, f: &mut Formatter<'_>) -> Result

Formats the value using the given formatter. Read more
Source§

impl<'de> Deserialize<'de> for AddTracksByIdsRequest

Source§

fn deserialize<__D>(__deserializer: __D) -> Result<Self, __D::Error>
where + __D: Deserializer<'de>,

Deserialize this value from the given Serde deserializer. Read more
Source§

impl NamedType for AddTracksByIdsRequest

Source§

fn sid() -> SpectaID

Source§

fn named_data_type( + type_map: &mut TypeCollection, + generics: &[DataType], +) -> NamedDataType

this is equivalent to [Type::inline] but returns a [NamedDataType] instead.
Source§

fn definition_named_data_type(type_map: &mut TypeCollection) -> NamedDataType

this is equivalent to [Type::definition] but returns a [NamedDataType] instead.
Source§

impl Type for AddTracksByIdsRequest

Source§

fn inline(type_map: &mut TypeCollection, generics: Generics<'_>) -> DataType

Returns the definition of a type using the provided generics. Read more
Source§

fn reference(type_map: &mut TypeCollection, generics: &[DataType]) -> Reference

Generates a datatype corresponding to a reference to this type, +as determined by its category. Getting a reference to a type implies that +it should belong in the type map (since it has to be referenced from somewhere), +so the output of definition will be put into the type map.
Source§

impl Flatten for AddTracksByIdsRequest

Auto Trait Implementations§

Blanket Implementations§

Source§

impl<T> Any for T
where + T: 'static + ?Sized,

Source§

fn type_id(&self) -> TypeId

Gets the TypeId of self. Read more
Source§

impl<T> Borrow<T> for T
where + T: ?Sized,

Source§

fn borrow(&self) -> &T

Immutably borrows from an owned value. Read more
Source§

impl<T> BorrowMut<T> for T
where + T: ?Sized,

Source§

fn borrow_mut(&mut self) -> &mut T

Mutably borrows from an owned value. Read more
§

impl<'de, D, R> CommandArg<'de, R> for D
where + D: Deserialize<'de>, + R: Runtime,

§

fn from_command(command: CommandItem<'de, R>) -> Result<D, InvokeError>

Derives an instance of Self from the [CommandItem]. Read more
Source§

impl<T> From<T> for T

Source§

fn from(t: T) -> T

Returns the argument unchanged.

+
§

impl<T> FunctionArg for T
where + T: Type,

§

fn to_datatype(type_map: &mut TypeCollection) -> Option<DataType>

Gets the type of an argument as a [DataType]. Read more
§

impl<T> Instrument for T

§

fn instrument(self, span: Span) -> Instrumented<Self>

Instruments this type with the provided [Span], returning an +Instrumented wrapper. Read more
§

fn in_current_span(self) -> Instrumented<Self>

Instruments this type with the current Span, returning an +Instrumented wrapper. Read more
Source§

impl<T, U> Into<U> for T
where + U: From<T>,

Source§

fn into(self) -> U

Calls U::from(self).

+

That is, this conversion is whatever the implementation of +From<T> for U chooses to do.

+
§

impl<T> PolicyExt for T
where + T: ?Sized,

§

fn and<P, B, E>(self, other: P) -> And<T, P>
where + T: Policy<B, E>, + P: Policy<B, E>,

Create a new Policy that returns [Action::Follow] only if self and other return +Action::Follow. Read more
§

fn or<P, B, E>(self, other: P) -> Or<T, P>
where + T: Policy<B, E>, + P: Policy<B, E>,

Create a new Policy that returns [Action::Follow] if either self or other returns +Action::Follow. Read more
Source§

impl<T> Same for T

Source§

type Output = T

Should always be Self
§

impl<T> ScopeObject for T
where + T: Send + Sync + Debug + DeserializeOwned + 'static,

§

type Error = Error

The error type.
§

fn deserialize<R>( + _app: &AppHandle<R>, + raw: Value, +) -> Result<T, <T as ScopeObject>::Error>
where + R: Runtime,

Deserialize the raw scope value.
Source§

impl<T, U> TryFrom<U> for T
where + U: Into<T>,

Source§

type Error = Infallible

The type returned in the event of a conversion error.
Source§

fn try_from(value: U) -> Result<T, <T as TryFrom<U>>::Error>

Performs the conversion.
Source§

impl<T, U> TryInto<U> for T
where + U: TryFrom<T>,

Source§

type Error = <U as TryFrom<T>>::Error

The type returned in the event of a conversion error.
Source§

fn try_into(self) -> Result<U, <U as TryFrom<T>>::Error>

Performs the conversion.
§

impl<V, T> VZip<V> for T
where + V: MultiLane<T>,

§

fn vzip(self) -> V

§

impl<T> WithSubscriber for T

§

fn with_subscriber<S>(self, subscriber: S) -> WithDispatch<Self>
where + S: Into<Dispatch>,

Attaches the provided Subscriber to this type, returning a +[WithDispatch] wrapper. Read more
§

fn with_current_subscriber(self) -> WithDispatch<Self>

Attaches the current default Subscriber to this type, returning a +[WithDispatch] wrapper. Read more
Source§

impl<T> DeserializeOwned for T
where + T: for<'de> Deserialize<'de>,

\ No newline at end of file diff --git a/api/jellytau_lib/commands/player/remote/fn.lms_create_sync_group.html b/api/jellytau_lib/commands/player/remote/fn.lms_create_sync_group.html new file mode 100644 index 00000000..f488a434 --- /dev/null +++ b/api/jellytau_lib/commands/player/remote/fn.lms_create_sync_group.html @@ -0,0 +1,7 @@ +lms_create_sync_group in jellytau_lib::commands::player::remote - RustSkip to main content

lms_create_sync_group

Function lms_create_sync_group 

Source
pub async fn lms_create_sync_group(
+    player: State<'_, PlayerStateWrapper>,
+    master_mac: String,
+    slave_macs: Vec<String>,
+) -> Result<(), String>
Expand description

Fuse LMS zones into a sync group. master_mac keeps playing and the +slave_macs zones join it in sync.

+
\ No newline at end of file diff --git a/api/jellytau_lib/commands/player/remote/fn.lms_dissolve_sync_group.html b/api/jellytau_lib/commands/player/remote/fn.lms_dissolve_sync_group.html new file mode 100644 index 00000000..047142b8 --- /dev/null +++ b/api/jellytau_lib/commands/player/remote/fn.lms_dissolve_sync_group.html @@ -0,0 +1,5 @@ +lms_dissolve_sync_group in jellytau_lib::commands::player::remote - RustSkip to main content

lms_dissolve_sync_group

Function lms_dissolve_sync_group 

Source
pub async fn lms_dissolve_sync_group(
+    player: State<'_, PlayerStateWrapper>,
+    master_mac: String,
+) -> Result<(), String>
Expand description

Dissolve an entire LMS sync group, identified by its master’s MAC.

+
\ No newline at end of file diff --git a/api/jellytau_lib/commands/player/remote/fn.lms_get_sync_groups.html b/api/jellytau_lib/commands/player/remote/fn.lms_get_sync_groups.html new file mode 100644 index 00000000..71052d50 --- /dev/null +++ b/api/jellytau_lib/commands/player/remote/fn.lms_get_sync_groups.html @@ -0,0 +1,4 @@ +lms_get_sync_groups in jellytau_lib::commands::player::remote - RustSkip to main content

lms_get_sync_groups

Function lms_get_sync_groups 

Source
pub async fn lms_get_sync_groups(
+    player: State<'_, PlayerStateWrapper>,
+) -> Result<Vec<LmsSyncGroup>, String>
Expand description

List current LMS sync groups.

+
\ No newline at end of file diff --git a/api/jellytau_lib/commands/player/remote/fn.lms_unsync_player.html b/api/jellytau_lib/commands/player/remote/fn.lms_unsync_player.html new file mode 100644 index 00000000..ad31ff5c --- /dev/null +++ b/api/jellytau_lib/commands/player/remote/fn.lms_unsync_player.html @@ -0,0 +1,5 @@ +lms_unsync_player in jellytau_lib::commands::player::remote - RustSkip to main content

lms_unsync_player

Function lms_unsync_player 

Source
pub async fn lms_unsync_player(
+    player: State<'_, PlayerStateWrapper>,
+    mac: String,
+) -> Result<(), String>
Expand description

Remove a single LMS zone from its sync group (decouple one player).

+
\ No newline at end of file diff --git a/api/jellytau_lib/commands/player/remote/fn.remote_play_on_session.html b/api/jellytau_lib/commands/player/remote/fn.remote_play_on_session.html new file mode 100644 index 00000000..6bce8ee0 --- /dev/null +++ b/api/jellytau_lib/commands/player/remote/fn.remote_play_on_session.html @@ -0,0 +1,7 @@ +remote_play_on_session in jellytau_lib::commands::player::remote - RustSkip to main content

remote_play_on_session

Function remote_play_on_session 

Source
pub async fn remote_play_on_session(
+    player: State<'_, PlayerStateWrapper>,
+    session_id: String,
+    item_ids: Vec<String>,
+    start_index: usize,
+) -> Result<(), String>
Expand description

Play items on a remote Jellyfin session (casting)

+
\ No newline at end of file diff --git a/api/jellytau_lib/commands/player/remote/fn.remote_send_command.html b/api/jellytau_lib/commands/player/remote/fn.remote_send_command.html new file mode 100644 index 00000000..d1a1cec5 --- /dev/null +++ b/api/jellytau_lib/commands/player/remote/fn.remote_send_command.html @@ -0,0 +1,6 @@ +remote_send_command in jellytau_lib::commands::player::remote - RustSkip to main content

remote_send_command

Function remote_send_command 

Source
pub async fn remote_send_command(
+    player: State<'_, PlayerStateWrapper>,
+    session_id: String,
+    command: String,
+) -> Result<(), String>
Expand description

Send a playback command to a remote session

+
\ No newline at end of file diff --git a/api/jellytau_lib/commands/player/remote/fn.remote_session_seek.html b/api/jellytau_lib/commands/player/remote/fn.remote_session_seek.html new file mode 100644 index 00000000..a46ce853 --- /dev/null +++ b/api/jellytau_lib/commands/player/remote/fn.remote_session_seek.html @@ -0,0 +1,6 @@ +remote_session_seek in jellytau_lib::commands::player::remote - RustSkip to main content

remote_session_seek

Function remote_session_seek 

Source
pub async fn remote_session_seek(
+    player: State<'_, PlayerStateWrapper>,
+    session_id: String,
+    position_ticks: i64,
+) -> Result<(), String>
Expand description

Seek on a remote session

+
\ No newline at end of file diff --git a/api/jellytau_lib/commands/player/remote/fn.remote_session_set_volume.html b/api/jellytau_lib/commands/player/remote/fn.remote_session_set_volume.html new file mode 100644 index 00000000..2b665632 --- /dev/null +++ b/api/jellytau_lib/commands/player/remote/fn.remote_session_set_volume.html @@ -0,0 +1,6 @@ +remote_session_set_volume in jellytau_lib::commands::player::remote - RustSkip to main content

remote_session_set_volume

Function remote_session_set_volume 

Source
pub async fn remote_session_set_volume(
+    player: State<'_, PlayerStateWrapper>,
+    session_id: String,
+    volume: i32,
+) -> Result<(), String>
Expand description

Set volume on a remote session

+
\ No newline at end of file diff --git a/api/jellytau_lib/commands/player/remote/fn.remote_session_toggle_mute.html b/api/jellytau_lib/commands/player/remote/fn.remote_session_toggle_mute.html new file mode 100644 index 00000000..22a65871 --- /dev/null +++ b/api/jellytau_lib/commands/player/remote/fn.remote_session_toggle_mute.html @@ -0,0 +1,5 @@ +remote_session_toggle_mute in jellytau_lib::commands::player::remote - RustSkip to main content

remote_session_toggle_mute

Function remote_session_toggle_mute 

Source
pub async fn remote_session_toggle_mute(
+    player: State<'_, PlayerStateWrapper>,
+    session_id: String,
+) -> Result<(), String>
Expand description

Toggle mute on a remote session

+
\ No newline at end of file diff --git a/api/jellytau_lib/commands/player/remote/index.html b/api/jellytau_lib/commands/player/remote/index.html new file mode 100644 index 00000000..09f7ee1b --- /dev/null +++ b/api/jellytau_lib/commands/player/remote/index.html @@ -0,0 +1,6 @@ +jellytau_lib::commands::player::remote - RustSkip to main content

Module remote

Module remote 

Source
Expand description

Remote Jellyfin session control commands (casting to another device).

+

TRACES: UR-010, UR-046 | IR-012, IR-028, JA-022, JA-023, JA-025, JA-026 | DR-037, DR-058

+

These thin command adapters forward control actions to the active Jellyfin +session via the player’s configured JellyfinClient.

+

Functions§

lms_create_sync_group
Fuse LMS zones into a sync group. master_mac keeps playing and the +slave_macs zones join it in sync.
lms_dissolve_sync_group
Dissolve an entire LMS sync group, identified by its master’s MAC.
lms_get_sync_groups
List current LMS sync groups.
lms_unsync_player
Remove a single LMS zone from its sync group (decouple one player).
remote_play_on_session
Play items on a remote Jellyfin session (casting)
remote_send_command
Send a playback command to a remote session
remote_session_seek
Seek on a remote session
remote_session_set_volume
Set volume on a remote session
remote_session_toggle_mute
Toggle mute on a remote session
\ No newline at end of file diff --git a/api/jellytau_lib/commands/player/remote/sidebar-items.js b/api/jellytau_lib/commands/player/remote/sidebar-items.js new file mode 100644 index 00000000..e9d36685 --- /dev/null +++ b/api/jellytau_lib/commands/player/remote/sidebar-items.js @@ -0,0 +1 @@ +window.SIDEBAR_ITEMS = {"fn":["lms_create_sync_group","lms_dissolve_sync_group","lms_get_sync_groups","lms_unsync_player","remote_play_on_session","remote_send_command","remote_session_seek","remote_session_set_volume","remote_session_toggle_mute"]}; \ No newline at end of file diff --git a/api/jellytau_lib/commands/player/session/fn.player_dismiss_session.html b/api/jellytau_lib/commands/player/session/fn.player_dismiss_session.html new file mode 100644 index 00000000..1a32c22b --- /dev/null +++ b/api/jellytau_lib/commands/player/session/fn.player_dismiss_session.html @@ -0,0 +1,5 @@ +player_dismiss_session in jellytau_lib::commands::player::session - RustSkip to main content

player_dismiss_session

Function player_dismiss_session 

Source
pub async fn player_dismiss_session(
+    session: State<'_, MediaSessionManagerWrapper>,
+    player: State<'_, PlayerStateWrapper>,
+) -> Result<(), String>
Expand description

Dismiss the current media session (returns to Idle)

+
\ No newline at end of file diff --git a/api/jellytau_lib/commands/player/session/fn.player_get_session.html b/api/jellytau_lib/commands/player/session/fn.player_get_session.html new file mode 100644 index 00000000..9651e79a --- /dev/null +++ b/api/jellytau_lib/commands/player/session/fn.player_get_session.html @@ -0,0 +1,4 @@ +player_get_session in jellytau_lib::commands::player::session - RustSkip to main content

player_get_session

Function player_get_session 

Source
pub async fn player_get_session(
+    session: State<'_, MediaSessionManagerWrapper>,
+) -> Result<MediaSessionType, String>
Expand description

Get the current media session state

+
\ No newline at end of file diff --git a/api/jellytau_lib/commands/player/session/index.html b/api/jellytau_lib/commands/player/session/index.html new file mode 100644 index 00000000..89bd28cb --- /dev/null +++ b/api/jellytau_lib/commands/player/session/index.html @@ -0,0 +1,5 @@ +jellytau_lib::commands::player::session - RustSkip to main content

Module session

Module session 

Source
Expand description

Media session state commands.

+

TRACES: UR-005 | DR-009

+

Read and dismiss the current media session (the Now Playing surface backing +lockscreen/notification controls).

+

Functions§

player_dismiss_session
Dismiss the current media session (returns to Idle)
player_get_session
Get the current media session state
\ No newline at end of file diff --git a/api/jellytau_lib/commands/player/session/sidebar-items.js b/api/jellytau_lib/commands/player/session/sidebar-items.js new file mode 100644 index 00000000..12c62745 --- /dev/null +++ b/api/jellytau_lib/commands/player/session/sidebar-items.js @@ -0,0 +1 @@ +window.SIDEBAR_ITEMS = {"fn":["player_dismiss_session","player_get_session"]}; \ No newline at end of file diff --git a/api/jellytau_lib/commands/player/settings/constant.STREAMING_QUALITY_KEY.html b/api/jellytau_lib/commands/player/settings/constant.STREAMING_QUALITY_KEY.html new file mode 100644 index 00000000..2f10fa0f --- /dev/null +++ b/api/jellytau_lib/commands/player/settings/constant.STREAMING_QUALITY_KEY.html @@ -0,0 +1,8 @@ +STREAMING_QUALITY_KEY in jellytau_lib::commands::player::settings - RustSkip to main content

STREAMING_QUALITY_KEY

Constant STREAMING_QUALITY_KEY 

Source
const STREAMING_QUALITY_KEY: &str = "streaming_quality";
Expand description

app_settings key holding the persisted streaming bandwidth ceiling.

+

The cap is persisted (unlike the rest of VideoSettings, which is +process-lifetime state) because forgetting it is the one failure that costs +the user something real: a limit set for a metered connection that silently +reverts to uncapped on the next launch spends their data allowance without +ever showing them a changed setting.

+

TRACES: UR-074 | DR-162

+
\ No newline at end of file diff --git a/api/jellytau_lib/commands/player/settings/fn.persist_streaming_quality.html b/api/jellytau_lib/commands/player/settings/fn.persist_streaming_quality.html new file mode 100644 index 00000000..328ae0a7 --- /dev/null +++ b/api/jellytau_lib/commands/player/settings/fn.persist_streaming_quality.html @@ -0,0 +1,8 @@ +persist_streaming_quality in jellytau_lib::commands::player::settings - RustSkip to main content

persist_streaming_quality

Function persist_streaming_quality 

Source
async fn persist_streaming_quality(
+    db: &State<'_, DatabaseWrapper>,
+    quality: StreamingQuality,
+)
Expand description

Write the ceiling to app_settings. Failure is logged, not returned: the +setting has already been applied in memory, and refusing the whole call +because the write failed would leave the UI showing a cap that is active.

+

TRACES: UR-074 | DR-162

+
\ No newline at end of file diff --git a/api/jellytau_lib/commands/player/settings/fn.player_get_audio_settings.html b/api/jellytau_lib/commands/player/settings/fn.player_get_audio_settings.html new file mode 100644 index 00000000..fe55cd2c --- /dev/null +++ b/api/jellytau_lib/commands/player/settings/fn.player_get_audio_settings.html @@ -0,0 +1,3 @@ +player_get_audio_settings in jellytau_lib::commands::player::settings - RustSkip to main content

player_get_audio_settings

Function player_get_audio_settings 

Source
pub async fn player_get_audio_settings(
+    player: State<'_, PlayerStateWrapper>,
+) -> Result<AudioSettings, String>
\ No newline at end of file diff --git a/api/jellytau_lib/commands/player/settings/fn.player_get_eq_presets.html b/api/jellytau_lib/commands/player/settings/fn.player_get_eq_presets.html new file mode 100644 index 00000000..0c7f59ef --- /dev/null +++ b/api/jellytau_lib/commands/player/settings/fn.player_get_eq_presets.html @@ -0,0 +1,5 @@ +player_get_eq_presets in jellytau_lib::commands::player::settings - RustSkip to main content

player_get_eq_presets

Function player_get_eq_presets 

Source
pub async fn player_get_eq_presets() -> Result<Vec<(EqPreset, Vec<f32>)>, String>
Expand description

The built-in equalizer presets and their per-band gain curves (dB), for the +settings UI. The curve numbers are domain data defined by the band layout, +so the frontend reads them here rather than encoding them.

+

TRACES: UR-027 | DR-030

+
\ No newline at end of file diff --git a/api/jellytau_lib/commands/player/settings/fn.player_get_streaming_qualities.html b/api/jellytau_lib/commands/player/settings/fn.player_get_streaming_qualities.html new file mode 100644 index 00000000..e27c69ca --- /dev/null +++ b/api/jellytau_lib/commands/player/settings/fn.player_get_streaming_qualities.html @@ -0,0 +1,7 @@ +player_get_streaming_qualities in jellytau_lib::commands::player::settings - RustSkip to main content

player_get_streaming_qualities

Function player_get_streaming_qualities 

Source
pub async fn player_get_streaming_qualities() -> Result<Vec<(StreamingQuality, String, String)>, String>
Expand description

The bandwidth ceilings the quality picker may offer, each with the label and +one-line detail to show for it, highest first.

+

The ladder and its numbers are Jellyfin encoding domain vocabulary, so the +frontend reads them here rather than encoding them — the same arrangement as +player_get_eq_presets.

+

TRACES: UR-074 | DR-162

+
\ No newline at end of file diff --git a/api/jellytau_lib/commands/player/settings/fn.player_get_video_settings.html b/api/jellytau_lib/commands/player/settings/fn.player_get_video_settings.html new file mode 100644 index 00000000..6f971429 --- /dev/null +++ b/api/jellytau_lib/commands/player/settings/fn.player_get_video_settings.html @@ -0,0 +1,3 @@ +player_get_video_settings in jellytau_lib::commands::player::settings - RustSkip to main content

player_get_video_settings

Function player_get_video_settings 

Source
pub async fn player_get_video_settings(
+    video_settings: State<'_, VideoSettingsWrapper>,
+) -> Result<VideoSettings, String>
\ No newline at end of file diff --git a/api/jellytau_lib/commands/player/settings/fn.player_set_audio_settings.html b/api/jellytau_lib/commands/player/settings/fn.player_set_audio_settings.html new file mode 100644 index 00000000..08941478 --- /dev/null +++ b/api/jellytau_lib/commands/player/settings/fn.player_set_audio_settings.html @@ -0,0 +1,4 @@ +player_set_audio_settings in jellytau_lib::commands::player::settings - RustSkip to main content

player_set_audio_settings

Function player_set_audio_settings 

Source
pub async fn player_set_audio_settings(
+    player: State<'_, PlayerStateWrapper>,
+    settings: AudioSettings,
+) -> Result<AudioSettings, String>
\ No newline at end of file diff --git a/api/jellytau_lib/commands/player/settings/fn.player_set_video_settings.html b/api/jellytau_lib/commands/player/settings/fn.player_set_video_settings.html new file mode 100644 index 00000000..8274855e --- /dev/null +++ b/api/jellytau_lib/commands/player/settings/fn.player_set_video_settings.html @@ -0,0 +1,6 @@ +player_set_video_settings in jellytau_lib::commands::player::settings - RustSkip to main content

player_set_video_settings

Function player_set_video_settings 

Source
pub async fn player_set_video_settings(
+    video_settings: State<'_, VideoSettingsWrapper>,
+    player: State<'_, PlayerStateWrapper>,
+    db: State<'_, DatabaseWrapper>,
+    settings: VideoSettings,
+) -> Result<VideoSettings, String>
\ No newline at end of file diff --git a/api/jellytau_lib/commands/player/settings/fn.restore_streaming_quality.html b/api/jellytau_lib/commands/player/settings/fn.restore_streaming_quality.html new file mode 100644 index 00000000..ae759ace --- /dev/null +++ b/api/jellytau_lib/commands/player/settings/fn.restore_streaming_quality.html @@ -0,0 +1,7 @@ +restore_streaming_quality in jellytau_lib::commands::player::settings - RustSkip to main content

restore_streaming_quality

Function restore_streaming_quality 

Source
pub async fn restore_streaming_quality(app: &AppHandle)
Expand description

Restore the persisted bandwidth ceiling at startup, into both the repository +(which enforces it) and VideoSettings (which the settings UI reads).

+

Called from the Tauri setup hook. A missing or unreadable row leaves the +default — uncapped — in place, so a database problem degrades to the old +behaviour rather than to an arbitrary limit.

+

TRACES: UR-074 | DR-162

+
\ No newline at end of file diff --git a/api/jellytau_lib/commands/player/settings/index.html b/api/jellytau_lib/commands/player/settings/index.html new file mode 100644 index 00000000..d62b349f --- /dev/null +++ b/api/jellytau_lib/commands/player/settings/index.html @@ -0,0 +1,9 @@ +jellytau_lib::commands::player::settings - RustSkip to main content

Module settings

Module settings 

Source
Expand description

Audio and video playback settings commands.

+

TRACES: UR-022, UR-027, UR-031, UR-032, UR-033, UR-074 | DR-025, DR-030, DR-034, DR-035, DR-036, DR-162, IR-020

+

Constants§

STREAMING_QUALITY_KEY 🔒
app_settings key holding the persisted streaming bandwidth ceiling.

Functions§

persist_streaming_quality 🔒
Write the ceiling to app_settings. Failure is logged, not returned: the +setting has already been applied in memory, and refusing the whole call +because the write failed would leave the UI showing a cap that is active.
player_get_audio_settings
player_get_eq_presets
The built-in equalizer presets and their per-band gain curves (dB), for the +settings UI. The curve numbers are domain data defined by the band layout, +so the frontend reads them here rather than encoding them.
player_get_streaming_qualities
The bandwidth ceilings the quality picker may offer, each with the label and +one-line detail to show for it, highest first.
player_get_video_settings
player_set_audio_settings
player_set_video_settings
restore_streaming_quality
Restore the persisted bandwidth ceiling at startup, into both the repository +(which enforces it) and VideoSettings (which the settings UI reads).
\ No newline at end of file diff --git a/api/jellytau_lib/commands/player/settings/sidebar-items.js b/api/jellytau_lib/commands/player/settings/sidebar-items.js new file mode 100644 index 00000000..e319bf43 --- /dev/null +++ b/api/jellytau_lib/commands/player/settings/sidebar-items.js @@ -0,0 +1 @@ +window.SIDEBAR_ITEMS = {"constant":["STREAMING_QUALITY_KEY"],"fn":["persist_streaming_quality","player_get_audio_settings","player_get_eq_presets","player_get_streaming_qualities","player_get_video_settings","player_set_audio_settings","player_set_video_settings","restore_streaming_quality"]}; \ No newline at end of file diff --git a/api/jellytau_lib/commands/player/sidebar-items.js b/api/jellytau_lib/commands/player/sidebar-items.js new file mode 100644 index 00000000..c483aca5 --- /dev/null +++ b/api/jellytau_lib/commands/player/sidebar-items.js @@ -0,0 +1 @@ +window.SIDEBAR_ITEMS = {"enum":["AudioTrackSwitchResponse","PlayQueueContext","PlayTracksContext","StreamQualityResponse","VideoBackend","VideoSeekResponse"],"fn":["background_audio_plan","background_audio_source","check_for_local_download","create_media_item","get_player_status","get_queue_status","normalize_volume","play_selection_on_remote","player_background_action","player_configure_jellyfin","player_cycle_repeat","player_disable_jellyfin","player_enter_background_audio","player_exit_background_audio","player_get_cache_config","player_get_capabilities","player_get_queue","player_get_status","player_local_media_path","player_next","player_pause","player_play","player_play_album_track","player_play_item","player_play_queue","player_play_tracks","player_preload_upcoming","player_previous","player_seek","player_seek_video","player_set_audio_track","player_set_cache_config","player_set_stream_quality","player_set_subtitle_track","player_set_volume","player_stop","player_switch_audio_track","player_toggle","player_toggle_mute","player_toggle_shuffle","refresh_queue_local_sources","resolve_local_media_path","sanitize_filename"],"mod":["queue","remote","session","settings","timers"],"struct":["BackgroundAudioPlan","MediaSessionManagerWrapper","MergedMediaItem","PlayAlbumTrackRequest","PlayItemRequest","PlayQueueRequest","PlayTracksRequest","PlaybackCapabilities","PlayerStateWrapper","PlayerStatus","PreloadResult","QueueStatus","SmartCacheWrapper","VideoSettingsWrapper"]}; \ No newline at end of file diff --git a/api/jellytau_lib/commands/player/struct.BackgroundAudioPlan.html b/api/jellytau_lib/commands/player/struct.BackgroundAudioPlan.html new file mode 100644 index 00000000..a4f571d7 --- /dev/null +++ b/api/jellytau_lib/commands/player/struct.BackgroundAudioPlan.html @@ -0,0 +1,34 @@ +BackgroundAudioPlan in jellytau_lib::commands::player - RustSkip to main content

BackgroundAudioPlan

Struct BackgroundAudioPlan 

Source
pub(super) struct BackgroundAudioPlan {
+    pub base_seconds: f64,
+    pub seek_to: Option<f64>,
+}
Expand description

How a background-audio handoff must start playback, given where its audio +actually begins.

+

TRACES: UR-040, UR-071 | DR-180 | UT-181

+

Fields§

§base_seconds: f64

The position the stream’s own zero corresponds to, recorded as the +handoff base so later readings can be shifted back to the episode’s +timeline.

+
§seek_to: Option<f64>

Where to seek after loading, if the source does not already start there.

+

Auto Trait Implementations§

Blanket Implementations§

Source§

impl<T> Any for T
where + T: 'static + ?Sized,

Source§

fn type_id(&self) -> TypeId

Gets the TypeId of self. Read more
Source§

impl<T> Borrow<T> for T
where + T: ?Sized,

Source§

fn borrow(&self) -> &T

Immutably borrows from an owned value. Read more
Source§

impl<T> BorrowMut<T> for T
where + T: ?Sized,

Source§

fn borrow_mut(&mut self) -> &mut T

Mutably borrows from an owned value. Read more
Source§

impl<T> From<T> for T

Source§

fn from(t: T) -> T

Returns the argument unchanged.

+
§

impl<T> Instrument for T

§

fn instrument(self, span: Span) -> Instrumented<Self>

Instruments this type with the provided [Span], returning an +Instrumented wrapper. Read more
§

fn in_current_span(self) -> Instrumented<Self>

Instruments this type with the current Span, returning an +Instrumented wrapper. Read more
Source§

impl<T, U> Into<U> for T
where + U: From<T>,

Source§

fn into(self) -> U

Calls U::from(self).

+

That is, this conversion is whatever the implementation of +From<T> for U chooses to do.

+
§

impl<T> PolicyExt for T
where + T: ?Sized,

§

fn and<P, B, E>(self, other: P) -> And<T, P>
where + T: Policy<B, E>, + P: Policy<B, E>,

Create a new Policy that returns [Action::Follow] only if self and other return +Action::Follow. Read more
§

fn or<P, B, E>(self, other: P) -> Or<T, P>
where + T: Policy<B, E>, + P: Policy<B, E>,

Create a new Policy that returns [Action::Follow] if either self or other returns +Action::Follow. Read more
Source§

impl<T> Same for T

Source§

type Output = T

Should always be Self
Source§

impl<T, U> TryFrom<U> for T
where + U: Into<T>,

Source§

type Error = Infallible

The type returned in the event of a conversion error.
Source§

fn try_from(value: U) -> Result<T, <T as TryFrom<U>>::Error>

Performs the conversion.
Source§

impl<T, U> TryInto<U> for T
where + U: TryFrom<T>,

Source§

type Error = <U as TryFrom<T>>::Error

The type returned in the event of a conversion error.
Source§

fn try_into(self) -> Result<U, <U as TryFrom<T>>::Error>

Performs the conversion.
§

impl<V, T> VZip<V> for T
where + V: MultiLane<T>,

§

fn vzip(self) -> V

§

impl<T> WithSubscriber for T

§

fn with_subscriber<S>(self, subscriber: S) -> WithDispatch<Self>
where + S: Into<Dispatch>,

Attaches the provided Subscriber to this type, returning a +[WithDispatch] wrapper. Read more
§

fn with_current_subscriber(self) -> WithDispatch<Self>

Attaches the current default Subscriber to this type, returning a +[WithDispatch] wrapper. Read more
\ No newline at end of file diff --git a/api/jellytau_lib/commands/player/struct.MediaSessionManagerWrapper.html b/api/jellytau_lib/commands/player/struct.MediaSessionManagerWrapper.html new file mode 100644 index 00000000..1809010a --- /dev/null +++ b/api/jellytau_lib/commands/player/struct.MediaSessionManagerWrapper.html @@ -0,0 +1,26 @@ +MediaSessionManagerWrapper in jellytau_lib::commands::player - RustSkip to main content

MediaSessionManagerWrapper

Struct MediaSessionManagerWrapper 

Source
pub struct MediaSessionManagerWrapper(pub Mutex<MediaSessionManager>);
Expand description

Media session manager wrapper for Tauri state management

+

@req: DR-009 - Audio player UI (mini player, full screen)

+

Tuple Fields§

§0: Mutex<MediaSessionManager>

Auto Trait Implementations§

Blanket Implementations§

Source§

impl<T> Any for T
where + T: 'static + ?Sized,

Source§

fn type_id(&self) -> TypeId

Gets the TypeId of self. Read more
Source§

impl<T> Borrow<T> for T
where + T: ?Sized,

Source§

fn borrow(&self) -> &T

Immutably borrows from an owned value. Read more
Source§

impl<T> BorrowMut<T> for T
where + T: ?Sized,

Source§

fn borrow_mut(&mut self) -> &mut T

Mutably borrows from an owned value. Read more
Source§

impl<T> From<T> for T

Source§

fn from(t: T) -> T

Returns the argument unchanged.

+
§

impl<T> Instrument for T

§

fn instrument(self, span: Span) -> Instrumented<Self>

Instruments this type with the provided [Span], returning an +Instrumented wrapper. Read more
§

fn in_current_span(self) -> Instrumented<Self>

Instruments this type with the current Span, returning an +Instrumented wrapper. Read more
Source§

impl<T, U> Into<U> for T
where + U: From<T>,

Source§

fn into(self) -> U

Calls U::from(self).

+

That is, this conversion is whatever the implementation of +From<T> for U chooses to do.

+
§

impl<T> PolicyExt for T
where + T: ?Sized,

§

fn and<P, B, E>(self, other: P) -> And<T, P>
where + T: Policy<B, E>, + P: Policy<B, E>,

Create a new Policy that returns [Action::Follow] only if self and other return +Action::Follow. Read more
§

fn or<P, B, E>(self, other: P) -> Or<T, P>
where + T: Policy<B, E>, + P: Policy<B, E>,

Create a new Policy that returns [Action::Follow] if either self or other returns +Action::Follow. Read more
Source§

impl<T> Same for T

Source§

type Output = T

Should always be Self
Source§

impl<T, U> TryFrom<U> for T
where + U: Into<T>,

Source§

type Error = Infallible

The type returned in the event of a conversion error.
Source§

fn try_from(value: U) -> Result<T, <T as TryFrom<U>>::Error>

Performs the conversion.
Source§

impl<T, U> TryInto<U> for T
where + U: TryFrom<T>,

Source§

type Error = <U as TryFrom<T>>::Error

The type returned in the event of a conversion error.
Source§

fn try_into(self) -> Result<U, <U as TryFrom<T>>::Error>

Performs the conversion.
§

impl<V, T> VZip<V> for T
where + V: MultiLane<T>,

§

fn vzip(self) -> V

§

impl<T> WithSubscriber for T

§

fn with_subscriber<S>(self, subscriber: S) -> WithDispatch<Self>
where + S: Into<Dispatch>,

Attaches the provided Subscriber to this type, returning a +[WithDispatch] wrapper. Read more
§

fn with_current_subscriber(self) -> WithDispatch<Self>

Attaches the current default Subscriber to this type, returning a +[WithDispatch] wrapper. Read more
\ No newline at end of file diff --git a/api/jellytau_lib/commands/player/struct.MergedMediaItem.html b/api/jellytau_lib/commands/player/struct.MergedMediaItem.html new file mode 100644 index 00000000..52a939e3 --- /dev/null +++ b/api/jellytau_lib/commands/player/struct.MergedMediaItem.html @@ -0,0 +1,53 @@ +MergedMediaItem in jellytau_lib::commands::player - RustSkip to main content

MergedMediaItem

Struct MergedMediaItem 

Source
pub struct MergedMediaItem {
+    pub id: String,
+    pub title: String,
+    pub artist: Option<String>,
+    pub album: Option<String>,
+    pub album_id: Option<String>,
+    pub duration: Option<f64>,
+    pub primary_image_tag: Option<String>,
+    pub image_id: Option<String>,
+    pub media_type: String,
+}
Expand description

Lightweight media item for merged playback state +Converts from both local MediaItem and remote NowPlayingItem

+

Fields§

§id: String§title: String§artist: Option<String>§album: Option<String>§album_id: Option<String>§duration: Option<f64>§primary_image_tag: Option<String>§image_id: Option<String>

Neutral image identifier — replaces primary_image_tag (same value).

+
§media_type: String

Trait Implementations§

Source§

impl Clone for MergedMediaItem

Source§

fn clone(&self) -> MergedMediaItem

Returns a duplicate of the value. Read more
1.0.0 (const: unstable) · Source§

fn clone_from(&mut self, source: &Self)

Performs copy-assignment from source. Read more
Source§

impl Debug for MergedMediaItem

Source§

fn fmt(&self, f: &mut Formatter<'_>) -> Result

Formats the value using the given formatter. Read more
Source§

impl From<&MediaItem> for MergedMediaItem

Source§

fn from(item: &MediaItem) -> Self

Converts to this type from the input type.
Source§

impl From<&NowPlayingItem> for MergedMediaItem

Source§

fn from(item: &NowPlayingItem) -> Self

Converts to this type from the input type.
Source§

impl NamedType for MergedMediaItem

Source§

fn sid() -> SpectaID

Source§

fn named_data_type( + type_map: &mut TypeCollection, + generics: &[DataType], +) -> NamedDataType

this is equivalent to [Type::inline] but returns a [NamedDataType] instead.
Source§

fn definition_named_data_type(type_map: &mut TypeCollection) -> NamedDataType

this is equivalent to [Type::definition] but returns a [NamedDataType] instead.
Source§

impl Serialize for MergedMediaItem

Source§

fn serialize<__S>(&self, __serializer: __S) -> Result<__S::Ok, __S::Error>
where + __S: Serializer,

Serialize this value into the given Serde serializer. Read more
Source§

impl Type for MergedMediaItem

Source§

fn inline(type_map: &mut TypeCollection, generics: Generics<'_>) -> DataType

Returns the definition of a type using the provided generics. Read more
Source§

fn reference(type_map: &mut TypeCollection, generics: &[DataType]) -> Reference

Generates a datatype corresponding to a reference to this type, +as determined by its category. Getting a reference to a type implies that +it should belong in the type map (since it has to be referenced from somewhere), +so the output of definition will be put into the type map.
Source§

impl Flatten for MergedMediaItem

Auto Trait Implementations§

Blanket Implementations§

Source§

impl<T> Any for T
where + T: 'static + ?Sized,

Source§

fn type_id(&self) -> TypeId

Gets the TypeId of self. Read more
Source§

impl<T> Borrow<T> for T
where + T: ?Sized,

Source§

fn borrow(&self) -> &T

Immutably borrows from an owned value. Read more
Source§

impl<T> BorrowMut<T> for T
where + T: ?Sized,

Source§

fn borrow_mut(&mut self) -> &mut T

Mutably borrows from an owned value. Read more
Source§

impl<T> CloneToUninit for T
where + T: Clone,

Source§

unsafe fn clone_to_uninit(&self, dest: *mut u8)

🔬This is a nightly-only experimental API. (clone_to_uninit)
Performs copy-assignment from self to dest. Read more
Source§

impl<T> From<T> for T

Source§

fn from(t: T) -> T

Returns the argument unchanged.

+
§

impl<T> FunctionArg for T
where + T: Type,

§

fn to_datatype(type_map: &mut TypeCollection) -> Option<DataType>

Gets the type of an argument as a [DataType]. Read more
§

impl<T> Instrument for T

§

fn instrument(self, span: Span) -> Instrumented<Self>

Instruments this type with the provided [Span], returning an +Instrumented wrapper. Read more
§

fn in_current_span(self) -> Instrumented<Self>

Instruments this type with the current Span, returning an +Instrumented wrapper. Read more
Source§

impl<T, U> Into<U> for T
where + U: From<T>,

Source§

fn into(self) -> U

Calls U::from(self).

+

That is, this conversion is whatever the implementation of +From<T> for U chooses to do.

+
§

impl<T> IpcResponse for T
where + T: Serialize,

§

fn body(self) -> Result<InvokeResponseBody, Error>

Resolve the IPC response body.
§

impl<T> PolicyExt for T
where + T: ?Sized,

§

fn and<P, B, E>(self, other: P) -> And<T, P>
where + T: Policy<B, E>, + P: Policy<B, E>,

Create a new Policy that returns [Action::Follow] only if self and other return +Action::Follow. Read more
§

fn or<P, B, E>(self, other: P) -> Or<T, P>
where + T: Policy<B, E>, + P: Policy<B, E>,

Create a new Policy that returns [Action::Follow] if either self or other returns +Action::Follow. Read more
Source§

impl<T> Same for T

Source§

type Output = T

Should always be Self
Source§

impl<T> Serialize for T
where + T: Serialize + ?Sized,

Source§

fn erased_serialize(&self, serializer: &mut dyn Serializer) -> Result<(), Error>

Source§

fn do_erased_serialize( + &self, + serializer: &mut dyn Serializer, +) -> Result<(), ErrorImpl>

Source§

impl<T> ToOwned for T
where + T: Clone,

Source§

type Owned = T

The resulting type after obtaining ownership.
Source§

fn to_owned(&self) -> T

Creates owned data from borrowed data, usually by cloning. Read more
Source§

fn clone_into(&self, target: &mut T)

Uses borrowed data to replace owned data, usually by cloning. Read more
Source§

impl<T, U> TryFrom<U> for T
where + U: Into<T>,

Source§

type Error = Infallible

The type returned in the event of a conversion error.
Source§

fn try_from(value: U) -> Result<T, <T as TryFrom<U>>::Error>

Performs the conversion.
Source§

impl<T, U> TryInto<U> for T
where + U: TryFrom<T>,

Source§

type Error = <U as TryFrom<T>>::Error

The type returned in the event of a conversion error.
Source§

fn try_into(self) -> Result<U, <U as TryFrom<T>>::Error>

Performs the conversion.
§

impl<V, T> VZip<V> for T
where + V: MultiLane<T>,

§

fn vzip(self) -> V

§

impl<T> WithSubscriber for T

§

fn with_subscriber<S>(self, subscriber: S) -> WithDispatch<Self>
where + S: Into<Dispatch>,

Attaches the provided Subscriber to this type, returning a +[WithDispatch] wrapper. Read more
§

fn with_current_subscriber(self) -> WithDispatch<Self>

Attaches the current default Subscriber to this type, returning a +[WithDispatch] wrapper. Read more
§

impl<T> UserEvent for T
where + T: Debug + Clone + Send + 'static,

\ No newline at end of file diff --git a/api/jellytau_lib/commands/player/struct.PlayAlbumTrackRequest.html b/api/jellytau_lib/commands/player/struct.PlayAlbumTrackRequest.html new file mode 100644 index 00000000..8ecd58ab --- /dev/null +++ b/api/jellytau_lib/commands/player/struct.PlayAlbumTrackRequest.html @@ -0,0 +1,46 @@ +PlayAlbumTrackRequest in jellytau_lib::commands::player - RustSkip to main content

PlayAlbumTrackRequest

Struct PlayAlbumTrackRequest 

Source
pub struct PlayAlbumTrackRequest {
+    pub album_id: String,
+    pub album_name: String,
+    pub track_id: String,
+    pub shuffle: bool,
+}
Expand description

Request to play a track from an album (backend fetches all tracks)

+

Fields§

§album_id: String§album_name: String§track_id: String§shuffle: bool

Trait Implementations§

Source§

impl Debug for PlayAlbumTrackRequest

Source§

fn fmt(&self, f: &mut Formatter<'_>) -> Result

Formats the value using the given formatter. Read more
Source§

impl<'de> Deserialize<'de> for PlayAlbumTrackRequest

Source§

fn deserialize<__D>(__deserializer: __D) -> Result<Self, __D::Error>
where + __D: Deserializer<'de>,

Deserialize this value from the given Serde deserializer. Read more
Source§

impl NamedType for PlayAlbumTrackRequest

Source§

fn sid() -> SpectaID

Source§

fn named_data_type( + type_map: &mut TypeCollection, + generics: &[DataType], +) -> NamedDataType

this is equivalent to [Type::inline] but returns a [NamedDataType] instead.
Source§

fn definition_named_data_type(type_map: &mut TypeCollection) -> NamedDataType

this is equivalent to [Type::definition] but returns a [NamedDataType] instead.
Source§

impl Type for PlayAlbumTrackRequest

Source§

fn inline(type_map: &mut TypeCollection, generics: Generics<'_>) -> DataType

Returns the definition of a type using the provided generics. Read more
Source§

fn reference(type_map: &mut TypeCollection, generics: &[DataType]) -> Reference

Generates a datatype corresponding to a reference to this type, +as determined by its category. Getting a reference to a type implies that +it should belong in the type map (since it has to be referenced from somewhere), +so the output of definition will be put into the type map.
Source§

impl Flatten for PlayAlbumTrackRequest

Auto Trait Implementations§

Blanket Implementations§

Source§

impl<T> Any for T
where + T: 'static + ?Sized,

Source§

fn type_id(&self) -> TypeId

Gets the TypeId of self. Read more
Source§

impl<T> Borrow<T> for T
where + T: ?Sized,

Source§

fn borrow(&self) -> &T

Immutably borrows from an owned value. Read more
Source§

impl<T> BorrowMut<T> for T
where + T: ?Sized,

Source§

fn borrow_mut(&mut self) -> &mut T

Mutably borrows from an owned value. Read more
§

impl<'de, D, R> CommandArg<'de, R> for D
where + D: Deserialize<'de>, + R: Runtime,

§

fn from_command(command: CommandItem<'de, R>) -> Result<D, InvokeError>

Derives an instance of Self from the [CommandItem]. Read more
Source§

impl<T> From<T> for T

Source§

fn from(t: T) -> T

Returns the argument unchanged.

+
§

impl<T> FunctionArg for T
where + T: Type,

§

fn to_datatype(type_map: &mut TypeCollection) -> Option<DataType>

Gets the type of an argument as a [DataType]. Read more
§

impl<T> Instrument for T

§

fn instrument(self, span: Span) -> Instrumented<Self>

Instruments this type with the provided [Span], returning an +Instrumented wrapper. Read more
§

fn in_current_span(self) -> Instrumented<Self>

Instruments this type with the current Span, returning an +Instrumented wrapper. Read more
Source§

impl<T, U> Into<U> for T
where + U: From<T>,

Source§

fn into(self) -> U

Calls U::from(self).

+

That is, this conversion is whatever the implementation of +From<T> for U chooses to do.

+
§

impl<T> PolicyExt for T
where + T: ?Sized,

§

fn and<P, B, E>(self, other: P) -> And<T, P>
where + T: Policy<B, E>, + P: Policy<B, E>,

Create a new Policy that returns [Action::Follow] only if self and other return +Action::Follow. Read more
§

fn or<P, B, E>(self, other: P) -> Or<T, P>
where + T: Policy<B, E>, + P: Policy<B, E>,

Create a new Policy that returns [Action::Follow] if either self or other returns +Action::Follow. Read more
Source§

impl<T> Same for T

Source§

type Output = T

Should always be Self
§

impl<T> ScopeObject for T
where + T: Send + Sync + Debug + DeserializeOwned + 'static,

§

type Error = Error

The error type.
§

fn deserialize<R>( + _app: &AppHandle<R>, + raw: Value, +) -> Result<T, <T as ScopeObject>::Error>
where + R: Runtime,

Deserialize the raw scope value.
Source§

impl<T, U> TryFrom<U> for T
where + U: Into<T>,

Source§

type Error = Infallible

The type returned in the event of a conversion error.
Source§

fn try_from(value: U) -> Result<T, <T as TryFrom<U>>::Error>

Performs the conversion.
Source§

impl<T, U> TryInto<U> for T
where + U: TryFrom<T>,

Source§

type Error = <U as TryFrom<T>>::Error

The type returned in the event of a conversion error.
Source§

fn try_into(self) -> Result<U, <U as TryFrom<T>>::Error>

Performs the conversion.
§

impl<V, T> VZip<V> for T
where + V: MultiLane<T>,

§

fn vzip(self) -> V

§

impl<T> WithSubscriber for T

§

fn with_subscriber<S>(self, subscriber: S) -> WithDispatch<Self>
where + S: Into<Dispatch>,

Attaches the provided Subscriber to this type, returning a +[WithDispatch] wrapper. Read more
§

fn with_current_subscriber(self) -> WithDispatch<Self>

Attaches the current default Subscriber to this type, returning a +[WithDispatch] wrapper. Read more
Source§

impl<T> DeserializeOwned for T
where + T: for<'de> Deserialize<'de>,

\ No newline at end of file diff --git a/api/jellytau_lib/commands/player/struct.PlayItemRequest.html b/api/jellytau_lib/commands/player/struct.PlayItemRequest.html new file mode 100644 index 00000000..64094b73 --- /dev/null +++ b/api/jellytau_lib/commands/player/struct.PlayItemRequest.html @@ -0,0 +1,84 @@ +PlayItemRequest in jellytau_lib::commands::player - RustSkip to main content

PlayItemRequest

Struct PlayItemRequest 

Source
pub struct PlayItemRequest {
+    pub id: String,
+    pub title: String,
+    pub stream_url: String,
+    pub video_codec: String,
+    pub needs_transcoding: bool,
+    pub artist: Option<String>,
+    pub primary_image_tag: Option<String>,
+    pub server_id: Option<String>,
+    pub duration_seconds: Option<f64>,
+    pub item_type: Option<String>,
+    pub series_id: Option<String>,
+    pub subtitles: Vec<SubtitleTrack>,
+}
Expand description

Request to play a single video item

+

Simplified to video playback only. Audio playback uses player_play_tracks +to avoid Tauri Android serialization issues with complex objects.

+

Fields§

§id: String§title: String§stream_url: String§video_codec: String

Video codec (e.g., “h264”, “hevc”) for video media

+
§needs_transcoding: bool

Whether the video requires server-side transcoding

+
§artist: Option<String>

Optional now-playing metadata. Used by the background-audio handoff so the +lockscreen/miniplayer show the item (title/subtitle/artwork). Defaulted so +existing video-only callers need not send them.

+
§primary_image_tag: Option<String>§server_id: Option<String>§duration_seconds: Option<f64>

Total media duration (seconds). Threaded through the background-audio +handoff so the lockscreen MediaSession advertises a real duration — a +zero-duration session renders no scrubber, even with ACTION_SEEK_TO set.

+
§item_type: Option<String>

Item type (e.g. “Episode”, “Movie”, “Audio”). Carried through the +background-audio handoff so an episode played as audio-only is still +recognised as an episode by autoplay (UR-040) and advances to the next one.

+
§series_id: Option<String>

Series ID for TV episodes. Needed alongside item_type so the backend can +look up the next episode when a background-audio track ends.

+
§subtitles: Vec<SubtitleTrack>

Subtitle tracks to sideload, with URLs the frontend has already resolved.

+

Only the native backends use these: on Android they become the +MediaItem.SubtitleConfigurations ExoPlayer renders. The HTML5 path +builds its own <track> children instead and ignores this list.

+

Order is the contract. player_set_subtitle_track(n) reaches +JellyTauPlayer.setSubtitleTrack(n), which indexes into ExoPlayer’s +text track groups — i.e. the position of the sideloaded configuration, +not the Jellyfin stream index (which is kept on each entry for the UI’s +benefit). So n must be a position in this very array, and the array +must not be reordered or filtered between building it and sending it. +nativeSubtitleArrayIndex() on the frontend computes n from the same +list that is sent here, for exactly this reason.

+

Defaulted so the background-audio handoff and the autoplay/next-episode +callers, which have no subtitles to offer, need not send the field.

+

TRACES: UR-020 | IR-016, JA-008 | UT-145

+

Trait Implementations§

Source§

impl Debug for PlayItemRequest

Source§

fn fmt(&self, f: &mut Formatter<'_>) -> Result

Formats the value using the given formatter. Read more
Source§

impl<'de> Deserialize<'de> for PlayItemRequest

Source§

fn deserialize<__D>(__deserializer: __D) -> Result<Self, __D::Error>
where + __D: Deserializer<'de>,

Deserialize this value from the given Serde deserializer. Read more
Source§

impl NamedType for PlayItemRequest

Source§

fn sid() -> SpectaID

Source§

fn named_data_type( + type_map: &mut TypeCollection, + generics: &[DataType], +) -> NamedDataType

this is equivalent to [Type::inline] but returns a [NamedDataType] instead.
Source§

fn definition_named_data_type(type_map: &mut TypeCollection) -> NamedDataType

this is equivalent to [Type::definition] but returns a [NamedDataType] instead.
Source§

impl Type for PlayItemRequest

Source§

fn inline(type_map: &mut TypeCollection, generics: Generics<'_>) -> DataType

Returns the definition of a type using the provided generics. Read more
Source§

fn reference(type_map: &mut TypeCollection, generics: &[DataType]) -> Reference

Generates a datatype corresponding to a reference to this type, +as determined by its category. Getting a reference to a type implies that +it should belong in the type map (since it has to be referenced from somewhere), +so the output of definition will be put into the type map.
Source§

impl Flatten for PlayItemRequest

Auto Trait Implementations§

Blanket Implementations§

Source§

impl<T> Any for T
where + T: 'static + ?Sized,

Source§

fn type_id(&self) -> TypeId

Gets the TypeId of self. Read more
Source§

impl<T> Borrow<T> for T
where + T: ?Sized,

Source§

fn borrow(&self) -> &T

Immutably borrows from an owned value. Read more
Source§

impl<T> BorrowMut<T> for T
where + T: ?Sized,

Source§

fn borrow_mut(&mut self) -> &mut T

Mutably borrows from an owned value. Read more
§

impl<'de, D, R> CommandArg<'de, R> for D
where + D: Deserialize<'de>, + R: Runtime,

§

fn from_command(command: CommandItem<'de, R>) -> Result<D, InvokeError>

Derives an instance of Self from the [CommandItem]. Read more
Source§

impl<T> From<T> for T

Source§

fn from(t: T) -> T

Returns the argument unchanged.

+
§

impl<T> FunctionArg for T
where + T: Type,

§

fn to_datatype(type_map: &mut TypeCollection) -> Option<DataType>

Gets the type of an argument as a [DataType]. Read more
§

impl<T> Instrument for T

§

fn instrument(self, span: Span) -> Instrumented<Self>

Instruments this type with the provided [Span], returning an +Instrumented wrapper. Read more
§

fn in_current_span(self) -> Instrumented<Self>

Instruments this type with the current Span, returning an +Instrumented wrapper. Read more
Source§

impl<T, U> Into<U> for T
where + U: From<T>,

Source§

fn into(self) -> U

Calls U::from(self).

+

That is, this conversion is whatever the implementation of +From<T> for U chooses to do.

+
§

impl<T> PolicyExt for T
where + T: ?Sized,

§

fn and<P, B, E>(self, other: P) -> And<T, P>
where + T: Policy<B, E>, + P: Policy<B, E>,

Create a new Policy that returns [Action::Follow] only if self and other return +Action::Follow. Read more
§

fn or<P, B, E>(self, other: P) -> Or<T, P>
where + T: Policy<B, E>, + P: Policy<B, E>,

Create a new Policy that returns [Action::Follow] if either self or other returns +Action::Follow. Read more
Source§

impl<T> Same for T

Source§

type Output = T

Should always be Self
§

impl<T> ScopeObject for T
where + T: Send + Sync + Debug + DeserializeOwned + 'static,

§

type Error = Error

The error type.
§

fn deserialize<R>( + _app: &AppHandle<R>, + raw: Value, +) -> Result<T, <T as ScopeObject>::Error>
where + R: Runtime,

Deserialize the raw scope value.
Source§

impl<T, U> TryFrom<U> for T
where + U: Into<T>,

Source§

type Error = Infallible

The type returned in the event of a conversion error.
Source§

fn try_from(value: U) -> Result<T, <T as TryFrom<U>>::Error>

Performs the conversion.
Source§

impl<T, U> TryInto<U> for T
where + U: TryFrom<T>,

Source§

type Error = <U as TryFrom<T>>::Error

The type returned in the event of a conversion error.
Source§

fn try_into(self) -> Result<U, <U as TryFrom<T>>::Error>

Performs the conversion.
§

impl<V, T> VZip<V> for T
where + V: MultiLane<T>,

§

fn vzip(self) -> V

§

impl<T> WithSubscriber for T

§

fn with_subscriber<S>(self, subscriber: S) -> WithDispatch<Self>
where + S: Into<Dispatch>,

Attaches the provided Subscriber to this type, returning a +[WithDispatch] wrapper. Read more
§

fn with_current_subscriber(self) -> WithDispatch<Self>

Attaches the current default Subscriber to this type, returning a +[WithDispatch] wrapper. Read more
Source§

impl<T> DeserializeOwned for T
where + T: for<'de> Deserialize<'de>,

\ No newline at end of file diff --git a/api/jellytau_lib/commands/player/struct.PlayQueueRequest.html b/api/jellytau_lib/commands/player/struct.PlayQueueRequest.html new file mode 100644 index 00000000..217b11a7 --- /dev/null +++ b/api/jellytau_lib/commands/player/struct.PlayQueueRequest.html @@ -0,0 +1,48 @@ +PlayQueueRequest in jellytau_lib::commands::player - RustSkip to main content

PlayQueueRequest

Struct PlayQueueRequest 

Source
pub struct PlayQueueRequest {
+    pub items: Vec<PlayItemRequest>,
+    pub start_index: usize,
+    pub shuffle: bool,
+    pub context: Option<PlayQueueContext>,
+}
Expand description

Request to play a queue of items

+

Fields§

§items: Vec<PlayItemRequest>§start_index: usize§shuffle: bool§context: Option<PlayQueueContext>

Optional context for the queue (album, playlist, or custom) +Used for remote playback transfer

+

Trait Implementations§

Source§

impl Debug for PlayQueueRequest

Source§

fn fmt(&self, f: &mut Formatter<'_>) -> Result

Formats the value using the given formatter. Read more
Source§

impl<'de> Deserialize<'de> for PlayQueueRequest

Source§

fn deserialize<__D>(__deserializer: __D) -> Result<Self, __D::Error>
where + __D: Deserializer<'de>,

Deserialize this value from the given Serde deserializer. Read more
Source§

impl NamedType for PlayQueueRequest

Source§

fn sid() -> SpectaID

Source§

fn named_data_type( + type_map: &mut TypeCollection, + generics: &[DataType], +) -> NamedDataType

this is equivalent to [Type::inline] but returns a [NamedDataType] instead.
Source§

fn definition_named_data_type(type_map: &mut TypeCollection) -> NamedDataType

this is equivalent to [Type::definition] but returns a [NamedDataType] instead.
Source§

impl Type for PlayQueueRequest

Source§

fn inline(type_map: &mut TypeCollection, generics: Generics<'_>) -> DataType

Returns the definition of a type using the provided generics. Read more
Source§

fn reference(type_map: &mut TypeCollection, generics: &[DataType]) -> Reference

Generates a datatype corresponding to a reference to this type, +as determined by its category. Getting a reference to a type implies that +it should belong in the type map (since it has to be referenced from somewhere), +so the output of definition will be put into the type map.
Source§

impl Flatten for PlayQueueRequest

Auto Trait Implementations§

Blanket Implementations§

Source§

impl<T> Any for T
where + T: 'static + ?Sized,

Source§

fn type_id(&self) -> TypeId

Gets the TypeId of self. Read more
Source§

impl<T> Borrow<T> for T
where + T: ?Sized,

Source§

fn borrow(&self) -> &T

Immutably borrows from an owned value. Read more
Source§

impl<T> BorrowMut<T> for T
where + T: ?Sized,

Source§

fn borrow_mut(&mut self) -> &mut T

Mutably borrows from an owned value. Read more
§

impl<'de, D, R> CommandArg<'de, R> for D
where + D: Deserialize<'de>, + R: Runtime,

§

fn from_command(command: CommandItem<'de, R>) -> Result<D, InvokeError>

Derives an instance of Self from the [CommandItem]. Read more
Source§

impl<T> From<T> for T

Source§

fn from(t: T) -> T

Returns the argument unchanged.

+
§

impl<T> FunctionArg for T
where + T: Type,

§

fn to_datatype(type_map: &mut TypeCollection) -> Option<DataType>

Gets the type of an argument as a [DataType]. Read more
§

impl<T> Instrument for T

§

fn instrument(self, span: Span) -> Instrumented<Self>

Instruments this type with the provided [Span], returning an +Instrumented wrapper. Read more
§

fn in_current_span(self) -> Instrumented<Self>

Instruments this type with the current Span, returning an +Instrumented wrapper. Read more
Source§

impl<T, U> Into<U> for T
where + U: From<T>,

Source§

fn into(self) -> U

Calls U::from(self).

+

That is, this conversion is whatever the implementation of +From<T> for U chooses to do.

+
§

impl<T> PolicyExt for T
where + T: ?Sized,

§

fn and<P, B, E>(self, other: P) -> And<T, P>
where + T: Policy<B, E>, + P: Policy<B, E>,

Create a new Policy that returns [Action::Follow] only if self and other return +Action::Follow. Read more
§

fn or<P, B, E>(self, other: P) -> Or<T, P>
where + T: Policy<B, E>, + P: Policy<B, E>,

Create a new Policy that returns [Action::Follow] if either self or other returns +Action::Follow. Read more
Source§

impl<T> Same for T

Source§

type Output = T

Should always be Self
§

impl<T> ScopeObject for T
where + T: Send + Sync + Debug + DeserializeOwned + 'static,

§

type Error = Error

The error type.
§

fn deserialize<R>( + _app: &AppHandle<R>, + raw: Value, +) -> Result<T, <T as ScopeObject>::Error>
where + R: Runtime,

Deserialize the raw scope value.
Source§

impl<T, U> TryFrom<U> for T
where + U: Into<T>,

Source§

type Error = Infallible

The type returned in the event of a conversion error.
Source§

fn try_from(value: U) -> Result<T, <T as TryFrom<U>>::Error>

Performs the conversion.
Source§

impl<T, U> TryInto<U> for T
where + U: TryFrom<T>,

Source§

type Error = <U as TryFrom<T>>::Error

The type returned in the event of a conversion error.
Source§

fn try_into(self) -> Result<U, <U as TryFrom<T>>::Error>

Performs the conversion.
§

impl<V, T> VZip<V> for T
where + V: MultiLane<T>,

§

fn vzip(self) -> V

§

impl<T> WithSubscriber for T

§

fn with_subscriber<S>(self, subscriber: S) -> WithDispatch<Self>
where + S: Into<Dispatch>,

Attaches the provided Subscriber to this type, returning a +[WithDispatch] wrapper. Read more
§

fn with_current_subscriber(self) -> WithDispatch<Self>

Attaches the current default Subscriber to this type, returning a +[WithDispatch] wrapper. Read more
Source§

impl<T> DeserializeOwned for T
where + T: for<'de> Deserialize<'de>,

\ No newline at end of file diff --git a/api/jellytau_lib/commands/player/struct.PlayTracksRequest.html b/api/jellytau_lib/commands/player/struct.PlayTracksRequest.html new file mode 100644 index 00000000..a5b3e98e --- /dev/null +++ b/api/jellytau_lib/commands/player/struct.PlayTracksRequest.html @@ -0,0 +1,49 @@ +PlayTracksRequest in jellytau_lib::commands::player - RustSkip to main content

PlayTracksRequest

Struct PlayTracksRequest 

Source
pub struct PlayTracksRequest {
+    pub track_ids: Vec<String>,
+    pub start_index: usize,
+    pub shuffle: bool,
+    pub context: PlayTracksContext,
+    pub start_position: Option<f64>,
+}
Expand description

Request to play tracks by ID (backend fetches metadata)

+

Fields§

§track_ids: Vec<String>§start_index: usize§shuffle: bool§context: PlayTracksContext§start_position: Option<f64>

Position (seconds) to resume the starting track from. Used when taking +over playback from a remote session so we don’t restart from 0.

+

Trait Implementations§

Source§

impl Debug for PlayTracksRequest

Source§

fn fmt(&self, f: &mut Formatter<'_>) -> Result

Formats the value using the given formatter. Read more
Source§

impl<'de> Deserialize<'de> for PlayTracksRequest

Source§

fn deserialize<__D>(__deserializer: __D) -> Result<Self, __D::Error>
where + __D: Deserializer<'de>,

Deserialize this value from the given Serde deserializer. Read more
Source§

impl NamedType for PlayTracksRequest

Source§

fn sid() -> SpectaID

Source§

fn named_data_type( + type_map: &mut TypeCollection, + generics: &[DataType], +) -> NamedDataType

this is equivalent to [Type::inline] but returns a [NamedDataType] instead.
Source§

fn definition_named_data_type(type_map: &mut TypeCollection) -> NamedDataType

this is equivalent to [Type::definition] but returns a [NamedDataType] instead.
Source§

impl Type for PlayTracksRequest

Source§

fn inline(type_map: &mut TypeCollection, generics: Generics<'_>) -> DataType

Returns the definition of a type using the provided generics. Read more
Source§

fn reference(type_map: &mut TypeCollection, generics: &[DataType]) -> Reference

Generates a datatype corresponding to a reference to this type, +as determined by its category. Getting a reference to a type implies that +it should belong in the type map (since it has to be referenced from somewhere), +so the output of definition will be put into the type map.
Source§

impl Flatten for PlayTracksRequest

Auto Trait Implementations§

Blanket Implementations§

Source§

impl<T> Any for T
where + T: 'static + ?Sized,

Source§

fn type_id(&self) -> TypeId

Gets the TypeId of self. Read more
Source§

impl<T> Borrow<T> for T
where + T: ?Sized,

Source§

fn borrow(&self) -> &T

Immutably borrows from an owned value. Read more
Source§

impl<T> BorrowMut<T> for T
where + T: ?Sized,

Source§

fn borrow_mut(&mut self) -> &mut T

Mutably borrows from an owned value. Read more
§

impl<'de, D, R> CommandArg<'de, R> for D
where + D: Deserialize<'de>, + R: Runtime,

§

fn from_command(command: CommandItem<'de, R>) -> Result<D, InvokeError>

Derives an instance of Self from the [CommandItem]. Read more
Source§

impl<T> From<T> for T

Source§

fn from(t: T) -> T

Returns the argument unchanged.

+
§

impl<T> FunctionArg for T
where + T: Type,

§

fn to_datatype(type_map: &mut TypeCollection) -> Option<DataType>

Gets the type of an argument as a [DataType]. Read more
§

impl<T> Instrument for T

§

fn instrument(self, span: Span) -> Instrumented<Self>

Instruments this type with the provided [Span], returning an +Instrumented wrapper. Read more
§

fn in_current_span(self) -> Instrumented<Self>

Instruments this type with the current Span, returning an +Instrumented wrapper. Read more
Source§

impl<T, U> Into<U> for T
where + U: From<T>,

Source§

fn into(self) -> U

Calls U::from(self).

+

That is, this conversion is whatever the implementation of +From<T> for U chooses to do.

+
§

impl<T> PolicyExt for T
where + T: ?Sized,

§

fn and<P, B, E>(self, other: P) -> And<T, P>
where + T: Policy<B, E>, + P: Policy<B, E>,

Create a new Policy that returns [Action::Follow] only if self and other return +Action::Follow. Read more
§

fn or<P, B, E>(self, other: P) -> Or<T, P>
where + T: Policy<B, E>, + P: Policy<B, E>,

Create a new Policy that returns [Action::Follow] if either self or other returns +Action::Follow. Read more
Source§

impl<T> Same for T

Source§

type Output = T

Should always be Self
§

impl<T> ScopeObject for T
where + T: Send + Sync + Debug + DeserializeOwned + 'static,

§

type Error = Error

The error type.
§

fn deserialize<R>( + _app: &AppHandle<R>, + raw: Value, +) -> Result<T, <T as ScopeObject>::Error>
where + R: Runtime,

Deserialize the raw scope value.
Source§

impl<T, U> TryFrom<U> for T
where + U: Into<T>,

Source§

type Error = Infallible

The type returned in the event of a conversion error.
Source§

fn try_from(value: U) -> Result<T, <T as TryFrom<U>>::Error>

Performs the conversion.
Source§

impl<T, U> TryInto<U> for T
where + U: TryFrom<T>,

Source§

type Error = <U as TryFrom<T>>::Error

The type returned in the event of a conversion error.
Source§

fn try_into(self) -> Result<U, <U as TryFrom<T>>::Error>

Performs the conversion.
§

impl<V, T> VZip<V> for T
where + V: MultiLane<T>,

§

fn vzip(self) -> V

§

impl<T> WithSubscriber for T

§

fn with_subscriber<S>(self, subscriber: S) -> WithDispatch<Self>
where + S: Into<Dispatch>,

Attaches the provided Subscriber to this type, returning a +[WithDispatch] wrapper. Read more
§

fn with_current_subscriber(self) -> WithDispatch<Self>

Attaches the current default Subscriber to this type, returning a +[WithDispatch] wrapper. Read more
Source§

impl<T> DeserializeOwned for T
where + T: for<'de> Deserialize<'de>,

\ No newline at end of file diff --git a/api/jellytau_lib/commands/player/struct.PlaybackCapabilities.html b/api/jellytau_lib/commands/player/struct.PlaybackCapabilities.html new file mode 100644 index 00000000..117bea55 --- /dev/null +++ b/api/jellytau_lib/commands/player/struct.PlaybackCapabilities.html @@ -0,0 +1,53 @@ +PlaybackCapabilities in jellytau_lib::commands::player - RustSkip to main content

PlaybackCapabilities

Struct PlaybackCapabilities 

Source
pub struct PlaybackCapabilities {
+    pub uses_webview_audio: bool,
+    pub supports_native_video: bool,
+}
Expand description

What playback facilities this platform’s backend actually provides.

+

The frontend is presentation-only and must not re-derive backend facts from +navigator.userAgent — that sniffing was a second copy of the same platform +decision Rust already makes with cfg!, and it drifted. These flags are the +single source of truth; the frontend consumes them.

+

TRACES: UR-003, UR-005 | DR-004, DR-023, DR-024

+

Fields§

§uses_webview_audio: bool

True when audio is rendered by a webview <audio> element rather than a +native backend. Native audio exists on Linux (mpv) and Android +(ExoPlayer); everything else (Windows, future desktops) uses the webview.

+
§supports_native_video: bool

True when video can be rendered by a native surface composited behind +a transparent webview. Android only: ExoPlayer draws into a SurfaceView +beneath the WebView. Linux cannot do this (WebKitGTK/Wayland +compositing), so it stays on the HTML5 element.

+

Trait Implementations§

Source§

impl Debug for PlaybackCapabilities

Source§

fn fmt(&self, f: &mut Formatter<'_>) -> Result

Formats the value using the given formatter. Read more
Source§

impl NamedType for PlaybackCapabilities

Source§

fn sid() -> SpectaID

Source§

fn named_data_type( + type_map: &mut TypeCollection, + generics: &[DataType], +) -> NamedDataType

this is equivalent to [Type::inline] but returns a [NamedDataType] instead.
Source§

fn definition_named_data_type(type_map: &mut TypeCollection) -> NamedDataType

this is equivalent to [Type::definition] but returns a [NamedDataType] instead.
Source§

impl Serialize for PlaybackCapabilities

Source§

fn serialize<__S>(&self, __serializer: __S) -> Result<__S::Ok, __S::Error>
where + __S: Serializer,

Serialize this value into the given Serde serializer. Read more
Source§

impl Type for PlaybackCapabilities

Source§

fn inline(type_map: &mut TypeCollection, generics: Generics<'_>) -> DataType

Returns the definition of a type using the provided generics. Read more
Source§

fn reference(type_map: &mut TypeCollection, generics: &[DataType]) -> Reference

Generates a datatype corresponding to a reference to this type, +as determined by its category. Getting a reference to a type implies that +it should belong in the type map (since it has to be referenced from somewhere), +so the output of definition will be put into the type map.
Source§

impl Flatten for PlaybackCapabilities

Auto Trait Implementations§

Blanket Implementations§

Source§

impl<T> Any for T
where + T: 'static + ?Sized,

Source§

fn type_id(&self) -> TypeId

Gets the TypeId of self. Read more
Source§

impl<T> Borrow<T> for T
where + T: ?Sized,

Source§

fn borrow(&self) -> &T

Immutably borrows from an owned value. Read more
Source§

impl<T> BorrowMut<T> for T
where + T: ?Sized,

Source§

fn borrow_mut(&mut self) -> &mut T

Mutably borrows from an owned value. Read more
Source§

impl<T> From<T> for T

Source§

fn from(t: T) -> T

Returns the argument unchanged.

+
§

impl<T> FunctionArg for T
where + T: Type,

§

fn to_datatype(type_map: &mut TypeCollection) -> Option<DataType>

Gets the type of an argument as a [DataType]. Read more
§

impl<T> Instrument for T

§

fn instrument(self, span: Span) -> Instrumented<Self>

Instruments this type with the provided [Span], returning an +Instrumented wrapper. Read more
§

fn in_current_span(self) -> Instrumented<Self>

Instruments this type with the current Span, returning an +Instrumented wrapper. Read more
Source§

impl<T, U> Into<U> for T
where + U: From<T>,

Source§

fn into(self) -> U

Calls U::from(self).

+

That is, this conversion is whatever the implementation of +From<T> for U chooses to do.

+
§

impl<T> IpcResponse for T
where + T: Serialize,

§

fn body(self) -> Result<InvokeResponseBody, Error>

Resolve the IPC response body.
§

impl<T> PolicyExt for T
where + T: ?Sized,

§

fn and<P, B, E>(self, other: P) -> And<T, P>
where + T: Policy<B, E>, + P: Policy<B, E>,

Create a new Policy that returns [Action::Follow] only if self and other return +Action::Follow. Read more
§

fn or<P, B, E>(self, other: P) -> Or<T, P>
where + T: Policy<B, E>, + P: Policy<B, E>,

Create a new Policy that returns [Action::Follow] if either self or other returns +Action::Follow. Read more
Source§

impl<T> Same for T

Source§

type Output = T

Should always be Self
Source§

impl<T> Serialize for T
where + T: Serialize + ?Sized,

Source§

fn erased_serialize(&self, serializer: &mut dyn Serializer) -> Result<(), Error>

Source§

fn do_erased_serialize( + &self, + serializer: &mut dyn Serializer, +) -> Result<(), ErrorImpl>

Source§

impl<T, U> TryFrom<U> for T
where + U: Into<T>,

Source§

type Error = Infallible

The type returned in the event of a conversion error.
Source§

fn try_from(value: U) -> Result<T, <T as TryFrom<U>>::Error>

Performs the conversion.
Source§

impl<T, U> TryInto<U> for T
where + U: TryFrom<T>,

Source§

type Error = <U as TryFrom<T>>::Error

The type returned in the event of a conversion error.
Source§

fn try_into(self) -> Result<U, <U as TryFrom<T>>::Error>

Performs the conversion.
§

impl<V, T> VZip<V> for T
where + V: MultiLane<T>,

§

fn vzip(self) -> V

§

impl<T> WithSubscriber for T

§

fn with_subscriber<S>(self, subscriber: S) -> WithDispatch<Self>
where + S: Into<Dispatch>,

Attaches the provided Subscriber to this type, returning a +[WithDispatch] wrapper. Read more
§

fn with_current_subscriber(self) -> WithDispatch<Self>

Attaches the current default Subscriber to this type, returning a +[WithDispatch] wrapper. Read more
\ No newline at end of file diff --git a/api/jellytau_lib/commands/player/struct.PlayerStateWrapper.html b/api/jellytau_lib/commands/player/struct.PlayerStateWrapper.html new file mode 100644 index 00000000..00abc837 --- /dev/null +++ b/api/jellytau_lib/commands/player/struct.PlayerStateWrapper.html @@ -0,0 +1,29 @@ +PlayerStateWrapper in jellytau_lib::commands::player - RustSkip to main content

PlayerStateWrapper

Struct PlayerStateWrapper 

Source
pub struct PlayerStateWrapper(pub Arc<Mutex<PlayerController>>);
Expand description

Player state wrapper for Tauri.

+

Uses Arc to allow sharing with the MediaSession handler on Android +for lockscreen control integration.

+

@req: UR-005 - Control media playback +@req: DR-001 - Player state machine

+

Tuple Fields§

§0: Arc<Mutex<PlayerController>>

Auto Trait Implementations§

Blanket Implementations§

Source§

impl<T> Any for T
where + T: 'static + ?Sized,

Source§

fn type_id(&self) -> TypeId

Gets the TypeId of self. Read more
Source§

impl<T> Borrow<T> for T
where + T: ?Sized,

Source§

fn borrow(&self) -> &T

Immutably borrows from an owned value. Read more
Source§

impl<T> BorrowMut<T> for T
where + T: ?Sized,

Source§

fn borrow_mut(&mut self) -> &mut T

Mutably borrows from an owned value. Read more
Source§

impl<T> From<T> for T

Source§

fn from(t: T) -> T

Returns the argument unchanged.

+
§

impl<T> Instrument for T

§

fn instrument(self, span: Span) -> Instrumented<Self>

Instruments this type with the provided [Span], returning an +Instrumented wrapper. Read more
§

fn in_current_span(self) -> Instrumented<Self>

Instruments this type with the current Span, returning an +Instrumented wrapper. Read more
Source§

impl<T, U> Into<U> for T
where + U: From<T>,

Source§

fn into(self) -> U

Calls U::from(self).

+

That is, this conversion is whatever the implementation of +From<T> for U chooses to do.

+
§

impl<T> PolicyExt for T
where + T: ?Sized,

§

fn and<P, B, E>(self, other: P) -> And<T, P>
where + T: Policy<B, E>, + P: Policy<B, E>,

Create a new Policy that returns [Action::Follow] only if self and other return +Action::Follow. Read more
§

fn or<P, B, E>(self, other: P) -> Or<T, P>
where + T: Policy<B, E>, + P: Policy<B, E>,

Create a new Policy that returns [Action::Follow] if either self or other returns +Action::Follow. Read more
Source§

impl<T> Same for T

Source§

type Output = T

Should always be Self
Source§

impl<T, U> TryFrom<U> for T
where + U: Into<T>,

Source§

type Error = Infallible

The type returned in the event of a conversion error.
Source§

fn try_from(value: U) -> Result<T, <T as TryFrom<U>>::Error>

Performs the conversion.
Source§

impl<T, U> TryInto<U> for T
where + U: TryFrom<T>,

Source§

type Error = <U as TryFrom<T>>::Error

The type returned in the event of a conversion error.
Source§

fn try_into(self) -> Result<U, <U as TryFrom<T>>::Error>

Performs the conversion.
§

impl<V, T> VZip<V> for T
where + V: MultiLane<T>,

§

fn vzip(self) -> V

§

impl<T> WithSubscriber for T

§

fn with_subscriber<S>(self, subscriber: S) -> WithDispatch<Self>
where + S: Into<Dispatch>,

Attaches the provided Subscriber to this type, returning a +[WithDispatch] wrapper. Read more
§

fn with_current_subscriber(self) -> WithDispatch<Self>

Attaches the current default Subscriber to this type, returning a +[WithDispatch] wrapper. Read more
\ No newline at end of file diff --git a/api/jellytau_lib/commands/player/struct.PlayerStatus.html b/api/jellytau_lib/commands/player/struct.PlayerStatus.html new file mode 100644 index 00000000..6480ccdc --- /dev/null +++ b/api/jellytau_lib/commands/player/struct.PlayerStatus.html @@ -0,0 +1,56 @@ +PlayerStatus in jellytau_lib::commands::player - RustSkip to main content

PlayerStatus

Struct PlayerStatus 

Source
pub struct PlayerStatus {
+    pub state: PlayerState,
+    pub position: f64,
+    pub duration: Option<f64>,
+    pub volume: f32,
+    pub muted: bool,
+    pub shuffle: bool,
+    pub repeat: RepeatMode,
+    pub backend: VideoBackend,
+    pub use_html5_element: bool,
+    pub merged_media: Option<MergedMediaItem>,
+    pub merged_is_playing: bool,
+    pub merged_volume: f32,
+}
Expand description

Response for player state queries

+

Fields§

§state: PlayerState§position: f64§duration: Option<f64>§volume: f32§muted: bool§shuffle: bool§repeat: RepeatMode§backend: VideoBackend

Backend being used (native = ExoPlayer/libmpv, html5 = fallback)

+
§use_html5_element: bool

Whether frontend should render HTML5 video element

+
§merged_media: Option<MergedMediaItem>

Media item from either local queue or remote session

+
§merged_is_playing: bool

Playing state from either local player or remote session

+
§merged_volume: f32

Volume from either local player or remote session (0-1 normalized)

+

Trait Implementations§

Source§

impl Debug for PlayerStatus

Source§

fn fmt(&self, f: &mut Formatter<'_>) -> Result

Formats the value using the given formatter. Read more
Source§

impl NamedType for PlayerStatus

Source§

fn sid() -> SpectaID

Source§

fn named_data_type( + type_map: &mut TypeCollection, + generics: &[DataType], +) -> NamedDataType

this is equivalent to [Type::inline] but returns a [NamedDataType] instead.
Source§

fn definition_named_data_type(type_map: &mut TypeCollection) -> NamedDataType

this is equivalent to [Type::definition] but returns a [NamedDataType] instead.
Source§

impl Serialize for PlayerStatus

Source§

fn serialize<__S>(&self, __serializer: __S) -> Result<__S::Ok, __S::Error>
where + __S: Serializer,

Serialize this value into the given Serde serializer. Read more
Source§

impl Type for PlayerStatus

Source§

fn inline(type_map: &mut TypeCollection, generics: Generics<'_>) -> DataType

Returns the definition of a type using the provided generics. Read more
Source§

fn reference(type_map: &mut TypeCollection, generics: &[DataType]) -> Reference

Generates a datatype corresponding to a reference to this type, +as determined by its category. Getting a reference to a type implies that +it should belong in the type map (since it has to be referenced from somewhere), +so the output of definition will be put into the type map.
Source§

impl Flatten for PlayerStatus

Auto Trait Implementations§

Blanket Implementations§

Source§

impl<T> Any for T
where + T: 'static + ?Sized,

Source§

fn type_id(&self) -> TypeId

Gets the TypeId of self. Read more
Source§

impl<T> Borrow<T> for T
where + T: ?Sized,

Source§

fn borrow(&self) -> &T

Immutably borrows from an owned value. Read more
Source§

impl<T> BorrowMut<T> for T
where + T: ?Sized,

Source§

fn borrow_mut(&mut self) -> &mut T

Mutably borrows from an owned value. Read more
Source§

impl<T> From<T> for T

Source§

fn from(t: T) -> T

Returns the argument unchanged.

+
§

impl<T> FunctionArg for T
where + T: Type,

§

fn to_datatype(type_map: &mut TypeCollection) -> Option<DataType>

Gets the type of an argument as a [DataType]. Read more
§

impl<T> Instrument for T

§

fn instrument(self, span: Span) -> Instrumented<Self>

Instruments this type with the provided [Span], returning an +Instrumented wrapper. Read more
§

fn in_current_span(self) -> Instrumented<Self>

Instruments this type with the current Span, returning an +Instrumented wrapper. Read more
Source§

impl<T, U> Into<U> for T
where + U: From<T>,

Source§

fn into(self) -> U

Calls U::from(self).

+

That is, this conversion is whatever the implementation of +From<T> for U chooses to do.

+
§

impl<T> IpcResponse for T
where + T: Serialize,

§

fn body(self) -> Result<InvokeResponseBody, Error>

Resolve the IPC response body.
§

impl<T> PolicyExt for T
where + T: ?Sized,

§

fn and<P, B, E>(self, other: P) -> And<T, P>
where + T: Policy<B, E>, + P: Policy<B, E>,

Create a new Policy that returns [Action::Follow] only if self and other return +Action::Follow. Read more
§

fn or<P, B, E>(self, other: P) -> Or<T, P>
where + T: Policy<B, E>, + P: Policy<B, E>,

Create a new Policy that returns [Action::Follow] if either self or other returns +Action::Follow. Read more
Source§

impl<T> Same for T

Source§

type Output = T

Should always be Self
Source§

impl<T> Serialize for T
where + T: Serialize + ?Sized,

Source§

fn erased_serialize(&self, serializer: &mut dyn Serializer) -> Result<(), Error>

Source§

fn do_erased_serialize( + &self, + serializer: &mut dyn Serializer, +) -> Result<(), ErrorImpl>

Source§

impl<T, U> TryFrom<U> for T
where + U: Into<T>,

Source§

type Error = Infallible

The type returned in the event of a conversion error.
Source§

fn try_from(value: U) -> Result<T, <T as TryFrom<U>>::Error>

Performs the conversion.
Source§

impl<T, U> TryInto<U> for T
where + U: TryFrom<T>,

Source§

type Error = <U as TryFrom<T>>::Error

The type returned in the event of a conversion error.
Source§

fn try_into(self) -> Result<U, <U as TryFrom<T>>::Error>

Performs the conversion.
§

impl<V, T> VZip<V> for T
where + V: MultiLane<T>,

§

fn vzip(self) -> V

§

impl<T> WithSubscriber for T

§

fn with_subscriber<S>(self, subscriber: S) -> WithDispatch<Self>
where + S: Into<Dispatch>,

Attaches the provided Subscriber to this type, returning a +[WithDispatch] wrapper. Read more
§

fn with_current_subscriber(self) -> WithDispatch<Self>

Attaches the current default Subscriber to this type, returning a +[WithDispatch] wrapper. Read more
\ No newline at end of file diff --git a/api/jellytau_lib/commands/player/struct.PreloadResult.html b/api/jellytau_lib/commands/player/struct.PreloadResult.html new file mode 100644 index 00000000..751ab471 --- /dev/null +++ b/api/jellytau_lib/commands/player/struct.PreloadResult.html @@ -0,0 +1,45 @@ +PreloadResult in jellytau_lib::commands::player - RustSkip to main content

PreloadResult

Struct PreloadResult 

Source
pub struct PreloadResult {
+    pub queued_count: usize,
+    pub already_downloaded: usize,
+    pub skipped: usize,
+}
Expand description

Response for preload operation

+

Fields§

§queued_count: usize

Number of tracks queued for preload

+
§already_downloaded: usize

Number of tracks already downloaded

+
§skipped: usize

Number of tracks skipped (no jellyfin ID or other reasons)

+

Trait Implementations§

Source§

impl Debug for PreloadResult

Source§

fn fmt(&self, f: &mut Formatter<'_>) -> Result

Formats the value using the given formatter. Read more
Source§

impl NamedType for PreloadResult

Source§

fn sid() -> SpectaID

Source§

fn named_data_type( + type_map: &mut TypeCollection, + generics: &[DataType], +) -> NamedDataType

this is equivalent to [Type::inline] but returns a [NamedDataType] instead.
Source§

fn definition_named_data_type(type_map: &mut TypeCollection) -> NamedDataType

this is equivalent to [Type::definition] but returns a [NamedDataType] instead.
Source§

impl Serialize for PreloadResult

Source§

fn serialize<__S>(&self, __serializer: __S) -> Result<__S::Ok, __S::Error>
where + __S: Serializer,

Serialize this value into the given Serde serializer. Read more
Source§

impl Type for PreloadResult

Source§

fn inline(type_map: &mut TypeCollection, generics: Generics<'_>) -> DataType

Returns the definition of a type using the provided generics. Read more
Source§

fn reference(type_map: &mut TypeCollection, generics: &[DataType]) -> Reference

Generates a datatype corresponding to a reference to this type, +as determined by its category. Getting a reference to a type implies that +it should belong in the type map (since it has to be referenced from somewhere), +so the output of definition will be put into the type map.
Source§

impl Flatten for PreloadResult

Auto Trait Implementations§

Blanket Implementations§

Source§

impl<T> Any for T
where + T: 'static + ?Sized,

Source§

fn type_id(&self) -> TypeId

Gets the TypeId of self. Read more
Source§

impl<T> Borrow<T> for T
where + T: ?Sized,

Source§

fn borrow(&self) -> &T

Immutably borrows from an owned value. Read more
Source§

impl<T> BorrowMut<T> for T
where + T: ?Sized,

Source§

fn borrow_mut(&mut self) -> &mut T

Mutably borrows from an owned value. Read more
Source§

impl<T> From<T> for T

Source§

fn from(t: T) -> T

Returns the argument unchanged.

+
§

impl<T> FunctionArg for T
where + T: Type,

§

fn to_datatype(type_map: &mut TypeCollection) -> Option<DataType>

Gets the type of an argument as a [DataType]. Read more
§

impl<T> Instrument for T

§

fn instrument(self, span: Span) -> Instrumented<Self>

Instruments this type with the provided [Span], returning an +Instrumented wrapper. Read more
§

fn in_current_span(self) -> Instrumented<Self>

Instruments this type with the current Span, returning an +Instrumented wrapper. Read more
Source§

impl<T, U> Into<U> for T
where + U: From<T>,

Source§

fn into(self) -> U

Calls U::from(self).

+

That is, this conversion is whatever the implementation of +From<T> for U chooses to do.

+
§

impl<T> IpcResponse for T
where + T: Serialize,

§

fn body(self) -> Result<InvokeResponseBody, Error>

Resolve the IPC response body.
§

impl<T> PolicyExt for T
where + T: ?Sized,

§

fn and<P, B, E>(self, other: P) -> And<T, P>
where + T: Policy<B, E>, + P: Policy<B, E>,

Create a new Policy that returns [Action::Follow] only if self and other return +Action::Follow. Read more
§

fn or<P, B, E>(self, other: P) -> Or<T, P>
where + T: Policy<B, E>, + P: Policy<B, E>,

Create a new Policy that returns [Action::Follow] if either self or other returns +Action::Follow. Read more
Source§

impl<T> Same for T

Source§

type Output = T

Should always be Self
Source§

impl<T> Serialize for T
where + T: Serialize + ?Sized,

Source§

fn erased_serialize(&self, serializer: &mut dyn Serializer) -> Result<(), Error>

Source§

fn do_erased_serialize( + &self, + serializer: &mut dyn Serializer, +) -> Result<(), ErrorImpl>

Source§

impl<T, U> TryFrom<U> for T
where + U: Into<T>,

Source§

type Error = Infallible

The type returned in the event of a conversion error.
Source§

fn try_from(value: U) -> Result<T, <T as TryFrom<U>>::Error>

Performs the conversion.
Source§

impl<T, U> TryInto<U> for T
where + U: TryFrom<T>,

Source§

type Error = <U as TryFrom<T>>::Error

The type returned in the event of a conversion error.
Source§

fn try_into(self) -> Result<U, <U as TryFrom<T>>::Error>

Performs the conversion.
§

impl<V, T> VZip<V> for T
where + V: MultiLane<T>,

§

fn vzip(self) -> V

§

impl<T> WithSubscriber for T

§

fn with_subscriber<S>(self, subscriber: S) -> WithDispatch<Self>
where + S: Into<Dispatch>,

Attaches the provided Subscriber to this type, returning a +[WithDispatch] wrapper. Read more
§

fn with_current_subscriber(self) -> WithDispatch<Self>

Attaches the current default Subscriber to this type, returning a +[WithDispatch] wrapper. Read more
\ No newline at end of file diff --git a/api/jellytau_lib/commands/player/struct.QueueStatus.html b/api/jellytau_lib/commands/player/struct.QueueStatus.html new file mode 100644 index 00000000..b47f60ed --- /dev/null +++ b/api/jellytau_lib/commands/player/struct.QueueStatus.html @@ -0,0 +1,45 @@ +QueueStatus in jellytau_lib::commands::player - RustSkip to main content

QueueStatus

Struct QueueStatus 

Source
pub struct QueueStatus {
+    pub items: Vec<MediaItem>,
+    pub current_index: Option<usize>,
+    pub shuffle: bool,
+    pub repeat: RepeatMode,
+    pub has_next: bool,
+    pub has_previous: bool,
+}
Expand description

Response for queue queries

+

Fields§

§items: Vec<MediaItem>§current_index: Option<usize>§shuffle: bool§repeat: RepeatMode§has_next: bool§has_previous: bool

Trait Implementations§

Source§

impl Debug for QueueStatus

Source§

fn fmt(&self, f: &mut Formatter<'_>) -> Result

Formats the value using the given formatter. Read more
Source§

impl NamedType for QueueStatus

Source§

fn sid() -> SpectaID

Source§

fn named_data_type( + type_map: &mut TypeCollection, + generics: &[DataType], +) -> NamedDataType

this is equivalent to [Type::inline] but returns a [NamedDataType] instead.
Source§

fn definition_named_data_type(type_map: &mut TypeCollection) -> NamedDataType

this is equivalent to [Type::definition] but returns a [NamedDataType] instead.
Source§

impl Serialize for QueueStatus

Source§

fn serialize<__S>(&self, __serializer: __S) -> Result<__S::Ok, __S::Error>
where + __S: Serializer,

Serialize this value into the given Serde serializer. Read more
Source§

impl Type for QueueStatus

Source§

fn inline(type_map: &mut TypeCollection, generics: Generics<'_>) -> DataType

Returns the definition of a type using the provided generics. Read more
Source§

fn reference(type_map: &mut TypeCollection, generics: &[DataType]) -> Reference

Generates a datatype corresponding to a reference to this type, +as determined by its category. Getting a reference to a type implies that +it should belong in the type map (since it has to be referenced from somewhere), +so the output of definition will be put into the type map.
Source§

impl Flatten for QueueStatus

Auto Trait Implementations§

Blanket Implementations§

Source§

impl<T> Any for T
where + T: 'static + ?Sized,

Source§

fn type_id(&self) -> TypeId

Gets the TypeId of self. Read more
Source§

impl<T> Borrow<T> for T
where + T: ?Sized,

Source§

fn borrow(&self) -> &T

Immutably borrows from an owned value. Read more
Source§

impl<T> BorrowMut<T> for T
where + T: ?Sized,

Source§

fn borrow_mut(&mut self) -> &mut T

Mutably borrows from an owned value. Read more
Source§

impl<T> From<T> for T

Source§

fn from(t: T) -> T

Returns the argument unchanged.

+
§

impl<T> FunctionArg for T
where + T: Type,

§

fn to_datatype(type_map: &mut TypeCollection) -> Option<DataType>

Gets the type of an argument as a [DataType]. Read more
§

impl<T> Instrument for T

§

fn instrument(self, span: Span) -> Instrumented<Self>

Instruments this type with the provided [Span], returning an +Instrumented wrapper. Read more
§

fn in_current_span(self) -> Instrumented<Self>

Instruments this type with the current Span, returning an +Instrumented wrapper. Read more
Source§

impl<T, U> Into<U> for T
where + U: From<T>,

Source§

fn into(self) -> U

Calls U::from(self).

+

That is, this conversion is whatever the implementation of +From<T> for U chooses to do.

+
§

impl<T> IpcResponse for T
where + T: Serialize,

§

fn body(self) -> Result<InvokeResponseBody, Error>

Resolve the IPC response body.
§

impl<T> PolicyExt for T
where + T: ?Sized,

§

fn and<P, B, E>(self, other: P) -> And<T, P>
where + T: Policy<B, E>, + P: Policy<B, E>,

Create a new Policy that returns [Action::Follow] only if self and other return +Action::Follow. Read more
§

fn or<P, B, E>(self, other: P) -> Or<T, P>
where + T: Policy<B, E>, + P: Policy<B, E>,

Create a new Policy that returns [Action::Follow] if either self or other returns +Action::Follow. Read more
Source§

impl<T> Same for T

Source§

type Output = T

Should always be Self
Source§

impl<T> Serialize for T
where + T: Serialize + ?Sized,

Source§

fn erased_serialize(&self, serializer: &mut dyn Serializer) -> Result<(), Error>

Source§

fn do_erased_serialize( + &self, + serializer: &mut dyn Serializer, +) -> Result<(), ErrorImpl>

Source§

impl<T, U> TryFrom<U> for T
where + U: Into<T>,

Source§

type Error = Infallible

The type returned in the event of a conversion error.
Source§

fn try_from(value: U) -> Result<T, <T as TryFrom<U>>::Error>

Performs the conversion.
Source§

impl<T, U> TryInto<U> for T
where + U: TryFrom<T>,

Source§

type Error = <U as TryFrom<T>>::Error

The type returned in the event of a conversion error.
Source§

fn try_into(self) -> Result<U, <U as TryFrom<T>>::Error>

Performs the conversion.
§

impl<V, T> VZip<V> for T
where + V: MultiLane<T>,

§

fn vzip(self) -> V

§

impl<T> WithSubscriber for T

§

fn with_subscriber<S>(self, subscriber: S) -> WithDispatch<Self>
where + S: Into<Dispatch>,

Attaches the provided Subscriber to this type, returning a +[WithDispatch] wrapper. Read more
§

fn with_current_subscriber(self) -> WithDispatch<Self>

Attaches the current default Subscriber to this type, returning a +[WithDispatch] wrapper. Read more
\ No newline at end of file diff --git a/api/jellytau_lib/commands/player/struct.SmartCacheWrapper.html b/api/jellytau_lib/commands/player/struct.SmartCacheWrapper.html new file mode 100644 index 00000000..35174aff --- /dev/null +++ b/api/jellytau_lib/commands/player/struct.SmartCacheWrapper.html @@ -0,0 +1,25 @@ +SmartCacheWrapper in jellytau_lib::commands::player - RustSkip to main content

SmartCacheWrapper

Struct SmartCacheWrapper 

Source
pub struct SmartCacheWrapper(pub Mutex<SmartCache>);
Expand description

SmartCache wrapper for Tauri state management

+

Tuple Fields§

§0: Mutex<SmartCache>

Auto Trait Implementations§

Blanket Implementations§

Source§

impl<T> Any for T
where + T: 'static + ?Sized,

Source§

fn type_id(&self) -> TypeId

Gets the TypeId of self. Read more
Source§

impl<T> Borrow<T> for T
where + T: ?Sized,

Source§

fn borrow(&self) -> &T

Immutably borrows from an owned value. Read more
Source§

impl<T> BorrowMut<T> for T
where + T: ?Sized,

Source§

fn borrow_mut(&mut self) -> &mut T

Mutably borrows from an owned value. Read more
Source§

impl<T> From<T> for T

Source§

fn from(t: T) -> T

Returns the argument unchanged.

+
§

impl<T> Instrument for T

§

fn instrument(self, span: Span) -> Instrumented<Self>

Instruments this type with the provided [Span], returning an +Instrumented wrapper. Read more
§

fn in_current_span(self) -> Instrumented<Self>

Instruments this type with the current Span, returning an +Instrumented wrapper. Read more
Source§

impl<T, U> Into<U> for T
where + U: From<T>,

Source§

fn into(self) -> U

Calls U::from(self).

+

That is, this conversion is whatever the implementation of +From<T> for U chooses to do.

+
§

impl<T> PolicyExt for T
where + T: ?Sized,

§

fn and<P, B, E>(self, other: P) -> And<T, P>
where + T: Policy<B, E>, + P: Policy<B, E>,

Create a new Policy that returns [Action::Follow] only if self and other return +Action::Follow. Read more
§

fn or<P, B, E>(self, other: P) -> Or<T, P>
where + T: Policy<B, E>, + P: Policy<B, E>,

Create a new Policy that returns [Action::Follow] if either self or other returns +Action::Follow. Read more
Source§

impl<T> Same for T

Source§

type Output = T

Should always be Self
Source§

impl<T, U> TryFrom<U> for T
where + U: Into<T>,

Source§

type Error = Infallible

The type returned in the event of a conversion error.
Source§

fn try_from(value: U) -> Result<T, <T as TryFrom<U>>::Error>

Performs the conversion.
Source§

impl<T, U> TryInto<U> for T
where + U: TryFrom<T>,

Source§

type Error = <U as TryFrom<T>>::Error

The type returned in the event of a conversion error.
Source§

fn try_into(self) -> Result<U, <U as TryFrom<T>>::Error>

Performs the conversion.
§

impl<V, T> VZip<V> for T
where + V: MultiLane<T>,

§

fn vzip(self) -> V

§

impl<T> WithSubscriber for T

§

fn with_subscriber<S>(self, subscriber: S) -> WithDispatch<Self>
where + S: Into<Dispatch>,

Attaches the provided Subscriber to this type, returning a +[WithDispatch] wrapper. Read more
§

fn with_current_subscriber(self) -> WithDispatch<Self>

Attaches the current default Subscriber to this type, returning a +[WithDispatch] wrapper. Read more
\ No newline at end of file diff --git a/api/jellytau_lib/commands/player/struct.VideoSettingsWrapper.html b/api/jellytau_lib/commands/player/struct.VideoSettingsWrapper.html new file mode 100644 index 00000000..f7dd1ff8 --- /dev/null +++ b/api/jellytau_lib/commands/player/struct.VideoSettingsWrapper.html @@ -0,0 +1,26 @@ +VideoSettingsWrapper in jellytau_lib::commands::player - RustSkip to main content

VideoSettingsWrapper

Struct VideoSettingsWrapper 

Source
pub struct VideoSettingsWrapper(pub Mutex<VideoSettings>);
Expand description

Video settings state wrapper for Tauri

+

@req: DR-048 - Video settings (auto-play toggle, countdown duration)

+

Tuple Fields§

§0: Mutex<VideoSettings>

Auto Trait Implementations§

Blanket Implementations§

Source§

impl<T> Any for T
where + T: 'static + ?Sized,

Source§

fn type_id(&self) -> TypeId

Gets the TypeId of self. Read more
Source§

impl<T> Borrow<T> for T
where + T: ?Sized,

Source§

fn borrow(&self) -> &T

Immutably borrows from an owned value. Read more
Source§

impl<T> BorrowMut<T> for T
where + T: ?Sized,

Source§

fn borrow_mut(&mut self) -> &mut T

Mutably borrows from an owned value. Read more
Source§

impl<T> From<T> for T

Source§

fn from(t: T) -> T

Returns the argument unchanged.

+
§

impl<T> Instrument for T

§

fn instrument(self, span: Span) -> Instrumented<Self>

Instruments this type with the provided [Span], returning an +Instrumented wrapper. Read more
§

fn in_current_span(self) -> Instrumented<Self>

Instruments this type with the current Span, returning an +Instrumented wrapper. Read more
Source§

impl<T, U> Into<U> for T
where + U: From<T>,

Source§

fn into(self) -> U

Calls U::from(self).

+

That is, this conversion is whatever the implementation of +From<T> for U chooses to do.

+
§

impl<T> PolicyExt for T
where + T: ?Sized,

§

fn and<P, B, E>(self, other: P) -> And<T, P>
where + T: Policy<B, E>, + P: Policy<B, E>,

Create a new Policy that returns [Action::Follow] only if self and other return +Action::Follow. Read more
§

fn or<P, B, E>(self, other: P) -> Or<T, P>
where + T: Policy<B, E>, + P: Policy<B, E>,

Create a new Policy that returns [Action::Follow] if either self or other returns +Action::Follow. Read more
Source§

impl<T> Same for T

Source§

type Output = T

Should always be Self
Source§

impl<T, U> TryFrom<U> for T
where + U: Into<T>,

Source§

type Error = Infallible

The type returned in the event of a conversion error.
Source§

fn try_from(value: U) -> Result<T, <T as TryFrom<U>>::Error>

Performs the conversion.
Source§

impl<T, U> TryInto<U> for T
where + U: TryFrom<T>,

Source§

type Error = <U as TryFrom<T>>::Error

The type returned in the event of a conversion error.
Source§

fn try_into(self) -> Result<U, <U as TryFrom<T>>::Error>

Performs the conversion.
§

impl<V, T> VZip<V> for T
where + V: MultiLane<T>,

§

fn vzip(self) -> V

§

impl<T> WithSubscriber for T

§

fn with_subscriber<S>(self, subscriber: S) -> WithDispatch<Self>
where + S: Into<Dispatch>,

Attaches the provided Subscriber to this type, returning a +[WithDispatch] wrapper. Read more
§

fn with_current_subscriber(self) -> WithDispatch<Self>

Attaches the current default Subscriber to this type, returning a +[WithDispatch] wrapper. Read more
\ No newline at end of file diff --git a/api/jellytau_lib/commands/player/timers/fn.player_cancel_autoplay_countdown.html b/api/jellytau_lib/commands/player/timers/fn.player_cancel_autoplay_countdown.html new file mode 100644 index 00000000..57b8d1ac --- /dev/null +++ b/api/jellytau_lib/commands/player/timers/fn.player_cancel_autoplay_countdown.html @@ -0,0 +1,4 @@ +player_cancel_autoplay_countdown in jellytau_lib::commands::player::timers - RustSkip to main content

player_cancel_autoplay_countdown

Function player_cancel_autoplay_countdown 

Source
pub async fn player_cancel_autoplay_countdown(
+    player: State<'_, PlayerStateWrapper>,
+) -> Result<(), String>
Expand description

Cancel active autoplay countdown

+
\ No newline at end of file diff --git a/api/jellytau_lib/commands/player/timers/fn.player_cancel_sleep_timer.html b/api/jellytau_lib/commands/player/timers/fn.player_cancel_sleep_timer.html new file mode 100644 index 00000000..76792ff1 --- /dev/null +++ b/api/jellytau_lib/commands/player/timers/fn.player_cancel_sleep_timer.html @@ -0,0 +1,4 @@ +player_cancel_sleep_timer in jellytau_lib::commands::player::timers - RustSkip to main content

player_cancel_sleep_timer

Function player_cancel_sleep_timer 

Source
pub async fn player_cancel_sleep_timer(
+    player: State<'_, PlayerStateWrapper>,
+) -> Result<SleepTimerState, String>
Expand description

Cancel sleep timer

+
\ No newline at end of file diff --git a/api/jellytau_lib/commands/player/timers/fn.player_get_autoplay_settings.html b/api/jellytau_lib/commands/player/timers/fn.player_get_autoplay_settings.html new file mode 100644 index 00000000..a4e21917 --- /dev/null +++ b/api/jellytau_lib/commands/player/timers/fn.player_get_autoplay_settings.html @@ -0,0 +1,4 @@ +player_get_autoplay_settings in jellytau_lib::commands::player::timers - RustSkip to main content

player_get_autoplay_settings

Function player_get_autoplay_settings 

Source
pub async fn player_get_autoplay_settings(
+    player: State<'_, PlayerStateWrapper>,
+) -> Result<AutoplaySettings, String>
Expand description

Get autoplay settings

+
\ No newline at end of file diff --git a/api/jellytau_lib/commands/player/timers/fn.player_get_sleep_timer.html b/api/jellytau_lib/commands/player/timers/fn.player_get_sleep_timer.html new file mode 100644 index 00000000..99054c7b --- /dev/null +++ b/api/jellytau_lib/commands/player/timers/fn.player_get_sleep_timer.html @@ -0,0 +1,4 @@ +player_get_sleep_timer in jellytau_lib::commands::player::timers - RustSkip to main content

player_get_sleep_timer

Function player_get_sleep_timer 

Source
pub async fn player_get_sleep_timer(
+    player: State<'_, PlayerStateWrapper>,
+) -> Result<SleepTimerState, String>
Expand description

Get current sleep timer state

+
\ No newline at end of file diff --git a/api/jellytau_lib/commands/player/timers/fn.player_on_playback_ended.html b/api/jellytau_lib/commands/player/timers/fn.player_on_playback_ended.html new file mode 100644 index 00000000..367b9c44 --- /dev/null +++ b/api/jellytau_lib/commands/player/timers/fn.player_on_playback_ended.html @@ -0,0 +1,15 @@ +player_on_playback_ended in jellytau_lib::commands::player::timers - RustSkip to main content

player_on_playback_ended

Function player_on_playback_ended 

Source
pub async fn player_on_playback_ended(
+    player: State<'_, PlayerStateWrapper>,
+    repository_manager: State<'_, RepositoryManagerWrapper>,
+    db: State<'_, DatabaseWrapper>,
+    item_id: Option<String>,
+    repository_handle: Option<String>,
+) -> Result<(), String>
Expand description

Handle playback ended event - triggers autoplay decision logic +This is called from:

+
    +
  • Frontend when HTML5 video ends (Linux/desktop) - passes itemId + repositoryHandle for the video
  • +
  • Frontend when audio track ends via backend event - no itemId/repositoryHandle needed
  • +
  • Android JNI callback also triggers this logic directly
  • +
+

TRACES: UR-023, UR-026, UR-040 | DR-047, DR-052, DR-129

+
\ No newline at end of file diff --git a/api/jellytau_lib/commands/player/timers/fn.player_play_next_episode.html b/api/jellytau_lib/commands/player/timers/fn.player_play_next_episode.html new file mode 100644 index 00000000..553c3c21 --- /dev/null +++ b/api/jellytau_lib/commands/player/timers/fn.player_play_next_episode.html @@ -0,0 +1,6 @@ +player_play_next_episode in jellytau_lib::commands::player::timers - RustSkip to main content

player_play_next_episode

Function player_play_next_episode 

Source
pub async fn player_play_next_episode(
+    player: State<'_, PlayerStateWrapper>,
+    db: State<'_, DatabaseWrapper>,
+    item: PlayItemRequest,
+) -> Result<PlayerStatus, String>
Expand description

Play next episode (user confirmed from popup)

+
\ No newline at end of file diff --git a/api/jellytau_lib/commands/player/timers/fn.player_recover_stream.html b/api/jellytau_lib/commands/player/timers/fn.player_recover_stream.html new file mode 100644 index 00000000..81cc4522 --- /dev/null +++ b/api/jellytau_lib/commands/player/timers/fn.player_recover_stream.html @@ -0,0 +1,18 @@ +player_recover_stream in jellytau_lib::commands::player::timers - RustSkip to main content

player_recover_stream

Function player_recover_stream 

Source
pub async fn player_recover_stream(
+    player: State<'_, PlayerStateWrapper>,
+) -> Result<bool, String>
Expand description

Try to recover playback after a recoverable player error, reporting +whether it was handled.

+

The frontend’s error handler stops the player, which is right for a real +failure and wrong for a network blip — it turned every hiccup into “playback +died”. This is the echo path for backends that cannot decide in-process: +MpvBackend is constructed before PlayerController exists ([lib.rs]), so +its event thread has no controller to ask. It emits the error, the frontend +echoes it here, and the decision stays in Rust — the same shape as +PlaybackEndedplayer_on_playback_ended.

+

Returns true when the stream was re-opened and the caller must NOT stop the +player; false when the error is real and should be surfaced as before. +Android decides inside its JNI callback and only emits errors it has already +declined to recover, so this reports false for those without a second +opinion — the shared attempt budget is spent by then either way.

+

TRACES: UR-004, UR-040 | DR-130 | UT-117

+
\ No newline at end of file diff --git a/api/jellytau_lib/commands/player/timers/fn.player_report_media_loaded.html b/api/jellytau_lib/commands/player/timers/fn.player_report_media_loaded.html new file mode 100644 index 00000000..6442c14b --- /dev/null +++ b/api/jellytau_lib/commands/player/timers/fn.player_report_media_loaded.html @@ -0,0 +1,5 @@ +player_report_media_loaded in jellytau_lib::commands::player::timers - RustSkip to main content

player_report_media_loaded

Function player_report_media_loaded 

Source
pub async fn player_report_media_loaded(
+    player: State<'_, PlayerStateWrapper>,
+    duration: f64,
+) -> Result<(), String>
Expand description

Report that the HTML5

+
\ No newline at end of file diff --git a/api/jellytau_lib/commands/player/timers/fn.player_report_position.html b/api/jellytau_lib/commands/player/timers/fn.player_report_position.html new file mode 100644 index 00000000..20e033db --- /dev/null +++ b/api/jellytau_lib/commands/player/timers/fn.player_report_position.html @@ -0,0 +1,7 @@ +player_report_position in jellytau_lib::commands::player::timers - RustSkip to main content

player_report_position

Function player_report_position 

Source
pub async fn player_report_position(
+    player: State<'_, PlayerStateWrapper>,
+    position: f64,
+    duration: f64,
+) -> Result<(), String>
Expand description

Report an HTML5

+
\ No newline at end of file diff --git a/api/jellytau_lib/commands/player/timers/fn.player_report_state.html b/api/jellytau_lib/commands/player/timers/fn.player_report_state.html new file mode 100644 index 00000000..fdf05379 --- /dev/null +++ b/api/jellytau_lib/commands/player/timers/fn.player_report_state.html @@ -0,0 +1,6 @@ +player_report_state in jellytau_lib::commands::player::timers - RustSkip to main content

player_report_state

Function player_report_state 

Source
pub async fn player_report_state(
+    player: State<'_, PlayerStateWrapper>,
+    state: String,
+    media_id: Option<String>,
+) -> Result<(), String>
Expand description

Report an HTML5

+
\ No newline at end of file diff --git a/api/jellytau_lib/commands/player/timers/fn.player_set_autoplay_settings.html b/api/jellytau_lib/commands/player/timers/fn.player_set_autoplay_settings.html new file mode 100644 index 00000000..73d811c0 --- /dev/null +++ b/api/jellytau_lib/commands/player/timers/fn.player_set_autoplay_settings.html @@ -0,0 +1,7 @@ +player_set_autoplay_settings in jellytau_lib::commands::player::timers - RustSkip to main content

player_set_autoplay_settings

Function player_set_autoplay_settings 

Source
pub async fn player_set_autoplay_settings(
+    player: State<'_, PlayerStateWrapper>,
+    db: State<'_, DatabaseWrapper>,
+    user_id: String,
+    settings: AutoplaySettings,
+) -> Result<AutoplaySettings, String>
Expand description

Set autoplay settings and persist to database

+
\ No newline at end of file diff --git a/api/jellytau_lib/commands/player/timers/fn.player_set_sleep_timer.html b/api/jellytau_lib/commands/player/timers/fn.player_set_sleep_timer.html new file mode 100644 index 00000000..2eacfbd6 --- /dev/null +++ b/api/jellytau_lib/commands/player/timers/fn.player_set_sleep_timer.html @@ -0,0 +1,5 @@ +player_set_sleep_timer in jellytau_lib::commands::player::timers - RustSkip to main content

player_set_sleep_timer

Function player_set_sleep_timer 

Source
pub async fn player_set_sleep_timer(
+    player: State<'_, PlayerStateWrapper>,
+    mode: SleepTimerMode,
+) -> Result<SleepTimerState, String>
Expand description

Set sleep timer mode

+
\ No newline at end of file diff --git a/api/jellytau_lib/commands/player/timers/index.html b/api/jellytau_lib/commands/player/timers/index.html new file mode 100644 index 00000000..9fe5a1e4 --- /dev/null +++ b/api/jellytau_lib/commands/player/timers/index.html @@ -0,0 +1,8 @@ +jellytau_lib::commands::player::timers - RustSkip to main content

Module timers

Module timers 

Source
Expand description

Sleep-timer and autoplay commands.

+

TRACES: UR-026, UR-023 | DR-029, DR-047, DR-049

+

Thin command adapters over PlayerController’s sleep-timer and autoplay +logic, plus persistence of autoplay settings to the database.

+

Functions§

player_cancel_autoplay_countdown
Cancel active autoplay countdown
player_cancel_sleep_timer
Cancel sleep timer
player_get_autoplay_settings
Get autoplay settings
player_get_sleep_timer
Get current sleep timer state
player_on_playback_ended
Handle playback ended event - triggers autoplay decision logic +This is called from:
player_play_next_episode
Play next episode (user confirmed from popup)
player_recover_stream
Try to recover playback after a recoverable player error, reporting +whether it was handled.
player_report_media_loaded
Report that the HTML5
player_report_position
Report an HTML5
player_report_state
Report an HTML5
player_set_autoplay_settings
Set autoplay settings and persist to database
player_set_sleep_timer
Set sleep timer mode
\ No newline at end of file diff --git a/api/jellytau_lib/commands/player/timers/sidebar-items.js b/api/jellytau_lib/commands/player/timers/sidebar-items.js new file mode 100644 index 00000000..83e4ac66 --- /dev/null +++ b/api/jellytau_lib/commands/player/timers/sidebar-items.js @@ -0,0 +1 @@ +window.SIDEBAR_ITEMS = {"fn":["player_cancel_autoplay_countdown","player_cancel_sleep_timer","player_get_autoplay_settings","player_get_sleep_timer","player_on_playback_ended","player_play_next_episode","player_recover_stream","player_report_media_loaded","player_report_position","player_report_state","player_set_autoplay_settings","player_set_sleep_timer"]}; \ No newline at end of file diff --git a/api/jellytau_lib/commands/playlist/fn.playlist_add_items.html b/api/jellytau_lib/commands/playlist/fn.playlist_add_items.html new file mode 100644 index 00000000..04b833aa --- /dev/null +++ b/api/jellytau_lib/commands/playlist/fn.playlist_add_items.html @@ -0,0 +1,7 @@ +playlist_add_items in jellytau_lib::commands::playlist - RustSkip to main content

playlist_add_items

Function playlist_add_items 

Source
pub async fn playlist_add_items(
+    manager: State<'_, RepositoryManagerWrapper>,
+    handle: String,
+    playlist_id: String,
+    item_ids: Vec<String>,
+) -> Result<(), String>
Expand description

Add items to a playlist

+
\ No newline at end of file diff --git a/api/jellytau_lib/commands/playlist/fn.playlist_create.html b/api/jellytau_lib/commands/playlist/fn.playlist_create.html new file mode 100644 index 00000000..d14f0708 --- /dev/null +++ b/api/jellytau_lib/commands/playlist/fn.playlist_create.html @@ -0,0 +1,7 @@ +playlist_create in jellytau_lib::commands::playlist - RustSkip to main content

playlist_create

Function playlist_create 

Source
pub async fn playlist_create(
+    manager: State<'_, RepositoryManagerWrapper>,
+    handle: String,
+    name: String,
+    item_ids: Option<Vec<String>>,
+) -> Result<PlaylistCreatedResult, String>
Expand description

Create a new playlist

+
\ No newline at end of file diff --git a/api/jellytau_lib/commands/playlist/fn.playlist_delete.html b/api/jellytau_lib/commands/playlist/fn.playlist_delete.html new file mode 100644 index 00000000..300886ea --- /dev/null +++ b/api/jellytau_lib/commands/playlist/fn.playlist_delete.html @@ -0,0 +1,6 @@ +playlist_delete in jellytau_lib::commands::playlist - RustSkip to main content

playlist_delete

Function playlist_delete 

Source
pub async fn playlist_delete(
+    manager: State<'_, RepositoryManagerWrapper>,
+    handle: String,
+    playlist_id: String,
+) -> Result<(), String>
Expand description

Delete a playlist

+
\ No newline at end of file diff --git a/api/jellytau_lib/commands/playlist/fn.playlist_get_items.html b/api/jellytau_lib/commands/playlist/fn.playlist_get_items.html new file mode 100644 index 00000000..56e1cf41 --- /dev/null +++ b/api/jellytau_lib/commands/playlist/fn.playlist_get_items.html @@ -0,0 +1,6 @@ +playlist_get_items in jellytau_lib::commands::playlist - RustSkip to main content

playlist_get_items

Function playlist_get_items 

Source
pub async fn playlist_get_items(
+    manager: State<'_, RepositoryManagerWrapper>,
+    handle: String,
+    playlist_id: String,
+) -> Result<Vec<PlaylistEntry>, String>
Expand description

Get playlist items with PlaylistItemId

+
\ No newline at end of file diff --git a/api/jellytau_lib/commands/playlist/fn.playlist_move_item.html b/api/jellytau_lib/commands/playlist/fn.playlist_move_item.html new file mode 100644 index 00000000..b90b55e4 --- /dev/null +++ b/api/jellytau_lib/commands/playlist/fn.playlist_move_item.html @@ -0,0 +1,8 @@ +playlist_move_item in jellytau_lib::commands::playlist - RustSkip to main content

playlist_move_item

Function playlist_move_item 

Source
pub async fn playlist_move_item(
+    manager: State<'_, RepositoryManagerWrapper>,
+    handle: String,
+    playlist_id: String,
+    item_id: String,
+    new_index: u32,
+) -> Result<(), String>
Expand description

Move a playlist item to a new position

+
\ No newline at end of file diff --git a/api/jellytau_lib/commands/playlist/fn.playlist_remove_items.html b/api/jellytau_lib/commands/playlist/fn.playlist_remove_items.html new file mode 100644 index 00000000..9ac81a6b --- /dev/null +++ b/api/jellytau_lib/commands/playlist/fn.playlist_remove_items.html @@ -0,0 +1,7 @@ +playlist_remove_items in jellytau_lib::commands::playlist - RustSkip to main content

playlist_remove_items

Function playlist_remove_items 

Source
pub async fn playlist_remove_items(
+    manager: State<'_, RepositoryManagerWrapper>,
+    handle: String,
+    playlist_id: String,
+    entry_ids: Vec<String>,
+) -> Result<(), String>
Expand description

Remove items from a playlist (uses PlaylistItemId entry IDs, NOT media item IDs)

+
\ No newline at end of file diff --git a/api/jellytau_lib/commands/playlist/fn.playlist_rename.html b/api/jellytau_lib/commands/playlist/fn.playlist_rename.html new file mode 100644 index 00000000..e4b27c0a --- /dev/null +++ b/api/jellytau_lib/commands/playlist/fn.playlist_rename.html @@ -0,0 +1,7 @@ +playlist_rename in jellytau_lib::commands::playlist - RustSkip to main content

playlist_rename

Function playlist_rename 

Source
pub async fn playlist_rename(
+    manager: State<'_, RepositoryManagerWrapper>,
+    handle: String,
+    playlist_id: String,
+    name: String,
+) -> Result<(), String>
Expand description

Rename a playlist

+
\ No newline at end of file diff --git a/api/jellytau_lib/commands/playlist/index.html b/api/jellytau_lib/commands/playlist/index.html new file mode 100644 index 00000000..184f2c7d --- /dev/null +++ b/api/jellytau_lib/commands/playlist/index.html @@ -0,0 +1,4 @@ +jellytau_lib::commands::playlist - RustSkip to main content

Module playlist

Module playlist 

Source
Expand description

Tauri commands for playlist management +Uses handle-based system: UUID -> Arc

+

TRACES: UR-014 | JA-019, JA-020

+

Functions§

playlist_add_items
Add items to a playlist
playlist_create
Create a new playlist
playlist_delete
Delete a playlist
playlist_get_items
Get playlist items with PlaylistItemId
playlist_move_item
Move a playlist item to a new position
playlist_remove_items
Remove items from a playlist (uses PlaylistItemId entry IDs, NOT media item IDs)
playlist_rename
Rename a playlist
\ No newline at end of file diff --git a/api/jellytau_lib/commands/playlist/sidebar-items.js b/api/jellytau_lib/commands/playlist/sidebar-items.js new file mode 100644 index 00000000..0c55c5c4 --- /dev/null +++ b/api/jellytau_lib/commands/playlist/sidebar-items.js @@ -0,0 +1 @@ +window.SIDEBAR_ITEMS = {"fn":["playlist_add_items","playlist_create","playlist_delete","playlist_get_items","playlist_move_item","playlist_remove_items","playlist_rename"]}; \ No newline at end of file diff --git a/api/jellytau_lib/commands/repository/constant.FAVORITES_CHANGED_EVENT.html b/api/jellytau_lib/commands/repository/constant.FAVORITES_CHANGED_EVENT.html new file mode 100644 index 00000000..356b6782 --- /dev/null +++ b/api/jellytau_lib/commands/repository/constant.FAVORITES_CHANGED_EVENT.html @@ -0,0 +1,5 @@ +FAVORITES_CHANGED_EVENT in jellytau_lib::commands::repository - RustSkip to main content

FAVORITES_CHANGED_EVENT

Constant FAVORITES_CHANGED_EVENT 

Source
pub const FAVORITES_CHANGED_EVENT: &str = "favorites-changed";
Expand description

Tauri event announcing that favourite state changed behind the UI’s back — +either because the server disagreed with the cache on a background refresh, +or because pending offline toggles were pushed on reconnect.

+

TRACES: UR-069 | DR-120

+
\ No newline at end of file diff --git a/api/jellytau_lib/commands/repository/constant.SEARCH_EVENT_NAME.html b/api/jellytau_lib/commands/repository/constant.SEARCH_EVENT_NAME.html new file mode 100644 index 00000000..66e3b211 --- /dev/null +++ b/api/jellytau_lib/commands/repository/constant.SEARCH_EVENT_NAME.html @@ -0,0 +1,2 @@ +SEARCH_EVENT_NAME in jellytau_lib::commands::repository - RustSkip to main content

SEARCH_EVENT_NAME

Constant SEARCH_EVENT_NAME 

Source
pub const SEARCH_EVENT_NAME: &str = "search-event";
Expand description

Tauri event name carrying the merged (cache + server) search results.

+
\ No newline at end of file diff --git a/api/jellytau_lib/commands/repository/fn.changed_favorite_ids.html b/api/jellytau_lib/commands/repository/fn.changed_favorite_ids.html new file mode 100644 index 00000000..9877b618 --- /dev/null +++ b/api/jellytau_lib/commands/repository/fn.changed_favorite_ids.html @@ -0,0 +1,11 @@ +changed_favorite_ids in jellytau_lib::commands::repository - RustSkip to main content

changed_favorite_ids

Function changed_favorite_ids 

Source
fn changed_favorite_ids(
+    cached: &HashSet<String>,
+    server: &HashSet<String>,
+) -> Vec<String>
Expand description

Ids whose favourite state differs between what we showed and what the server +has — favourited elsewhere since the cache was written, or un-favourited +elsewhere.

+

Pulled out of the command so the “emit nothing when nothing changed” rule is +testable: an unchanged set must leave a quiet page quiet rather than +triggering a refetch on every visit.

+

TRACES: UR-069 | DR-120 | UT-107

+
\ No newline at end of file diff --git a/api/jellytau_lib/commands/repository/fn.repository_clear_watch_history.html b/api/jellytau_lib/commands/repository/fn.repository_clear_watch_history.html new file mode 100644 index 00000000..4c001899 --- /dev/null +++ b/api/jellytau_lib/commands/repository/fn.repository_clear_watch_history.html @@ -0,0 +1,12 @@ +repository_clear_watch_history in jellytau_lib::commands::repository - RustSkip to main content

repository_clear_watch_history

Function repository_clear_watch_history 

Source
pub async fn repository_clear_watch_history(
+    manager: State<'_, RepositoryManagerWrapper>,
+    handle: String,
+    item_id: String,
+) -> Result<(), String>
Expand description

Erase the viewer’s watch history for an item.

+

Clears the played flag and the resume position; on a series or season the +server applies it to everything inside. A series cleared this way is “never +watched” again, so repository_get_series_current_episode returns its +premiere. Requires the server — offline this fails rather than diverging +local state the next sync would overwrite.

+

TRACES: UR-064 | DR-106

+
\ No newline at end of file diff --git a/api/jellytau_lib/commands/repository/fn.repository_create.html b/api/jellytau_lib/commands/repository/fn.repository_create.html new file mode 100644 index 00000000..17446c5f --- /dev/null +++ b/api/jellytau_lib/commands/repository/fn.repository_create.html @@ -0,0 +1,12 @@ +repository_create in jellytau_lib::commands::repository - RustSkip to main content

repository_create

Function repository_create 

Source
pub async fn repository_create(
+    manager: State<'_, RepositoryManagerWrapper>,
+    player: State<'_, PlayerStateWrapper>,
+    db: State<'_, DatabaseWrapper>,
+    connectivity: State<'_, ConnectivityMonitorWrapper>,
+    server_url: String,
+    user_id: String,
+    access_token: String,
+    server_id: String,
+) -> Result<String, String>
Expand description

Create a new repository instance +Returns a handle (UUID) for accessing the repository

+
\ No newline at end of file diff --git a/api/jellytau_lib/commands/repository/fn.repository_destroy.html b/api/jellytau_lib/commands/repository/fn.repository_destroy.html new file mode 100644 index 00000000..75acb769 --- /dev/null +++ b/api/jellytau_lib/commands/repository/fn.repository_destroy.html @@ -0,0 +1,5 @@ +repository_destroy in jellytau_lib::commands::repository - RustSkip to main content

repository_destroy

Function repository_destroy 

Source
pub async fn repository_destroy(
+    manager: State<'_, RepositoryManagerWrapper>,
+    handle: String,
+) -> Result<(), String>
Expand description

Destroy a repository instance

+
\ No newline at end of file diff --git a/api/jellytau_lib/commands/repository/fn.repository_get_audio_only_stream_url_for_video.html b/api/jellytau_lib/commands/repository/fn.repository_get_audio_only_stream_url_for_video.html new file mode 100644 index 00000000..7068ffd6 --- /dev/null +++ b/api/jellytau_lib/commands/repository/fn.repository_get_audio_only_stream_url_for_video.html @@ -0,0 +1,10 @@ +repository_get_audio_only_stream_url_for_video in jellytau_lib::commands::repository - RustSkip to main content

repository_get_audio_only_stream_url_for_video

Function repository_get_audio_only_stream_url_for_video 

Source
pub async fn repository_get_audio_only_stream_url_for_video(
+    manager: State<'_, RepositoryManagerWrapper>,
+    handle: String,
+    item_id: String,
+    media_source_id: Option<String>,
+    start_time_seconds: Option<f64>,
+    audio_stream_index: Option<i32>,
+) -> Result<String, String>
Expand description

Get an audio-only stream URL for a video item (background-audio handoff).

+

TRACES: UR-040 | JA-032 | UT-061

+
\ No newline at end of file diff --git a/api/jellytau_lib/commands/repository/fn.repository_get_audio_stream_url.html b/api/jellytau_lib/commands/repository/fn.repository_get_audio_stream_url.html new file mode 100644 index 00000000..ddcf7452 --- /dev/null +++ b/api/jellytau_lib/commands/repository/fn.repository_get_audio_stream_url.html @@ -0,0 +1,6 @@ +repository_get_audio_stream_url in jellytau_lib::commands::repository - RustSkip to main content

repository_get_audio_stream_url

Function repository_get_audio_stream_url 

Source
pub async fn repository_get_audio_stream_url(
+    manager: State<'_, RepositoryManagerWrapper>,
+    handle: String,
+    item_id: String,
+) -> Result<String, String>
Expand description

Get audio stream URL for a track

+
\ No newline at end of file diff --git a/api/jellytau_lib/commands/repository/fn.repository_get_channels.html b/api/jellytau_lib/commands/repository/fn.repository_get_channels.html new file mode 100644 index 00000000..093b07cc --- /dev/null +++ b/api/jellytau_lib/commands/repository/fn.repository_get_channels.html @@ -0,0 +1,5 @@ +repository_get_channels in jellytau_lib::commands::repository - RustSkip to main content

repository_get_channels

Function repository_get_channels 

Source
pub async fn repository_get_channels(
+    manager: State<'_, RepositoryManagerWrapper>,
+    handle: String,
+) -> Result<SearchResult, String>
Expand description

Get the root list of plugin “Channels”

+
\ No newline at end of file diff --git a/api/jellytau_lib/commands/repository/fn.repository_get_download_disk_usage.html b/api/jellytau_lib/commands/repository/fn.repository_get_download_disk_usage.html new file mode 100644 index 00000000..9461373c --- /dev/null +++ b/api/jellytau_lib/commands/repository/fn.repository_get_download_disk_usage.html @@ -0,0 +1,6 @@ +repository_get_download_disk_usage in jellytau_lib::commands::repository - RustSkip to main content

repository_get_download_disk_usage

Function repository_get_download_disk_usage 

Source
pub async fn repository_get_download_disk_usage(
+    manager: State<'_, RepositoryManagerWrapper>,
+    handle: String,
+) -> Result<DownloadDiskUsage, String>
Expand description

On-disk usage of downloaded content (device total, per-item/container bytes).

+

TRACES: UR-056 | DR-085

+
\ No newline at end of file diff --git a/api/jellytau_lib/commands/repository/fn.repository_get_downloaded_items.html b/api/jellytau_lib/commands/repository/fn.repository_get_downloaded_items.html new file mode 100644 index 00000000..feef3f21 --- /dev/null +++ b/api/jellytau_lib/commands/repository/fn.repository_get_downloaded_items.html @@ -0,0 +1,8 @@ +repository_get_downloaded_items in jellytau_lib::commands::repository - RustSkip to main content

repository_get_downloaded_items

Function repository_get_downloaded_items 

Source
pub async fn repository_get_downloaded_items(
+    manager: State<'_, RepositoryManagerWrapper>,
+    handle: String,
+    parent_id: String,
+    options: Option<GetItemsOptions>,
+) -> Result<SearchResult, String>
Expand description

Downloaded-only browse: items under a container that are on the device.

+

TRACES: UR-055 | DR-082, DR-083

+
\ No newline at end of file diff --git a/api/jellytau_lib/commands/repository/fn.repository_get_downloaded_libraries.html b/api/jellytau_lib/commands/repository/fn.repository_get_downloaded_libraries.html new file mode 100644 index 00000000..d7f8d775 --- /dev/null +++ b/api/jellytau_lib/commands/repository/fn.repository_get_downloaded_libraries.html @@ -0,0 +1,8 @@ +repository_get_downloaded_libraries in jellytau_lib::commands::repository - RustSkip to main content

repository_get_downloaded_libraries

Function repository_get_downloaded_libraries 

Source
pub async fn repository_get_downloaded_libraries(
+    manager: State<'_, RepositoryManagerWrapper>,
+    handle: String,
+) -> Result<Vec<Library>, String>
Expand description

Downloaded-only browse: libraries that contain downloaded content.

+

Backs the Downloads “Downloaded” surface. Never merges server results and is +authoritative — an empty list means nothing is downloaded.

+

TRACES: UR-055 | DR-082

+
\ No newline at end of file diff --git a/api/jellytau_lib/commands/repository/fn.repository_get_favorites.html b/api/jellytau_lib/commands/repository/fn.repository_get_favorites.html new file mode 100644 index 00000000..8c1bfa12 --- /dev/null +++ b/api/jellytau_lib/commands/repository/fn.repository_get_favorites.html @@ -0,0 +1,14 @@ +repository_get_favorites in jellytau_lib::commands::repository - RustSkip to main content

repository_get_favorites

Function repository_get_favorites 

Source
pub async fn repository_get_favorites(
+    app: AppHandle,
+    manager: State<'_, RepositoryManagerWrapper>,
+    handle: String,
+    scope: SearchScope,
+    options: Option<GetItemsOptions>,
+) -> Result<SearchResult, String>
Expand description

Everything the viewer has favourited, across libraries, narrowed by scope.

+

Two-phase like repository_search: the local answer returns immediately and +a background server pass emits favorites-changed when the server’s set +differs. Without the second phase a favourite marked in another client shows +up only on the second visit to the page, since the cache-first read hands +back local rows and the refresh is invisible to the frontend.

+

TRACES: UR-067 | DR-115, DR-120, JA-033 | UT-107

+
\ No newline at end of file diff --git a/api/jellytau_lib/commands/repository/fn.repository_get_genres.html b/api/jellytau_lib/commands/repository/fn.repository_get_genres.html new file mode 100644 index 00000000..2ebc3af2 --- /dev/null +++ b/api/jellytau_lib/commands/repository/fn.repository_get_genres.html @@ -0,0 +1,6 @@ +repository_get_genres in jellytau_lib::commands::repository - RustSkip to main content

repository_get_genres

Function repository_get_genres 

Source
pub async fn repository_get_genres(
+    manager: State<'_, RepositoryManagerWrapper>,
+    handle: String,
+    parent_id: Option<String>,
+) -> Result<Vec<Genre>, String>
Expand description

Get genres for a library

+
\ No newline at end of file diff --git a/api/jellytau_lib/commands/repository/fn.repository_get_image_url.html b/api/jellytau_lib/commands/repository/fn.repository_get_image_url.html new file mode 100644 index 00000000..c6573ede --- /dev/null +++ b/api/jellytau_lib/commands/repository/fn.repository_get_image_url.html @@ -0,0 +1,8 @@ +repository_get_image_url in jellytau_lib::commands::repository - RustSkip to main content

repository_get_image_url

Function repository_get_image_url 

Source
pub fn repository_get_image_url(
+    manager: State<'_, RepositoryManagerWrapper>,
+    handle: String,
+    item_id: String,
+    image_type: ImageType,
+    options: Option<ImageOptions>,
+) -> Result<String, String>
Expand description

Get image URL for an item

+
\ No newline at end of file diff --git a/api/jellytau_lib/commands/repository/fn.repository_get_item.html b/api/jellytau_lib/commands/repository/fn.repository_get_item.html new file mode 100644 index 00000000..db052bea --- /dev/null +++ b/api/jellytau_lib/commands/repository/fn.repository_get_item.html @@ -0,0 +1,6 @@ +repository_get_item in jellytau_lib::commands::repository - RustSkip to main content

repository_get_item

Function repository_get_item 

Source
pub async fn repository_get_item(
+    manager: State<'_, RepositoryManagerWrapper>,
+    handle: String,
+    item_id: String,
+) -> Result<MediaItem, String>
Expand description

Get a single item by ID

+
\ No newline at end of file diff --git a/api/jellytau_lib/commands/repository/fn.repository_get_items.html b/api/jellytau_lib/commands/repository/fn.repository_get_items.html new file mode 100644 index 00000000..5e90890f --- /dev/null +++ b/api/jellytau_lib/commands/repository/fn.repository_get_items.html @@ -0,0 +1,7 @@ +repository_get_items in jellytau_lib::commands::repository - RustSkip to main content

repository_get_items

Function repository_get_items 

Source
pub async fn repository_get_items(
+    manager: State<'_, RepositoryManagerWrapper>,
+    handle: String,
+    parent_id: String,
+    options: Option<GetItemsOptions>,
+) -> Result<SearchResult, String>
Expand description

Get items in a container (library, folder, album, etc.)

+
\ No newline at end of file diff --git a/api/jellytau_lib/commands/repository/fn.repository_get_items_by_person.html b/api/jellytau_lib/commands/repository/fn.repository_get_items_by_person.html new file mode 100644 index 00000000..a2258e1a --- /dev/null +++ b/api/jellytau_lib/commands/repository/fn.repository_get_items_by_person.html @@ -0,0 +1,7 @@ +repository_get_items_by_person in jellytau_lib::commands::repository - RustSkip to main content

repository_get_items_by_person

Function repository_get_items_by_person 

Source
pub async fn repository_get_items_by_person(
+    manager: State<'_, RepositoryManagerWrapper>,
+    handle: String,
+    person_id: String,
+    options: Option<GetItemsOptions>,
+) -> Result<SearchResult, String>
Expand description

Get items by person (actor, director, etc.)

+
\ No newline at end of file diff --git a/api/jellytau_lib/commands/repository/fn.repository_get_latest_items.html b/api/jellytau_lib/commands/repository/fn.repository_get_latest_items.html new file mode 100644 index 00000000..d14222e8 --- /dev/null +++ b/api/jellytau_lib/commands/repository/fn.repository_get_latest_items.html @@ -0,0 +1,7 @@ +repository_get_latest_items in jellytau_lib::commands::repository - RustSkip to main content

repository_get_latest_items

Function repository_get_latest_items 

Source
pub async fn repository_get_latest_items(
+    manager: State<'_, RepositoryManagerWrapper>,
+    handle: String,
+    parent_id: String,
+    limit: Option<usize>,
+) -> Result<Vec<MediaItem>, String>
Expand description

Get latest items in a library

+
\ No newline at end of file diff --git a/api/jellytau_lib/commands/repository/fn.repository_get_libraries.html b/api/jellytau_lib/commands/repository/fn.repository_get_libraries.html new file mode 100644 index 00000000..6cf37e35 --- /dev/null +++ b/api/jellytau_lib/commands/repository/fn.repository_get_libraries.html @@ -0,0 +1,5 @@ +repository_get_libraries in jellytau_lib::commands::repository - RustSkip to main content

repository_get_libraries

Function repository_get_libraries 

Source
pub async fn repository_get_libraries(
+    manager: State<'_, RepositoryManagerWrapper>,
+    handle: String,
+) -> Result<Vec<Library>, String>
Expand description

Get libraries

+
\ No newline at end of file diff --git a/api/jellytau_lib/commands/repository/fn.repository_get_live_tv_channels.html b/api/jellytau_lib/commands/repository/fn.repository_get_live_tv_channels.html new file mode 100644 index 00000000..6029601c --- /dev/null +++ b/api/jellytau_lib/commands/repository/fn.repository_get_live_tv_channels.html @@ -0,0 +1,5 @@ +repository_get_live_tv_channels in jellytau_lib::commands::repository - RustSkip to main content

repository_get_live_tv_channels

Function repository_get_live_tv_channels 

Source
pub async fn repository_get_live_tv_channels(
+    manager: State<'_, RepositoryManagerWrapper>,
+    handle: String,
+) -> Result<Vec<MediaItem>, String>
Expand description

Get Live TV channels (broadcast / IPTV) for browsing

+
\ No newline at end of file diff --git a/api/jellytau_lib/commands/repository/fn.repository_get_next_up_episodes.html b/api/jellytau_lib/commands/repository/fn.repository_get_next_up_episodes.html new file mode 100644 index 00000000..f084265e --- /dev/null +++ b/api/jellytau_lib/commands/repository/fn.repository_get_next_up_episodes.html @@ -0,0 +1,8 @@ +repository_get_next_up_episodes in jellytau_lib::commands::repository - RustSkip to main content

repository_get_next_up_episodes

Function repository_get_next_up_episodes 

Source
pub async fn repository_get_next_up_episodes(
+    manager: State<'_, RepositoryManagerWrapper>,
+    handle: String,
+    series_id: Option<String>,
+    limit: Option<usize>,
+) -> Result<Vec<MediaItem>, String>
Expand description

Get next up episodes.

+

TRACES: UR-023, UR-034 | IR-024, JA-014 | DR-026

+
\ No newline at end of file diff --git a/api/jellytau_lib/commands/repository/fn.repository_get_person.html b/api/jellytau_lib/commands/repository/fn.repository_get_person.html new file mode 100644 index 00000000..14003315 --- /dev/null +++ b/api/jellytau_lib/commands/repository/fn.repository_get_person.html @@ -0,0 +1,6 @@ +repository_get_person in jellytau_lib::commands::repository - RustSkip to main content

repository_get_person

Function repository_get_person 

Source
pub async fn repository_get_person(
+    manager: State<'_, RepositoryManagerWrapper>,
+    handle: String,
+    person_id: String,
+) -> Result<MediaItem, String>
Expand description

Get person details

+
\ No newline at end of file diff --git a/api/jellytau_lib/commands/repository/fn.repository_get_playback_info.html b/api/jellytau_lib/commands/repository/fn.repository_get_playback_info.html new file mode 100644 index 00000000..2b924785 --- /dev/null +++ b/api/jellytau_lib/commands/repository/fn.repository_get_playback_info.html @@ -0,0 +1,6 @@ +repository_get_playback_info in jellytau_lib::commands::repository - RustSkip to main content

repository_get_playback_info

Function repository_get_playback_info 

Source
pub async fn repository_get_playback_info(
+    manager: State<'_, RepositoryManagerWrapper>,
+    handle: String,
+    item_id: String,
+) -> Result<PlaybackInfo, String>
Expand description

Get playback info for an item

+
\ No newline at end of file diff --git a/api/jellytau_lib/commands/repository/fn.repository_get_recently_played_audio.html b/api/jellytau_lib/commands/repository/fn.repository_get_recently_played_audio.html new file mode 100644 index 00000000..606c41cf --- /dev/null +++ b/api/jellytau_lib/commands/repository/fn.repository_get_recently_played_audio.html @@ -0,0 +1,6 @@ +repository_get_recently_played_audio in jellytau_lib::commands::repository - RustSkip to main content

repository_get_recently_played_audio

Function repository_get_recently_played_audio 

Source
pub async fn repository_get_recently_played_audio(
+    manager: State<'_, RepositoryManagerWrapper>,
+    handle: String,
+    limit: Option<usize>,
+) -> Result<Vec<MediaItem>, String>
Expand description

Get recently played audio

+
\ No newline at end of file diff --git a/api/jellytau_lib/commands/repository/fn.repository_get_rediscover_albums.html b/api/jellytau_lib/commands/repository/fn.repository_get_rediscover_albums.html new file mode 100644 index 00000000..c8fff6eb --- /dev/null +++ b/api/jellytau_lib/commands/repository/fn.repository_get_rediscover_albums.html @@ -0,0 +1,7 @@ +repository_get_rediscover_albums in jellytau_lib::commands::repository - RustSkip to main content

repository_get_rediscover_albums

Function repository_get_rediscover_albums 

Source
pub async fn repository_get_rediscover_albums(
+    manager: State<'_, RepositoryManagerWrapper>,
+    handle: String,
+    parent_id: Option<String>,
+    limit: Option<usize>,
+) -> Result<Vec<MediaItem>, String>
Expand description

Get albums the user hasn’t listened to recently (“rediscover”)

+
\ No newline at end of file diff --git a/api/jellytau_lib/commands/repository/fn.repository_get_resume_items.html b/api/jellytau_lib/commands/repository/fn.repository_get_resume_items.html new file mode 100644 index 00000000..1709643c --- /dev/null +++ b/api/jellytau_lib/commands/repository/fn.repository_get_resume_items.html @@ -0,0 +1,11 @@ +repository_get_resume_items in jellytau_lib::commands::repository - RustSkip to main content

repository_get_resume_items

Function repository_get_resume_items 

Source
pub async fn repository_get_resume_items(
+    manager: State<'_, RepositoryManagerWrapper>,
+    handle: String,
+    parent_id: Option<String>,
+    limit: Option<usize>,
+) -> Result<Vec<MediaItem>, String>
Expand description

Get resume items (continue watching/listening).

+

The home screen’s Continue Watching row and every library’s “pick up where +you left off” hero come through here; each item carries its own resume +position in UserData.

+

TRACES: UR-019, UR-023, UR-034 | IR-024, JA-013, JA-015 | DR-026, DR-038

+
\ No newline at end of file diff --git a/api/jellytau_lib/commands/repository/fn.repository_get_resume_movies.html b/api/jellytau_lib/commands/repository/fn.repository_get_resume_movies.html new file mode 100644 index 00000000..e0f2da66 --- /dev/null +++ b/api/jellytau_lib/commands/repository/fn.repository_get_resume_movies.html @@ -0,0 +1,6 @@ +repository_get_resume_movies in jellytau_lib::commands::repository - RustSkip to main content

repository_get_resume_movies

Function repository_get_resume_movies 

Source
pub async fn repository_get_resume_movies(
+    manager: State<'_, RepositoryManagerWrapper>,
+    handle: String,
+    limit: Option<usize>,
+) -> Result<Vec<MediaItem>, String>
Expand description

Get resume movies

+
\ No newline at end of file diff --git a/api/jellytau_lib/commands/repository/fn.repository_get_series_current_episode.html b/api/jellytau_lib/commands/repository/fn.repository_get_series_current_episode.html new file mode 100644 index 00000000..831064d0 --- /dev/null +++ b/api/jellytau_lib/commands/repository/fn.repository_get_series_current_episode.html @@ -0,0 +1,11 @@ +repository_get_series_current_episode in jellytau_lib::commands::repository - RustSkip to main content

repository_get_series_current_episode

Function repository_get_series_current_episode 

Source
pub async fn repository_get_series_current_episode(
+    manager: State<'_, RepositoryManagerWrapper>,
+    handle: String,
+    series_id: String,
+) -> Result<Option<MediaItem>, String>
Expand description

The episode a viewer should land on when they open a series.

+

“Current” is domain policy, not layout: an episode in progress, else the +server’s Next Up for the series, else the first unwatched episode, else the +first. The third rung is what makes this work offline, where Next Up is +always empty. Returns None only when the series has no episodes at all.

+

TRACES: UR-062 | DR-101

+
\ No newline at end of file diff --git a/api/jellytau_lib/commands/repository/fn.repository_get_series_episodes.html b/api/jellytau_lib/commands/repository/fn.repository_get_series_episodes.html new file mode 100644 index 00000000..3183ee48 --- /dev/null +++ b/api/jellytau_lib/commands/repository/fn.repository_get_series_episodes.html @@ -0,0 +1,11 @@ +repository_get_series_episodes in jellytau_lib::commands::repository - RustSkip to main content

repository_get_series_episodes

Function repository_get_series_episodes 

Source
pub async fn repository_get_series_episodes(
+    manager: State<'_, RepositoryManagerWrapper>,
+    handle: String,
+    series_id: String,
+) -> Result<Vec<MediaItem>, String>
Expand description

Every episode of a series, across all seasons, in series order.

+

Jellyfin hangs episodes off season folders — except for “flat” series whose +children are episodes directly. Both shapes are provider vocabulary, so the +fan-out and its fallback live in Rust rather than being reimplemented in the +frontend (which is what it used to do).

+

TRACES: UR-062 | DR-101

+
\ No newline at end of file diff --git a/api/jellytau_lib/commands/repository/fn.repository_get_similar_items.html b/api/jellytau_lib/commands/repository/fn.repository_get_similar_items.html new file mode 100644 index 00000000..1e091da0 --- /dev/null +++ b/api/jellytau_lib/commands/repository/fn.repository_get_similar_items.html @@ -0,0 +1,7 @@ +repository_get_similar_items in jellytau_lib::commands::repository - RustSkip to main content

repository_get_similar_items

Function repository_get_similar_items 

Source
pub async fn repository_get_similar_items(
+    manager: State<'_, RepositoryManagerWrapper>,
+    handle: String,
+    item_id: String,
+    limit: Option<usize>,
+) -> Result<SearchResult, String>
Expand description

Get similar/related items for a media item

+
\ No newline at end of file diff --git a/api/jellytau_lib/commands/repository/fn.repository_get_subtitle_url.html b/api/jellytau_lib/commands/repository/fn.repository_get_subtitle_url.html new file mode 100644 index 00000000..ed014dcb --- /dev/null +++ b/api/jellytau_lib/commands/repository/fn.repository_get_subtitle_url.html @@ -0,0 +1,9 @@ +repository_get_subtitle_url in jellytau_lib::commands::repository - RustSkip to main content

repository_get_subtitle_url

Function repository_get_subtitle_url 

Source
pub fn repository_get_subtitle_url(
+    manager: State<'_, RepositoryManagerWrapper>,
+    handle: String,
+    item_id: String,
+    media_source_id: String,
+    stream_index: i32,
+    format: String,
+) -> Result<String, String>
Expand description

Get subtitle URL for a media item

+
\ No newline at end of file diff --git a/api/jellytau_lib/commands/repository/fn.repository_get_video_download_url.html b/api/jellytau_lib/commands/repository/fn.repository_get_video_download_url.html new file mode 100644 index 00000000..38528756 --- /dev/null +++ b/api/jellytau_lib/commands/repository/fn.repository_get_video_download_url.html @@ -0,0 +1,8 @@ +repository_get_video_download_url in jellytau_lib::commands::repository - RustSkip to main content

repository_get_video_download_url

Function repository_get_video_download_url 

Source
pub async fn repository_get_video_download_url(
+    manager: State<'_, RepositoryManagerWrapper>,
+    handle: String,
+    item_id: String,
+    quality: String,
+    media_source_id: Option<String>,
+) -> Result<String, String>
Expand description

Get video download URL with quality preset

+
\ No newline at end of file diff --git a/api/jellytau_lib/commands/repository/fn.repository_get_video_stream_url.html b/api/jellytau_lib/commands/repository/fn.repository_get_video_stream_url.html new file mode 100644 index 00000000..09586290 --- /dev/null +++ b/api/jellytau_lib/commands/repository/fn.repository_get_video_stream_url.html @@ -0,0 +1,12 @@ +repository_get_video_stream_url in jellytau_lib::commands::repository - RustSkip to main content

repository_get_video_stream_url

Function repository_get_video_stream_url 

Source
pub async fn repository_get_video_stream_url(
+    manager: State<'_, RepositoryManagerWrapper>,
+    handle: String,
+    item_id: String,
+    media_source_id: Option<String>,
+    audio_stream_index: Option<i32>,
+) -> Result<String, String>
Expand description

Get a video stream URL.

+

There is no start-position parameter on purpose: the URL is an HLS playlist +covering the whole item, and a position on it makes the server reject every +segment with 400 (DR-181). Callers resume by seeking after load.

+

TRACES: UR-004 | DR-181 | UT-182

+
\ No newline at end of file diff --git a/api/jellytau_lib/commands/repository/fn.repository_jray_actors_at.html b/api/jellytau_lib/commands/repository/fn.repository_jray_actors_at.html new file mode 100644 index 00000000..c8044c14 --- /dev/null +++ b/api/jellytau_lib/commands/repository/fn.repository_jray_actors_at.html @@ -0,0 +1,9 @@ +repository_jray_actors_at in jellytau_lib::commands::repository - RustSkip to main content

repository_jray_actors_at

Function repository_jray_actors_at 

Source
pub async fn repository_jray_actors_at(
+    manager: State<'_, RepositoryManagerWrapper>,
+    handle: String,
+    item_id: String,
+    t: f64,
+) -> Result<Vec<JRayActor>, String>
Expand description

Query the optional JRay plugin for the actors on screen at time t +(seconds) in an item. Returns an empty list when JRay isn’t installed or +has no data for the item, so the caller can render nothing without error.

+
\ No newline at end of file diff --git a/api/jellytau_lib/commands/repository/fn.repository_mark_favorite.html b/api/jellytau_lib/commands/repository/fn.repository_mark_favorite.html new file mode 100644 index 00000000..73c65d09 --- /dev/null +++ b/api/jellytau_lib/commands/repository/fn.repository_mark_favorite.html @@ -0,0 +1,6 @@ +repository_mark_favorite in jellytau_lib::commands::repository - RustSkip to main content

repository_mark_favorite

Function repository_mark_favorite 

Source
pub async fn repository_mark_favorite(
+    manager: State<'_, RepositoryManagerWrapper>,
+    handle: String,
+    item_id: String,
+) -> Result<(), String>
Expand description

Mark an item as favorite

+
\ No newline at end of file diff --git a/api/jellytau_lib/commands/repository/fn.repository_open_live_stream.html b/api/jellytau_lib/commands/repository/fn.repository_open_live_stream.html new file mode 100644 index 00000000..8f3ecc70 --- /dev/null +++ b/api/jellytau_lib/commands/repository/fn.repository_open_live_stream.html @@ -0,0 +1,6 @@ +repository_open_live_stream in jellytau_lib::commands::repository - RustSkip to main content

repository_open_live_stream

Function repository_open_live_stream 

Source
pub async fn repository_open_live_stream(
+    manager: State<'_, RepositoryManagerWrapper>,
+    handle: String,
+    item_id: String,
+) -> Result<LiveStreamInfo, String>
Expand description

Open a live stream for a Live TV channel / live item

+
\ No newline at end of file diff --git a/api/jellytau_lib/commands/repository/fn.repository_report_playback_progress.html b/api/jellytau_lib/commands/repository/fn.repository_report_playback_progress.html new file mode 100644 index 00000000..c409fe67 --- /dev/null +++ b/api/jellytau_lib/commands/repository/fn.repository_report_playback_progress.html @@ -0,0 +1,7 @@ +repository_report_playback_progress in jellytau_lib::commands::repository - RustSkip to main content

repository_report_playback_progress

Function repository_report_playback_progress 

Source
pub async fn repository_report_playback_progress(
+    manager: State<'_, RepositoryManagerWrapper>,
+    handle: String,
+    item_id: String,
+    position_ms: i64,
+) -> Result<(), String>
Expand description

Report playback progress

+
\ No newline at end of file diff --git a/api/jellytau_lib/commands/repository/fn.repository_report_playback_start.html b/api/jellytau_lib/commands/repository/fn.repository_report_playback_start.html new file mode 100644 index 00000000..62c6bd30 --- /dev/null +++ b/api/jellytau_lib/commands/repository/fn.repository_report_playback_start.html @@ -0,0 +1,7 @@ +repository_report_playback_start in jellytau_lib::commands::repository - RustSkip to main content

repository_report_playback_start

Function repository_report_playback_start 

Source
pub async fn repository_report_playback_start(
+    manager: State<'_, RepositoryManagerWrapper>,
+    handle: String,
+    item_id: String,
+    position_ms: i64,
+) -> Result<(), String>
Expand description

Report playback start

+
\ No newline at end of file diff --git a/api/jellytau_lib/commands/repository/fn.repository_report_playback_stopped.html b/api/jellytau_lib/commands/repository/fn.repository_report_playback_stopped.html new file mode 100644 index 00000000..70976182 --- /dev/null +++ b/api/jellytau_lib/commands/repository/fn.repository_report_playback_stopped.html @@ -0,0 +1,15 @@ +repository_report_playback_stopped in jellytau_lib::commands::repository - RustSkip to main content

repository_report_playback_stopped

Function repository_report_playback_stopped 

Source
pub async fn repository_report_playback_stopped(
+    db: State<'_, DatabaseWrapper>,
+    manager: State<'_, RepositoryManagerWrapper>,
+    handle: String,
+    item_id: String,
+    position_ms: i64,
+) -> Result<(), String>
Expand description

Report playback stopped

+

A stop-report that cannot reach the server is queued rather than dropped: +this is the position the resume point is built from, and losing it is +exactly the “it forgot where I was” the sync queue exists to prevent. The +drain (DR-131) pushes it on the next reconnect. Queueing is best-effort — +failing the command because the queue write failed would tell the caller +the report was lost when the local position was already saved.

+

TRACES: UR-025 | DR-154 | UT-151

+
\ No newline at end of file diff --git a/api/jellytau_lib/commands/repository/fn.repository_search.html b/api/jellytau_lib/commands/repository/fn.repository_search.html new file mode 100644 index 00000000..c1a805c5 --- /dev/null +++ b/api/jellytau_lib/commands/repository/fn.repository_search.html @@ -0,0 +1,12 @@ +repository_search in jellytau_lib::commands::repository - RustSkip to main content

repository_search

Function repository_search 

Source
pub async fn repository_search(
+    app: AppHandle,
+    manager: State<'_, RepositoryManagerWrapper>,
+    handle: String,
+    query: String,
+    options: Option<SearchOptions>,
+    request_id: u32,
+) -> Result<SearchResult, String>
Expand description

Search for items.

+

Resolves SearchOptions::scope into concrete Jellyfin item types before +dispatching, so scope taxonomy stays in Rust.

+

TRACES: UR-049, UR-050 | DR-063

+
\ No newline at end of file diff --git a/api/jellytau_lib/commands/repository/fn.repository_unmark_favorite.html b/api/jellytau_lib/commands/repository/fn.repository_unmark_favorite.html new file mode 100644 index 00000000..aa186e37 --- /dev/null +++ b/api/jellytau_lib/commands/repository/fn.repository_unmark_favorite.html @@ -0,0 +1,6 @@ +repository_unmark_favorite in jellytau_lib::commands::repository - RustSkip to main content

repository_unmark_favorite

Function repository_unmark_favorite 

Source
pub async fn repository_unmark_favorite(
+    manager: State<'_, RepositoryManagerWrapper>,
+    handle: String,
+    item_id: String,
+) -> Result<(), String>
Expand description

Unmark an item as favorite

+
\ No newline at end of file diff --git a/api/jellytau_lib/commands/repository/index.html b/api/jellytau_lib/commands/repository/index.html new file mode 100644 index 00000000..a3600dbb --- /dev/null +++ b/api/jellytau_lib/commands/repository/index.html @@ -0,0 +1,12 @@ +jellytau_lib::commands::repository - RustSkip to main content

Module repository

Module repository 

Source
Expand description

Tauri commands for repository access +Uses handle-based system: UUID -> Arc

+

TRACES: UR-007, UR-008, UR-023, UR-034, UR-035, UR-036 | IR-022, IR-024, JA-004, JA-005, JA-006, JA-029, JA-030, JA-031

+

Structs§

FavoritesChangedEvent
Payload for FAVORITES_CHANGED_EVENT — the ids whose favourite state +actually flipped, so the frontend refreshes those rather than everything.
RepositoryManager
Repository handle manager
RepositoryManagerWrapper
Wrapper for Tauri state
SearchUpdateEvent
Payload for the deferred, merged search results pushed to the frontend.

Constants§

FAVORITES_CHANGED_EVENT
Tauri event announcing that favourite state changed behind the UI’s back — +either because the server disagreed with the cache on a background refresh, +or because pending offline toggles were pushed on reconnect.
SEARCH_EVENT_NAME
Tauri event name carrying the merged (cache + server) search results.

Functions§

changed_favorite_ids 🔒
Ids whose favourite state differs between what we showed and what the server +has — favourited elsewhere since the cache was written, or un-favourited +elsewhere.
repository_clear_watch_history
Erase the viewer’s watch history for an item.
repository_create
Create a new repository instance +Returns a handle (UUID) for accessing the repository
repository_destroy
Destroy a repository instance
repository_get_audio_only_stream_url_for_video
Get an audio-only stream URL for a video item (background-audio handoff).
repository_get_audio_stream_url
Get audio stream URL for a track
repository_get_channels
Get the root list of plugin “Channels”
repository_get_download_disk_usage
On-disk usage of downloaded content (device total, per-item/container bytes).
repository_get_downloaded_items
Downloaded-only browse: items under a container that are on the device.
repository_get_downloaded_libraries
Downloaded-only browse: libraries that contain downloaded content.
repository_get_favorites
Everything the viewer has favourited, across libraries, narrowed by scope.
repository_get_genres
Get genres for a library
repository_get_image_url
Get image URL for an item
repository_get_item
Get a single item by ID
repository_get_items
Get items in a container (library, folder, album, etc.)
repository_get_items_by_person
Get items by person (actor, director, etc.)
repository_get_latest_items
Get latest items in a library
repository_get_libraries
Get libraries
repository_get_live_tv_channels
Get Live TV channels (broadcast / IPTV) for browsing
repository_get_next_up_episodes
Get next up episodes.
repository_get_person
Get person details
repository_get_playback_info
Get playback info for an item
repository_get_recently_played_audio
Get recently played audio
repository_get_rediscover_albums
Get albums the user hasn’t listened to recently (“rediscover”)
repository_get_resume_items
Get resume items (continue watching/listening).
repository_get_resume_movies
Get resume movies
repository_get_series_current_episode
The episode a viewer should land on when they open a series.
repository_get_series_episodes
Every episode of a series, across all seasons, in series order.
repository_get_similar_items
Get similar/related items for a media item
repository_get_subtitle_url
Get subtitle URL for a media item
repository_get_video_download_url
Get video download URL with quality preset
repository_get_video_stream_url
Get a video stream URL.
repository_jray_actors_at
Query the optional JRay plugin for the actors on screen at time t +(seconds) in an item. Returns an empty list when JRay isn’t installed or +has no data for the item, so the caller can render nothing without error.
repository_mark_favorite
Mark an item as favorite
repository_open_live_stream
Open a live stream for a Live TV channel / live item
repository_report_playback_progress
Report playback progress
repository_report_playback_start
Report playback start
repository_report_playback_stopped
Report playback stopped
repository_search
Search for items.
repository_unmark_favorite
Unmark an item as favorite
\ No newline at end of file diff --git a/api/jellytau_lib/commands/repository/sidebar-items.js b/api/jellytau_lib/commands/repository/sidebar-items.js new file mode 100644 index 00000000..8687ddc6 --- /dev/null +++ b/api/jellytau_lib/commands/repository/sidebar-items.js @@ -0,0 +1 @@ +window.SIDEBAR_ITEMS = {"constant":["FAVORITES_CHANGED_EVENT","SEARCH_EVENT_NAME"],"fn":["changed_favorite_ids","repository_clear_watch_history","repository_create","repository_destroy","repository_get_audio_only_stream_url_for_video","repository_get_audio_stream_url","repository_get_channels","repository_get_download_disk_usage","repository_get_downloaded_items","repository_get_downloaded_libraries","repository_get_favorites","repository_get_genres","repository_get_image_url","repository_get_item","repository_get_items","repository_get_items_by_person","repository_get_latest_items","repository_get_libraries","repository_get_live_tv_channels","repository_get_next_up_episodes","repository_get_person","repository_get_playback_info","repository_get_recently_played_audio","repository_get_rediscover_albums","repository_get_resume_items","repository_get_resume_movies","repository_get_series_current_episode","repository_get_series_episodes","repository_get_similar_items","repository_get_subtitle_url","repository_get_video_download_url","repository_get_video_stream_url","repository_jray_actors_at","repository_mark_favorite","repository_open_live_stream","repository_report_playback_progress","repository_report_playback_start","repository_report_playback_stopped","repository_search","repository_unmark_favorite"],"struct":["FavoritesChangedEvent","RepositoryManager","RepositoryManagerWrapper","SearchUpdateEvent"]}; \ No newline at end of file diff --git a/api/jellytau_lib/commands/repository/struct.FavoritesChangedEvent.html b/api/jellytau_lib/commands/repository/struct.FavoritesChangedEvent.html new file mode 100644 index 00000000..cd1bcaef --- /dev/null +++ b/api/jellytau_lib/commands/repository/struct.FavoritesChangedEvent.html @@ -0,0 +1,54 @@ +FavoritesChangedEvent in jellytau_lib::commands::repository - RustSkip to main content

FavoritesChangedEvent

Struct FavoritesChangedEvent 

Source
pub struct FavoritesChangedEvent {
+    pub item_ids: Vec<String>,
+}
Expand description

Payload for FAVORITES_CHANGED_EVENT — the ids whose favourite state +actually flipped, so the frontend refreshes those rather than everything.

+

TRACES: UR-069 | DR-120 | UT-107

+

Fields§

§item_ids: Vec<String>

Trait Implementations§

Source§

impl Clone for FavoritesChangedEvent

Source§

fn clone(&self) -> FavoritesChangedEvent

Returns a duplicate of the value. Read more
1.0.0 (const: unstable) · Source§

fn clone_from(&mut self, source: &Self)

Performs copy-assignment from source. Read more
Source§

impl Debug for FavoritesChangedEvent

Source§

fn fmt(&self, f: &mut Formatter<'_>) -> Result

Formats the value using the given formatter. Read more
Source§

impl<'de> Deserialize<'de> for FavoritesChangedEvent

Source§

fn deserialize<__D>(__deserializer: __D) -> Result<Self, __D::Error>
where + __D: Deserializer<'de>,

Deserialize this value from the given Serde deserializer. Read more
Source§

impl NamedType for FavoritesChangedEvent

Source§

fn sid() -> SpectaID

Source§

fn named_data_type( + type_map: &mut TypeCollection, + generics: &[DataType], +) -> NamedDataType

this is equivalent to [Type::inline] but returns a [NamedDataType] instead.
Source§

fn definition_named_data_type(type_map: &mut TypeCollection) -> NamedDataType

this is equivalent to [Type::definition] but returns a [NamedDataType] instead.
Source§

impl Serialize for FavoritesChangedEvent

Source§

fn serialize<__S>(&self, __serializer: __S) -> Result<__S::Ok, __S::Error>
where + __S: Serializer,

Serialize this value into the given Serde serializer. Read more
Source§

impl Type for FavoritesChangedEvent

Source§

fn inline(type_map: &mut TypeCollection, generics: Generics<'_>) -> DataType

Returns the definition of a type using the provided generics. Read more
Source§

fn reference(type_map: &mut TypeCollection, generics: &[DataType]) -> Reference

Generates a datatype corresponding to a reference to this type, +as determined by its category. Getting a reference to a type implies that +it should belong in the type map (since it has to be referenced from somewhere), +so the output of definition will be put into the type map.
Source§

impl Flatten for FavoritesChangedEvent

Auto Trait Implementations§

Blanket Implementations§

Source§

impl<T> Any for T
where + T: 'static + ?Sized,

Source§

fn type_id(&self) -> TypeId

Gets the TypeId of self. Read more
Source§

impl<T> Borrow<T> for T
where + T: ?Sized,

Source§

fn borrow(&self) -> &T

Immutably borrows from an owned value. Read more
Source§

impl<T> BorrowMut<T> for T
where + T: ?Sized,

Source§

fn borrow_mut(&mut self) -> &mut T

Mutably borrows from an owned value. Read more
Source§

impl<T> CloneToUninit for T
where + T: Clone,

Source§

unsafe fn clone_to_uninit(&self, dest: *mut u8)

🔬This is a nightly-only experimental API. (clone_to_uninit)
Performs copy-assignment from self to dest. Read more
§

impl<'de, D, R> CommandArg<'de, R> for D
where + D: Deserialize<'de>, + R: Runtime,

§

fn from_command(command: CommandItem<'de, R>) -> Result<D, InvokeError>

Derives an instance of Self from the [CommandItem]. Read more
Source§

impl<T> From<T> for T

Source§

fn from(t: T) -> T

Returns the argument unchanged.

+
§

impl<T> FunctionArg for T
where + T: Type,

§

fn to_datatype(type_map: &mut TypeCollection) -> Option<DataType>

Gets the type of an argument as a [DataType]. Read more
§

impl<T> Instrument for T

§

fn instrument(self, span: Span) -> Instrumented<Self>

Instruments this type with the provided [Span], returning an +Instrumented wrapper. Read more
§

fn in_current_span(self) -> Instrumented<Self>

Instruments this type with the current Span, returning an +Instrumented wrapper. Read more
Source§

impl<T, U> Into<U> for T
where + U: From<T>,

Source§

fn into(self) -> U

Calls U::from(self).

+

That is, this conversion is whatever the implementation of +From<T> for U chooses to do.

+
§

impl<T> IpcResponse for T
where + T: Serialize,

§

fn body(self) -> Result<InvokeResponseBody, Error>

Resolve the IPC response body.
§

impl<T> PolicyExt for T
where + T: ?Sized,

§

fn and<P, B, E>(self, other: P) -> And<T, P>
where + T: Policy<B, E>, + P: Policy<B, E>,

Create a new Policy that returns [Action::Follow] only if self and other return +Action::Follow. Read more
§

fn or<P, B, E>(self, other: P) -> Or<T, P>
where + T: Policy<B, E>, + P: Policy<B, E>,

Create a new Policy that returns [Action::Follow] if either self or other returns +Action::Follow. Read more
Source§

impl<T> Same for T

Source§

type Output = T

Should always be Self
§

impl<T> ScopeObject for T
where + T: Send + Sync + Debug + DeserializeOwned + 'static,

§

type Error = Error

The error type.
§

fn deserialize<R>( + _app: &AppHandle<R>, + raw: Value, +) -> Result<T, <T as ScopeObject>::Error>
where + R: Runtime,

Deserialize the raw scope value.
Source§

impl<T> Serialize for T
where + T: Serialize + ?Sized,

Source§

fn erased_serialize(&self, serializer: &mut dyn Serializer) -> Result<(), Error>

Source§

fn do_erased_serialize( + &self, + serializer: &mut dyn Serializer, +) -> Result<(), ErrorImpl>

Source§

impl<T> ToOwned for T
where + T: Clone,

Source§

type Owned = T

The resulting type after obtaining ownership.
Source§

fn to_owned(&self) -> T

Creates owned data from borrowed data, usually by cloning. Read more
Source§

fn clone_into(&self, target: &mut T)

Uses borrowed data to replace owned data, usually by cloning. Read more
Source§

impl<T, U> TryFrom<U> for T
where + U: Into<T>,

Source§

type Error = Infallible

The type returned in the event of a conversion error.
Source§

fn try_from(value: U) -> Result<T, <T as TryFrom<U>>::Error>

Performs the conversion.
Source§

impl<T, U> TryInto<U> for T
where + U: TryFrom<T>,

Source§

type Error = <U as TryFrom<T>>::Error

The type returned in the event of a conversion error.
Source§

fn try_into(self) -> Result<U, <U as TryFrom<T>>::Error>

Performs the conversion.
§

impl<V, T> VZip<V> for T
where + V: MultiLane<T>,

§

fn vzip(self) -> V

§

impl<T> WithSubscriber for T

§

fn with_subscriber<S>(self, subscriber: S) -> WithDispatch<Self>
where + S: Into<Dispatch>,

Attaches the provided Subscriber to this type, returning a +[WithDispatch] wrapper. Read more
§

fn with_current_subscriber(self) -> WithDispatch<Self>

Attaches the current default Subscriber to this type, returning a +[WithDispatch] wrapper. Read more
Source§

impl<T> DeserializeOwned for T
where + T: for<'de> Deserialize<'de>,

§

impl<T> UserEvent for T
where + T: Debug + Clone + Send + 'static,

\ No newline at end of file diff --git a/api/jellytau_lib/commands/repository/struct.RepositoryManager.html b/api/jellytau_lib/commands/repository/struct.RepositoryManager.html new file mode 100644 index 00000000..05f37a5f --- /dev/null +++ b/api/jellytau_lib/commands/repository/struct.RepositoryManager.html @@ -0,0 +1,33 @@ +RepositoryManager in jellytau_lib::commands::repository - RustSkip to main content

RepositoryManager

Struct RepositoryManager 

Source
pub struct RepositoryManager {
+    repositories: Arc<Mutex<HashMap<String, Arc<HybridRepository>>>>,
+}
Expand description

Repository handle manager

+

Fields§

§repositories: Arc<Mutex<HashMap<String, Arc<HybridRepository>>>>

Implementations§

Source§

impl RepositoryManager

Source

pub fn new() -> Self

Source

pub fn create(&self, handle: String, repository: HybridRepository)

Source

pub fn get(&self, handle: &str) -> Option<Arc<HybridRepository>>

Source

pub fn handles(&self) -> Vec<String>

Handles of every live repository.

+

The background catalog indexer (DR-109) runs outside any command, so it +has no handle passed in and needs to discover one. In practice there is a +single signed-in repository; returning all of them avoids inventing an +“active” concept the rest of the code does not have.

+

TRACES: UR-065 | DR-109

+
Source

pub fn destroy(&self, handle: &str)

Auto Trait Implementations§

Blanket Implementations§

Source§

impl<T> Any for T
where + T: 'static + ?Sized,

Source§

fn type_id(&self) -> TypeId

Gets the TypeId of self. Read more
Source§

impl<T> Borrow<T> for T
where + T: ?Sized,

Source§

fn borrow(&self) -> &T

Immutably borrows from an owned value. Read more
Source§

impl<T> BorrowMut<T> for T
where + T: ?Sized,

Source§

fn borrow_mut(&mut self) -> &mut T

Mutably borrows from an owned value. Read more
Source§

impl<T> From<T> for T

Source§

fn from(t: T) -> T

Returns the argument unchanged.

+
§

impl<T> Instrument for T

§

fn instrument(self, span: Span) -> Instrumented<Self>

Instruments this type with the provided [Span], returning an +Instrumented wrapper. Read more
§

fn in_current_span(self) -> Instrumented<Self>

Instruments this type with the current Span, returning an +Instrumented wrapper. Read more
Source§

impl<T, U> Into<U> for T
where + U: From<T>,

Source§

fn into(self) -> U

Calls U::from(self).

+

That is, this conversion is whatever the implementation of +From<T> for U chooses to do.

+
§

impl<T> PolicyExt for T
where + T: ?Sized,

§

fn and<P, B, E>(self, other: P) -> And<T, P>
where + T: Policy<B, E>, + P: Policy<B, E>,

Create a new Policy that returns [Action::Follow] only if self and other return +Action::Follow. Read more
§

fn or<P, B, E>(self, other: P) -> Or<T, P>
where + T: Policy<B, E>, + P: Policy<B, E>,

Create a new Policy that returns [Action::Follow] if either self or other returns +Action::Follow. Read more
Source§

impl<T> Same for T

Source§

type Output = T

Should always be Self
Source§

impl<T, U> TryFrom<U> for T
where + U: Into<T>,

Source§

type Error = Infallible

The type returned in the event of a conversion error.
Source§

fn try_from(value: U) -> Result<T, <T as TryFrom<U>>::Error>

Performs the conversion.
Source§

impl<T, U> TryInto<U> for T
where + U: TryFrom<T>,

Source§

type Error = <U as TryFrom<T>>::Error

The type returned in the event of a conversion error.
Source§

fn try_into(self) -> Result<U, <U as TryFrom<T>>::Error>

Performs the conversion.
§

impl<V, T> VZip<V> for T
where + V: MultiLane<T>,

§

fn vzip(self) -> V

§

impl<T> WithSubscriber for T

§

fn with_subscriber<S>(self, subscriber: S) -> WithDispatch<Self>
where + S: Into<Dispatch>,

Attaches the provided Subscriber to this type, returning a +[WithDispatch] wrapper. Read more
§

fn with_current_subscriber(self) -> WithDispatch<Self>

Attaches the current default Subscriber to this type, returning a +[WithDispatch] wrapper. Read more
\ No newline at end of file diff --git a/api/jellytau_lib/commands/repository/struct.RepositoryManagerWrapper.html b/api/jellytau_lib/commands/repository/struct.RepositoryManagerWrapper.html new file mode 100644 index 00000000..51f51d6d --- /dev/null +++ b/api/jellytau_lib/commands/repository/struct.RepositoryManagerWrapper.html @@ -0,0 +1,25 @@ +RepositoryManagerWrapper in jellytau_lib::commands::repository - RustSkip to main content

RepositoryManagerWrapper

Struct RepositoryManagerWrapper 

Source
pub struct RepositoryManagerWrapper(pub RepositoryManager);
Expand description

Wrapper for Tauri state

+

Tuple Fields§

§0: RepositoryManager

Auto Trait Implementations§

Blanket Implementations§

Source§

impl<T> Any for T
where + T: 'static + ?Sized,

Source§

fn type_id(&self) -> TypeId

Gets the TypeId of self. Read more
Source§

impl<T> Borrow<T> for T
where + T: ?Sized,

Source§

fn borrow(&self) -> &T

Immutably borrows from an owned value. Read more
Source§

impl<T> BorrowMut<T> for T
where + T: ?Sized,

Source§

fn borrow_mut(&mut self) -> &mut T

Mutably borrows from an owned value. Read more
Source§

impl<T> From<T> for T

Source§

fn from(t: T) -> T

Returns the argument unchanged.

+
§

impl<T> Instrument for T

§

fn instrument(self, span: Span) -> Instrumented<Self>

Instruments this type with the provided [Span], returning an +Instrumented wrapper. Read more
§

fn in_current_span(self) -> Instrumented<Self>

Instruments this type with the current Span, returning an +Instrumented wrapper. Read more
Source§

impl<T, U> Into<U> for T
where + U: From<T>,

Source§

fn into(self) -> U

Calls U::from(self).

+

That is, this conversion is whatever the implementation of +From<T> for U chooses to do.

+
§

impl<T> PolicyExt for T
where + T: ?Sized,

§

fn and<P, B, E>(self, other: P) -> And<T, P>
where + T: Policy<B, E>, + P: Policy<B, E>,

Create a new Policy that returns [Action::Follow] only if self and other return +Action::Follow. Read more
§

fn or<P, B, E>(self, other: P) -> Or<T, P>
where + T: Policy<B, E>, + P: Policy<B, E>,

Create a new Policy that returns [Action::Follow] if either self or other returns +Action::Follow. Read more
Source§

impl<T> Same for T

Source§

type Output = T

Should always be Self
Source§

impl<T, U> TryFrom<U> for T
where + U: Into<T>,

Source§

type Error = Infallible

The type returned in the event of a conversion error.
Source§

fn try_from(value: U) -> Result<T, <T as TryFrom<U>>::Error>

Performs the conversion.
Source§

impl<T, U> TryInto<U> for T
where + U: TryFrom<T>,

Source§

type Error = <U as TryFrom<T>>::Error

The type returned in the event of a conversion error.
Source§

fn try_into(self) -> Result<U, <U as TryFrom<T>>::Error>

Performs the conversion.
§

impl<V, T> VZip<V> for T
where + V: MultiLane<T>,

§

fn vzip(self) -> V

§

impl<T> WithSubscriber for T

§

fn with_subscriber<S>(self, subscriber: S) -> WithDispatch<Self>
where + S: Into<Dispatch>,

Attaches the provided Subscriber to this type, returning a +[WithDispatch] wrapper. Read more
§

fn with_current_subscriber(self) -> WithDispatch<Self>

Attaches the current default Subscriber to this type, returning a +[WithDispatch] wrapper. Read more
\ No newline at end of file diff --git a/api/jellytau_lib/commands/repository/struct.SearchUpdateEvent.html b/api/jellytau_lib/commands/repository/struct.SearchUpdateEvent.html new file mode 100644 index 00000000..8cff5a05 --- /dev/null +++ b/api/jellytau_lib/commands/repository/struct.SearchUpdateEvent.html @@ -0,0 +1,55 @@ +SearchUpdateEvent in jellytau_lib::commands::repository - RustSkip to main content

SearchUpdateEvent

Struct SearchUpdateEvent 

Source
pub struct SearchUpdateEvent {
+    pub request_id: u32,
+    pub result: SearchResult,
+}
Expand description

Payload for the deferred, merged search results pushed to the frontend.

+

request_id matches the value the frontend passed to repository_search, +letting it discard updates from queries that have since been superseded.

+

Fields§

§request_id: u32§result: SearchResult

Trait Implementations§

Source§

impl Clone for SearchUpdateEvent

Source§

fn clone(&self) -> SearchUpdateEvent

Returns a duplicate of the value. Read more
1.0.0 (const: unstable) · Source§

fn clone_from(&mut self, source: &Self)

Performs copy-assignment from source. Read more
Source§

impl Debug for SearchUpdateEvent

Source§

fn fmt(&self, f: &mut Formatter<'_>) -> Result

Formats the value using the given formatter. Read more
Source§

impl<'de> Deserialize<'de> for SearchUpdateEvent

Source§

fn deserialize<__D>(__deserializer: __D) -> Result<Self, __D::Error>
where + __D: Deserializer<'de>,

Deserialize this value from the given Serde deserializer. Read more
Source§

impl NamedType for SearchUpdateEvent

Source§

fn sid() -> SpectaID

Source§

fn named_data_type( + type_map: &mut TypeCollection, + generics: &[DataType], +) -> NamedDataType

this is equivalent to [Type::inline] but returns a [NamedDataType] instead.
Source§

fn definition_named_data_type(type_map: &mut TypeCollection) -> NamedDataType

this is equivalent to [Type::definition] but returns a [NamedDataType] instead.
Source§

impl Serialize for SearchUpdateEvent

Source§

fn serialize<__S>(&self, __serializer: __S) -> Result<__S::Ok, __S::Error>
where + __S: Serializer,

Serialize this value into the given Serde serializer. Read more
Source§

impl Type for SearchUpdateEvent

Source§

fn inline(type_map: &mut TypeCollection, generics: Generics<'_>) -> DataType

Returns the definition of a type using the provided generics. Read more
Source§

fn reference(type_map: &mut TypeCollection, generics: &[DataType]) -> Reference

Generates a datatype corresponding to a reference to this type, +as determined by its category. Getting a reference to a type implies that +it should belong in the type map (since it has to be referenced from somewhere), +so the output of definition will be put into the type map.
Source§

impl Flatten for SearchUpdateEvent

Auto Trait Implementations§

Blanket Implementations§

Source§

impl<T> Any for T
where + T: 'static + ?Sized,

Source§

fn type_id(&self) -> TypeId

Gets the TypeId of self. Read more
Source§

impl<T> Borrow<T> for T
where + T: ?Sized,

Source§

fn borrow(&self) -> &T

Immutably borrows from an owned value. Read more
Source§

impl<T> BorrowMut<T> for T
where + T: ?Sized,

Source§

fn borrow_mut(&mut self) -> &mut T

Mutably borrows from an owned value. Read more
Source§

impl<T> CloneToUninit for T
where + T: Clone,

Source§

unsafe fn clone_to_uninit(&self, dest: *mut u8)

🔬This is a nightly-only experimental API. (clone_to_uninit)
Performs copy-assignment from self to dest. Read more
§

impl<'de, D, R> CommandArg<'de, R> for D
where + D: Deserialize<'de>, + R: Runtime,

§

fn from_command(command: CommandItem<'de, R>) -> Result<D, InvokeError>

Derives an instance of Self from the [CommandItem]. Read more
Source§

impl<T> From<T> for T

Source§

fn from(t: T) -> T

Returns the argument unchanged.

+
§

impl<T> FunctionArg for T
where + T: Type,

§

fn to_datatype(type_map: &mut TypeCollection) -> Option<DataType>

Gets the type of an argument as a [DataType]. Read more
§

impl<T> Instrument for T

§

fn instrument(self, span: Span) -> Instrumented<Self>

Instruments this type with the provided [Span], returning an +Instrumented wrapper. Read more
§

fn in_current_span(self) -> Instrumented<Self>

Instruments this type with the current Span, returning an +Instrumented wrapper. Read more
Source§

impl<T, U> Into<U> for T
where + U: From<T>,

Source§

fn into(self) -> U

Calls U::from(self).

+

That is, this conversion is whatever the implementation of +From<T> for U chooses to do.

+
§

impl<T> IpcResponse for T
where + T: Serialize,

§

fn body(self) -> Result<InvokeResponseBody, Error>

Resolve the IPC response body.
§

impl<T> PolicyExt for T
where + T: ?Sized,

§

fn and<P, B, E>(self, other: P) -> And<T, P>
where + T: Policy<B, E>, + P: Policy<B, E>,

Create a new Policy that returns [Action::Follow] only if self and other return +Action::Follow. Read more
§

fn or<P, B, E>(self, other: P) -> Or<T, P>
where + T: Policy<B, E>, + P: Policy<B, E>,

Create a new Policy that returns [Action::Follow] if either self or other returns +Action::Follow. Read more
Source§

impl<T> Same for T

Source§

type Output = T

Should always be Self
§

impl<T> ScopeObject for T
where + T: Send + Sync + Debug + DeserializeOwned + 'static,

§

type Error = Error

The error type.
§

fn deserialize<R>( + _app: &AppHandle<R>, + raw: Value, +) -> Result<T, <T as ScopeObject>::Error>
where + R: Runtime,

Deserialize the raw scope value.
Source§

impl<T> Serialize for T
where + T: Serialize + ?Sized,

Source§

fn erased_serialize(&self, serializer: &mut dyn Serializer) -> Result<(), Error>

Source§

fn do_erased_serialize( + &self, + serializer: &mut dyn Serializer, +) -> Result<(), ErrorImpl>

Source§

impl<T> ToOwned for T
where + T: Clone,

Source§

type Owned = T

The resulting type after obtaining ownership.
Source§

fn to_owned(&self) -> T

Creates owned data from borrowed data, usually by cloning. Read more
Source§

fn clone_into(&self, target: &mut T)

Uses borrowed data to replace owned data, usually by cloning. Read more
Source§

impl<T, U> TryFrom<U> for T
where + U: Into<T>,

Source§

type Error = Infallible

The type returned in the event of a conversion error.
Source§

fn try_from(value: U) -> Result<T, <T as TryFrom<U>>::Error>

Performs the conversion.
Source§

impl<T, U> TryInto<U> for T
where + U: TryFrom<T>,

Source§

type Error = <U as TryFrom<T>>::Error

The type returned in the event of a conversion error.
Source§

fn try_into(self) -> Result<U, <U as TryFrom<T>>::Error>

Performs the conversion.
§

impl<V, T> VZip<V> for T
where + V: MultiLane<T>,

§

fn vzip(self) -> V

§

impl<T> WithSubscriber for T

§

fn with_subscriber<S>(self, subscriber: S) -> WithDispatch<Self>
where + S: Into<Dispatch>,

Attaches the provided Subscriber to this type, returning a +[WithDispatch] wrapper. Read more
§

fn with_current_subscriber(self) -> WithDispatch<Self>

Attaches the current default Subscriber to this type, returning a +[WithDispatch] wrapper. Read more
Source§

impl<T> DeserializeOwned for T
where + T: for<'de> Deserialize<'de>,

§

impl<T> UserEvent for T
where + T: Debug + Clone + Send + 'static,

\ No newline at end of file diff --git a/api/jellytau_lib/commands/sessions/fn.sessions_poll_now.html b/api/jellytau_lib/commands/sessions/fn.sessions_poll_now.html new file mode 100644 index 00000000..f25548c1 --- /dev/null +++ b/api/jellytau_lib/commands/sessions/fn.sessions_poll_now.html @@ -0,0 +1,4 @@ +sessions_poll_now in jellytau_lib::commands::sessions - RustSkip to main content

sessions_poll_now

Function sessions_poll_now 

Source
pub async fn sessions_poll_now(
+    poller: State<'_, SessionPollerWrapper>,
+) -> Result<Vec<SessionInfo>, String>
Expand description

Manually trigger a session poll (for refresh button)

+
\ No newline at end of file diff --git a/api/jellytau_lib/commands/sessions/fn.sessions_set_polling_hint.html b/api/jellytau_lib/commands/sessions/fn.sessions_set_polling_hint.html new file mode 100644 index 00000000..9796a40b --- /dev/null +++ b/api/jellytau_lib/commands/sessions/fn.sessions_set_polling_hint.html @@ -0,0 +1,5 @@ +sessions_set_polling_hint in jellytau_lib::commands::sessions - RustSkip to main content

sessions_set_polling_hint

Function sessions_set_polling_hint 

Source
pub fn sessions_set_polling_hint(
+    poller: State<'_, SessionPollerWrapper>,
+    hint: String,
+) -> Result<(), String>
Expand description

Set polling frequency hint based on UI state

+
\ No newline at end of file diff --git a/api/jellytau_lib/commands/sessions/index.html b/api/jellytau_lib/commands/sessions/index.html new file mode 100644 index 00000000..fb732ba2 --- /dev/null +++ b/api/jellytau_lib/commands/sessions/index.html @@ -0,0 +1,2 @@ +jellytau_lib::commands::sessions - RustSkip to main content

Module sessions

Module sessions 

Source
Expand description

TRACES: UR-010 | JA-021 | DR-037

+

Structs§

SessionPollerWrapper
Tauri state wrapper for SessionPollerManager

Functions§

sessions_poll_now
Manually trigger a session poll (for refresh button)
sessions_set_polling_hint
Set polling frequency hint based on UI state
\ No newline at end of file diff --git a/api/jellytau_lib/commands/sessions/sidebar-items.js b/api/jellytau_lib/commands/sessions/sidebar-items.js new file mode 100644 index 00000000..7495e5b7 --- /dev/null +++ b/api/jellytau_lib/commands/sessions/sidebar-items.js @@ -0,0 +1 @@ +window.SIDEBAR_ITEMS = {"fn":["sessions_poll_now","sessions_set_polling_hint"],"struct":["SessionPollerWrapper"]}; \ No newline at end of file diff --git a/api/jellytau_lib/commands/sessions/struct.SessionPollerWrapper.html b/api/jellytau_lib/commands/sessions/struct.SessionPollerWrapper.html new file mode 100644 index 00000000..9aa60616 --- /dev/null +++ b/api/jellytau_lib/commands/sessions/struct.SessionPollerWrapper.html @@ -0,0 +1,25 @@ +SessionPollerWrapper in jellytau_lib::commands::sessions - RustSkip to main content

SessionPollerWrapper

Struct SessionPollerWrapper 

Source
pub struct SessionPollerWrapper(pub Arc<SessionPollerManager>);
Expand description

Tauri state wrapper for SessionPollerManager

+

Tuple Fields§

§0: Arc<SessionPollerManager>

Auto Trait Implementations§

Blanket Implementations§

Source§

impl<T> Any for T
where + T: 'static + ?Sized,

Source§

fn type_id(&self) -> TypeId

Gets the TypeId of self. Read more
Source§

impl<T> Borrow<T> for T
where + T: ?Sized,

Source§

fn borrow(&self) -> &T

Immutably borrows from an owned value. Read more
Source§

impl<T> BorrowMut<T> for T
where + T: ?Sized,

Source§

fn borrow_mut(&mut self) -> &mut T

Mutably borrows from an owned value. Read more
Source§

impl<T> From<T> for T

Source§

fn from(t: T) -> T

Returns the argument unchanged.

+
§

impl<T> Instrument for T

§

fn instrument(self, span: Span) -> Instrumented<Self>

Instruments this type with the provided [Span], returning an +Instrumented wrapper. Read more
§

fn in_current_span(self) -> Instrumented<Self>

Instruments this type with the current Span, returning an +Instrumented wrapper. Read more
Source§

impl<T, U> Into<U> for T
where + U: From<T>,

Source§

fn into(self) -> U

Calls U::from(self).

+

That is, this conversion is whatever the implementation of +From<T> for U chooses to do.

+
§

impl<T> PolicyExt for T
where + T: ?Sized,

§

fn and<P, B, E>(self, other: P) -> And<T, P>
where + T: Policy<B, E>, + P: Policy<B, E>,

Create a new Policy that returns [Action::Follow] only if self and other return +Action::Follow. Read more
§

fn or<P, B, E>(self, other: P) -> Or<T, P>
where + T: Policy<B, E>, + P: Policy<B, E>,

Create a new Policy that returns [Action::Follow] if either self or other returns +Action::Follow. Read more
Source§

impl<T> Same for T

Source§

type Output = T

Should always be Self
Source§

impl<T, U> TryFrom<U> for T
where + U: Into<T>,

Source§

type Error = Infallible

The type returned in the event of a conversion error.
Source§

fn try_from(value: U) -> Result<T, <T as TryFrom<U>>::Error>

Performs the conversion.
Source§

impl<T, U> TryInto<U> for T
where + U: TryFrom<T>,

Source§

type Error = <U as TryFrom<T>>::Error

The type returned in the event of a conversion error.
Source§

fn try_into(self) -> Result<U, <U as TryFrom<T>>::Error>

Performs the conversion.
§

impl<V, T> VZip<V> for T
where + V: MultiLane<T>,

§

fn vzip(self) -> V

§

impl<T> WithSubscriber for T

§

fn with_subscriber<S>(self, subscriber: S) -> WithDispatch<Self>
where + S: Into<Dispatch>,

Attaches the provided Subscriber to this type, returning a +[WithDispatch] wrapper. Read more
§

fn with_current_subscriber(self) -> WithDispatch<Self>

Attaches the current default Subscriber to this type, returning a +[WithDispatch] wrapper. Read more
\ No newline at end of file diff --git a/api/jellytau_lib/commands/sidebar-items.js b/api/jellytau_lib/commands/sidebar-items.js new file mode 100644 index 00000000..cf540d60 --- /dev/null +++ b/api/jellytau_lib/commands/sidebar-items.js @@ -0,0 +1 @@ +window.SIDEBAR_ITEMS = {"mod":["auth","catalog","connectivity","conversions","device","diagnostics","download","favorites","library","offline","playback_mode","playback_reporting","player","playlist","repository","sessions","storage","sync","sync_drain"]}; \ No newline at end of file diff --git a/api/jellytau_lib/commands/storage/fn.media_local_url.html b/api/jellytau_lib/commands/storage/fn.media_local_url.html new file mode 100644 index 00000000..690a0358 --- /dev/null +++ b/api/jellytau_lib/commands/storage/fn.media_local_url.html @@ -0,0 +1,12 @@ +media_local_url in jellytau_lib::commands::storage - RustSkip to main content

media_local_url

Function media_local_url 

Source
pub fn media_local_url(
+    server: State<'_, MediaServerWrapper>,
+    path: String,
+) -> Result<String, String>
Expand description

A playable URL for a downloaded file on disk.

+

Local media is served over a loopback HTTP server rather than handed to the +webview as a file:///asset URL, because the asset protocol cannot stream a +large file — it answers a range-less request with the whole thing, which +Chromium abandons. See media_server for why real HTTP is used.

+

The returned URL carries the server’s per-session token, so it is only valid +for this run of the app and must not be persisted.

+

TRACES: UR-071 | DR-137

+
\ No newline at end of file diff --git a/api/jellytau_lib/commands/storage/fn.row_to_cached_item.html b/api/jellytau_lib/commands/storage/fn.row_to_cached_item.html new file mode 100644 index 00000000..4355a180 --- /dev/null +++ b/api/jellytau_lib/commands/storage/fn.row_to_cached_item.html @@ -0,0 +1 @@ +row_to_cached_item in jellytau_lib::commands::storage - RustSkip to main content

row_to_cached_item

Function row_to_cached_item 

Source
fn row_to_cached_item(row: &Row<'_>) -> Result<CachedItem>
\ No newline at end of file diff --git a/api/jellytau_lib/commands/storage/fn.storage_delete_server.html b/api/jellytau_lib/commands/storage/fn.storage_delete_server.html new file mode 100644 index 00000000..84224fa9 --- /dev/null +++ b/api/jellytau_lib/commands/storage/fn.storage_delete_server.html @@ -0,0 +1,6 @@ +storage_delete_server in jellytau_lib::commands::storage - RustSkip to main content

storage_delete_server

Function storage_delete_server 

Source
pub async fn storage_delete_server(
+    db: State<'_, DatabaseWrapper>,
+    creds: State<'_, CredentialStoreWrapper>,
+    server_id: String,
+) -> Result<(), String>
Expand description

Delete a server and all associated data

+
\ No newline at end of file diff --git a/api/jellytau_lib/commands/storage/fn.storage_delete_user.html b/api/jellytau_lib/commands/storage/fn.storage_delete_user.html new file mode 100644 index 00000000..8a5d8fad --- /dev/null +++ b/api/jellytau_lib/commands/storage/fn.storage_delete_user.html @@ -0,0 +1,6 @@ +storage_delete_user in jellytau_lib::commands::storage - RustSkip to main content

storage_delete_user

Function storage_delete_user 

Source
pub async fn storage_delete_user(
+    db: State<'_, DatabaseWrapper>,
+    creds: State<'_, CredentialStoreWrapper>,
+    user_id: String,
+) -> Result<(), String>
Expand description

Delete a user account and their token from secure storage

+
\ No newline at end of file diff --git a/api/jellytau_lib/commands/storage/fn.storage_get_access_token.html b/api/jellytau_lib/commands/storage/fn.storage_get_access_token.html new file mode 100644 index 00000000..6a6b0d35 --- /dev/null +++ b/api/jellytau_lib/commands/storage/fn.storage_get_access_token.html @@ -0,0 +1,5 @@ +storage_get_access_token in jellytau_lib::commands::storage - RustSkip to main content

storage_get_access_token

Function storage_get_access_token 

Source
pub fn storage_get_access_token(
+    creds: State<'_, CredentialStoreWrapper>,
+    user_id: String,
+) -> Result<Option<String>, String>
Expand description

Get user’s access token from secure storage

+
\ No newline at end of file diff --git a/api/jellytau_lib/commands/storage/fn.storage_get_active_session.html b/api/jellytau_lib/commands/storage/fn.storage_get_active_session.html new file mode 100644 index 00000000..23be437a --- /dev/null +++ b/api/jellytau_lib/commands/storage/fn.storage_get_active_session.html @@ -0,0 +1,5 @@ +storage_get_active_session in jellytau_lib::commands::storage - RustSkip to main content

storage_get_active_session

Function storage_get_active_session 

Source
pub async fn storage_get_active_session(
+    db: State<'_, DatabaseWrapper>,
+    creds: State<'_, CredentialStoreWrapper>,
+) -> Result<Option<ActiveSession>, String>
Expand description

Get the active session (user + server + token) for session restoration

+
\ No newline at end of file diff --git a/api/jellytau_lib/commands/storage/fn.storage_get_active_user.html b/api/jellytau_lib/commands/storage/fn.storage_get_active_user.html new file mode 100644 index 00000000..7ff48877 --- /dev/null +++ b/api/jellytau_lib/commands/storage/fn.storage_get_active_user.html @@ -0,0 +1,5 @@ +storage_get_active_user in jellytau_lib::commands::storage - RustSkip to main content

storage_get_active_user

Function storage_get_active_user 

Source
pub async fn storage_get_active_user(
+    db: State<'_, DatabaseWrapper>,
+    server_id: String,
+) -> Result<Option<UserInfo>, String>
Expand description

Get the active user for a server

+
\ No newline at end of file diff --git a/api/jellytau_lib/commands/storage/fn.storage_get_item.html b/api/jellytau_lib/commands/storage/fn.storage_get_item.html new file mode 100644 index 00000000..23e905a9 --- /dev/null +++ b/api/jellytau_lib/commands/storage/fn.storage_get_item.html @@ -0,0 +1,5 @@ +storage_get_item in jellytau_lib::commands::storage - RustSkip to main content

storage_get_item

Function storage_get_item 

Source
pub async fn storage_get_item(
+    db: State<'_, DatabaseWrapper>,
+    item_id: String,
+) -> Result<Option<CachedItem>, String>
Expand description

Get a single cached item by ID

+
\ No newline at end of file diff --git a/api/jellytau_lib/commands/storage/fn.storage_get_items.html b/api/jellytau_lib/commands/storage/fn.storage_get_items.html new file mode 100644 index 00000000..9a2da89c --- /dev/null +++ b/api/jellytau_lib/commands/storage/fn.storage_get_items.html @@ -0,0 +1,10 @@ +storage_get_items in jellytau_lib::commands::storage - RustSkip to main content

storage_get_items

Function storage_get_items 

Source
pub async fn storage_get_items(
+    db: State<'_, DatabaseWrapper>,
+    server_id: String,
+    parent_id: Option<String>,
+    library_id: Option<String>,
+    item_type: Option<String>,
+    limit: Option<i32>,
+    offset: Option<i32>,
+) -> Result<Vec<CachedItem>, String>
Expand description

Get cached items with optional filtering

+
\ No newline at end of file diff --git a/api/jellytau_lib/commands/storage/fn.storage_get_libraries.html b/api/jellytau_lib/commands/storage/fn.storage_get_libraries.html new file mode 100644 index 00000000..d5e30fbf --- /dev/null +++ b/api/jellytau_lib/commands/storage/fn.storage_get_libraries.html @@ -0,0 +1,5 @@ +storage_get_libraries in jellytau_lib::commands::storage - RustSkip to main content

storage_get_libraries

Function storage_get_libraries 

Source
pub async fn storage_get_libraries(
+    db: State<'_, DatabaseWrapper>,
+    server_id: String,
+) -> Result<Vec<CachedLibrary>, String>
Expand description

Get cached libraries for a server

+
\ No newline at end of file diff --git a/api/jellytau_lib/commands/storage/fn.storage_get_path.html b/api/jellytau_lib/commands/storage/fn.storage_get_path.html new file mode 100644 index 00000000..e99f7776 --- /dev/null +++ b/api/jellytau_lib/commands/storage/fn.storage_get_path.html @@ -0,0 +1,4 @@ +storage_get_path in jellytau_lib::commands::storage - RustSkip to main content

storage_get_path

Function storage_get_path 

Source
pub fn storage_get_path(
+    db: State<'_, DatabaseWrapper>,
+) -> Result<String, String>
Expand description

Get storage directory path (parent directory of the database file)

+
\ No newline at end of file diff --git a/api/jellytau_lib/commands/storage/fn.storage_get_pending_sync_count.html b/api/jellytau_lib/commands/storage/fn.storage_get_pending_sync_count.html new file mode 100644 index 00000000..59ccb4c9 --- /dev/null +++ b/api/jellytau_lib/commands/storage/fn.storage_get_pending_sync_count.html @@ -0,0 +1,5 @@ +storage_get_pending_sync_count in jellytau_lib::commands::storage - RustSkip to main content

storage_get_pending_sync_count

Function storage_get_pending_sync_count 

Source
pub async fn storage_get_pending_sync_count(
+    db: State<'_, DatabaseWrapper>,
+    user_id: String,
+) -> Result<i32, String>
Expand description

Get count of pending sync operations for a user

+
\ No newline at end of file diff --git a/api/jellytau_lib/commands/storage/fn.storage_get_playback_progress.html b/api/jellytau_lib/commands/storage/fn.storage_get_playback_progress.html new file mode 100644 index 00000000..523c9557 --- /dev/null +++ b/api/jellytau_lib/commands/storage/fn.storage_get_playback_progress.html @@ -0,0 +1,6 @@ +storage_get_playback_progress in jellytau_lib::commands::storage - RustSkip to main content

storage_get_playback_progress

Function storage_get_playback_progress 

Source
pub async fn storage_get_playback_progress(
+    db: State<'_, DatabaseWrapper>,
+    user_id: String,
+    item_id: String,
+) -> Result<Option<PlaybackProgress>, String>
Expand description

Get playback progress for an item

+
\ No newline at end of file diff --git a/api/jellytau_lib/commands/storage/fn.storage_get_security_status.html b/api/jellytau_lib/commands/storage/fn.storage_get_security_status.html new file mode 100644 index 00000000..781f6708 --- /dev/null +++ b/api/jellytau_lib/commands/storage/fn.storage_get_security_status.html @@ -0,0 +1,4 @@ +storage_get_security_status in jellytau_lib::commands::storage - RustSkip to main content

storage_get_security_status

Function storage_get_security_status 

Source
pub fn storage_get_security_status(
+    creds: State<'_, CredentialStoreWrapper>,
+) -> Result<SecurityStatus, String>
Expand description

Get security status (keyring vs encrypted file fallback)

+
\ No newline at end of file diff --git a/api/jellytau_lib/commands/storage/fn.storage_get_servers.html b/api/jellytau_lib/commands/storage/fn.storage_get_servers.html new file mode 100644 index 00000000..fa2834db --- /dev/null +++ b/api/jellytau_lib/commands/storage/fn.storage_get_servers.html @@ -0,0 +1,4 @@ +storage_get_servers in jellytau_lib::commands::storage - RustSkip to main content

storage_get_servers

Function storage_get_servers 

Source
pub async fn storage_get_servers(
+    db: State<'_, DatabaseWrapper>,
+) -> Result<Vec<ServerInfo>, String>
Expand description

Get all saved servers

+
\ No newline at end of file diff --git a/api/jellytau_lib/commands/storage/fn.storage_get_size.html b/api/jellytau_lib/commands/storage/fn.storage_get_size.html new file mode 100644 index 00000000..4d2c331f --- /dev/null +++ b/api/jellytau_lib/commands/storage/fn.storage_get_size.html @@ -0,0 +1,4 @@ +storage_get_size in jellytau_lib::commands::storage - RustSkip to main content

storage_get_size

Function storage_get_size 

Source
pub fn storage_get_size(
+    db: State<'_, DatabaseWrapper>,
+) -> Result<Option<u64>, String>
Expand description

Get database file size in bytes

+
\ No newline at end of file diff --git a/api/jellytau_lib/commands/storage/fn.storage_get_users.html b/api/jellytau_lib/commands/storage/fn.storage_get_users.html new file mode 100644 index 00000000..381b0fed --- /dev/null +++ b/api/jellytau_lib/commands/storage/fn.storage_get_users.html @@ -0,0 +1,5 @@ +storage_get_users in jellytau_lib::commands::storage - RustSkip to main content

storage_get_users

Function storage_get_users 

Source
pub async fn storage_get_users(
+    db: State<'_, DatabaseWrapper>,
+    server_id: String,
+) -> Result<Vec<UserInfo>, String>
Expand description

Get users for a server

+
\ No newline at end of file diff --git a/api/jellytau_lib/commands/storage/fn.storage_init.html b/api/jellytau_lib/commands/storage/fn.storage_init.html new file mode 100644 index 00000000..74789e4c --- /dev/null +++ b/api/jellytau_lib/commands/storage/fn.storage_init.html @@ -0,0 +1,2 @@ +storage_init in jellytau_lib::commands::storage - RustSkip to main content

storage_init

Function storage_init 

Source
pub fn storage_init(db: State<'_, DatabaseWrapper>) -> Result<String, String>
Expand description

Initialize the database and run migrations

+
\ No newline at end of file diff --git a/api/jellytau_lib/commands/storage/fn.storage_mark_played.html b/api/jellytau_lib/commands/storage/fn.storage_mark_played.html new file mode 100644 index 00000000..4526995e --- /dev/null +++ b/api/jellytau_lib/commands/storage/fn.storage_mark_played.html @@ -0,0 +1,7 @@ +storage_mark_played in jellytau_lib::commands::storage - RustSkip to main content

storage_mark_played

Function storage_mark_played 

Source
pub async fn storage_mark_played(
+    db: State<'_, DatabaseWrapper>,
+    smart_cache: State<'_, SmartCacheWrapper>,
+    user_id: String,
+    item_id: String,
+) -> Result<(), String>
Expand description

Mark item as played in local database

+
\ No newline at end of file diff --git a/api/jellytau_lib/commands/storage/fn.storage_mark_synced.html b/api/jellytau_lib/commands/storage/fn.storage_mark_synced.html new file mode 100644 index 00000000..19ed9bdd --- /dev/null +++ b/api/jellytau_lib/commands/storage/fn.storage_mark_synced.html @@ -0,0 +1,6 @@ +storage_mark_synced in jellytau_lib::commands::storage - RustSkip to main content

storage_mark_synced

Function storage_mark_synced 

Source
pub async fn storage_mark_synced(
+    db: State<'_, DatabaseWrapper>,
+    user_id: String,
+    item_id: String,
+) -> Result<(), String>
Expand description

Mark pending sync as completed for an item

+
\ No newline at end of file diff --git a/api/jellytau_lib/commands/storage/fn.storage_save_item.html b/api/jellytau_lib/commands/storage/fn.storage_save_item.html new file mode 100644 index 00000000..185b0c48 --- /dev/null +++ b/api/jellytau_lib/commands/storage/fn.storage_save_item.html @@ -0,0 +1,6 @@ +storage_save_item in jellytau_lib::commands::storage - RustSkip to main content

storage_save_item

Function storage_save_item 

Source
pub async fn storage_save_item(
+    db: State<'_, DatabaseWrapper>,
+    item: CachedItem,
+    server_id: String,
+) -> Result<(), String>
Expand description

Save an item to the cache

+
\ No newline at end of file diff --git a/api/jellytau_lib/commands/storage/fn.storage_save_library.html b/api/jellytau_lib/commands/storage/fn.storage_save_library.html new file mode 100644 index 00000000..74592022 --- /dev/null +++ b/api/jellytau_lib/commands/storage/fn.storage_save_library.html @@ -0,0 +1,10 @@ +storage_save_library in jellytau_lib::commands::storage - RustSkip to main content

storage_save_library

Function storage_save_library 

Source
pub async fn storage_save_library(
+    db: State<'_, DatabaseWrapper>,
+    id: String,
+    server_id: String,
+    name: String,
+    collection_type: Option<String>,
+    image_tag: Option<String>,
+    sort_order: Option<i32>,
+) -> Result<(), String>
Expand description

Save a library to the cache

+
\ No newline at end of file diff --git a/api/jellytau_lib/commands/storage/fn.storage_save_server.html b/api/jellytau_lib/commands/storage/fn.storage_save_server.html new file mode 100644 index 00000000..f75034b0 --- /dev/null +++ b/api/jellytau_lib/commands/storage/fn.storage_save_server.html @@ -0,0 +1,9 @@ +storage_save_server in jellytau_lib::commands::storage - RustSkip to main content

storage_save_server

Function storage_save_server 

Source
pub async fn storage_save_server(
+    db: State<'_, DatabaseWrapper>,
+    id: String,
+    name: String,
+    url: String,
+    version: Option<String>,
+) -> Result<(), String>
Expand description

Save a server connection +Uses INSERT … ON CONFLICT to avoid triggering CASCADE DELETE on users

+
\ No newline at end of file diff --git a/api/jellytau_lib/commands/storage/fn.storage_save_user.html b/api/jellytau_lib/commands/storage/fn.storage_save_user.html new file mode 100644 index 00000000..6da92e5e --- /dev/null +++ b/api/jellytau_lib/commands/storage/fn.storage_save_user.html @@ -0,0 +1,9 @@ +storage_save_user in jellytau_lib::commands::storage - RustSkip to main content

storage_save_user

Function storage_save_user 

Source
pub async fn storage_save_user(
+    db: State<'_, DatabaseWrapper>,
+    creds: State<'_, CredentialStoreWrapper>,
+    id: String,
+    server_id: String,
+    username: String,
+    access_token: Option<String>,
+) -> Result<bool, String>
Expand description

Save a user account (token stored in secure storage, not database)

+
\ No newline at end of file diff --git a/api/jellytau_lib/commands/storage/fn.storage_search_items.html b/api/jellytau_lib/commands/storage/fn.storage_search_items.html new file mode 100644 index 00000000..aa005632 --- /dev/null +++ b/api/jellytau_lib/commands/storage/fn.storage_search_items.html @@ -0,0 +1,7 @@ +storage_search_items in jellytau_lib::commands::storage - RustSkip to main content

storage_search_items

Function storage_search_items 

Source
pub async fn storage_search_items(
+    db: State<'_, DatabaseWrapper>,
+    server_id: String,
+    query: String,
+    limit: Option<i32>,
+) -> Result<Vec<CachedItem>, String>
Expand description

Search cached items using FTS

+
\ No newline at end of file diff --git a/api/jellytau_lib/commands/storage/fn.storage_set_active_user.html b/api/jellytau_lib/commands/storage/fn.storage_set_active_user.html new file mode 100644 index 00000000..e973581a --- /dev/null +++ b/api/jellytau_lib/commands/storage/fn.storage_set_active_user.html @@ -0,0 +1,6 @@ +storage_set_active_user in jellytau_lib::commands::storage - RustSkip to main content

storage_set_active_user

Function storage_set_active_user 

Source
pub async fn storage_set_active_user(
+    db: State<'_, DatabaseWrapper>,
+    user_id: String,
+    _server_id: String,
+) -> Result<(), String>
Expand description

Set a user as active (and deactivate all other users globally)

+
\ No newline at end of file diff --git a/api/jellytau_lib/commands/storage/fn.storage_set_watched.html b/api/jellytau_lib/commands/storage/fn.storage_set_watched.html new file mode 100644 index 00000000..5ad71138 --- /dev/null +++ b/api/jellytau_lib/commands/storage/fn.storage_set_watched.html @@ -0,0 +1,23 @@ +storage_set_watched in jellytau_lib::commands::storage - RustSkip to main content

storage_set_watched

Function storage_set_watched 

Source
pub async fn storage_set_watched(
+    db: State<'_, DatabaseWrapper>,
+    user_id: String,
+    item_id: String,
+    watched: bool,
+) -> Result<(), String>
Expand description

Set the watched flag locally for an item and everything inside it.

+

This backs the watched toggle, and is deliberately separate from +storage_mark_played — which reports a single track/episode finishing and +increments play_count — because the toggle has two directions and applies +to containers.

+

The recursion is what makes the toggle honest offline. Jellyfin applies +POST/DELETE /PlayedItems/{id} recursively over a season or series, so +online the server fixes up the children on the next read; with no server to +ask, marking a season watched would otherwise tick the season and leave every +episode inside it unwatched. Targets are drawn from items by the same link +columns the rest of the offline layer uses, so an id that is not cached +selects nothing and the statement is a no-op rather than a foreign-key error.

+

Un-marking clears the resume position too, matching the server, so an item +un-marked offline does not come back offering to resume from a position it is +no longer meant to have.

+

pending_sync = 1 hands the rows to the sync drain.

+

TRACES: UR-073 | DR-158

+
\ No newline at end of file diff --git a/api/jellytau_lib/commands/storage/fn.storage_toggle_favorite.html b/api/jellytau_lib/commands/storage/fn.storage_toggle_favorite.html new file mode 100644 index 00000000..b3ac2aea --- /dev/null +++ b/api/jellytau_lib/commands/storage/fn.storage_toggle_favorite.html @@ -0,0 +1,8 @@ +storage_toggle_favorite in jellytau_lib::commands::storage - RustSkip to main content

storage_toggle_favorite

Function storage_toggle_favorite 

Source
pub async fn storage_toggle_favorite(
+    db: State<'_, DatabaseWrapper>,
+    user_id: String,
+    item_id: String,
+    is_favorite: bool,
+) -> Result<bool, String>
Expand description

Toggle favorite status for an item in local database +This updates the is_favorite field and marks it for sync to Jellyfin

+
\ No newline at end of file diff --git a/api/jellytau_lib/commands/storage/fn.storage_update_playback_context.html b/api/jellytau_lib/commands/storage/fn.storage_update_playback_context.html new file mode 100644 index 00000000..3ed5c4b5 --- /dev/null +++ b/api/jellytau_lib/commands/storage/fn.storage_update_playback_context.html @@ -0,0 +1,10 @@ +storage_update_playback_context in jellytau_lib::commands::storage - RustSkip to main content

storage_update_playback_context

Function storage_update_playback_context 

Source
pub async fn storage_update_playback_context(
+    db: State<'_, DatabaseWrapper>,
+    user_id: String,
+    item_id: String,
+    position_ms: i64,
+    context_type: Option<String>,
+    context_id: Option<String>,
+) -> Result<(), String>
Expand description

Update playback progress with context in local database +This stores the progress along with playback context (container vs single)

+
\ No newline at end of file diff --git a/api/jellytau_lib/commands/storage/fn.storage_update_playback_progress.html b/api/jellytau_lib/commands/storage/fn.storage_update_playback_progress.html new file mode 100644 index 00000000..d4284ed5 --- /dev/null +++ b/api/jellytau_lib/commands/storage/fn.storage_update_playback_progress.html @@ -0,0 +1,8 @@ +storage_update_playback_progress in jellytau_lib::commands::storage - RustSkip to main content

storage_update_playback_progress

Function storage_update_playback_progress 

Source
pub async fn storage_update_playback_progress(
+    db: State<'_, DatabaseWrapper>,
+    user_id: String,
+    item_id: String,
+    position_ms: i64,
+) -> Result<(), String>
Expand description

Update playback progress in local database +This stores the progress locally for offline access and “continue watching”

+
\ No newline at end of file diff --git a/api/jellytau_lib/commands/storage/index.html b/api/jellytau_lib/commands/storage/index.html new file mode 100644 index 00000000..ff3bdd14 --- /dev/null +++ b/api/jellytau_lib/commands/storage/index.html @@ -0,0 +1,7 @@ +jellytau_lib::commands::storage - RustSkip to main content

Module storage

Module storage 

Source
Expand description

Tauri commands for database/storage operations

+

TRACES: UR-002, UR-011, UR-012, UR-017, UR-019, UR-025, UR-047 | IR-013 | DR-012, DR-013, DR-022, DR-060

+

Re-exports§

pub use people::*;
pub use series_prefs::*;
pub use thumbnails::*;

Modules§

people 🔒
Person/cast metadata cache commands.
series_prefs 🔒
Per-series preferred audio track commands.
thumbnails 🔒
Thumbnail cache and image-URL commands.

Structs§

ActiveSession
Active session info (for session restoration)
CachedItem
Cached media item returned to frontend
CachedLibrary
Cached library info returned to frontend
CredentialStoreWrapper
Wrapper for thread-safe credential store access
DatabaseWrapper
Wrapper for thread-safe database access
PlaybackProgress
Playback progress info
SecurityStatus
Security status info
ServerInfo
Server info returned to frontend
ThumbnailCacheWrapper
Wrapper for thread-safe thumbnail cache access
UserInfo
User info returned to frontend

Functions§

media_local_url
A playable URL for a downloaded file on disk.
row_to_cached_item 🔒
storage_delete_server
Delete a server and all associated data
storage_delete_user
Delete a user account and their token from secure storage
storage_get_access_token
Get user’s access token from secure storage
storage_get_active_session
Get the active session (user + server + token) for session restoration
storage_get_active_user
Get the active user for a server
storage_get_item
Get a single cached item by ID
storage_get_items
Get cached items with optional filtering
storage_get_libraries
Get cached libraries for a server
storage_get_path
Get storage directory path (parent directory of the database file)
storage_get_pending_sync_count
Get count of pending sync operations for a user
storage_get_playback_progress
Get playback progress for an item
storage_get_security_status
Get security status (keyring vs encrypted file fallback)
storage_get_servers
Get all saved servers
storage_get_size
Get database file size in bytes
storage_get_users
Get users for a server
storage_init
Initialize the database and run migrations
storage_mark_played
Mark item as played in local database
storage_mark_synced
Mark pending sync as completed for an item
storage_save_item
Save an item to the cache
storage_save_library
Save a library to the cache
storage_save_server
Save a server connection +Uses INSERT … ON CONFLICT to avoid triggering CASCADE DELETE on users
storage_save_user
Save a user account (token stored in secure storage, not database)
storage_search_items
Search cached items using FTS
storage_set_active_user
Set a user as active (and deactivate all other users globally)
storage_set_watched
Set the watched flag locally for an item and everything inside it.
storage_toggle_favorite
Toggle favorite status for an item in local database +This updates the is_favorite field and marks it for sync to Jellyfin
storage_update_playback_context
Update playback progress with context in local database +This stores the progress along with playback context (container vs single)
storage_update_playback_progress
Update playback progress in local database +This stores the progress locally for offline access and “continue watching”
\ No newline at end of file diff --git a/api/jellytau_lib/commands/storage/people/fn.storage_get_item_people.html b/api/jellytau_lib/commands/storage/people/fn.storage_get_item_people.html new file mode 100644 index 00000000..d84fe4e6 --- /dev/null +++ b/api/jellytau_lib/commands/storage/people/fn.storage_get_item_people.html @@ -0,0 +1,5 @@ +storage_get_item_people in jellytau_lib::commands::storage::people - RustSkip to main content

storage_get_item_people

Function storage_get_item_people 

Source
pub async fn storage_get_item_people(
+    db: State<'_, DatabaseWrapper>,
+    item_id: String,
+) -> Result<Vec<CachedItemPerson>, String>
Expand description

Get people for an item (with person details joined)

+
\ No newline at end of file diff --git a/api/jellytau_lib/commands/storage/people/fn.storage_get_person.html b/api/jellytau_lib/commands/storage/people/fn.storage_get_person.html new file mode 100644 index 00000000..b65e4139 --- /dev/null +++ b/api/jellytau_lib/commands/storage/people/fn.storage_get_person.html @@ -0,0 +1,5 @@ +storage_get_person in jellytau_lib::commands::storage::people - RustSkip to main content

storage_get_person

Function storage_get_person 

Source
pub async fn storage_get_person(
+    db: State<'_, DatabaseWrapper>,
+    person_id: String,
+) -> Result<Option<CachedPerson>, String>
Expand description

Get a cached person by ID

+
\ No newline at end of file diff --git a/api/jellytau_lib/commands/storage/people/fn.storage_save_item_people.html b/api/jellytau_lib/commands/storage/people/fn.storage_save_item_people.html new file mode 100644 index 00000000..4dadf22b --- /dev/null +++ b/api/jellytau_lib/commands/storage/people/fn.storage_save_item_people.html @@ -0,0 +1,5 @@ +storage_save_item_people in jellytau_lib::commands::storage::people - RustSkip to main content

storage_save_item_people

Function storage_save_item_people 

Source
pub async fn storage_save_item_people(
+    db: State<'_, DatabaseWrapper>,
+    associations: Vec<CachedItemPerson>,
+) -> Result<(), String>
Expand description

Save item-person associations (batch)

+
\ No newline at end of file diff --git a/api/jellytau_lib/commands/storage/people/fn.storage_save_person.html b/api/jellytau_lib/commands/storage/people/fn.storage_save_person.html new file mode 100644 index 00000000..4575c766 --- /dev/null +++ b/api/jellytau_lib/commands/storage/people/fn.storage_save_person.html @@ -0,0 +1,5 @@ +storage_save_person in jellytau_lib::commands::storage::people - RustSkip to main content

storage_save_person

Function storage_save_person 

Source
pub async fn storage_save_person(
+    db: State<'_, DatabaseWrapper>,
+    person: CachedPerson,
+) -> Result<(), String>
Expand description

Save a person to the cache

+
\ No newline at end of file diff --git a/api/jellytau_lib/commands/storage/people/index.html b/api/jellytau_lib/commands/storage/people/index.html new file mode 100644 index 00000000..bc414a23 --- /dev/null +++ b/api/jellytau_lib/commands/storage/people/index.html @@ -0,0 +1,3 @@ +jellytau_lib::commands::storage::people - RustSkip to main content

Module people

Module people 

Source
Expand description

Person/cast metadata cache commands.

+

TRACES: UR-035, UR-036 | IR-023 | DR-040, DR-041

+

Structs§

CachedItemPerson
Item-person association for caching
CachedPerson
Cached person info returned to frontend

Functions§

storage_get_item_people
Get people for an item (with person details joined)
storage_get_person
Get a cached person by ID
storage_save_item_people
Save item-person associations (batch)
storage_save_person
Save a person to the cache
\ No newline at end of file diff --git a/api/jellytau_lib/commands/storage/people/sidebar-items.js b/api/jellytau_lib/commands/storage/people/sidebar-items.js new file mode 100644 index 00000000..2cf73458 --- /dev/null +++ b/api/jellytau_lib/commands/storage/people/sidebar-items.js @@ -0,0 +1 @@ +window.SIDEBAR_ITEMS = {"fn":["storage_get_item_people","storage_get_person","storage_save_item_people","storage_save_person"],"struct":["CachedItemPerson","CachedPerson"]}; \ No newline at end of file diff --git a/api/jellytau_lib/commands/storage/people/struct.CachedItemPerson.html b/api/jellytau_lib/commands/storage/people/struct.CachedItemPerson.html new file mode 100644 index 00000000..cd7636b2 --- /dev/null +++ b/api/jellytau_lib/commands/storage/people/struct.CachedItemPerson.html @@ -0,0 +1,57 @@ +CachedItemPerson in jellytau_lib::commands::storage::people - RustSkip to main content

CachedItemPerson

Struct CachedItemPerson 

Source
pub struct CachedItemPerson {
+    pub item_id: String,
+    pub person_id: String,
+    pub server_id: String,
+    pub person_type: String,
+    pub role: Option<String>,
+    pub sort_order: i32,
+}
Expand description

Item-person association for caching

+

Fields§

§item_id: String§person_id: String§server_id: String§person_type: String§role: Option<String>§sort_order: i32

Trait Implementations§

Source§

impl Clone for CachedItemPerson

Source§

fn clone(&self) -> CachedItemPerson

Returns a duplicate of the value. Read more
1.0.0 (const: unstable) · Source§

fn clone_from(&mut self, source: &Self)

Performs copy-assignment from source. Read more
Source§

impl Debug for CachedItemPerson

Source§

fn fmt(&self, f: &mut Formatter<'_>) -> Result

Formats the value using the given formatter. Read more
Source§

impl<'de> Deserialize<'de> for CachedItemPerson

Source§

fn deserialize<__D>(__deserializer: __D) -> Result<Self, __D::Error>
where + __D: Deserializer<'de>,

Deserialize this value from the given Serde deserializer. Read more
Source§

impl NamedType for CachedItemPerson

Source§

fn sid() -> SpectaID

Source§

fn named_data_type( + type_map: &mut TypeCollection, + generics: &[DataType], +) -> NamedDataType

this is equivalent to [Type::inline] but returns a [NamedDataType] instead.
Source§

fn definition_named_data_type(type_map: &mut TypeCollection) -> NamedDataType

this is equivalent to [Type::definition] but returns a [NamedDataType] instead.
Source§

impl Serialize for CachedItemPerson

Source§

fn serialize<__S>(&self, __serializer: __S) -> Result<__S::Ok, __S::Error>
where + __S: Serializer,

Serialize this value into the given Serde serializer. Read more
Source§

impl Type for CachedItemPerson

Source§

fn inline(type_map: &mut TypeCollection, generics: Generics<'_>) -> DataType

Returns the definition of a type using the provided generics. Read more
Source§

fn reference(type_map: &mut TypeCollection, generics: &[DataType]) -> Reference

Generates a datatype corresponding to a reference to this type, +as determined by its category. Getting a reference to a type implies that +it should belong in the type map (since it has to be referenced from somewhere), +so the output of definition will be put into the type map.
Source§

impl Flatten for CachedItemPerson

Auto Trait Implementations§

Blanket Implementations§

Source§

impl<T> Any for T
where + T: 'static + ?Sized,

Source§

fn type_id(&self) -> TypeId

Gets the TypeId of self. Read more
Source§

impl<T> Borrow<T> for T
where + T: ?Sized,

Source§

fn borrow(&self) -> &T

Immutably borrows from an owned value. Read more
Source§

impl<T> BorrowMut<T> for T
where + T: ?Sized,

Source§

fn borrow_mut(&mut self) -> &mut T

Mutably borrows from an owned value. Read more
Source§

impl<T> CloneToUninit for T
where + T: Clone,

Source§

unsafe fn clone_to_uninit(&self, dest: *mut u8)

🔬This is a nightly-only experimental API. (clone_to_uninit)
Performs copy-assignment from self to dest. Read more
§

impl<'de, D, R> CommandArg<'de, R> for D
where + D: Deserialize<'de>, + R: Runtime,

§

fn from_command(command: CommandItem<'de, R>) -> Result<D, InvokeError>

Derives an instance of Self from the [CommandItem]. Read more
Source§

impl<T> From<T> for T

Source§

fn from(t: T) -> T

Returns the argument unchanged.

+
§

impl<T> FunctionArg for T
where + T: Type,

§

fn to_datatype(type_map: &mut TypeCollection) -> Option<DataType>

Gets the type of an argument as a [DataType]. Read more
§

impl<T> Instrument for T

§

fn instrument(self, span: Span) -> Instrumented<Self>

Instruments this type with the provided [Span], returning an +Instrumented wrapper. Read more
§

fn in_current_span(self) -> Instrumented<Self>

Instruments this type with the current Span, returning an +Instrumented wrapper. Read more
Source§

impl<T, U> Into<U> for T
where + U: From<T>,

Source§

fn into(self) -> U

Calls U::from(self).

+

That is, this conversion is whatever the implementation of +From<T> for U chooses to do.

+
§

impl<T> IpcResponse for T
where + T: Serialize,

§

fn body(self) -> Result<InvokeResponseBody, Error>

Resolve the IPC response body.
§

impl<T> PolicyExt for T
where + T: ?Sized,

§

fn and<P, B, E>(self, other: P) -> And<T, P>
where + T: Policy<B, E>, + P: Policy<B, E>,

Create a new Policy that returns [Action::Follow] only if self and other return +Action::Follow. Read more
§

fn or<P, B, E>(self, other: P) -> Or<T, P>
where + T: Policy<B, E>, + P: Policy<B, E>,

Create a new Policy that returns [Action::Follow] if either self or other returns +Action::Follow. Read more
Source§

impl<T> Same for T

Source§

type Output = T

Should always be Self
§

impl<T> ScopeObject for T
where + T: Send + Sync + Debug + DeserializeOwned + 'static,

§

type Error = Error

The error type.
§

fn deserialize<R>( + _app: &AppHandle<R>, + raw: Value, +) -> Result<T, <T as ScopeObject>::Error>
where + R: Runtime,

Deserialize the raw scope value.
Source§

impl<T> Serialize for T
where + T: Serialize + ?Sized,

Source§

fn erased_serialize(&self, serializer: &mut dyn Serializer) -> Result<(), Error>

Source§

fn do_erased_serialize( + &self, + serializer: &mut dyn Serializer, +) -> Result<(), ErrorImpl>

Source§

impl<T> ToOwned for T
where + T: Clone,

Source§

type Owned = T

The resulting type after obtaining ownership.
Source§

fn to_owned(&self) -> T

Creates owned data from borrowed data, usually by cloning. Read more
Source§

fn clone_into(&self, target: &mut T)

Uses borrowed data to replace owned data, usually by cloning. Read more
Source§

impl<T, U> TryFrom<U> for T
where + U: Into<T>,

Source§

type Error = Infallible

The type returned in the event of a conversion error.
Source§

fn try_from(value: U) -> Result<T, <T as TryFrom<U>>::Error>

Performs the conversion.
Source§

impl<T, U> TryInto<U> for T
where + U: TryFrom<T>,

Source§

type Error = <U as TryFrom<T>>::Error

The type returned in the event of a conversion error.
Source§

fn try_into(self) -> Result<U, <U as TryFrom<T>>::Error>

Performs the conversion.
§

impl<V, T> VZip<V> for T
where + V: MultiLane<T>,

§

fn vzip(self) -> V

§

impl<T> WithSubscriber for T

§

fn with_subscriber<S>(self, subscriber: S) -> WithDispatch<Self>
where + S: Into<Dispatch>,

Attaches the provided Subscriber to this type, returning a +[WithDispatch] wrapper. Read more
§

fn with_current_subscriber(self) -> WithDispatch<Self>

Attaches the current default Subscriber to this type, returning a +[WithDispatch] wrapper. Read more
Source§

impl<T> DeserializeOwned for T
where + T: for<'de> Deserialize<'de>,

§

impl<T> UserEvent for T
where + T: Debug + Clone + Send + 'static,

\ No newline at end of file diff --git a/api/jellytau_lib/commands/storage/people/struct.CachedPerson.html b/api/jellytau_lib/commands/storage/people/struct.CachedPerson.html new file mode 100644 index 00000000..7816e901 --- /dev/null +++ b/api/jellytau_lib/commands/storage/people/struct.CachedPerson.html @@ -0,0 +1,58 @@ +CachedPerson in jellytau_lib::commands::storage::people - RustSkip to main content

CachedPerson

Struct CachedPerson 

Source
pub struct CachedPerson {
+    pub id: String,
+    pub server_id: String,
+    pub name: String,
+    pub overview: Option<String>,
+    pub primary_image_tag: Option<String>,
+    pub premiere_date: Option<String>,
+    pub end_date: Option<String>,
+}
Expand description

Cached person info returned to frontend

+

Fields§

§id: String§server_id: String§name: String§overview: Option<String>§primary_image_tag: Option<String>§premiere_date: Option<String>§end_date: Option<String>

Trait Implementations§

Source§

impl Clone for CachedPerson

Source§

fn clone(&self) -> CachedPerson

Returns a duplicate of the value. Read more
1.0.0 (const: unstable) · Source§

fn clone_from(&mut self, source: &Self)

Performs copy-assignment from source. Read more
Source§

impl Debug for CachedPerson

Source§

fn fmt(&self, f: &mut Formatter<'_>) -> Result

Formats the value using the given formatter. Read more
Source§

impl<'de> Deserialize<'de> for CachedPerson

Source§

fn deserialize<__D>(__deserializer: __D) -> Result<Self, __D::Error>
where + __D: Deserializer<'de>,

Deserialize this value from the given Serde deserializer. Read more
Source§

impl NamedType for CachedPerson

Source§

fn sid() -> SpectaID

Source§

fn named_data_type( + type_map: &mut TypeCollection, + generics: &[DataType], +) -> NamedDataType

this is equivalent to [Type::inline] but returns a [NamedDataType] instead.
Source§

fn definition_named_data_type(type_map: &mut TypeCollection) -> NamedDataType

this is equivalent to [Type::definition] but returns a [NamedDataType] instead.
Source§

impl Serialize for CachedPerson

Source§

fn serialize<__S>(&self, __serializer: __S) -> Result<__S::Ok, __S::Error>
where + __S: Serializer,

Serialize this value into the given Serde serializer. Read more
Source§

impl Type for CachedPerson

Source§

fn inline(type_map: &mut TypeCollection, generics: Generics<'_>) -> DataType

Returns the definition of a type using the provided generics. Read more
Source§

fn reference(type_map: &mut TypeCollection, generics: &[DataType]) -> Reference

Generates a datatype corresponding to a reference to this type, +as determined by its category. Getting a reference to a type implies that +it should belong in the type map (since it has to be referenced from somewhere), +so the output of definition will be put into the type map.
Source§

impl Flatten for CachedPerson

Auto Trait Implementations§

Blanket Implementations§

Source§

impl<T> Any for T
where + T: 'static + ?Sized,

Source§

fn type_id(&self) -> TypeId

Gets the TypeId of self. Read more
Source§

impl<T> Borrow<T> for T
where + T: ?Sized,

Source§

fn borrow(&self) -> &T

Immutably borrows from an owned value. Read more
Source§

impl<T> BorrowMut<T> for T
where + T: ?Sized,

Source§

fn borrow_mut(&mut self) -> &mut T

Mutably borrows from an owned value. Read more
Source§

impl<T> CloneToUninit for T
where + T: Clone,

Source§

unsafe fn clone_to_uninit(&self, dest: *mut u8)

🔬This is a nightly-only experimental API. (clone_to_uninit)
Performs copy-assignment from self to dest. Read more
§

impl<'de, D, R> CommandArg<'de, R> for D
where + D: Deserialize<'de>, + R: Runtime,

§

fn from_command(command: CommandItem<'de, R>) -> Result<D, InvokeError>

Derives an instance of Self from the [CommandItem]. Read more
Source§

impl<T> From<T> for T

Source§

fn from(t: T) -> T

Returns the argument unchanged.

+
§

impl<T> FunctionArg for T
where + T: Type,

§

fn to_datatype(type_map: &mut TypeCollection) -> Option<DataType>

Gets the type of an argument as a [DataType]. Read more
§

impl<T> Instrument for T

§

fn instrument(self, span: Span) -> Instrumented<Self>

Instruments this type with the provided [Span], returning an +Instrumented wrapper. Read more
§

fn in_current_span(self) -> Instrumented<Self>

Instruments this type with the current Span, returning an +Instrumented wrapper. Read more
Source§

impl<T, U> Into<U> for T
where + U: From<T>,

Source§

fn into(self) -> U

Calls U::from(self).

+

That is, this conversion is whatever the implementation of +From<T> for U chooses to do.

+
§

impl<T> IpcResponse for T
where + T: Serialize,

§

fn body(self) -> Result<InvokeResponseBody, Error>

Resolve the IPC response body.
§

impl<T> PolicyExt for T
where + T: ?Sized,

§

fn and<P, B, E>(self, other: P) -> And<T, P>
where + T: Policy<B, E>, + P: Policy<B, E>,

Create a new Policy that returns [Action::Follow] only if self and other return +Action::Follow. Read more
§

fn or<P, B, E>(self, other: P) -> Or<T, P>
where + T: Policy<B, E>, + P: Policy<B, E>,

Create a new Policy that returns [Action::Follow] if either self or other returns +Action::Follow. Read more
Source§

impl<T> Same for T

Source§

type Output = T

Should always be Self
§

impl<T> ScopeObject for T
where + T: Send + Sync + Debug + DeserializeOwned + 'static,

§

type Error = Error

The error type.
§

fn deserialize<R>( + _app: &AppHandle<R>, + raw: Value, +) -> Result<T, <T as ScopeObject>::Error>
where + R: Runtime,

Deserialize the raw scope value.
Source§

impl<T> Serialize for T
where + T: Serialize + ?Sized,

Source§

fn erased_serialize(&self, serializer: &mut dyn Serializer) -> Result<(), Error>

Source§

fn do_erased_serialize( + &self, + serializer: &mut dyn Serializer, +) -> Result<(), ErrorImpl>

Source§

impl<T> ToOwned for T
where + T: Clone,

Source§

type Owned = T

The resulting type after obtaining ownership.
Source§

fn to_owned(&self) -> T

Creates owned data from borrowed data, usually by cloning. Read more
Source§

fn clone_into(&self, target: &mut T)

Uses borrowed data to replace owned data, usually by cloning. Read more
Source§

impl<T, U> TryFrom<U> for T
where + U: Into<T>,

Source§

type Error = Infallible

The type returned in the event of a conversion error.
Source§

fn try_from(value: U) -> Result<T, <T as TryFrom<U>>::Error>

Performs the conversion.
Source§

impl<T, U> TryInto<U> for T
where + U: TryFrom<T>,

Source§

type Error = <U as TryFrom<T>>::Error

The type returned in the event of a conversion error.
Source§

fn try_into(self) -> Result<U, <U as TryFrom<T>>::Error>

Performs the conversion.
§

impl<V, T> VZip<V> for T
where + V: MultiLane<T>,

§

fn vzip(self) -> V

§

impl<T> WithSubscriber for T

§

fn with_subscriber<S>(self, subscriber: S) -> WithDispatch<Self>
where + S: Into<Dispatch>,

Attaches the provided Subscriber to this type, returning a +[WithDispatch] wrapper. Read more
§

fn with_current_subscriber(self) -> WithDispatch<Self>

Attaches the current default Subscriber to this type, returning a +[WithDispatch] wrapper. Read more
Source§

impl<T> DeserializeOwned for T
where + T: for<'de> Deserialize<'de>,

§

impl<T> UserEvent for T
where + T: Debug + Clone + Send + 'static,

\ No newline at end of file diff --git a/api/jellytau_lib/commands/storage/series_prefs/fn.storage_get_series_audio_preference.html b/api/jellytau_lib/commands/storage/series_prefs/fn.storage_get_series_audio_preference.html new file mode 100644 index 00000000..b14d530a --- /dev/null +++ b/api/jellytau_lib/commands/storage/series_prefs/fn.storage_get_series_audio_preference.html @@ -0,0 +1,6 @@ +storage_get_series_audio_preference in jellytau_lib::commands::storage::series_prefs - RustSkip to main content

storage_get_series_audio_preference

Function storage_get_series_audio_preference 

Source
pub async fn storage_get_series_audio_preference(
+    db: State<'_, DatabaseWrapper>,
+    user_id: String,
+    series_id: String,
+) -> Result<Option<SeriesAudioPreference>, String>
Expand description

Get user’s preferred audio track for a series

+
\ No newline at end of file diff --git a/api/jellytau_lib/commands/storage/series_prefs/fn.storage_save_series_audio_preference.html b/api/jellytau_lib/commands/storage/series_prefs/fn.storage_save_series_audio_preference.html new file mode 100644 index 00000000..3079d097 --- /dev/null +++ b/api/jellytau_lib/commands/storage/series_prefs/fn.storage_save_series_audio_preference.html @@ -0,0 +1,10 @@ +storage_save_series_audio_preference in jellytau_lib::commands::storage::series_prefs - RustSkip to main content

storage_save_series_audio_preference

Function storage_save_series_audio_preference 

Source
pub async fn storage_save_series_audio_preference(
+    db: State<'_, DatabaseWrapper>,
+    user_id: String,
+    series_id: String,
+    server_id: String,
+    audio_track_display_title: Option<String>,
+    audio_track_language: Option<String>,
+    audio_track_index: Option<i32>,
+) -> Result<(), String>
Expand description

Save user’s preferred audio track for a series

+
\ No newline at end of file diff --git a/api/jellytau_lib/commands/storage/series_prefs/index.html b/api/jellytau_lib/commands/storage/series_prefs/index.html new file mode 100644 index 00000000..37a5a3b8 --- /dev/null +++ b/api/jellytau_lib/commands/storage/series_prefs/index.html @@ -0,0 +1,3 @@ +jellytau_lib::commands::storage::series_prefs - RustSkip to main content

Module series_prefs

Module series_prefs 

Source
Expand description

Per-series preferred audio track commands.

+

TRACES: UR-021 | DR-024

+

Structs§

SeriesAudioPreference
Audio track preference for a series

Functions§

storage_get_series_audio_preference
Get user’s preferred audio track for a series
storage_save_series_audio_preference
Save user’s preferred audio track for a series
\ No newline at end of file diff --git a/api/jellytau_lib/commands/storage/series_prefs/sidebar-items.js b/api/jellytau_lib/commands/storage/series_prefs/sidebar-items.js new file mode 100644 index 00000000..af41ae57 --- /dev/null +++ b/api/jellytau_lib/commands/storage/series_prefs/sidebar-items.js @@ -0,0 +1 @@ +window.SIDEBAR_ITEMS = {"fn":["storage_get_series_audio_preference","storage_save_series_audio_preference"],"struct":["SeriesAudioPreference"]}; \ No newline at end of file diff --git a/api/jellytau_lib/commands/storage/series_prefs/struct.SeriesAudioPreference.html b/api/jellytau_lib/commands/storage/series_prefs/struct.SeriesAudioPreference.html new file mode 100644 index 00000000..8a2925e7 --- /dev/null +++ b/api/jellytau_lib/commands/storage/series_prefs/struct.SeriesAudioPreference.html @@ -0,0 +1,55 @@ +SeriesAudioPreference in jellytau_lib::commands::storage::series_prefs - RustSkip to main content

SeriesAudioPreference

Struct SeriesAudioPreference 

Source
pub struct SeriesAudioPreference {
+    pub series_id: String,
+    pub audio_track_display_title: Option<String>,
+    pub audio_track_language: Option<String>,
+    pub audio_track_index: Option<i32>,
+}
Expand description

Audio track preference for a series

+

Fields§

§series_id: String§audio_track_display_title: Option<String>§audio_track_language: Option<String>§audio_track_index: Option<i32>

Trait Implementations§

Source§

impl Clone for SeriesAudioPreference

Source§

fn clone(&self) -> SeriesAudioPreference

Returns a duplicate of the value. Read more
1.0.0 (const: unstable) · Source§

fn clone_from(&mut self, source: &Self)

Performs copy-assignment from source. Read more
Source§

impl Debug for SeriesAudioPreference

Source§

fn fmt(&self, f: &mut Formatter<'_>) -> Result

Formats the value using the given formatter. Read more
Source§

impl<'de> Deserialize<'de> for SeriesAudioPreference

Source§

fn deserialize<__D>(__deserializer: __D) -> Result<Self, __D::Error>
where + __D: Deserializer<'de>,

Deserialize this value from the given Serde deserializer. Read more
Source§

impl NamedType for SeriesAudioPreference

Source§

fn sid() -> SpectaID

Source§

fn named_data_type( + type_map: &mut TypeCollection, + generics: &[DataType], +) -> NamedDataType

this is equivalent to [Type::inline] but returns a [NamedDataType] instead.
Source§

fn definition_named_data_type(type_map: &mut TypeCollection) -> NamedDataType

this is equivalent to [Type::definition] but returns a [NamedDataType] instead.
Source§

impl Serialize for SeriesAudioPreference

Source§

fn serialize<__S>(&self, __serializer: __S) -> Result<__S::Ok, __S::Error>
where + __S: Serializer,

Serialize this value into the given Serde serializer. Read more
Source§

impl Type for SeriesAudioPreference

Source§

fn inline(type_map: &mut TypeCollection, generics: Generics<'_>) -> DataType

Returns the definition of a type using the provided generics. Read more
Source§

fn reference(type_map: &mut TypeCollection, generics: &[DataType]) -> Reference

Generates a datatype corresponding to a reference to this type, +as determined by its category. Getting a reference to a type implies that +it should belong in the type map (since it has to be referenced from somewhere), +so the output of definition will be put into the type map.
Source§

impl Flatten for SeriesAudioPreference

Auto Trait Implementations§

Blanket Implementations§

Source§

impl<T> Any for T
where + T: 'static + ?Sized,

Source§

fn type_id(&self) -> TypeId

Gets the TypeId of self. Read more
Source§

impl<T> Borrow<T> for T
where + T: ?Sized,

Source§

fn borrow(&self) -> &T

Immutably borrows from an owned value. Read more
Source§

impl<T> BorrowMut<T> for T
where + T: ?Sized,

Source§

fn borrow_mut(&mut self) -> &mut T

Mutably borrows from an owned value. Read more
Source§

impl<T> CloneToUninit for T
where + T: Clone,

Source§

unsafe fn clone_to_uninit(&self, dest: *mut u8)

🔬This is a nightly-only experimental API. (clone_to_uninit)
Performs copy-assignment from self to dest. Read more
§

impl<'de, D, R> CommandArg<'de, R> for D
where + D: Deserialize<'de>, + R: Runtime,

§

fn from_command(command: CommandItem<'de, R>) -> Result<D, InvokeError>

Derives an instance of Self from the [CommandItem]. Read more
Source§

impl<T> From<T> for T

Source§

fn from(t: T) -> T

Returns the argument unchanged.

+
§

impl<T> FunctionArg for T
where + T: Type,

§

fn to_datatype(type_map: &mut TypeCollection) -> Option<DataType>

Gets the type of an argument as a [DataType]. Read more
§

impl<T> Instrument for T

§

fn instrument(self, span: Span) -> Instrumented<Self>

Instruments this type with the provided [Span], returning an +Instrumented wrapper. Read more
§

fn in_current_span(self) -> Instrumented<Self>

Instruments this type with the current Span, returning an +Instrumented wrapper. Read more
Source§

impl<T, U> Into<U> for T
where + U: From<T>,

Source§

fn into(self) -> U

Calls U::from(self).

+

That is, this conversion is whatever the implementation of +From<T> for U chooses to do.

+
§

impl<T> IpcResponse for T
where + T: Serialize,

§

fn body(self) -> Result<InvokeResponseBody, Error>

Resolve the IPC response body.
§

impl<T> PolicyExt for T
where + T: ?Sized,

§

fn and<P, B, E>(self, other: P) -> And<T, P>
where + T: Policy<B, E>, + P: Policy<B, E>,

Create a new Policy that returns [Action::Follow] only if self and other return +Action::Follow. Read more
§

fn or<P, B, E>(self, other: P) -> Or<T, P>
where + T: Policy<B, E>, + P: Policy<B, E>,

Create a new Policy that returns [Action::Follow] if either self or other returns +Action::Follow. Read more
Source§

impl<T> Same for T

Source§

type Output = T

Should always be Self
§

impl<T> ScopeObject for T
where + T: Send + Sync + Debug + DeserializeOwned + 'static,

§

type Error = Error

The error type.
§

fn deserialize<R>( + _app: &AppHandle<R>, + raw: Value, +) -> Result<T, <T as ScopeObject>::Error>
where + R: Runtime,

Deserialize the raw scope value.
Source§

impl<T> Serialize for T
where + T: Serialize + ?Sized,

Source§

fn erased_serialize(&self, serializer: &mut dyn Serializer) -> Result<(), Error>

Source§

fn do_erased_serialize( + &self, + serializer: &mut dyn Serializer, +) -> Result<(), ErrorImpl>

Source§

impl<T> ToOwned for T
where + T: Clone,

Source§

type Owned = T

The resulting type after obtaining ownership.
Source§

fn to_owned(&self) -> T

Creates owned data from borrowed data, usually by cloning. Read more
Source§

fn clone_into(&self, target: &mut T)

Uses borrowed data to replace owned data, usually by cloning. Read more
Source§

impl<T, U> TryFrom<U> for T
where + U: Into<T>,

Source§

type Error = Infallible

The type returned in the event of a conversion error.
Source§

fn try_from(value: U) -> Result<T, <T as TryFrom<U>>::Error>

Performs the conversion.
Source§

impl<T, U> TryInto<U> for T
where + U: TryFrom<T>,

Source§

type Error = <U as TryFrom<T>>::Error

The type returned in the event of a conversion error.
Source§

fn try_into(self) -> Result<U, <U as TryFrom<T>>::Error>

Performs the conversion.
§

impl<V, T> VZip<V> for T
where + V: MultiLane<T>,

§

fn vzip(self) -> V

§

impl<T> WithSubscriber for T

§

fn with_subscriber<S>(self, subscriber: S) -> WithDispatch<Self>
where + S: Into<Dispatch>,

Attaches the provided Subscriber to this type, returning a +[WithDispatch] wrapper. Read more
§

fn with_current_subscriber(self) -> WithDispatch<Self>

Attaches the current default Subscriber to this type, returning a +[WithDispatch] wrapper. Read more
Source§

impl<T> DeserializeOwned for T
where + T: for<'de> Deserialize<'de>,

§

impl<T> UserEvent for T
where + T: Debug + Clone + Send + 'static,

\ No newline at end of file diff --git a/api/jellytau_lib/commands/storage/sidebar-items.js b/api/jellytau_lib/commands/storage/sidebar-items.js new file mode 100644 index 00000000..54b09ada --- /dev/null +++ b/api/jellytau_lib/commands/storage/sidebar-items.js @@ -0,0 +1 @@ +window.SIDEBAR_ITEMS = {"fn":["media_local_url","row_to_cached_item","storage_delete_server","storage_delete_user","storage_get_access_token","storage_get_active_session","storage_get_active_user","storage_get_item","storage_get_items","storage_get_libraries","storage_get_path","storage_get_pending_sync_count","storage_get_playback_progress","storage_get_security_status","storage_get_servers","storage_get_size","storage_get_users","storage_init","storage_mark_played","storage_mark_synced","storage_save_item","storage_save_library","storage_save_server","storage_save_user","storage_search_items","storage_set_active_user","storage_set_watched","storage_toggle_favorite","storage_update_playback_context","storage_update_playback_progress"],"mod":["people","series_prefs","thumbnails"],"struct":["ActiveSession","CachedItem","CachedLibrary","CredentialStoreWrapper","DatabaseWrapper","PlaybackProgress","SecurityStatus","ServerInfo","ThumbnailCacheWrapper","UserInfo"]}; \ No newline at end of file diff --git a/api/jellytau_lib/commands/storage/struct.ActiveSession.html b/api/jellytau_lib/commands/storage/struct.ActiveSession.html new file mode 100644 index 00000000..8cf0f97a --- /dev/null +++ b/api/jellytau_lib/commands/storage/struct.ActiveSession.html @@ -0,0 +1,57 @@ +ActiveSession in jellytau_lib::commands::storage - RustSkip to main content

ActiveSession

Struct ActiveSession 

Source
pub struct ActiveSession {
+    pub user_id: String,
+    pub username: String,
+    pub server_id: String,
+    pub server_url: String,
+    pub server_name: String,
+    pub access_token: String,
+}
Expand description

Active session info (for session restoration)

+

Fields§

§user_id: String§username: String§server_id: String§server_url: String§server_name: String§access_token: String

Trait Implementations§

Source§

impl Clone for ActiveSession

Source§

fn clone(&self) -> ActiveSession

Returns a duplicate of the value. Read more
1.0.0 (const: unstable) · Source§

fn clone_from(&mut self, source: &Self)

Performs copy-assignment from source. Read more
Source§

impl Debug for ActiveSession

Source§

fn fmt(&self, f: &mut Formatter<'_>) -> Result

Formats the value using the given formatter. Read more
Source§

impl<'de> Deserialize<'de> for ActiveSession

Source§

fn deserialize<__D>(__deserializer: __D) -> Result<Self, __D::Error>
where + __D: Deserializer<'de>,

Deserialize this value from the given Serde deserializer. Read more
Source§

impl NamedType for ActiveSession

Source§

fn sid() -> SpectaID

Source§

fn named_data_type( + type_map: &mut TypeCollection, + generics: &[DataType], +) -> NamedDataType

this is equivalent to [Type::inline] but returns a [NamedDataType] instead.
Source§

fn definition_named_data_type(type_map: &mut TypeCollection) -> NamedDataType

this is equivalent to [Type::definition] but returns a [NamedDataType] instead.
Source§

impl Serialize for ActiveSession

Source§

fn serialize<__S>(&self, __serializer: __S) -> Result<__S::Ok, __S::Error>
where + __S: Serializer,

Serialize this value into the given Serde serializer. Read more
Source§

impl Type for ActiveSession

Source§

fn inline(type_map: &mut TypeCollection, generics: Generics<'_>) -> DataType

Returns the definition of a type using the provided generics. Read more
Source§

fn reference(type_map: &mut TypeCollection, generics: &[DataType]) -> Reference

Generates a datatype corresponding to a reference to this type, +as determined by its category. Getting a reference to a type implies that +it should belong in the type map (since it has to be referenced from somewhere), +so the output of definition will be put into the type map.
Source§

impl Flatten for ActiveSession

Auto Trait Implementations§

Blanket Implementations§

Source§

impl<T> Any for T
where + T: 'static + ?Sized,

Source§

fn type_id(&self) -> TypeId

Gets the TypeId of self. Read more
Source§

impl<T> Borrow<T> for T
where + T: ?Sized,

Source§

fn borrow(&self) -> &T

Immutably borrows from an owned value. Read more
Source§

impl<T> BorrowMut<T> for T
where + T: ?Sized,

Source§

fn borrow_mut(&mut self) -> &mut T

Mutably borrows from an owned value. Read more
Source§

impl<T> CloneToUninit for T
where + T: Clone,

Source§

unsafe fn clone_to_uninit(&self, dest: *mut u8)

🔬This is a nightly-only experimental API. (clone_to_uninit)
Performs copy-assignment from self to dest. Read more
§

impl<'de, D, R> CommandArg<'de, R> for D
where + D: Deserialize<'de>, + R: Runtime,

§

fn from_command(command: CommandItem<'de, R>) -> Result<D, InvokeError>

Derives an instance of Self from the [CommandItem]. Read more
Source§

impl<T> From<T> for T

Source§

fn from(t: T) -> T

Returns the argument unchanged.

+
§

impl<T> FunctionArg for T
where + T: Type,

§

fn to_datatype(type_map: &mut TypeCollection) -> Option<DataType>

Gets the type of an argument as a [DataType]. Read more
§

impl<T> Instrument for T

§

fn instrument(self, span: Span) -> Instrumented<Self>

Instruments this type with the provided [Span], returning an +Instrumented wrapper. Read more
§

fn in_current_span(self) -> Instrumented<Self>

Instruments this type with the current Span, returning an +Instrumented wrapper. Read more
Source§

impl<T, U> Into<U> for T
where + U: From<T>,

Source§

fn into(self) -> U

Calls U::from(self).

+

That is, this conversion is whatever the implementation of +From<T> for U chooses to do.

+
§

impl<T> IpcResponse for T
where + T: Serialize,

§

fn body(self) -> Result<InvokeResponseBody, Error>

Resolve the IPC response body.
§

impl<T> PolicyExt for T
where + T: ?Sized,

§

fn and<P, B, E>(self, other: P) -> And<T, P>
where + T: Policy<B, E>, + P: Policy<B, E>,

Create a new Policy that returns [Action::Follow] only if self and other return +Action::Follow. Read more
§

fn or<P, B, E>(self, other: P) -> Or<T, P>
where + T: Policy<B, E>, + P: Policy<B, E>,

Create a new Policy that returns [Action::Follow] if either self or other returns +Action::Follow. Read more
Source§

impl<T> Same for T

Source§

type Output = T

Should always be Self
§

impl<T> ScopeObject for T
where + T: Send + Sync + Debug + DeserializeOwned + 'static,

§

type Error = Error

The error type.
§

fn deserialize<R>( + _app: &AppHandle<R>, + raw: Value, +) -> Result<T, <T as ScopeObject>::Error>
where + R: Runtime,

Deserialize the raw scope value.
Source§

impl<T> Serialize for T
where + T: Serialize + ?Sized,

Source§

fn erased_serialize(&self, serializer: &mut dyn Serializer) -> Result<(), Error>

Source§

fn do_erased_serialize( + &self, + serializer: &mut dyn Serializer, +) -> Result<(), ErrorImpl>

Source§

impl<T> ToOwned for T
where + T: Clone,

Source§

type Owned = T

The resulting type after obtaining ownership.
Source§

fn to_owned(&self) -> T

Creates owned data from borrowed data, usually by cloning. Read more
Source§

fn clone_into(&self, target: &mut T)

Uses borrowed data to replace owned data, usually by cloning. Read more
Source§

impl<T, U> TryFrom<U> for T
where + U: Into<T>,

Source§

type Error = Infallible

The type returned in the event of a conversion error.
Source§

fn try_from(value: U) -> Result<T, <T as TryFrom<U>>::Error>

Performs the conversion.
Source§

impl<T, U> TryInto<U> for T
where + U: TryFrom<T>,

Source§

type Error = <U as TryFrom<T>>::Error

The type returned in the event of a conversion error.
Source§

fn try_into(self) -> Result<U, <U as TryFrom<T>>::Error>

Performs the conversion.
§

impl<V, T> VZip<V> for T
where + V: MultiLane<T>,

§

fn vzip(self) -> V

§

impl<T> WithSubscriber for T

§

fn with_subscriber<S>(self, subscriber: S) -> WithDispatch<Self>
where + S: Into<Dispatch>,

Attaches the provided Subscriber to this type, returning a +[WithDispatch] wrapper. Read more
§

fn with_current_subscriber(self) -> WithDispatch<Self>

Attaches the current default Subscriber to this type, returning a +[WithDispatch] wrapper. Read more
Source§

impl<T> DeserializeOwned for T
where + T: for<'de> Deserialize<'de>,

§

impl<T> UserEvent for T
where + T: Debug + Clone + Send + 'static,

\ No newline at end of file diff --git a/api/jellytau_lib/commands/storage/struct.CachedItem.html b/api/jellytau_lib/commands/storage/struct.CachedItem.html new file mode 100644 index 00000000..23bc7ab0 --- /dev/null +++ b/api/jellytau_lib/commands/storage/struct.CachedItem.html @@ -0,0 +1,73 @@ +CachedItem in jellytau_lib::commands::storage - RustSkip to main content

CachedItem

Struct CachedItem 

Source
pub struct CachedItem {
Show 22 fields + pub id: String, + pub name: String, + pub item_type: String, + pub parent_id: Option<String>, + pub library_id: Option<String>, + pub overview: Option<String>, + pub genres: Option<String>, + pub runtime_ticks: Option<i64>, + pub production_year: Option<i32>, + pub community_rating: Option<f64>, + pub official_rating: Option<String>, + pub primary_image_tag: Option<String>, + pub album_id: Option<String>, + pub album_name: Option<String>, + pub album_artist: Option<String>, + pub artists: Option<String>, + pub index_number: Option<i32>, + pub series_id: Option<String>, + pub series_name: Option<String>, + pub season_id: Option<String>, + pub season_name: Option<String>, + pub parent_index_number: Option<i32>, +
}
Expand description

Cached media item returned to frontend

+

Fields§

§id: String§name: String§item_type: String§parent_id: Option<String>§library_id: Option<String>§overview: Option<String>§genres: Option<String>§runtime_ticks: Option<i64>§production_year: Option<i32>§community_rating: Option<f64>§official_rating: Option<String>§primary_image_tag: Option<String>§album_id: Option<String>§album_name: Option<String>§album_artist: Option<String>§artists: Option<String>§index_number: Option<i32>§series_id: Option<String>§series_name: Option<String>§season_id: Option<String>§season_name: Option<String>§parent_index_number: Option<i32>

Trait Implementations§

Source§

impl Clone for CachedItem

Source§

fn clone(&self) -> CachedItem

Returns a duplicate of the value. Read more
1.0.0 (const: unstable) · Source§

fn clone_from(&mut self, source: &Self)

Performs copy-assignment from source. Read more
Source§

impl Debug for CachedItem

Source§

fn fmt(&self, f: &mut Formatter<'_>) -> Result

Formats the value using the given formatter. Read more
Source§

impl<'de> Deserialize<'de> for CachedItem

Source§

fn deserialize<__D>(__deserializer: __D) -> Result<Self, __D::Error>
where + __D: Deserializer<'de>,

Deserialize this value from the given Serde deserializer. Read more
Source§

impl NamedType for CachedItem

Source§

fn sid() -> SpectaID

Source§

fn named_data_type( + type_map: &mut TypeCollection, + generics: &[DataType], +) -> NamedDataType

this is equivalent to [Type::inline] but returns a [NamedDataType] instead.
Source§

fn definition_named_data_type(type_map: &mut TypeCollection) -> NamedDataType

this is equivalent to [Type::definition] but returns a [NamedDataType] instead.
Source§

impl Serialize for CachedItem

Source§

fn serialize<__S>(&self, __serializer: __S) -> Result<__S::Ok, __S::Error>
where + __S: Serializer,

Serialize this value into the given Serde serializer. Read more
Source§

impl Type for CachedItem

Source§

fn inline(type_map: &mut TypeCollection, generics: Generics<'_>) -> DataType

Returns the definition of a type using the provided generics. Read more
Source§

fn reference(type_map: &mut TypeCollection, generics: &[DataType]) -> Reference

Generates a datatype corresponding to a reference to this type, +as determined by its category. Getting a reference to a type implies that +it should belong in the type map (since it has to be referenced from somewhere), +so the output of definition will be put into the type map.
Source§

impl Flatten for CachedItem

Auto Trait Implementations§

Blanket Implementations§

Source§

impl<T> Any for T
where + T: 'static + ?Sized,

Source§

fn type_id(&self) -> TypeId

Gets the TypeId of self. Read more
Source§

impl<T> Borrow<T> for T
where + T: ?Sized,

Source§

fn borrow(&self) -> &T

Immutably borrows from an owned value. Read more
Source§

impl<T> BorrowMut<T> for T
where + T: ?Sized,

Source§

fn borrow_mut(&mut self) -> &mut T

Mutably borrows from an owned value. Read more
Source§

impl<T> CloneToUninit for T
where + T: Clone,

Source§

unsafe fn clone_to_uninit(&self, dest: *mut u8)

🔬This is a nightly-only experimental API. (clone_to_uninit)
Performs copy-assignment from self to dest. Read more
§

impl<'de, D, R> CommandArg<'de, R> for D
where + D: Deserialize<'de>, + R: Runtime,

§

fn from_command(command: CommandItem<'de, R>) -> Result<D, InvokeError>

Derives an instance of Self from the [CommandItem]. Read more
Source§

impl<T> From<T> for T

Source§

fn from(t: T) -> T

Returns the argument unchanged.

+
§

impl<T> FunctionArg for T
where + T: Type,

§

fn to_datatype(type_map: &mut TypeCollection) -> Option<DataType>

Gets the type of an argument as a [DataType]. Read more
§

impl<T> Instrument for T

§

fn instrument(self, span: Span) -> Instrumented<Self>

Instruments this type with the provided [Span], returning an +Instrumented wrapper. Read more
§

fn in_current_span(self) -> Instrumented<Self>

Instruments this type with the current Span, returning an +Instrumented wrapper. Read more
Source§

impl<T, U> Into<U> for T
where + U: From<T>,

Source§

fn into(self) -> U

Calls U::from(self).

+

That is, this conversion is whatever the implementation of +From<T> for U chooses to do.

+
§

impl<T> IpcResponse for T
where + T: Serialize,

§

fn body(self) -> Result<InvokeResponseBody, Error>

Resolve the IPC response body.
§

impl<T> PolicyExt for T
where + T: ?Sized,

§

fn and<P, B, E>(self, other: P) -> And<T, P>
where + T: Policy<B, E>, + P: Policy<B, E>,

Create a new Policy that returns [Action::Follow] only if self and other return +Action::Follow. Read more
§

fn or<P, B, E>(self, other: P) -> Or<T, P>
where + T: Policy<B, E>, + P: Policy<B, E>,

Create a new Policy that returns [Action::Follow] if either self or other returns +Action::Follow. Read more
Source§

impl<T> Same for T

Source§

type Output = T

Should always be Self
§

impl<T> ScopeObject for T
where + T: Send + Sync + Debug + DeserializeOwned + 'static,

§

type Error = Error

The error type.
§

fn deserialize<R>( + _app: &AppHandle<R>, + raw: Value, +) -> Result<T, <T as ScopeObject>::Error>
where + R: Runtime,

Deserialize the raw scope value.
Source§

impl<T> Serialize for T
where + T: Serialize + ?Sized,

Source§

fn erased_serialize(&self, serializer: &mut dyn Serializer) -> Result<(), Error>

Source§

fn do_erased_serialize( + &self, + serializer: &mut dyn Serializer, +) -> Result<(), ErrorImpl>

Source§

impl<T> ToOwned for T
where + T: Clone,

Source§

type Owned = T

The resulting type after obtaining ownership.
Source§

fn to_owned(&self) -> T

Creates owned data from borrowed data, usually by cloning. Read more
Source§

fn clone_into(&self, target: &mut T)

Uses borrowed data to replace owned data, usually by cloning. Read more
Source§

impl<T, U> TryFrom<U> for T
where + U: Into<T>,

Source§

type Error = Infallible

The type returned in the event of a conversion error.
Source§

fn try_from(value: U) -> Result<T, <T as TryFrom<U>>::Error>

Performs the conversion.
Source§

impl<T, U> TryInto<U> for T
where + U: TryFrom<T>,

Source§

type Error = <U as TryFrom<T>>::Error

The type returned in the event of a conversion error.
Source§

fn try_into(self) -> Result<U, <U as TryFrom<T>>::Error>

Performs the conversion.
§

impl<V, T> VZip<V> for T
where + V: MultiLane<T>,

§

fn vzip(self) -> V

§

impl<T> WithSubscriber for T

§

fn with_subscriber<S>(self, subscriber: S) -> WithDispatch<Self>
where + S: Into<Dispatch>,

Attaches the provided Subscriber to this type, returning a +[WithDispatch] wrapper. Read more
§

fn with_current_subscriber(self) -> WithDispatch<Self>

Attaches the current default Subscriber to this type, returning a +[WithDispatch] wrapper. Read more
Source§

impl<T> DeserializeOwned for T
where + T: for<'de> Deserialize<'de>,

§

impl<T> UserEvent for T
where + T: Debug + Clone + Send + 'static,

\ No newline at end of file diff --git a/api/jellytau_lib/commands/storage/struct.CachedLibrary.html b/api/jellytau_lib/commands/storage/struct.CachedLibrary.html new file mode 100644 index 00000000..2fa326c4 --- /dev/null +++ b/api/jellytau_lib/commands/storage/struct.CachedLibrary.html @@ -0,0 +1,56 @@ +CachedLibrary in jellytau_lib::commands::storage - RustSkip to main content

CachedLibrary

Struct CachedLibrary 

Source
pub struct CachedLibrary {
+    pub id: String,
+    pub server_id: String,
+    pub name: String,
+    pub collection_type: Option<String>,
+    pub image_tag: Option<String>,
+}
Expand description

Cached library info returned to frontend

+

Fields§

§id: String§server_id: String§name: String§collection_type: Option<String>§image_tag: Option<String>

Trait Implementations§

Source§

impl Clone for CachedLibrary

Source§

fn clone(&self) -> CachedLibrary

Returns a duplicate of the value. Read more
1.0.0 (const: unstable) · Source§

fn clone_from(&mut self, source: &Self)

Performs copy-assignment from source. Read more
Source§

impl Debug for CachedLibrary

Source§

fn fmt(&self, f: &mut Formatter<'_>) -> Result

Formats the value using the given formatter. Read more
Source§

impl<'de> Deserialize<'de> for CachedLibrary

Source§

fn deserialize<__D>(__deserializer: __D) -> Result<Self, __D::Error>
where + __D: Deserializer<'de>,

Deserialize this value from the given Serde deserializer. Read more
Source§

impl NamedType for CachedLibrary

Source§

fn sid() -> SpectaID

Source§

fn named_data_type( + type_map: &mut TypeCollection, + generics: &[DataType], +) -> NamedDataType

this is equivalent to [Type::inline] but returns a [NamedDataType] instead.
Source§

fn definition_named_data_type(type_map: &mut TypeCollection) -> NamedDataType

this is equivalent to [Type::definition] but returns a [NamedDataType] instead.
Source§

impl Serialize for CachedLibrary

Source§

fn serialize<__S>(&self, __serializer: __S) -> Result<__S::Ok, __S::Error>
where + __S: Serializer,

Serialize this value into the given Serde serializer. Read more
Source§

impl Type for CachedLibrary

Source§

fn inline(type_map: &mut TypeCollection, generics: Generics<'_>) -> DataType

Returns the definition of a type using the provided generics. Read more
Source§

fn reference(type_map: &mut TypeCollection, generics: &[DataType]) -> Reference

Generates a datatype corresponding to a reference to this type, +as determined by its category. Getting a reference to a type implies that +it should belong in the type map (since it has to be referenced from somewhere), +so the output of definition will be put into the type map.
Source§

impl Flatten for CachedLibrary

Auto Trait Implementations§

Blanket Implementations§

Source§

impl<T> Any for T
where + T: 'static + ?Sized,

Source§

fn type_id(&self) -> TypeId

Gets the TypeId of self. Read more
Source§

impl<T> Borrow<T> for T
where + T: ?Sized,

Source§

fn borrow(&self) -> &T

Immutably borrows from an owned value. Read more
Source§

impl<T> BorrowMut<T> for T
where + T: ?Sized,

Source§

fn borrow_mut(&mut self) -> &mut T

Mutably borrows from an owned value. Read more
Source§

impl<T> CloneToUninit for T
where + T: Clone,

Source§

unsafe fn clone_to_uninit(&self, dest: *mut u8)

🔬This is a nightly-only experimental API. (clone_to_uninit)
Performs copy-assignment from self to dest. Read more
§

impl<'de, D, R> CommandArg<'de, R> for D
where + D: Deserialize<'de>, + R: Runtime,

§

fn from_command(command: CommandItem<'de, R>) -> Result<D, InvokeError>

Derives an instance of Self from the [CommandItem]. Read more
Source§

impl<T> From<T> for T

Source§

fn from(t: T) -> T

Returns the argument unchanged.

+
§

impl<T> FunctionArg for T
where + T: Type,

§

fn to_datatype(type_map: &mut TypeCollection) -> Option<DataType>

Gets the type of an argument as a [DataType]. Read more
§

impl<T> Instrument for T

§

fn instrument(self, span: Span) -> Instrumented<Self>

Instruments this type with the provided [Span], returning an +Instrumented wrapper. Read more
§

fn in_current_span(self) -> Instrumented<Self>

Instruments this type with the current Span, returning an +Instrumented wrapper. Read more
Source§

impl<T, U> Into<U> for T
where + U: From<T>,

Source§

fn into(self) -> U

Calls U::from(self).

+

That is, this conversion is whatever the implementation of +From<T> for U chooses to do.

+
§

impl<T> IpcResponse for T
where + T: Serialize,

§

fn body(self) -> Result<InvokeResponseBody, Error>

Resolve the IPC response body.
§

impl<T> PolicyExt for T
where + T: ?Sized,

§

fn and<P, B, E>(self, other: P) -> And<T, P>
where + T: Policy<B, E>, + P: Policy<B, E>,

Create a new Policy that returns [Action::Follow] only if self and other return +Action::Follow. Read more
§

fn or<P, B, E>(self, other: P) -> Or<T, P>
where + T: Policy<B, E>, + P: Policy<B, E>,

Create a new Policy that returns [Action::Follow] if either self or other returns +Action::Follow. Read more
Source§

impl<T> Same for T

Source§

type Output = T

Should always be Self
§

impl<T> ScopeObject for T
where + T: Send + Sync + Debug + DeserializeOwned + 'static,

§

type Error = Error

The error type.
§

fn deserialize<R>( + _app: &AppHandle<R>, + raw: Value, +) -> Result<T, <T as ScopeObject>::Error>
where + R: Runtime,

Deserialize the raw scope value.
Source§

impl<T> Serialize for T
where + T: Serialize + ?Sized,

Source§

fn erased_serialize(&self, serializer: &mut dyn Serializer) -> Result<(), Error>

Source§

fn do_erased_serialize( + &self, + serializer: &mut dyn Serializer, +) -> Result<(), ErrorImpl>

Source§

impl<T> ToOwned for T
where + T: Clone,

Source§

type Owned = T

The resulting type after obtaining ownership.
Source§

fn to_owned(&self) -> T

Creates owned data from borrowed data, usually by cloning. Read more
Source§

fn clone_into(&self, target: &mut T)

Uses borrowed data to replace owned data, usually by cloning. Read more
Source§

impl<T, U> TryFrom<U> for T
where + U: Into<T>,

Source§

type Error = Infallible

The type returned in the event of a conversion error.
Source§

fn try_from(value: U) -> Result<T, <T as TryFrom<U>>::Error>

Performs the conversion.
Source§

impl<T, U> TryInto<U> for T
where + U: TryFrom<T>,

Source§

type Error = <U as TryFrom<T>>::Error

The type returned in the event of a conversion error.
Source§

fn try_into(self) -> Result<U, <U as TryFrom<T>>::Error>

Performs the conversion.
§

impl<V, T> VZip<V> for T
where + V: MultiLane<T>,

§

fn vzip(self) -> V

§

impl<T> WithSubscriber for T

§

fn with_subscriber<S>(self, subscriber: S) -> WithDispatch<Self>
where + S: Into<Dispatch>,

Attaches the provided Subscriber to this type, returning a +[WithDispatch] wrapper. Read more
§

fn with_current_subscriber(self) -> WithDispatch<Self>

Attaches the current default Subscriber to this type, returning a +[WithDispatch] wrapper. Read more
Source§

impl<T> DeserializeOwned for T
where + T: for<'de> Deserialize<'de>,

§

impl<T> UserEvent for T
where + T: Debug + Clone + Send + 'static,

\ No newline at end of file diff --git a/api/jellytau_lib/commands/storage/struct.CredentialStoreWrapper.html b/api/jellytau_lib/commands/storage/struct.CredentialStoreWrapper.html new file mode 100644 index 00000000..c8b0bad6 --- /dev/null +++ b/api/jellytau_lib/commands/storage/struct.CredentialStoreWrapper.html @@ -0,0 +1,25 @@ +CredentialStoreWrapper in jellytau_lib::commands::storage - RustSkip to main content

CredentialStoreWrapper

Struct CredentialStoreWrapper 

Source
pub struct CredentialStoreWrapper(pub Mutex<CredentialStore>);
Expand description

Wrapper for thread-safe credential store access

+

Tuple Fields§

§0: Mutex<CredentialStore>

Auto Trait Implementations§

Blanket Implementations§

Source§

impl<T> Any for T
where + T: 'static + ?Sized,

Source§

fn type_id(&self) -> TypeId

Gets the TypeId of self. Read more
Source§

impl<T> Borrow<T> for T
where + T: ?Sized,

Source§

fn borrow(&self) -> &T

Immutably borrows from an owned value. Read more
Source§

impl<T> BorrowMut<T> for T
where + T: ?Sized,

Source§

fn borrow_mut(&mut self) -> &mut T

Mutably borrows from an owned value. Read more
Source§

impl<T> From<T> for T

Source§

fn from(t: T) -> T

Returns the argument unchanged.

+
§

impl<T> Instrument for T

§

fn instrument(self, span: Span) -> Instrumented<Self>

Instruments this type with the provided [Span], returning an +Instrumented wrapper. Read more
§

fn in_current_span(self) -> Instrumented<Self>

Instruments this type with the current Span, returning an +Instrumented wrapper. Read more
Source§

impl<T, U> Into<U> for T
where + U: From<T>,

Source§

fn into(self) -> U

Calls U::from(self).

+

That is, this conversion is whatever the implementation of +From<T> for U chooses to do.

+
§

impl<T> PolicyExt for T
where + T: ?Sized,

§

fn and<P, B, E>(self, other: P) -> And<T, P>
where + T: Policy<B, E>, + P: Policy<B, E>,

Create a new Policy that returns [Action::Follow] only if self and other return +Action::Follow. Read more
§

fn or<P, B, E>(self, other: P) -> Or<T, P>
where + T: Policy<B, E>, + P: Policy<B, E>,

Create a new Policy that returns [Action::Follow] if either self or other returns +Action::Follow. Read more
Source§

impl<T> Same for T

Source§

type Output = T

Should always be Self
Source§

impl<T, U> TryFrom<U> for T
where + U: Into<T>,

Source§

type Error = Infallible

The type returned in the event of a conversion error.
Source§

fn try_from(value: U) -> Result<T, <T as TryFrom<U>>::Error>

Performs the conversion.
Source§

impl<T, U> TryInto<U> for T
where + U: TryFrom<T>,

Source§

type Error = <U as TryFrom<T>>::Error

The type returned in the event of a conversion error.
Source§

fn try_into(self) -> Result<U, <U as TryFrom<T>>::Error>

Performs the conversion.
§

impl<V, T> VZip<V> for T
where + V: MultiLane<T>,

§

fn vzip(self) -> V

§

impl<T> WithSubscriber for T

§

fn with_subscriber<S>(self, subscriber: S) -> WithDispatch<Self>
where + S: Into<Dispatch>,

Attaches the provided Subscriber to this type, returning a +[WithDispatch] wrapper. Read more
§

fn with_current_subscriber(self) -> WithDispatch<Self>

Attaches the current default Subscriber to this type, returning a +[WithDispatch] wrapper. Read more
\ No newline at end of file diff --git a/api/jellytau_lib/commands/storage/struct.DatabaseWrapper.html b/api/jellytau_lib/commands/storage/struct.DatabaseWrapper.html new file mode 100644 index 00000000..789397df --- /dev/null +++ b/api/jellytau_lib/commands/storage/struct.DatabaseWrapper.html @@ -0,0 +1,25 @@ +DatabaseWrapper in jellytau_lib::commands::storage - RustSkip to main content

DatabaseWrapper

Struct DatabaseWrapper 

Source
pub struct DatabaseWrapper(pub Mutex<Database>);
Expand description

Wrapper for thread-safe database access

+

Tuple Fields§

§0: Mutex<Database>

Auto Trait Implementations§

Blanket Implementations§

Source§

impl<T> Any for T
where + T: 'static + ?Sized,

Source§

fn type_id(&self) -> TypeId

Gets the TypeId of self. Read more
Source§

impl<T> Borrow<T> for T
where + T: ?Sized,

Source§

fn borrow(&self) -> &T

Immutably borrows from an owned value. Read more
Source§

impl<T> BorrowMut<T> for T
where + T: ?Sized,

Source§

fn borrow_mut(&mut self) -> &mut T

Mutably borrows from an owned value. Read more
Source§

impl<T> From<T> for T

Source§

fn from(t: T) -> T

Returns the argument unchanged.

+
§

impl<T> Instrument for T

§

fn instrument(self, span: Span) -> Instrumented<Self>

Instruments this type with the provided [Span], returning an +Instrumented wrapper. Read more
§

fn in_current_span(self) -> Instrumented<Self>

Instruments this type with the current Span, returning an +Instrumented wrapper. Read more
Source§

impl<T, U> Into<U> for T
where + U: From<T>,

Source§

fn into(self) -> U

Calls U::from(self).

+

That is, this conversion is whatever the implementation of +From<T> for U chooses to do.

+
§

impl<T> PolicyExt for T
where + T: ?Sized,

§

fn and<P, B, E>(self, other: P) -> And<T, P>
where + T: Policy<B, E>, + P: Policy<B, E>,

Create a new Policy that returns [Action::Follow] only if self and other return +Action::Follow. Read more
§

fn or<P, B, E>(self, other: P) -> Or<T, P>
where + T: Policy<B, E>, + P: Policy<B, E>,

Create a new Policy that returns [Action::Follow] if either self or other returns +Action::Follow. Read more
Source§

impl<T> Same for T

Source§

type Output = T

Should always be Self
Source§

impl<T, U> TryFrom<U> for T
where + U: Into<T>,

Source§

type Error = Infallible

The type returned in the event of a conversion error.
Source§

fn try_from(value: U) -> Result<T, <T as TryFrom<U>>::Error>

Performs the conversion.
Source§

impl<T, U> TryInto<U> for T
where + U: TryFrom<T>,

Source§

type Error = <U as TryFrom<T>>::Error

The type returned in the event of a conversion error.
Source§

fn try_into(self) -> Result<U, <U as TryFrom<T>>::Error>

Performs the conversion.
§

impl<V, T> VZip<V> for T
where + V: MultiLane<T>,

§

fn vzip(self) -> V

§

impl<T> WithSubscriber for T

§

fn with_subscriber<S>(self, subscriber: S) -> WithDispatch<Self>
where + S: Into<Dispatch>,

Attaches the provided Subscriber to this type, returning a +[WithDispatch] wrapper. Read more
§

fn with_current_subscriber(self) -> WithDispatch<Self>

Attaches the current default Subscriber to this type, returning a +[WithDispatch] wrapper. Read more
\ No newline at end of file diff --git a/api/jellytau_lib/commands/storage/struct.PlaybackProgress.html b/api/jellytau_lib/commands/storage/struct.PlaybackProgress.html new file mode 100644 index 00000000..542a945c --- /dev/null +++ b/api/jellytau_lib/commands/storage/struct.PlaybackProgress.html @@ -0,0 +1,58 @@ +PlaybackProgress in jellytau_lib::commands::storage - RustSkip to main content

PlaybackProgress

Struct PlaybackProgress 

Source
pub struct PlaybackProgress {
+    pub item_id: String,
+    pub position_ms: i64,
+    pub is_played: bool,
+    pub is_favorite: bool,
+    pub play_count: i32,
+}
Expand description

Playback progress info

+

Fields§

§item_id: String§position_ms: i64

Resume position in milliseconds. Stored as Jellyfin ticks in the DB; +converted here so the frontend never sees ticks.

+
§is_played: bool§is_favorite: bool§play_count: i32

Trait Implementations§

Source§

impl Clone for PlaybackProgress

Source§

fn clone(&self) -> PlaybackProgress

Returns a duplicate of the value. Read more
1.0.0 (const: unstable) · Source§

fn clone_from(&mut self, source: &Self)

Performs copy-assignment from source. Read more
Source§

impl Debug for PlaybackProgress

Source§

fn fmt(&self, f: &mut Formatter<'_>) -> Result

Formats the value using the given formatter. Read more
Source§

impl<'de> Deserialize<'de> for PlaybackProgress

Source§

fn deserialize<__D>(__deserializer: __D) -> Result<Self, __D::Error>
where + __D: Deserializer<'de>,

Deserialize this value from the given Serde deserializer. Read more
Source§

impl NamedType for PlaybackProgress

Source§

fn sid() -> SpectaID

Source§

fn named_data_type( + type_map: &mut TypeCollection, + generics: &[DataType], +) -> NamedDataType

this is equivalent to [Type::inline] but returns a [NamedDataType] instead.
Source§

fn definition_named_data_type(type_map: &mut TypeCollection) -> NamedDataType

this is equivalent to [Type::definition] but returns a [NamedDataType] instead.
Source§

impl Serialize for PlaybackProgress

Source§

fn serialize<__S>(&self, __serializer: __S) -> Result<__S::Ok, __S::Error>
where + __S: Serializer,

Serialize this value into the given Serde serializer. Read more
Source§

impl Type for PlaybackProgress

Source§

fn inline(type_map: &mut TypeCollection, generics: Generics<'_>) -> DataType

Returns the definition of a type using the provided generics. Read more
Source§

fn reference(type_map: &mut TypeCollection, generics: &[DataType]) -> Reference

Generates a datatype corresponding to a reference to this type, +as determined by its category. Getting a reference to a type implies that +it should belong in the type map (since it has to be referenced from somewhere), +so the output of definition will be put into the type map.
Source§

impl Flatten for PlaybackProgress

Auto Trait Implementations§

Blanket Implementations§

Source§

impl<T> Any for T
where + T: 'static + ?Sized,

Source§

fn type_id(&self) -> TypeId

Gets the TypeId of self. Read more
Source§

impl<T> Borrow<T> for T
where + T: ?Sized,

Source§

fn borrow(&self) -> &T

Immutably borrows from an owned value. Read more
Source§

impl<T> BorrowMut<T> for T
where + T: ?Sized,

Source§

fn borrow_mut(&mut self) -> &mut T

Mutably borrows from an owned value. Read more
Source§

impl<T> CloneToUninit for T
where + T: Clone,

Source§

unsafe fn clone_to_uninit(&self, dest: *mut u8)

🔬This is a nightly-only experimental API. (clone_to_uninit)
Performs copy-assignment from self to dest. Read more
§

impl<'de, D, R> CommandArg<'de, R> for D
where + D: Deserialize<'de>, + R: Runtime,

§

fn from_command(command: CommandItem<'de, R>) -> Result<D, InvokeError>

Derives an instance of Self from the [CommandItem]. Read more
Source§

impl<T> From<T> for T

Source§

fn from(t: T) -> T

Returns the argument unchanged.

+
§

impl<T> FunctionArg for T
where + T: Type,

§

fn to_datatype(type_map: &mut TypeCollection) -> Option<DataType>

Gets the type of an argument as a [DataType]. Read more
§

impl<T> Instrument for T

§

fn instrument(self, span: Span) -> Instrumented<Self>

Instruments this type with the provided [Span], returning an +Instrumented wrapper. Read more
§

fn in_current_span(self) -> Instrumented<Self>

Instruments this type with the current Span, returning an +Instrumented wrapper. Read more
Source§

impl<T, U> Into<U> for T
where + U: From<T>,

Source§

fn into(self) -> U

Calls U::from(self).

+

That is, this conversion is whatever the implementation of +From<T> for U chooses to do.

+
§

impl<T> IpcResponse for T
where + T: Serialize,

§

fn body(self) -> Result<InvokeResponseBody, Error>

Resolve the IPC response body.
§

impl<T> PolicyExt for T
where + T: ?Sized,

§

fn and<P, B, E>(self, other: P) -> And<T, P>
where + T: Policy<B, E>, + P: Policy<B, E>,

Create a new Policy that returns [Action::Follow] only if self and other return +Action::Follow. Read more
§

fn or<P, B, E>(self, other: P) -> Or<T, P>
where + T: Policy<B, E>, + P: Policy<B, E>,

Create a new Policy that returns [Action::Follow] if either self or other returns +Action::Follow. Read more
Source§

impl<T> Same for T

Source§

type Output = T

Should always be Self
§

impl<T> ScopeObject for T
where + T: Send + Sync + Debug + DeserializeOwned + 'static,

§

type Error = Error

The error type.
§

fn deserialize<R>( + _app: &AppHandle<R>, + raw: Value, +) -> Result<T, <T as ScopeObject>::Error>
where + R: Runtime,

Deserialize the raw scope value.
Source§

impl<T> Serialize for T
where + T: Serialize + ?Sized,

Source§

fn erased_serialize(&self, serializer: &mut dyn Serializer) -> Result<(), Error>

Source§

fn do_erased_serialize( + &self, + serializer: &mut dyn Serializer, +) -> Result<(), ErrorImpl>

Source§

impl<T> ToOwned for T
where + T: Clone,

Source§

type Owned = T

The resulting type after obtaining ownership.
Source§

fn to_owned(&self) -> T

Creates owned data from borrowed data, usually by cloning. Read more
Source§

fn clone_into(&self, target: &mut T)

Uses borrowed data to replace owned data, usually by cloning. Read more
Source§

impl<T, U> TryFrom<U> for T
where + U: Into<T>,

Source§

type Error = Infallible

The type returned in the event of a conversion error.
Source§

fn try_from(value: U) -> Result<T, <T as TryFrom<U>>::Error>

Performs the conversion.
Source§

impl<T, U> TryInto<U> for T
where + U: TryFrom<T>,

Source§

type Error = <U as TryFrom<T>>::Error

The type returned in the event of a conversion error.
Source§

fn try_into(self) -> Result<U, <U as TryFrom<T>>::Error>

Performs the conversion.
§

impl<V, T> VZip<V> for T
where + V: MultiLane<T>,

§

fn vzip(self) -> V

§

impl<T> WithSubscriber for T

§

fn with_subscriber<S>(self, subscriber: S) -> WithDispatch<Self>
where + S: Into<Dispatch>,

Attaches the provided Subscriber to this type, returning a +[WithDispatch] wrapper. Read more
§

fn with_current_subscriber(self) -> WithDispatch<Self>

Attaches the current default Subscriber to this type, returning a +[WithDispatch] wrapper. Read more
Source§

impl<T> DeserializeOwned for T
where + T: for<'de> Deserialize<'de>,

§

impl<T> UserEvent for T
where + T: Debug + Clone + Send + 'static,

\ No newline at end of file diff --git a/api/jellytau_lib/commands/storage/struct.SecurityStatus.html b/api/jellytau_lib/commands/storage/struct.SecurityStatus.html new file mode 100644 index 00000000..c8607c61 --- /dev/null +++ b/api/jellytau_lib/commands/storage/struct.SecurityStatus.html @@ -0,0 +1,53 @@ +SecurityStatus in jellytau_lib::commands::storage - RustSkip to main content

SecurityStatus

Struct SecurityStatus 

Source
pub struct SecurityStatus {
+    pub using_keyring: bool,
+    pub storage_type: String,
+}
Expand description

Security status info

+

Fields§

§using_keyring: bool§storage_type: String

Trait Implementations§

Source§

impl Clone for SecurityStatus

Source§

fn clone(&self) -> SecurityStatus

Returns a duplicate of the value. Read more
1.0.0 (const: unstable) · Source§

fn clone_from(&mut self, source: &Self)

Performs copy-assignment from source. Read more
Source§

impl Debug for SecurityStatus

Source§

fn fmt(&self, f: &mut Formatter<'_>) -> Result

Formats the value using the given formatter. Read more
Source§

impl<'de> Deserialize<'de> for SecurityStatus

Source§

fn deserialize<__D>(__deserializer: __D) -> Result<Self, __D::Error>
where + __D: Deserializer<'de>,

Deserialize this value from the given Serde deserializer. Read more
Source§

impl NamedType for SecurityStatus

Source§

fn sid() -> SpectaID

Source§

fn named_data_type( + type_map: &mut TypeCollection, + generics: &[DataType], +) -> NamedDataType

this is equivalent to [Type::inline] but returns a [NamedDataType] instead.
Source§

fn definition_named_data_type(type_map: &mut TypeCollection) -> NamedDataType

this is equivalent to [Type::definition] but returns a [NamedDataType] instead.
Source§

impl Serialize for SecurityStatus

Source§

fn serialize<__S>(&self, __serializer: __S) -> Result<__S::Ok, __S::Error>
where + __S: Serializer,

Serialize this value into the given Serde serializer. Read more
Source§

impl Type for SecurityStatus

Source§

fn inline(type_map: &mut TypeCollection, generics: Generics<'_>) -> DataType

Returns the definition of a type using the provided generics. Read more
Source§

fn reference(type_map: &mut TypeCollection, generics: &[DataType]) -> Reference

Generates a datatype corresponding to a reference to this type, +as determined by its category. Getting a reference to a type implies that +it should belong in the type map (since it has to be referenced from somewhere), +so the output of definition will be put into the type map.
Source§

impl Flatten for SecurityStatus

Auto Trait Implementations§

Blanket Implementations§

Source§

impl<T> Any for T
where + T: 'static + ?Sized,

Source§

fn type_id(&self) -> TypeId

Gets the TypeId of self. Read more
Source§

impl<T> Borrow<T> for T
where + T: ?Sized,

Source§

fn borrow(&self) -> &T

Immutably borrows from an owned value. Read more
Source§

impl<T> BorrowMut<T> for T
where + T: ?Sized,

Source§

fn borrow_mut(&mut self) -> &mut T

Mutably borrows from an owned value. Read more
Source§

impl<T> CloneToUninit for T
where + T: Clone,

Source§

unsafe fn clone_to_uninit(&self, dest: *mut u8)

🔬This is a nightly-only experimental API. (clone_to_uninit)
Performs copy-assignment from self to dest. Read more
§

impl<'de, D, R> CommandArg<'de, R> for D
where + D: Deserialize<'de>, + R: Runtime,

§

fn from_command(command: CommandItem<'de, R>) -> Result<D, InvokeError>

Derives an instance of Self from the [CommandItem]. Read more
Source§

impl<T> From<T> for T

Source§

fn from(t: T) -> T

Returns the argument unchanged.

+
§

impl<T> FunctionArg for T
where + T: Type,

§

fn to_datatype(type_map: &mut TypeCollection) -> Option<DataType>

Gets the type of an argument as a [DataType]. Read more
§

impl<T> Instrument for T

§

fn instrument(self, span: Span) -> Instrumented<Self>

Instruments this type with the provided [Span], returning an +Instrumented wrapper. Read more
§

fn in_current_span(self) -> Instrumented<Self>

Instruments this type with the current Span, returning an +Instrumented wrapper. Read more
Source§

impl<T, U> Into<U> for T
where + U: From<T>,

Source§

fn into(self) -> U

Calls U::from(self).

+

That is, this conversion is whatever the implementation of +From<T> for U chooses to do.

+
§

impl<T> IpcResponse for T
where + T: Serialize,

§

fn body(self) -> Result<InvokeResponseBody, Error>

Resolve the IPC response body.
§

impl<T> PolicyExt for T
where + T: ?Sized,

§

fn and<P, B, E>(self, other: P) -> And<T, P>
where + T: Policy<B, E>, + P: Policy<B, E>,

Create a new Policy that returns [Action::Follow] only if self and other return +Action::Follow. Read more
§

fn or<P, B, E>(self, other: P) -> Or<T, P>
where + T: Policy<B, E>, + P: Policy<B, E>,

Create a new Policy that returns [Action::Follow] if either self or other returns +Action::Follow. Read more
Source§

impl<T> Same for T

Source§

type Output = T

Should always be Self
§

impl<T> ScopeObject for T
where + T: Send + Sync + Debug + DeserializeOwned + 'static,

§

type Error = Error

The error type.
§

fn deserialize<R>( + _app: &AppHandle<R>, + raw: Value, +) -> Result<T, <T as ScopeObject>::Error>
where + R: Runtime,

Deserialize the raw scope value.
Source§

impl<T> Serialize for T
where + T: Serialize + ?Sized,

Source§

fn erased_serialize(&self, serializer: &mut dyn Serializer) -> Result<(), Error>

Source§

fn do_erased_serialize( + &self, + serializer: &mut dyn Serializer, +) -> Result<(), ErrorImpl>

Source§

impl<T> ToOwned for T
where + T: Clone,

Source§

type Owned = T

The resulting type after obtaining ownership.
Source§

fn to_owned(&self) -> T

Creates owned data from borrowed data, usually by cloning. Read more
Source§

fn clone_into(&self, target: &mut T)

Uses borrowed data to replace owned data, usually by cloning. Read more
Source§

impl<T, U> TryFrom<U> for T
where + U: Into<T>,

Source§

type Error = Infallible

The type returned in the event of a conversion error.
Source§

fn try_from(value: U) -> Result<T, <T as TryFrom<U>>::Error>

Performs the conversion.
Source§

impl<T, U> TryInto<U> for T
where + U: TryFrom<T>,

Source§

type Error = <U as TryFrom<T>>::Error

The type returned in the event of a conversion error.
Source§

fn try_into(self) -> Result<U, <U as TryFrom<T>>::Error>

Performs the conversion.
§

impl<V, T> VZip<V> for T
where + V: MultiLane<T>,

§

fn vzip(self) -> V

§

impl<T> WithSubscriber for T

§

fn with_subscriber<S>(self, subscriber: S) -> WithDispatch<Self>
where + S: Into<Dispatch>,

Attaches the provided Subscriber to this type, returning a +[WithDispatch] wrapper. Read more
§

fn with_current_subscriber(self) -> WithDispatch<Self>

Attaches the current default Subscriber to this type, returning a +[WithDispatch] wrapper. Read more
Source§

impl<T> DeserializeOwned for T
where + T: for<'de> Deserialize<'de>,

§

impl<T> UserEvent for T
where + T: Debug + Clone + Send + 'static,

\ No newline at end of file diff --git a/api/jellytau_lib/commands/storage/struct.ServerInfo.html b/api/jellytau_lib/commands/storage/struct.ServerInfo.html new file mode 100644 index 00000000..ae575772 --- /dev/null +++ b/api/jellytau_lib/commands/storage/struct.ServerInfo.html @@ -0,0 +1,55 @@ +ServerInfo in jellytau_lib::commands::storage - RustSkip to main content

ServerInfo

Struct ServerInfo 

Source
pub struct ServerInfo {
+    pub id: String,
+    pub name: String,
+    pub url: String,
+    pub version: Option<String>,
+}
Expand description

Server info returned to frontend

+

Fields§

§id: String§name: String§url: String§version: Option<String>

Trait Implementations§

Source§

impl Clone for ServerInfo

Source§

fn clone(&self) -> ServerInfo

Returns a duplicate of the value. Read more
1.0.0 (const: unstable) · Source§

fn clone_from(&mut self, source: &Self)

Performs copy-assignment from source. Read more
Source§

impl Debug for ServerInfo

Source§

fn fmt(&self, f: &mut Formatter<'_>) -> Result

Formats the value using the given formatter. Read more
Source§

impl<'de> Deserialize<'de> for ServerInfo

Source§

fn deserialize<__D>(__deserializer: __D) -> Result<Self, __D::Error>
where + __D: Deserializer<'de>,

Deserialize this value from the given Serde deserializer. Read more
Source§

impl NamedType for ServerInfo

Source§

fn sid() -> SpectaID

Source§

fn named_data_type( + type_map: &mut TypeCollection, + generics: &[DataType], +) -> NamedDataType

this is equivalent to [Type::inline] but returns a [NamedDataType] instead.
Source§

fn definition_named_data_type(type_map: &mut TypeCollection) -> NamedDataType

this is equivalent to [Type::definition] but returns a [NamedDataType] instead.
Source§

impl Serialize for ServerInfo

Source§

fn serialize<__S>(&self, __serializer: __S) -> Result<__S::Ok, __S::Error>
where + __S: Serializer,

Serialize this value into the given Serde serializer. Read more
Source§

impl Type for ServerInfo

Source§

fn inline(type_map: &mut TypeCollection, generics: Generics<'_>) -> DataType

Returns the definition of a type using the provided generics. Read more
Source§

fn reference(type_map: &mut TypeCollection, generics: &[DataType]) -> Reference

Generates a datatype corresponding to a reference to this type, +as determined by its category. Getting a reference to a type implies that +it should belong in the type map (since it has to be referenced from somewhere), +so the output of definition will be put into the type map.
Source§

impl Flatten for ServerInfo

Auto Trait Implementations§

Blanket Implementations§

Source§

impl<T> Any for T
where + T: 'static + ?Sized,

Source§

fn type_id(&self) -> TypeId

Gets the TypeId of self. Read more
Source§

impl<T> Borrow<T> for T
where + T: ?Sized,

Source§

fn borrow(&self) -> &T

Immutably borrows from an owned value. Read more
Source§

impl<T> BorrowMut<T> for T
where + T: ?Sized,

Source§

fn borrow_mut(&mut self) -> &mut T

Mutably borrows from an owned value. Read more
Source§

impl<T> CloneToUninit for T
where + T: Clone,

Source§

unsafe fn clone_to_uninit(&self, dest: *mut u8)

🔬This is a nightly-only experimental API. (clone_to_uninit)
Performs copy-assignment from self to dest. Read more
§

impl<'de, D, R> CommandArg<'de, R> for D
where + D: Deserialize<'de>, + R: Runtime,

§

fn from_command(command: CommandItem<'de, R>) -> Result<D, InvokeError>

Derives an instance of Self from the [CommandItem]. Read more
Source§

impl<T> From<T> for T

Source§

fn from(t: T) -> T

Returns the argument unchanged.

+
§

impl<T> FunctionArg for T
where + T: Type,

§

fn to_datatype(type_map: &mut TypeCollection) -> Option<DataType>

Gets the type of an argument as a [DataType]. Read more
§

impl<T> Instrument for T

§

fn instrument(self, span: Span) -> Instrumented<Self>

Instruments this type with the provided [Span], returning an +Instrumented wrapper. Read more
§

fn in_current_span(self) -> Instrumented<Self>

Instruments this type with the current Span, returning an +Instrumented wrapper. Read more
Source§

impl<T, U> Into<U> for T
where + U: From<T>,

Source§

fn into(self) -> U

Calls U::from(self).

+

That is, this conversion is whatever the implementation of +From<T> for U chooses to do.

+
§

impl<T> IpcResponse for T
where + T: Serialize,

§

fn body(self) -> Result<InvokeResponseBody, Error>

Resolve the IPC response body.
§

impl<T> PolicyExt for T
where + T: ?Sized,

§

fn and<P, B, E>(self, other: P) -> And<T, P>
where + T: Policy<B, E>, + P: Policy<B, E>,

Create a new Policy that returns [Action::Follow] only if self and other return +Action::Follow. Read more
§

fn or<P, B, E>(self, other: P) -> Or<T, P>
where + T: Policy<B, E>, + P: Policy<B, E>,

Create a new Policy that returns [Action::Follow] if either self or other returns +Action::Follow. Read more
Source§

impl<T> Same for T

Source§

type Output = T

Should always be Self
§

impl<T> ScopeObject for T
where + T: Send + Sync + Debug + DeserializeOwned + 'static,

§

type Error = Error

The error type.
§

fn deserialize<R>( + _app: &AppHandle<R>, + raw: Value, +) -> Result<T, <T as ScopeObject>::Error>
where + R: Runtime,

Deserialize the raw scope value.
Source§

impl<T> Serialize for T
where + T: Serialize + ?Sized,

Source§

fn erased_serialize(&self, serializer: &mut dyn Serializer) -> Result<(), Error>

Source§

fn do_erased_serialize( + &self, + serializer: &mut dyn Serializer, +) -> Result<(), ErrorImpl>

Source§

impl<T> ToOwned for T
where + T: Clone,

Source§

type Owned = T

The resulting type after obtaining ownership.
Source§

fn to_owned(&self) -> T

Creates owned data from borrowed data, usually by cloning. Read more
Source§

fn clone_into(&self, target: &mut T)

Uses borrowed data to replace owned data, usually by cloning. Read more
Source§

impl<T, U> TryFrom<U> for T
where + U: Into<T>,

Source§

type Error = Infallible

The type returned in the event of a conversion error.
Source§

fn try_from(value: U) -> Result<T, <T as TryFrom<U>>::Error>

Performs the conversion.
Source§

impl<T, U> TryInto<U> for T
where + U: TryFrom<T>,

Source§

type Error = <U as TryFrom<T>>::Error

The type returned in the event of a conversion error.
Source§

fn try_into(self) -> Result<U, <U as TryFrom<T>>::Error>

Performs the conversion.
§

impl<V, T> VZip<V> for T
where + V: MultiLane<T>,

§

fn vzip(self) -> V

§

impl<T> WithSubscriber for T

§

fn with_subscriber<S>(self, subscriber: S) -> WithDispatch<Self>
where + S: Into<Dispatch>,

Attaches the provided Subscriber to this type, returning a +[WithDispatch] wrapper. Read more
§

fn with_current_subscriber(self) -> WithDispatch<Self>

Attaches the current default Subscriber to this type, returning a +[WithDispatch] wrapper. Read more
Source§

impl<T> DeserializeOwned for T
where + T: for<'de> Deserialize<'de>,

§

impl<T> UserEvent for T
where + T: Debug + Clone + Send + 'static,

\ No newline at end of file diff --git a/api/jellytau_lib/commands/storage/struct.ThumbnailCacheWrapper.html b/api/jellytau_lib/commands/storage/struct.ThumbnailCacheWrapper.html new file mode 100644 index 00000000..c79791db --- /dev/null +++ b/api/jellytau_lib/commands/storage/struct.ThumbnailCacheWrapper.html @@ -0,0 +1,25 @@ +ThumbnailCacheWrapper in jellytau_lib::commands::storage - RustSkip to main content

ThumbnailCacheWrapper

Struct ThumbnailCacheWrapper 

Source
pub struct ThumbnailCacheWrapper(pub Arc<ThumbnailCache>);
Expand description

Wrapper for thread-safe thumbnail cache access

+

Tuple Fields§

§0: Arc<ThumbnailCache>

Auto Trait Implementations§

Blanket Implementations§

Source§

impl<T> Any for T
where + T: 'static + ?Sized,

Source§

fn type_id(&self) -> TypeId

Gets the TypeId of self. Read more
Source§

impl<T> Borrow<T> for T
where + T: ?Sized,

Source§

fn borrow(&self) -> &T

Immutably borrows from an owned value. Read more
Source§

impl<T> BorrowMut<T> for T
where + T: ?Sized,

Source§

fn borrow_mut(&mut self) -> &mut T

Mutably borrows from an owned value. Read more
Source§

impl<T> From<T> for T

Source§

fn from(t: T) -> T

Returns the argument unchanged.

+
§

impl<T> Instrument for T

§

fn instrument(self, span: Span) -> Instrumented<Self>

Instruments this type with the provided [Span], returning an +Instrumented wrapper. Read more
§

fn in_current_span(self) -> Instrumented<Self>

Instruments this type with the current Span, returning an +Instrumented wrapper. Read more
Source§

impl<T, U> Into<U> for T
where + U: From<T>,

Source§

fn into(self) -> U

Calls U::from(self).

+

That is, this conversion is whatever the implementation of +From<T> for U chooses to do.

+
§

impl<T> PolicyExt for T
where + T: ?Sized,

§

fn and<P, B, E>(self, other: P) -> And<T, P>
where + T: Policy<B, E>, + P: Policy<B, E>,

Create a new Policy that returns [Action::Follow] only if self and other return +Action::Follow. Read more
§

fn or<P, B, E>(self, other: P) -> Or<T, P>
where + T: Policy<B, E>, + P: Policy<B, E>,

Create a new Policy that returns [Action::Follow] if either self or other returns +Action::Follow. Read more
Source§

impl<T> Same for T

Source§

type Output = T

Should always be Self
Source§

impl<T, U> TryFrom<U> for T
where + U: Into<T>,

Source§

type Error = Infallible

The type returned in the event of a conversion error.
Source§

fn try_from(value: U) -> Result<T, <T as TryFrom<U>>::Error>

Performs the conversion.
Source§

impl<T, U> TryInto<U> for T
where + U: TryFrom<T>,

Source§

type Error = <U as TryFrom<T>>::Error

The type returned in the event of a conversion error.
Source§

fn try_into(self) -> Result<U, <U as TryFrom<T>>::Error>

Performs the conversion.
§

impl<V, T> VZip<V> for T
where + V: MultiLane<T>,

§

fn vzip(self) -> V

§

impl<T> WithSubscriber for T

§

fn with_subscriber<S>(self, subscriber: S) -> WithDispatch<Self>
where + S: Into<Dispatch>,

Attaches the provided Subscriber to this type, returning a +[WithDispatch] wrapper. Read more
§

fn with_current_subscriber(self) -> WithDispatch<Self>

Attaches the current default Subscriber to this type, returning a +[WithDispatch] wrapper. Read more
\ No newline at end of file diff --git a/api/jellytau_lib/commands/storage/struct.UserInfo.html b/api/jellytau_lib/commands/storage/struct.UserInfo.html new file mode 100644 index 00000000..7b242baa --- /dev/null +++ b/api/jellytau_lib/commands/storage/struct.UserInfo.html @@ -0,0 +1,55 @@ +UserInfo in jellytau_lib::commands::storage - RustSkip to main content

UserInfo

Struct UserInfo 

Source
pub struct UserInfo {
+    pub id: String,
+    pub server_id: String,
+    pub username: String,
+    pub is_active: bool,
+}
Expand description

User info returned to frontend

+

Fields§

§id: String§server_id: String§username: String§is_active: bool

Trait Implementations§

Source§

impl Clone for UserInfo

Source§

fn clone(&self) -> UserInfo

Returns a duplicate of the value. Read more
1.0.0 (const: unstable) · Source§

fn clone_from(&mut self, source: &Self)

Performs copy-assignment from source. Read more
Source§

impl Debug for UserInfo

Source§

fn fmt(&self, f: &mut Formatter<'_>) -> Result

Formats the value using the given formatter. Read more
Source§

impl<'de> Deserialize<'de> for UserInfo

Source§

fn deserialize<__D>(__deserializer: __D) -> Result<Self, __D::Error>
where + __D: Deserializer<'de>,

Deserialize this value from the given Serde deserializer. Read more
Source§

impl NamedType for UserInfo

Source§

fn sid() -> SpectaID

Source§

fn named_data_type( + type_map: &mut TypeCollection, + generics: &[DataType], +) -> NamedDataType

this is equivalent to [Type::inline] but returns a [NamedDataType] instead.
Source§

fn definition_named_data_type(type_map: &mut TypeCollection) -> NamedDataType

this is equivalent to [Type::definition] but returns a [NamedDataType] instead.
Source§

impl Serialize for UserInfo

Source§

fn serialize<__S>(&self, __serializer: __S) -> Result<__S::Ok, __S::Error>
where + __S: Serializer,

Serialize this value into the given Serde serializer. Read more
Source§

impl Type for UserInfo

Source§

fn inline(type_map: &mut TypeCollection, generics: Generics<'_>) -> DataType

Returns the definition of a type using the provided generics. Read more
Source§

fn reference(type_map: &mut TypeCollection, generics: &[DataType]) -> Reference

Generates a datatype corresponding to a reference to this type, +as determined by its category. Getting a reference to a type implies that +it should belong in the type map (since it has to be referenced from somewhere), +so the output of definition will be put into the type map.
Source§

impl Flatten for UserInfo

Auto Trait Implementations§

Blanket Implementations§

Source§

impl<T> Any for T
where + T: 'static + ?Sized,

Source§

fn type_id(&self) -> TypeId

Gets the TypeId of self. Read more
Source§

impl<T> Borrow<T> for T
where + T: ?Sized,

Source§

fn borrow(&self) -> &T

Immutably borrows from an owned value. Read more
Source§

impl<T> BorrowMut<T> for T
where + T: ?Sized,

Source§

fn borrow_mut(&mut self) -> &mut T

Mutably borrows from an owned value. Read more
Source§

impl<T> CloneToUninit for T
where + T: Clone,

Source§

unsafe fn clone_to_uninit(&self, dest: *mut u8)

🔬This is a nightly-only experimental API. (clone_to_uninit)
Performs copy-assignment from self to dest. Read more
§

impl<'de, D, R> CommandArg<'de, R> for D
where + D: Deserialize<'de>, + R: Runtime,

§

fn from_command(command: CommandItem<'de, R>) -> Result<D, InvokeError>

Derives an instance of Self from the [CommandItem]. Read more
Source§

impl<T> From<T> for T

Source§

fn from(t: T) -> T

Returns the argument unchanged.

+
§

impl<T> FunctionArg for T
where + T: Type,

§

fn to_datatype(type_map: &mut TypeCollection) -> Option<DataType>

Gets the type of an argument as a [DataType]. Read more
§

impl<T> Instrument for T

§

fn instrument(self, span: Span) -> Instrumented<Self>

Instruments this type with the provided [Span], returning an +Instrumented wrapper. Read more
§

fn in_current_span(self) -> Instrumented<Self>

Instruments this type with the current Span, returning an +Instrumented wrapper. Read more
Source§

impl<T, U> Into<U> for T
where + U: From<T>,

Source§

fn into(self) -> U

Calls U::from(self).

+

That is, this conversion is whatever the implementation of +From<T> for U chooses to do.

+
§

impl<T> IpcResponse for T
where + T: Serialize,

§

fn body(self) -> Result<InvokeResponseBody, Error>

Resolve the IPC response body.
§

impl<T> PolicyExt for T
where + T: ?Sized,

§

fn and<P, B, E>(self, other: P) -> And<T, P>
where + T: Policy<B, E>, + P: Policy<B, E>,

Create a new Policy that returns [Action::Follow] only if self and other return +Action::Follow. Read more
§

fn or<P, B, E>(self, other: P) -> Or<T, P>
where + T: Policy<B, E>, + P: Policy<B, E>,

Create a new Policy that returns [Action::Follow] if either self or other returns +Action::Follow. Read more
Source§

impl<T> Same for T

Source§

type Output = T

Should always be Self
§

impl<T> ScopeObject for T
where + T: Send + Sync + Debug + DeserializeOwned + 'static,

§

type Error = Error

The error type.
§

fn deserialize<R>( + _app: &AppHandle<R>, + raw: Value, +) -> Result<T, <T as ScopeObject>::Error>
where + R: Runtime,

Deserialize the raw scope value.
Source§

impl<T> Serialize for T
where + T: Serialize + ?Sized,

Source§

fn erased_serialize(&self, serializer: &mut dyn Serializer) -> Result<(), Error>

Source§

fn do_erased_serialize( + &self, + serializer: &mut dyn Serializer, +) -> Result<(), ErrorImpl>

Source§

impl<T> ToOwned for T
where + T: Clone,

Source§

type Owned = T

The resulting type after obtaining ownership.
Source§

fn to_owned(&self) -> T

Creates owned data from borrowed data, usually by cloning. Read more
Source§

fn clone_into(&self, target: &mut T)

Uses borrowed data to replace owned data, usually by cloning. Read more
Source§

impl<T, U> TryFrom<U> for T
where + U: Into<T>,

Source§

type Error = Infallible

The type returned in the event of a conversion error.
Source§

fn try_from(value: U) -> Result<T, <T as TryFrom<U>>::Error>

Performs the conversion.
Source§

impl<T, U> TryInto<U> for T
where + U: TryFrom<T>,

Source§

type Error = <U as TryFrom<T>>::Error

The type returned in the event of a conversion error.
Source§

fn try_into(self) -> Result<U, <U as TryFrom<T>>::Error>

Performs the conversion.
§

impl<V, T> VZip<V> for T
where + V: MultiLane<T>,

§

fn vzip(self) -> V

§

impl<T> WithSubscriber for T

§

fn with_subscriber<S>(self, subscriber: S) -> WithDispatch<Self>
where + S: Into<Dispatch>,

Attaches the provided Subscriber to this type, returning a +[WithDispatch] wrapper. Read more
§

fn with_current_subscriber(self) -> WithDispatch<Self>

Attaches the current default Subscriber to this type, returning a +[WithDispatch] wrapper. Read more
Source§

impl<T> DeserializeOwned for T
where + T: for<'de> Deserialize<'de>,

§

impl<T> UserEvent for T
where + T: Debug + Clone + Send + 'static,

\ No newline at end of file diff --git a/api/jellytau_lib/commands/storage/thumbnails/fn.image_get_url.html b/api/jellytau_lib/commands/storage/thumbnails/fn.image_get_url.html new file mode 100644 index 00000000..5227f7bf --- /dev/null +++ b/api/jellytau_lib/commands/storage/thumbnails/fn.image_get_url.html @@ -0,0 +1,9 @@ +image_get_url in jellytau_lib::commands::storage::thumbnails - RustSkip to main content

image_get_url

Function image_get_url 

Source
pub async fn image_get_url(
+    repository_manager: State<'_, RepositoryManagerWrapper>,
+    thumbnail_cache: State<'_, ThumbnailCacheWrapper>,
+    db: State<'_, DatabaseWrapper>,
+    repository_handle: String,
+    request: GetImageRequest,
+) -> Result<String, String>
Expand description

Get image as base64 data URL, caching if not already cached +This extends the thumbnail system to serve all images through Rust with automatic caching

+
\ No newline at end of file diff --git a/api/jellytau_lib/commands/storage/thumbnails/fn.image_semaphore.html b/api/jellytau_lib/commands/storage/thumbnails/fn.image_semaphore.html new file mode 100644 index 00000000..df83fd3c --- /dev/null +++ b/api/jellytau_lib/commands/storage/thumbnails/fn.image_semaphore.html @@ -0,0 +1 @@ +image_semaphore in jellytau_lib::commands::storage::thumbnails - RustSkip to main content

image_semaphore

Function image_semaphore 

Source
fn image_semaphore() -> &'static Semaphore
\ No newline at end of file diff --git a/api/jellytau_lib/commands/storage/thumbnails/fn.mime_from_ext.html b/api/jellytau_lib/commands/storage/thumbnails/fn.mime_from_ext.html new file mode 100644 index 00000000..0b436b93 --- /dev/null +++ b/api/jellytau_lib/commands/storage/thumbnails/fn.mime_from_ext.html @@ -0,0 +1 @@ +mime_from_ext in jellytau_lib::commands::storage::thumbnails - RustSkip to main content

mime_from_ext

Function mime_from_ext 

Source
fn mime_from_ext(ext: Option<&str>) -> &'static str
\ No newline at end of file diff --git a/api/jellytau_lib/commands/storage/thumbnails/fn.thumbnail_clear_cache.html b/api/jellytau_lib/commands/storage/thumbnails/fn.thumbnail_clear_cache.html new file mode 100644 index 00000000..dab6998f --- /dev/null +++ b/api/jellytau_lib/commands/storage/thumbnails/fn.thumbnail_clear_cache.html @@ -0,0 +1,5 @@ +thumbnail_clear_cache in jellytau_lib::commands::storage::thumbnails - RustSkip to main content

thumbnail_clear_cache

Function thumbnail_clear_cache 

Source
pub async fn thumbnail_clear_cache(
+    db: State<'_, DatabaseWrapper>,
+    thumbnail_cache: State<'_, ThumbnailCacheWrapper>,
+) -> Result<(), String>
Expand description

Clear all cached thumbnails

+
\ No newline at end of file diff --git a/api/jellytau_lib/commands/storage/thumbnails/fn.thumbnail_delete_item.html b/api/jellytau_lib/commands/storage/thumbnails/fn.thumbnail_delete_item.html new file mode 100644 index 00000000..3c8acbe8 --- /dev/null +++ b/api/jellytau_lib/commands/storage/thumbnails/fn.thumbnail_delete_item.html @@ -0,0 +1,6 @@ +thumbnail_delete_item in jellytau_lib::commands::storage::thumbnails - RustSkip to main content

thumbnail_delete_item

Function thumbnail_delete_item 

Source
pub async fn thumbnail_delete_item(
+    db: State<'_, DatabaseWrapper>,
+    thumbnail_cache: State<'_, ThumbnailCacheWrapper>,
+    item_id: String,
+) -> Result<(), String>
Expand description

Delete cached thumbnails for a specific item

+
\ No newline at end of file diff --git a/api/jellytau_lib/commands/storage/thumbnails/fn.thumbnail_get_cached.html b/api/jellytau_lib/commands/storage/thumbnails/fn.thumbnail_get_cached.html new file mode 100644 index 00000000..414c8455 --- /dev/null +++ b/api/jellytau_lib/commands/storage/thumbnails/fn.thumbnail_get_cached.html @@ -0,0 +1,9 @@ +thumbnail_get_cached in jellytau_lib::commands::storage::thumbnails - RustSkip to main content

thumbnail_get_cached

Function thumbnail_get_cached 

Source
pub async fn thumbnail_get_cached(
+    db: State<'_, DatabaseWrapper>,
+    thumbnail_cache: State<'_, ThumbnailCacheWrapper>,
+    item_id: String,
+    image_type: String,
+    tag: String,
+) -> Result<Option<String>, String>
Expand description

Get cached thumbnail path, returns None if not cached +Also updates last_accessed timestamp for LRU tracking

+
\ No newline at end of file diff --git a/api/jellytau_lib/commands/storage/thumbnails/fn.thumbnail_get_stats.html b/api/jellytau_lib/commands/storage/thumbnails/fn.thumbnail_get_stats.html new file mode 100644 index 00000000..1edb9ecf --- /dev/null +++ b/api/jellytau_lib/commands/storage/thumbnails/fn.thumbnail_get_stats.html @@ -0,0 +1,5 @@ +thumbnail_get_stats in jellytau_lib::commands::storage::thumbnails - RustSkip to main content

thumbnail_get_stats

Function thumbnail_get_stats 

Source
pub async fn thumbnail_get_stats(
+    db: State<'_, DatabaseWrapper>,
+    thumbnail_cache: State<'_, ThumbnailCacheWrapper>,
+) -> Result<ThumbnailCacheStats, String>
Expand description

Get thumbnail cache statistics

+
\ No newline at end of file diff --git a/api/jellytau_lib/commands/storage/thumbnails/fn.thumbnail_save.html b/api/jellytau_lib/commands/storage/thumbnails/fn.thumbnail_save.html new file mode 100644 index 00000000..7c593ab0 --- /dev/null +++ b/api/jellytau_lib/commands/storage/thumbnails/fn.thumbnail_save.html @@ -0,0 +1,10 @@ +thumbnail_save in jellytau_lib::commands::storage::thumbnails - RustSkip to main content

thumbnail_save

Function thumbnail_save 

Source
pub async fn thumbnail_save(
+    db: State<'_, DatabaseWrapper>,
+    thumbnail_cache: State<'_, ThumbnailCacheWrapper>,
+    item_id: String,
+    image_type: String,
+    tag: String,
+    url: String,
+) -> Result<String, String>
Expand description

Download and save a thumbnail to cache +Returns the local file path on success

+
\ No newline at end of file diff --git a/api/jellytau_lib/commands/storage/thumbnails/fn.thumbnail_set_limit.html b/api/jellytau_lib/commands/storage/thumbnails/fn.thumbnail_set_limit.html new file mode 100644 index 00000000..4ee00f0a --- /dev/null +++ b/api/jellytau_lib/commands/storage/thumbnails/fn.thumbnail_set_limit.html @@ -0,0 +1,6 @@ +thumbnail_set_limit in jellytau_lib::commands::storage::thumbnails - RustSkip to main content

thumbnail_set_limit

Function thumbnail_set_limit 

Source
pub async fn thumbnail_set_limit(
+    db: State<'_, DatabaseWrapper>,
+    thumbnail_cache: State<'_, ThumbnailCacheWrapper>,
+    limit_bytes: u64,
+) -> Result<(), String>
Expand description

Set thumbnail cache storage limit in bytes

+
\ No newline at end of file diff --git a/api/jellytau_lib/commands/storage/thumbnails/index.html b/api/jellytau_lib/commands/storage/thumbnails/index.html new file mode 100644 index 00000000..e5b1afd5 --- /dev/null +++ b/api/jellytau_lib/commands/storage/thumbnails/index.html @@ -0,0 +1,8 @@ +jellytau_lib::commands::storage::thumbnails - RustSkip to main content

Module thumbnails

Module thumbnails 

Source
Expand description

Thumbnail cache and image-URL commands.

+

TRACES: UR-007 | JA-028 | DR-016

+

Structs§

GetImageRequest
Request to get an image URL (with caching)

Statics§

IMAGE_DOWNLOAD_SEMAPHORE 🔒
Limit concurrent image downloads to avoid saturating the connection pool. +Without this, rendering a page with hundreds of album cards fires hundreds of +concurrent HTTP requests, starving API calls and causing timeouts.

Functions§

image_get_url
Get image as base64 data URL, caching if not already cached +This extends the thumbnail system to serve all images through Rust with automatic caching
image_semaphore 🔒
mime_from_ext 🔒
thumbnail_clear_cache
Clear all cached thumbnails
thumbnail_delete_item
Delete cached thumbnails for a specific item
thumbnail_get_cached
Get cached thumbnail path, returns None if not cached +Also updates last_accessed timestamp for LRU tracking
thumbnail_get_stats
Get thumbnail cache statistics
thumbnail_save
Download and save a thumbnail to cache +Returns the local file path on success
thumbnail_set_limit
Set thumbnail cache storage limit in bytes
\ No newline at end of file diff --git a/api/jellytau_lib/commands/storage/thumbnails/sidebar-items.js b/api/jellytau_lib/commands/storage/thumbnails/sidebar-items.js new file mode 100644 index 00000000..275f9306 --- /dev/null +++ b/api/jellytau_lib/commands/storage/thumbnails/sidebar-items.js @@ -0,0 +1 @@ +window.SIDEBAR_ITEMS = {"fn":["image_get_url","image_semaphore","mime_from_ext","thumbnail_clear_cache","thumbnail_delete_item","thumbnail_get_cached","thumbnail_get_stats","thumbnail_save","thumbnail_set_limit"],"static":["IMAGE_DOWNLOAD_SEMAPHORE"],"struct":["GetImageRequest"]}; \ No newline at end of file diff --git a/api/jellytau_lib/commands/storage/thumbnails/static.IMAGE_DOWNLOAD_SEMAPHORE.html b/api/jellytau_lib/commands/storage/thumbnails/static.IMAGE_DOWNLOAD_SEMAPHORE.html new file mode 100644 index 00000000..8aab8b31 --- /dev/null +++ b/api/jellytau_lib/commands/storage/thumbnails/static.IMAGE_DOWNLOAD_SEMAPHORE.html @@ -0,0 +1,4 @@ +IMAGE_DOWNLOAD_SEMAPHORE in jellytau_lib::commands::storage::thumbnails - RustSkip to main content

IMAGE_DOWNLOAD_SEMAPHORE

Static IMAGE_DOWNLOAD_SEMAPHORE 

Source
static IMAGE_DOWNLOAD_SEMAPHORE: OnceLock<Semaphore>
Expand description

Limit concurrent image downloads to avoid saturating the connection pool. +Without this, rendering a page with hundreds of album cards fires hundreds of +concurrent HTTP requests, starving API calls and causing timeouts.

+
\ No newline at end of file diff --git a/api/jellytau_lib/commands/storage/thumbnails/struct.GetImageRequest.html b/api/jellytau_lib/commands/storage/thumbnails/struct.GetImageRequest.html new file mode 100644 index 00000000..113a2815 --- /dev/null +++ b/api/jellytau_lib/commands/storage/thumbnails/struct.GetImageRequest.html @@ -0,0 +1,47 @@ +GetImageRequest in jellytau_lib::commands::storage::thumbnails - RustSkip to main content

GetImageRequest

Struct GetImageRequest 

Source
pub struct GetImageRequest {
+    pub item_id: String,
+    pub image_type: String,
+    pub max_width: Option<u32>,
+    pub max_height: Option<u32>,
+    pub tag: Option<String>,
+}
Expand description

Request to get an image URL (with caching)

+

Fields§

§item_id: String§image_type: String§max_width: Option<u32>§max_height: Option<u32>§tag: Option<String>

Trait Implementations§

Source§

impl Debug for GetImageRequest

Source§

fn fmt(&self, f: &mut Formatter<'_>) -> Result

Formats the value using the given formatter. Read more
Source§

impl<'de> Deserialize<'de> for GetImageRequest

Source§

fn deserialize<__D>(__deserializer: __D) -> Result<Self, __D::Error>
where + __D: Deserializer<'de>,

Deserialize this value from the given Serde deserializer. Read more
Source§

impl NamedType for GetImageRequest

Source§

fn sid() -> SpectaID

Source§

fn named_data_type( + type_map: &mut TypeCollection, + generics: &[DataType], +) -> NamedDataType

this is equivalent to [Type::inline] but returns a [NamedDataType] instead.
Source§

fn definition_named_data_type(type_map: &mut TypeCollection) -> NamedDataType

this is equivalent to [Type::definition] but returns a [NamedDataType] instead.
Source§

impl Type for GetImageRequest

Source§

fn inline(type_map: &mut TypeCollection, generics: Generics<'_>) -> DataType

Returns the definition of a type using the provided generics. Read more
Source§

fn reference(type_map: &mut TypeCollection, generics: &[DataType]) -> Reference

Generates a datatype corresponding to a reference to this type, +as determined by its category. Getting a reference to a type implies that +it should belong in the type map (since it has to be referenced from somewhere), +so the output of definition will be put into the type map.
Source§

impl Flatten for GetImageRequest

Auto Trait Implementations§

Blanket Implementations§

Source§

impl<T> Any for T
where + T: 'static + ?Sized,

Source§

fn type_id(&self) -> TypeId

Gets the TypeId of self. Read more
Source§

impl<T> Borrow<T> for T
where + T: ?Sized,

Source§

fn borrow(&self) -> &T

Immutably borrows from an owned value. Read more
Source§

impl<T> BorrowMut<T> for T
where + T: ?Sized,

Source§

fn borrow_mut(&mut self) -> &mut T

Mutably borrows from an owned value. Read more
§

impl<'de, D, R> CommandArg<'de, R> for D
where + D: Deserialize<'de>, + R: Runtime,

§

fn from_command(command: CommandItem<'de, R>) -> Result<D, InvokeError>

Derives an instance of Self from the [CommandItem]. Read more
Source§

impl<T> From<T> for T

Source§

fn from(t: T) -> T

Returns the argument unchanged.

+
§

impl<T> FunctionArg for T
where + T: Type,

§

fn to_datatype(type_map: &mut TypeCollection) -> Option<DataType>

Gets the type of an argument as a [DataType]. Read more
§

impl<T> Instrument for T

§

fn instrument(self, span: Span) -> Instrumented<Self>

Instruments this type with the provided [Span], returning an +Instrumented wrapper. Read more
§

fn in_current_span(self) -> Instrumented<Self>

Instruments this type with the current Span, returning an +Instrumented wrapper. Read more
Source§

impl<T, U> Into<U> for T
where + U: From<T>,

Source§

fn into(self) -> U

Calls U::from(self).

+

That is, this conversion is whatever the implementation of +From<T> for U chooses to do.

+
§

impl<T> PolicyExt for T
where + T: ?Sized,

§

fn and<P, B, E>(self, other: P) -> And<T, P>
where + T: Policy<B, E>, + P: Policy<B, E>,

Create a new Policy that returns [Action::Follow] only if self and other return +Action::Follow. Read more
§

fn or<P, B, E>(self, other: P) -> Or<T, P>
where + T: Policy<B, E>, + P: Policy<B, E>,

Create a new Policy that returns [Action::Follow] if either self or other returns +Action::Follow. Read more
Source§

impl<T> Same for T

Source§

type Output = T

Should always be Self
§

impl<T> ScopeObject for T
where + T: Send + Sync + Debug + DeserializeOwned + 'static,

§

type Error = Error

The error type.
§

fn deserialize<R>( + _app: &AppHandle<R>, + raw: Value, +) -> Result<T, <T as ScopeObject>::Error>
where + R: Runtime,

Deserialize the raw scope value.
Source§

impl<T, U> TryFrom<U> for T
where + U: Into<T>,

Source§

type Error = Infallible

The type returned in the event of a conversion error.
Source§

fn try_from(value: U) -> Result<T, <T as TryFrom<U>>::Error>

Performs the conversion.
Source§

impl<T, U> TryInto<U> for T
where + U: TryFrom<T>,

Source§

type Error = <U as TryFrom<T>>::Error

The type returned in the event of a conversion error.
Source§

fn try_into(self) -> Result<U, <U as TryFrom<T>>::Error>

Performs the conversion.
§

impl<V, T> VZip<V> for T
where + V: MultiLane<T>,

§

fn vzip(self) -> V

§

impl<T> WithSubscriber for T

§

fn with_subscriber<S>(self, subscriber: S) -> WithDispatch<Self>
where + S: Into<Dispatch>,

Attaches the provided Subscriber to this type, returning a +[WithDispatch] wrapper. Read more
§

fn with_current_subscriber(self) -> WithDispatch<Self>

Attaches the current default Subscriber to this type, returning a +[WithDispatch] wrapper. Read more
Source§

impl<T> DeserializeOwned for T
where + T: for<'de> Deserialize<'de>,

\ No newline at end of file diff --git a/api/jellytau_lib/commands/sync/fn.sync_cleanup_completed.html b/api/jellytau_lib/commands/sync/fn.sync_cleanup_completed.html new file mode 100644 index 00000000..c3e507c4 --- /dev/null +++ b/api/jellytau_lib/commands/sync/fn.sync_cleanup_completed.html @@ -0,0 +1,5 @@ +sync_cleanup_completed in jellytau_lib::commands::sync - RustSkip to main content

sync_cleanup_completed

Function sync_cleanup_completed 

Source
pub async fn sync_cleanup_completed(
+    db: State<'_, DatabaseWrapper>,
+    days_old: i32,
+) -> Result<i32, String>
Expand description

Delete completed sync operations older than specified days

+
\ No newline at end of file diff --git a/api/jellytau_lib/commands/sync/fn.sync_clear_user.html b/api/jellytau_lib/commands/sync/fn.sync_clear_user.html new file mode 100644 index 00000000..494c57b0 --- /dev/null +++ b/api/jellytau_lib/commands/sync/fn.sync_clear_user.html @@ -0,0 +1,5 @@ +sync_clear_user in jellytau_lib::commands::sync - RustSkip to main content

sync_clear_user

Function sync_clear_user 

Source
pub async fn sync_clear_user(
+    db: State<'_, DatabaseWrapper>,
+    user_id: String,
+) -> Result<(), String>
Expand description

Delete all sync operations for a user (used during logout)

+
\ No newline at end of file diff --git a/api/jellytau_lib/commands/sync/fn.sync_get_pending.html b/api/jellytau_lib/commands/sync/fn.sync_get_pending.html new file mode 100644 index 00000000..36a574de --- /dev/null +++ b/api/jellytau_lib/commands/sync/fn.sync_get_pending.html @@ -0,0 +1,6 @@ +sync_get_pending in jellytau_lib::commands::sync - RustSkip to main content

sync_get_pending

Function sync_get_pending 

Source
pub async fn sync_get_pending(
+    db: State<'_, DatabaseWrapper>,
+    user_id: String,
+    limit: Option<i32>,
+) -> Result<Vec<SyncQueueItem>, String>
Expand description

Get all pending sync operations for a user

+
\ No newline at end of file diff --git a/api/jellytau_lib/commands/sync/fn.sync_get_pending_count.html b/api/jellytau_lib/commands/sync/fn.sync_get_pending_count.html new file mode 100644 index 00000000..9f055ebc --- /dev/null +++ b/api/jellytau_lib/commands/sync/fn.sync_get_pending_count.html @@ -0,0 +1,5 @@ +sync_get_pending_count in jellytau_lib::commands::sync - RustSkip to main content

sync_get_pending_count

Function sync_get_pending_count 

Source
pub async fn sync_get_pending_count(
+    db: State<'_, DatabaseWrapper>,
+    user_id: String,
+) -> Result<i32, String>
Expand description

Get count of pending sync operations for a user

+
\ No newline at end of file diff --git a/api/jellytau_lib/commands/sync/fn.sync_mark_completed.html b/api/jellytau_lib/commands/sync/fn.sync_mark_completed.html new file mode 100644 index 00000000..c82946ea --- /dev/null +++ b/api/jellytau_lib/commands/sync/fn.sync_mark_completed.html @@ -0,0 +1,5 @@ +sync_mark_completed in jellytau_lib::commands::sync - RustSkip to main content

sync_mark_completed

Function sync_mark_completed 

Source
pub async fn sync_mark_completed(
+    db: State<'_, DatabaseWrapper>,
+    id: i64,
+) -> Result<(), String>
Expand description

Mark a sync operation as completed

+
\ No newline at end of file diff --git a/api/jellytau_lib/commands/sync/fn.sync_mark_failed.html b/api/jellytau_lib/commands/sync/fn.sync_mark_failed.html new file mode 100644 index 00000000..914824c7 --- /dev/null +++ b/api/jellytau_lib/commands/sync/fn.sync_mark_failed.html @@ -0,0 +1,6 @@ +sync_mark_failed in jellytau_lib::commands::sync - RustSkip to main content

sync_mark_failed

Function sync_mark_failed 

Source
pub async fn sync_mark_failed(
+    db: State<'_, DatabaseWrapper>,
+    id: i64,
+    error: String,
+) -> Result<(), String>
Expand description

Mark a sync operation as failed with error message

+
\ No newline at end of file diff --git a/api/jellytau_lib/commands/sync/fn.sync_mark_processing.html b/api/jellytau_lib/commands/sync/fn.sync_mark_processing.html new file mode 100644 index 00000000..d03f248d --- /dev/null +++ b/api/jellytau_lib/commands/sync/fn.sync_mark_processing.html @@ -0,0 +1,5 @@ +sync_mark_processing in jellytau_lib::commands::sync - RustSkip to main content

sync_mark_processing

Function sync_mark_processing 

Source
pub async fn sync_mark_processing(
+    db: State<'_, DatabaseWrapper>,
+    id: i64,
+) -> Result<(), String>
Expand description

Mark a sync operation as in progress

+
\ No newline at end of file diff --git a/api/jellytau_lib/commands/sync/fn.sync_queue_mutation.html b/api/jellytau_lib/commands/sync/fn.sync_queue_mutation.html new file mode 100644 index 00000000..e499a5b9 --- /dev/null +++ b/api/jellytau_lib/commands/sync/fn.sync_queue_mutation.html @@ -0,0 +1,8 @@ +sync_queue_mutation in jellytau_lib::commands::sync - RustSkip to main content

sync_queue_mutation

Function sync_queue_mutation 

Source
pub async fn sync_queue_mutation(
+    db: State<'_, DatabaseWrapper>,
+    user_id: String,
+    operation: String,
+    item_id: Option<String>,
+    payload: Option<String>,
+) -> Result<i64, String>
Expand description

Queue a mutation for sync to server

+
\ No newline at end of file diff --git a/api/jellytau_lib/commands/sync/index.html b/api/jellytau_lib/commands/sync/index.html new file mode 100644 index 00000000..5e621e84 --- /dev/null +++ b/api/jellytau_lib/commands/sync/index.html @@ -0,0 +1,7 @@ +jellytau_lib::commands::sync - RustSkip to main content

Module sync

Module sync 

Source
Expand description

Tauri commands for sync queue operations

+

The sync queue stores mutations (favorites, playback progress, etc.) +that need to be synced to the Jellyfin server when connectivity is restored. +Draining it lives in sync_drain (DR-131); this module is the storage and +read side the UI lists from (DR-132). +TRACES: UR-002, UR-017, UR-025 | DR-014, DR-131, DR-132

+

Structs§

SyncQueueItem
Sync queue item returned to frontend

Functions§

sync_cleanup_completed
Delete completed sync operations older than specified days
sync_clear_user
Delete all sync operations for a user (used during logout)
sync_get_pending
Get all pending sync operations for a user
sync_get_pending_count
Get count of pending sync operations for a user
sync_mark_completed
Mark a sync operation as completed
sync_mark_failed
Mark a sync operation as failed with error message
sync_mark_processing
Mark a sync operation as in progress
sync_queue_mutation
Queue a mutation for sync to server
\ No newline at end of file diff --git a/api/jellytau_lib/commands/sync/sidebar-items.js b/api/jellytau_lib/commands/sync/sidebar-items.js new file mode 100644 index 00000000..0dc80852 --- /dev/null +++ b/api/jellytau_lib/commands/sync/sidebar-items.js @@ -0,0 +1 @@ +window.SIDEBAR_ITEMS = {"fn":["sync_cleanup_completed","sync_clear_user","sync_get_pending","sync_get_pending_count","sync_mark_completed","sync_mark_failed","sync_mark_processing","sync_queue_mutation"],"struct":["SyncQueueItem"]}; \ No newline at end of file diff --git a/api/jellytau_lib/commands/sync/struct.SyncQueueItem.html b/api/jellytau_lib/commands/sync/struct.SyncQueueItem.html new file mode 100644 index 00000000..5554c024 --- /dev/null +++ b/api/jellytau_lib/commands/sync/struct.SyncQueueItem.html @@ -0,0 +1,65 @@ +SyncQueueItem in jellytau_lib::commands::sync - RustSkip to main content

SyncQueueItem

Struct SyncQueueItem 

Source
pub struct SyncQueueItem {
+    pub id: i64,
+    pub user_id: String,
+    pub operation: String,
+    pub item_id: Option<String>,
+    pub payload: Option<String>,
+    pub status: String,
+    pub retry_count: i32,
+    pub created_at: Option<String>,
+    pub error_message: Option<String>,
+    pub item_name: Option<String>,
+}
Expand description

Sync queue item returned to frontend

+

Fields§

§id: i64§user_id: String§operation: String§item_id: Option<String>§payload: Option<String>§status: String§retry_count: i32§created_at: Option<String>§error_message: Option<String>§item_name: Option<String>

Cached title of the item the operation is about, when the catalog knows +it. Resolved here rather than by a per-row frontend fetch — the queue +list is otherwise a wall of opaque ids.

+

TRACES: UR-025 | DR-132

+

Trait Implementations§

Source§

impl Clone for SyncQueueItem

Source§

fn clone(&self) -> SyncQueueItem

Returns a duplicate of the value. Read more
1.0.0 (const: unstable) · Source§

fn clone_from(&mut self, source: &Self)

Performs copy-assignment from source. Read more
Source§

impl Debug for SyncQueueItem

Source§

fn fmt(&self, f: &mut Formatter<'_>) -> Result

Formats the value using the given formatter. Read more
Source§

impl<'de> Deserialize<'de> for SyncQueueItem

Source§

fn deserialize<__D>(__deserializer: __D) -> Result<Self, __D::Error>
where + __D: Deserializer<'de>,

Deserialize this value from the given Serde deserializer. Read more
Source§

impl NamedType for SyncQueueItem

Source§

fn sid() -> SpectaID

Source§

fn named_data_type( + type_map: &mut TypeCollection, + generics: &[DataType], +) -> NamedDataType

this is equivalent to [Type::inline] but returns a [NamedDataType] instead.
Source§

fn definition_named_data_type(type_map: &mut TypeCollection) -> NamedDataType

this is equivalent to [Type::definition] but returns a [NamedDataType] instead.
Source§

impl Serialize for SyncQueueItem

Source§

fn serialize<__S>(&self, __serializer: __S) -> Result<__S::Ok, __S::Error>
where + __S: Serializer,

Serialize this value into the given Serde serializer. Read more
Source§

impl Type for SyncQueueItem

Source§

fn inline(type_map: &mut TypeCollection, generics: Generics<'_>) -> DataType

Returns the definition of a type using the provided generics. Read more
Source§

fn reference(type_map: &mut TypeCollection, generics: &[DataType]) -> Reference

Generates a datatype corresponding to a reference to this type, +as determined by its category. Getting a reference to a type implies that +it should belong in the type map (since it has to be referenced from somewhere), +so the output of definition will be put into the type map.
Source§

impl Flatten for SyncQueueItem

Auto Trait Implementations§

Blanket Implementations§

Source§

impl<T> Any for T
where + T: 'static + ?Sized,

Source§

fn type_id(&self) -> TypeId

Gets the TypeId of self. Read more
Source§

impl<T> Borrow<T> for T
where + T: ?Sized,

Source§

fn borrow(&self) -> &T

Immutably borrows from an owned value. Read more
Source§

impl<T> BorrowMut<T> for T
where + T: ?Sized,

Source§

fn borrow_mut(&mut self) -> &mut T

Mutably borrows from an owned value. Read more
Source§

impl<T> CloneToUninit for T
where + T: Clone,

Source§

unsafe fn clone_to_uninit(&self, dest: *mut u8)

🔬This is a nightly-only experimental API. (clone_to_uninit)
Performs copy-assignment from self to dest. Read more
§

impl<'de, D, R> CommandArg<'de, R> for D
where + D: Deserialize<'de>, + R: Runtime,

§

fn from_command(command: CommandItem<'de, R>) -> Result<D, InvokeError>

Derives an instance of Self from the [CommandItem]. Read more
Source§

impl<T> From<T> for T

Source§

fn from(t: T) -> T

Returns the argument unchanged.

+
§

impl<T> FunctionArg for T
where + T: Type,

§

fn to_datatype(type_map: &mut TypeCollection) -> Option<DataType>

Gets the type of an argument as a [DataType]. Read more
§

impl<T> Instrument for T

§

fn instrument(self, span: Span) -> Instrumented<Self>

Instruments this type with the provided [Span], returning an +Instrumented wrapper. Read more
§

fn in_current_span(self) -> Instrumented<Self>

Instruments this type with the current Span, returning an +Instrumented wrapper. Read more
Source§

impl<T, U> Into<U> for T
where + U: From<T>,

Source§

fn into(self) -> U

Calls U::from(self).

+

That is, this conversion is whatever the implementation of +From<T> for U chooses to do.

+
§

impl<T> IpcResponse for T
where + T: Serialize,

§

fn body(self) -> Result<InvokeResponseBody, Error>

Resolve the IPC response body.
§

impl<T> PolicyExt for T
where + T: ?Sized,

§

fn and<P, B, E>(self, other: P) -> And<T, P>
where + T: Policy<B, E>, + P: Policy<B, E>,

Create a new Policy that returns [Action::Follow] only if self and other return +Action::Follow. Read more
§

fn or<P, B, E>(self, other: P) -> Or<T, P>
where + T: Policy<B, E>, + P: Policy<B, E>,

Create a new Policy that returns [Action::Follow] if either self or other returns +Action::Follow. Read more
Source§

impl<T> Same for T

Source§

type Output = T

Should always be Self
§

impl<T> ScopeObject for T
where + T: Send + Sync + Debug + DeserializeOwned + 'static,

§

type Error = Error

The error type.
§

fn deserialize<R>( + _app: &AppHandle<R>, + raw: Value, +) -> Result<T, <T as ScopeObject>::Error>
where + R: Runtime,

Deserialize the raw scope value.
Source§

impl<T> Serialize for T
where + T: Serialize + ?Sized,

Source§

fn erased_serialize(&self, serializer: &mut dyn Serializer) -> Result<(), Error>

Source§

fn do_erased_serialize( + &self, + serializer: &mut dyn Serializer, +) -> Result<(), ErrorImpl>

Source§

impl<T> ToOwned for T
where + T: Clone,

Source§

type Owned = T

The resulting type after obtaining ownership.
Source§

fn to_owned(&self) -> T

Creates owned data from borrowed data, usually by cloning. Read more
Source§

fn clone_into(&self, target: &mut T)

Uses borrowed data to replace owned data, usually by cloning. Read more
Source§

impl<T, U> TryFrom<U> for T
where + U: Into<T>,

Source§

type Error = Infallible

The type returned in the event of a conversion error.
Source§

fn try_from(value: U) -> Result<T, <T as TryFrom<U>>::Error>

Performs the conversion.
Source§

impl<T, U> TryInto<U> for T
where + U: TryFrom<T>,

Source§

type Error = <U as TryFrom<T>>::Error

The type returned in the event of a conversion error.
Source§

fn try_into(self) -> Result<U, <U as TryFrom<T>>::Error>

Performs the conversion.
§

impl<V, T> VZip<V> for T
where + V: MultiLane<T>,

§

fn vzip(self) -> V

§

impl<T> WithSubscriber for T

§

fn with_subscriber<S>(self, subscriber: S) -> WithDispatch<Self>
where + S: Into<Dispatch>,

Attaches the provided Subscriber to this type, returning a +[WithDispatch] wrapper. Read more
§

fn with_current_subscriber(self) -> WithDispatch<Self>

Attaches the current default Subscriber to this type, returning a +[WithDispatch] wrapper. Read more
Source§

impl<T> DeserializeOwned for T
where + T: for<'de> Deserialize<'de>,

§

impl<T> UserEvent for T
where + T: Debug + Clone + Send + 'static,

\ No newline at end of file diff --git a/api/jellytau_lib/commands/sync_drain/constant.MAX_SYNC_ATTEMPTS.html b/api/jellytau_lib/commands/sync_drain/constant.MAX_SYNC_ATTEMPTS.html new file mode 100644 index 00000000..9deda0e1 --- /dev/null +++ b/api/jellytau_lib/commands/sync_drain/constant.MAX_SYNC_ATTEMPTS.html @@ -0,0 +1,5 @@ +MAX_SYNC_ATTEMPTS in jellytau_lib::commands::sync_drain - RustSkip to main content

MAX_SYNC_ATTEMPTS

Constant MAX_SYNC_ATTEMPTS 

Source
pub const MAX_SYNC_ATTEMPTS: i32 = 5;
Expand description

How many times a row may fail before it stops being retried.

+

A row that can never succeed (a deleted item, an operation this build does +not know how to push) must eventually leave the queue, or it re-creates the +bug this module fixes: a count that only ever goes up.

+
\ No newline at end of file diff --git a/api/jellytau_lib/commands/sync_drain/constant.SYNC_QUEUE_CHANGED_EVENT.html b/api/jellytau_lib/commands/sync_drain/constant.SYNC_QUEUE_CHANGED_EVENT.html new file mode 100644 index 00000000..ce4bf59f --- /dev/null +++ b/api/jellytau_lib/commands/sync_drain/constant.SYNC_QUEUE_CHANGED_EVENT.html @@ -0,0 +1,3 @@ +SYNC_QUEUE_CHANGED_EVENT in jellytau_lib::commands::sync_drain - RustSkip to main content

SYNC_QUEUE_CHANGED_EVENT

Constant SYNC_QUEUE_CHANGED_EVENT 

Source
pub const SYNC_QUEUE_CHANGED_EVENT: &str = "sync-queue-changed";
Expand description

Emitted after a drain so open views can re-read the queue instead of waiting +for the frontend’s 10s poll.

+
\ No newline at end of file diff --git a/api/jellytau_lib/commands/sync_drain/enum.QueuedOp.html b/api/jellytau_lib/commands/sync_drain/enum.QueuedOp.html new file mode 100644 index 00000000..6544fc09 --- /dev/null +++ b/api/jellytau_lib/commands/sync_drain/enum.QueuedOp.html @@ -0,0 +1,63 @@ +QueuedOp in jellytau_lib::commands::sync_drain - RustSkip to main content

QueuedOp

Enum QueuedOp 

Source
pub enum QueuedOp {
+    PlaybackStart {
+        item_id: String,
+        position_ticks: i64,
+    },
+    PlaybackStopped {
+        item_id: String,
+        position_ticks: i64,
+    },
+    MarkPlayed {
+        item_id: String,
+    },
+    MarkUnplayed {
+        item_id: String,
+    },
+    Favorite {
+        item_id: String,
+        is_favorite: bool,
+    },
+}
Expand description

A queued mutation, resolved from its stored operation + JSON payload.

+

Variants§

§

PlaybackStart

Fields

§item_id: String
§position_ticks: i64
§

PlaybackStopped

Also where update_progress lands: replaying a mid-playback progress +report long after the fact would tell the server we are still playing. +What the row actually carries is a resume position, and “stopped at N” +is how that reaches Jellyfin’s UserData.

+

Fields

§item_id: String
§position_ticks: i64
§

MarkPlayed

Fields

§item_id: String
§

MarkUnplayed

The inverse, queued by the watched toggle. Pushes as clear_watch_history +(Jellyfin’s mark-unplayed), which also zeroes the resume position — so an +item un-marked offline does not come back carrying a stale position.

+

Fields

§item_id: String
§

Favorite

Legacy rows only — live favourite toggles drain via user_data.pending_sync +(DR-120). Supported so a row written by an older build still lands.

+

Fields

§item_id: String
§is_favorite: bool

Trait Implementations§

Source§

impl Clone for QueuedOp

Source§

fn clone(&self) -> QueuedOp

Returns a duplicate of the value. Read more
1.0.0 (const: unstable) · Source§

fn clone_from(&mut self, source: &Self)

Performs copy-assignment from source. Read more
Source§

impl Debug for QueuedOp

Source§

fn fmt(&self, f: &mut Formatter<'_>) -> Result

Formats the value using the given formatter. Read more
Source§

impl PartialEq for QueuedOp

Source§

fn eq(&self, other: &QueuedOp) -> bool

Tests for self and other values to be equal, and is used by ==.
1.0.0 (const: unstable) · Source§

fn ne(&self, other: &Rhs) -> bool

Tests for !=. The default implementation is almost always sufficient, +and should not be overridden without very good reason.
Source§

impl Eq for QueuedOp

Source§

impl StructuralPartialEq for QueuedOp

Auto Trait Implementations§

Blanket Implementations§

Source§

impl<T> Any for T
where + T: 'static + ?Sized,

Source§

fn type_id(&self) -> TypeId

Gets the TypeId of self. Read more
Source§

impl<T> Borrow<T> for T
where + T: ?Sized,

Source§

fn borrow(&self) -> &T

Immutably borrows from an owned value. Read more
Source§

impl<T> BorrowMut<T> for T
where + T: ?Sized,

Source§

fn borrow_mut(&mut self) -> &mut T

Mutably borrows from an owned value. Read more
Source§

impl<T> CloneToUninit for T
where + T: Clone,

Source§

unsafe fn clone_to_uninit(&self, dest: *mut u8)

🔬This is a nightly-only experimental API. (clone_to_uninit)
Performs copy-assignment from self to dest. Read more
§

impl<Q, K> Equivalent<K> for Q
where + Q: Eq + ?Sized, + K: Borrow<Q> + ?Sized,

§

fn equivalent(&self, key: &K) -> bool

Checks if this value is equivalent to the given key. Read more
§

impl<Q, K> Equivalent<K> for Q
where + Q: Eq + ?Sized, + K: Borrow<Q> + ?Sized,

§

fn equivalent(&self, key: &K) -> bool

Compare self to key and return true if they are equal.
§

impl<Q, K> Equivalent<K> for Q
where + Q: Eq + ?Sized, + K: Borrow<Q> + ?Sized,

§

fn equivalent(&self, key: &K) -> bool

Checks if this value is equivalent to the given key. Read more
Source§

impl<T> From<T> for T

Source§

fn from(t: T) -> T

Returns the argument unchanged.

+
§

impl<T> Instrument for T

§

fn instrument(self, span: Span) -> Instrumented<Self>

Instruments this type with the provided [Span], returning an +Instrumented wrapper. Read more
§

fn in_current_span(self) -> Instrumented<Self>

Instruments this type with the current Span, returning an +Instrumented wrapper. Read more
Source§

impl<T, U> Into<U> for T
where + U: From<T>,

Source§

fn into(self) -> U

Calls U::from(self).

+

That is, this conversion is whatever the implementation of +From<T> for U chooses to do.

+
§

impl<T> PolicyExt for T
where + T: ?Sized,

§

fn and<P, B, E>(self, other: P) -> And<T, P>
where + T: Policy<B, E>, + P: Policy<B, E>,

Create a new Policy that returns [Action::Follow] only if self and other return +Action::Follow. Read more
§

fn or<P, B, E>(self, other: P) -> Or<T, P>
where + T: Policy<B, E>, + P: Policy<B, E>,

Create a new Policy that returns [Action::Follow] if either self or other returns +Action::Follow. Read more
Source§

impl<T> Same for T

Source§

type Output = T

Should always be Self
Source§

impl<T> ToOwned for T
where + T: Clone,

Source§

type Owned = T

The resulting type after obtaining ownership.
Source§

fn to_owned(&self) -> T

Creates owned data from borrowed data, usually by cloning. Read more
Source§

fn clone_into(&self, target: &mut T)

Uses borrowed data to replace owned data, usually by cloning. Read more
Source§

impl<T, U> TryFrom<U> for T
where + U: Into<T>,

Source§

type Error = Infallible

The type returned in the event of a conversion error.
Source§

fn try_from(value: U) -> Result<T, <T as TryFrom<U>>::Error>

Performs the conversion.
Source§

impl<T, U> TryInto<U> for T
where + U: TryFrom<T>,

Source§

type Error = <U as TryFrom<T>>::Error

The type returned in the event of a conversion error.
Source§

fn try_into(self) -> Result<U, <U as TryFrom<T>>::Error>

Performs the conversion.
§

impl<V, T> VZip<V> for T
where + V: MultiLane<T>,

§

fn vzip(self) -> V

§

impl<T> WithSubscriber for T

§

fn with_subscriber<S>(self, subscriber: S) -> WithDispatch<Self>
where + S: Into<Dispatch>,

Attaches the provided Subscriber to this type, returning a +[WithDispatch] wrapper. Read more
§

fn with_current_subscriber(self) -> WithDispatch<Self>

Attaches the current default Subscriber to this type, returning a +[WithDispatch] wrapper. Read more
§

impl<T> UserEvent for T
where + T: Debug + Clone + Send + 'static,

\ No newline at end of file diff --git a/api/jellytau_lib/commands/sync_drain/fn.drain_sync_queue.html b/api/jellytau_lib/commands/sync_drain/fn.drain_sync_queue.html new file mode 100644 index 00000000..a35bd723 --- /dev/null +++ b/api/jellytau_lib/commands/sync_drain/fn.drain_sync_queue.html @@ -0,0 +1,12 @@ +drain_sync_queue in jellytau_lib::commands::sync_drain - RustSkip to main content

drain_sync_queue

Function drain_sync_queue 

Source
pub async fn drain_sync_queue(
+    db: &Arc<RusqliteService>,
+    sink: &dyn SyncSink,
+    user_id: &str,
+) -> Result<DrainReport, String>
Expand description

Push every queued mutation for this user, oldest first.

+

Chronological order matters: a stale start replayed after a later stop would +otherwise move the server’s resume position backwards.

+

A row that fails keeps its place and is retried on the next reconnect, until +MAX_SYNC_ATTEMPTS — after which it is abandoned, because a row nothing can +ever push is exactly what turned this queue into a counter that only grew.

+

TRACES: UR-025, UR-002 | DR-131 | UT-122

+
\ No newline at end of file diff --git a/api/jellytau_lib/commands/sync_drain/fn.enqueue_playback_stopped.html b/api/jellytau_lib/commands/sync_drain/fn.enqueue_playback_stopped.html new file mode 100644 index 00000000..9ec0f36a --- /dev/null +++ b/api/jellytau_lib/commands/sync_drain/fn.enqueue_playback_stopped.html @@ -0,0 +1,18 @@ +enqueue_playback_stopped in jellytau_lib::commands::sync_drain - RustSkip to main content

enqueue_playback_stopped

Function enqueue_playback_stopped 

Source
pub async fn enqueue_playback_stopped(
+    db: &Arc<RusqliteService>,
+    user_id: &str,
+    item_id: &str,
+    position_ticks: i64,
+) -> Result<(), String>
Expand description

Queue a watch position that could not be reported to the server.

+

The stop-report path pushed straight to the server and, on failure, logged +and dropped the position — so closing a video while the server was +unreachable lost the resume point outright, even though the queue and its +drain (DR-131) were built and running. This is the missing producer.

+

The pending row for an item is replaced rather than appended to. Progress +is reported every 10s, so a server that stays down would otherwise grow one +row per tick, all of them superseded by the newest — the unbounded queue +DR-131 exists to prevent. Only pending/failed rows are superseded: +an abandoned row has been given up on and must not be revived, and a +completed one is history.

+

TRACES: UR-025 | DR-154 | UT-151

+
\ No newline at end of file diff --git a/api/jellytau_lib/commands/sync_drain/fn.mark_completed.html b/api/jellytau_lib/commands/sync_drain/fn.mark_completed.html new file mode 100644 index 00000000..a1fa56e5 --- /dev/null +++ b/api/jellytau_lib/commands/sync_drain/fn.mark_completed.html @@ -0,0 +1,4 @@ +mark_completed in jellytau_lib::commands::sync_drain - RustSkip to main content

mark_completed

Function mark_completed 

Source
async fn mark_completed(
+    db: &Arc<RusqliteService>,
+    id: i64,
+) -> Result<(), String>
\ No newline at end of file diff --git a/api/jellytau_lib/commands/sync_drain/fn.mark_deferred.html b/api/jellytau_lib/commands/sync_drain/fn.mark_deferred.html new file mode 100644 index 00000000..2bd66692 --- /dev/null +++ b/api/jellytau_lib/commands/sync_drain/fn.mark_deferred.html @@ -0,0 +1,7 @@ +mark_deferred in jellytau_lib::commands::sync_drain - RustSkip to main content

mark_deferred

Function mark_deferred 

Source
async fn mark_deferred(
+    db: &Arc<RusqliteService>,
+    id: i64,
+    reason: &str,
+) -> Result<(), String>
Expand description

Put a row back in the queue untouched apart from its error note — used when +the server was simply unreachable.

+
\ No newline at end of file diff --git a/api/jellytau_lib/commands/sync_drain/fn.mark_failed.html b/api/jellytau_lib/commands/sync_drain/fn.mark_failed.html new file mode 100644 index 00000000..0c056916 --- /dev/null +++ b/api/jellytau_lib/commands/sync_drain/fn.mark_failed.html @@ -0,0 +1,7 @@ +mark_failed in jellytau_lib::commands::sync_drain - RustSkip to main content

mark_failed

Function mark_failed 

Source
async fn mark_failed(
+    db: &Arc<RusqliteService>,
+    id: i64,
+    attempts: i32,
+    give_up: bool,
+    reason: &str,
+) -> Result<(), String>
\ No newline at end of file diff --git a/api/jellytau_lib/commands/sync_drain/fn.parse_queued_op.html b/api/jellytau_lib/commands/sync_drain/fn.parse_queued_op.html new file mode 100644 index 00000000..0bd739b5 --- /dev/null +++ b/api/jellytau_lib/commands/sync_drain/fn.parse_queued_op.html @@ -0,0 +1,11 @@ +parse_queued_op in jellytau_lib::commands::sync_drain - RustSkip to main content

parse_queued_op

Function parse_queued_op 

Source
pub fn parse_queued_op(
+    operation: &str,
+    item_id: Option<&str>,
+    payload: Option<&str>,
+) -> Result<QueuedOp, String>
Expand description

Turn a stored row into something pushable.

+

Payload keys differ by producer: the Rust reporter writes position_ticks, +while syncService.queuePlaybackProgress writes camelCase positionMs. +Both are accepted rather than normalised at the producer, because rows +already in users’ databases were written by both.

+

TRACES: UR-025 | DR-131 | UT-122

+
\ No newline at end of file diff --git a/api/jellytau_lib/commands/sync_drain/fn.read_queue.html b/api/jellytau_lib/commands/sync_drain/fn.read_queue.html new file mode 100644 index 00000000..12fec5fb --- /dev/null +++ b/api/jellytau_lib/commands/sync_drain/fn.read_queue.html @@ -0,0 +1,4 @@ +read_queue in jellytau_lib::commands::sync_drain - RustSkip to main content

read_queue

Function read_queue 

Source
async fn read_queue(
+    db: &Arc<RusqliteService>,
+    user_id: &str,
+) -> Result<Vec<QueuedRow>, String>
\ No newline at end of file diff --git a/api/jellytau_lib/commands/sync_drain/fn.remaining_count.html b/api/jellytau_lib/commands/sync_drain/fn.remaining_count.html new file mode 100644 index 00000000..c3dbddfd --- /dev/null +++ b/api/jellytau_lib/commands/sync_drain/fn.remaining_count.html @@ -0,0 +1,4 @@ +remaining_count in jellytau_lib::commands::sync_drain - RustSkip to main content

remaining_count

Function remaining_count 

Source
async fn remaining_count(
+    db: &Arc<RusqliteService>,
+    user_id: &str,
+) -> Result<i32, String>
\ No newline at end of file diff --git a/api/jellytau_lib/commands/sync_drain/fn.run_drain.html b/api/jellytau_lib/commands/sync_drain/fn.run_drain.html new file mode 100644 index 00000000..2c57ae90 --- /dev/null +++ b/api/jellytau_lib/commands/sync_drain/fn.run_drain.html @@ -0,0 +1,3 @@ +run_drain in jellytau_lib::commands::sync_drain - RustSkip to main content

run_drain

Function run_drain 

Source
pub async fn run_drain(app: &AppHandle) -> Result<DrainReport, String>
Expand description

Resolve app state and drain. Shared by the reconnect hook and the manual +“Sync now” command.

+
\ No newline at end of file diff --git a/api/jellytau_lib/commands/sync_drain/fn.spawn_sync_queue_drain.html b/api/jellytau_lib/commands/sync_drain/fn.spawn_sync_queue_drain.html new file mode 100644 index 00000000..e2326de9 --- /dev/null +++ b/api/jellytau_lib/commands/sync_drain/fn.spawn_sync_queue_drain.html @@ -0,0 +1,3 @@ +spawn_sync_queue_drain in jellytau_lib::commands::sync_drain - RustSkip to main content

spawn_sync_queue_drain

Function spawn_sync_queue_drain 

Source
pub fn spawn_sync_queue_drain(app: AppHandle)
Expand description

Drain on every offline→online transition.

+

TRACES: UR-025 | DR-131

+
\ No newline at end of file diff --git a/api/jellytau_lib/commands/sync_drain/fn.sync_process_pending.html b/api/jellytau_lib/commands/sync_drain/fn.sync_process_pending.html new file mode 100644 index 00000000..13eefd14 --- /dev/null +++ b/api/jellytau_lib/commands/sync_drain/fn.sync_process_pending.html @@ -0,0 +1,3 @@ +sync_process_pending in jellytau_lib::commands::sync_drain - RustSkip to main content

sync_process_pending

Function sync_process_pending 

Source
pub async fn sync_process_pending(app: AppHandle) -> Result<DrainReport, String>
Expand description

Push the queue now, on the user’s say-so, instead of waiting for a reconnect.

+

TRACES: UR-025 | DR-132

+
\ No newline at end of file diff --git a/api/jellytau_lib/commands/sync_drain/index.html b/api/jellytau_lib/commands/sync_drain/index.html new file mode 100644 index 00000000..bebbad01 --- /dev/null +++ b/api/jellytau_lib/commands/sync_drain/index.html @@ -0,0 +1,16 @@ +jellytau_lib::commands::sync_drain - RustSkip to main content

Module sync_drain

Module sync_drain 

Source
Expand description

Draining the offline mutation queue (sync_queue) to the server.

+

sync_queue had producers but no consumer: PlaybackReporter::queue_for_sync +inserts a row whenever a start/stop/mark-played cannot reach the server, and +nothing ever pushed one. sync_mark_processing/_completed/_failed were +registered commands with no callers, so the queue only grew — the offline +banner’s “N pending” climbed forever and the watch positions those rows stood +for never reached Jellyfin.

+

Same shape as the favourites drain (DR-120), and for the same reason: a drain +started by a component dies with it, so it lives in Rust and hangs off the +connectivity:reconnected transition the ConnectivityMonitor already emits.

+

TRACES: UR-025, UR-002 | DR-131 | UT-122

+

Structs§

DrainReport
What a drain did, for logging and for the frontend’s “Sync now” button.
PushFailure 🔒
Why a push failed, and whether the row should be charged an attempt for it.
QueuedRow 🔒

Enums§

QueuedOp
A queued mutation, resolved from its stored operation + JSON payload.

Constants§

MAX_SYNC_ATTEMPTS
How many times a row may fail before it stops being retried.
SYNC_QUEUE_CHANGED_EVENT
Emitted after a drain so open views can re-read the queue instead of waiting +for the frontend’s 10s poll.

Traits§

SyncSink
The slice of the repository the drain needs — narrow so it can be doubled in +a test without forty unimplemented!() methods.

Functions§

drain_sync_queue
Push every queued mutation for this user, oldest first.
enqueue_playback_stopped
Queue a watch position that could not be reported to the server.
mark_completed 🔒
mark_deferred 🔒
Put a row back in the queue untouched apart from its error note — used when +the server was simply unreachable.
mark_failed 🔒
parse_queued_op
Turn a stored row into something pushable.
read_queue 🔒
remaining_count 🔒
run_drain
Resolve app state and drain. Shared by the reconnect hook and the manual +“Sync now” command.
spawn_sync_queue_drain
Drain on every offline→online transition.
sync_process_pending
Push the queue now, on the user’s say-so, instead of waiting for a reconnect.
\ No newline at end of file diff --git a/api/jellytau_lib/commands/sync_drain/sidebar-items.js b/api/jellytau_lib/commands/sync_drain/sidebar-items.js new file mode 100644 index 00000000..8154b5ff --- /dev/null +++ b/api/jellytau_lib/commands/sync_drain/sidebar-items.js @@ -0,0 +1 @@ +window.SIDEBAR_ITEMS = {"constant":["MAX_SYNC_ATTEMPTS","SYNC_QUEUE_CHANGED_EVENT"],"enum":["QueuedOp"],"fn":["drain_sync_queue","enqueue_playback_stopped","mark_completed","mark_deferred","mark_failed","parse_queued_op","read_queue","remaining_count","run_drain","spawn_sync_queue_drain","sync_process_pending"],"struct":["DrainReport","PushFailure","QueuedRow"],"trait":["SyncSink"]}; \ No newline at end of file diff --git a/api/jellytau_lib/commands/sync_drain/struct.DrainReport.html b/api/jellytau_lib/commands/sync_drain/struct.DrainReport.html new file mode 100644 index 00000000..b7c23c38 --- /dev/null +++ b/api/jellytau_lib/commands/sync_drain/struct.DrainReport.html @@ -0,0 +1,67 @@ +DrainReport in jellytau_lib::commands::sync_drain - RustSkip to main content

DrainReport

Struct DrainReport 

Source
pub struct DrainReport {
+    pub pushed: i32,
+    pub deferred: i32,
+    pub abandoned: i32,
+    pub remaining: i32,
+}
Expand description

What a drain did, for logging and for the frontend’s “Sync now” button.

+

Fields§

§pushed: i32

Rows that reached the server and are now completed.

+
§deferred: i32

Rows that failed and will be retried on the next reconnect.

+
§abandoned: i32

Rows that exhausted MAX_SYNC_ATTEMPTS and were given up on.

+
§remaining: i32

Rows still waiting afterwards (what the badge counts).

+

Trait Implementations§

Source§

impl Clone for DrainReport

Source§

fn clone(&self) -> DrainReport

Returns a duplicate of the value. Read more
1.0.0 (const: unstable) · Source§

fn clone_from(&mut self, source: &Self)

Performs copy-assignment from source. Read more
Source§

impl Debug for DrainReport

Source§

fn fmt(&self, f: &mut Formatter<'_>) -> Result

Formats the value using the given formatter. Read more
Source§

impl Default for DrainReport

Source§

fn default() -> DrainReport

Returns the “default value” for a type. Read more
Source§

impl<'de> Deserialize<'de> for DrainReport

Source§

fn deserialize<__D>(__deserializer: __D) -> Result<Self, __D::Error>
where + __D: Deserializer<'de>,

Deserialize this value from the given Serde deserializer. Read more
Source§

impl NamedType for DrainReport

Source§

fn sid() -> SpectaID

Source§

fn named_data_type( + type_map: &mut TypeCollection, + generics: &[DataType], +) -> NamedDataType

this is equivalent to [Type::inline] but returns a [NamedDataType] instead.
Source§

fn definition_named_data_type(type_map: &mut TypeCollection) -> NamedDataType

this is equivalent to [Type::definition] but returns a [NamedDataType] instead.
Source§

impl PartialEq for DrainReport

Source§

fn eq(&self, other: &DrainReport) -> bool

Tests for self and other values to be equal, and is used by ==.
1.0.0 (const: unstable) · Source§

fn ne(&self, other: &Rhs) -> bool

Tests for !=. The default implementation is almost always sufficient, +and should not be overridden without very good reason.
Source§

impl Serialize for DrainReport

Source§

fn serialize<__S>(&self, __serializer: __S) -> Result<__S::Ok, __S::Error>
where + __S: Serializer,

Serialize this value into the given Serde serializer. Read more
Source§

impl Type for DrainReport

Source§

fn inline(type_map: &mut TypeCollection, generics: Generics<'_>) -> DataType

Returns the definition of a type using the provided generics. Read more
Source§

fn reference(type_map: &mut TypeCollection, generics: &[DataType]) -> Reference

Generates a datatype corresponding to a reference to this type, +as determined by its category. Getting a reference to a type implies that +it should belong in the type map (since it has to be referenced from somewhere), +so the output of definition will be put into the type map.
Source§

impl Eq for DrainReport

Source§

impl Flatten for DrainReport

Source§

impl StructuralPartialEq for DrainReport

Auto Trait Implementations§

Blanket Implementations§

Source§

impl<T> Any for T
where + T: 'static + ?Sized,

Source§

fn type_id(&self) -> TypeId

Gets the TypeId of self. Read more
Source§

impl<T> Borrow<T> for T
where + T: ?Sized,

Source§

fn borrow(&self) -> &T

Immutably borrows from an owned value. Read more
Source§

impl<T> BorrowMut<T> for T
where + T: ?Sized,

Source§

fn borrow_mut(&mut self) -> &mut T

Mutably borrows from an owned value. Read more
Source§

impl<T> CloneToUninit for T
where + T: Clone,

Source§

unsafe fn clone_to_uninit(&self, dest: *mut u8)

🔬This is a nightly-only experimental API. (clone_to_uninit)
Performs copy-assignment from self to dest. Read more
§

impl<'de, D, R> CommandArg<'de, R> for D
where + D: Deserialize<'de>, + R: Runtime,

§

fn from_command(command: CommandItem<'de, R>) -> Result<D, InvokeError>

Derives an instance of Self from the [CommandItem]. Read more
§

impl<Q, K> Equivalent<K> for Q
where + Q: Eq + ?Sized, + K: Borrow<Q> + ?Sized,

§

fn equivalent(&self, key: &K) -> bool

Checks if this value is equivalent to the given key. Read more
§

impl<Q, K> Equivalent<K> for Q
where + Q: Eq + ?Sized, + K: Borrow<Q> + ?Sized,

§

fn equivalent(&self, key: &K) -> bool

Compare self to key and return true if they are equal.
§

impl<Q, K> Equivalent<K> for Q
where + Q: Eq + ?Sized, + K: Borrow<Q> + ?Sized,

§

fn equivalent(&self, key: &K) -> bool

Checks if this value is equivalent to the given key. Read more
Source§

impl<T> From<T> for T

Source§

fn from(t: T) -> T

Returns the argument unchanged.

+
§

impl<T> FunctionArg for T
where + T: Type,

§

fn to_datatype(type_map: &mut TypeCollection) -> Option<DataType>

Gets the type of an argument as a [DataType]. Read more
§

impl<T> Instrument for T

§

fn instrument(self, span: Span) -> Instrumented<Self>

Instruments this type with the provided [Span], returning an +Instrumented wrapper. Read more
§

fn in_current_span(self) -> Instrumented<Self>

Instruments this type with the current Span, returning an +Instrumented wrapper. Read more
Source§

impl<T, U> Into<U> for T
where + U: From<T>,

Source§

fn into(self) -> U

Calls U::from(self).

+

That is, this conversion is whatever the implementation of +From<T> for U chooses to do.

+
§

impl<T> IpcResponse for T
where + T: Serialize,

§

fn body(self) -> Result<InvokeResponseBody, Error>

Resolve the IPC response body.
§

impl<T> NoneValue for T
where + T: Default,

§

type NoneType = T

§

fn null_value() -> T

The none-equivalent value.
§

impl<T> PolicyExt for T
where + T: ?Sized,

§

fn and<P, B, E>(self, other: P) -> And<T, P>
where + T: Policy<B, E>, + P: Policy<B, E>,

Create a new Policy that returns [Action::Follow] only if self and other return +Action::Follow. Read more
§

fn or<P, B, E>(self, other: P) -> Or<T, P>
where + T: Policy<B, E>, + P: Policy<B, E>,

Create a new Policy that returns [Action::Follow] if either self or other returns +Action::Follow. Read more
Source§

impl<T> Same for T

Source§

type Output = T

Should always be Self
§

impl<T> ScopeObject for T
where + T: Send + Sync + Debug + DeserializeOwned + 'static,

§

type Error = Error

The error type.
§

fn deserialize<R>( + _app: &AppHandle<R>, + raw: Value, +) -> Result<T, <T as ScopeObject>::Error>
where + R: Runtime,

Deserialize the raw scope value.
Source§

impl<T> Serialize for T
where + T: Serialize + ?Sized,

Source§

fn erased_serialize(&self, serializer: &mut dyn Serializer) -> Result<(), Error>

Source§

fn do_erased_serialize( + &self, + serializer: &mut dyn Serializer, +) -> Result<(), ErrorImpl>

Source§

impl<T> ToOwned for T
where + T: Clone,

Source§

type Owned = T

The resulting type after obtaining ownership.
Source§

fn to_owned(&self) -> T

Creates owned data from borrowed data, usually by cloning. Read more
Source§

fn clone_into(&self, target: &mut T)

Uses borrowed data to replace owned data, usually by cloning. Read more
Source§

impl<T, U> TryFrom<U> for T
where + U: Into<T>,

Source§

type Error = Infallible

The type returned in the event of a conversion error.
Source§

fn try_from(value: U) -> Result<T, <T as TryFrom<U>>::Error>

Performs the conversion.
Source§

impl<T, U> TryInto<U> for T
where + U: TryFrom<T>,

Source§

type Error = <U as TryFrom<T>>::Error

The type returned in the event of a conversion error.
Source§

fn try_into(self) -> Result<U, <U as TryFrom<T>>::Error>

Performs the conversion.
§

impl<V, T> VZip<V> for T
where + V: MultiLane<T>,

§

fn vzip(self) -> V

§

impl<T> WithSubscriber for T

§

fn with_subscriber<S>(self, subscriber: S) -> WithDispatch<Self>
where + S: Into<Dispatch>,

Attaches the provided Subscriber to this type, returning a +[WithDispatch] wrapper. Read more
§

fn with_current_subscriber(self) -> WithDispatch<Self>

Attaches the current default Subscriber to this type, returning a +[WithDispatch] wrapper. Read more
Source§

impl<T> DeserializeOwned for T
where + T: for<'de> Deserialize<'de>,

§

impl<T> UserEvent for T
where + T: Debug + Clone + Send + 'static,

\ No newline at end of file diff --git a/api/jellytau_lib/commands/sync_drain/struct.PushFailure.html b/api/jellytau_lib/commands/sync_drain/struct.PushFailure.html new file mode 100644 index 00000000..3081c2f5 --- /dev/null +++ b/api/jellytau_lib/commands/sync_drain/struct.PushFailure.html @@ -0,0 +1,29 @@ +PushFailure in jellytau_lib::commands::sync_drain - RustSkip to main content

PushFailure

Struct PushFailure 

Source
struct PushFailure {
+    reason: String,
+    transient: bool,
+}
Expand description

Why a push failed, and whether the row should be charged an attempt for it.

+

Fields§

§reason: String§transient: bool

The server could not be reached at all — retry later, free of charge.

+

Auto Trait Implementations§

Blanket Implementations§

Source§

impl<T> Any for T
where + T: 'static + ?Sized,

Source§

fn type_id(&self) -> TypeId

Gets the TypeId of self. Read more
Source§

impl<T> Borrow<T> for T
where + T: ?Sized,

Source§

fn borrow(&self) -> &T

Immutably borrows from an owned value. Read more
Source§

impl<T> BorrowMut<T> for T
where + T: ?Sized,

Source§

fn borrow_mut(&mut self) -> &mut T

Mutably borrows from an owned value. Read more
Source§

impl<T> From<T> for T

Source§

fn from(t: T) -> T

Returns the argument unchanged.

+
§

impl<T> Instrument for T

§

fn instrument(self, span: Span) -> Instrumented<Self>

Instruments this type with the provided [Span], returning an +Instrumented wrapper. Read more
§

fn in_current_span(self) -> Instrumented<Self>

Instruments this type with the current Span, returning an +Instrumented wrapper. Read more
Source§

impl<T, U> Into<U> for T
where + U: From<T>,

Source§

fn into(self) -> U

Calls U::from(self).

+

That is, this conversion is whatever the implementation of +From<T> for U chooses to do.

+
§

impl<T> PolicyExt for T
where + T: ?Sized,

§

fn and<P, B, E>(self, other: P) -> And<T, P>
where + T: Policy<B, E>, + P: Policy<B, E>,

Create a new Policy that returns [Action::Follow] only if self and other return +Action::Follow. Read more
§

fn or<P, B, E>(self, other: P) -> Or<T, P>
where + T: Policy<B, E>, + P: Policy<B, E>,

Create a new Policy that returns [Action::Follow] if either self or other returns +Action::Follow. Read more
Source§

impl<T> Same for T

Source§

type Output = T

Should always be Self
Source§

impl<T, U> TryFrom<U> for T
where + U: Into<T>,

Source§

type Error = Infallible

The type returned in the event of a conversion error.
Source§

fn try_from(value: U) -> Result<T, <T as TryFrom<U>>::Error>

Performs the conversion.
Source§

impl<T, U> TryInto<U> for T
where + U: TryFrom<T>,

Source§

type Error = <U as TryFrom<T>>::Error

The type returned in the event of a conversion error.
Source§

fn try_into(self) -> Result<U, <U as TryFrom<T>>::Error>

Performs the conversion.
§

impl<V, T> VZip<V> for T
where + V: MultiLane<T>,

§

fn vzip(self) -> V

§

impl<T> WithSubscriber for T

§

fn with_subscriber<S>(self, subscriber: S) -> WithDispatch<Self>
where + S: Into<Dispatch>,

Attaches the provided Subscriber to this type, returning a +[WithDispatch] wrapper. Read more
§

fn with_current_subscriber(self) -> WithDispatch<Self>

Attaches the current default Subscriber to this type, returning a +[WithDispatch] wrapper. Read more
\ No newline at end of file diff --git a/api/jellytau_lib/commands/sync_drain/struct.QueuedRow.html b/api/jellytau_lib/commands/sync_drain/struct.QueuedRow.html new file mode 100644 index 00000000..32b3d8a4 --- /dev/null +++ b/api/jellytau_lib/commands/sync_drain/struct.QueuedRow.html @@ -0,0 +1,33 @@ +QueuedRow in jellytau_lib::commands::sync_drain - RustSkip to main content

QueuedRow

Struct QueuedRow 

Source
struct QueuedRow {
+    id: i64,
+    operation: String,
+    item_id: Option<String>,
+    payload: Option<String>,
+    retry_count: i32,
+}

Fields§

§id: i64§operation: String§item_id: Option<String>§payload: Option<String>§retry_count: i32

Trait Implementations§

Source§

impl Clone for QueuedRow

Source§

fn clone(&self) -> QueuedRow

Returns a duplicate of the value. Read more
1.0.0 (const: unstable) · Source§

fn clone_from(&mut self, source: &Self)

Performs copy-assignment from source. Read more
Source§

impl Debug for QueuedRow

Source§

fn fmt(&self, f: &mut Formatter<'_>) -> Result

Formats the value using the given formatter. Read more

Auto Trait Implementations§

Blanket Implementations§

Source§

impl<T> Any for T
where + T: 'static + ?Sized,

Source§

fn type_id(&self) -> TypeId

Gets the TypeId of self. Read more
Source§

impl<T> Borrow<T> for T
where + T: ?Sized,

Source§

fn borrow(&self) -> &T

Immutably borrows from an owned value. Read more
Source§

impl<T> BorrowMut<T> for T
where + T: ?Sized,

Source§

fn borrow_mut(&mut self) -> &mut T

Mutably borrows from an owned value. Read more
Source§

impl<T> CloneToUninit for T
where + T: Clone,

Source§

unsafe fn clone_to_uninit(&self, dest: *mut u8)

🔬This is a nightly-only experimental API. (clone_to_uninit)
Performs copy-assignment from self to dest. Read more
Source§

impl<T> From<T> for T

Source§

fn from(t: T) -> T

Returns the argument unchanged.

+
§

impl<T> Instrument for T

§

fn instrument(self, span: Span) -> Instrumented<Self>

Instruments this type with the provided [Span], returning an +Instrumented wrapper. Read more
§

fn in_current_span(self) -> Instrumented<Self>

Instruments this type with the current Span, returning an +Instrumented wrapper. Read more
Source§

impl<T, U> Into<U> for T
where + U: From<T>,

Source§

fn into(self) -> U

Calls U::from(self).

+

That is, this conversion is whatever the implementation of +From<T> for U chooses to do.

+
§

impl<T> PolicyExt for T
where + T: ?Sized,

§

fn and<P, B, E>(self, other: P) -> And<T, P>
where + T: Policy<B, E>, + P: Policy<B, E>,

Create a new Policy that returns [Action::Follow] only if self and other return +Action::Follow. Read more
§

fn or<P, B, E>(self, other: P) -> Or<T, P>
where + T: Policy<B, E>, + P: Policy<B, E>,

Create a new Policy that returns [Action::Follow] if either self or other returns +Action::Follow. Read more
Source§

impl<T> Same for T

Source§

type Output = T

Should always be Self
Source§

impl<T> ToOwned for T
where + T: Clone,

Source§

type Owned = T

The resulting type after obtaining ownership.
Source§

fn to_owned(&self) -> T

Creates owned data from borrowed data, usually by cloning. Read more
Source§

fn clone_into(&self, target: &mut T)

Uses borrowed data to replace owned data, usually by cloning. Read more
Source§

impl<T, U> TryFrom<U> for T
where + U: Into<T>,

Source§

type Error = Infallible

The type returned in the event of a conversion error.
Source§

fn try_from(value: U) -> Result<T, <T as TryFrom<U>>::Error>

Performs the conversion.
Source§

impl<T, U> TryInto<U> for T
where + U: TryFrom<T>,

Source§

type Error = <U as TryFrom<T>>::Error

The type returned in the event of a conversion error.
Source§

fn try_into(self) -> Result<U, <U as TryFrom<T>>::Error>

Performs the conversion.
§

impl<V, T> VZip<V> for T
where + V: MultiLane<T>,

§

fn vzip(self) -> V

§

impl<T> WithSubscriber for T

§

fn with_subscriber<S>(self, subscriber: S) -> WithDispatch<Self>
where + S: Into<Dispatch>,

Attaches the provided Subscriber to this type, returning a +[WithDispatch] wrapper. Read more
§

fn with_current_subscriber(self) -> WithDispatch<Self>

Attaches the current default Subscriber to this type, returning a +[WithDispatch] wrapper. Read more
§

impl<T> UserEvent for T
where + T: Debug + Clone + Send + 'static,

\ No newline at end of file diff --git a/api/jellytau_lib/commands/sync_drain/trait.SyncSink.html b/api/jellytau_lib/commands/sync_drain/trait.SyncSink.html new file mode 100644 index 00000000..2ec71e6f --- /dev/null +++ b/api/jellytau_lib/commands/sync_drain/trait.SyncSink.html @@ -0,0 +1,18 @@ +SyncSink in jellytau_lib::commands::sync_drain - RustSkip to main content

SyncSink

Trait SyncSink 

Source
pub trait SyncSink: Send + Sync {
+    // Required method
+    fn push<'life0, 'life1, 'async_trait>(
+        &'life0 self,
+        op: &'life1 QueuedOp,
+    ) -> Pin<Box<dyn Future<Output = Result<(), RepoError>> + Send + 'async_trait>>
+       where Self: 'async_trait,
+             'life0: 'async_trait,
+             'life1: 'async_trait;
+}
Expand description

The slice of the repository the drain needs — narrow so it can be doubled in +a test without forty unimplemented!() methods.

+

Required Methods§

Source

fn push<'life0, 'life1, 'async_trait>( + &'life0 self, + op: &'life1 QueuedOp, +) -> Pin<Box<dyn Future<Output = Result<(), RepoError>> + Send + 'async_trait>>
where + Self: 'async_trait, + 'life0: 'async_trait, + 'life1: 'async_trait,

Implementors§

\ No newline at end of file diff --git a/api/jellytau_lib/connectivity/constant.OFFLINE_CONFIRM_WINDOW.html b/api/jellytau_lib/connectivity/constant.OFFLINE_CONFIRM_WINDOW.html new file mode 100644 index 00000000..ba9dbbd2 --- /dev/null +++ b/api/jellytau_lib/connectivity/constant.OFFLINE_CONFIRM_WINDOW.html @@ -0,0 +1 @@ +OFFLINE_CONFIRM_WINDOW in jellytau_lib::connectivity - RustSkip to main content

OFFLINE_CONFIRM_WINDOW

Constant OFFLINE_CONFIRM_WINDOW 

Source
const OFFLINE_CONFIRM_WINDOW: Duration;
\ No newline at end of file diff --git a/api/jellytau_lib/connectivity/constant.RETRY_CHECK_INTERVAL_MS.html b/api/jellytau_lib/connectivity/constant.RETRY_CHECK_INTERVAL_MS.html new file mode 100644 index 00000000..49bf6526 --- /dev/null +++ b/api/jellytau_lib/connectivity/constant.RETRY_CHECK_INTERVAL_MS.html @@ -0,0 +1 @@ +RETRY_CHECK_INTERVAL_MS in jellytau_lib::connectivity - RustSkip to main content

RETRY_CHECK_INTERVAL_MS

Constant RETRY_CHECK_INTERVAL_MS 

Source
const RETRY_CHECK_INTERVAL_MS: u64 = 5000;
\ No newline at end of file diff --git a/api/jellytau_lib/connectivity/index.html b/api/jellytau_lib/connectivity/index.html new file mode 100644 index 00000000..7e2b1347 --- /dev/null +++ b/api/jellytau_lib/connectivity/index.html @@ -0,0 +1 @@ +jellytau_lib::connectivity - RustSkip to main content

Module connectivity

Module connectivity 

Source

Structs§

ConnectivityChangeEvent 🔒
Connectivity change event emitted to frontend
ConnectivityMonitor
Connectivity monitor for tracking server reachability.
ConnectivityReporter
Shared reachability state and transition logic.
ConnectivityStatus
Connectivity status

Constants§

OFFLINE_CONFIRM_WINDOW 🔒
RETRY_CHECK_INTERVAL_MS 🔒
\ No newline at end of file diff --git a/api/jellytau_lib/connectivity/sidebar-items.js b/api/jellytau_lib/connectivity/sidebar-items.js new file mode 100644 index 00000000..4a707704 --- /dev/null +++ b/api/jellytau_lib/connectivity/sidebar-items.js @@ -0,0 +1 @@ +window.SIDEBAR_ITEMS = {"constant":["OFFLINE_CONFIRM_WINDOW","RETRY_CHECK_INTERVAL_MS"],"struct":["ConnectivityChangeEvent","ConnectivityMonitor","ConnectivityReporter","ConnectivityStatus"]}; \ No newline at end of file diff --git a/api/jellytau_lib/connectivity/struct.ConnectivityChangeEvent.html b/api/jellytau_lib/connectivity/struct.ConnectivityChangeEvent.html new file mode 100644 index 00000000..bb76f2ce --- /dev/null +++ b/api/jellytau_lib/connectivity/struct.ConnectivityChangeEvent.html @@ -0,0 +1,43 @@ +ConnectivityChangeEvent in jellytau_lib::connectivity - RustSkip to main content

ConnectivityChangeEvent

Struct ConnectivityChangeEvent 

Source
struct ConnectivityChangeEvent {
+    is_reachable: bool,
+}
Expand description

Connectivity change event emitted to frontend

+

Fields§

§is_reachable: bool

Trait Implementations§

Source§

impl Clone for ConnectivityChangeEvent

Source§

fn clone(&self) -> ConnectivityChangeEvent

Returns a duplicate of the value. Read more
1.0.0 (const: unstable) · Source§

fn clone_from(&mut self, source: &Self)

Performs copy-assignment from source. Read more
Source§

impl Debug for ConnectivityChangeEvent

Source§

fn fmt(&self, f: &mut Formatter<'_>) -> Result

Formats the value using the given formatter. Read more
Source§

impl NamedType for ConnectivityChangeEvent

Source§

fn sid() -> SpectaID

Source§

fn named_data_type( + type_map: &mut TypeCollection, + generics: &[DataType], +) -> NamedDataType

this is equivalent to [Type::inline] but returns a [NamedDataType] instead.
Source§

fn definition_named_data_type(type_map: &mut TypeCollection) -> NamedDataType

this is equivalent to [Type::definition] but returns a [NamedDataType] instead.
Source§

impl Serialize for ConnectivityChangeEvent

Source§

fn serialize<__S>(&self, __serializer: __S) -> Result<__S::Ok, __S::Error>
where + __S: Serializer,

Serialize this value into the given Serde serializer. Read more
Source§

impl Type for ConnectivityChangeEvent

Source§

fn inline(type_map: &mut TypeCollection, generics: Generics<'_>) -> DataType

Returns the definition of a type using the provided generics. Read more
Source§

fn reference(type_map: &mut TypeCollection, generics: &[DataType]) -> Reference

Generates a datatype corresponding to a reference to this type, +as determined by its category. Getting a reference to a type implies that +it should belong in the type map (since it has to be referenced from somewhere), +so the output of definition will be put into the type map.
Source§

impl Flatten for ConnectivityChangeEvent

Auto Trait Implementations§

Blanket Implementations§

Source§

impl<T> Any for T
where + T: 'static + ?Sized,

Source§

fn type_id(&self) -> TypeId

Gets the TypeId of self. Read more
Source§

impl<T> Borrow<T> for T
where + T: ?Sized,

Source§

fn borrow(&self) -> &T

Immutably borrows from an owned value. Read more
Source§

impl<T> BorrowMut<T> for T
where + T: ?Sized,

Source§

fn borrow_mut(&mut self) -> &mut T

Mutably borrows from an owned value. Read more
Source§

impl<T> CloneToUninit for T
where + T: Clone,

Source§

unsafe fn clone_to_uninit(&self, dest: *mut u8)

🔬This is a nightly-only experimental API. (clone_to_uninit)
Performs copy-assignment from self to dest. Read more
Source§

impl<T> From<T> for T

Source§

fn from(t: T) -> T

Returns the argument unchanged.

+
§

impl<T> FunctionArg for T
where + T: Type,

§

fn to_datatype(type_map: &mut TypeCollection) -> Option<DataType>

Gets the type of an argument as a [DataType]. Read more
§

impl<T> Instrument for T

§

fn instrument(self, span: Span) -> Instrumented<Self>

Instruments this type with the provided [Span], returning an +Instrumented wrapper. Read more
§

fn in_current_span(self) -> Instrumented<Self>

Instruments this type with the current Span, returning an +Instrumented wrapper. Read more
Source§

impl<T, U> Into<U> for T
where + U: From<T>,

Source§

fn into(self) -> U

Calls U::from(self).

+

That is, this conversion is whatever the implementation of +From<T> for U chooses to do.

+
§

impl<T> IpcResponse for T
where + T: Serialize,

§

fn body(self) -> Result<InvokeResponseBody, Error>

Resolve the IPC response body.
§

impl<T> PolicyExt for T
where + T: ?Sized,

§

fn and<P, B, E>(self, other: P) -> And<T, P>
where + T: Policy<B, E>, + P: Policy<B, E>,

Create a new Policy that returns [Action::Follow] only if self and other return +Action::Follow. Read more
§

fn or<P, B, E>(self, other: P) -> Or<T, P>
where + T: Policy<B, E>, + P: Policy<B, E>,

Create a new Policy that returns [Action::Follow] if either self or other returns +Action::Follow. Read more
Source§

impl<T> Same for T

Source§

type Output = T

Should always be Self
Source§

impl<T> Serialize for T
where + T: Serialize + ?Sized,

Source§

fn erased_serialize(&self, serializer: &mut dyn Serializer) -> Result<(), Error>

Source§

fn do_erased_serialize( + &self, + serializer: &mut dyn Serializer, +) -> Result<(), ErrorImpl>

Source§

impl<T> ToOwned for T
where + T: Clone,

Source§

type Owned = T

The resulting type after obtaining ownership.
Source§

fn to_owned(&self) -> T

Creates owned data from borrowed data, usually by cloning. Read more
Source§

fn clone_into(&self, target: &mut T)

Uses borrowed data to replace owned data, usually by cloning. Read more
Source§

impl<T, U> TryFrom<U> for T
where + U: Into<T>,

Source§

type Error = Infallible

The type returned in the event of a conversion error.
Source§

fn try_from(value: U) -> Result<T, <T as TryFrom<U>>::Error>

Performs the conversion.
Source§

impl<T, U> TryInto<U> for T
where + U: TryFrom<T>,

Source§

type Error = <U as TryFrom<T>>::Error

The type returned in the event of a conversion error.
Source§

fn try_into(self) -> Result<U, <U as TryFrom<T>>::Error>

Performs the conversion.
§

impl<V, T> VZip<V> for T
where + V: MultiLane<T>,

§

fn vzip(self) -> V

§

impl<T> WithSubscriber for T

§

fn with_subscriber<S>(self, subscriber: S) -> WithDispatch<Self>
where + S: Into<Dispatch>,

Attaches the provided Subscriber to this type, returning a +[WithDispatch] wrapper. Read more
§

fn with_current_subscriber(self) -> WithDispatch<Self>

Attaches the current default Subscriber to this type, returning a +[WithDispatch] wrapper. Read more
§

impl<T> UserEvent for T
where + T: Debug + Clone + Send + 'static,

\ No newline at end of file diff --git a/api/jellytau_lib/connectivity/struct.ConnectivityMonitor.html b/api/jellytau_lib/connectivity/struct.ConnectivityMonitor.html new file mode 100644 index 00000000..2f0ce9e1 --- /dev/null +++ b/api/jellytau_lib/connectivity/struct.ConnectivityMonitor.html @@ -0,0 +1,53 @@ +ConnectivityMonitor in jellytau_lib::connectivity - RustSkip to main content

ConnectivityMonitor

Struct ConnectivityMonitor 

Source
pub struct ConnectivityMonitor {
+    server_url: Arc<RwLock<Option<String>>>,
+    http_client: Arc<HttpClient>,
+    reporter: ConnectivityReporter,
+    is_monitoring: Arc<AtomicBool>,
+}
Expand description

Connectivity monitor for tracking server reachability.

+

Reachability is driven primarily by real repository traffic via the shared +ConnectivityReporter. The monitor itself only runs an offline recovery +probe (see start_monitoring) and serves the connectivity Tauri commands.

+

Fields§

§server_url: Arc<RwLock<Option<String>>>§http_client: Arc<HttpClient>§reporter: ConnectivityReporter§is_monitoring: Arc<AtomicBool>

Implementations§

Source§

impl ConnectivityMonitor

Source

pub fn new(http_client: HttpClient) -> Self

Create a new connectivity monitor

+
Source

pub fn set_app_handle(&mut self, app_handle: AppHandle)

Set the Tauri app handle for event emission. +Must be called before the reporter is shared with the repository.

+
Source

pub fn reporter(&self) -> ConnectivityReporter

Get a cheap, cloneable reporter so the repository can feed server +outcomes into the same reachability state the UI observes.

+
Source

pub async fn set_server_url(&self, url: String)

Update the server URL

+
Source

pub async fn get_status(&self) -> ConnectivityStatus

Get current connectivity status

+
Source

pub async fn check_reachability(&self) -> bool

Deliberately probe the server’s reachability (manual check / recovery probe). +The result is applied immediately (no debounce) since this is an explicit test.

+
Source

pub async fn mark_reachable(&self)

Mark server as reachable (called after successful API call / login)

+
Source

pub async fn mark_unreachable(&self, error: Option<String>)

Mark server as unreachable directly.

+

Used by deliberate signals (e.g. a failed login/connect) where the caller +knows the server is unreachable now. Repository traffic should prefer +reporter().report_network_failure() so the debounce applies.

+
Source

pub async fn start_monitoring(&self)

Start the offline recovery probe.

+

While online, reachability is kept fresh by real traffic, so the probe +idles. While offline, it polls /System/Info/Public every +RETRY_CHECK_INTERVAL_MS to detect the server returning even when no user +traffic is flowing.

+
Source

pub fn stop_monitoring(&self)

Stop monitoring connectivity

+

Auto Trait Implementations§

Blanket Implementations§

Source§

impl<T> Any for T
where + T: 'static + ?Sized,

Source§

fn type_id(&self) -> TypeId

Gets the TypeId of self. Read more
Source§

impl<T> Borrow<T> for T
where + T: ?Sized,

Source§

fn borrow(&self) -> &T

Immutably borrows from an owned value. Read more
Source§

impl<T> BorrowMut<T> for T
where + T: ?Sized,

Source§

fn borrow_mut(&mut self) -> &mut T

Mutably borrows from an owned value. Read more
Source§

impl<T> From<T> for T

Source§

fn from(t: T) -> T

Returns the argument unchanged.

+
§

impl<T> Instrument for T

§

fn instrument(self, span: Span) -> Instrumented<Self>

Instruments this type with the provided [Span], returning an +Instrumented wrapper. Read more
§

fn in_current_span(self) -> Instrumented<Self>

Instruments this type with the current Span, returning an +Instrumented wrapper. Read more
Source§

impl<T, U> Into<U> for T
where + U: From<T>,

Source§

fn into(self) -> U

Calls U::from(self).

+

That is, this conversion is whatever the implementation of +From<T> for U chooses to do.

+
§

impl<T> PolicyExt for T
where + T: ?Sized,

§

fn and<P, B, E>(self, other: P) -> And<T, P>
where + T: Policy<B, E>, + P: Policy<B, E>,

Create a new Policy that returns [Action::Follow] only if self and other return +Action::Follow. Read more
§

fn or<P, B, E>(self, other: P) -> Or<T, P>
where + T: Policy<B, E>, + P: Policy<B, E>,

Create a new Policy that returns [Action::Follow] if either self or other returns +Action::Follow. Read more
Source§

impl<T> Same for T

Source§

type Output = T

Should always be Self
Source§

impl<T, U> TryFrom<U> for T
where + U: Into<T>,

Source§

type Error = Infallible

The type returned in the event of a conversion error.
Source§

fn try_from(value: U) -> Result<T, <T as TryFrom<U>>::Error>

Performs the conversion.
Source§

impl<T, U> TryInto<U> for T
where + U: TryFrom<T>,

Source§

type Error = <U as TryFrom<T>>::Error

The type returned in the event of a conversion error.
Source§

fn try_into(self) -> Result<U, <U as TryFrom<T>>::Error>

Performs the conversion.
§

impl<V, T> VZip<V> for T
where + V: MultiLane<T>,

§

fn vzip(self) -> V

§

impl<T> WithSubscriber for T

§

fn with_subscriber<S>(self, subscriber: S) -> WithDispatch<Self>
where + S: Into<Dispatch>,

Attaches the provided Subscriber to this type, returning a +[WithDispatch] wrapper. Read more
§

fn with_current_subscriber(self) -> WithDispatch<Self>

Attaches the current default Subscriber to this type, returning a +[WithDispatch] wrapper. Read more
\ No newline at end of file diff --git a/api/jellytau_lib/connectivity/struct.ConnectivityReporter.html b/api/jellytau_lib/connectivity/struct.ConnectivityReporter.html new file mode 100644 index 00000000..7019ce3f --- /dev/null +++ b/api/jellytau_lib/connectivity/struct.ConnectivityReporter.html @@ -0,0 +1,58 @@ +ConnectivityReporter in jellytau_lib::connectivity - RustSkip to main content

ConnectivityReporter

Struct ConnectivityReporter 

Source
pub struct ConnectivityReporter {
+    status: Arc<RwLock<ConnectivityStatus>>,
+    first_failure_at: Arc<RwLock<Option<Instant>>>,
+    app_handle: Option<AppHandle>,
+}
Expand description

Shared reachability state and transition logic.

+

This is the single place that mutates reachability and emits events. It is +cheap to clone (all fields are Arc/Option) and is shared by:

+
    +
  • the ConnectivityMonitor (commands, offline recovery probe), and
  • +
  • OnlineRepository, which reports the outcome of every server request.
  • +
+

Reachability is therefore driven by real traffic; the probe only fills the +gap while offline.

+

Fields§

§status: Arc<RwLock<ConnectivityStatus>>§first_failure_at: Arc<RwLock<Option<Instant>>>

Timestamp of the first network failure in the current failure streak. +Used to debounce the transition to offline (see OFFLINE_CONFIRM_WINDOW).

+
§app_handle: Option<AppHandle>

Implementations§

Source§

impl ConnectivityReporter

Source

fn new( + status: Arc<RwLock<ConnectivityStatus>>, + app_handle: Option<AppHandle>, +) -> Self

Source

pub async fn is_reachable(&self) -> bool

Current reachability as seen by this reporter (shared with the monitor +and the UI). Useful for callers that want to branch on connectivity.

+
Source

pub async fn report_success(&self)

Report that a real server request succeeded (or that the server answered +at all, e.g. with 401/404/5xx). The server is up — recover instantly.

+
Source

pub async fn report_network_failure(&self, error: Option<String>)

Report that a real server request failed with a network-level error +(connection refused, timeout, DNS). Subject to the time-window debounce: +we only flip to offline once failures have persisted for +OFFLINE_CONFIRM_WINDOW with no intervening success.

+
Source

async fn apply_probe_result(&self, is_reachable: bool, error: Option<String>)

Apply a deliberate reachability probe result (offline recovery probe or a +manual check). Unlike report_network_failure, a probe is an explicit +reachability test, so its result is applied immediately without debounce.

+
Source

async fn set_reachable(&self, is_reachable: bool, error: Option<String>)

Core transition: update status and emit events only on an actual change.

+
Source

async fn emit_connectivity_change(&self, is_reachable: bool)

Emit connectivity change event to frontend

+
Source

async fn emit_server_reconnected(&self)

Emit server reconnected event to frontend

+

Trait Implementations§

Source§

impl Clone for ConnectivityReporter

Source§

fn clone(&self) -> ConnectivityReporter

Returns a duplicate of the value. Read more
1.0.0 (const: unstable) · Source§

fn clone_from(&mut self, source: &Self)

Performs copy-assignment from source. Read more

Auto Trait Implementations§

Blanket Implementations§

Source§

impl<T> Any for T
where + T: 'static + ?Sized,

Source§

fn type_id(&self) -> TypeId

Gets the TypeId of self. Read more
Source§

impl<T> Borrow<T> for T
where + T: ?Sized,

Source§

fn borrow(&self) -> &T

Immutably borrows from an owned value. Read more
Source§

impl<T> BorrowMut<T> for T
where + T: ?Sized,

Source§

fn borrow_mut(&mut self) -> &mut T

Mutably borrows from an owned value. Read more
Source§

impl<T> CloneToUninit for T
where + T: Clone,

Source§

unsafe fn clone_to_uninit(&self, dest: *mut u8)

🔬This is a nightly-only experimental API. (clone_to_uninit)
Performs copy-assignment from self to dest. Read more
Source§

impl<T> From<T> for T

Source§

fn from(t: T) -> T

Returns the argument unchanged.

+
§

impl<T> Instrument for T

§

fn instrument(self, span: Span) -> Instrumented<Self>

Instruments this type with the provided [Span], returning an +Instrumented wrapper. Read more
§

fn in_current_span(self) -> Instrumented<Self>

Instruments this type with the current Span, returning an +Instrumented wrapper. Read more
Source§

impl<T, U> Into<U> for T
where + U: From<T>,

Source§

fn into(self) -> U

Calls U::from(self).

+

That is, this conversion is whatever the implementation of +From<T> for U chooses to do.

+
§

impl<T> PolicyExt for T
where + T: ?Sized,

§

fn and<P, B, E>(self, other: P) -> And<T, P>
where + T: Policy<B, E>, + P: Policy<B, E>,

Create a new Policy that returns [Action::Follow] only if self and other return +Action::Follow. Read more
§

fn or<P, B, E>(self, other: P) -> Or<T, P>
where + T: Policy<B, E>, + P: Policy<B, E>,

Create a new Policy that returns [Action::Follow] if either self or other returns +Action::Follow. Read more
Source§

impl<T> Same for T

Source§

type Output = T

Should always be Self
Source§

impl<T> ToOwned for T
where + T: Clone,

Source§

type Owned = T

The resulting type after obtaining ownership.
Source§

fn to_owned(&self) -> T

Creates owned data from borrowed data, usually by cloning. Read more
Source§

fn clone_into(&self, target: &mut T)

Uses borrowed data to replace owned data, usually by cloning. Read more
Source§

impl<T, U> TryFrom<U> for T
where + U: Into<T>,

Source§

type Error = Infallible

The type returned in the event of a conversion error.
Source§

fn try_from(value: U) -> Result<T, <T as TryFrom<U>>::Error>

Performs the conversion.
Source§

impl<T, U> TryInto<U> for T
where + U: TryFrom<T>,

Source§

type Error = <U as TryFrom<T>>::Error

The type returned in the event of a conversion error.
Source§

fn try_into(self) -> Result<U, <U as TryFrom<T>>::Error>

Performs the conversion.
§

impl<V, T> VZip<V> for T
where + V: MultiLane<T>,

§

fn vzip(self) -> V

§

impl<T> WithSubscriber for T

§

fn with_subscriber<S>(self, subscriber: S) -> WithDispatch<Self>
where + S: Into<Dispatch>,

Attaches the provided Subscriber to this type, returning a +[WithDispatch] wrapper. Read more
§

fn with_current_subscriber(self) -> WithDispatch<Self>

Attaches the current default Subscriber to this type, returning a +[WithDispatch] wrapper. Read more
\ No newline at end of file diff --git a/api/jellytau_lib/connectivity/struct.ConnectivityStatus.html b/api/jellytau_lib/connectivity/struct.ConnectivityStatus.html new file mode 100644 index 00000000..764c1547 --- /dev/null +++ b/api/jellytau_lib/connectivity/struct.ConnectivityStatus.html @@ -0,0 +1,60 @@ +ConnectivityStatus in jellytau_lib::connectivity - RustSkip to main content

ConnectivityStatus

Struct ConnectivityStatus 

Source
pub struct ConnectivityStatus {
+    pub is_server_reachable: bool,
+    pub last_checked: Option<String>,
+    pub connection_error: Option<String>,
+    pub is_checking: bool,
+}
Expand description

Connectivity status

+

Fields§

§is_server_reachable: bool

Whether the Jellyfin server is reachable

+
§last_checked: Option<String>

Last time we checked server reachability (ISO 8601 string)

+
§connection_error: Option<String>

Error message from last connectivity check

+
§is_checking: bool

Whether we’re currently checking connectivity

+

Trait Implementations§

Source§

impl Clone for ConnectivityStatus

Source§

fn clone(&self) -> ConnectivityStatus

Returns a duplicate of the value. Read more
1.0.0 (const: unstable) · Source§

fn clone_from(&mut self, source: &Self)

Performs copy-assignment from source. Read more
Source§

impl Debug for ConnectivityStatus

Source§

fn fmt(&self, f: &mut Formatter<'_>) -> Result

Formats the value using the given formatter. Read more
Source§

impl Default for ConnectivityStatus

Source§

fn default() -> Self

Returns the “default value” for a type. Read more
Source§

impl<'de> Deserialize<'de> for ConnectivityStatus

Source§

fn deserialize<__D>(__deserializer: __D) -> Result<Self, __D::Error>
where + __D: Deserializer<'de>,

Deserialize this value from the given Serde deserializer. Read more
Source§

impl NamedType for ConnectivityStatus

Source§

fn sid() -> SpectaID

Source§

fn named_data_type( + type_map: &mut TypeCollection, + generics: &[DataType], +) -> NamedDataType

this is equivalent to [Type::inline] but returns a [NamedDataType] instead.
Source§

fn definition_named_data_type(type_map: &mut TypeCollection) -> NamedDataType

this is equivalent to [Type::definition] but returns a [NamedDataType] instead.
Source§

impl Serialize for ConnectivityStatus

Source§

fn serialize<__S>(&self, __serializer: __S) -> Result<__S::Ok, __S::Error>
where + __S: Serializer,

Serialize this value into the given Serde serializer. Read more
Source§

impl Type for ConnectivityStatus

Source§

fn inline(type_map: &mut TypeCollection, generics: Generics<'_>) -> DataType

Returns the definition of a type using the provided generics. Read more
Source§

fn reference(type_map: &mut TypeCollection, generics: &[DataType]) -> Reference

Generates a datatype corresponding to a reference to this type, +as determined by its category. Getting a reference to a type implies that +it should belong in the type map (since it has to be referenced from somewhere), +so the output of definition will be put into the type map.
Source§

impl Flatten for ConnectivityStatus

Auto Trait Implementations§

Blanket Implementations§

Source§

impl<T> Any for T
where + T: 'static + ?Sized,

Source§

fn type_id(&self) -> TypeId

Gets the TypeId of self. Read more
Source§

impl<T> Borrow<T> for T
where + T: ?Sized,

Source§

fn borrow(&self) -> &T

Immutably borrows from an owned value. Read more
Source§

impl<T> BorrowMut<T> for T
where + T: ?Sized,

Source§

fn borrow_mut(&mut self) -> &mut T

Mutably borrows from an owned value. Read more
Source§

impl<T> CloneToUninit for T
where + T: Clone,

Source§

unsafe fn clone_to_uninit(&self, dest: *mut u8)

🔬This is a nightly-only experimental API. (clone_to_uninit)
Performs copy-assignment from self to dest. Read more
§

impl<'de, D, R> CommandArg<'de, R> for D
where + D: Deserialize<'de>, + R: Runtime,

§

fn from_command(command: CommandItem<'de, R>) -> Result<D, InvokeError>

Derives an instance of Self from the [CommandItem]. Read more
Source§

impl<T> From<T> for T

Source§

fn from(t: T) -> T

Returns the argument unchanged.

+
§

impl<T> FunctionArg for T
where + T: Type,

§

fn to_datatype(type_map: &mut TypeCollection) -> Option<DataType>

Gets the type of an argument as a [DataType]. Read more
§

impl<T> Instrument for T

§

fn instrument(self, span: Span) -> Instrumented<Self>

Instruments this type with the provided [Span], returning an +Instrumented wrapper. Read more
§

fn in_current_span(self) -> Instrumented<Self>

Instruments this type with the current Span, returning an +Instrumented wrapper. Read more
Source§

impl<T, U> Into<U> for T
where + U: From<T>,

Source§

fn into(self) -> U

Calls U::from(self).

+

That is, this conversion is whatever the implementation of +From<T> for U chooses to do.

+
§

impl<T> IpcResponse for T
where + T: Serialize,

§

fn body(self) -> Result<InvokeResponseBody, Error>

Resolve the IPC response body.
§

impl<T> NoneValue for T
where + T: Default,

§

type NoneType = T

§

fn null_value() -> T

The none-equivalent value.
§

impl<T> PolicyExt for T
where + T: ?Sized,

§

fn and<P, B, E>(self, other: P) -> And<T, P>
where + T: Policy<B, E>, + P: Policy<B, E>,

Create a new Policy that returns [Action::Follow] only if self and other return +Action::Follow. Read more
§

fn or<P, B, E>(self, other: P) -> Or<T, P>
where + T: Policy<B, E>, + P: Policy<B, E>,

Create a new Policy that returns [Action::Follow] if either self or other returns +Action::Follow. Read more
Source§

impl<T> Same for T

Source§

type Output = T

Should always be Self
§

impl<T> ScopeObject for T
where + T: Send + Sync + Debug + DeserializeOwned + 'static,

§

type Error = Error

The error type.
§

fn deserialize<R>( + _app: &AppHandle<R>, + raw: Value, +) -> Result<T, <T as ScopeObject>::Error>
where + R: Runtime,

Deserialize the raw scope value.
Source§

impl<T> Serialize for T
where + T: Serialize + ?Sized,

Source§

fn erased_serialize(&self, serializer: &mut dyn Serializer) -> Result<(), Error>

Source§

fn do_erased_serialize( + &self, + serializer: &mut dyn Serializer, +) -> Result<(), ErrorImpl>

Source§

impl<T> ToOwned for T
where + T: Clone,

Source§

type Owned = T

The resulting type after obtaining ownership.
Source§

fn to_owned(&self) -> T

Creates owned data from borrowed data, usually by cloning. Read more
Source§

fn clone_into(&self, target: &mut T)

Uses borrowed data to replace owned data, usually by cloning. Read more
Source§

impl<T, U> TryFrom<U> for T
where + U: Into<T>,

Source§

type Error = Infallible

The type returned in the event of a conversion error.
Source§

fn try_from(value: U) -> Result<T, <T as TryFrom<U>>::Error>

Performs the conversion.
Source§

impl<T, U> TryInto<U> for T
where + U: TryFrom<T>,

Source§

type Error = <U as TryFrom<T>>::Error

The type returned in the event of a conversion error.
Source§

fn try_into(self) -> Result<U, <U as TryFrom<T>>::Error>

Performs the conversion.
§

impl<V, T> VZip<V> for T
where + V: MultiLane<T>,

§

fn vzip(self) -> V

§

impl<T> WithSubscriber for T

§

fn with_subscriber<S>(self, subscriber: S) -> WithDispatch<Self>
where + S: Into<Dispatch>,

Attaches the provided Subscriber to this type, returning a +[WithDispatch] wrapper. Read more
§

fn with_current_subscriber(self) -> WithDispatch<Self>

Attaches the current default Subscriber to this type, returning a +[WithDispatch] wrapper. Read more
Source§

impl<T> DeserializeOwned for T
where + T: for<'de> Deserialize<'de>,

§

impl<T> UserEvent for T
where + T: Debug + Clone + Send + 'static,

\ No newline at end of file diff --git a/api/jellytau_lib/credentials/constant.CREDENTIALS_FILENAME.html b/api/jellytau_lib/credentials/constant.CREDENTIALS_FILENAME.html new file mode 100644 index 00000000..daa5dced --- /dev/null +++ b/api/jellytau_lib/credentials/constant.CREDENTIALS_FILENAME.html @@ -0,0 +1 @@ +CREDENTIALS_FILENAME in jellytau_lib::credentials - RustSkip to main content

CREDENTIALS_FILENAME

Constant CREDENTIALS_FILENAME 

Source
const CREDENTIALS_FILENAME: &str = "credentials.enc";
\ No newline at end of file diff --git a/api/jellytau_lib/credentials/constant.SERVICE_NAME.html b/api/jellytau_lib/credentials/constant.SERVICE_NAME.html new file mode 100644 index 00000000..84ca055b --- /dev/null +++ b/api/jellytau_lib/credentials/constant.SERVICE_NAME.html @@ -0,0 +1 @@ +SERVICE_NAME in jellytau_lib::credentials - RustSkip to main content

SERVICE_NAME

Constant SERVICE_NAME 

Source
const SERVICE_NAME: &str = "com.dtourolle.jellytau";
\ No newline at end of file diff --git a/api/jellytau_lib/credentials/enum.CredentialError.html b/api/jellytau_lib/credentials/enum.CredentialError.html new file mode 100644 index 00000000..4f893a74 --- /dev/null +++ b/api/jellytau_lib/credentials/enum.CredentialError.html @@ -0,0 +1,39 @@ +CredentialError in jellytau_lib::credentials - RustSkip to main content

CredentialError

Enum CredentialError 

Source
pub enum CredentialError {
+    Keyring(String),
+    Encryption(String),
+    Io(String),
+    NotFound,
+}
Expand description

Error types for credential operations

+

Variants§

§

Keyring(String)

Keyring operation failed

+
§

Encryption(String)

Encryption/decryption failed

+
§

Io(String)

File I/O failed

+
§

NotFound

Credential not found

+

Trait Implementations§

Source§

impl Debug for CredentialError

Source§

fn fmt(&self, f: &mut Formatter<'_>) -> Result

Formats the value using the given formatter. Read more
Source§

impl Display for CredentialError

Source§

fn fmt(&self, f: &mut Formatter<'_>) -> Result

Formats the value using the given formatter. Read more
Source§

impl Error for CredentialError

1.30.0 · Source§

fn source(&self) -> Option<&(dyn Error + 'static)>

Returns the lower-level source of this error, if any. Read more
1.0.0 · Source§

fn description(&self) -> &str

👎Deprecated since 1.42.0:

use the Display impl or to_string()

+
1.0.0 · Source§

fn cause(&self) -> Option<&dyn Error>

👎Deprecated since 1.33.0:

replaced by Error::source, which can support downcasting

+
Source§

fn provide<'a>(&'a self, request: &mut Request<'a>)

🔬This is a nightly-only experimental API. (error_generic_member_access)
Provides type-based access to context intended for error reports. Read more

Auto Trait Implementations§

Blanket Implementations§

Source§

impl<T> Any for T
where + T: 'static + ?Sized,

Source§

fn type_id(&self) -> TypeId

Gets the TypeId of self. Read more
Source§

impl<T> Borrow<T> for T
where + T: ?Sized,

Source§

fn borrow(&self) -> &T

Immutably borrows from an owned value. Read more
Source§

impl<T> BorrowMut<T> for T
where + T: ?Sized,

Source§

fn borrow_mut(&mut self) -> &mut T

Mutably borrows from an owned value. Read more
Source§

impl<T> From<T> for T

Source§

fn from(t: T) -> T

Returns the argument unchanged.

+
§

impl<T> Instrument for T

§

fn instrument(self, span: Span) -> Instrumented<Self>

Instruments this type with the provided [Span], returning an +Instrumented wrapper. Read more
§

fn in_current_span(self) -> Instrumented<Self>

Instruments this type with the current Span, returning an +Instrumented wrapper. Read more
Source§

impl<T, U> Into<U> for T
where + U: From<T>,

Source§

fn into(self) -> U

Calls U::from(self).

+

That is, this conversion is whatever the implementation of +From<T> for U chooses to do.

+
§

impl<T> PolicyExt for T
where + T: ?Sized,

§

fn and<P, B, E>(self, other: P) -> And<T, P>
where + T: Policy<B, E>, + P: Policy<B, E>,

Create a new Policy that returns [Action::Follow] only if self and other return +Action::Follow. Read more
§

fn or<P, B, E>(self, other: P) -> Or<T, P>
where + T: Policy<B, E>, + P: Policy<B, E>,

Create a new Policy that returns [Action::Follow] if either self or other returns +Action::Follow. Read more
Source§

impl<T> Same for T

Source§

type Output = T

Should always be Self
Source§

impl<T> ToString for T
where + T: Display + ?Sized,

Source§

fn to_string(&self) -> String

Converts the given value to a String. Read more
§

impl<T> ToStringFallible for T
where + T: Display,

§

fn try_to_string(&self) -> Result<String, TryReserveError>

ToString::to_string, but without panic on OOM.

+
Source§

impl<T, U> TryFrom<U> for T
where + U: Into<T>,

Source§

type Error = Infallible

The type returned in the event of a conversion error.
Source§

fn try_from(value: U) -> Result<T, <T as TryFrom<U>>::Error>

Performs the conversion.
Source§

impl<T, U> TryInto<U> for T
where + U: TryFrom<T>,

Source§

type Error = <U as TryFrom<T>>::Error

The type returned in the event of a conversion error.
Source§

fn try_into(self) -> Result<U, <U as TryFrom<T>>::Error>

Performs the conversion.
§

impl<V, T> VZip<V> for T
where + V: MultiLane<T>,

§

fn vzip(self) -> V

§

impl<T> WithSubscriber for T

§

fn with_subscriber<S>(self, subscriber: S) -> WithDispatch<Self>
where + S: Into<Dispatch>,

Attaches the provided Subscriber to this type, returning a +[WithDispatch] wrapper. Read more
§

fn with_current_subscriber(self) -> WithDispatch<Self>

Attaches the current default Subscriber to this type, returning a +[WithDispatch] wrapper. Read more
\ No newline at end of file diff --git a/api/jellytau_lib/credentials/enum.CredentialResult.html b/api/jellytau_lib/credentials/enum.CredentialResult.html new file mode 100644 index 00000000..223bd660 --- /dev/null +++ b/api/jellytau_lib/credentials/enum.CredentialResult.html @@ -0,0 +1,30 @@ +CredentialResult in jellytau_lib::credentials - RustSkip to main content

CredentialResult

Enum CredentialResult 

Source
pub enum CredentialResult {
+    Keyring,
+    EncryptedFile,
+}
Expand description

Result of a credential storage operation

+

Variants§

§

Keyring

Operation succeeded using the system keyring

+
§

EncryptedFile

Operation succeeded using encrypted file fallback

+

Trait Implementations§

Source§

impl Debug for CredentialResult

Source§

fn fmt(&self, f: &mut Formatter<'_>) -> Result

Formats the value using the given formatter. Read more

Auto Trait Implementations§

Blanket Implementations§

Source§

impl<T> Any for T
where + T: 'static + ?Sized,

Source§

fn type_id(&self) -> TypeId

Gets the TypeId of self. Read more
Source§

impl<T> Borrow<T> for T
where + T: ?Sized,

Source§

fn borrow(&self) -> &T

Immutably borrows from an owned value. Read more
Source§

impl<T> BorrowMut<T> for T
where + T: ?Sized,

Source§

fn borrow_mut(&mut self) -> &mut T

Mutably borrows from an owned value. Read more
Source§

impl<T> From<T> for T

Source§

fn from(t: T) -> T

Returns the argument unchanged.

+
§

impl<T> Instrument for T

§

fn instrument(self, span: Span) -> Instrumented<Self>

Instruments this type with the provided [Span], returning an +Instrumented wrapper. Read more
§

fn in_current_span(self) -> Instrumented<Self>

Instruments this type with the current Span, returning an +Instrumented wrapper. Read more
Source§

impl<T, U> Into<U> for T
where + U: From<T>,

Source§

fn into(self) -> U

Calls U::from(self).

+

That is, this conversion is whatever the implementation of +From<T> for U chooses to do.

+
§

impl<T> PolicyExt for T
where + T: ?Sized,

§

fn and<P, B, E>(self, other: P) -> And<T, P>
where + T: Policy<B, E>, + P: Policy<B, E>,

Create a new Policy that returns [Action::Follow] only if self and other return +Action::Follow. Read more
§

fn or<P, B, E>(self, other: P) -> Or<T, P>
where + T: Policy<B, E>, + P: Policy<B, E>,

Create a new Policy that returns [Action::Follow] if either self or other returns +Action::Follow. Read more
Source§

impl<T> Same for T

Source§

type Output = T

Should always be Self
Source§

impl<T, U> TryFrom<U> for T
where + U: Into<T>,

Source§

type Error = Infallible

The type returned in the event of a conversion error.
Source§

fn try_from(value: U) -> Result<T, <T as TryFrom<U>>::Error>

Performs the conversion.
Source§

impl<T, U> TryInto<U> for T
where + U: TryFrom<T>,

Source§

type Error = <U as TryFrom<T>>::Error

The type returned in the event of a conversion error.
Source§

fn try_into(self) -> Result<U, <U as TryFrom<T>>::Error>

Performs the conversion.
§

impl<V, T> VZip<V> for T
where + V: MultiLane<T>,

§

fn vzip(self) -> V

§

impl<T> WithSubscriber for T

§

fn with_subscriber<S>(self, subscriber: S) -> WithDispatch<Self>
where + S: Into<Dispatch>,

Attaches the provided Subscriber to this type, returning a +[WithDispatch] wrapper. Read more
§

fn with_current_subscriber(self) -> WithDispatch<Self>

Attaches the current default Subscriber to this type, returning a +[WithDispatch] wrapper. Read more
\ No newline at end of file diff --git a/api/jellytau_lib/credentials/index.html b/api/jellytau_lib/credentials/index.html new file mode 100644 index 00000000..d4a87b1b --- /dev/null +++ b/api/jellytau_lib/credentials/index.html @@ -0,0 +1,10 @@ +jellytau_lib::credentials - RustSkip to main content

Module credentials

Module credentials 

Source
Expand description

Secure credential storage module

+

Provides secure storage for access tokens using:

+
    +
  • Primary: System keyring (Secret Service on Linux, Keychain on macOS)
  • +
  • Fallback: AES-256-GCM encrypted file when keyring unavailable
  • +
+

The fallback is less secure as the encryption key is derived from machine +identifiers, but provides functionality on headless systems.

+

TRACES: UR-012 | IR-014

+

Structs§

CredentialStore
Credential storage manager

Enums§

CredentialError
Error types for credential operations
CredentialResult
Result of a credential storage operation

Constants§

CREDENTIALS_FILENAME 🔒
SERVICE_NAME 🔒
\ No newline at end of file diff --git a/api/jellytau_lib/credentials/sidebar-items.js b/api/jellytau_lib/credentials/sidebar-items.js new file mode 100644 index 00000000..7d6a8372 --- /dev/null +++ b/api/jellytau_lib/credentials/sidebar-items.js @@ -0,0 +1 @@ +window.SIDEBAR_ITEMS = {"constant":["CREDENTIALS_FILENAME","SERVICE_NAME"],"enum":["CredentialError","CredentialResult"],"struct":["CredentialStore"]}; \ No newline at end of file diff --git a/api/jellytau_lib/credentials/struct.CredentialStore.html b/api/jellytau_lib/credentials/struct.CredentialStore.html new file mode 100644 index 00000000..f45e40a9 --- /dev/null +++ b/api/jellytau_lib/credentials/struct.CredentialStore.html @@ -0,0 +1,61 @@ +CredentialStore in jellytau_lib::credentials - RustSkip to main content

CredentialStore

Struct CredentialStore 

Source
pub struct CredentialStore {
+    using_keyring: bool,
+    credentials_path: PathBuf,
+    encryption_key: [u8; 32],
+}
Expand description

Credential storage manager

+

Fields§

§using_keyring: bool

Whether we’re using keyring (true) or encrypted file (false)

+
§credentials_path: PathBuf

Path to the encrypted credentials file (fallback)

+
§encryption_key: [u8; 32]

Encryption key for file fallback (derived from machine ID)

+

Implementations§

Source§

impl CredentialStore

Source

pub fn new() -> Self

Create a new credential store, detecting the best available backend

+
Source

pub fn is_using_keyring(&self) -> bool

Check if we’re using the secure keyring backend

+
Source

pub fn save_token( + &self, + user_id: &str, + token: &str, +) -> Result<CredentialResult, CredentialError>

Save an access token for a user

+
Source

pub fn get_token(&self, user_id: &str) -> Result<String, CredentialError>

Get an access token for a user

+
Source

pub fn delete_token(&self, user_id: &str) -> Result<(), CredentialError>

Delete an access token for a user

+
Source

fn test_keyring_available() -> bool

Source

fn test_keyring_inner() -> bool

Source

fn save_to_keyring( + &self, + user_id: &str, + token: &str, +) -> Result<(), CredentialError>

Source

fn get_from_keyring(&self, user_id: &str) -> Result<String, CredentialError>

Source

fn delete_from_keyring(&self, user_id: &str) -> Result<(), CredentialError>

Source

fn get_credentials_path() -> PathBuf

Source

fn derive_encryption_key() -> [u8; 32]

Source

fn load_credentials_file(&self) -> Result<Value, CredentialError>

Load and decrypt the credential map.

+

A file that is present but undecryptable is deliberately reported as +an empty credential set rather than as an error. The key never leaves +the device it was derived on (Android Keystore keys are never backed up, +and the file fallback’s key is derived from machine identifiers), so a +restored/transferred install gets ciphertext with no key and every read +would fail permanently. Surfacing that as an error made session restore +throw instead of falling back to the login screen: an unrecoverable app +rather than a clean logged-out one. The next successful login re-encrypts +the file with the current key, so the state self-heals.

+

TRACES: UR-012 | IR-014

+
Source

fn save_credentials_file(&self, data: &Value) -> Result<(), CredentialError>

Source

fn encrypt(&self, plaintext: &str) -> Result<String, CredentialError>

Source

fn decrypt(&self, encrypted: &str) -> Result<String, CredentialError>

Source

fn save_to_file( + &self, + user_id: &str, + token: &str, +) -> Result<(), CredentialError>

Source

fn get_from_file(&self, user_id: &str) -> Result<String, CredentialError>

Source

fn delete_from_file(&self, user_id: &str) -> Result<(), CredentialError>

Trait Implementations§

Source§

impl Default for CredentialStore

Source§

fn default() -> Self

Returns the “default value” for a type. Read more

Auto Trait Implementations§

Blanket Implementations§

Source§

impl<T> Any for T
where + T: 'static + ?Sized,

Source§

fn type_id(&self) -> TypeId

Gets the TypeId of self. Read more
Source§

impl<T> Borrow<T> for T
where + T: ?Sized,

Source§

fn borrow(&self) -> &T

Immutably borrows from an owned value. Read more
Source§

impl<T> BorrowMut<T> for T
where + T: ?Sized,

Source§

fn borrow_mut(&mut self) -> &mut T

Mutably borrows from an owned value. Read more
Source§

impl<T> From<T> for T

Source§

fn from(t: T) -> T

Returns the argument unchanged.

+
§

impl<T> Instrument for T

§

fn instrument(self, span: Span) -> Instrumented<Self>

Instruments this type with the provided [Span], returning an +Instrumented wrapper. Read more
§

fn in_current_span(self) -> Instrumented<Self>

Instruments this type with the current Span, returning an +Instrumented wrapper. Read more
Source§

impl<T, U> Into<U> for T
where + U: From<T>,

Source§

fn into(self) -> U

Calls U::from(self).

+

That is, this conversion is whatever the implementation of +From<T> for U chooses to do.

+
§

impl<T> NoneValue for T
where + T: Default,

§

type NoneType = T

§

fn null_value() -> T

The none-equivalent value.
§

impl<T> PolicyExt for T
where + T: ?Sized,

§

fn and<P, B, E>(self, other: P) -> And<T, P>
where + T: Policy<B, E>, + P: Policy<B, E>,

Create a new Policy that returns [Action::Follow] only if self and other return +Action::Follow. Read more
§

fn or<P, B, E>(self, other: P) -> Or<T, P>
where + T: Policy<B, E>, + P: Policy<B, E>,

Create a new Policy that returns [Action::Follow] if either self or other returns +Action::Follow. Read more
Source§

impl<T> Same for T

Source§

type Output = T

Should always be Self
Source§

impl<T, U> TryFrom<U> for T
where + U: Into<T>,

Source§

type Error = Infallible

The type returned in the event of a conversion error.
Source§

fn try_from(value: U) -> Result<T, <T as TryFrom<U>>::Error>

Performs the conversion.
Source§

impl<T, U> TryInto<U> for T
where + U: TryFrom<T>,

Source§

type Error = <U as TryFrom<T>>::Error

The type returned in the event of a conversion error.
Source§

fn try_into(self) -> Result<U, <U as TryFrom<T>>::Error>

Performs the conversion.
§

impl<V, T> VZip<V> for T
where + V: MultiLane<T>,

§

fn vzip(self) -> V

§

impl<T> WithSubscriber for T

§

fn with_subscriber<S>(self, subscriber: S) -> WithDispatch<Self>
where + S: Into<Dispatch>,

Attaches the provided Subscriber to this type, returning a +[WithDispatch] wrapper. Read more
§

fn with_current_subscriber(self) -> WithDispatch<Self>

Attaches the current default Subscriber to this type, returning a +[WithDispatch] wrapper. Read more
\ No newline at end of file diff --git a/api/jellytau_lib/domain/from_jellyfin/constant.TICKS_PER_MILLISECOND.html b/api/jellytau_lib/domain/from_jellyfin/constant.TICKS_PER_MILLISECOND.html new file mode 100644 index 00000000..b1d472b1 --- /dev/null +++ b/api/jellytau_lib/domain/from_jellyfin/constant.TICKS_PER_MILLISECOND.html @@ -0,0 +1,3 @@ +TICKS_PER_MILLISECOND in jellytau_lib::domain::from_jellyfin - RustSkip to main content

TICKS_PER_MILLISECOND

Constant TICKS_PER_MILLISECOND 

Source
const TICKS_PER_MILLISECOND: i64 = 10_000;
Expand description

Jellyfin ticks per second (10 million). A tick is 100 ns. +The frontend must never see ticks — this is where they die.

+
\ No newline at end of file diff --git a/api/jellytau_lib/domain/from_jellyfin/fn.kind_from_jellyfin.html b/api/jellytau_lib/domain/from_jellyfin/fn.kind_from_jellyfin.html new file mode 100644 index 00000000..4cb0fc11 --- /dev/null +++ b/api/jellytau_lib/domain/from_jellyfin/fn.kind_from_jellyfin.html @@ -0,0 +1,8 @@ +kind_from_jellyfin in jellytau_lib::domain::from_jellyfin - RustSkip to main content

kind_from_jellyfin

Function kind_from_jellyfin 

Source
pub fn kind_from_jellyfin(item_type: &str, is_folder: bool) -> MediaKind
Expand description

Classify a Jellyfin Type string into a neutral MediaKind.

+

Total and panic-free: any unrecognised string maps to MediaKind::Other +rather than failing. is_folder disambiguates the one Jellyfin type +(ChannelFolderItem) whose kind depends on whether it is a container.

+

The recognised set is every item_type the frontend audit found in use +(docs/specs/frontend-domain-model.md), plus the common cast/crew person +subtypes Jellyfin returns in People[].Type.

+
\ No newline at end of file diff --git a/api/jellytau_lib/domain/from_jellyfin/fn.stream_kind_from_jellyfin.html b/api/jellytau_lib/domain/from_jellyfin/fn.stream_kind_from_jellyfin.html new file mode 100644 index 00000000..08dbb59f --- /dev/null +++ b/api/jellytau_lib/domain/from_jellyfin/fn.stream_kind_from_jellyfin.html @@ -0,0 +1,3 @@ +stream_kind_from_jellyfin in jellytau_lib::domain::from_jellyfin - RustSkip to main content

stream_kind_from_jellyfin

Function stream_kind_from_jellyfin 

Source
pub fn stream_kind_from_jellyfin(stream_type: &str) -> StreamKind
Expand description

Classify a Jellyfin media-stream Type string into a neutral StreamKind. +Total and panic-free.

+
\ No newline at end of file diff --git a/api/jellytau_lib/domain/from_jellyfin/fn.ticks_to_ms.html b/api/jellytau_lib/domain/from_jellyfin/fn.ticks_to_ms.html new file mode 100644 index 00000000..e9eeb3ce --- /dev/null +++ b/api/jellytau_lib/domain/from_jellyfin/fn.ticks_to_ms.html @@ -0,0 +1,3 @@ +ticks_to_ms in jellytau_lib::domain::from_jellyfin - RustSkip to main content

ticks_to_ms

Function ticks_to_ms 

Source
pub fn ticks_to_ms(ticks: i64) -> i64
Expand description

Convert a Jellyfin RunTimeTicks value to milliseconds.

+

Domain durations are milliseconds; ticks are a Jellyfin unit and stop here.

+
\ No newline at end of file diff --git a/api/jellytau_lib/domain/from_jellyfin/index.html b/api/jellytau_lib/domain/from_jellyfin/index.html new file mode 100644 index 00000000..c49ecc08 --- /dev/null +++ b/api/jellytau_lib/domain/from_jellyfin/index.html @@ -0,0 +1,8 @@ +jellytau_lib::domain::from_jellyfin - RustSkip to main content

Module from_jellyfin

Module from_jellyfin 

Source
Expand description

Jellyfin → domain translation.

+

The ONLY place Jellyfin’s vocabulary touches the domain model. Adding a +second provider later means a sibling from_<provider>.rs; the domain types +and every consumer stay untouched.

+

Spec: docs/specs/frontend-domain-model.md

+

Constants§

TICKS_PER_MILLISECOND 🔒
Jellyfin ticks per second (10 million). A tick is 100 ns. +The frontend must never see ticks — this is where they die.

Functions§

kind_from_jellyfin
Classify a Jellyfin Type string into a neutral MediaKind.
stream_kind_from_jellyfin
Classify a Jellyfin media-stream Type string into a neutral StreamKind. +Total and panic-free.
ticks_to_ms
Convert a Jellyfin RunTimeTicks value to milliseconds.
\ No newline at end of file diff --git a/api/jellytau_lib/domain/from_jellyfin/sidebar-items.js b/api/jellytau_lib/domain/from_jellyfin/sidebar-items.js new file mode 100644 index 00000000..f12bb00b --- /dev/null +++ b/api/jellytau_lib/domain/from_jellyfin/sidebar-items.js @@ -0,0 +1 @@ +window.SIDEBAR_ITEMS = {"constant":["TICKS_PER_MILLISECOND"],"fn":["kind_from_jellyfin","stream_kind_from_jellyfin","ticks_to_ms"]}; \ No newline at end of file diff --git a/api/jellytau_lib/domain/index.html b/api/jellytau_lib/domain/index.html new file mode 100644 index 00000000..4b4cd14c --- /dev/null +++ b/api/jellytau_lib/domain/index.html @@ -0,0 +1,4 @@ +jellytau_lib::domain - RustSkip to main content

Module domain

Module domain 

Source
Expand description

Canonical, provider-neutral domain model — the single source of truth for +the app’s core data shapes, shared with the frontend via generated bindings.

+

Spec: docs/specs/frontend-domain-model.md

+

Re-exports§

pub use from_jellyfin::kind_from_jellyfin;
pub use from_jellyfin::stream_kind_from_jellyfin;
pub use from_jellyfin::ticks_to_ms;
pub use media::MediaKind;
pub use media::StreamKind;
pub use search_rank::rank_search_results;

Modules§

from_jellyfin
Jellyfin → domain translation.
media
Canonical, provider-neutral media domain model.
search_rank
Relevance ranking for search results.
\ No newline at end of file diff --git a/api/jellytau_lib/domain/media/enum.MediaKind.html b/api/jellytau_lib/domain/media/enum.MediaKind.html new file mode 100644 index 00000000..b35adc29 --- /dev/null +++ b/api/jellytau_lib/domain/media/enum.MediaKind.html @@ -0,0 +1,92 @@ +MediaKind in jellytau_lib::domain::media - RustSkip to main content

MediaKind

Enum MediaKind 

Source
pub enum MediaKind {
+
Show 14 variants Track, + Album, + Artist, + Playlist, + Movie, + Series, + Season, + Episode, + Person, + Channel, + Folder, + LiveChannel, + ChannelItem, + Other, +
}
Expand description

The kind of a media item — provider-neutral classification.

+

Replaces the stringly-typed item_type that carried Jellyfin’s vocabulary +("Audio", "MusicAlbum", …) across the boundary. A closed enum means a +typo or an unhandled kind is a compile error on the frontend, not a silent +runtime miss across ~127 comparison sites.

+

Variants§

§

Track

§

Album

§

Artist

§

Playlist

§

Movie

§

Series

§

Season

§

Episode

§

Person

§

Channel

A channel container the user drills into (Jellyfin Channel).

+
§

Folder

§

LiveChannel

A live TV channel — playable, but a live stream with no seekable +timeline (no resume/seek). Jellyfin TvChannel/LiveTvChannel.

+
§

ChannelItem

A playable leaf inside a channel (Jellyfin ChannelFolderItem that is +not itself a folder) — e.g. a plugin-channel VOD item that has no +dedicated item type but carries its own media streams. Playable and +seekable, unlike LiveChannel. Distinct from Channel (the container) +and from Other so the UI can route it to playback.

+
§

Other

A kind we do not model explicitly. Reached only for provider item types +that map to nothing meaningful; consumers treat it like an opaque +container. The mapping must be total — it never panics — so this is the +safe sink for unknown strings. Also the Default, so a defaulted +MediaItem (see the dual-carry migration) is inert rather than a lie.

+

Implementations§

Source§

impl MediaKind

Source

pub fn is_container(self) -> bool

True for kinds that are containers/collections rather than playable leaves. +Presentation-neutral helper the backend can use for e.g. drill-vs-play.

+

Trait Implementations§

Source§

impl Clone for MediaKind

Source§

fn clone(&self) -> MediaKind

Returns a duplicate of the value. Read more
1.0.0 (const: unstable) · Source§

fn clone_from(&mut self, source: &Self)

Performs copy-assignment from source. Read more
Source§

impl Debug for MediaKind

Source§

fn fmt(&self, f: &mut Formatter<'_>) -> Result

Formats the value using the given formatter. Read more
Source§

impl Default for MediaKind

Source§

fn default() -> MediaKind

Returns the “default value” for a type. Read more
Source§

impl<'de> Deserialize<'de> for MediaKind

Source§

fn deserialize<__D>(__deserializer: __D) -> Result<Self, __D::Error>
where + __D: Deserializer<'de>,

Deserialize this value from the given Serde deserializer. Read more
Source§

impl NamedType for MediaKind

Source§

fn sid() -> SpectaID

Source§

fn named_data_type( + type_map: &mut TypeCollection, + generics: &[DataType], +) -> NamedDataType

this is equivalent to [Type::inline] but returns a [NamedDataType] instead.
Source§

fn definition_named_data_type(type_map: &mut TypeCollection) -> NamedDataType

this is equivalent to [Type::definition] but returns a [NamedDataType] instead.
Source§

impl PartialEq for MediaKind

Source§

fn eq(&self, other: &MediaKind) -> bool

Tests for self and other values to be equal, and is used by ==.
1.0.0 (const: unstable) · Source§

fn ne(&self, other: &Rhs) -> bool

Tests for !=. The default implementation is almost always sufficient, +and should not be overridden without very good reason.
Source§

impl Serialize for MediaKind

Source§

fn serialize<__S>(&self, __serializer: __S) -> Result<__S::Ok, __S::Error>
where + __S: Serializer,

Serialize this value into the given Serde serializer. Read more
Source§

impl Type for MediaKind

Source§

fn inline(type_map: &mut TypeCollection, generics: Generics<'_>) -> DataType

Returns the definition of a type using the provided generics. Read more
Source§

fn reference(type_map: &mut TypeCollection, generics: &[DataType]) -> Reference

Generates a datatype corresponding to a reference to this type, +as determined by its category. Getting a reference to a type implies that +it should belong in the type map (since it has to be referenced from somewhere), +so the output of definition will be put into the type map.
Source§

impl Copy for MediaKind

Source§

impl Eq for MediaKind

Source§

impl StructuralPartialEq for MediaKind

Auto Trait Implementations§

Blanket Implementations§

Source§

impl<T> Any for T
where + T: 'static + ?Sized,

Source§

fn type_id(&self) -> TypeId

Gets the TypeId of self. Read more
Source§

impl<T> Borrow<T> for T
where + T: ?Sized,

Source§

fn borrow(&self) -> &T

Immutably borrows from an owned value. Read more
Source§

impl<T> BorrowMut<T> for T
where + T: ?Sized,

Source§

fn borrow_mut(&mut self) -> &mut T

Mutably borrows from an owned value. Read more
Source§

impl<T> CloneToUninit for T
where + T: Clone,

Source§

unsafe fn clone_to_uninit(&self, dest: *mut u8)

🔬This is a nightly-only experimental API. (clone_to_uninit)
Performs copy-assignment from self to dest. Read more
§

impl<'de, D, R> CommandArg<'de, R> for D
where + D: Deserialize<'de>, + R: Runtime,

§

fn from_command(command: CommandItem<'de, R>) -> Result<D, InvokeError>

Derives an instance of Self from the [CommandItem]. Read more
§

impl<Q, K> Equivalent<K> for Q
where + Q: Eq + ?Sized, + K: Borrow<Q> + ?Sized,

§

fn equivalent(&self, key: &K) -> bool

Checks if this value is equivalent to the given key. Read more
§

impl<Q, K> Equivalent<K> for Q
where + Q: Eq + ?Sized, + K: Borrow<Q> + ?Sized,

§

fn equivalent(&self, key: &K) -> bool

Compare self to key and return true if they are equal.
§

impl<Q, K> Equivalent<K> for Q
where + Q: Eq + ?Sized, + K: Borrow<Q> + ?Sized,

§

fn equivalent(&self, key: &K) -> bool

Checks if this value is equivalent to the given key. Read more
Source§

impl<T> From<T> for T

Source§

fn from(t: T) -> T

Returns the argument unchanged.

+
§

impl<T> FunctionArg for T
where + T: Type,

§

fn to_datatype(type_map: &mut TypeCollection) -> Option<DataType>

Gets the type of an argument as a [DataType]. Read more
§

impl<T> Instrument for T

§

fn instrument(self, span: Span) -> Instrumented<Self>

Instruments this type with the provided [Span], returning an +Instrumented wrapper. Read more
§

fn in_current_span(self) -> Instrumented<Self>

Instruments this type with the current Span, returning an +Instrumented wrapper. Read more
Source§

impl<T, U> Into<U> for T
where + U: From<T>,

Source§

fn into(self) -> U

Calls U::from(self).

+

That is, this conversion is whatever the implementation of +From<T> for U chooses to do.

+
§

impl<T> IpcResponse for T
where + T: Serialize,

§

fn body(self) -> Result<InvokeResponseBody, Error>

Resolve the IPC response body.
§

impl<T> NoneValue for T
where + T: Default,

§

type NoneType = T

§

fn null_value() -> T

The none-equivalent value.
§

impl<T> PolicyExt for T
where + T: ?Sized,

§

fn and<P, B, E>(self, other: P) -> And<T, P>
where + T: Policy<B, E>, + P: Policy<B, E>,

Create a new Policy that returns [Action::Follow] only if self and other return +Action::Follow. Read more
§

fn or<P, B, E>(self, other: P) -> Or<T, P>
where + T: Policy<B, E>, + P: Policy<B, E>,

Create a new Policy that returns [Action::Follow] if either self or other returns +Action::Follow. Read more
Source§

impl<T> Same for T

Source§

type Output = T

Should always be Self
§

impl<T> ScopeObject for T
where + T: Send + Sync + Debug + DeserializeOwned + 'static,

§

type Error = Error

The error type.
§

fn deserialize<R>( + _app: &AppHandle<R>, + raw: Value, +) -> Result<T, <T as ScopeObject>::Error>
where + R: Runtime,

Deserialize the raw scope value.
Source§

impl<T> Serialize for T
where + T: Serialize + ?Sized,

Source§

fn erased_serialize(&self, serializer: &mut dyn Serializer) -> Result<(), Error>

Source§

fn do_erased_serialize( + &self, + serializer: &mut dyn Serializer, +) -> Result<(), ErrorImpl>

Source§

impl<T> ToOwned for T
where + T: Clone,

Source§

type Owned = T

The resulting type after obtaining ownership.
Source§

fn to_owned(&self) -> T

Creates owned data from borrowed data, usually by cloning. Read more
Source§

fn clone_into(&self, target: &mut T)

Uses borrowed data to replace owned data, usually by cloning. Read more
Source§

impl<T, U> TryFrom<U> for T
where + U: Into<T>,

Source§

type Error = Infallible

The type returned in the event of a conversion error.
Source§

fn try_from(value: U) -> Result<T, <T as TryFrom<U>>::Error>

Performs the conversion.
Source§

impl<T, U> TryInto<U> for T
where + U: TryFrom<T>,

Source§

type Error = <U as TryFrom<T>>::Error

The type returned in the event of a conversion error.
Source§

fn try_into(self) -> Result<U, <U as TryFrom<T>>::Error>

Performs the conversion.
§

impl<V, T> VZip<V> for T
where + V: MultiLane<T>,

§

fn vzip(self) -> V

§

impl<T> WithSubscriber for T

§

fn with_subscriber<S>(self, subscriber: S) -> WithDispatch<Self>
where + S: Into<Dispatch>,

Attaches the provided Subscriber to this type, returning a +[WithDispatch] wrapper. Read more
§

fn with_current_subscriber(self) -> WithDispatch<Self>

Attaches the current default Subscriber to this type, returning a +[WithDispatch] wrapper. Read more
Source§

impl<T> DeserializeOwned for T
where + T: for<'de> Deserialize<'de>,

§

impl<T> UserEvent for T
where + T: Debug + Clone + Send + 'static,

\ No newline at end of file diff --git a/api/jellytau_lib/domain/media/enum.StreamKind.html b/api/jellytau_lib/domain/media/enum.StreamKind.html new file mode 100644 index 00000000..a4777c84 --- /dev/null +++ b/api/jellytau_lib/domain/media/enum.StreamKind.html @@ -0,0 +1,65 @@ +StreamKind in jellytau_lib::domain::media - RustSkip to main content

StreamKind

Enum StreamKind 

Source
pub enum StreamKind {
+    Audio,
+    Video,
+    Subtitle,
+    Other,
+}
Expand description

The kind of a media stream within an item (audio track, video track, +subtitle, …) — provider-neutral, replacing the stringly Jellyfin stream type.

+

Variants§

§

Audio

§

Video

§

Subtitle

§

Other

Any stream kind we do not model explicitly (e.g. embedded image, data).

+

Trait Implementations§

Source§

impl Clone for StreamKind

Source§

fn clone(&self) -> StreamKind

Returns a duplicate of the value. Read more
1.0.0 (const: unstable) · Source§

fn clone_from(&mut self, source: &Self)

Performs copy-assignment from source. Read more
Source§

impl Debug for StreamKind

Source§

fn fmt(&self, f: &mut Formatter<'_>) -> Result

Formats the value using the given formatter. Read more
Source§

impl Default for StreamKind

Source§

fn default() -> StreamKind

Returns the “default value” for a type. Read more
Source§

impl<'de> Deserialize<'de> for StreamKind

Source§

fn deserialize<__D>(__deserializer: __D) -> Result<Self, __D::Error>
where + __D: Deserializer<'de>,

Deserialize this value from the given Serde deserializer. Read more
Source§

impl NamedType for StreamKind

Source§

fn sid() -> SpectaID

Source§

fn named_data_type( + type_map: &mut TypeCollection, + generics: &[DataType], +) -> NamedDataType

this is equivalent to [Type::inline] but returns a [NamedDataType] instead.
Source§

fn definition_named_data_type(type_map: &mut TypeCollection) -> NamedDataType

this is equivalent to [Type::definition] but returns a [NamedDataType] instead.
Source§

impl PartialEq for StreamKind

Source§

fn eq(&self, other: &StreamKind) -> bool

Tests for self and other values to be equal, and is used by ==.
1.0.0 (const: unstable) · Source§

fn ne(&self, other: &Rhs) -> bool

Tests for !=. The default implementation is almost always sufficient, +and should not be overridden without very good reason.
Source§

impl Serialize for StreamKind

Source§

fn serialize<__S>(&self, __serializer: __S) -> Result<__S::Ok, __S::Error>
where + __S: Serializer,

Serialize this value into the given Serde serializer. Read more
Source§

impl Type for StreamKind

Source§

fn inline(type_map: &mut TypeCollection, generics: Generics<'_>) -> DataType

Returns the definition of a type using the provided generics. Read more
Source§

fn reference(type_map: &mut TypeCollection, generics: &[DataType]) -> Reference

Generates a datatype corresponding to a reference to this type, +as determined by its category. Getting a reference to a type implies that +it should belong in the type map (since it has to be referenced from somewhere), +so the output of definition will be put into the type map.
Source§

impl Copy for StreamKind

Source§

impl Eq for StreamKind

Source§

impl StructuralPartialEq for StreamKind

Auto Trait Implementations§

Blanket Implementations§

Source§

impl<T> Any for T
where + T: 'static + ?Sized,

Source§

fn type_id(&self) -> TypeId

Gets the TypeId of self. Read more
Source§

impl<T> Borrow<T> for T
where + T: ?Sized,

Source§

fn borrow(&self) -> &T

Immutably borrows from an owned value. Read more
Source§

impl<T> BorrowMut<T> for T
where + T: ?Sized,

Source§

fn borrow_mut(&mut self) -> &mut T

Mutably borrows from an owned value. Read more
Source§

impl<T> CloneToUninit for T
where + T: Clone,

Source§

unsafe fn clone_to_uninit(&self, dest: *mut u8)

🔬This is a nightly-only experimental API. (clone_to_uninit)
Performs copy-assignment from self to dest. Read more
§

impl<'de, D, R> CommandArg<'de, R> for D
where + D: Deserialize<'de>, + R: Runtime,

§

fn from_command(command: CommandItem<'de, R>) -> Result<D, InvokeError>

Derives an instance of Self from the [CommandItem]. Read more
§

impl<Q, K> Equivalent<K> for Q
where + Q: Eq + ?Sized, + K: Borrow<Q> + ?Sized,

§

fn equivalent(&self, key: &K) -> bool

Checks if this value is equivalent to the given key. Read more
§

impl<Q, K> Equivalent<K> for Q
where + Q: Eq + ?Sized, + K: Borrow<Q> + ?Sized,

§

fn equivalent(&self, key: &K) -> bool

Compare self to key and return true if they are equal.
§

impl<Q, K> Equivalent<K> for Q
where + Q: Eq + ?Sized, + K: Borrow<Q> + ?Sized,

§

fn equivalent(&self, key: &K) -> bool

Checks if this value is equivalent to the given key. Read more
Source§

impl<T> From<T> for T

Source§

fn from(t: T) -> T

Returns the argument unchanged.

+
§

impl<T> FunctionArg for T
where + T: Type,

§

fn to_datatype(type_map: &mut TypeCollection) -> Option<DataType>

Gets the type of an argument as a [DataType]. Read more
§

impl<T> Instrument for T

§

fn instrument(self, span: Span) -> Instrumented<Self>

Instruments this type with the provided [Span], returning an +Instrumented wrapper. Read more
§

fn in_current_span(self) -> Instrumented<Self>

Instruments this type with the current Span, returning an +Instrumented wrapper. Read more
Source§

impl<T, U> Into<U> for T
where + U: From<T>,

Source§

fn into(self) -> U

Calls U::from(self).

+

That is, this conversion is whatever the implementation of +From<T> for U chooses to do.

+
§

impl<T> IpcResponse for T
where + T: Serialize,

§

fn body(self) -> Result<InvokeResponseBody, Error>

Resolve the IPC response body.
§

impl<T> NoneValue for T
where + T: Default,

§

type NoneType = T

§

fn null_value() -> T

The none-equivalent value.
§

impl<T> PolicyExt for T
where + T: ?Sized,

§

fn and<P, B, E>(self, other: P) -> And<T, P>
where + T: Policy<B, E>, + P: Policy<B, E>,

Create a new Policy that returns [Action::Follow] only if self and other return +Action::Follow. Read more
§

fn or<P, B, E>(self, other: P) -> Or<T, P>
where + T: Policy<B, E>, + P: Policy<B, E>,

Create a new Policy that returns [Action::Follow] if either self or other returns +Action::Follow. Read more
Source§

impl<T> Same for T

Source§

type Output = T

Should always be Self
§

impl<T> ScopeObject for T
where + T: Send + Sync + Debug + DeserializeOwned + 'static,

§

type Error = Error

The error type.
§

fn deserialize<R>( + _app: &AppHandle<R>, + raw: Value, +) -> Result<T, <T as ScopeObject>::Error>
where + R: Runtime,

Deserialize the raw scope value.
Source§

impl<T> Serialize for T
where + T: Serialize + ?Sized,

Source§

fn erased_serialize(&self, serializer: &mut dyn Serializer) -> Result<(), Error>

Source§

fn do_erased_serialize( + &self, + serializer: &mut dyn Serializer, +) -> Result<(), ErrorImpl>

Source§

impl<T> ToOwned for T
where + T: Clone,

Source§

type Owned = T

The resulting type after obtaining ownership.
Source§

fn to_owned(&self) -> T

Creates owned data from borrowed data, usually by cloning. Read more
Source§

fn clone_into(&self, target: &mut T)

Uses borrowed data to replace owned data, usually by cloning. Read more
Source§

impl<T, U> TryFrom<U> for T
where + U: Into<T>,

Source§

type Error = Infallible

The type returned in the event of a conversion error.
Source§

fn try_from(value: U) -> Result<T, <T as TryFrom<U>>::Error>

Performs the conversion.
Source§

impl<T, U> TryInto<U> for T
where + U: TryFrom<T>,

Source§

type Error = <U as TryFrom<T>>::Error

The type returned in the event of a conversion error.
Source§

fn try_into(self) -> Result<U, <U as TryFrom<T>>::Error>

Performs the conversion.
§

impl<V, T> VZip<V> for T
where + V: MultiLane<T>,

§

fn vzip(self) -> V

§

impl<T> WithSubscriber for T

§

fn with_subscriber<S>(self, subscriber: S) -> WithDispatch<Self>
where + S: Into<Dispatch>,

Attaches the provided Subscriber to this type, returning a +[WithDispatch] wrapper. Read more
§

fn with_current_subscriber(self) -> WithDispatch<Self>

Attaches the current default Subscriber to this type, returning a +[WithDispatch] wrapper. Read more
Source§

impl<T> DeserializeOwned for T
where + T: for<'de> Deserialize<'de>,

§

impl<T> UserEvent for T
where + T: Debug + Clone + Send + 'static,

\ No newline at end of file diff --git a/api/jellytau_lib/domain/media/index.html b/api/jellytau_lib/domain/media/index.html new file mode 100644 index 00000000..361b9154 --- /dev/null +++ b/api/jellytau_lib/domain/media/index.html @@ -0,0 +1,12 @@ +jellytau_lib::domain::media - RustSkip to main content

Module media

Module media 

Source
Expand description

Canonical, provider-neutral media domain model.

+

This is the single source of truth for what a media item is across the +whole app. Rust (repositories, player, downloads) uses these types directly; +the frontend consumes the tauri-specta-generated projection in +src/lib/api/bindings.ts. There is no second hand-written copy in either +language, so the model cannot drift.

+

No provider (Jellyfin) vocabulary belongs in this file. Translation from a +provider’s wire shape lives beside it in from_jellyfin.rs and is the only +place provider terms touch the domain type.

+

Spec: docs/specs/frontend-domain-model.md

+

Enums§

MediaKind
The kind of a media item — provider-neutral classification.
StreamKind
The kind of a media stream within an item (audio track, video track, +subtitle, …) — provider-neutral, replacing the stringly Jellyfin stream type.
\ No newline at end of file diff --git a/api/jellytau_lib/domain/media/sidebar-items.js b/api/jellytau_lib/domain/media/sidebar-items.js new file mode 100644 index 00000000..95561b52 --- /dev/null +++ b/api/jellytau_lib/domain/media/sidebar-items.js @@ -0,0 +1 @@ +window.SIDEBAR_ITEMS = {"enum":["MediaKind","StreamKind"]}; \ No newline at end of file diff --git a/api/jellytau_lib/domain/search_rank/enum.MatchQuality.html b/api/jellytau_lib/domain/search_rank/enum.MatchQuality.html new file mode 100644 index 00000000..2c729e62 --- /dev/null +++ b/api/jellytau_lib/domain/search_rank/enum.MatchQuality.html @@ -0,0 +1,57 @@ +MatchQuality in jellytau_lib::domain::search_rank - RustSkip to main content

MatchQuality

Enum MatchQuality 

Source
pub enum MatchQuality {
+    Prefix,
+    WordStart,
+    Substring,
+    None,
+}
Expand description

How well a query matched an item’s name — better matches sort first.

+

Ordered by discriminant: Prefix is the strongest. Derived Ord gives the +comparison for free, so adding a tier in the right position is all it takes.

+

Variants§

§

Prefix

The name starts with the query — “parks” in “Parks and Recreation”.

+
§

WordStart

Some later word starts with the query — “recreation” in “Parks and +Recreation”. Still a deliberate hit: users type whole words.

+
§

Substring

The query appears mid-word — “parks” in “Sparks of Love”. Weakest hit +that still counts as a match.

+
§

None

No match on the name at all. The backend returned it for some other +reason (overview, artist, album), so it is kept but sorted last.

+

Trait Implementations§

Source§

impl Clone for MatchQuality

Source§

fn clone(&self) -> MatchQuality

Returns a duplicate of the value. Read more
1.0.0 (const: unstable) · Source§

fn clone_from(&mut self, source: &Self)

Performs copy-assignment from source. Read more
Source§

impl Debug for MatchQuality

Source§

fn fmt(&self, f: &mut Formatter<'_>) -> Result

Formats the value using the given formatter. Read more
Source§

impl Ord for MatchQuality

Source§

fn cmp(&self, other: &MatchQuality) -> Ordering

This method returns an Ordering between self and other. Read more
1.21.0 (const: unstable) · Source§

fn max(self, other: Self) -> Self
where + Self: Sized,

Compares and returns the maximum of two values. Read more
1.21.0 (const: unstable) · Source§

fn min(self, other: Self) -> Self
where + Self: Sized,

Compares and returns the minimum of two values. Read more
1.50.0 (const: unstable) · Source§

fn clamp(self, min: Self, max: Self) -> Self
where + Self: Sized,

Restrict a value to a certain interval. Read more
Source§

impl PartialEq for MatchQuality

Source§

fn eq(&self, other: &MatchQuality) -> bool

Tests for self and other values to be equal, and is used by ==.
1.0.0 (const: unstable) · Source§

fn ne(&self, other: &Rhs) -> bool

Tests for !=. The default implementation is almost always sufficient, +and should not be overridden without very good reason.
Source§

impl PartialOrd for MatchQuality

Source§

fn partial_cmp(&self, other: &MatchQuality) -> Option<Ordering>

This method returns an ordering between self and other values if one exists. Read more
1.0.0 (const: unstable) · Source§

fn lt(&self, other: &Rhs) -> bool

Tests less than (for self and other) and is used by the < operator. Read more
1.0.0 (const: unstable) · Source§

fn le(&self, other: &Rhs) -> bool

Tests less than or equal to (for self and other) and is used by the +<= operator. Read more
1.0.0 (const: unstable) · Source§

fn gt(&self, other: &Rhs) -> bool

Tests greater than (for self and other) and is used by the > +operator. Read more
1.0.0 (const: unstable) · Source§

fn ge(&self, other: &Rhs) -> bool

Tests greater than or equal to (for self and other) and is used by +the >= operator. Read more
Source§

impl Copy for MatchQuality

Source§

impl Eq for MatchQuality

Source§

impl StructuralPartialEq for MatchQuality

Auto Trait Implementations§

Blanket Implementations§

Source§

impl<T> Any for T
where + T: 'static + ?Sized,

Source§

fn type_id(&self) -> TypeId

Gets the TypeId of self. Read more
Source§

impl<T> Borrow<T> for T
where + T: ?Sized,

Source§

fn borrow(&self) -> &T

Immutably borrows from an owned value. Read more
Source§

impl<T> BorrowMut<T> for T
where + T: ?Sized,

Source§

fn borrow_mut(&mut self) -> &mut T

Mutably borrows from an owned value. Read more
Source§

impl<T> CloneToUninit for T
where + T: Clone,

Source§

unsafe fn clone_to_uninit(&self, dest: *mut u8)

🔬This is a nightly-only experimental API. (clone_to_uninit)
Performs copy-assignment from self to dest. Read more
§

impl<Q, K> Comparable<K> for Q
where + Q: Ord + ?Sized, + K: Borrow<Q> + ?Sized,

§

fn compare(&self, key: &K) -> Ordering

Compare self to key and return their ordering.
§

impl<Q, K> Equivalent<K> for Q
where + Q: Eq + ?Sized, + K: Borrow<Q> + ?Sized,

§

fn equivalent(&self, key: &K) -> bool

Checks if this value is equivalent to the given key. Read more
§

impl<Q, K> Equivalent<K> for Q
where + Q: Eq + ?Sized, + K: Borrow<Q> + ?Sized,

§

fn equivalent(&self, key: &K) -> bool

Compare self to key and return true if they are equal.
§

impl<Q, K> Equivalent<K> for Q
where + Q: Eq + ?Sized, + K: Borrow<Q> + ?Sized,

§

fn equivalent(&self, key: &K) -> bool

Checks if this value is equivalent to the given key. Read more
Source§

impl<T> From<T> for T

Source§

fn from(t: T) -> T

Returns the argument unchanged.

+
§

impl<T> Instrument for T

§

fn instrument(self, span: Span) -> Instrumented<Self>

Instruments this type with the provided [Span], returning an +Instrumented wrapper. Read more
§

fn in_current_span(self) -> Instrumented<Self>

Instruments this type with the current Span, returning an +Instrumented wrapper. Read more
Source§

impl<T, U> Into<U> for T
where + U: From<T>,

Source§

fn into(self) -> U

Calls U::from(self).

+

That is, this conversion is whatever the implementation of +From<T> for U chooses to do.

+
§

impl<T> PolicyExt for T
where + T: ?Sized,

§

fn and<P, B, E>(self, other: P) -> And<T, P>
where + T: Policy<B, E>, + P: Policy<B, E>,

Create a new Policy that returns [Action::Follow] only if self and other return +Action::Follow. Read more
§

fn or<P, B, E>(self, other: P) -> Or<T, P>
where + T: Policy<B, E>, + P: Policy<B, E>,

Create a new Policy that returns [Action::Follow] if either self or other returns +Action::Follow. Read more
Source§

impl<T> Same for T

Source§

type Output = T

Should always be Self
Source§

impl<T> ToOwned for T
where + T: Clone,

Source§

type Owned = T

The resulting type after obtaining ownership.
Source§

fn to_owned(&self) -> T

Creates owned data from borrowed data, usually by cloning. Read more
Source§

fn clone_into(&self, target: &mut T)

Uses borrowed data to replace owned data, usually by cloning. Read more
Source§

impl<T, U> TryFrom<U> for T
where + U: Into<T>,

Source§

type Error = Infallible

The type returned in the event of a conversion error.
Source§

fn try_from(value: U) -> Result<T, <T as TryFrom<U>>::Error>

Performs the conversion.
Source§

impl<T, U> TryInto<U> for T
where + U: TryFrom<T>,

Source§

type Error = <U as TryFrom<T>>::Error

The type returned in the event of a conversion error.
Source§

fn try_into(self) -> Result<U, <U as TryFrom<T>>::Error>

Performs the conversion.
§

impl<V, T> VZip<V> for T
where + V: MultiLane<T>,

§

fn vzip(self) -> V

§

impl<T> WithSubscriber for T

§

fn with_subscriber<S>(self, subscriber: S) -> WithDispatch<Self>
where + S: Into<Dispatch>,

Attaches the provided Subscriber to this type, returning a +[WithDispatch] wrapper. Read more
§

fn with_current_subscriber(self) -> WithDispatch<Self>

Attaches the current default Subscriber to this type, returning a +[WithDispatch] wrapper. Read more
§

impl<T> UserEvent for T
where + T: Debug + Clone + Send + 'static,

\ No newline at end of file diff --git a/api/jellytau_lib/domain/search_rank/fn.kind_rank.html b/api/jellytau_lib/domain/search_rank/fn.kind_rank.html new file mode 100644 index 00000000..0c737af6 --- /dev/null +++ b/api/jellytau_lib/domain/search_rank/fn.kind_rank.html @@ -0,0 +1,5 @@ +kind_rank in jellytau_lib::domain::search_rank - RustSkip to main content

kind_rank

Function kind_rank 

Source
fn kind_rank(kind: MediaKind) -> u8
Expand description

Rank of a media kind when match quality ties — lower sorts first.

+

Containers outrank the items they contain: searching a show’s name should +surface the show, not an arbitrary episode of it. Within a tier the order is +arbitrary but stable, and equal ranks fall through to input order.

+
\ No newline at end of file diff --git a/api/jellytau_lib/domain/search_rank/fn.match_quality.html b/api/jellytau_lib/domain/search_rank/fn.match_quality.html new file mode 100644 index 00000000..1a26d38c --- /dev/null +++ b/api/jellytau_lib/domain/search_rank/fn.match_quality.html @@ -0,0 +1,4 @@ +match_quality in jellytau_lib::domain::search_rank - RustSkip to main content

match_quality

Function match_quality 

Source
pub fn match_quality(name: &str, query: &str) -> MatchQuality
Expand description

Classify how query matches name, case-insensitively.

+

Both sides are trimmed and lowercased; an empty query matches everything +equally (Prefix), which leaves the input order untouched.

+
\ No newline at end of file diff --git a/api/jellytau_lib/domain/search_rank/fn.rank_search_results.html b/api/jellytau_lib/domain/search_rank/fn.rank_search_results.html new file mode 100644 index 00000000..6e0ff477 --- /dev/null +++ b/api/jellytau_lib/domain/search_rank/fn.rank_search_results.html @@ -0,0 +1,5 @@ +rank_search_results in jellytau_lib::domain::search_rank - RustSkip to main content

rank_search_results

Function rank_search_results 

Source
pub fn rank_search_results(items: &mut [MediaItem], query: &str)
Expand description

Sort search results by relevance to query, in place.

+

Stable, so items the rules rank equally keep the order the backend supplied +(FTS rank for cache hits, Jellyfin’s own ordering for server hits).

+

TRACES: UR-060 | DR-090

+
\ No newline at end of file diff --git a/api/jellytau_lib/domain/search_rank/index.html b/api/jellytau_lib/domain/search_rank/index.html new file mode 100644 index 00000000..6404222c --- /dev/null +++ b/api/jellytau_lib/domain/search_rank/index.html @@ -0,0 +1,20 @@ +jellytau_lib::domain::search_rank - RustSkip to main content

Module search_rank

Module search_rank 

Source
Expand description

Relevance ranking for search results.

+

Both search paths (the SQLite FTS cache and the Jellyfin server) return items +in an order that ignores where in the name the query matched: a server +substring hit like “Sparks of Love” can outrank “Parks and Recreation” for +the query “parks”. Neither backend is going to change, so the app imposes its +own ordering on the union.

+

Ranking is domain logic, not presentation: it encodes what a “better match” +means and which media kinds outrank which. The frontend only renders the +order it is given.

+

Two rules, in priority order:

+
    +
  1. Match position — a prefix match beats a word-start match, which beats +a mid-word substring match. This is what makes “parks” find +“Parks and Recreation” before “Sparks of Love”.
  2. +
  3. Kind — containers before their contents at equal match quality, so a +series outranks its own episodes.
  4. +
+

Ties fall back to the input order, so a backend’s own relevance signal (FTS +rank) still breaks ties it was never overruled on.

+

Enums§

MatchQuality
How well a query matched an item’s name — better matches sort first.

Functions§

kind_rank 🔒
Rank of a media kind when match quality ties — lower sorts first.
match_quality
Classify how query matches name, case-insensitively.
rank_search_results
Sort search results by relevance to query, in place.
\ No newline at end of file diff --git a/api/jellytau_lib/domain/search_rank/sidebar-items.js b/api/jellytau_lib/domain/search_rank/sidebar-items.js new file mode 100644 index 00000000..305c4259 --- /dev/null +++ b/api/jellytau_lib/domain/search_rank/sidebar-items.js @@ -0,0 +1 @@ +window.SIDEBAR_ITEMS = {"enum":["MatchQuality"],"fn":["kind_rank","match_quality","rank_search_results"]}; \ No newline at end of file diff --git a/api/jellytau_lib/domain/sidebar-items.js b/api/jellytau_lib/domain/sidebar-items.js new file mode 100644 index 00000000..dca7a116 --- /dev/null +++ b/api/jellytau_lib/domain/sidebar-items.js @@ -0,0 +1 @@ +window.SIDEBAR_ITEMS = {"mod":["from_jellyfin","media","search_rank"]}; \ No newline at end of file diff --git a/api/jellytau_lib/download/cache/index.html b/api/jellytau_lib/download/cache/index.html new file mode 100644 index 00000000..67759ae7 --- /dev/null +++ b/api/jellytau_lib/download/cache/index.html @@ -0,0 +1,2 @@ +jellytau_lib::download::cache - RustSkip to main content

Module cache

Module cache 

Source
Expand description

Smart caching engine for predictive downloads

+

Structs§

CacheConfig
Smart caching configuration
SmartCache
Smart caching engine
\ No newline at end of file diff --git a/api/jellytau_lib/download/cache/sidebar-items.js b/api/jellytau_lib/download/cache/sidebar-items.js new file mode 100644 index 00000000..fab84d4f --- /dev/null +++ b/api/jellytau_lib/download/cache/sidebar-items.js @@ -0,0 +1 @@ +window.SIDEBAR_ITEMS = {"struct":["CacheConfig","SmartCache"]}; \ No newline at end of file diff --git a/api/jellytau_lib/download/cache/struct.CacheConfig.html b/api/jellytau_lib/download/cache/struct.CacheConfig.html new file mode 100644 index 00000000..959a762c --- /dev/null +++ b/api/jellytau_lib/download/cache/struct.CacheConfig.html @@ -0,0 +1,69 @@ +CacheConfig in jellytau_lib::download::cache - RustSkip to main content

CacheConfig

Struct CacheConfig 

Source
pub struct CacheConfig {
+    pub queue_precache_enabled: bool,
+    pub queue_precache_count: usize,
+    pub album_affinity_enabled: bool,
+    pub album_affinity_threshold: usize,
+    pub storage_limit: u64,
+    pub wifi_only: bool,
+    pub temporary_ttl_hours: u64,
+}
Expand description

Smart caching configuration

+

Fields§

§queue_precache_enabled: bool

Enable queue pre-caching

+
§queue_precache_count: usize

Number of tracks to pre-cache from queue

+
§album_affinity_enabled: bool

Enable album affinity detection

+
§album_affinity_threshold: usize

Threshold for album affinity (tracks played before caching)

+
§storage_limit: u64

Storage limit in bytes (0 = unlimited)

+
§wifi_only: bool

Only cache on WiFi

+
§temporary_ttl_hours: u64

How long a temporary (download_source = 'auto') download lives before +it is reclaimed, in hours. 0 disables expiry, leaving space pressure as +the only reclaim trigger.

+

TRACES: UR-071 | DR-127

+

Trait Implementations§

Source§

impl Clone for CacheConfig

Source§

fn clone(&self) -> CacheConfig

Returns a duplicate of the value. Read more
1.0.0 (const: unstable) · Source§

fn clone_from(&mut self, source: &Self)

Performs copy-assignment from source. Read more
Source§

impl Debug for CacheConfig

Source§

fn fmt(&self, f: &mut Formatter<'_>) -> Result

Formats the value using the given formatter. Read more
Source§

impl Default for CacheConfig

Source§

fn default() -> Self

Returns the “default value” for a type. Read more
Source§

impl<'de> Deserialize<'de> for CacheConfig

Source§

fn deserialize<__D>(__deserializer: __D) -> Result<Self, __D::Error>
where + __D: Deserializer<'de>,

Deserialize this value from the given Serde deserializer. Read more
Source§

impl NamedType for CacheConfig

Source§

fn sid() -> SpectaID

Source§

fn named_data_type( + type_map: &mut TypeCollection, + generics: &[DataType], +) -> NamedDataType

this is equivalent to [Type::inline] but returns a [NamedDataType] instead.
Source§

fn definition_named_data_type(type_map: &mut TypeCollection) -> NamedDataType

this is equivalent to [Type::definition] but returns a [NamedDataType] instead.
Source§

impl Serialize for CacheConfig

Source§

fn serialize<__S>(&self, __serializer: __S) -> Result<__S::Ok, __S::Error>
where + __S: Serializer,

Serialize this value into the given Serde serializer. Read more
Source§

impl Type for CacheConfig

Source§

fn inline(type_map: &mut TypeCollection, generics: Generics<'_>) -> DataType

Returns the definition of a type using the provided generics. Read more
Source§

fn reference(type_map: &mut TypeCollection, generics: &[DataType]) -> Reference

Generates a datatype corresponding to a reference to this type, +as determined by its category. Getting a reference to a type implies that +it should belong in the type map (since it has to be referenced from somewhere), +so the output of definition will be put into the type map.
Source§

impl Flatten for CacheConfig

Auto Trait Implementations§

Blanket Implementations§

Source§

impl<T> Any for T
where + T: 'static + ?Sized,

Source§

fn type_id(&self) -> TypeId

Gets the TypeId of self. Read more
Source§

impl<T> Borrow<T> for T
where + T: ?Sized,

Source§

fn borrow(&self) -> &T

Immutably borrows from an owned value. Read more
Source§

impl<T> BorrowMut<T> for T
where + T: ?Sized,

Source§

fn borrow_mut(&mut self) -> &mut T

Mutably borrows from an owned value. Read more
Source§

impl<T> CloneToUninit for T
where + T: Clone,

Source§

unsafe fn clone_to_uninit(&self, dest: *mut u8)

🔬This is a nightly-only experimental API. (clone_to_uninit)
Performs copy-assignment from self to dest. Read more
§

impl<'de, D, R> CommandArg<'de, R> for D
where + D: Deserialize<'de>, + R: Runtime,

§

fn from_command(command: CommandItem<'de, R>) -> Result<D, InvokeError>

Derives an instance of Self from the [CommandItem]. Read more
Source§

impl<T> From<T> for T

Source§

fn from(t: T) -> T

Returns the argument unchanged.

+
§

impl<T> FunctionArg for T
where + T: Type,

§

fn to_datatype(type_map: &mut TypeCollection) -> Option<DataType>

Gets the type of an argument as a [DataType]. Read more
§

impl<T> Instrument for T

§

fn instrument(self, span: Span) -> Instrumented<Self>

Instruments this type with the provided [Span], returning an +Instrumented wrapper. Read more
§

fn in_current_span(self) -> Instrumented<Self>

Instruments this type with the current Span, returning an +Instrumented wrapper. Read more
Source§

impl<T, U> Into<U> for T
where + U: From<T>,

Source§

fn into(self) -> U

Calls U::from(self).

+

That is, this conversion is whatever the implementation of +From<T> for U chooses to do.

+
§

impl<T> IpcResponse for T
where + T: Serialize,

§

fn body(self) -> Result<InvokeResponseBody, Error>

Resolve the IPC response body.
§

impl<T> NoneValue for T
where + T: Default,

§

type NoneType = T

§

fn null_value() -> T

The none-equivalent value.
§

impl<T> PolicyExt for T
where + T: ?Sized,

§

fn and<P, B, E>(self, other: P) -> And<T, P>
where + T: Policy<B, E>, + P: Policy<B, E>,

Create a new Policy that returns [Action::Follow] only if self and other return +Action::Follow. Read more
§

fn or<P, B, E>(self, other: P) -> Or<T, P>
where + T: Policy<B, E>, + P: Policy<B, E>,

Create a new Policy that returns [Action::Follow] if either self or other returns +Action::Follow. Read more
Source§

impl<T> Same for T

Source§

type Output = T

Should always be Self
§

impl<T> ScopeObject for T
where + T: Send + Sync + Debug + DeserializeOwned + 'static,

§

type Error = Error

The error type.
§

fn deserialize<R>( + _app: &AppHandle<R>, + raw: Value, +) -> Result<T, <T as ScopeObject>::Error>
where + R: Runtime,

Deserialize the raw scope value.
Source§

impl<T> Serialize for T
where + T: Serialize + ?Sized,

Source§

fn erased_serialize(&self, serializer: &mut dyn Serializer) -> Result<(), Error>

Source§

fn do_erased_serialize( + &self, + serializer: &mut dyn Serializer, +) -> Result<(), ErrorImpl>

Source§

impl<T> ToOwned for T
where + T: Clone,

Source§

type Owned = T

The resulting type after obtaining ownership.
Source§

fn to_owned(&self) -> T

Creates owned data from borrowed data, usually by cloning. Read more
Source§

fn clone_into(&self, target: &mut T)

Uses borrowed data to replace owned data, usually by cloning. Read more
Source§

impl<T, U> TryFrom<U> for T
where + U: Into<T>,

Source§

type Error = Infallible

The type returned in the event of a conversion error.
Source§

fn try_from(value: U) -> Result<T, <T as TryFrom<U>>::Error>

Performs the conversion.
Source§

impl<T, U> TryInto<U> for T
where + U: TryFrom<T>,

Source§

type Error = <U as TryFrom<T>>::Error

The type returned in the event of a conversion error.
Source§

fn try_into(self) -> Result<U, <U as TryFrom<T>>::Error>

Performs the conversion.
§

impl<V, T> VZip<V> for T
where + V: MultiLane<T>,

§

fn vzip(self) -> V

§

impl<T> WithSubscriber for T

§

fn with_subscriber<S>(self, subscriber: S) -> WithDispatch<Self>
where + S: Into<Dispatch>,

Attaches the provided Subscriber to this type, returning a +[WithDispatch] wrapper. Read more
§

fn with_current_subscriber(self) -> WithDispatch<Self>

Attaches the current default Subscriber to this type, returning a +[WithDispatch] wrapper. Read more
Source§

impl<T> DeserializeOwned for T
where + T: for<'de> Deserialize<'de>,

§

impl<T> UserEvent for T
where + T: Debug + Clone + Send + 'static,

\ No newline at end of file diff --git a/api/jellytau_lib/download/cache/struct.SmartCache.html b/api/jellytau_lib/download/cache/struct.SmartCache.html new file mode 100644 index 00000000..0292b21d --- /dev/null +++ b/api/jellytau_lib/download/cache/struct.SmartCache.html @@ -0,0 +1,89 @@ +SmartCache in jellytau_lib::download::cache - RustSkip to main content

SmartCache

Struct SmartCache 

Source
pub struct SmartCache {
+    config: Arc<Mutex<CacheConfig>>,
+    album_play_history: Arc<Mutex<HashMap<String, Vec<String>>>>,
+}
Expand description

Smart caching engine

+

Fields§

§config: Arc<Mutex<CacheConfig>>§album_play_history: Arc<Mutex<HashMap<String, Vec<String>>>>

Track recently played items per album

+

Implementations§

Source§

impl SmartCache

Source

pub fn new(config: CacheConfig) -> Self

Source

pub fn update_config(&self, config: CacheConfig)

Update configuration

+
Source

pub fn should_precache_queue(&self) -> bool

Check if should pre-cache queue items.

+

Note this deliberately does NOT consult wifi_only. It used to return +queue_precache_enabled && !wifi_only, which disabled precaching +outright whenever the user enabled WiFi-only — regardless of the network +actually in use. The network check now lives in the download queue pump +(downloads_allowed_on_current_network), which is the single gate for +all download traffic, so this only answers “is precaching enabled?”.

+

TRACES: UR-053 | DR-074

+
Source

pub fn queue_precache_count(&self) -> usize

Get number of queue items to pre-cache

+
Source

pub fn track_play(&self, item_id: &str, album_id: Option<&str>)

Track that an item was played

+
Source

pub fn should_cache_album(&self, album_id: &str) -> Option<bool>

Check if album affinity threshold reached for caching

+
Source

pub fn get_config(&self) -> Option<CacheConfig>

Get configuration

+
Source

pub fn get_album_play_history(&self) -> Vec<(String, usize)>

Get all tracked albums with their play counts +Returns Vec<(album_id, unique_tracks_played)>

+
Source

pub async fn get_total_download_size_async<S: DatabaseService>( + &self, + db_service: &Arc<S>, + user_id: &str, +) -> Result<u64, String>

Get total download size for a user (async version)

+
Source

pub async fn can_download_async<S: DatabaseService>( + &self, + db_service: &Arc<S>, + user_id: &str, + new_size: u64, +) -> bool

Check if storage limit allows download (async version)

+
Source

pub async fn reclaim_expired_async<S: DatabaseService>( + &self, + db_service: &Arc<S>, + user_id: &str, + now: &str, +) -> Result<usize, String>

Reclaim temporary downloads whose life limit has passed.

+

The time-based half of the temporary tier (DR-127); [evict_lru_async] +is the space-pressure half. A row is reclaimed by whichever fires first.

+

Scoped to download_source = 'auto' for the same reason eviction is: a +'user' row is someone’s own download and has no expiry. COALESCE +guards rows predating migration 012, whose source is NULL and whose +provenance must therefore be treated as the user’s.

+

Expiry is normally derivedcompleted_at plus the configured TTL — +rather than stamped at completion. That means a TTL change applies to +entries already on disk instead of only to future ones, and entries +predating the column expire without a backfill. expires_at is honoured +as a per-row override when something sets it.

+

now is passed in rather than read from the clock so the policy is +testable without sleeping. Both sides go through SQLite’s datetime() +because completed_at is written as CURRENT_TIMESTAMP +(YYYY-MM-DD HH:MM:SS) while callers pass RFC-3339 (…T…+00:00) — a raw +string comparison between the two formats is wrong, since ' ' < 'T'.

+

Returns the number of entries reclaimed.

+

TRACES: UR-071 | DR-127 | UT-120

+
Source

pub async fn evict_lru_async<S: DatabaseService>( + &self, + db_service: &Arc<S>, + user_id: &str, + space_needed: u64, +) -> Result<u64, String>

Evict least recently used items to make space (async version).

+

The space-pressure half of the temporary tier; [reclaim_expired_async] +is the time-based half.

+

Trait Implementations§

Source§

impl Clone for SmartCache

Source§

fn clone(&self) -> SmartCache

Returns a duplicate of the value. Read more
1.0.0 (const: unstable) · Source§

fn clone_from(&mut self, source: &Self)

Performs copy-assignment from source. Read more

Auto Trait Implementations§

Blanket Implementations§

Source§

impl<T> Any for T
where + T: 'static + ?Sized,

Source§

fn type_id(&self) -> TypeId

Gets the TypeId of self. Read more
Source§

impl<T> Borrow<T> for T
where + T: ?Sized,

Source§

fn borrow(&self) -> &T

Immutably borrows from an owned value. Read more
Source§

impl<T> BorrowMut<T> for T
where + T: ?Sized,

Source§

fn borrow_mut(&mut self) -> &mut T

Mutably borrows from an owned value. Read more
Source§

impl<T> CloneToUninit for T
where + T: Clone,

Source§

unsafe fn clone_to_uninit(&self, dest: *mut u8)

🔬This is a nightly-only experimental API. (clone_to_uninit)
Performs copy-assignment from self to dest. Read more
Source§

impl<T> From<T> for T

Source§

fn from(t: T) -> T

Returns the argument unchanged.

+
§

impl<T> Instrument for T

§

fn instrument(self, span: Span) -> Instrumented<Self>

Instruments this type with the provided [Span], returning an +Instrumented wrapper. Read more
§

fn in_current_span(self) -> Instrumented<Self>

Instruments this type with the current Span, returning an +Instrumented wrapper. Read more
Source§

impl<T, U> Into<U> for T
where + U: From<T>,

Source§

fn into(self) -> U

Calls U::from(self).

+

That is, this conversion is whatever the implementation of +From<T> for U chooses to do.

+
§

impl<T> PolicyExt for T
where + T: ?Sized,

§

fn and<P, B, E>(self, other: P) -> And<T, P>
where + T: Policy<B, E>, + P: Policy<B, E>,

Create a new Policy that returns [Action::Follow] only if self and other return +Action::Follow. Read more
§

fn or<P, B, E>(self, other: P) -> Or<T, P>
where + T: Policy<B, E>, + P: Policy<B, E>,

Create a new Policy that returns [Action::Follow] if either self or other returns +Action::Follow. Read more
Source§

impl<T> Same for T

Source§

type Output = T

Should always be Self
Source§

impl<T> ToOwned for T
where + T: Clone,

Source§

type Owned = T

The resulting type after obtaining ownership.
Source§

fn to_owned(&self) -> T

Creates owned data from borrowed data, usually by cloning. Read more
Source§

fn clone_into(&self, target: &mut T)

Uses borrowed data to replace owned data, usually by cloning. Read more
Source§

impl<T, U> TryFrom<U> for T
where + U: Into<T>,

Source§

type Error = Infallible

The type returned in the event of a conversion error.
Source§

fn try_from(value: U) -> Result<T, <T as TryFrom<U>>::Error>

Performs the conversion.
Source§

impl<T, U> TryInto<U> for T
where + U: TryFrom<T>,

Source§

type Error = <U as TryFrom<T>>::Error

The type returned in the event of a conversion error.
Source§

fn try_into(self) -> Result<U, <U as TryFrom<T>>::Error>

Performs the conversion.
§

impl<V, T> VZip<V> for T
where + V: MultiLane<T>,

§

fn vzip(self) -> V

§

impl<T> WithSubscriber for T

§

fn with_subscriber<S>(self, subscriber: S) -> WithDispatch<Self>
where + S: Into<Dispatch>,

Attaches the provided Subscriber to this type, returning a +[WithDispatch] wrapper. Read more
§

fn with_current_subscriber(self) -> WithDispatch<Self>

Attaches the current default Subscriber to this type, returning a +[WithDispatch] wrapper. Read more
\ No newline at end of file diff --git a/api/jellytau_lib/download/events/enum.DownloadEvent.html b/api/jellytau_lib/download/events/enum.DownloadEvent.html new file mode 100644 index 00000000..ecbc6d7c --- /dev/null +++ b/api/jellytau_lib/download/events/enum.DownloadEvent.html @@ -0,0 +1,88 @@ +DownloadEvent in jellytau_lib::download::events - RustSkip to main content

DownloadEvent

Enum DownloadEvent 

Source
pub enum DownloadEvent {
+    Queued {
+        download_id: i64,
+        item_id: String,
+    },
+    Started {
+        download_id: i64,
+        item_id: String,
+    },
+    Progress {
+        download_id: i64,
+        item_id: String,
+        bytes_downloaded: i64,
+        total_bytes: Option<i64>,
+        progress: f64,
+    },
+    Completed {
+        download_id: i64,
+        item_id: String,
+        file_path: String,
+    },
+    Failed {
+        download_id: i64,
+        item_id: String,
+        error: String,
+    },
+    Paused {
+        download_id: i64,
+        item_id: String,
+    },
+    Cancelled {
+        download_id: i64,
+        item_id: String,
+    },
+    WaitingForNetwork,
+}
Expand description

Events emitted during download operations

+

Variants§

§

Queued

Download has been queued

+

Fields

§download_id: i64
§item_id: String
§

Started

Download has started

+

Fields

§download_id: i64
§item_id: String
§

Progress

Download progress update

+

Fields

§download_id: i64
§item_id: String
§bytes_downloaded: i64
§total_bytes: Option<i64>
§progress: f64
§

Completed

Download completed successfully

+

Fields

§download_id: i64
§item_id: String
§file_path: String
§

Failed

Download failed with error

+

Fields

§download_id: i64
§item_id: String
§error: String
§

Paused

Download paused

+

Fields

§download_id: i64
§item_id: String
§

Cancelled

Download cancelled

+

Fields

§download_id: i64
§item_id: String
§

WaitingForNetwork

The queue is holding: WiFi-only is enabled and the current network is +metered/cellular. Pending rows stay pending and resume on network change.

+

TRACES: UR-053 | DR-074

+

Trait Implementations§

Source§

impl Clone for DownloadEvent

Source§

fn clone(&self) -> DownloadEvent

Returns a duplicate of the value. Read more
1.0.0 (const: unstable) · Source§

fn clone_from(&mut self, source: &Self)

Performs copy-assignment from source. Read more
Source§

impl Debug for DownloadEvent

Source§

fn fmt(&self, f: &mut Formatter<'_>) -> Result

Formats the value using the given formatter. Read more
Source§

impl<'de> Deserialize<'de> for DownloadEvent

Source§

fn deserialize<__D>(__deserializer: __D) -> Result<Self, __D::Error>
where + __D: Deserializer<'de>,

Deserialize this value from the given Serde deserializer. Read more
Source§

impl Serialize for DownloadEvent

Source§

fn serialize<__S>(&self, __serializer: __S) -> Result<__S::Ok, __S::Error>
where + __S: Serializer,

Serialize this value into the given Serde serializer. Read more

Auto Trait Implementations§

Blanket Implementations§

Source§

impl<T> Any for T
where + T: 'static + ?Sized,

Source§

fn type_id(&self) -> TypeId

Gets the TypeId of self. Read more
Source§

impl<T> Borrow<T> for T
where + T: ?Sized,

Source§

fn borrow(&self) -> &T

Immutably borrows from an owned value. Read more
Source§

impl<T> BorrowMut<T> for T
where + T: ?Sized,

Source§

fn borrow_mut(&mut self) -> &mut T

Mutably borrows from an owned value. Read more
Source§

impl<T> CloneToUninit for T
where + T: Clone,

Source§

unsafe fn clone_to_uninit(&self, dest: *mut u8)

🔬This is a nightly-only experimental API. (clone_to_uninit)
Performs copy-assignment from self to dest. Read more
§

impl<'de, D, R> CommandArg<'de, R> for D
where + D: Deserialize<'de>, + R: Runtime,

§

fn from_command(command: CommandItem<'de, R>) -> Result<D, InvokeError>

Derives an instance of Self from the [CommandItem]. Read more
Source§

impl<T> From<T> for T

Source§

fn from(t: T) -> T

Returns the argument unchanged.

+
§

impl<T> Instrument for T

§

fn instrument(self, span: Span) -> Instrumented<Self>

Instruments this type with the provided [Span], returning an +Instrumented wrapper. Read more
§

fn in_current_span(self) -> Instrumented<Self>

Instruments this type with the current Span, returning an +Instrumented wrapper. Read more
Source§

impl<T, U> Into<U> for T
where + U: From<T>,

Source§

fn into(self) -> U

Calls U::from(self).

+

That is, this conversion is whatever the implementation of +From<T> for U chooses to do.

+
§

impl<T> IpcResponse for T
where + T: Serialize,

§

fn body(self) -> Result<InvokeResponseBody, Error>

Resolve the IPC response body.
§

impl<T> PolicyExt for T
where + T: ?Sized,

§

fn and<P, B, E>(self, other: P) -> And<T, P>
where + T: Policy<B, E>, + P: Policy<B, E>,

Create a new Policy that returns [Action::Follow] only if self and other return +Action::Follow. Read more
§

fn or<P, B, E>(self, other: P) -> Or<T, P>
where + T: Policy<B, E>, + P: Policy<B, E>,

Create a new Policy that returns [Action::Follow] if either self or other returns +Action::Follow. Read more
Source§

impl<T> Same for T

Source§

type Output = T

Should always be Self
§

impl<T> ScopeObject for T
where + T: Send + Sync + Debug + DeserializeOwned + 'static,

§

type Error = Error

The error type.
§

fn deserialize<R>( + _app: &AppHandle<R>, + raw: Value, +) -> Result<T, <T as ScopeObject>::Error>
where + R: Runtime,

Deserialize the raw scope value.
Source§

impl<T> Serialize for T
where + T: Serialize + ?Sized,

Source§

fn erased_serialize(&self, serializer: &mut dyn Serializer) -> Result<(), Error>

Source§

fn do_erased_serialize( + &self, + serializer: &mut dyn Serializer, +) -> Result<(), ErrorImpl>

Source§

impl<T> ToOwned for T
where + T: Clone,

Source§

type Owned = T

The resulting type after obtaining ownership.
Source§

fn to_owned(&self) -> T

Creates owned data from borrowed data, usually by cloning. Read more
Source§

fn clone_into(&self, target: &mut T)

Uses borrowed data to replace owned data, usually by cloning. Read more
Source§

impl<T, U> TryFrom<U> for T
where + U: Into<T>,

Source§

type Error = Infallible

The type returned in the event of a conversion error.
Source§

fn try_from(value: U) -> Result<T, <T as TryFrom<U>>::Error>

Performs the conversion.
Source§

impl<T, U> TryInto<U> for T
where + U: TryFrom<T>,

Source§

type Error = <U as TryFrom<T>>::Error

The type returned in the event of a conversion error.
Source§

fn try_into(self) -> Result<U, <U as TryFrom<T>>::Error>

Performs the conversion.
§

impl<V, T> VZip<V> for T
where + V: MultiLane<T>,

§

fn vzip(self) -> V

§

impl<T> WithSubscriber for T

§

fn with_subscriber<S>(self, subscriber: S) -> WithDispatch<Self>
where + S: Into<Dispatch>,

Attaches the provided Subscriber to this type, returning a +[WithDispatch] wrapper. Read more
§

fn with_current_subscriber(self) -> WithDispatch<Self>

Attaches the current default Subscriber to this type, returning a +[WithDispatch] wrapper. Read more
Source§

impl<T> DeserializeOwned for T
where + T: for<'de> Deserialize<'de>,

§

impl<T> UserEvent for T
where + T: Debug + Clone + Send + 'static,

\ No newline at end of file diff --git a/api/jellytau_lib/download/events/index.html b/api/jellytau_lib/download/events/index.html new file mode 100644 index 00000000..0401767e --- /dev/null +++ b/api/jellytau_lib/download/events/index.html @@ -0,0 +1,2 @@ +jellytau_lib::download::events - RustSkip to main content

Module events

Module events 

Source
Expand description

Download events for progress tracking and status updates

+

Enums§

DownloadEvent
Events emitted during download operations
\ No newline at end of file diff --git a/api/jellytau_lib/download/events/sidebar-items.js b/api/jellytau_lib/download/events/sidebar-items.js new file mode 100644 index 00000000..fb824287 --- /dev/null +++ b/api/jellytau_lib/download/events/sidebar-items.js @@ -0,0 +1 @@ +window.SIDEBAR_ITEMS = {"enum":["DownloadEvent"]}; \ No newline at end of file diff --git a/api/jellytau_lib/download/index.html b/api/jellytau_lib/download/index.html new file mode 100644 index 00000000..dbd7e86a --- /dev/null +++ b/api/jellytau_lib/download/index.html @@ -0,0 +1,9 @@ +jellytau_lib::download - RustSkip to main content

Module download

Module download 

Source
Expand description

Download manager for offline media support

+

This module handles downloading media from Jellyfin servers with:

+
    +
  • Priority-based queue management
  • +
  • Progress tracking and event emission
  • +
  • Retry logic with exponential backoff
  • +
  • Resume support via HTTP Range requests
  • +
+

Re-exports§

pub use worker::DownloadWorker;

Modules§

cache
Smart caching engine for predictive downloads
events
Download events for progress tracking and status updates
network
Network transport classification for the WiFi-only download gate.
stop
Stop signalling for in-flight downloads.
worker
Download worker for HTTP streaming with progress tracking and retry logic

Structs§

DownloadInfo
Information about a download
DownloadManager
Download manager coordinating downloads across workers
DownloadTask
Download task for workers
\ No newline at end of file diff --git a/api/jellytau_lib/download/network/enum.NetworkType.html b/api/jellytau_lib/download/network/enum.NetworkType.html new file mode 100644 index 00000000..02856548 --- /dev/null +++ b/api/jellytau_lib/download/network/enum.NetworkType.html @@ -0,0 +1,73 @@ +NetworkType in jellytau_lib::download::network - RustSkip to main content

NetworkType

Enum NetworkType 

Source
pub enum NetworkType {
+    None,
+    Wifi,
+    Ethernet,
+    Cellular,
+    Other,
+    Unknown,
+}
Expand description

Kind of network transport currently active.

+

Mirrors the string constants in NetworkTypeMonitor.kt; the two must stay +in sync (the serde rename below is what the frontend sends).

+

TRACES: UR-053 | DR-074

+

Variants§

§

None

No active network.

+
§

Wifi

WiFi (may still be metered — check unmetered).

+
§

Ethernet

Wired ethernet, typical on Android TV and desktop.

+
§

Cellular

Mobile data — never acceptable when wifi-only is enabled.

+
§

Other

Some other transport (VPN over unknown carrier, Bluetooth tethering, …).

+
§

Unknown

Could not determine the transport.

+

Trait Implementations§

Source§

impl Clone for NetworkType

Source§

fn clone(&self) -> NetworkType

Returns a duplicate of the value. Read more
1.0.0 (const: unstable) · Source§

fn clone_from(&mut self, source: &Self)

Performs copy-assignment from source. Read more
Source§

impl Debug for NetworkType

Source§

fn fmt(&self, f: &mut Formatter<'_>) -> Result

Formats the value using the given formatter. Read more
Source§

impl<'de> Deserialize<'de> for NetworkType

Source§

fn deserialize<__D>(__deserializer: __D) -> Result<Self, __D::Error>
where + __D: Deserializer<'de>,

Deserialize this value from the given Serde deserializer. Read more
Source§

impl NamedType for NetworkType

Source§

fn sid() -> SpectaID

Source§

fn named_data_type( + type_map: &mut TypeCollection, + generics: &[DataType], +) -> NamedDataType

this is equivalent to [Type::inline] but returns a [NamedDataType] instead.
Source§

fn definition_named_data_type(type_map: &mut TypeCollection) -> NamedDataType

this is equivalent to [Type::definition] but returns a [NamedDataType] instead.
Source§

impl PartialEq for NetworkType

Source§

fn eq(&self, other: &NetworkType) -> bool

Tests for self and other values to be equal, and is used by ==.
1.0.0 (const: unstable) · Source§

fn ne(&self, other: &Rhs) -> bool

Tests for !=. The default implementation is almost always sufficient, +and should not be overridden without very good reason.
Source§

impl Serialize for NetworkType

Source§

fn serialize<__S>(&self, __serializer: __S) -> Result<__S::Ok, __S::Error>
where + __S: Serializer,

Serialize this value into the given Serde serializer. Read more
Source§

impl Type for NetworkType

Source§

fn inline(type_map: &mut TypeCollection, generics: Generics<'_>) -> DataType

Returns the definition of a type using the provided generics. Read more
Source§

fn reference(type_map: &mut TypeCollection, generics: &[DataType]) -> Reference

Generates a datatype corresponding to a reference to this type, +as determined by its category. Getting a reference to a type implies that +it should belong in the type map (since it has to be referenced from somewhere), +so the output of definition will be put into the type map.
Source§

impl Copy for NetworkType

Source§

impl Eq for NetworkType

Source§

impl StructuralPartialEq for NetworkType

Auto Trait Implementations§

Blanket Implementations§

Source§

impl<T> Any for T
where + T: 'static + ?Sized,

Source§

fn type_id(&self) -> TypeId

Gets the TypeId of self. Read more
Source§

impl<T> Borrow<T> for T
where + T: ?Sized,

Source§

fn borrow(&self) -> &T

Immutably borrows from an owned value. Read more
Source§

impl<T> BorrowMut<T> for T
where + T: ?Sized,

Source§

fn borrow_mut(&mut self) -> &mut T

Mutably borrows from an owned value. Read more
Source§

impl<T> CloneToUninit for T
where + T: Clone,

Source§

unsafe fn clone_to_uninit(&self, dest: *mut u8)

🔬This is a nightly-only experimental API. (clone_to_uninit)
Performs copy-assignment from self to dest. Read more
§

impl<'de, D, R> CommandArg<'de, R> for D
where + D: Deserialize<'de>, + R: Runtime,

§

fn from_command(command: CommandItem<'de, R>) -> Result<D, InvokeError>

Derives an instance of Self from the [CommandItem]. Read more
§

impl<Q, K> Equivalent<K> for Q
where + Q: Eq + ?Sized, + K: Borrow<Q> + ?Sized,

§

fn equivalent(&self, key: &K) -> bool

Checks if this value is equivalent to the given key. Read more
§

impl<Q, K> Equivalent<K> for Q
where + Q: Eq + ?Sized, + K: Borrow<Q> + ?Sized,

§

fn equivalent(&self, key: &K) -> bool

Compare self to key and return true if they are equal.
§

impl<Q, K> Equivalent<K> for Q
where + Q: Eq + ?Sized, + K: Borrow<Q> + ?Sized,

§

fn equivalent(&self, key: &K) -> bool

Checks if this value is equivalent to the given key. Read more
Source§

impl<T> From<T> for T

Source§

fn from(t: T) -> T

Returns the argument unchanged.

+
§

impl<T> FunctionArg for T
where + T: Type,

§

fn to_datatype(type_map: &mut TypeCollection) -> Option<DataType>

Gets the type of an argument as a [DataType]. Read more
§

impl<T> Instrument for T

§

fn instrument(self, span: Span) -> Instrumented<Self>

Instruments this type with the provided [Span], returning an +Instrumented wrapper. Read more
§

fn in_current_span(self) -> Instrumented<Self>

Instruments this type with the current Span, returning an +Instrumented wrapper. Read more
Source§

impl<T, U> Into<U> for T
where + U: From<T>,

Source§

fn into(self) -> U

Calls U::from(self).

+

That is, this conversion is whatever the implementation of +From<T> for U chooses to do.

+
§

impl<T> IpcResponse for T
where + T: Serialize,

§

fn body(self) -> Result<InvokeResponseBody, Error>

Resolve the IPC response body.
§

impl<T> PolicyExt for T
where + T: ?Sized,

§

fn and<P, B, E>(self, other: P) -> And<T, P>
where + T: Policy<B, E>, + P: Policy<B, E>,

Create a new Policy that returns [Action::Follow] only if self and other return +Action::Follow. Read more
§

fn or<P, B, E>(self, other: P) -> Or<T, P>
where + T: Policy<B, E>, + P: Policy<B, E>,

Create a new Policy that returns [Action::Follow] if either self or other returns +Action::Follow. Read more
Source§

impl<T> Same for T

Source§

type Output = T

Should always be Self
§

impl<T> ScopeObject for T
where + T: Send + Sync + Debug + DeserializeOwned + 'static,

§

type Error = Error

The error type.
§

fn deserialize<R>( + _app: &AppHandle<R>, + raw: Value, +) -> Result<T, <T as ScopeObject>::Error>
where + R: Runtime,

Deserialize the raw scope value.
Source§

impl<T> Serialize for T
where + T: Serialize + ?Sized,

Source§

fn erased_serialize(&self, serializer: &mut dyn Serializer) -> Result<(), Error>

Source§

fn do_erased_serialize( + &self, + serializer: &mut dyn Serializer, +) -> Result<(), ErrorImpl>

Source§

impl<T> ToOwned for T
where + T: Clone,

Source§

type Owned = T

The resulting type after obtaining ownership.
Source§

fn to_owned(&self) -> T

Creates owned data from borrowed data, usually by cloning. Read more
Source§

fn clone_into(&self, target: &mut T)

Uses borrowed data to replace owned data, usually by cloning. Read more
Source§

impl<T, U> TryFrom<U> for T
where + U: Into<T>,

Source§

type Error = Infallible

The type returned in the event of a conversion error.
Source§

fn try_from(value: U) -> Result<T, <T as TryFrom<U>>::Error>

Performs the conversion.
Source§

impl<T, U> TryInto<U> for T
where + U: TryFrom<T>,

Source§

type Error = <U as TryFrom<T>>::Error

The type returned in the event of a conversion error.
Source§

fn try_into(self) -> Result<U, <U as TryFrom<T>>::Error>

Performs the conversion.
§

impl<V, T> VZip<V> for T
where + V: MultiLane<T>,

§

fn vzip(self) -> V

§

impl<T> WithSubscriber for T

§

fn with_subscriber<S>(self, subscriber: S) -> WithDispatch<Self>
where + S: Into<Dispatch>,

Attaches the provided Subscriber to this type, returning a +[WithDispatch] wrapper. Read more
§

fn with_current_subscriber(self) -> WithDispatch<Self>

Attaches the current default Subscriber to this type, returning a +[WithDispatch] wrapper. Read more
Source§

impl<T> DeserializeOwned for T
where + T: for<'de> Deserialize<'de>,

§

impl<T> UserEvent for T
where + T: Debug + Clone + Send + 'static,

\ No newline at end of file diff --git a/api/jellytau_lib/download/network/index.html b/api/jellytau_lib/download/network/index.html new file mode 100644 index 00000000..32d8b09b --- /dev/null +++ b/api/jellytau_lib/download/network/index.html @@ -0,0 +1,10 @@ +jellytau_lib::download::network - RustSkip to main content

Module network

Module network 

Source
Expand description

Network transport classification for the WiFi-only download gate.

+

This answers “what kind of connection are we on?”, which is orthogonal to +the ConnectivityMonitor’s “is the server reachable?”. The download queue +pump consults this before starting pending rows when the user has enabled +WiFi-only downloads.

+

On Android the real transport is read from NetworkCapabilities in +NetworkTypeMonitor.kt and pushed in from the frontend. On desktop there is +no metered-connection concept worth enforcing, so we report Ethernet, +which is always acceptable — gating desktop downloads would be a regression.

+

Structs§

NetworkState
Current network transport plus whether it is metered.
NetworkStateHandle
Shared, mutable view of the current network transport.

Enums§

NetworkType
Kind of network transport currently active.
\ No newline at end of file diff --git a/api/jellytau_lib/download/network/sidebar-items.js b/api/jellytau_lib/download/network/sidebar-items.js new file mode 100644 index 00000000..f40bc87c --- /dev/null +++ b/api/jellytau_lib/download/network/sidebar-items.js @@ -0,0 +1 @@ +window.SIDEBAR_ITEMS = {"enum":["NetworkType"],"struct":["NetworkState","NetworkStateHandle"]}; \ No newline at end of file diff --git a/api/jellytau_lib/download/network/struct.NetworkState.html b/api/jellytau_lib/download/network/struct.NetworkState.html new file mode 100644 index 00000000..0237c4ff --- /dev/null +++ b/api/jellytau_lib/download/network/struct.NetworkState.html @@ -0,0 +1,69 @@ +NetworkState in jellytau_lib::download::network - RustSkip to main content

NetworkState

Struct NetworkState 

Source
pub struct NetworkState {
+    pub network_type: NetworkType,
+    pub unmetered: bool,
+}
Expand description

Current network transport plus whether it is metered.

+

TRACES: UR-053 | DR-074

+

Fields§

§network_type: NetworkType§unmetered: bool

Whether the active network is unmetered (Android NET_CAPABILITY_NOT_METERED).

+

Implementations§

Source§

impl NetworkState

Source

pub fn allows_download(&self, wifi_only: bool) -> bool

Whether downloads may run right now given the wifi-only preference.

+

Ethernet counts as acceptable — it is unmetered in practice and is what +Android TV devices use. Cellular never does. None/Unknown fail +closed: if we cannot tell what we are on, we do not spend the user’s +mobile data to find out.

+

TRACES: UR-053 | DR-074

+

Trait Implementations§

Source§

impl Clone for NetworkState

Source§

fn clone(&self) -> NetworkState

Returns a duplicate of the value. Read more
1.0.0 (const: unstable) · Source§

fn clone_from(&mut self, source: &Self)

Performs copy-assignment from source. Read more
Source§

impl Debug for NetworkState

Source§

fn fmt(&self, f: &mut Formatter<'_>) -> Result

Formats the value using the given formatter. Read more
Source§

impl Default for NetworkState

Source§

fn default() -> Self

Returns the “default value” for a type. Read more
Source§

impl<'de> Deserialize<'de> for NetworkState

Source§

fn deserialize<__D>(__deserializer: __D) -> Result<Self, __D::Error>
where + __D: Deserializer<'de>,

Deserialize this value from the given Serde deserializer. Read more
Source§

impl NamedType for NetworkState

Source§

fn sid() -> SpectaID

Source§

fn named_data_type( + type_map: &mut TypeCollection, + generics: &[DataType], +) -> NamedDataType

this is equivalent to [Type::inline] but returns a [NamedDataType] instead.
Source§

fn definition_named_data_type(type_map: &mut TypeCollection) -> NamedDataType

this is equivalent to [Type::definition] but returns a [NamedDataType] instead.
Source§

impl PartialEq for NetworkState

Source§

fn eq(&self, other: &NetworkState) -> bool

Tests for self and other values to be equal, and is used by ==.
1.0.0 (const: unstable) · Source§

fn ne(&self, other: &Rhs) -> bool

Tests for !=. The default implementation is almost always sufficient, +and should not be overridden without very good reason.
Source§

impl Serialize for NetworkState

Source§

fn serialize<__S>(&self, __serializer: __S) -> Result<__S::Ok, __S::Error>
where + __S: Serializer,

Serialize this value into the given Serde serializer. Read more
Source§

impl Type for NetworkState

Source§

fn inline(type_map: &mut TypeCollection, generics: Generics<'_>) -> DataType

Returns the definition of a type using the provided generics. Read more
Source§

fn reference(type_map: &mut TypeCollection, generics: &[DataType]) -> Reference

Generates a datatype corresponding to a reference to this type, +as determined by its category. Getting a reference to a type implies that +it should belong in the type map (since it has to be referenced from somewhere), +so the output of definition will be put into the type map.
Source§

impl Copy for NetworkState

Source§

impl Eq for NetworkState

Source§

impl Flatten for NetworkState

Source§

impl StructuralPartialEq for NetworkState

Auto Trait Implementations§

Blanket Implementations§

Source§

impl<T> Any for T
where + T: 'static + ?Sized,

Source§

fn type_id(&self) -> TypeId

Gets the TypeId of self. Read more
Source§

impl<T> Borrow<T> for T
where + T: ?Sized,

Source§

fn borrow(&self) -> &T

Immutably borrows from an owned value. Read more
Source§

impl<T> BorrowMut<T> for T
where + T: ?Sized,

Source§

fn borrow_mut(&mut self) -> &mut T

Mutably borrows from an owned value. Read more
Source§

impl<T> CloneToUninit for T
where + T: Clone,

Source§

unsafe fn clone_to_uninit(&self, dest: *mut u8)

🔬This is a nightly-only experimental API. (clone_to_uninit)
Performs copy-assignment from self to dest. Read more
§

impl<'de, D, R> CommandArg<'de, R> for D
where + D: Deserialize<'de>, + R: Runtime,

§

fn from_command(command: CommandItem<'de, R>) -> Result<D, InvokeError>

Derives an instance of Self from the [CommandItem]. Read more
§

impl<Q, K> Equivalent<K> for Q
where + Q: Eq + ?Sized, + K: Borrow<Q> + ?Sized,

§

fn equivalent(&self, key: &K) -> bool

Checks if this value is equivalent to the given key. Read more
§

impl<Q, K> Equivalent<K> for Q
where + Q: Eq + ?Sized, + K: Borrow<Q> + ?Sized,

§

fn equivalent(&self, key: &K) -> bool

Compare self to key and return true if they are equal.
§

impl<Q, K> Equivalent<K> for Q
where + Q: Eq + ?Sized, + K: Borrow<Q> + ?Sized,

§

fn equivalent(&self, key: &K) -> bool

Checks if this value is equivalent to the given key. Read more
Source§

impl<T> From<T> for T

Source§

fn from(t: T) -> T

Returns the argument unchanged.

+
§

impl<T> FunctionArg for T
where + T: Type,

§

fn to_datatype(type_map: &mut TypeCollection) -> Option<DataType>

Gets the type of an argument as a [DataType]. Read more
§

impl<T> Instrument for T

§

fn instrument(self, span: Span) -> Instrumented<Self>

Instruments this type with the provided [Span], returning an +Instrumented wrapper. Read more
§

fn in_current_span(self) -> Instrumented<Self>

Instruments this type with the current Span, returning an +Instrumented wrapper. Read more
Source§

impl<T, U> Into<U> for T
where + U: From<T>,

Source§

fn into(self) -> U

Calls U::from(self).

+

That is, this conversion is whatever the implementation of +From<T> for U chooses to do.

+
§

impl<T> IpcResponse for T
where + T: Serialize,

§

fn body(self) -> Result<InvokeResponseBody, Error>

Resolve the IPC response body.
§

impl<T> NoneValue for T
where + T: Default,

§

type NoneType = T

§

fn null_value() -> T

The none-equivalent value.
§

impl<T> PolicyExt for T
where + T: ?Sized,

§

fn and<P, B, E>(self, other: P) -> And<T, P>
where + T: Policy<B, E>, + P: Policy<B, E>,

Create a new Policy that returns [Action::Follow] only if self and other return +Action::Follow. Read more
§

fn or<P, B, E>(self, other: P) -> Or<T, P>
where + T: Policy<B, E>, + P: Policy<B, E>,

Create a new Policy that returns [Action::Follow] if either self or other returns +Action::Follow. Read more
Source§

impl<T> Same for T

Source§

type Output = T

Should always be Self
§

impl<T> ScopeObject for T
where + T: Send + Sync + Debug + DeserializeOwned + 'static,

§

type Error = Error

The error type.
§

fn deserialize<R>( + _app: &AppHandle<R>, + raw: Value, +) -> Result<T, <T as ScopeObject>::Error>
where + R: Runtime,

Deserialize the raw scope value.
Source§

impl<T> Serialize for T
where + T: Serialize + ?Sized,

Source§

fn erased_serialize(&self, serializer: &mut dyn Serializer) -> Result<(), Error>

Source§

fn do_erased_serialize( + &self, + serializer: &mut dyn Serializer, +) -> Result<(), ErrorImpl>

Source§

impl<T> ToOwned for T
where + T: Clone,

Source§

type Owned = T

The resulting type after obtaining ownership.
Source§

fn to_owned(&self) -> T

Creates owned data from borrowed data, usually by cloning. Read more
Source§

fn clone_into(&self, target: &mut T)

Uses borrowed data to replace owned data, usually by cloning. Read more
Source§

impl<T, U> TryFrom<U> for T
where + U: Into<T>,

Source§

type Error = Infallible

The type returned in the event of a conversion error.
Source§

fn try_from(value: U) -> Result<T, <T as TryFrom<U>>::Error>

Performs the conversion.
Source§

impl<T, U> TryInto<U> for T
where + U: TryFrom<T>,

Source§

type Error = <U as TryFrom<T>>::Error

The type returned in the event of a conversion error.
Source§

fn try_into(self) -> Result<U, <U as TryFrom<T>>::Error>

Performs the conversion.
§

impl<V, T> VZip<V> for T
where + V: MultiLane<T>,

§

fn vzip(self) -> V

§

impl<T> WithSubscriber for T

§

fn with_subscriber<S>(self, subscriber: S) -> WithDispatch<Self>
where + S: Into<Dispatch>,

Attaches the provided Subscriber to this type, returning a +[WithDispatch] wrapper. Read more
§

fn with_current_subscriber(self) -> WithDispatch<Self>

Attaches the current default Subscriber to this type, returning a +[WithDispatch] wrapper. Read more
Source§

impl<T> DeserializeOwned for T
where + T: for<'de> Deserialize<'de>,

§

impl<T> UserEvent for T
where + T: Debug + Clone + Send + 'static,

\ No newline at end of file diff --git a/api/jellytau_lib/download/network/struct.NetworkStateHandle.html b/api/jellytau_lib/download/network/struct.NetworkStateHandle.html new file mode 100644 index 00000000..6a04a359 --- /dev/null +++ b/api/jellytau_lib/download/network/struct.NetworkStateHandle.html @@ -0,0 +1,33 @@ +NetworkStateHandle in jellytau_lib::download::network - RustSkip to main content

NetworkStateHandle

Struct NetworkStateHandle 

Source
pub struct NetworkStateHandle {
+    state: Arc<RwLock<NetworkState>>,
+}
Expand description

Shared, mutable view of the current network transport.

+

Cheap to clone; the frontend updates it via set_network_state whenever +Android reports a network change.

+

Fields§

§state: Arc<RwLock<NetworkState>>

Implementations§

Source§

impl NetworkStateHandle

Source

pub fn new() -> Self

Source

pub async fn get(&self) -> NetworkState

Source

pub async fn set(&self, new_state: NetworkState)

Source

pub async fn allows_download(&self, wifi_only: bool) -> bool

Whether downloads may run right now given the wifi-only preference.

+

Trait Implementations§

Source§

impl Clone for NetworkStateHandle

Source§

fn clone(&self) -> NetworkStateHandle

Returns a duplicate of the value. Read more
1.0.0 (const: unstable) · Source§

fn clone_from(&mut self, source: &Self)

Performs copy-assignment from source. Read more
Source§

impl Default for NetworkStateHandle

Source§

fn default() -> NetworkStateHandle

Returns the “default value” for a type. Read more

Auto Trait Implementations§

Blanket Implementations§

Source§

impl<T> Any for T
where + T: 'static + ?Sized,

Source§

fn type_id(&self) -> TypeId

Gets the TypeId of self. Read more
Source§

impl<T> Borrow<T> for T
where + T: ?Sized,

Source§

fn borrow(&self) -> &T

Immutably borrows from an owned value. Read more
Source§

impl<T> BorrowMut<T> for T
where + T: ?Sized,

Source§

fn borrow_mut(&mut self) -> &mut T

Mutably borrows from an owned value. Read more
Source§

impl<T> CloneToUninit for T
where + T: Clone,

Source§

unsafe fn clone_to_uninit(&self, dest: *mut u8)

🔬This is a nightly-only experimental API. (clone_to_uninit)
Performs copy-assignment from self to dest. Read more
Source§

impl<T> From<T> for T

Source§

fn from(t: T) -> T

Returns the argument unchanged.

+
§

impl<T> Instrument for T

§

fn instrument(self, span: Span) -> Instrumented<Self>

Instruments this type with the provided [Span], returning an +Instrumented wrapper. Read more
§

fn in_current_span(self) -> Instrumented<Self>

Instruments this type with the current Span, returning an +Instrumented wrapper. Read more
Source§

impl<T, U> Into<U> for T
where + U: From<T>,

Source§

fn into(self) -> U

Calls U::from(self).

+

That is, this conversion is whatever the implementation of +From<T> for U chooses to do.

+
§

impl<T> NoneValue for T
where + T: Default,

§

type NoneType = T

§

fn null_value() -> T

The none-equivalent value.
§

impl<T> PolicyExt for T
where + T: ?Sized,

§

fn and<P, B, E>(self, other: P) -> And<T, P>
where + T: Policy<B, E>, + P: Policy<B, E>,

Create a new Policy that returns [Action::Follow] only if self and other return +Action::Follow. Read more
§

fn or<P, B, E>(self, other: P) -> Or<T, P>
where + T: Policy<B, E>, + P: Policy<B, E>,

Create a new Policy that returns [Action::Follow] if either self or other returns +Action::Follow. Read more
Source§

impl<T> Same for T

Source§

type Output = T

Should always be Self
Source§

impl<T> ToOwned for T
where + T: Clone,

Source§

type Owned = T

The resulting type after obtaining ownership.
Source§

fn to_owned(&self) -> T

Creates owned data from borrowed data, usually by cloning. Read more
Source§

fn clone_into(&self, target: &mut T)

Uses borrowed data to replace owned data, usually by cloning. Read more
Source§

impl<T, U> TryFrom<U> for T
where + U: Into<T>,

Source§

type Error = Infallible

The type returned in the event of a conversion error.
Source§

fn try_from(value: U) -> Result<T, <T as TryFrom<U>>::Error>

Performs the conversion.
Source§

impl<T, U> TryInto<U> for T
where + U: TryFrom<T>,

Source§

type Error = <U as TryFrom<T>>::Error

The type returned in the event of a conversion error.
Source§

fn try_into(self) -> Result<U, <U as TryFrom<T>>::Error>

Performs the conversion.
§

impl<V, T> VZip<V> for T
where + V: MultiLane<T>,

§

fn vzip(self) -> V

§

impl<T> WithSubscriber for T

§

fn with_subscriber<S>(self, subscriber: S) -> WithDispatch<Self>
where + S: Into<Dispatch>,

Attaches the provided Subscriber to this type, returning a +[WithDispatch] wrapper. Read more
§

fn with_current_subscriber(self) -> WithDispatch<Self>

Attaches the current default Subscriber to this type, returning a +[WithDispatch] wrapper. Read more
\ No newline at end of file diff --git a/api/jellytau_lib/download/sidebar-items.js b/api/jellytau_lib/download/sidebar-items.js new file mode 100644 index 00000000..836f6972 --- /dev/null +++ b/api/jellytau_lib/download/sidebar-items.js @@ -0,0 +1 @@ +window.SIDEBAR_ITEMS = {"mod":["cache","events","network","stop","worker"],"struct":["DownloadInfo","DownloadManager","DownloadTask"]}; \ No newline at end of file diff --git a/api/jellytau_lib/download/stop/fn.clear.html b/api/jellytau_lib/download/stop/fn.clear.html new file mode 100644 index 00000000..a794b1d2 --- /dev/null +++ b/api/jellytau_lib/download/stop/fn.clear.html @@ -0,0 +1,2 @@ +clear in jellytau_lib::download::stop - RustSkip to main content

clear

Function clear 

Source
pub fn clear(download_id: i64)
Expand description

Forget a download’s flag. Called when its task finishes, however it ended.

+
\ No newline at end of file diff --git a/api/jellytau_lib/download/stop/fn.register.html b/api/jellytau_lib/download/stop/fn.register.html new file mode 100644 index 00000000..743021ab --- /dev/null +++ b/api/jellytau_lib/download/stop/fn.register.html @@ -0,0 +1,5 @@ +register in jellytau_lib::download::stop - RustSkip to main content

register

Function register 

Source
pub fn register(download_id: i64) -> Arc<AtomicBool> 
Expand description

Register download_id as in-flight and hand back its stop flag.

+

Called by the worker as it starts. A previous flag for the same id is +replaced, so a download that is paused and later resumed does not inherit the +set flag from its last run and stop immediately.

+
\ No newline at end of file diff --git a/api/jellytau_lib/download/stop/fn.registry.html b/api/jellytau_lib/download/stop/fn.registry.html new file mode 100644 index 00000000..e16c9b11 --- /dev/null +++ b/api/jellytau_lib/download/stop/fn.registry.html @@ -0,0 +1,2 @@ +registry in jellytau_lib::download::stop - RustSkip to main content

registry

Function registry 

Source
fn registry() -> &'static Mutex<HashMap<i64, Arc<AtomicBool>>>
Expand description

download id → its stop flag, for downloads currently in flight.

+
\ No newline at end of file diff --git a/api/jellytau_lib/download/stop/fn.signal.html b/api/jellytau_lib/download/stop/fn.signal.html new file mode 100644 index 00000000..97bdd0eb --- /dev/null +++ b/api/jellytau_lib/download/stop/fn.signal.html @@ -0,0 +1,5 @@ +signal in jellytau_lib::download::stop - RustSkip to main content

signal

Function signal 

Source
pub fn signal(download_id: i64) -> bool
Expand description

Ask an in-flight download to stop.

+

Returns whether one was actually in flight — the caller uses this to tell a +running download (which will stop shortly) from a merely queued one (which +the database update alone has already handled).

+
\ No newline at end of file diff --git a/api/jellytau_lib/download/stop/index.html b/api/jellytau_lib/download/stop/index.html new file mode 100644 index 00000000..9b706091 --- /dev/null +++ b/api/jellytau_lib/download/stop/index.html @@ -0,0 +1,17 @@ +jellytau_lib::download::stop - RustSkip to main content

Module stop

Module stop 

Source
Expand description

Stop signalling for in-flight downloads.

+

TRACES: UR-055 | DR-168

+

Pausing and cancelling used to be database-only: pause_download wrote +status = 'paused' and nothing else. No cancellation existed anywhere in the +download stack — no token, no flag, no abort — so the streaming task kept +running, kept writing bytes, and on finishing overwrote the row with +completed or failed. The row flicked to “paused” and then undid itself, +which is precisely the reported “pause does not work”.

+

This is the missing half: a flag per in-flight download that the worker reads +between chunks. Setting it makes the worker return Stopped promptly and +leave the .part file intact, which is what lets a resume pick up from +where it stopped via the existing HTTP Range request.

+

Kept as a module-level registry rather than on DownloadManager because the +two sides never meet: the command handler holds the manager’s lock, while the +worker runs detached inside tauri::async_runtime::spawn with no access to +Tauri state. A registry both can reach is the smallest thing that works.

+

Functions§

clear
Forget a download’s flag. Called when its task finishes, however it ended.
register
Register download_id as in-flight and hand back its stop flag.
registry 🔒
download id → its stop flag, for downloads currently in flight.
signal
Ask an in-flight download to stop.
\ No newline at end of file diff --git a/api/jellytau_lib/download/stop/sidebar-items.js b/api/jellytau_lib/download/stop/sidebar-items.js new file mode 100644 index 00000000..4c445a8b --- /dev/null +++ b/api/jellytau_lib/download/stop/sidebar-items.js @@ -0,0 +1 @@ +window.SIDEBAR_ITEMS = {"fn":["clear","register","registry","signal"]}; \ No newline at end of file diff --git a/api/jellytau_lib/download/struct.DownloadInfo.html b/api/jellytau_lib/download/struct.DownloadInfo.html new file mode 100644 index 00000000..eb4b3984 --- /dev/null +++ b/api/jellytau_lib/download/struct.DownloadInfo.html @@ -0,0 +1,76 @@ +DownloadInfo in jellytau_lib::download - RustSkip to main content

DownloadInfo

Struct DownloadInfo 

Source
pub struct DownloadInfo {
Show 25 fields + pub id: i64, + pub item_id: String, + pub user_id: String, + pub file_path: String, + pub file_size: Option<i64>, + pub mime_type: Option<String>, + pub status: String, + pub progress: f64, + pub bytes_downloaded: i64, + pub queued_at: String, + pub started_at: Option<String>, + pub completed_at: Option<String>, + pub error_message: Option<String>, + pub retry_count: i32, + pub priority: i32, + pub item_name: Option<String>, + pub artist_name: Option<String>, + pub album_name: Option<String>, + pub series_name: Option<String>, + pub season_name: Option<String>, + pub episode_number: Option<i32>, + pub season_number: Option<i32>, + pub quality_preset: Option<String>, + pub media_type: String, + pub download_source: String, +
}
Expand description

Information about a download

+

Fields§

§id: i64§item_id: String§user_id: String§file_path: String§file_size: Option<i64>§mime_type: Option<String>§status: String§progress: f64§bytes_downloaded: i64§queued_at: String§started_at: Option<String>§completed_at: Option<String>§error_message: Option<String>§retry_count: i32§priority: i32§item_name: Option<String>§artist_name: Option<String>§album_name: Option<String>§series_name: Option<String>§season_name: Option<String>§episode_number: Option<i32>§season_number: Option<i32>§quality_preset: Option<String>§media_type: String§download_source: String

Trait Implementations§

Source§

impl Clone for DownloadInfo

Source§

fn clone(&self) -> DownloadInfo

Returns a duplicate of the value. Read more
1.0.0 (const: unstable) · Source§

fn clone_from(&mut self, source: &Self)

Performs copy-assignment from source. Read more
Source§

impl Debug for DownloadInfo

Source§

fn fmt(&self, f: &mut Formatter<'_>) -> Result

Formats the value using the given formatter. Read more
Source§

impl<'de> Deserialize<'de> for DownloadInfo

Source§

fn deserialize<__D>(__deserializer: __D) -> Result<Self, __D::Error>
where + __D: Deserializer<'de>,

Deserialize this value from the given Serde deserializer. Read more
Source§

impl NamedType for DownloadInfo

Source§

fn sid() -> SpectaID

Source§

fn named_data_type( + type_map: &mut TypeCollection, + generics: &[DataType], +) -> NamedDataType

this is equivalent to [Type::inline] but returns a [NamedDataType] instead.
Source§

fn definition_named_data_type(type_map: &mut TypeCollection) -> NamedDataType

this is equivalent to [Type::definition] but returns a [NamedDataType] instead.
Source§

impl Serialize for DownloadInfo

Source§

fn serialize<__S>(&self, __serializer: __S) -> Result<__S::Ok, __S::Error>
where + __S: Serializer,

Serialize this value into the given Serde serializer. Read more
Source§

impl Type for DownloadInfo

Source§

fn inline(type_map: &mut TypeCollection, generics: Generics<'_>) -> DataType

Returns the definition of a type using the provided generics. Read more
Source§

fn reference(type_map: &mut TypeCollection, generics: &[DataType]) -> Reference

Generates a datatype corresponding to a reference to this type, +as determined by its category. Getting a reference to a type implies that +it should belong in the type map (since it has to be referenced from somewhere), +so the output of definition will be put into the type map.
Source§

impl Flatten for DownloadInfo

Auto Trait Implementations§

Blanket Implementations§

Source§

impl<T> Any for T
where + T: 'static + ?Sized,

Source§

fn type_id(&self) -> TypeId

Gets the TypeId of self. Read more
Source§

impl<T> Borrow<T> for T
where + T: ?Sized,

Source§

fn borrow(&self) -> &T

Immutably borrows from an owned value. Read more
Source§

impl<T> BorrowMut<T> for T
where + T: ?Sized,

Source§

fn borrow_mut(&mut self) -> &mut T

Mutably borrows from an owned value. Read more
Source§

impl<T> CloneToUninit for T
where + T: Clone,

Source§

unsafe fn clone_to_uninit(&self, dest: *mut u8)

🔬This is a nightly-only experimental API. (clone_to_uninit)
Performs copy-assignment from self to dest. Read more
§

impl<'de, D, R> CommandArg<'de, R> for D
where + D: Deserialize<'de>, + R: Runtime,

§

fn from_command(command: CommandItem<'de, R>) -> Result<D, InvokeError>

Derives an instance of Self from the [CommandItem]. Read more
Source§

impl<T> From<T> for T

Source§

fn from(t: T) -> T

Returns the argument unchanged.

+
§

impl<T> FunctionArg for T
where + T: Type,

§

fn to_datatype(type_map: &mut TypeCollection) -> Option<DataType>

Gets the type of an argument as a [DataType]. Read more
§

impl<T> Instrument for T

§

fn instrument(self, span: Span) -> Instrumented<Self>

Instruments this type with the provided [Span], returning an +Instrumented wrapper. Read more
§

fn in_current_span(self) -> Instrumented<Self>

Instruments this type with the current Span, returning an +Instrumented wrapper. Read more
Source§

impl<T, U> Into<U> for T
where + U: From<T>,

Source§

fn into(self) -> U

Calls U::from(self).

+

That is, this conversion is whatever the implementation of +From<T> for U chooses to do.

+
§

impl<T> IpcResponse for T
where + T: Serialize,

§

fn body(self) -> Result<InvokeResponseBody, Error>

Resolve the IPC response body.
§

impl<T> PolicyExt for T
where + T: ?Sized,

§

fn and<P, B, E>(self, other: P) -> And<T, P>
where + T: Policy<B, E>, + P: Policy<B, E>,

Create a new Policy that returns [Action::Follow] only if self and other return +Action::Follow. Read more
§

fn or<P, B, E>(self, other: P) -> Or<T, P>
where + T: Policy<B, E>, + P: Policy<B, E>,

Create a new Policy that returns [Action::Follow] if either self or other returns +Action::Follow. Read more
Source§

impl<T> Same for T

Source§

type Output = T

Should always be Self
§

impl<T> ScopeObject for T
where + T: Send + Sync + Debug + DeserializeOwned + 'static,

§

type Error = Error

The error type.
§

fn deserialize<R>( + _app: &AppHandle<R>, + raw: Value, +) -> Result<T, <T as ScopeObject>::Error>
where + R: Runtime,

Deserialize the raw scope value.
Source§

impl<T> Serialize for T
where + T: Serialize + ?Sized,

Source§

fn erased_serialize(&self, serializer: &mut dyn Serializer) -> Result<(), Error>

Source§

fn do_erased_serialize( + &self, + serializer: &mut dyn Serializer, +) -> Result<(), ErrorImpl>

Source§

impl<T> ToOwned for T
where + T: Clone,

Source§

type Owned = T

The resulting type after obtaining ownership.
Source§

fn to_owned(&self) -> T

Creates owned data from borrowed data, usually by cloning. Read more
Source§

fn clone_into(&self, target: &mut T)

Uses borrowed data to replace owned data, usually by cloning. Read more
Source§

impl<T, U> TryFrom<U> for T
where + U: Into<T>,

Source§

type Error = Infallible

The type returned in the event of a conversion error.
Source§

fn try_from(value: U) -> Result<T, <T as TryFrom<U>>::Error>

Performs the conversion.
Source§

impl<T, U> TryInto<U> for T
where + U: TryFrom<T>,

Source§

type Error = <U as TryFrom<T>>::Error

The type returned in the event of a conversion error.
Source§

fn try_into(self) -> Result<U, <U as TryFrom<T>>::Error>

Performs the conversion.
§

impl<V, T> VZip<V> for T
where + V: MultiLane<T>,

§

fn vzip(self) -> V

§

impl<T> WithSubscriber for T

§

fn with_subscriber<S>(self, subscriber: S) -> WithDispatch<Self>
where + S: Into<Dispatch>,

Attaches the provided Subscriber to this type, returning a +[WithDispatch] wrapper. Read more
§

fn with_current_subscriber(self) -> WithDispatch<Self>

Attaches the current default Subscriber to this type, returning a +[WithDispatch] wrapper. Read more
Source§

impl<T> DeserializeOwned for T
where + T: for<'de> Deserialize<'de>,

§

impl<T> UserEvent for T
where + T: Debug + Clone + Send + 'static,

\ No newline at end of file diff --git a/api/jellytau_lib/download/struct.DownloadManager.html b/api/jellytau_lib/download/struct.DownloadManager.html new file mode 100644 index 00000000..cfea32f4 --- /dev/null +++ b/api/jellytau_lib/download/struct.DownloadManager.html @@ -0,0 +1,38 @@ +DownloadManager in jellytau_lib::download - RustSkip to main content

DownloadManager

Struct DownloadManager 

Source
pub struct DownloadManager {
+    max_concurrent: usize,
+    active_downloads: Arc<Mutex<HashSet<i64>>>,
+}
Expand description

Download manager coordinating downloads across workers

+

Fields§

§max_concurrent: usize

Maximum concurrent downloads

+
§active_downloads: Arc<Mutex<HashSet<i64>>>

Currently active download IDs

+

Implementations§

Source§

impl DownloadManager

Source

pub fn new(_media_dir: PathBuf) -> Self

Create a new download manager

+
Source

pub fn max_concurrent(&self) -> usize

Get the maximum concurrent downloads

+
Source

pub fn set_max_concurrent(&mut self, max: usize)

Set the maximum concurrent downloads

+
Source

pub fn can_start_download(&self) -> bool

Check if a new download can be started based on concurrent limit

+
Source

pub fn active_count(&self) -> usize

Get the number of currently active downloads

+
Source

pub fn register_download(&self, download_id: i64) -> bool

Register a download as active

+
Source

pub fn unregister_download(&self, download_id: i64)

Unregister a download when it completes or fails

+
Source

pub fn get_active_downloads(&self) -> Arc<Mutex<HashSet<i64>>>

Get a clone of the active downloads set (for internal use)

+

Auto Trait Implementations§

Blanket Implementations§

Source§

impl<T> Any for T
where + T: 'static + ?Sized,

Source§

fn type_id(&self) -> TypeId

Gets the TypeId of self. Read more
Source§

impl<T> Borrow<T> for T
where + T: ?Sized,

Source§

fn borrow(&self) -> &T

Immutably borrows from an owned value. Read more
Source§

impl<T> BorrowMut<T> for T
where + T: ?Sized,

Source§

fn borrow_mut(&mut self) -> &mut T

Mutably borrows from an owned value. Read more
Source§

impl<T> From<T> for T

Source§

fn from(t: T) -> T

Returns the argument unchanged.

+
§

impl<T> Instrument for T

§

fn instrument(self, span: Span) -> Instrumented<Self>

Instruments this type with the provided [Span], returning an +Instrumented wrapper. Read more
§

fn in_current_span(self) -> Instrumented<Self>

Instruments this type with the current Span, returning an +Instrumented wrapper. Read more
Source§

impl<T, U> Into<U> for T
where + U: From<T>,

Source§

fn into(self) -> U

Calls U::from(self).

+

That is, this conversion is whatever the implementation of +From<T> for U chooses to do.

+
§

impl<T> PolicyExt for T
where + T: ?Sized,

§

fn and<P, B, E>(self, other: P) -> And<T, P>
where + T: Policy<B, E>, + P: Policy<B, E>,

Create a new Policy that returns [Action::Follow] only if self and other return +Action::Follow. Read more
§

fn or<P, B, E>(self, other: P) -> Or<T, P>
where + T: Policy<B, E>, + P: Policy<B, E>,

Create a new Policy that returns [Action::Follow] if either self or other returns +Action::Follow. Read more
Source§

impl<T> Same for T

Source§

type Output = T

Should always be Self
Source§

impl<T, U> TryFrom<U> for T
where + U: Into<T>,

Source§

type Error = Infallible

The type returned in the event of a conversion error.
Source§

fn try_from(value: U) -> Result<T, <T as TryFrom<U>>::Error>

Performs the conversion.
Source§

impl<T, U> TryInto<U> for T
where + U: TryFrom<T>,

Source§

type Error = <U as TryFrom<T>>::Error

The type returned in the event of a conversion error.
Source§

fn try_into(self) -> Result<U, <U as TryFrom<T>>::Error>

Performs the conversion.
§

impl<V, T> VZip<V> for T
where + V: MultiLane<T>,

§

fn vzip(self) -> V

§

impl<T> WithSubscriber for T

§

fn with_subscriber<S>(self, subscriber: S) -> WithDispatch<Self>
where + S: Into<Dispatch>,

Attaches the provided Subscriber to this type, returning a +[WithDispatch] wrapper. Read more
§

fn with_current_subscriber(self) -> WithDispatch<Self>

Attaches the current default Subscriber to this type, returning a +[WithDispatch] wrapper. Read more
\ No newline at end of file diff --git a/api/jellytau_lib/download/struct.DownloadTask.html b/api/jellytau_lib/download/struct.DownloadTask.html new file mode 100644 index 00000000..08efd886 --- /dev/null +++ b/api/jellytau_lib/download/struct.DownloadTask.html @@ -0,0 +1,31 @@ +DownloadTask in jellytau_lib::download - RustSkip to main content

DownloadTask

Struct DownloadTask 

Source
pub struct DownloadTask {
+    pub url: String,
+    pub target_path: PathBuf,
+}
Expand description

Download task for workers

+

Fields§

§url: String§target_path: PathBuf

Trait Implementations§

Source§

impl Clone for DownloadTask

Source§

fn clone(&self) -> DownloadTask

Returns a duplicate of the value. Read more
1.0.0 (const: unstable) · Source§

fn clone_from(&mut self, source: &Self)

Performs copy-assignment from source. Read more
Source§

impl Debug for DownloadTask

Source§

fn fmt(&self, f: &mut Formatter<'_>) -> Result

Formats the value using the given formatter. Read more

Auto Trait Implementations§

Blanket Implementations§

Source§

impl<T> Any for T
where + T: 'static + ?Sized,

Source§

fn type_id(&self) -> TypeId

Gets the TypeId of self. Read more
Source§

impl<T> Borrow<T> for T
where + T: ?Sized,

Source§

fn borrow(&self) -> &T

Immutably borrows from an owned value. Read more
Source§

impl<T> BorrowMut<T> for T
where + T: ?Sized,

Source§

fn borrow_mut(&mut self) -> &mut T

Mutably borrows from an owned value. Read more
Source§

impl<T> CloneToUninit for T
where + T: Clone,

Source§

unsafe fn clone_to_uninit(&self, dest: *mut u8)

🔬This is a nightly-only experimental API. (clone_to_uninit)
Performs copy-assignment from self to dest. Read more
Source§

impl<T> From<T> for T

Source§

fn from(t: T) -> T

Returns the argument unchanged.

+
§

impl<T> Instrument for T

§

fn instrument(self, span: Span) -> Instrumented<Self>

Instruments this type with the provided [Span], returning an +Instrumented wrapper. Read more
§

fn in_current_span(self) -> Instrumented<Self>

Instruments this type with the current Span, returning an +Instrumented wrapper. Read more
Source§

impl<T, U> Into<U> for T
where + U: From<T>,

Source§

fn into(self) -> U

Calls U::from(self).

+

That is, this conversion is whatever the implementation of +From<T> for U chooses to do.

+
§

impl<T> PolicyExt for T
where + T: ?Sized,

§

fn and<P, B, E>(self, other: P) -> And<T, P>
where + T: Policy<B, E>, + P: Policy<B, E>,

Create a new Policy that returns [Action::Follow] only if self and other return +Action::Follow. Read more
§

fn or<P, B, E>(self, other: P) -> Or<T, P>
where + T: Policy<B, E>, + P: Policy<B, E>,

Create a new Policy that returns [Action::Follow] if either self or other returns +Action::Follow. Read more
Source§

impl<T> Same for T

Source§

type Output = T

Should always be Self
Source§

impl<T> ToOwned for T
where + T: Clone,

Source§

type Owned = T

The resulting type after obtaining ownership.
Source§

fn to_owned(&self) -> T

Creates owned data from borrowed data, usually by cloning. Read more
Source§

fn clone_into(&self, target: &mut T)

Uses borrowed data to replace owned data, usually by cloning. Read more
Source§

impl<T, U> TryFrom<U> for T
where + U: Into<T>,

Source§

type Error = Infallible

The type returned in the event of a conversion error.
Source§

fn try_from(value: U) -> Result<T, <T as TryFrom<U>>::Error>

Performs the conversion.
Source§

impl<T, U> TryInto<U> for T
where + U: TryFrom<T>,

Source§

type Error = <U as TryFrom<T>>::Error

The type returned in the event of a conversion error.
Source§

fn try_into(self) -> Result<U, <U as TryFrom<T>>::Error>

Performs the conversion.
§

impl<V, T> VZip<V> for T
where + V: MultiLane<T>,

§

fn vzip(self) -> V

§

impl<T> WithSubscriber for T

§

fn with_subscriber<S>(self, subscriber: S) -> WithDispatch<Self>
where + S: Into<Dispatch>,

Attaches the provided Subscriber to this type, returning a +[WithDispatch] wrapper. Read more
§

fn with_current_subscriber(self) -> WithDispatch<Self>

Attaches the current default Subscriber to this type, returning a +[WithDispatch] wrapper. Read more
§

impl<T> UserEvent for T
where + T: Debug + Clone + Send + 'static,

\ No newline at end of file diff --git a/api/jellytau_lib/download/worker/enum.DownloadError.html b/api/jellytau_lib/download/worker/enum.DownloadError.html new file mode 100644 index 00000000..bff91d64 --- /dev/null +++ b/api/jellytau_lib/download/worker/enum.DownloadError.html @@ -0,0 +1,41 @@ +DownloadError in jellytau_lib::download::worker - RustSkip to main content

DownloadError

Enum DownloadError 

Source
pub enum DownloadError {
+    Network(String),
+    Http(u16),
+    FileSystem(String),
+    Stopped,
+}
Expand description

Download error types

+

Variants§

§

Network(String)

§

Http(u16)

§

FileSystem(String)

§

Stopped

The download was asked to stop (paused or cancelled). Not a failure: the +row’s status already says what happened, and the partial file is kept so a +resume can continue from it.

+

Implementations§

Source§

impl DownloadError

Source

fn is_retryable(&self) -> bool

Check if this error is retryable

+
Source

pub fn is_stopped(&self) -> bool

Whether this outcome means “the user stopped it”, rather than a failure to +record and report.

+

Trait Implementations§

Source§

impl Debug for DownloadError

Source§

fn fmt(&self, f: &mut Formatter<'_>) -> Result

Formats the value using the given formatter. Read more
Source§

impl Display for DownloadError

Source§

fn fmt(&self, f: &mut Formatter<'_>) -> Result

Formats the value using the given formatter. Read more
Source§

impl Error for DownloadError

1.30.0 · Source§

fn source(&self) -> Option<&(dyn Error + 'static)>

Returns the lower-level source of this error, if any. Read more
1.0.0 · Source§

fn description(&self) -> &str

👎Deprecated since 1.42.0:

use the Display impl or to_string()

+
1.0.0 · Source§

fn cause(&self) -> Option<&dyn Error>

👎Deprecated since 1.33.0:

replaced by Error::source, which can support downcasting

+
Source§

fn provide<'a>(&'a self, request: &mut Request<'a>)

🔬This is a nightly-only experimental API. (error_generic_member_access)
Provides type-based access to context intended for error reports. Read more

Auto Trait Implementations§

Blanket Implementations§

Source§

impl<T> Any for T
where + T: 'static + ?Sized,

Source§

fn type_id(&self) -> TypeId

Gets the TypeId of self. Read more
Source§

impl<T> Borrow<T> for T
where + T: ?Sized,

Source§

fn borrow(&self) -> &T

Immutably borrows from an owned value. Read more
Source§

impl<T> BorrowMut<T> for T
where + T: ?Sized,

Source§

fn borrow_mut(&mut self) -> &mut T

Mutably borrows from an owned value. Read more
Source§

impl<T> From<T> for T

Source§

fn from(t: T) -> T

Returns the argument unchanged.

+
§

impl<T> Instrument for T

§

fn instrument(self, span: Span) -> Instrumented<Self>

Instruments this type with the provided [Span], returning an +Instrumented wrapper. Read more
§

fn in_current_span(self) -> Instrumented<Self>

Instruments this type with the current Span, returning an +Instrumented wrapper. Read more
Source§

impl<T, U> Into<U> for T
where + U: From<T>,

Source§

fn into(self) -> U

Calls U::from(self).

+

That is, this conversion is whatever the implementation of +From<T> for U chooses to do.

+
§

impl<T> PolicyExt for T
where + T: ?Sized,

§

fn and<P, B, E>(self, other: P) -> And<T, P>
where + T: Policy<B, E>, + P: Policy<B, E>,

Create a new Policy that returns [Action::Follow] only if self and other return +Action::Follow. Read more
§

fn or<P, B, E>(self, other: P) -> Or<T, P>
where + T: Policy<B, E>, + P: Policy<B, E>,

Create a new Policy that returns [Action::Follow] if either self or other returns +Action::Follow. Read more
Source§

impl<T> Same for T

Source§

type Output = T

Should always be Self
Source§

impl<T> ToString for T
where + T: Display + ?Sized,

Source§

fn to_string(&self) -> String

Converts the given value to a String. Read more
§

impl<T> ToStringFallible for T
where + T: Display,

§

fn try_to_string(&self) -> Result<String, TryReserveError>

ToString::to_string, but without panic on OOM.

+
Source§

impl<T, U> TryFrom<U> for T
where + U: Into<T>,

Source§

type Error = Infallible

The type returned in the event of a conversion error.
Source§

fn try_from(value: U) -> Result<T, <T as TryFrom<U>>::Error>

Performs the conversion.
Source§

impl<T, U> TryInto<U> for T
where + U: TryFrom<T>,

Source§

type Error = <U as TryFrom<T>>::Error

The type returned in the event of a conversion error.
Source§

fn try_into(self) -> Result<U, <U as TryFrom<T>>::Error>

Performs the conversion.
§

impl<V, T> VZip<V> for T
where + V: MultiLane<T>,

§

fn vzip(self) -> V

§

impl<T> WithSubscriber for T

§

fn with_subscriber<S>(self, subscriber: S) -> WithDispatch<Self>
where + S: Into<Dispatch>,

Attaches the provided Subscriber to this type, returning a +[WithDispatch] wrapper. Read more
§

fn with_current_subscriber(self) -> WithDispatch<Self>

Attaches the current default Subscriber to this type, returning a +[WithDispatch] wrapper. Read more
\ No newline at end of file diff --git a/api/jellytau_lib/download/worker/fn.partial_path.html b/api/jellytau_lib/download/worker/fn.partial_path.html new file mode 100644 index 00000000..04ae8c3b --- /dev/null +++ b/api/jellytau_lib/download/worker/fn.partial_path.html @@ -0,0 +1,13 @@ +partial_path in jellytau_lib::download::worker - RustSkip to main content

partial_path

Function partial_path 

Source
pub fn partial_path(target: &Path) -> PathBuf
Expand description

The partial-download sidecar for target.

+

Appends .part rather than replacing the extension. The worker used +Path::with_extension("part"), which replaces: movie.mp4 became +movie.part. Every cleanup path meanwhile deleted "{file_path}.part" — +movie.mp4.part — so nothing ever matched and the partial file of every +cancelled or failed download was left on disk forever, invisible to the +disk-usage totals because no downloads row pointed at it. That is the +reported “failure is not cleaned”.

+

Appending also removes a collision the old form had: movie.mp4 and +movie.mkv both mapped to movie.part and would have fought over one file.

+

One function so the writer and the cleaners cannot disagree again.

+

TRACES: UR-055 | DR-169

+
\ No newline at end of file diff --git a/api/jellytau_lib/download/worker/fn.resume_offset.html b/api/jellytau_lib/download/worker/fn.resume_offset.html new file mode 100644 index 00000000..1f5bc2de --- /dev/null +++ b/api/jellytau_lib/download/worker/fn.resume_offset.html @@ -0,0 +1,14 @@ +resume_offset in jellytau_lib::download::worker - RustSkip to main content

resume_offset

Function resume_offset 

Source
pub fn resume_offset(existing_bytes: u64, status: u16) -> u64
Expand description

Where to resume writing a partial download, given how the server answered.

+

A byte offset of 0 means “start the file again”; anything else means “append +from here”.

+

This is what makes non-original downloads survive. Those presets ask +Jellyfin to transcode, and a live transcode is chunked with no +Content-Length and cannot be byte-seeked: the server ignores Range and +answers 200 with the whole stream from the beginning, not 206 with the +requested tail. The worker sent the header and appended the body regardless, +so every retry — and every resume — concatenated a fresh copy of the whole +transcode onto the bytes already on disk. The file grew past its real size +and would not play. Only a 206 actually promises the tail; a 200 means we +must discard what we have and take the stream from the top.

+

TRACES: UR-071 | DR-170

+
\ No newline at end of file diff --git a/api/jellytau_lib/download/worker/index.html b/api/jellytau_lib/download/worker/index.html new file mode 100644 index 00000000..944f5133 --- /dev/null +++ b/api/jellytau_lib/download/worker/index.html @@ -0,0 +1,2 @@ +jellytau_lib::download::worker - RustSkip to main content

Module worker

Module worker 

Source
Expand description

Download worker for HTTP streaming with progress tracking and retry logic

+

Structs§

DownloadResult
Result of a successful download
DownloadWorker
Download worker that handles individual download tasks

Enums§

DownloadError
Download error types

Functions§

partial_path
The partial-download sidecar for target.
resume_offset
Where to resume writing a partial download, given how the server answered.
\ No newline at end of file diff --git a/api/jellytau_lib/download/worker/sidebar-items.js b/api/jellytau_lib/download/worker/sidebar-items.js new file mode 100644 index 00000000..4eac26cb --- /dev/null +++ b/api/jellytau_lib/download/worker/sidebar-items.js @@ -0,0 +1 @@ +window.SIDEBAR_ITEMS = {"enum":["DownloadError"],"fn":["partial_path","resume_offset"],"struct":["DownloadResult","DownloadWorker"]}; \ No newline at end of file diff --git a/api/jellytau_lib/download/worker/struct.DownloadResult.html b/api/jellytau_lib/download/worker/struct.DownloadResult.html new file mode 100644 index 00000000..57d57782 --- /dev/null +++ b/api/jellytau_lib/download/worker/struct.DownloadResult.html @@ -0,0 +1,27 @@ +DownloadResult in jellytau_lib::download::worker - RustSkip to main content

DownloadResult

Struct DownloadResult 

Source
pub struct DownloadResult {
+    pub bytes_downloaded: u64,
+}
Expand description

Result of a successful download

+

Fields§

§bytes_downloaded: u64

Trait Implementations§

Source§

impl Debug for DownloadResult

Source§

fn fmt(&self, f: &mut Formatter<'_>) -> Result

Formats the value using the given formatter. Read more

Auto Trait Implementations§

Blanket Implementations§

Source§

impl<T> Any for T
where + T: 'static + ?Sized,

Source§

fn type_id(&self) -> TypeId

Gets the TypeId of self. Read more
Source§

impl<T> Borrow<T> for T
where + T: ?Sized,

Source§

fn borrow(&self) -> &T

Immutably borrows from an owned value. Read more
Source§

impl<T> BorrowMut<T> for T
where + T: ?Sized,

Source§

fn borrow_mut(&mut self) -> &mut T

Mutably borrows from an owned value. Read more
Source§

impl<T> From<T> for T

Source§

fn from(t: T) -> T

Returns the argument unchanged.

+
§

impl<T> Instrument for T

§

fn instrument(self, span: Span) -> Instrumented<Self>

Instruments this type with the provided [Span], returning an +Instrumented wrapper. Read more
§

fn in_current_span(self) -> Instrumented<Self>

Instruments this type with the current Span, returning an +Instrumented wrapper. Read more
Source§

impl<T, U> Into<U> for T
where + U: From<T>,

Source§

fn into(self) -> U

Calls U::from(self).

+

That is, this conversion is whatever the implementation of +From<T> for U chooses to do.

+
§

impl<T> PolicyExt for T
where + T: ?Sized,

§

fn and<P, B, E>(self, other: P) -> And<T, P>
where + T: Policy<B, E>, + P: Policy<B, E>,

Create a new Policy that returns [Action::Follow] only if self and other return +Action::Follow. Read more
§

fn or<P, B, E>(self, other: P) -> Or<T, P>
where + T: Policy<B, E>, + P: Policy<B, E>,

Create a new Policy that returns [Action::Follow] if either self or other returns +Action::Follow. Read more
Source§

impl<T> Same for T

Source§

type Output = T

Should always be Self
Source§

impl<T, U> TryFrom<U> for T
where + U: Into<T>,

Source§

type Error = Infallible

The type returned in the event of a conversion error.
Source§

fn try_from(value: U) -> Result<T, <T as TryFrom<U>>::Error>

Performs the conversion.
Source§

impl<T, U> TryInto<U> for T
where + U: TryFrom<T>,

Source§

type Error = <U as TryFrom<T>>::Error

The type returned in the event of a conversion error.
Source§

fn try_into(self) -> Result<U, <U as TryFrom<T>>::Error>

Performs the conversion.
§

impl<V, T> VZip<V> for T
where + V: MultiLane<T>,

§

fn vzip(self) -> V

§

impl<T> WithSubscriber for T

§

fn with_subscriber<S>(self, subscriber: S) -> WithDispatch<Self>
where + S: Into<Dispatch>,

Attaches the provided Subscriber to this type, returning a +[WithDispatch] wrapper. Read more
§

fn with_current_subscriber(self) -> WithDispatch<Self>

Attaches the current default Subscriber to this type, returning a +[WithDispatch] wrapper. Read more
\ No newline at end of file diff --git a/api/jellytau_lib/download/worker/struct.DownloadWorker.html b/api/jellytau_lib/download/worker/struct.DownloadWorker.html new file mode 100644 index 00000000..02cbb9d9 --- /dev/null +++ b/api/jellytau_lib/download/worker/struct.DownloadWorker.html @@ -0,0 +1,50 @@ +DownloadWorker in jellytau_lib::download::worker - RustSkip to main content

DownloadWorker

Struct DownloadWorker 

Source
pub struct DownloadWorker {
+    client: Client,
+    max_retries: u32,
+}
Expand description

Download worker that handles individual download tasks

+

Fields§

§client: Client

HTTP client for downloads

+
§max_retries: u32

Maximum retry attempts

+

Implementations§

Source§

impl DownloadWorker

Source

pub fn new() -> Self

Source

pub async fn download<F>( + &self, + task: &DownloadTask, + stop: &AtomicBool, + on_progress: F, +) -> Result<DownloadResult, DownloadError>
where + F: Fn(u64, Option<u64>) + Send + Sync,

Download a file with retry logic and progress tracking.

+

stop is the pause/cancel flag (see crate::download::stop). It is +checked between chunks and again between retries, so a paused download +stops promptly rather than after its next backoff — up to 45 seconds +away, which reads as the pause having done nothing.

+

TRACES: UR-055 | DR-168

+
Source

async fn try_download<F>( + &self, + task: &DownloadTask, + stop: &AtomicBool, + on_progress: &F, +) -> Result<DownloadResult, DownloadError>
where + F: Fn(u64, Option<u64>) + Send + Sync,

Attempt a single download

+
Source

fn exponential_backoff(retry_count: u32) -> Duration

Calculate exponential backoff delay

+

Auto Trait Implementations§

Blanket Implementations§

Source§

impl<T> Any for T
where + T: 'static + ?Sized,

Source§

fn type_id(&self) -> TypeId

Gets the TypeId of self. Read more
Source§

impl<T> Borrow<T> for T
where + T: ?Sized,

Source§

fn borrow(&self) -> &T

Immutably borrows from an owned value. Read more
Source§

impl<T> BorrowMut<T> for T
where + T: ?Sized,

Source§

fn borrow_mut(&mut self) -> &mut T

Mutably borrows from an owned value. Read more
Source§

impl<T> From<T> for T

Source§

fn from(t: T) -> T

Returns the argument unchanged.

+
§

impl<T> Instrument for T

§

fn instrument(self, span: Span) -> Instrumented<Self>

Instruments this type with the provided [Span], returning an +Instrumented wrapper. Read more
§

fn in_current_span(self) -> Instrumented<Self>

Instruments this type with the current Span, returning an +Instrumented wrapper. Read more
Source§

impl<T, U> Into<U> for T
where + U: From<T>,

Source§

fn into(self) -> U

Calls U::from(self).

+

That is, this conversion is whatever the implementation of +From<T> for U chooses to do.

+
§

impl<T> PolicyExt for T
where + T: ?Sized,

§

fn and<P, B, E>(self, other: P) -> And<T, P>
where + T: Policy<B, E>, + P: Policy<B, E>,

Create a new Policy that returns [Action::Follow] only if self and other return +Action::Follow. Read more
§

fn or<P, B, E>(self, other: P) -> Or<T, P>
where + T: Policy<B, E>, + P: Policy<B, E>,

Create a new Policy that returns [Action::Follow] if either self or other returns +Action::Follow. Read more
Source§

impl<T> Same for T

Source§

type Output = T

Should always be Self
Source§

impl<T, U> TryFrom<U> for T
where + U: Into<T>,

Source§

type Error = Infallible

The type returned in the event of a conversion error.
Source§

fn try_from(value: U) -> Result<T, <T as TryFrom<U>>::Error>

Performs the conversion.
Source§

impl<T, U> TryInto<U> for T
where + U: TryFrom<T>,

Source§

type Error = <U as TryFrom<T>>::Error

The type returned in the event of a conversion error.
Source§

fn try_into(self) -> Result<U, <U as TryFrom<T>>::Error>

Performs the conversion.
§

impl<V, T> VZip<V> for T
where + V: MultiLane<T>,

§

fn vzip(self) -> V

§

impl<T> WithSubscriber for T

§

fn with_subscriber<S>(self, subscriber: S) -> WithDispatch<Self>
where + S: Into<Dispatch>,

Attaches the provided Subscriber to this type, returning a +[WithDispatch] wrapper. Read more
§

fn with_current_subscriber(self) -> WithDispatch<Self>

Attaches the current default Subscriber to this type, returning a +[WithDispatch] wrapper. Read more
\ No newline at end of file diff --git a/api/jellytau_lib/fn.build_log_plugin.html b/api/jellytau_lib/fn.build_log_plugin.html new file mode 100644 index 00000000..620cc89f --- /dev/null +++ b/api/jellytau_lib/fn.build_log_plugin.html @@ -0,0 +1,35 @@ +build_log_plugin in jellytau_lib - RustSkip to main content

build_log_plugin

Function build_log_plugin 

Source
pub(crate) fn build_log_plugin() -> TauriPlugin<Wry>
Expand description

Cached thumbnails are handed to the webview as asset-protocol URLs by +convertFileSrc (asset://localhost/… on Linux/macOS, +http://asset.localhost/… on Windows/Android). Tauri only answers that +origin when the protocol-asset cargo feature is compiled in and +app.security.assetProtocol.enable is set in tauri.conf.json. Both are +required together: with either missing the URL resolves to nothing and the +webview reports NETWORK_NO_SOURCE, which is how offline video came to fail +silently.

+

The scope is $APPDATA/thumbnails/**, not the storage root: downloaded media +moved to the loopback media server in DR-137, so imageCache is the only +remaining convertFileSrc caller and the database and the encrypted-token +fallback file — which share that root — never need to be readable by the +webview. Widen it only if something other than thumbnails starts resolving +through convertFileSrc again.

+

TRACES: UR-012, UR-071 | DR-134, DR-137, DR-198 +Build the logging plugin.

+

Replaces the previous env_logger init, which wrote to stdout only. That +was invisible to anyone who launched from a desktop icon, and worse than +useless on Android: stdout is not logcat, so the Rust backend produced no +visible output at all on the platform carrying the hardest bugs in this +project’s history (the autoplay deadlock, the truncated-stream restart, the +background-audio stall). tauri-plugin-log routes to logcat there for free.

+

Three decisions worth keeping:

+
    +
  • Every line goes through redact first. A credential must never reach +disk, not merely be stripped later when a bundle is exported — a file on +the device is already the disclosure.
  • +
  • The size cap is deliberate. RotationStrategy::KeepAll would let a +long-running session fill a phone. One rotation keeps yesterday’s evidence +without unbounded growth.
  • +
  • The level is read from disk. Someone reproducing a bug needs debug +logging to survive the restart that reproduces it.
  • +
+

TRACES: UR-078 | DR-218

+
\ No newline at end of file diff --git a/api/jellytau_lib/fn.create_player_backend.html b/api/jellytau_lib/fn.create_player_backend.html new file mode 100644 index 00000000..7354db1d --- /dev/null +++ b/api/jellytau_lib/fn.create_player_backend.html @@ -0,0 +1,6 @@ +create_player_backend in jellytau_lib - RustSkip to main content

create_player_backend

Function create_player_backend 

Source
pub(crate) fn create_player_backend(
+    app_handle: AppHandle,
+    playback_reporter: Arc<Mutex<Option<PlaybackReporter>>>,
+    position_throttler: Arc<EventThrottler>,
+) -> Box<dyn PlayerBackend>
Expand description

Create the appropriate player backend for the current platform.

+
\ No newline at end of file diff --git a/api/jellytau_lib/fn.emit_backend_init_failed.html b/api/jellytau_lib/fn.emit_backend_init_failed.html new file mode 100644 index 00000000..7669a873 --- /dev/null +++ b/api/jellytau_lib/fn.emit_backend_init_failed.html @@ -0,0 +1,7 @@ +emit_backend_init_failed in jellytau_lib - RustSkip to main content

emit_backend_init_failed

Function emit_backend_init_failed 

Source
pub(crate) fn emit_backend_init_failed(
+    app_handle: &AppHandle,
+    backend: &'static str,
+    message: String,
+)
Expand description

Log a backend-initialization failure and notify the frontend, so the UI can +surface “playback unavailable” instead of the app hard-crashing.

+
\ No newline at end of file diff --git a/api/jellytau_lib/fn.enable_linux_hardware_video_decoding.html b/api/jellytau_lib/fn.enable_linux_hardware_video_decoding.html new file mode 100644 index 00000000..4b9ce0c2 --- /dev/null +++ b/api/jellytau_lib/fn.enable_linux_hardware_video_decoding.html @@ -0,0 +1,8 @@ +enable_linux_hardware_video_decoding in jellytau_lib - RustSkip to main content

enable_linux_hardware_video_decoding

Function enable_linux_hardware_video_decoding 

Source
pub(crate) fn enable_linux_hardware_video_decoding()
Expand description

Configure GStreamer (the media backend behind WebKitGTK’s HTML5 <video> +element on Linux) to prefer hardware-accelerated VAAPI decoding when the +host provides it, falling back to software decoding otherwise.

+

All variables are only set if the user has not already exported them, so an +explicit override (e.g. forcing software decode for debugging) is respected. +They must be applied before WebKitGTK builds its GStreamer pipeline, hence the +call at the very top of run().

+
\ No newline at end of file diff --git a/api/jellytau_lib/fn.log_available_vaapi_decoders.html b/api/jellytau_lib/fn.log_available_vaapi_decoders.html new file mode 100644 index 00000000..29b6d301 --- /dev/null +++ b/api/jellytau_lib/fn.log_available_vaapi_decoders.html @@ -0,0 +1,5 @@ +log_available_vaapi_decoders in jellytau_lib - RustSkip to main content

log_available_vaapi_decoders

Function log_available_vaapi_decoders 

Source
pub(crate) fn log_available_vaapi_decoders()
Expand description

Probe (via gst-inspect-1.0, which ships with GStreamer) which VAAPI hardware +video decoders GStreamer can actually load on this host, and log the result so +it is clear at startup whether hardware decoding is genuinely available or +whether playback will fall back to software.

+
\ No newline at end of file diff --git a/api/jellytau_lib/fn.run.html b/api/jellytau_lib/fn.run.html new file mode 100644 index 00000000..ae898a8b --- /dev/null +++ b/api/jellytau_lib/fn.run.html @@ -0,0 +1 @@ +run in jellytau_lib - RustSkip to main content

run

Function run 

Source
pub fn run()
\ No newline at end of file diff --git a/api/jellytau_lib/fn.set_env_if_unset.html b/api/jellytau_lib/fn.set_env_if_unset.html new file mode 100644 index 00000000..99172ce5 --- /dev/null +++ b/api/jellytau_lib/fn.set_env_if_unset.html @@ -0,0 +1 @@ +set_env_if_unset in jellytau_lib - RustSkip to main content

set_env_if_unset

Function set_env_if_unset 

Source
pub(crate) fn set_env_if_unset(key: &str, value: &str)
\ No newline at end of file diff --git a/api/jellytau_lib/fn.specta_builder.html b/api/jellytau_lib/fn.specta_builder.html new file mode 100644 index 00000000..611e76c3 --- /dev/null +++ b/api/jellytau_lib/fn.specta_builder.html @@ -0,0 +1,3 @@ +specta_builder in jellytau_lib - RustSkip to main content

specta_builder

Function specta_builder 

Source
pub(crate) fn specta_builder() -> Builder<Wry>
Expand description

Construct the tauri-specta command builder. Shared by run() and the +bindings-export test so the TypeScript bindings always match the handler.

+
\ No newline at end of file diff --git a/api/jellytau_lib/index.html b/api/jellytau_lib/index.html new file mode 100644 index 00000000..725dbcf5 --- /dev/null +++ b/api/jellytau_lib/index.html @@ -0,0 +1,17 @@ +jellytau_lib - RustSkip to main content

Crate jellytau_lib

Crate jellytau_lib 

Source

Modules§

auth 🔒
commands 🔒
connectivity 🔒
credentials 🔒
Secure credential storage module
domain 🔒
Canonical, provider-neutral domain model — the single source of truth for +the app’s core data shapes, shared with the frontend via generated bindings.
download 🔒
Download manager for offline media support
jellyfin 🔒
media_server 🔒
A loopback HTTP server for locally downloaded media.
playback_mode 🔒
playback_reporting 🔒
player 🔒
repository 🔒
session_poller 🔒
Session polling manager for remote playback control.
settings
TRACES: UR-023, UR-027, UR-031, UR-032, UR-033 | DR-030, DR-034, DR-035, DR-036, DR-048, IR-020
storage 🔒
Offline storage module using SQLite
thumbnail 🔒
Thumbnail caching with LRU eviction
utils

Structs§

BackendInitError 🔒
Payload emitted to the frontend when a native player backend fails to +initialize and the app falls back to a no-op backend.

Functions§

build_log_plugin 🔒
Cached thumbnails are handed to the webview as asset-protocol URLs by +convertFileSrc (asset://localhost/… on Linux/macOS, +http://asset.localhost/… on Windows/Android). Tauri only answers that +origin when the protocol-asset cargo feature is compiled in and +app.security.assetProtocol.enable is set in tauri.conf.json. Both are +required together: with either missing the URL resolves to nothing and the +webview reports NETWORK_NO_SOURCE, which is how offline video came to fail +silently.
create_player_backend 🔒
Create the appropriate player backend for the current platform.
emit_backend_init_failed 🔒
Log a backend-initialization failure and notify the frontend, so the UI can +surface “playback unavailable” instead of the app hard-crashing.
enable_linux_hardware_video_decoding 🔒
Configure GStreamer (the media backend behind WebKitGTK’s HTML5 <video> +element on Linux) to prefer hardware-accelerated VAAPI decoding when the +host provides it, falling back to software decoding otherwise.
log_available_vaapi_decoders 🔒
Probe (via gst-inspect-1.0, which ships with GStreamer) which VAAPI hardware +video decoders GStreamer can actually load on this host, and log the result so +it is clear at startup whether hardware decoding is genuinely available or +whether playback will fall back to software.
run
set_env_if_unset 🔒
specta_builder 🔒
Construct the tauri-specta command builder. Shared by run() and the +bindings-export test so the TypeScript bindings always match the handler.
\ No newline at end of file diff --git a/api/jellytau_lib/jellyfin/client/constant.APP_NAME.html b/api/jellytau_lib/jellyfin/client/constant.APP_NAME.html new file mode 100644 index 00000000..85ce609a --- /dev/null +++ b/api/jellytau_lib/jellyfin/client/constant.APP_NAME.html @@ -0,0 +1 @@ +APP_NAME in jellytau_lib::jellyfin::client - RustSkip to main content

APP_NAME

Constant APP_NAME 

Source
const APP_NAME: &str = "JellyTau";
\ No newline at end of file diff --git a/api/jellytau_lib/jellyfin/client/constant.APP_VERSION.html b/api/jellytau_lib/jellyfin/client/constant.APP_VERSION.html new file mode 100644 index 00000000..31486ad7 --- /dev/null +++ b/api/jellytau_lib/jellyfin/client/constant.APP_VERSION.html @@ -0,0 +1 @@ +APP_VERSION in jellytau_lib::jellyfin::client - RustSkip to main content

APP_VERSION

Constant APP_VERSION 

Source
const APP_VERSION: &str = "0.1.0";
\ No newline at end of file diff --git a/api/jellytau_lib/jellyfin/client/fn.default_true.html b/api/jellytau_lib/jellyfin/client/fn.default_true.html new file mode 100644 index 00000000..4ad9639a --- /dev/null +++ b/api/jellytau_lib/jellyfin/client/fn.default_true.html @@ -0,0 +1,4 @@ +default_true in jellytau_lib::jellyfin::client - RustSkip to main content

default_true

Function default_true 

Source
fn default_true() -> bool
Expand description

Default value for supports_remote_control when missing from API +We default to true to show all sessions. If a session explicitly doesn’t +support remote control, the Jellyfin API will set this field to false.

+
\ No newline at end of file diff --git a/api/jellytau_lib/jellyfin/client/index.html b/api/jellytau_lib/jellyfin/client/index.html new file mode 100644 index 00000000..8e033e52 --- /dev/null +++ b/api/jellytau_lib/jellyfin/client/index.html @@ -0,0 +1,4 @@ +jellytau_lib::jellyfin::client - RustSkip to main content

Module client

Module client 

Source
Expand description

TRACES: UR-009 | JA-001, JA-002, JA-003, JA-004, JA-007, JA-010, JA-011, JA-012, JA-017, JA-021 | IR-009, IR-010, IR-011

+

Structs§

JellyfinClient
Jellyfin API client for playback reporting
LmsSyncGroup
An LMS multi-room sync group, as returned by JellyLMS /JellyLms/SyncGroups.
NowPlayingItem
PlayState
SessionInfo
Session information from Jellyfin

Constants§

APP_NAME 🔒
APP_VERSION 🔒

Functions§

default_true 🔒
Default value for supports_remote_control when missing from API +We default to true to show all sessions. If a session explicitly doesn’t +support remote control, the Jellyfin API will set this field to false.
\ No newline at end of file diff --git a/api/jellytau_lib/jellyfin/client/sidebar-items.js b/api/jellytau_lib/jellyfin/client/sidebar-items.js new file mode 100644 index 00000000..8c105c91 --- /dev/null +++ b/api/jellytau_lib/jellyfin/client/sidebar-items.js @@ -0,0 +1 @@ +window.SIDEBAR_ITEMS = {"constant":["APP_NAME","APP_VERSION"],"fn":["default_true"],"struct":["JellyfinClient","LmsSyncGroup","NowPlayingItem","PlayState","SessionInfo"]}; \ No newline at end of file diff --git a/api/jellytau_lib/jellyfin/client/struct.JellyfinClient.html b/api/jellytau_lib/jellyfin/client/struct.JellyfinClient.html new file mode 100644 index 00000000..b42869ce --- /dev/null +++ b/api/jellytau_lib/jellyfin/client/struct.JellyfinClient.html @@ -0,0 +1,116 @@ +JellyfinClient in jellytau_lib::jellyfin::client - RustSkip to main content

JellyfinClient

Struct JellyfinClient 

Source
pub struct JellyfinClient {
+    config: Arc<JellyfinConfig>,
+    http_client: Client,
+}
Expand description

Jellyfin API client for playback reporting

+

Fields§

§config: Arc<JellyfinConfig>§http_client: Client

Implementations§

Source§

impl JellyfinClient

Source

pub fn new(config: JellyfinConfig) -> Result<Self, String>

Create a new Jellyfin API client

+
Source

fn get_device_name() -> &'static str

Get device name based on platform

+
Source

fn get_auth_header(&self) -> String

Build the X-Emby-Authorization header value

+
Source

async fn get<T: for<'de> Deserialize<'de>>( + &self, + endpoint: &str, +) -> Result<T, String>

Make a GET request to the Jellyfin API

+
Source

async fn post<T: Serialize>( + &self, + endpoint: &str, + body: &T, +) -> Result<(), String>

Make a POST request to the Jellyfin API

+
Source

pub async fn report_playback_start( + &self, + item_id: String, + position_ticks: i64, + play_session_id: Option<String>, +) -> Result<(), String>

Report playback start to Jellyfin

+
Source

pub async fn report_playback_stopped( + &self, + item_id: String, + position_ticks: i64, + play_session_id: Option<String>, +) -> Result<(), String>

Report playback stopped to Jellyfin

+
Source

pub async fn report_playback_progress( + &self, + item_id: String, + position_ticks: i64, + is_paused: bool, + play_session_id: Option<String>, +) -> Result<(), String>

Report playback progress to Jellyfin

+
Source

pub async fn play_on_session( + &self, + session_id: String, + item_ids: Vec<String>, + start_index: usize, + start_position_ticks: Option<i64>, +) -> Result<(), String>

Play items on a remote session (casting)

+
Source

pub async fn send_session_command( + &self, + session_id: String, + command: &str, +) -> Result<(), String>

Send a playback command to a remote session

+
Source

pub async fn session_seek( + &self, + session_id: String, + position_ticks: i64, +) -> Result<(), String>

Seek on a remote session

+

Jellyfin’s /Sessions/{id}/Playing/Seek endpoint takes the target as the +SeekPositionTicks query parameter, not a JSON body. Sending it in the +body (as we used to) is silently ignored and the remote never seeks.

+
Source

async fn send_general_command( + &self, + session_id: &str, + command_name: &str, + arguments: Option<Value>, +) -> Result<(), String>

Send a full GeneralCommand to a remote session. +Uses POST /Sessions/{id}/Command with a body containing Name and Arguments. +This is required for commands that need arguments (e.g. SetVolume).

+
Source

pub async fn session_set_volume( + &self, + session_id: String, + volume: i32, +) -> Result<(), String>

Set volume on a remote session

+
Source

pub async fn session_toggle_mute( + &self, + session_id: String, +) -> Result<(), String>

Toggle mute on a remote session

+
Source

pub async fn get_sessions(&self) -> Result<Vec<SessionInfo>, String>

Get all active sessions

+
Source

pub async fn get_session( + &self, + session_id: &str, +) -> Result<Option<SessionInfo>, String>

Get a specific session by ID

+
Source

pub async fn lms_get_sync_groups(&self) -> Result<Vec<LmsSyncGroup>, String>

List current LMS sync groups.

+
Source

pub async fn lms_create_sync_group( + &self, + master_mac: &str, + slave_macs: Vec<String>, +) -> Result<(), String>

Fuse LMS zones: create a sync group with master_mac as the sync master +and slave_macs joining it. The master keeps playing; slaves follow.

+
Source

pub async fn lms_unsync_player(&self, mac: &str) -> Result<(), String>

Remove a single LMS player from whatever sync group it’s in.

+
Source

pub async fn lms_dissolve_sync_group( + &self, + master_mac: &str, +) -> Result<(), String>

Dissolve an entire LMS sync group, identified by its master’s MAC.

+
Source

async fn delete(&self, endpoint: &str) -> Result<(), String>

Make a DELETE request to the Jellyfin API (used by the JellyLMS endpoints).

+

Trait Implementations§

Source§

impl Clone for JellyfinClient

Source§

fn clone(&self) -> JellyfinClient

Returns a duplicate of the value. Read more
1.0.0 (const: unstable) · Source§

fn clone_from(&mut self, source: &Self)

Performs copy-assignment from source. Read more

Auto Trait Implementations§

Blanket Implementations§

Source§

impl<T> Any for T
where + T: 'static + ?Sized,

Source§

fn type_id(&self) -> TypeId

Gets the TypeId of self. Read more
Source§

impl<T> Borrow<T> for T
where + T: ?Sized,

Source§

fn borrow(&self) -> &T

Immutably borrows from an owned value. Read more
Source§

impl<T> BorrowMut<T> for T
where + T: ?Sized,

Source§

fn borrow_mut(&mut self) -> &mut T

Mutably borrows from an owned value. Read more
Source§

impl<T> CloneToUninit for T
where + T: Clone,

Source§

unsafe fn clone_to_uninit(&self, dest: *mut u8)

🔬This is a nightly-only experimental API. (clone_to_uninit)
Performs copy-assignment from self to dest. Read more
Source§

impl<T> From<T> for T

Source§

fn from(t: T) -> T

Returns the argument unchanged.

+
§

impl<T> Instrument for T

§

fn instrument(self, span: Span) -> Instrumented<Self>

Instruments this type with the provided [Span], returning an +Instrumented wrapper. Read more
§

fn in_current_span(self) -> Instrumented<Self>

Instruments this type with the current Span, returning an +Instrumented wrapper. Read more
Source§

impl<T, U> Into<U> for T
where + U: From<T>,

Source§

fn into(self) -> U

Calls U::from(self).

+

That is, this conversion is whatever the implementation of +From<T> for U chooses to do.

+
§

impl<T> PolicyExt for T
where + T: ?Sized,

§

fn and<P, B, E>(self, other: P) -> And<T, P>
where + T: Policy<B, E>, + P: Policy<B, E>,

Create a new Policy that returns [Action::Follow] only if self and other return +Action::Follow. Read more
§

fn or<P, B, E>(self, other: P) -> Or<T, P>
where + T: Policy<B, E>, + P: Policy<B, E>,

Create a new Policy that returns [Action::Follow] if either self or other returns +Action::Follow. Read more
Source§

impl<T> Same for T

Source§

type Output = T

Should always be Self
Source§

impl<T> ToOwned for T
where + T: Clone,

Source§

type Owned = T

The resulting type after obtaining ownership.
Source§

fn to_owned(&self) -> T

Creates owned data from borrowed data, usually by cloning. Read more
Source§

fn clone_into(&self, target: &mut T)

Uses borrowed data to replace owned data, usually by cloning. Read more
Source§

impl<T, U> TryFrom<U> for T
where + U: Into<T>,

Source§

type Error = Infallible

The type returned in the event of a conversion error.
Source§

fn try_from(value: U) -> Result<T, <T as TryFrom<U>>::Error>

Performs the conversion.
Source§

impl<T, U> TryInto<U> for T
where + U: TryFrom<T>,

Source§

type Error = <U as TryFrom<T>>::Error

The type returned in the event of a conversion error.
Source§

fn try_into(self) -> Result<U, <U as TryFrom<T>>::Error>

Performs the conversion.
§

impl<V, T> VZip<V> for T
where + V: MultiLane<T>,

§

fn vzip(self) -> V

§

impl<T> WithSubscriber for T

§

fn with_subscriber<S>(self, subscriber: S) -> WithDispatch<Self>
where + S: Into<Dispatch>,

Attaches the provided Subscriber to this type, returning a +[WithDispatch] wrapper. Read more
§

fn with_current_subscriber(self) -> WithDispatch<Self>

Attaches the current default Subscriber to this type, returning a +[WithDispatch] wrapper. Read more
\ No newline at end of file diff --git a/api/jellytau_lib/jellyfin/client/struct.LmsSyncGroup.html b/api/jellytau_lib/jellyfin/client/struct.LmsSyncGroup.html new file mode 100644 index 00000000..182efa38 --- /dev/null +++ b/api/jellytau_lib/jellyfin/client/struct.LmsSyncGroup.html @@ -0,0 +1,57 @@ +LmsSyncGroup in jellytau_lib::jellyfin::client - RustSkip to main content

LmsSyncGroup

Struct LmsSyncGroup 

Source
pub struct LmsSyncGroup {
+    pub master_mac: String,
+    pub master_name: String,
+    pub slave_macs: Vec<String>,
+    pub slave_names: Vec<String>,
+}
Expand description

An LMS multi-room sync group, as returned by JellyLMS /JellyLms/SyncGroups.

+

Mirrors the plugin’s SyncGroup model. The master is the sync source; slaves +follow it in lockstep.

+

Fields§

§master_mac: String§master_name: String§slave_macs: Vec<String>§slave_names: Vec<String>

Trait Implementations§

Source§

impl Clone for LmsSyncGroup

Source§

fn clone(&self) -> LmsSyncGroup

Returns a duplicate of the value. Read more
1.0.0 (const: unstable) · Source§

fn clone_from(&mut self, source: &Self)

Performs copy-assignment from source. Read more
Source§

impl Debug for LmsSyncGroup

Source§

fn fmt(&self, f: &mut Formatter<'_>) -> Result

Formats the value using the given formatter. Read more
Source§

impl<'de> Deserialize<'de> for LmsSyncGroup

Source§

fn deserialize<__D>(__deserializer: __D) -> Result<Self, __D::Error>
where + __D: Deserializer<'de>,

Deserialize this value from the given Serde deserializer. Read more
Source§

impl NamedType for LmsSyncGroup

Source§

fn sid() -> SpectaID

Source§

fn named_data_type( + type_map: &mut TypeCollection, + generics: &[DataType], +) -> NamedDataType

this is equivalent to [Type::inline] but returns a [NamedDataType] instead.
Source§

fn definition_named_data_type(type_map: &mut TypeCollection) -> NamedDataType

this is equivalent to [Type::definition] but returns a [NamedDataType] instead.
Source§

impl Serialize for LmsSyncGroup

Source§

fn serialize<__S>(&self, __serializer: __S) -> Result<__S::Ok, __S::Error>
where + __S: Serializer,

Serialize this value into the given Serde serializer. Read more
Source§

impl Type for LmsSyncGroup

Source§

fn inline(type_map: &mut TypeCollection, generics: Generics<'_>) -> DataType

Returns the definition of a type using the provided generics. Read more
Source§

fn reference(type_map: &mut TypeCollection, generics: &[DataType]) -> Reference

Generates a datatype corresponding to a reference to this type, +as determined by its category. Getting a reference to a type implies that +it should belong in the type map (since it has to be referenced from somewhere), +so the output of definition will be put into the type map.
Source§

impl Flatten for LmsSyncGroup

Auto Trait Implementations§

Blanket Implementations§

Source§

impl<T> Any for T
where + T: 'static + ?Sized,

Source§

fn type_id(&self) -> TypeId

Gets the TypeId of self. Read more
Source§

impl<T> Borrow<T> for T
where + T: ?Sized,

Source§

fn borrow(&self) -> &T

Immutably borrows from an owned value. Read more
Source§

impl<T> BorrowMut<T> for T
where + T: ?Sized,

Source§

fn borrow_mut(&mut self) -> &mut T

Mutably borrows from an owned value. Read more
Source§

impl<T> CloneToUninit for T
where + T: Clone,

Source§

unsafe fn clone_to_uninit(&self, dest: *mut u8)

🔬This is a nightly-only experimental API. (clone_to_uninit)
Performs copy-assignment from self to dest. Read more
§

impl<'de, D, R> CommandArg<'de, R> for D
where + D: Deserialize<'de>, + R: Runtime,

§

fn from_command(command: CommandItem<'de, R>) -> Result<D, InvokeError>

Derives an instance of Self from the [CommandItem]. Read more
Source§

impl<T> From<T> for T

Source§

fn from(t: T) -> T

Returns the argument unchanged.

+
§

impl<T> FunctionArg for T
where + T: Type,

§

fn to_datatype(type_map: &mut TypeCollection) -> Option<DataType>

Gets the type of an argument as a [DataType]. Read more
§

impl<T> Instrument for T

§

fn instrument(self, span: Span) -> Instrumented<Self>

Instruments this type with the provided [Span], returning an +Instrumented wrapper. Read more
§

fn in_current_span(self) -> Instrumented<Self>

Instruments this type with the current Span, returning an +Instrumented wrapper. Read more
Source§

impl<T, U> Into<U> for T
where + U: From<T>,

Source§

fn into(self) -> U

Calls U::from(self).

+

That is, this conversion is whatever the implementation of +From<T> for U chooses to do.

+
§

impl<T> IpcResponse for T
where + T: Serialize,

§

fn body(self) -> Result<InvokeResponseBody, Error>

Resolve the IPC response body.
§

impl<T> PolicyExt for T
where + T: ?Sized,

§

fn and<P, B, E>(self, other: P) -> And<T, P>
where + T: Policy<B, E>, + P: Policy<B, E>,

Create a new Policy that returns [Action::Follow] only if self and other return +Action::Follow. Read more
§

fn or<P, B, E>(self, other: P) -> Or<T, P>
where + T: Policy<B, E>, + P: Policy<B, E>,

Create a new Policy that returns [Action::Follow] if either self or other returns +Action::Follow. Read more
Source§

impl<T> Same for T

Source§

type Output = T

Should always be Self
§

impl<T> ScopeObject for T
where + T: Send + Sync + Debug + DeserializeOwned + 'static,

§

type Error = Error

The error type.
§

fn deserialize<R>( + _app: &AppHandle<R>, + raw: Value, +) -> Result<T, <T as ScopeObject>::Error>
where + R: Runtime,

Deserialize the raw scope value.
Source§

impl<T> Serialize for T
where + T: Serialize + ?Sized,

Source§

fn erased_serialize(&self, serializer: &mut dyn Serializer) -> Result<(), Error>

Source§

fn do_erased_serialize( + &self, + serializer: &mut dyn Serializer, +) -> Result<(), ErrorImpl>

Source§

impl<T> ToOwned for T
where + T: Clone,

Source§

type Owned = T

The resulting type after obtaining ownership.
Source§

fn to_owned(&self) -> T

Creates owned data from borrowed data, usually by cloning. Read more
Source§

fn clone_into(&self, target: &mut T)

Uses borrowed data to replace owned data, usually by cloning. Read more
Source§

impl<T, U> TryFrom<U> for T
where + U: Into<T>,

Source§

type Error = Infallible

The type returned in the event of a conversion error.
Source§

fn try_from(value: U) -> Result<T, <T as TryFrom<U>>::Error>

Performs the conversion.
Source§

impl<T, U> TryInto<U> for T
where + U: TryFrom<T>,

Source§

type Error = <U as TryFrom<T>>::Error

The type returned in the event of a conversion error.
Source§

fn try_into(self) -> Result<U, <U as TryFrom<T>>::Error>

Performs the conversion.
§

impl<V, T> VZip<V> for T
where + V: MultiLane<T>,

§

fn vzip(self) -> V

§

impl<T> WithSubscriber for T

§

fn with_subscriber<S>(self, subscriber: S) -> WithDispatch<Self>
where + S: Into<Dispatch>,

Attaches the provided Subscriber to this type, returning a +[WithDispatch] wrapper. Read more
§

fn with_current_subscriber(self) -> WithDispatch<Self>

Attaches the current default Subscriber to this type, returning a +[WithDispatch] wrapper. Read more
Source§

impl<T> DeserializeOwned for T
where + T: for<'de> Deserialize<'de>,

§

impl<T> UserEvent for T
where + T: Debug + Clone + Send + 'static,

\ No newline at end of file diff --git a/api/jellytau_lib/jellyfin/client/struct.NowPlayingItem.html b/api/jellytau_lib/jellyfin/client/struct.NowPlayingItem.html new file mode 100644 index 00000000..e661f75e --- /dev/null +++ b/api/jellytau_lib/jellyfin/client/struct.NowPlayingItem.html @@ -0,0 +1,61 @@ +NowPlayingItem in jellytau_lib::jellyfin::client - RustSkip to main content

NowPlayingItem

Struct NowPlayingItem 

Source
pub struct NowPlayingItem {
+    pub id: Option<String>,
+    pub name: Option<String>,
+    pub run_time_ticks: Option<i64>,
+    pub album: Option<String>,
+    pub album_id: Option<String>,
+    pub album_artist: Option<String>,
+    pub artists: Option<Vec<String>>,
+    pub image_tags: Option<HashMap<String, String>>,
+    pub primary_image_tag: Option<String>,
+    pub album_primary_image_tag: Option<String>,
+    pub item_type: Option<String>,
+}

Fields§

§id: Option<String>§name: Option<String>§run_time_ticks: Option<i64>§album: Option<String>§album_id: Option<String>§album_artist: Option<String>§artists: Option<Vec<String>>§image_tags: Option<HashMap<String, String>>§primary_image_tag: Option<String>§album_primary_image_tag: Option<String>§item_type: Option<String>

Trait Implementations§

Source§

impl Clone for NowPlayingItem

Source§

fn clone(&self) -> NowPlayingItem

Returns a duplicate of the value. Read more
1.0.0 (const: unstable) · Source§

fn clone_from(&mut self, source: &Self)

Performs copy-assignment from source. Read more
Source§

impl Debug for NowPlayingItem

Source§

fn fmt(&self, f: &mut Formatter<'_>) -> Result

Formats the value using the given formatter. Read more
Source§

impl<'de> Deserialize<'de> for NowPlayingItem

Source§

fn deserialize<__D>(__deserializer: __D) -> Result<Self, __D::Error>
where + __D: Deserializer<'de>,

Deserialize this value from the given Serde deserializer. Read more
Source§

impl From<&NowPlayingItem> for MergedMediaItem

Source§

fn from(item: &NowPlayingItem) -> Self

Converts to this type from the input type.
Source§

impl NamedType for NowPlayingItem

Source§

fn sid() -> SpectaID

Source§

fn named_data_type( + type_map: &mut TypeCollection, + generics: &[DataType], +) -> NamedDataType

this is equivalent to [Type::inline] but returns a [NamedDataType] instead.
Source§

fn definition_named_data_type(type_map: &mut TypeCollection) -> NamedDataType

this is equivalent to [Type::definition] but returns a [NamedDataType] instead.
Source§

impl Serialize for NowPlayingItem

Source§

fn serialize<__S>(&self, __serializer: __S) -> Result<__S::Ok, __S::Error>
where + __S: Serializer,

Serialize this value into the given Serde serializer. Read more
Source§

impl Type for NowPlayingItem

Source§

fn inline(type_map: &mut TypeCollection, generics: Generics<'_>) -> DataType

Returns the definition of a type using the provided generics. Read more
Source§

fn reference(type_map: &mut TypeCollection, generics: &[DataType]) -> Reference

Generates a datatype corresponding to a reference to this type, +as determined by its category. Getting a reference to a type implies that +it should belong in the type map (since it has to be referenced from somewhere), +so the output of definition will be put into the type map.
Source§

impl Flatten for NowPlayingItem

Auto Trait Implementations§

Blanket Implementations§

Source§

impl<T> Any for T
where + T: 'static + ?Sized,

Source§

fn type_id(&self) -> TypeId

Gets the TypeId of self. Read more
Source§

impl<T> Borrow<T> for T
where + T: ?Sized,

Source§

fn borrow(&self) -> &T

Immutably borrows from an owned value. Read more
Source§

impl<T> BorrowMut<T> for T
where + T: ?Sized,

Source§

fn borrow_mut(&mut self) -> &mut T

Mutably borrows from an owned value. Read more
Source§

impl<T> CloneToUninit for T
where + T: Clone,

Source§

unsafe fn clone_to_uninit(&self, dest: *mut u8)

🔬This is a nightly-only experimental API. (clone_to_uninit)
Performs copy-assignment from self to dest. Read more
§

impl<'de, D, R> CommandArg<'de, R> for D
where + D: Deserialize<'de>, + R: Runtime,

§

fn from_command(command: CommandItem<'de, R>) -> Result<D, InvokeError>

Derives an instance of Self from the [CommandItem]. Read more
Source§

impl<T> From<T> for T

Source§

fn from(t: T) -> T

Returns the argument unchanged.

+
§

impl<T> FunctionArg for T
where + T: Type,

§

fn to_datatype(type_map: &mut TypeCollection) -> Option<DataType>

Gets the type of an argument as a [DataType]. Read more
§

impl<T> Instrument for T

§

fn instrument(self, span: Span) -> Instrumented<Self>

Instruments this type with the provided [Span], returning an +Instrumented wrapper. Read more
§

fn in_current_span(self) -> Instrumented<Self>

Instruments this type with the current Span, returning an +Instrumented wrapper. Read more
Source§

impl<T, U> Into<U> for T
where + U: From<T>,

Source§

fn into(self) -> U

Calls U::from(self).

+

That is, this conversion is whatever the implementation of +From<T> for U chooses to do.

+
§

impl<T> IpcResponse for T
where + T: Serialize,

§

fn body(self) -> Result<InvokeResponseBody, Error>

Resolve the IPC response body.
§

impl<T> PolicyExt for T
where + T: ?Sized,

§

fn and<P, B, E>(self, other: P) -> And<T, P>
where + T: Policy<B, E>, + P: Policy<B, E>,

Create a new Policy that returns [Action::Follow] only if self and other return +Action::Follow. Read more
§

fn or<P, B, E>(self, other: P) -> Or<T, P>
where + T: Policy<B, E>, + P: Policy<B, E>,

Create a new Policy that returns [Action::Follow] if either self or other returns +Action::Follow. Read more
Source§

impl<T> Same for T

Source§

type Output = T

Should always be Self
§

impl<T> ScopeObject for T
where + T: Send + Sync + Debug + DeserializeOwned + 'static,

§

type Error = Error

The error type.
§

fn deserialize<R>( + _app: &AppHandle<R>, + raw: Value, +) -> Result<T, <T as ScopeObject>::Error>
where + R: Runtime,

Deserialize the raw scope value.
Source§

impl<T> Serialize for T
where + T: Serialize + ?Sized,

Source§

fn erased_serialize(&self, serializer: &mut dyn Serializer) -> Result<(), Error>

Source§

fn do_erased_serialize( + &self, + serializer: &mut dyn Serializer, +) -> Result<(), ErrorImpl>

Source§

impl<T> ToOwned for T
where + T: Clone,

Source§

type Owned = T

The resulting type after obtaining ownership.
Source§

fn to_owned(&self) -> T

Creates owned data from borrowed data, usually by cloning. Read more
Source§

fn clone_into(&self, target: &mut T)

Uses borrowed data to replace owned data, usually by cloning. Read more
Source§

impl<T, U> TryFrom<U> for T
where + U: Into<T>,

Source§

type Error = Infallible

The type returned in the event of a conversion error.
Source§

fn try_from(value: U) -> Result<T, <T as TryFrom<U>>::Error>

Performs the conversion.
Source§

impl<T, U> TryInto<U> for T
where + U: TryFrom<T>,

Source§

type Error = <U as TryFrom<T>>::Error

The type returned in the event of a conversion error.
Source§

fn try_into(self) -> Result<U, <U as TryFrom<T>>::Error>

Performs the conversion.
§

impl<V, T> VZip<V> for T
where + V: MultiLane<T>,

§

fn vzip(self) -> V

§

impl<T> WithSubscriber for T

§

fn with_subscriber<S>(self, subscriber: S) -> WithDispatch<Self>
where + S: Into<Dispatch>,

Attaches the provided Subscriber to this type, returning a +[WithDispatch] wrapper. Read more
§

fn with_current_subscriber(self) -> WithDispatch<Self>

Attaches the current default Subscriber to this type, returning a +[WithDispatch] wrapper. Read more
Source§

impl<T> DeserializeOwned for T
where + T: for<'de> Deserialize<'de>,

§

impl<T> UserEvent for T
where + T: Debug + Clone + Send + 'static,

\ No newline at end of file diff --git a/api/jellytau_lib/jellyfin/client/struct.PlayState.html b/api/jellytau_lib/jellyfin/client/struct.PlayState.html new file mode 100644 index 00000000..391bd033 --- /dev/null +++ b/api/jellytau_lib/jellyfin/client/struct.PlayState.html @@ -0,0 +1,57 @@ +PlayState in jellytau_lib::jellyfin::client - RustSkip to main content

PlayState

Struct PlayState 

Source
pub struct PlayState {
+    pub position_ticks: Option<i64>,
+    pub can_seek: Option<bool>,
+    pub is_paused: Option<bool>,
+    pub is_muted: Option<bool>,
+    pub volume_level: Option<i32>,
+    pub repeat_mode: Option<String>,
+    pub shuffle_mode: Option<String>,
+}

Fields§

§position_ticks: Option<i64>§can_seek: Option<bool>§is_paused: Option<bool>§is_muted: Option<bool>§volume_level: Option<i32>§repeat_mode: Option<String>§shuffle_mode: Option<String>

Trait Implementations§

Source§

impl Clone for PlayState

Source§

fn clone(&self) -> PlayState

Returns a duplicate of the value. Read more
1.0.0 (const: unstable) · Source§

fn clone_from(&mut self, source: &Self)

Performs copy-assignment from source. Read more
Source§

impl Debug for PlayState

Source§

fn fmt(&self, f: &mut Formatter<'_>) -> Result

Formats the value using the given formatter. Read more
Source§

impl<'de> Deserialize<'de> for PlayState

Source§

fn deserialize<__D>(__deserializer: __D) -> Result<Self, __D::Error>
where + __D: Deserializer<'de>,

Deserialize this value from the given Serde deserializer. Read more
Source§

impl NamedType for PlayState

Source§

fn sid() -> SpectaID

Source§

fn named_data_type( + type_map: &mut TypeCollection, + generics: &[DataType], +) -> NamedDataType

this is equivalent to [Type::inline] but returns a [NamedDataType] instead.
Source§

fn definition_named_data_type(type_map: &mut TypeCollection) -> NamedDataType

this is equivalent to [Type::definition] but returns a [NamedDataType] instead.
Source§

impl Serialize for PlayState

Source§

fn serialize<__S>(&self, __serializer: __S) -> Result<__S::Ok, __S::Error>
where + __S: Serializer,

Serialize this value into the given Serde serializer. Read more
Source§

impl Type for PlayState

Source§

fn inline(type_map: &mut TypeCollection, generics: Generics<'_>) -> DataType

Returns the definition of a type using the provided generics. Read more
Source§

fn reference(type_map: &mut TypeCollection, generics: &[DataType]) -> Reference

Generates a datatype corresponding to a reference to this type, +as determined by its category. Getting a reference to a type implies that +it should belong in the type map (since it has to be referenced from somewhere), +so the output of definition will be put into the type map.
Source§

impl Flatten for PlayState

Auto Trait Implementations§

Blanket Implementations§

Source§

impl<T> Any for T
where + T: 'static + ?Sized,

Source§

fn type_id(&self) -> TypeId

Gets the TypeId of self. Read more
Source§

impl<T> Borrow<T> for T
where + T: ?Sized,

Source§

fn borrow(&self) -> &T

Immutably borrows from an owned value. Read more
Source§

impl<T> BorrowMut<T> for T
where + T: ?Sized,

Source§

fn borrow_mut(&mut self) -> &mut T

Mutably borrows from an owned value. Read more
Source§

impl<T> CloneToUninit for T
where + T: Clone,

Source§

unsafe fn clone_to_uninit(&self, dest: *mut u8)

🔬This is a nightly-only experimental API. (clone_to_uninit)
Performs copy-assignment from self to dest. Read more
§

impl<'de, D, R> CommandArg<'de, R> for D
where + D: Deserialize<'de>, + R: Runtime,

§

fn from_command(command: CommandItem<'de, R>) -> Result<D, InvokeError>

Derives an instance of Self from the [CommandItem]. Read more
Source§

impl<T> From<T> for T

Source§

fn from(t: T) -> T

Returns the argument unchanged.

+
§

impl<T> FunctionArg for T
where + T: Type,

§

fn to_datatype(type_map: &mut TypeCollection) -> Option<DataType>

Gets the type of an argument as a [DataType]. Read more
§

impl<T> Instrument for T

§

fn instrument(self, span: Span) -> Instrumented<Self>

Instruments this type with the provided [Span], returning an +Instrumented wrapper. Read more
§

fn in_current_span(self) -> Instrumented<Self>

Instruments this type with the current Span, returning an +Instrumented wrapper. Read more
Source§

impl<T, U> Into<U> for T
where + U: From<T>,

Source§

fn into(self) -> U

Calls U::from(self).

+

That is, this conversion is whatever the implementation of +From<T> for U chooses to do.

+
§

impl<T> IpcResponse for T
where + T: Serialize,

§

fn body(self) -> Result<InvokeResponseBody, Error>

Resolve the IPC response body.
§

impl<T> PolicyExt for T
where + T: ?Sized,

§

fn and<P, B, E>(self, other: P) -> And<T, P>
where + T: Policy<B, E>, + P: Policy<B, E>,

Create a new Policy that returns [Action::Follow] only if self and other return +Action::Follow. Read more
§

fn or<P, B, E>(self, other: P) -> Or<T, P>
where + T: Policy<B, E>, + P: Policy<B, E>,

Create a new Policy that returns [Action::Follow] if either self or other returns +Action::Follow. Read more
Source§

impl<T> Same for T

Source§

type Output = T

Should always be Self
§

impl<T> ScopeObject for T
where + T: Send + Sync + Debug + DeserializeOwned + 'static,

§

type Error = Error

The error type.
§

fn deserialize<R>( + _app: &AppHandle<R>, + raw: Value, +) -> Result<T, <T as ScopeObject>::Error>
where + R: Runtime,

Deserialize the raw scope value.
Source§

impl<T> Serialize for T
where + T: Serialize + ?Sized,

Source§

fn erased_serialize(&self, serializer: &mut dyn Serializer) -> Result<(), Error>

Source§

fn do_erased_serialize( + &self, + serializer: &mut dyn Serializer, +) -> Result<(), ErrorImpl>

Source§

impl<T> ToOwned for T
where + T: Clone,

Source§

type Owned = T

The resulting type after obtaining ownership.
Source§

fn to_owned(&self) -> T

Creates owned data from borrowed data, usually by cloning. Read more
Source§

fn clone_into(&self, target: &mut T)

Uses borrowed data to replace owned data, usually by cloning. Read more
Source§

impl<T, U> TryFrom<U> for T
where + U: Into<T>,

Source§

type Error = Infallible

The type returned in the event of a conversion error.
Source§

fn try_from(value: U) -> Result<T, <T as TryFrom<U>>::Error>

Performs the conversion.
Source§

impl<T, U> TryInto<U> for T
where + U: TryFrom<T>,

Source§

type Error = <U as TryFrom<T>>::Error

The type returned in the event of a conversion error.
Source§

fn try_into(self) -> Result<U, <U as TryFrom<T>>::Error>

Performs the conversion.
§

impl<V, T> VZip<V> for T
where + V: MultiLane<T>,

§

fn vzip(self) -> V

§

impl<T> WithSubscriber for T

§

fn with_subscriber<S>(self, subscriber: S) -> WithDispatch<Self>
where + S: Into<Dispatch>,

Attaches the provided Subscriber to this type, returning a +[WithDispatch] wrapper. Read more
§

fn with_current_subscriber(self) -> WithDispatch<Self>

Attaches the current default Subscriber to this type, returning a +[WithDispatch] wrapper. Read more
Source§

impl<T> DeserializeOwned for T
where + T: for<'de> Deserialize<'de>,

§

impl<T> UserEvent for T
where + T: Debug + Clone + Send + 'static,

\ No newline at end of file diff --git a/api/jellytau_lib/jellyfin/client/struct.SessionInfo.html b/api/jellytau_lib/jellyfin/client/struct.SessionInfo.html new file mode 100644 index 00000000..4040fab5 --- /dev/null +++ b/api/jellytau_lib/jellyfin/client/struct.SessionInfo.html @@ -0,0 +1,65 @@ +SessionInfo in jellytau_lib::jellyfin::client - RustSkip to main content

SessionInfo

Struct SessionInfo 

Source
pub struct SessionInfo {
Show 14 fields + pub id: Option<String>, + pub user_id: Option<String>, + pub user_name: Option<String>, + pub client: Option<String>, + pub device_name: Option<String>, + pub device_id: Option<String>, + pub application_version: Option<String>, + pub is_active: Option<bool>, + pub supports_media_control: Option<bool>, + pub supports_remote_control: bool, + pub now_playing_item: Option<NowPlayingItem>, + pub play_state: Option<PlayState>, + pub playable_media_types: Option<Vec<String>>, + pub supported_commands: Option<Vec<String>>, +
}
Expand description

Session information from Jellyfin

+

Fields§

§id: Option<String>§user_id: Option<String>§user_name: Option<String>§client: Option<String>§device_name: Option<String>§device_id: Option<String>§application_version: Option<String>§is_active: Option<bool>§supports_media_control: Option<bool>§supports_remote_control: bool§now_playing_item: Option<NowPlayingItem>§play_state: Option<PlayState>§playable_media_types: Option<Vec<String>>§supported_commands: Option<Vec<String>>

Trait Implementations§

Source§

impl Clone for SessionInfo

Source§

fn clone(&self) -> SessionInfo

Returns a duplicate of the value. Read more
1.0.0 (const: unstable) · Source§

fn clone_from(&mut self, source: &Self)

Performs copy-assignment from source. Read more
Source§

impl Debug for SessionInfo

Source§

fn fmt(&self, f: &mut Formatter<'_>) -> Result

Formats the value using the given formatter. Read more
Source§

impl<'de> Deserialize<'de> for SessionInfo

Source§

fn deserialize<__D>(__deserializer: __D) -> Result<Self, __D::Error>
where + __D: Deserializer<'de>,

Deserialize this value from the given Serde deserializer. Read more
Source§

impl NamedType for SessionInfo

Source§

fn sid() -> SpectaID

Source§

fn named_data_type( + type_map: &mut TypeCollection, + generics: &[DataType], +) -> NamedDataType

this is equivalent to [Type::inline] but returns a [NamedDataType] instead.
Source§

fn definition_named_data_type(type_map: &mut TypeCollection) -> NamedDataType

this is equivalent to [Type::definition] but returns a [NamedDataType] instead.
Source§

impl Serialize for SessionInfo

Source§

fn serialize<__S>(&self, __serializer: __S) -> Result<__S::Ok, __S::Error>
where + __S: Serializer,

Serialize this value into the given Serde serializer. Read more
Source§

impl Type for SessionInfo

Source§

fn inline(type_map: &mut TypeCollection, generics: Generics<'_>) -> DataType

Returns the definition of a type using the provided generics. Read more
Source§

fn reference(type_map: &mut TypeCollection, generics: &[DataType]) -> Reference

Generates a datatype corresponding to a reference to this type, +as determined by its category. Getting a reference to a type implies that +it should belong in the type map (since it has to be referenced from somewhere), +so the output of definition will be put into the type map.
Source§

impl Flatten for SessionInfo

Auto Trait Implementations§

Blanket Implementations§

Source§

impl<T> Any for T
where + T: 'static + ?Sized,

Source§

fn type_id(&self) -> TypeId

Gets the TypeId of self. Read more
Source§

impl<T> Borrow<T> for T
where + T: ?Sized,

Source§

fn borrow(&self) -> &T

Immutably borrows from an owned value. Read more
Source§

impl<T> BorrowMut<T> for T
where + T: ?Sized,

Source§

fn borrow_mut(&mut self) -> &mut T

Mutably borrows from an owned value. Read more
Source§

impl<T> CloneToUninit for T
where + T: Clone,

Source§

unsafe fn clone_to_uninit(&self, dest: *mut u8)

🔬This is a nightly-only experimental API. (clone_to_uninit)
Performs copy-assignment from self to dest. Read more
§

impl<'de, D, R> CommandArg<'de, R> for D
where + D: Deserialize<'de>, + R: Runtime,

§

fn from_command(command: CommandItem<'de, R>) -> Result<D, InvokeError>

Derives an instance of Self from the [CommandItem]. Read more
Source§

impl<T> From<T> for T

Source§

fn from(t: T) -> T

Returns the argument unchanged.

+
§

impl<T> FunctionArg for T
where + T: Type,

§

fn to_datatype(type_map: &mut TypeCollection) -> Option<DataType>

Gets the type of an argument as a [DataType]. Read more
§

impl<T> Instrument for T

§

fn instrument(self, span: Span) -> Instrumented<Self>

Instruments this type with the provided [Span], returning an +Instrumented wrapper. Read more
§

fn in_current_span(self) -> Instrumented<Self>

Instruments this type with the current Span, returning an +Instrumented wrapper. Read more
Source§

impl<T, U> Into<U> for T
where + U: From<T>,

Source§

fn into(self) -> U

Calls U::from(self).

+

That is, this conversion is whatever the implementation of +From<T> for U chooses to do.

+
§

impl<T> IpcResponse for T
where + T: Serialize,

§

fn body(self) -> Result<InvokeResponseBody, Error>

Resolve the IPC response body.
§

impl<T> PolicyExt for T
where + T: ?Sized,

§

fn and<P, B, E>(self, other: P) -> And<T, P>
where + T: Policy<B, E>, + P: Policy<B, E>,

Create a new Policy that returns [Action::Follow] only if self and other return +Action::Follow. Read more
§

fn or<P, B, E>(self, other: P) -> Or<T, P>
where + T: Policy<B, E>, + P: Policy<B, E>,

Create a new Policy that returns [Action::Follow] if either self or other returns +Action::Follow. Read more
Source§

impl<T> Same for T

Source§

type Output = T

Should always be Self
§

impl<T> ScopeObject for T
where + T: Send + Sync + Debug + DeserializeOwned + 'static,

§

type Error = Error

The error type.
§

fn deserialize<R>( + _app: &AppHandle<R>, + raw: Value, +) -> Result<T, <T as ScopeObject>::Error>
where + R: Runtime,

Deserialize the raw scope value.
Source§

impl<T> Serialize for T
where + T: Serialize + ?Sized,

Source§

fn erased_serialize(&self, serializer: &mut dyn Serializer) -> Result<(), Error>

Source§

fn do_erased_serialize( + &self, + serializer: &mut dyn Serializer, +) -> Result<(), ErrorImpl>

Source§

impl<T> ToOwned for T
where + T: Clone,

Source§

type Owned = T

The resulting type after obtaining ownership.
Source§

fn to_owned(&self) -> T

Creates owned data from borrowed data, usually by cloning. Read more
Source§

fn clone_into(&self, target: &mut T)

Uses borrowed data to replace owned data, usually by cloning. Read more
Source§

impl<T, U> TryFrom<U> for T
where + U: Into<T>,

Source§

type Error = Infallible

The type returned in the event of a conversion error.
Source§

fn try_from(value: U) -> Result<T, <T as TryFrom<U>>::Error>

Performs the conversion.
Source§

impl<T, U> TryInto<U> for T
where + U: TryFrom<T>,

Source§

type Error = <U as TryFrom<T>>::Error

The type returned in the event of a conversion error.
Source§

fn try_into(self) -> Result<U, <U as TryFrom<T>>::Error>

Performs the conversion.
§

impl<V, T> VZip<V> for T
where + V: MultiLane<T>,

§

fn vzip(self) -> V

§

impl<T> WithSubscriber for T

§

fn with_subscriber<S>(self, subscriber: S) -> WithDispatch<Self>
where + S: Into<Dispatch>,

Attaches the provided Subscriber to this type, returning a +[WithDispatch] wrapper. Read more
§

fn with_current_subscriber(self) -> WithDispatch<Self>

Attaches the current default Subscriber to this type, returning a +[WithDispatch] wrapper. Read more
Source§

impl<T> DeserializeOwned for T
where + T: for<'de> Deserialize<'de>,

§

impl<T> UserEvent for T
where + T: Debug + Clone + Send + 'static,

\ No newline at end of file diff --git a/api/jellytau_lib/jellyfin/http_client/constant.APP_NAME.html b/api/jellytau_lib/jellyfin/http_client/constant.APP_NAME.html new file mode 100644 index 00000000..d2175ddb --- /dev/null +++ b/api/jellytau_lib/jellyfin/http_client/constant.APP_NAME.html @@ -0,0 +1 @@ +APP_NAME in jellytau_lib::jellyfin::http_client - RustSkip to main content

APP_NAME

Constant APP_NAME 

Source
const APP_NAME: &str = "JellyTau";
\ No newline at end of file diff --git a/api/jellytau_lib/jellyfin/http_client/constant.APP_VERSION.html b/api/jellytau_lib/jellyfin/http_client/constant.APP_VERSION.html new file mode 100644 index 00000000..65aa868c --- /dev/null +++ b/api/jellytau_lib/jellyfin/http_client/constant.APP_VERSION.html @@ -0,0 +1 @@ +APP_VERSION in jellytau_lib::jellyfin::http_client - RustSkip to main content

APP_VERSION

Constant APP_VERSION 

Source
const APP_VERSION: &str = "0.1.0";
\ No newline at end of file diff --git a/api/jellytau_lib/jellyfin/http_client/constant.DEFAULT_MAX_RETRIES.html b/api/jellytau_lib/jellyfin/http_client/constant.DEFAULT_MAX_RETRIES.html new file mode 100644 index 00000000..322993c9 --- /dev/null +++ b/api/jellytau_lib/jellyfin/http_client/constant.DEFAULT_MAX_RETRIES.html @@ -0,0 +1 @@ +DEFAULT_MAX_RETRIES in jellytau_lib::jellyfin::http_client - RustSkip to main content

DEFAULT_MAX_RETRIES

Constant DEFAULT_MAX_RETRIES 

Source
const DEFAULT_MAX_RETRIES: u32 = 3;
\ No newline at end of file diff --git a/api/jellytau_lib/jellyfin/http_client/constant.DEFAULT_TIMEOUT_MS.html b/api/jellytau_lib/jellyfin/http_client/constant.DEFAULT_TIMEOUT_MS.html new file mode 100644 index 00000000..83420808 --- /dev/null +++ b/api/jellytau_lib/jellyfin/http_client/constant.DEFAULT_TIMEOUT_MS.html @@ -0,0 +1 @@ +DEFAULT_TIMEOUT_MS in jellytau_lib::jellyfin::http_client - RustSkip to main content

DEFAULT_TIMEOUT_MS

Constant DEFAULT_TIMEOUT_MS 

Source
const DEFAULT_TIMEOUT_MS: u64 = 30000;
\ No newline at end of file diff --git a/api/jellytau_lib/jellyfin/http_client/constant.RETRY_DELAYS_MS.html b/api/jellytau_lib/jellyfin/http_client/constant.RETRY_DELAYS_MS.html new file mode 100644 index 00000000..be543ac5 --- /dev/null +++ b/api/jellytau_lib/jellyfin/http_client/constant.RETRY_DELAYS_MS.html @@ -0,0 +1 @@ +RETRY_DELAYS_MS in jellytau_lib::jellyfin::http_client - RustSkip to main content

RETRY_DELAYS_MS

Constant RETRY_DELAYS_MS 

Source
const RETRY_DELAYS_MS: [u64; 3];
\ No newline at end of file diff --git a/api/jellytau_lib/jellyfin/http_client/enum.ErrorKind.html b/api/jellytau_lib/jellyfin/http_client/enum.ErrorKind.html new file mode 100644 index 00000000..cf6a045d --- /dev/null +++ b/api/jellytau_lib/jellyfin/http_client/enum.ErrorKind.html @@ -0,0 +1,34 @@ +ErrorKind in jellytau_lib::jellyfin::http_client - RustSkip to main content

ErrorKind

Enum ErrorKind 

Source
pub enum ErrorKind {
+    Network,
+    Authentication,
+    Server,
+    Client,
+}
Expand description

Error classification for retry logic

+

Variants§

§

Network

§

Authentication

§

Server

§

Client

Trait Implementations§

Source§

impl Clone for ErrorKind

Source§

fn clone(&self) -> ErrorKind

Returns a duplicate of the value. Read more
1.0.0 (const: unstable) · Source§

fn clone_from(&mut self, source: &Self)

Performs copy-assignment from source. Read more
Source§

impl Debug for ErrorKind

Source§

fn fmt(&self, f: &mut Formatter<'_>) -> Result

Formats the value using the given formatter. Read more
Source§

impl PartialEq for ErrorKind

Source§

fn eq(&self, other: &ErrorKind) -> bool

Tests for self and other values to be equal, and is used by ==.
1.0.0 (const: unstable) · Source§

fn ne(&self, other: &Rhs) -> bool

Tests for !=. The default implementation is almost always sufficient, +and should not be overridden without very good reason.
Source§

impl StructuralPartialEq for ErrorKind

Auto Trait Implementations§

Blanket Implementations§

Source§

impl<T> Any for T
where + T: 'static + ?Sized,

Source§

fn type_id(&self) -> TypeId

Gets the TypeId of self. Read more
Source§

impl<T> Borrow<T> for T
where + T: ?Sized,

Source§

fn borrow(&self) -> &T

Immutably borrows from an owned value. Read more
Source§

impl<T> BorrowMut<T> for T
where + T: ?Sized,

Source§

fn borrow_mut(&mut self) -> &mut T

Mutably borrows from an owned value. Read more
Source§

impl<T> CloneToUninit for T
where + T: Clone,

Source§

unsafe fn clone_to_uninit(&self, dest: *mut u8)

🔬This is a nightly-only experimental API. (clone_to_uninit)
Performs copy-assignment from self to dest. Read more
Source§

impl<T> From<T> for T

Source§

fn from(t: T) -> T

Returns the argument unchanged.

+
§

impl<T> Instrument for T

§

fn instrument(self, span: Span) -> Instrumented<Self>

Instruments this type with the provided [Span], returning an +Instrumented wrapper. Read more
§

fn in_current_span(self) -> Instrumented<Self>

Instruments this type with the current Span, returning an +Instrumented wrapper. Read more
Source§

impl<T, U> Into<U> for T
where + U: From<T>,

Source§

fn into(self) -> U

Calls U::from(self).

+

That is, this conversion is whatever the implementation of +From<T> for U chooses to do.

+
§

impl<T> PolicyExt for T
where + T: ?Sized,

§

fn and<P, B, E>(self, other: P) -> And<T, P>
where + T: Policy<B, E>, + P: Policy<B, E>,

Create a new Policy that returns [Action::Follow] only if self and other return +Action::Follow. Read more
§

fn or<P, B, E>(self, other: P) -> Or<T, P>
where + T: Policy<B, E>, + P: Policy<B, E>,

Create a new Policy that returns [Action::Follow] if either self or other returns +Action::Follow. Read more
Source§

impl<T> Same for T

Source§

type Output = T

Should always be Self
Source§

impl<T> ToOwned for T
where + T: Clone,

Source§

type Owned = T

The resulting type after obtaining ownership.
Source§

fn to_owned(&self) -> T

Creates owned data from borrowed data, usually by cloning. Read more
Source§

fn clone_into(&self, target: &mut T)

Uses borrowed data to replace owned data, usually by cloning. Read more
Source§

impl<T, U> TryFrom<U> for T
where + U: Into<T>,

Source§

type Error = Infallible

The type returned in the event of a conversion error.
Source§

fn try_from(value: U) -> Result<T, <T as TryFrom<U>>::Error>

Performs the conversion.
Source§

impl<T, U> TryInto<U> for T
where + U: TryFrom<T>,

Source§

type Error = <U as TryFrom<T>>::Error

The type returned in the event of a conversion error.
Source§

fn try_into(self) -> Result<U, <U as TryFrom<T>>::Error>

Performs the conversion.
§

impl<V, T> VZip<V> for T
where + V: MultiLane<T>,

§

fn vzip(self) -> V

§

impl<T> WithSubscriber for T

§

fn with_subscriber<S>(self, subscriber: S) -> WithDispatch<Self>
where + S: Into<Dispatch>,

Attaches the provided Subscriber to this type, returning a +[WithDispatch] wrapper. Read more
§

fn with_current_subscriber(self) -> WithDispatch<Self>

Attaches the current default Subscriber to this type, returning a +[WithDispatch] wrapper. Read more
§

impl<T> UserEvent for T
where + T: Debug + Clone + Send + 'static,

\ No newline at end of file diff --git a/api/jellytau_lib/jellyfin/http_client/index.html b/api/jellytau_lib/jellyfin/http_client/index.html new file mode 100644 index 00000000..cf8495b9 --- /dev/null +++ b/api/jellytau_lib/jellyfin/http_client/index.html @@ -0,0 +1 @@ +jellytau_lib::jellyfin::http_client - RustSkip to main content

Module http_client

Module http_client 

Source

Structs§

HttpClient
Enhanced HTTP client with retry logic and error classification
HttpConfig
HTTP client configuration

Enums§

ErrorKind
Error classification for retry logic

Constants§

APP_NAME 🔒
APP_VERSION 🔒
DEFAULT_MAX_RETRIES 🔒
DEFAULT_TIMEOUT_MS 🔒
RETRY_DELAYS_MS 🔒
\ No newline at end of file diff --git a/api/jellytau_lib/jellyfin/http_client/sidebar-items.js b/api/jellytau_lib/jellyfin/http_client/sidebar-items.js new file mode 100644 index 00000000..02353ffd --- /dev/null +++ b/api/jellytau_lib/jellyfin/http_client/sidebar-items.js @@ -0,0 +1 @@ +window.SIDEBAR_ITEMS = {"constant":["APP_NAME","APP_VERSION","DEFAULT_MAX_RETRIES","DEFAULT_TIMEOUT_MS","RETRY_DELAYS_MS"],"enum":["ErrorKind"],"struct":["HttpClient","HttpConfig"]}; \ No newline at end of file diff --git a/api/jellytau_lib/jellyfin/http_client/struct.HttpClient.html b/api/jellytau_lib/jellyfin/http_client/struct.HttpClient.html new file mode 100644 index 00000000..7307a633 --- /dev/null +++ b/api/jellytau_lib/jellyfin/http_client/struct.HttpClient.html @@ -0,0 +1,47 @@ +HttpClient in jellytau_lib::jellyfin::http_client - RustSkip to main content

HttpClient

Struct HttpClient 

Source
pub struct HttpClient {
+    pub(crate) client: Client,
+    config: HttpConfig,
+}
Expand description

Enhanced HTTP client with retry logic and error classification

+

Fields§

§client: Client§config: HttpConfig

Implementations§

Source§

impl HttpClient

Source

pub fn new(config: HttpConfig) -> Result<Self, String>

Create a new HTTP client with default configuration

+
Source

fn get_device_name() -> &'static str

Get device name based on platform

+
Source

pub fn build_auth_header(access_token: Option<&str>, device_id: &str) -> String

Build the X-Emby-Authorization header value

+
Source

pub fn classify_error(error: &Error) -> ErrorKind

Classify an error for retry logic

+
Source

pub fn should_retry(error: &Error) -> bool

Check if a request should be retried based on the error

+
Source

pub async fn request_with_retry( + &self, + request: Request, +) -> Result<Response, Error>

Make a request with automatic retry on network errors

+
Source

pub async fn get_json_fast<T: DeserializeOwned>( + &self, + url: &str, +) -> Result<T, String>

Make a GET request and deserialize JSON with a short timeout and no retries.

+

Intended for the initial “connect to server” probe on the login screen: +a wrong/unreachable URL must fail fast instead of burning through the +default 30s-per-attempt timeout and exponential backoff retries.

+
Source

pub async fn ping(&self, url: &str) -> bool

Quick ping to check if a server is reachable (no retry)

+

Trait Implementations§

Source§

impl Clone for HttpClient

Source§

fn clone(&self) -> HttpClient

Returns a duplicate of the value. Read more
1.0.0 (const: unstable) · Source§

fn clone_from(&mut self, source: &Self)

Performs copy-assignment from source. Read more

Auto Trait Implementations§

Blanket Implementations§

Source§

impl<T> Any for T
where + T: 'static + ?Sized,

Source§

fn type_id(&self) -> TypeId

Gets the TypeId of self. Read more
Source§

impl<T> Borrow<T> for T
where + T: ?Sized,

Source§

fn borrow(&self) -> &T

Immutably borrows from an owned value. Read more
Source§

impl<T> BorrowMut<T> for T
where + T: ?Sized,

Source§

fn borrow_mut(&mut self) -> &mut T

Mutably borrows from an owned value. Read more
Source§

impl<T> CloneToUninit for T
where + T: Clone,

Source§

unsafe fn clone_to_uninit(&self, dest: *mut u8)

🔬This is a nightly-only experimental API. (clone_to_uninit)
Performs copy-assignment from self to dest. Read more
Source§

impl<T> From<T> for T

Source§

fn from(t: T) -> T

Returns the argument unchanged.

+
§

impl<T> Instrument for T

§

fn instrument(self, span: Span) -> Instrumented<Self>

Instruments this type with the provided [Span], returning an +Instrumented wrapper. Read more
§

fn in_current_span(self) -> Instrumented<Self>

Instruments this type with the current Span, returning an +Instrumented wrapper. Read more
Source§

impl<T, U> Into<U> for T
where + U: From<T>,

Source§

fn into(self) -> U

Calls U::from(self).

+

That is, this conversion is whatever the implementation of +From<T> for U chooses to do.

+
§

impl<T> PolicyExt for T
where + T: ?Sized,

§

fn and<P, B, E>(self, other: P) -> And<T, P>
where + T: Policy<B, E>, + P: Policy<B, E>,

Create a new Policy that returns [Action::Follow] only if self and other return +Action::Follow. Read more
§

fn or<P, B, E>(self, other: P) -> Or<T, P>
where + T: Policy<B, E>, + P: Policy<B, E>,

Create a new Policy that returns [Action::Follow] if either self or other returns +Action::Follow. Read more
Source§

impl<T> Same for T

Source§

type Output = T

Should always be Self
Source§

impl<T> ToOwned for T
where + T: Clone,

Source§

type Owned = T

The resulting type after obtaining ownership.
Source§

fn to_owned(&self) -> T

Creates owned data from borrowed data, usually by cloning. Read more
Source§

fn clone_into(&self, target: &mut T)

Uses borrowed data to replace owned data, usually by cloning. Read more
Source§

impl<T, U> TryFrom<U> for T
where + U: Into<T>,

Source§

type Error = Infallible

The type returned in the event of a conversion error.
Source§

fn try_from(value: U) -> Result<T, <T as TryFrom<U>>::Error>

Performs the conversion.
Source§

impl<T, U> TryInto<U> for T
where + U: TryFrom<T>,

Source§

type Error = <U as TryFrom<T>>::Error

The type returned in the event of a conversion error.
Source§

fn try_into(self) -> Result<U, <U as TryFrom<T>>::Error>

Performs the conversion.
§

impl<V, T> VZip<V> for T
where + V: MultiLane<T>,

§

fn vzip(self) -> V

§

impl<T> WithSubscriber for T

§

fn with_subscriber<S>(self, subscriber: S) -> WithDispatch<Self>
where + S: Into<Dispatch>,

Attaches the provided Subscriber to this type, returning a +[WithDispatch] wrapper. Read more
§

fn with_current_subscriber(self) -> WithDispatch<Self>

Attaches the current default Subscriber to this type, returning a +[WithDispatch] wrapper. Read more
\ No newline at end of file diff --git a/api/jellytau_lib/jellyfin/http_client/struct.HttpConfig.html b/api/jellytau_lib/jellyfin/http_client/struct.HttpConfig.html new file mode 100644 index 00000000..bf7d68e9 --- /dev/null +++ b/api/jellytau_lib/jellyfin/http_client/struct.HttpConfig.html @@ -0,0 +1,32 @@ +HttpConfig in jellytau_lib::jellyfin::http_client - RustSkip to main content

HttpConfig

Struct HttpConfig 

Source
pub struct HttpConfig {
+    pub timeout: Duration,
+    pub max_retries: u32,
+}
Expand description

HTTP client configuration

+

Fields§

§timeout: Duration§max_retries: u32

Trait Implementations§

Source§

impl Clone for HttpConfig

Source§

fn clone(&self) -> HttpConfig

Returns a duplicate of the value. Read more
1.0.0 (const: unstable) · Source§

fn clone_from(&mut self, source: &Self)

Performs copy-assignment from source. Read more
Source§

impl Debug for HttpConfig

Source§

fn fmt(&self, f: &mut Formatter<'_>) -> Result

Formats the value using the given formatter. Read more
Source§

impl Default for HttpConfig

Source§

fn default() -> Self

Returns the “default value” for a type. Read more

Auto Trait Implementations§

Blanket Implementations§

Source§

impl<T> Any for T
where + T: 'static + ?Sized,

Source§

fn type_id(&self) -> TypeId

Gets the TypeId of self. Read more
Source§

impl<T> Borrow<T> for T
where + T: ?Sized,

Source§

fn borrow(&self) -> &T

Immutably borrows from an owned value. Read more
Source§

impl<T> BorrowMut<T> for T
where + T: ?Sized,

Source§

fn borrow_mut(&mut self) -> &mut T

Mutably borrows from an owned value. Read more
Source§

impl<T> CloneToUninit for T
where + T: Clone,

Source§

unsafe fn clone_to_uninit(&self, dest: *mut u8)

🔬This is a nightly-only experimental API. (clone_to_uninit)
Performs copy-assignment from self to dest. Read more
Source§

impl<T> From<T> for T

Source§

fn from(t: T) -> T

Returns the argument unchanged.

+
§

impl<T> Instrument for T

§

fn instrument(self, span: Span) -> Instrumented<Self>

Instruments this type with the provided [Span], returning an +Instrumented wrapper. Read more
§

fn in_current_span(self) -> Instrumented<Self>

Instruments this type with the current Span, returning an +Instrumented wrapper. Read more
Source§

impl<T, U> Into<U> for T
where + U: From<T>,

Source§

fn into(self) -> U

Calls U::from(self).

+

That is, this conversion is whatever the implementation of +From<T> for U chooses to do.

+
§

impl<T> NoneValue for T
where + T: Default,

§

type NoneType = T

§

fn null_value() -> T

The none-equivalent value.
§

impl<T> PolicyExt for T
where + T: ?Sized,

§

fn and<P, B, E>(self, other: P) -> And<T, P>
where + T: Policy<B, E>, + P: Policy<B, E>,

Create a new Policy that returns [Action::Follow] only if self and other return +Action::Follow. Read more
§

fn or<P, B, E>(self, other: P) -> Or<T, P>
where + T: Policy<B, E>, + P: Policy<B, E>,

Create a new Policy that returns [Action::Follow] if either self or other returns +Action::Follow. Read more
Source§

impl<T> Same for T

Source§

type Output = T

Should always be Self
Source§

impl<T> ToOwned for T
where + T: Clone,

Source§

type Owned = T

The resulting type after obtaining ownership.
Source§

fn to_owned(&self) -> T

Creates owned data from borrowed data, usually by cloning. Read more
Source§

fn clone_into(&self, target: &mut T)

Uses borrowed data to replace owned data, usually by cloning. Read more
Source§

impl<T, U> TryFrom<U> for T
where + U: Into<T>,

Source§

type Error = Infallible

The type returned in the event of a conversion error.
Source§

fn try_from(value: U) -> Result<T, <T as TryFrom<U>>::Error>

Performs the conversion.
Source§

impl<T, U> TryInto<U> for T
where + U: TryFrom<T>,

Source§

type Error = <U as TryFrom<T>>::Error

The type returned in the event of a conversion error.
Source§

fn try_into(self) -> Result<U, <U as TryFrom<T>>::Error>

Performs the conversion.
§

impl<V, T> VZip<V> for T
where + V: MultiLane<T>,

§

fn vzip(self) -> V

§

impl<T> WithSubscriber for T

§

fn with_subscriber<S>(self, subscriber: S) -> WithDispatch<Self>
where + S: Into<Dispatch>,

Attaches the provided Subscriber to this type, returning a +[WithDispatch] wrapper. Read more
§

fn with_current_subscriber(self) -> WithDispatch<Self>

Attaches the current default Subscriber to this type, returning a +[WithDispatch] wrapper. Read more
§

impl<T> UserEvent for T
where + T: Debug + Clone + Send + 'static,

\ No newline at end of file diff --git a/api/jellytau_lib/jellyfin/index.html b/api/jellytau_lib/jellyfin/index.html new file mode 100644 index 00000000..5c5de55a --- /dev/null +++ b/api/jellytau_lib/jellyfin/index.html @@ -0,0 +1 @@ +jellytau_lib::jellyfin - RustSkip to main content

Module jellyfin

Module jellyfin 

Source

Re-exports§

pub use client::JellyfinClient;
pub use client::NowPlayingItem;
pub use http_client::HttpClient;
pub use http_client::HttpConfig;
pub use types::*;

Modules§

client
TRACES: UR-009 | JA-001, JA-002, JA-003, JA-004, JA-007, JA-010, JA-011, JA-012, JA-017, JA-021 | IR-009, IR-010, IR-011
http_client
types
\ No newline at end of file diff --git a/api/jellytau_lib/jellyfin/sidebar-items.js b/api/jellytau_lib/jellyfin/sidebar-items.js new file mode 100644 index 00000000..25943552 --- /dev/null +++ b/api/jellytau_lib/jellyfin/sidebar-items.js @@ -0,0 +1 @@ +window.SIDEBAR_ITEMS = {"mod":["client","http_client","types"]}; \ No newline at end of file diff --git a/api/jellytau_lib/jellyfin/types/index.html b/api/jellytau_lib/jellyfin/types/index.html new file mode 100644 index 00000000..9271438d --- /dev/null +++ b/api/jellytau_lib/jellyfin/types/index.html @@ -0,0 +1 @@ +jellytau_lib::jellyfin::types - RustSkip to main content

Module types

Module types 

Source

Structs§

JellyfinConfig
Configuration for Jellyfin API client
PlaybackProgressRequest
Request body for reporting playback progress
PlaybackStartRequest
Request body for reporting playback start
PlaybackStoppedRequest
Request body for reporting playback stopped
\ No newline at end of file diff --git a/api/jellytau_lib/jellyfin/types/sidebar-items.js b/api/jellytau_lib/jellyfin/types/sidebar-items.js new file mode 100644 index 00000000..60eb3b67 --- /dev/null +++ b/api/jellytau_lib/jellyfin/types/sidebar-items.js @@ -0,0 +1 @@ +window.SIDEBAR_ITEMS = {"struct":["JellyfinConfig","PlaybackProgressRequest","PlaybackStartRequest","PlaybackStoppedRequest"]}; \ No newline at end of file diff --git a/api/jellytau_lib/jellyfin/types/struct.JellyfinConfig.html b/api/jellytau_lib/jellyfin/types/struct.JellyfinConfig.html new file mode 100644 index 00000000..34724df0 --- /dev/null +++ b/api/jellytau_lib/jellyfin/types/struct.JellyfinConfig.html @@ -0,0 +1,32 @@ +JellyfinConfig in jellytau_lib::jellyfin::types - RustSkip to main content

JellyfinConfig

Struct JellyfinConfig 

Source
pub struct JellyfinConfig {
+    pub server_url: String,
+    pub access_token: String,
+    pub device_id: String,
+}
Expand description

Configuration for Jellyfin API client

+

Fields§

§server_url: String§access_token: String§device_id: String

Trait Implementations§

Source§

impl Clone for JellyfinConfig

Source§

fn clone(&self) -> JellyfinConfig

Returns a duplicate of the value. Read more
1.0.0 (const: unstable) · Source§

fn clone_from(&mut self, source: &Self)

Performs copy-assignment from source. Read more
Source§

impl Debug for JellyfinConfig

Source§

fn fmt(&self, f: &mut Formatter<'_>) -> Result

Formats the value using the given formatter. Read more

Auto Trait Implementations§

Blanket Implementations§

Source§

impl<T> Any for T
where + T: 'static + ?Sized,

Source§

fn type_id(&self) -> TypeId

Gets the TypeId of self. Read more
Source§

impl<T> Borrow<T> for T
where + T: ?Sized,

Source§

fn borrow(&self) -> &T

Immutably borrows from an owned value. Read more
Source§

impl<T> BorrowMut<T> for T
where + T: ?Sized,

Source§

fn borrow_mut(&mut self) -> &mut T

Mutably borrows from an owned value. Read more
Source§

impl<T> CloneToUninit for T
where + T: Clone,

Source§

unsafe fn clone_to_uninit(&self, dest: *mut u8)

🔬This is a nightly-only experimental API. (clone_to_uninit)
Performs copy-assignment from self to dest. Read more
Source§

impl<T> From<T> for T

Source§

fn from(t: T) -> T

Returns the argument unchanged.

+
§

impl<T> Instrument for T

§

fn instrument(self, span: Span) -> Instrumented<Self>

Instruments this type with the provided [Span], returning an +Instrumented wrapper. Read more
§

fn in_current_span(self) -> Instrumented<Self>

Instruments this type with the current Span, returning an +Instrumented wrapper. Read more
Source§

impl<T, U> Into<U> for T
where + U: From<T>,

Source§

fn into(self) -> U

Calls U::from(self).

+

That is, this conversion is whatever the implementation of +From<T> for U chooses to do.

+
§

impl<T> PolicyExt for T
where + T: ?Sized,

§

fn and<P, B, E>(self, other: P) -> And<T, P>
where + T: Policy<B, E>, + P: Policy<B, E>,

Create a new Policy that returns [Action::Follow] only if self and other return +Action::Follow. Read more
§

fn or<P, B, E>(self, other: P) -> Or<T, P>
where + T: Policy<B, E>, + P: Policy<B, E>,

Create a new Policy that returns [Action::Follow] if either self or other returns +Action::Follow. Read more
Source§

impl<T> Same for T

Source§

type Output = T

Should always be Self
Source§

impl<T> ToOwned for T
where + T: Clone,

Source§

type Owned = T

The resulting type after obtaining ownership.
Source§

fn to_owned(&self) -> T

Creates owned data from borrowed data, usually by cloning. Read more
Source§

fn clone_into(&self, target: &mut T)

Uses borrowed data to replace owned data, usually by cloning. Read more
Source§

impl<T, U> TryFrom<U> for T
where + U: Into<T>,

Source§

type Error = Infallible

The type returned in the event of a conversion error.
Source§

fn try_from(value: U) -> Result<T, <T as TryFrom<U>>::Error>

Performs the conversion.
Source§

impl<T, U> TryInto<U> for T
where + U: TryFrom<T>,

Source§

type Error = <U as TryFrom<T>>::Error

The type returned in the event of a conversion error.
Source§

fn try_into(self) -> Result<U, <U as TryFrom<T>>::Error>

Performs the conversion.
§

impl<V, T> VZip<V> for T
where + V: MultiLane<T>,

§

fn vzip(self) -> V

§

impl<T> WithSubscriber for T

§

fn with_subscriber<S>(self, subscriber: S) -> WithDispatch<Self>
where + S: Into<Dispatch>,

Attaches the provided Subscriber to this type, returning a +[WithDispatch] wrapper. Read more
§

fn with_current_subscriber(self) -> WithDispatch<Self>

Attaches the current default Subscriber to this type, returning a +[WithDispatch] wrapper. Read more
§

impl<T> UserEvent for T
where + T: Debug + Clone + Send + 'static,

\ No newline at end of file diff --git a/api/jellytau_lib/jellyfin/types/struct.PlaybackProgressRequest.html b/api/jellytau_lib/jellyfin/types/struct.PlaybackProgressRequest.html new file mode 100644 index 00000000..7ed75556 --- /dev/null +++ b/api/jellytau_lib/jellyfin/types/struct.PlaybackProgressRequest.html @@ -0,0 +1,36 @@ +PlaybackProgressRequest in jellytau_lib::jellyfin::types - RustSkip to main content

PlaybackProgressRequest

Struct PlaybackProgressRequest 

Source
pub struct PlaybackProgressRequest {
+    pub item_id: String,
+    pub position_ticks: i64,
+    pub is_paused: bool,
+    pub play_session_id: Option<String>,
+}
Expand description

Request body for reporting playback progress

+

Fields§

§item_id: String§position_ticks: i64§is_paused: bool§play_session_id: Option<String>

Trait Implementations§

Source§

impl Debug for PlaybackProgressRequest

Source§

fn fmt(&self, f: &mut Formatter<'_>) -> Result

Formats the value using the given formatter. Read more
Source§

impl Serialize for PlaybackProgressRequest

Source§

fn serialize<__S>(&self, __serializer: __S) -> Result<__S::Ok, __S::Error>
where + __S: Serializer,

Serialize this value into the given Serde serializer. Read more

Auto Trait Implementations§

Blanket Implementations§

Source§

impl<T> Any for T
where + T: 'static + ?Sized,

Source§

fn type_id(&self) -> TypeId

Gets the TypeId of self. Read more
Source§

impl<T> Borrow<T> for T
where + T: ?Sized,

Source§

fn borrow(&self) -> &T

Immutably borrows from an owned value. Read more
Source§

impl<T> BorrowMut<T> for T
where + T: ?Sized,

Source§

fn borrow_mut(&mut self) -> &mut T

Mutably borrows from an owned value. Read more
Source§

impl<T> From<T> for T

Source§

fn from(t: T) -> T

Returns the argument unchanged.

+
§

impl<T> Instrument for T

§

fn instrument(self, span: Span) -> Instrumented<Self>

Instruments this type with the provided [Span], returning an +Instrumented wrapper. Read more
§

fn in_current_span(self) -> Instrumented<Self>

Instruments this type with the current Span, returning an +Instrumented wrapper. Read more
Source§

impl<T, U> Into<U> for T
where + U: From<T>,

Source§

fn into(self) -> U

Calls U::from(self).

+

That is, this conversion is whatever the implementation of +From<T> for U chooses to do.

+
§

impl<T> IpcResponse for T
where + T: Serialize,

§

fn body(self) -> Result<InvokeResponseBody, Error>

Resolve the IPC response body.
§

impl<T> PolicyExt for T
where + T: ?Sized,

§

fn and<P, B, E>(self, other: P) -> And<T, P>
where + T: Policy<B, E>, + P: Policy<B, E>,

Create a new Policy that returns [Action::Follow] only if self and other return +Action::Follow. Read more
§

fn or<P, B, E>(self, other: P) -> Or<T, P>
where + T: Policy<B, E>, + P: Policy<B, E>,

Create a new Policy that returns [Action::Follow] if either self or other returns +Action::Follow. Read more
Source§

impl<T> Same for T

Source§

type Output = T

Should always be Self
Source§

impl<T> Serialize for T
where + T: Serialize + ?Sized,

Source§

fn erased_serialize(&self, serializer: &mut dyn Serializer) -> Result<(), Error>

Source§

fn do_erased_serialize( + &self, + serializer: &mut dyn Serializer, +) -> Result<(), ErrorImpl>

Source§

impl<T, U> TryFrom<U> for T
where + U: Into<T>,

Source§

type Error = Infallible

The type returned in the event of a conversion error.
Source§

fn try_from(value: U) -> Result<T, <T as TryFrom<U>>::Error>

Performs the conversion.
Source§

impl<T, U> TryInto<U> for T
where + U: TryFrom<T>,

Source§

type Error = <U as TryFrom<T>>::Error

The type returned in the event of a conversion error.
Source§

fn try_into(self) -> Result<U, <U as TryFrom<T>>::Error>

Performs the conversion.
§

impl<V, T> VZip<V> for T
where + V: MultiLane<T>,

§

fn vzip(self) -> V

§

impl<T> WithSubscriber for T

§

fn with_subscriber<S>(self, subscriber: S) -> WithDispatch<Self>
where + S: Into<Dispatch>,

Attaches the provided Subscriber to this type, returning a +[WithDispatch] wrapper. Read more
§

fn with_current_subscriber(self) -> WithDispatch<Self>

Attaches the current default Subscriber to this type, returning a +[WithDispatch] wrapper. Read more
\ No newline at end of file diff --git a/api/jellytau_lib/jellyfin/types/struct.PlaybackStartRequest.html b/api/jellytau_lib/jellyfin/types/struct.PlaybackStartRequest.html new file mode 100644 index 00000000..68c26a84 --- /dev/null +++ b/api/jellytau_lib/jellyfin/types/struct.PlaybackStartRequest.html @@ -0,0 +1,37 @@ +PlaybackStartRequest in jellytau_lib::jellyfin::types - RustSkip to main content

PlaybackStartRequest

Struct PlaybackStartRequest 

Source
pub struct PlaybackStartRequest {
+    pub item_id: String,
+    pub position_ticks: i64,
+    pub play_session_id: Option<String>,
+    pub play_command: String,
+    pub is_paused: bool,
+}
Expand description

Request body for reporting playback start

+

Fields§

§item_id: String§position_ticks: i64§play_session_id: Option<String>§play_command: String§is_paused: bool

Trait Implementations§

Source§

impl Debug for PlaybackStartRequest

Source§

fn fmt(&self, f: &mut Formatter<'_>) -> Result

Formats the value using the given formatter. Read more
Source§

impl Serialize for PlaybackStartRequest

Source§

fn serialize<__S>(&self, __serializer: __S) -> Result<__S::Ok, __S::Error>
where + __S: Serializer,

Serialize this value into the given Serde serializer. Read more

Auto Trait Implementations§

Blanket Implementations§

Source§

impl<T> Any for T
where + T: 'static + ?Sized,

Source§

fn type_id(&self) -> TypeId

Gets the TypeId of self. Read more
Source§

impl<T> Borrow<T> for T
where + T: ?Sized,

Source§

fn borrow(&self) -> &T

Immutably borrows from an owned value. Read more
Source§

impl<T> BorrowMut<T> for T
where + T: ?Sized,

Source§

fn borrow_mut(&mut self) -> &mut T

Mutably borrows from an owned value. Read more
Source§

impl<T> From<T> for T

Source§

fn from(t: T) -> T

Returns the argument unchanged.

+
§

impl<T> Instrument for T

§

fn instrument(self, span: Span) -> Instrumented<Self>

Instruments this type with the provided [Span], returning an +Instrumented wrapper. Read more
§

fn in_current_span(self) -> Instrumented<Self>

Instruments this type with the current Span, returning an +Instrumented wrapper. Read more
Source§

impl<T, U> Into<U> for T
where + U: From<T>,

Source§

fn into(self) -> U

Calls U::from(self).

+

That is, this conversion is whatever the implementation of +From<T> for U chooses to do.

+
§

impl<T> IpcResponse for T
where + T: Serialize,

§

fn body(self) -> Result<InvokeResponseBody, Error>

Resolve the IPC response body.
§

impl<T> PolicyExt for T
where + T: ?Sized,

§

fn and<P, B, E>(self, other: P) -> And<T, P>
where + T: Policy<B, E>, + P: Policy<B, E>,

Create a new Policy that returns [Action::Follow] only if self and other return +Action::Follow. Read more
§

fn or<P, B, E>(self, other: P) -> Or<T, P>
where + T: Policy<B, E>, + P: Policy<B, E>,

Create a new Policy that returns [Action::Follow] if either self or other returns +Action::Follow. Read more
Source§

impl<T> Same for T

Source§

type Output = T

Should always be Self
Source§

impl<T> Serialize for T
where + T: Serialize + ?Sized,

Source§

fn erased_serialize(&self, serializer: &mut dyn Serializer) -> Result<(), Error>

Source§

fn do_erased_serialize( + &self, + serializer: &mut dyn Serializer, +) -> Result<(), ErrorImpl>

Source§

impl<T, U> TryFrom<U> for T
where + U: Into<T>,

Source§

type Error = Infallible

The type returned in the event of a conversion error.
Source§

fn try_from(value: U) -> Result<T, <T as TryFrom<U>>::Error>

Performs the conversion.
Source§

impl<T, U> TryInto<U> for T
where + U: TryFrom<T>,

Source§

type Error = <U as TryFrom<T>>::Error

The type returned in the event of a conversion error.
Source§

fn try_into(self) -> Result<U, <U as TryFrom<T>>::Error>

Performs the conversion.
§

impl<V, T> VZip<V> for T
where + V: MultiLane<T>,

§

fn vzip(self) -> V

§

impl<T> WithSubscriber for T

§

fn with_subscriber<S>(self, subscriber: S) -> WithDispatch<Self>
where + S: Into<Dispatch>,

Attaches the provided Subscriber to this type, returning a +[WithDispatch] wrapper. Read more
§

fn with_current_subscriber(self) -> WithDispatch<Self>

Attaches the current default Subscriber to this type, returning a +[WithDispatch] wrapper. Read more
\ No newline at end of file diff --git a/api/jellytau_lib/jellyfin/types/struct.PlaybackStoppedRequest.html b/api/jellytau_lib/jellyfin/types/struct.PlaybackStoppedRequest.html new file mode 100644 index 00000000..5493cd3b --- /dev/null +++ b/api/jellytau_lib/jellyfin/types/struct.PlaybackStoppedRequest.html @@ -0,0 +1,35 @@ +PlaybackStoppedRequest in jellytau_lib::jellyfin::types - RustSkip to main content

PlaybackStoppedRequest

Struct PlaybackStoppedRequest 

Source
pub struct PlaybackStoppedRequest {
+    pub item_id: String,
+    pub position_ticks: i64,
+    pub play_session_id: Option<String>,
+}
Expand description

Request body for reporting playback stopped

+

Fields§

§item_id: String§position_ticks: i64§play_session_id: Option<String>

Trait Implementations§

Source§

impl Debug for PlaybackStoppedRequest

Source§

fn fmt(&self, f: &mut Formatter<'_>) -> Result

Formats the value using the given formatter. Read more
Source§

impl Serialize for PlaybackStoppedRequest

Source§

fn serialize<__S>(&self, __serializer: __S) -> Result<__S::Ok, __S::Error>
where + __S: Serializer,

Serialize this value into the given Serde serializer. Read more

Auto Trait Implementations§

Blanket Implementations§

Source§

impl<T> Any for T
where + T: 'static + ?Sized,

Source§

fn type_id(&self) -> TypeId

Gets the TypeId of self. Read more
Source§

impl<T> Borrow<T> for T
where + T: ?Sized,

Source§

fn borrow(&self) -> &T

Immutably borrows from an owned value. Read more
Source§

impl<T> BorrowMut<T> for T
where + T: ?Sized,

Source§

fn borrow_mut(&mut self) -> &mut T

Mutably borrows from an owned value. Read more
Source§

impl<T> From<T> for T

Source§

fn from(t: T) -> T

Returns the argument unchanged.

+
§

impl<T> Instrument for T

§

fn instrument(self, span: Span) -> Instrumented<Self>

Instruments this type with the provided [Span], returning an +Instrumented wrapper. Read more
§

fn in_current_span(self) -> Instrumented<Self>

Instruments this type with the current Span, returning an +Instrumented wrapper. Read more
Source§

impl<T, U> Into<U> for T
where + U: From<T>,

Source§

fn into(self) -> U

Calls U::from(self).

+

That is, this conversion is whatever the implementation of +From<T> for U chooses to do.

+
§

impl<T> IpcResponse for T
where + T: Serialize,

§

fn body(self) -> Result<InvokeResponseBody, Error>

Resolve the IPC response body.
§

impl<T> PolicyExt for T
where + T: ?Sized,

§

fn and<P, B, E>(self, other: P) -> And<T, P>
where + T: Policy<B, E>, + P: Policy<B, E>,

Create a new Policy that returns [Action::Follow] only if self and other return +Action::Follow. Read more
§

fn or<P, B, E>(self, other: P) -> Or<T, P>
where + T: Policy<B, E>, + P: Policy<B, E>,

Create a new Policy that returns [Action::Follow] if either self or other returns +Action::Follow. Read more
Source§

impl<T> Same for T

Source§

type Output = T

Should always be Self
Source§

impl<T> Serialize for T
where + T: Serialize + ?Sized,

Source§

fn erased_serialize(&self, serializer: &mut dyn Serializer) -> Result<(), Error>

Source§

fn do_erased_serialize( + &self, + serializer: &mut dyn Serializer, +) -> Result<(), ErrorImpl>

Source§

impl<T, U> TryFrom<U> for T
where + U: Into<T>,

Source§

type Error = Infallible

The type returned in the event of a conversion error.
Source§

fn try_from(value: U) -> Result<T, <T as TryFrom<U>>::Error>

Performs the conversion.
Source§

impl<T, U> TryInto<U> for T
where + U: TryFrom<T>,

Source§

type Error = <U as TryFrom<T>>::Error

The type returned in the event of a conversion error.
Source§

fn try_into(self) -> Result<U, <U as TryFrom<T>>::Error>

Performs the conversion.
§

impl<V, T> VZip<V> for T
where + V: MultiLane<T>,

§

fn vzip(self) -> V

§

impl<T> WithSubscriber for T

§

fn with_subscriber<S>(self, subscriber: S) -> WithDispatch<Self>
where + S: Into<Dispatch>,

Attaches the provided Subscriber to this type, returning a +[WithDispatch] wrapper. Read more
§

fn with_current_subscriber(self) -> WithDispatch<Self>

Attaches the current default Subscriber to this type, returning a +[WithDispatch] wrapper. Read more
\ No newline at end of file diff --git a/api/jellytau_lib/media_server/constant.CHUNK_LEN.html b/api/jellytau_lib/media_server/constant.CHUNK_LEN.html new file mode 100644 index 00000000..4929a011 --- /dev/null +++ b/api/jellytau_lib/media_server/constant.CHUNK_LEN.html @@ -0,0 +1,5 @@ +CHUNK_LEN in jellytau_lib::media_server - RustSkip to main content

CHUNK_LEN

Constant CHUNK_LEN 

Source
const CHUNK_LEN: u64 = _; // 4_194_304u64
Expand description

Bytes per response. Large enough that a film needs relatively few round +trips, small enough that one response is never a memory problem on a phone. +Tauri’s asset protocol uses 1 MiB; 4 MiB quarters the request count for the +multi-gigabyte files this exists to serve.

+
\ No newline at end of file diff --git a/api/jellytau_lib/media_server/enum.Resolved.html b/api/jellytau_lib/media_server/enum.Resolved.html new file mode 100644 index 00000000..a2885751 --- /dev/null +++ b/api/jellytau_lib/media_server/enum.Resolved.html @@ -0,0 +1,36 @@ +Resolved in jellytau_lib::media_server - RustSkip to main content

Resolved

Enum Resolved 

Source
pub enum Resolved {
+    Allow(PathBuf),
+    Forbidden,
+}
Expand description

What a request path resolved to, before any file is touched.

+

Variants§

§

Allow(PathBuf)

§

Forbidden

Escaped the allowed root.

+

Trait Implementations§

Source§

impl Debug for Resolved

Source§

fn fmt(&self, f: &mut Formatter<'_>) -> Result

Formats the value using the given formatter. Read more
Source§

impl PartialEq for Resolved

Source§

fn eq(&self, other: &Resolved) -> bool

Tests for self and other values to be equal, and is used by ==.
1.0.0 (const: unstable) · Source§

fn ne(&self, other: &Rhs) -> bool

Tests for !=. The default implementation is almost always sufficient, +and should not be overridden without very good reason.
Source§

impl Eq for Resolved

Source§

impl StructuralPartialEq for Resolved

Auto Trait Implementations§

Blanket Implementations§

Source§

impl<T> Any for T
where + T: 'static + ?Sized,

Source§

fn type_id(&self) -> TypeId

Gets the TypeId of self. Read more
Source§

impl<T> Borrow<T> for T
where + T: ?Sized,

Source§

fn borrow(&self) -> &T

Immutably borrows from an owned value. Read more
Source§

impl<T> BorrowMut<T> for T
where + T: ?Sized,

Source§

fn borrow_mut(&mut self) -> &mut T

Mutably borrows from an owned value. Read more
§

impl<Q, K> Equivalent<K> for Q
where + Q: Eq + ?Sized, + K: Borrow<Q> + ?Sized,

§

fn equivalent(&self, key: &K) -> bool

Checks if this value is equivalent to the given key. Read more
§

impl<Q, K> Equivalent<K> for Q
where + Q: Eq + ?Sized, + K: Borrow<Q> + ?Sized,

§

fn equivalent(&self, key: &K) -> bool

Compare self to key and return true if they are equal.
§

impl<Q, K> Equivalent<K> for Q
where + Q: Eq + ?Sized, + K: Borrow<Q> + ?Sized,

§

fn equivalent(&self, key: &K) -> bool

Checks if this value is equivalent to the given key. Read more
Source§

impl<T> From<T> for T

Source§

fn from(t: T) -> T

Returns the argument unchanged.

+
§

impl<T> Instrument for T

§

fn instrument(self, span: Span) -> Instrumented<Self>

Instruments this type with the provided [Span], returning an +Instrumented wrapper. Read more
§

fn in_current_span(self) -> Instrumented<Self>

Instruments this type with the current Span, returning an +Instrumented wrapper. Read more
Source§

impl<T, U> Into<U> for T
where + U: From<T>,

Source§

fn into(self) -> U

Calls U::from(self).

+

That is, this conversion is whatever the implementation of +From<T> for U chooses to do.

+
§

impl<T> PolicyExt for T
where + T: ?Sized,

§

fn and<P, B, E>(self, other: P) -> And<T, P>
where + T: Policy<B, E>, + P: Policy<B, E>,

Create a new Policy that returns [Action::Follow] only if self and other return +Action::Follow. Read more
§

fn or<P, B, E>(self, other: P) -> Or<T, P>
where + T: Policy<B, E>, + P: Policy<B, E>,

Create a new Policy that returns [Action::Follow] if either self or other returns +Action::Follow. Read more
Source§

impl<T> Same for T

Source§

type Output = T

Should always be Self
Source§

impl<T, U> TryFrom<U> for T
where + U: Into<T>,

Source§

type Error = Infallible

The type returned in the event of a conversion error.
Source§

fn try_from(value: U) -> Result<T, <T as TryFrom<U>>::Error>

Performs the conversion.
Source§

impl<T, U> TryInto<U> for T
where + U: TryFrom<T>,

Source§

type Error = <U as TryFrom<T>>::Error

The type returned in the event of a conversion error.
Source§

fn try_into(self) -> Result<U, <U as TryFrom<T>>::Error>

Performs the conversion.
§

impl<V, T> VZip<V> for T
where + V: MultiLane<T>,

§

fn vzip(self) -> V

§

impl<T> WithSubscriber for T

§

fn with_subscriber<S>(self, subscriber: S) -> WithDispatch<Self>
where + S: Into<Dispatch>,

Attaches the provided Subscriber to this type, returning a +[WithDispatch] wrapper. Read more
§

fn with_current_subscriber(self) -> WithDispatch<Self>

Attaches the current default Subscriber to this type, returning a +[WithDispatch] wrapper. Read more
\ No newline at end of file diff --git a/api/jellytau_lib/media_server/fn.content_type.html b/api/jellytau_lib/media_server/fn.content_type.html new file mode 100644 index 00000000..304a32e7 --- /dev/null +++ b/api/jellytau_lib/media_server/fn.content_type.html @@ -0,0 +1,11 @@ +content_type in jellytau_lib::media_server - RustSkip to main content

content_type

Function content_type 

Source
fn content_type(path: &Path, head: &[u8]) -> &'static str
Expand description

Guess a content type.

+

Magic bytes win over the extension. Downloading at original quality +asks Jellyfin for a direct static copy, which returns the source file’s +bytes under a .mp4 name whatever the real container is — a downloaded film +named .mp4 turned out to be an AVI holding XVID. Trusting the extension +there labels it video/mp4 and the player is handed a container that is not +what the header claims. The extension is only a fallback for a file whose +bytes are unrecognised, and for the extension-less files the offline queue +writes under an item id.

+

TRACES: UR-071 | DR-137 | UT-127

+
\ No newline at end of file diff --git a/api/jellytau_lib/media_server/fn.empty.html b/api/jellytau_lib/media_server/fn.empty.html new file mode 100644 index 00000000..4cc2355d --- /dev/null +++ b/api/jellytau_lib/media_server/fn.empty.html @@ -0,0 +1 @@ +empty in jellytau_lib::media_server - RustSkip to main content

empty

Function empty 

Source
fn empty(status: u16) -> Response<Empty>
\ No newline at end of file diff --git a/api/jellytau_lib/media_server/fn.handle.html b/api/jellytau_lib/media_server/fn.handle.html new file mode 100644 index 00000000..85e6357e --- /dev/null +++ b/api/jellytau_lib/media_server/fn.handle.html @@ -0,0 +1 @@ +handle in jellytau_lib::media_server - RustSkip to main content

handle

Function handle 

Source
fn handle(request: Request, root: &Path, token: &str)
\ No newline at end of file diff --git a/api/jellytau_lib/media_server/fn.header.html b/api/jellytau_lib/media_server/fn.header.html new file mode 100644 index 00000000..d55df480 --- /dev/null +++ b/api/jellytau_lib/media_server/fn.header.html @@ -0,0 +1 @@ +header in jellytau_lib::media_server - RustSkip to main content

header

Function header 

Source
fn header(name: &str, value: &str) -> Header
\ No newline at end of file diff --git a/api/jellytau_lib/media_server/fn.resolve_path.html b/api/jellytau_lib/media_server/fn.resolve_path.html new file mode 100644 index 00000000..94c4ef41 --- /dev/null +++ b/api/jellytau_lib/media_server/fn.resolve_path.html @@ -0,0 +1,6 @@ +resolve_path in jellytau_lib::media_server - RustSkip to main content

resolve_path

Function resolve_path 

Source
pub fn resolve_path(raw: &str, root: &Path) -> Resolved
Expand description

Resolve a percent-encoded request path to a file inside root.

+

.. segments are folded away lexically rather than through canonicalize, +so a missing file still resolves (and then 404s) instead of being reported as +a scope violation.

+

TRACES: UR-071 | DR-137 | UT-127

+
\ No newline at end of file diff --git a/api/jellytau_lib/media_server/fn.route.html b/api/jellytau_lib/media_server/fn.route.html new file mode 100644 index 00000000..940309c8 --- /dev/null +++ b/api/jellytau_lib/media_server/fn.route.html @@ -0,0 +1,2 @@ +route in jellytau_lib::media_server - RustSkip to main content

route

Function route 

Source
fn route(url: &str, root: &Path, token: &str) -> Result<PathBuf, u16>
Expand description

Check the token and resolve the path, or return the status to answer with.

+
\ No newline at end of file diff --git a/api/jellytau_lib/media_server/fn.span_for.html b/api/jellytau_lib/media_server/fn.span_for.html new file mode 100644 index 00000000..37a238a9 --- /dev/null +++ b/api/jellytau_lib/media_server/fn.span_for.html @@ -0,0 +1,7 @@ +span_for in jellytau_lib::media_server - RustSkip to main content

span_for

Function span_for 

Source
pub fn span_for(range: Option<&str>, len: u64) -> Option<Span>
Expand description

Decide which span to send for a Range header (or its absence).

+

None means unsatisfiable — answer 416. A missing or unparseable header +yields the first chunk, so a client that did not ask for a range still gets a +bounded response it can continue from, which is exactly the case the asset +protocol answered with the whole file.

+

TRACES: UR-071 | DR-137 | UT-127

+
\ No newline at end of file diff --git a/api/jellytau_lib/media_server/fn.start.html b/api/jellytau_lib/media_server/fn.start.html new file mode 100644 index 00000000..3d759304 --- /dev/null +++ b/api/jellytau_lib/media_server/fn.start.html @@ -0,0 +1,4 @@ +start in jellytau_lib::media_server - RustSkip to main content

start

Function start 

Source
pub fn start(root: PathBuf) -> Result<MediaServer, String>
Expand description

Bind to an ephemeral loopback port and start serving root in a background +thread.

+

TRACES: UR-071 | DR-137

+
\ No newline at end of file diff --git a/api/jellytau_lib/media_server/index.html b/api/jellytau_lib/media_server/index.html new file mode 100644 index 00000000..1998daad --- /dev/null +++ b/api/jellytau_lib/media_server/index.html @@ -0,0 +1,30 @@ +jellytau_lib::media_server - RustSkip to main content

Module media_server

Module media_server 

Source
Expand description

A loopback HTTP server for locally downloaded media.

+

Tauri’s built-in asset protocol cannot serve a downloaded film to the +webview. Its response to a request without a Range header reads the whole +file into a Vec<u8>, and it only advertises Accept-Ranges: bytes from +inside the range branch — so the first request never learns ranges are +available and a multi-gigabyte body is attempted instead. Chromium abandoned +it with PIPELINE_ERROR_READ after ~31s, which reached the user as +“downloaded video does not play offline”.

+

Serving over real HTTP on 127.0.0.1 rather than a custom URI scheme is +deliberate: it makes range support a property of the transport instead of +depending on whether a platform’s webview forwards Range to a custom +scheme, which differs between Android and the desktop webviews.

+

Two things confine it, because loopback is shared between apps on +Android — any other installed app can connect to this port:

+
    +
  • it binds 127.0.0.1 only, so nothing off-device can reach it; and
  • +
  • every URL carries a random per-session token, so another app cannot guess a +working URL, and paths are confined to the app data directory even if one +did.
  • +
+

Phase 1 serves local files only. The same origin is the intended home for +remote passthrough (and download-while-watching) later; see the stage-2 spec.

+

TRACES: UR-071 | DR-137 | UT-127

+

Structs§

MediaServer
A running server. Dropping this does not stop the thread; the server lives +for the life of the process by design, since playback can start at any time.
MediaServerWrapper
Managed state. None when the server could not bind — local playback then +fails with a clear error instead of the app refusing to start.
Span
The byte range a response should carry. end is inclusive.

Enums§

Resolved
What a request path resolved to, before any file is touched.

Constants§

CHUNK_LEN 🔒
Bytes per response. Large enough that a film needs relatively few round +trips, small enough that one response is never a memory problem on a phone. +Tauri’s asset protocol uses 1 MiB; 4 MiB quarters the request count for the +multi-gigabyte files this exists to serve.

Functions§

content_type 🔒
Guess a content type.
empty 🔒
handle 🔒
header 🔒
resolve_path
Resolve a percent-encoded request path to a file inside root.
route 🔒
Check the token and resolve the path, or return the status to answer with.
span_for
Decide which span to send for a Range header (or its absence).
start
Bind to an ephemeral loopback port and start serving root in a background +thread.
\ No newline at end of file diff --git a/api/jellytau_lib/media_server/sidebar-items.js b/api/jellytau_lib/media_server/sidebar-items.js new file mode 100644 index 00000000..c7e1eddd --- /dev/null +++ b/api/jellytau_lib/media_server/sidebar-items.js @@ -0,0 +1 @@ +window.SIDEBAR_ITEMS = {"constant":["CHUNK_LEN"],"enum":["Resolved"],"fn":["content_type","empty","handle","header","resolve_path","route","span_for","start"],"struct":["MediaServer","MediaServerWrapper","Span"]}; \ No newline at end of file diff --git a/api/jellytau_lib/media_server/struct.MediaServer.html b/api/jellytau_lib/media_server/struct.MediaServer.html new file mode 100644 index 00000000..09033efb --- /dev/null +++ b/api/jellytau_lib/media_server/struct.MediaServer.html @@ -0,0 +1,31 @@ +MediaServer in jellytau_lib::media_server - RustSkip to main content

MediaServer

Struct MediaServer 

Source
pub struct MediaServer {
+    port: u16,
+    token: String,
+}
Expand description

A running server. Dropping this does not stop the thread; the server lives +for the life of the process by design, since playback can start at any time.

+

Fields§

§port: u16§token: String

Implementations§

Source§

impl MediaServer

Source

pub fn base_url(&self) -> String

The base a media URL is built on, e.g. http://127.0.0.1:53412/<token>.

+
Source

pub fn url_for(&self, path: &str) -> String

A playable URL for an absolute on-disk path.

+

Auto Trait Implementations§

Blanket Implementations§

Source§

impl<T> Any for T
where + T: 'static + ?Sized,

Source§

fn type_id(&self) -> TypeId

Gets the TypeId of self. Read more
Source§

impl<T> Borrow<T> for T
where + T: ?Sized,

Source§

fn borrow(&self) -> &T

Immutably borrows from an owned value. Read more
Source§

impl<T> BorrowMut<T> for T
where + T: ?Sized,

Source§

fn borrow_mut(&mut self) -> &mut T

Mutably borrows from an owned value. Read more
Source§

impl<T> From<T> for T

Source§

fn from(t: T) -> T

Returns the argument unchanged.

+
§

impl<T> Instrument for T

§

fn instrument(self, span: Span) -> Instrumented<Self>

Instruments this type with the provided [Span], returning an +Instrumented wrapper. Read more
§

fn in_current_span(self) -> Instrumented<Self>

Instruments this type with the current Span, returning an +Instrumented wrapper. Read more
Source§

impl<T, U> Into<U> for T
where + U: From<T>,

Source§

fn into(self) -> U

Calls U::from(self).

+

That is, this conversion is whatever the implementation of +From<T> for U chooses to do.

+
§

impl<T> PolicyExt for T
where + T: ?Sized,

§

fn and<P, B, E>(self, other: P) -> And<T, P>
where + T: Policy<B, E>, + P: Policy<B, E>,

Create a new Policy that returns [Action::Follow] only if self and other return +Action::Follow. Read more
§

fn or<P, B, E>(self, other: P) -> Or<T, P>
where + T: Policy<B, E>, + P: Policy<B, E>,

Create a new Policy that returns [Action::Follow] if either self or other returns +Action::Follow. Read more
Source§

impl<T> Same for T

Source§

type Output = T

Should always be Self
Source§

impl<T, U> TryFrom<U> for T
where + U: Into<T>,

Source§

type Error = Infallible

The type returned in the event of a conversion error.
Source§

fn try_from(value: U) -> Result<T, <T as TryFrom<U>>::Error>

Performs the conversion.
Source§

impl<T, U> TryInto<U> for T
where + U: TryFrom<T>,

Source§

type Error = <U as TryFrom<T>>::Error

The type returned in the event of a conversion error.
Source§

fn try_into(self) -> Result<U, <U as TryFrom<T>>::Error>

Performs the conversion.
§

impl<V, T> VZip<V> for T
where + V: MultiLane<T>,

§

fn vzip(self) -> V

§

impl<T> WithSubscriber for T

§

fn with_subscriber<S>(self, subscriber: S) -> WithDispatch<Self>
where + S: Into<Dispatch>,

Attaches the provided Subscriber to this type, returning a +[WithDispatch] wrapper. Read more
§

fn with_current_subscriber(self) -> WithDispatch<Self>

Attaches the current default Subscriber to this type, returning a +[WithDispatch] wrapper. Read more
\ No newline at end of file diff --git a/api/jellytau_lib/media_server/struct.MediaServerWrapper.html b/api/jellytau_lib/media_server/struct.MediaServerWrapper.html new file mode 100644 index 00000000..ef0d7538 --- /dev/null +++ b/api/jellytau_lib/media_server/struct.MediaServerWrapper.html @@ -0,0 +1,26 @@ +MediaServerWrapper in jellytau_lib::media_server - RustSkip to main content

MediaServerWrapper

Struct MediaServerWrapper 

Source
pub struct MediaServerWrapper(pub Option<MediaServer>);
Expand description

Managed state. None when the server could not bind — local playback then +fails with a clear error instead of the app refusing to start.

+

Tuple Fields§

§0: Option<MediaServer>

Auto Trait Implementations§

Blanket Implementations§

Source§

impl<T> Any for T
where + T: 'static + ?Sized,

Source§

fn type_id(&self) -> TypeId

Gets the TypeId of self. Read more
Source§

impl<T> Borrow<T> for T
where + T: ?Sized,

Source§

fn borrow(&self) -> &T

Immutably borrows from an owned value. Read more
Source§

impl<T> BorrowMut<T> for T
where + T: ?Sized,

Source§

fn borrow_mut(&mut self) -> &mut T

Mutably borrows from an owned value. Read more
Source§

impl<T> From<T> for T

Source§

fn from(t: T) -> T

Returns the argument unchanged.

+
§

impl<T> Instrument for T

§

fn instrument(self, span: Span) -> Instrumented<Self>

Instruments this type with the provided [Span], returning an +Instrumented wrapper. Read more
§

fn in_current_span(self) -> Instrumented<Self>

Instruments this type with the current Span, returning an +Instrumented wrapper. Read more
Source§

impl<T, U> Into<U> for T
where + U: From<T>,

Source§

fn into(self) -> U

Calls U::from(self).

+

That is, this conversion is whatever the implementation of +From<T> for U chooses to do.

+
§

impl<T> PolicyExt for T
where + T: ?Sized,

§

fn and<P, B, E>(self, other: P) -> And<T, P>
where + T: Policy<B, E>, + P: Policy<B, E>,

Create a new Policy that returns [Action::Follow] only if self and other return +Action::Follow. Read more
§

fn or<P, B, E>(self, other: P) -> Or<T, P>
where + T: Policy<B, E>, + P: Policy<B, E>,

Create a new Policy that returns [Action::Follow] if either self or other returns +Action::Follow. Read more
Source§

impl<T> Same for T

Source§

type Output = T

Should always be Self
Source§

impl<T, U> TryFrom<U> for T
where + U: Into<T>,

Source§

type Error = Infallible

The type returned in the event of a conversion error.
Source§

fn try_from(value: U) -> Result<T, <T as TryFrom<U>>::Error>

Performs the conversion.
Source§

impl<T, U> TryInto<U> for T
where + U: TryFrom<T>,

Source§

type Error = <U as TryFrom<T>>::Error

The type returned in the event of a conversion error.
Source§

fn try_into(self) -> Result<U, <U as TryFrom<T>>::Error>

Performs the conversion.
§

impl<V, T> VZip<V> for T
where + V: MultiLane<T>,

§

fn vzip(self) -> V

§

impl<T> WithSubscriber for T

§

fn with_subscriber<S>(self, subscriber: S) -> WithDispatch<Self>
where + S: Into<Dispatch>,

Attaches the provided Subscriber to this type, returning a +[WithDispatch] wrapper. Read more
§

fn with_current_subscriber(self) -> WithDispatch<Self>

Attaches the current default Subscriber to this type, returning a +[WithDispatch] wrapper. Read more
\ No newline at end of file diff --git a/api/jellytau_lib/media_server/struct.Span.html b/api/jellytau_lib/media_server/struct.Span.html new file mode 100644 index 00000000..7a635161 --- /dev/null +++ b/api/jellytau_lib/media_server/struct.Span.html @@ -0,0 +1,35 @@ +Span in jellytau_lib::media_server - RustSkip to main content

Span

Struct Span 

Source
pub struct Span {
+    pub start: u64,
+    pub end: u64,
+}
Expand description

The byte range a response should carry. end is inclusive.

+

Fields§

§start: u64§end: u64

Implementations§

Source§

impl Span

Source

pub fn len(&self) -> u64

Trait Implementations§

Source§

impl Debug for Span

Source§

fn fmt(&self, f: &mut Formatter<'_>) -> Result

Formats the value using the given formatter. Read more
Source§

impl PartialEq for Span

Source§

fn eq(&self, other: &Span) -> bool

Tests for self and other values to be equal, and is used by ==.
1.0.0 (const: unstable) · Source§

fn ne(&self, other: &Rhs) -> bool

Tests for !=. The default implementation is almost always sufficient, +and should not be overridden without very good reason.
Source§

impl Eq for Span

Source§

impl StructuralPartialEq for Span

Auto Trait Implementations§

§

impl Freeze for Span

§

impl RefUnwindSafe for Span

§

impl Send for Span

§

impl Sync for Span

§

impl Unpin for Span

§

impl UnsafeUnpin for Span

§

impl UnwindSafe for Span

Blanket Implementations§

Source§

impl<T> Any for T
where + T: 'static + ?Sized,

Source§

fn type_id(&self) -> TypeId

Gets the TypeId of self. Read more
Source§

impl<T> Borrow<T> for T
where + T: ?Sized,

Source§

fn borrow(&self) -> &T

Immutably borrows from an owned value. Read more
Source§

impl<T> BorrowMut<T> for T
where + T: ?Sized,

Source§

fn borrow_mut(&mut self) -> &mut T

Mutably borrows from an owned value. Read more
§

impl<Q, K> Equivalent<K> for Q
where + Q: Eq + ?Sized, + K: Borrow<Q> + ?Sized,

§

fn equivalent(&self, key: &K) -> bool

Checks if this value is equivalent to the given key. Read more
§

impl<Q, K> Equivalent<K> for Q
where + Q: Eq + ?Sized, + K: Borrow<Q> + ?Sized,

§

fn equivalent(&self, key: &K) -> bool

Compare self to key and return true if they are equal.
§

impl<Q, K> Equivalent<K> for Q
where + Q: Eq + ?Sized, + K: Borrow<Q> + ?Sized,

§

fn equivalent(&self, key: &K) -> bool

Checks if this value is equivalent to the given key. Read more
Source§

impl<T> From<T> for T

Source§

fn from(t: T) -> T

Returns the argument unchanged.

+
§

impl<T> Instrument for T

§

fn instrument(self, span: Span) -> Instrumented<Self>

Instruments this type with the provided [Span], returning an +Instrumented wrapper. Read more
§

fn in_current_span(self) -> Instrumented<Self>

Instruments this type with the current Span, returning an +Instrumented wrapper. Read more
Source§

impl<T, U> Into<U> for T
where + U: From<T>,

Source§

fn into(self) -> U

Calls U::from(self).

+

That is, this conversion is whatever the implementation of +From<T> for U chooses to do.

+
§

impl<T> PolicyExt for T
where + T: ?Sized,

§

fn and<P, B, E>(self, other: P) -> And<T, P>
where + T: Policy<B, E>, + P: Policy<B, E>,

Create a new Policy that returns [Action::Follow] only if self and other return +Action::Follow. Read more
§

fn or<P, B, E>(self, other: P) -> Or<T, P>
where + T: Policy<B, E>, + P: Policy<B, E>,

Create a new Policy that returns [Action::Follow] if either self or other returns +Action::Follow. Read more
Source§

impl<T> Same for T

Source§

type Output = T

Should always be Self
Source§

impl<T, U> TryFrom<U> for T
where + U: Into<T>,

Source§

type Error = Infallible

The type returned in the event of a conversion error.
Source§

fn try_from(value: U) -> Result<T, <T as TryFrom<U>>::Error>

Performs the conversion.
Source§

impl<T, U> TryInto<U> for T
where + U: TryFrom<T>,

Source§

type Error = <U as TryFrom<T>>::Error

The type returned in the event of a conversion error.
Source§

fn try_into(self) -> Result<U, <U as TryFrom<T>>::Error>

Performs the conversion.
§

impl<V, T> VZip<V> for T
where + V: MultiLane<T>,

§

fn vzip(self) -> V

§

impl<T> WithSubscriber for T

§

fn with_subscriber<S>(self, subscriber: S) -> WithDispatch<Self>
where + S: Into<Dispatch>,

Attaches the provided Subscriber to this type, returning a +[WithDispatch] wrapper. Read more
§

fn with_current_subscriber(self) -> WithDispatch<Self>

Attaches the current default Subscriber to this type, returning a +[WithDispatch] wrapper. Read more
\ No newline at end of file diff --git a/api/jellytau_lib/playback_mode/constant.DEFAULT_REMOTE_VOLUME.html b/api/jellytau_lib/playback_mode/constant.DEFAULT_REMOTE_VOLUME.html new file mode 100644 index 00000000..64a6584c --- /dev/null +++ b/api/jellytau_lib/playback_mode/constant.DEFAULT_REMOTE_VOLUME.html @@ -0,0 +1,3 @@ +DEFAULT_REMOTE_VOLUME in jellytau_lib::playback_mode - RustSkip to main content

DEFAULT_REMOTE_VOLUME

Constant DEFAULT_REMOTE_VOLUME 

Source
const DEFAULT_REMOTE_VOLUME: i32 = 50;
Expand description

Volume level (0-100) the remote volume slider starts at. The real level is +corrected by the session poller once the remote session reports its volume.

+
\ No newline at end of file diff --git a/api/jellytau_lib/playback_mode/constant.RESUME_THRESHOLD_SECONDS.html b/api/jellytau_lib/playback_mode/constant.RESUME_THRESHOLD_SECONDS.html new file mode 100644 index 00000000..c539ef6f --- /dev/null +++ b/api/jellytau_lib/playback_mode/constant.RESUME_THRESHOLD_SECONDS.html @@ -0,0 +1,3 @@ +RESUME_THRESHOLD_SECONDS in jellytau_lib::playback_mode - RustSkip to main content

RESUME_THRESHOLD_SECONDS

Constant RESUME_THRESHOLD_SECONDS 

Source
const RESUME_THRESHOLD_SECONDS: f64 = 0.5;
Expand description

Below this many seconds we treat the position as “at the start” and don’t +send a resume position, so a fresh track casts from 0 rather than ~0.

+
\ No newline at end of file diff --git a/api/jellytau_lib/playback_mode/constant.TICKS_PER_SECOND.html b/api/jellytau_lib/playback_mode/constant.TICKS_PER_SECOND.html new file mode 100644 index 00000000..2b657fe3 --- /dev/null +++ b/api/jellytau_lib/playback_mode/constant.TICKS_PER_SECOND.html @@ -0,0 +1,2 @@ +TICKS_PER_SECOND in jellytau_lib::playback_mode - RustSkip to main content

TICKS_PER_SECOND

Constant TICKS_PER_SECOND 

Source
const TICKS_PER_SECOND: f64 = 10_000_000.0;
Expand description

Number of Jellyfin ticks per second (100ns units).

+
\ No newline at end of file diff --git a/api/jellytau_lib/playback_mode/enum.PlaybackMode.html b/api/jellytau_lib/playback_mode/enum.PlaybackMode.html new file mode 100644 index 00000000..678c2360 --- /dev/null +++ b/api/jellytau_lib/playback_mode/enum.PlaybackMode.html @@ -0,0 +1,63 @@ +PlaybackMode in jellytau_lib::playback_mode - RustSkip to main content

PlaybackMode

Enum PlaybackMode 

Source
pub enum PlaybackMode {
+    Local,
+    Remote {
+        session_id: String,
+    },
+    Idle,
+}
Expand description

Playback mode - local device, remote session, or idle

+

Variants§

§

Local

§

Remote

Fields

§session_id: String
§

Idle

Trait Implementations§

Source§

impl Clone for PlaybackMode

Source§

fn clone(&self) -> PlaybackMode

Returns a duplicate of the value. Read more
1.0.0 (const: unstable) · Source§

fn clone_from(&mut self, source: &Self)

Performs copy-assignment from source. Read more
Source§

impl Debug for PlaybackMode

Source§

fn fmt(&self, f: &mut Formatter<'_>) -> Result

Formats the value using the given formatter. Read more
Source§

impl<'de> Deserialize<'de> for PlaybackMode

Source§

fn deserialize<__D>(__deserializer: __D) -> Result<Self, __D::Error>
where + __D: Deserializer<'de>,

Deserialize this value from the given Serde deserializer. Read more
Source§

impl NamedType for PlaybackMode

Source§

fn sid() -> SpectaID

Source§

fn named_data_type( + type_map: &mut TypeCollection, + generics: &[DataType], +) -> NamedDataType

this is equivalent to [Type::inline] but returns a [NamedDataType] instead.
Source§

fn definition_named_data_type(type_map: &mut TypeCollection) -> NamedDataType

this is equivalent to [Type::definition] but returns a [NamedDataType] instead.
Source§

impl PartialEq for PlaybackMode

Source§

fn eq(&self, other: &PlaybackMode) -> bool

Tests for self and other values to be equal, and is used by ==.
1.0.0 (const: unstable) · Source§

fn ne(&self, other: &Rhs) -> bool

Tests for !=. The default implementation is almost always sufficient, +and should not be overridden without very good reason.
Source§

impl Serialize for PlaybackMode

Source§

fn serialize<__S>(&self, __serializer: __S) -> Result<__S::Ok, __S::Error>
where + __S: Serializer,

Serialize this value into the given Serde serializer. Read more
Source§

impl Type for PlaybackMode

Source§

fn inline(type_map: &mut TypeCollection, generics: Generics<'_>) -> DataType

Returns the definition of a type using the provided generics. Read more
Source§

fn reference(type_map: &mut TypeCollection, generics: &[DataType]) -> Reference

Generates a datatype corresponding to a reference to this type, +as determined by its category. Getting a reference to a type implies that +it should belong in the type map (since it has to be referenced from somewhere), +so the output of definition will be put into the type map.
Source§

impl Eq for PlaybackMode

Source§

impl Flatten for PlaybackMode

Source§

impl StructuralPartialEq for PlaybackMode

Auto Trait Implementations§

Blanket Implementations§

Source§

impl<T> Any for T
where + T: 'static + ?Sized,

Source§

fn type_id(&self) -> TypeId

Gets the TypeId of self. Read more
Source§

impl<T> Borrow<T> for T
where + T: ?Sized,

Source§

fn borrow(&self) -> &T

Immutably borrows from an owned value. Read more
Source§

impl<T> BorrowMut<T> for T
where + T: ?Sized,

Source§

fn borrow_mut(&mut self) -> &mut T

Mutably borrows from an owned value. Read more
Source§

impl<T> CloneToUninit for T
where + T: Clone,

Source§

unsafe fn clone_to_uninit(&self, dest: *mut u8)

🔬This is a nightly-only experimental API. (clone_to_uninit)
Performs copy-assignment from self to dest. Read more
§

impl<'de, D, R> CommandArg<'de, R> for D
where + D: Deserialize<'de>, + R: Runtime,

§

fn from_command(command: CommandItem<'de, R>) -> Result<D, InvokeError>

Derives an instance of Self from the [CommandItem]. Read more
§

impl<Q, K> Equivalent<K> for Q
where + Q: Eq + ?Sized, + K: Borrow<Q> + ?Sized,

§

fn equivalent(&self, key: &K) -> bool

Checks if this value is equivalent to the given key. Read more
§

impl<Q, K> Equivalent<K> for Q
where + Q: Eq + ?Sized, + K: Borrow<Q> + ?Sized,

§

fn equivalent(&self, key: &K) -> bool

Compare self to key and return true if they are equal.
§

impl<Q, K> Equivalent<K> for Q
where + Q: Eq + ?Sized, + K: Borrow<Q> + ?Sized,

§

fn equivalent(&self, key: &K) -> bool

Checks if this value is equivalent to the given key. Read more
Source§

impl<T> From<T> for T

Source§

fn from(t: T) -> T

Returns the argument unchanged.

+
§

impl<T> FunctionArg for T
where + T: Type,

§

fn to_datatype(type_map: &mut TypeCollection) -> Option<DataType>

Gets the type of an argument as a [DataType]. Read more
§

impl<T> Instrument for T

§

fn instrument(self, span: Span) -> Instrumented<Self>

Instruments this type with the provided [Span], returning an +Instrumented wrapper. Read more
§

fn in_current_span(self) -> Instrumented<Self>

Instruments this type with the current Span, returning an +Instrumented wrapper. Read more
Source§

impl<T, U> Into<U> for T
where + U: From<T>,

Source§

fn into(self) -> U

Calls U::from(self).

+

That is, this conversion is whatever the implementation of +From<T> for U chooses to do.

+
§

impl<T> IpcResponse for T
where + T: Serialize,

§

fn body(self) -> Result<InvokeResponseBody, Error>

Resolve the IPC response body.
§

impl<T> PolicyExt for T
where + T: ?Sized,

§

fn and<P, B, E>(self, other: P) -> And<T, P>
where + T: Policy<B, E>, + P: Policy<B, E>,

Create a new Policy that returns [Action::Follow] only if self and other return +Action::Follow. Read more
§

fn or<P, B, E>(self, other: P) -> Or<T, P>
where + T: Policy<B, E>, + P: Policy<B, E>,

Create a new Policy that returns [Action::Follow] if either self or other returns +Action::Follow. Read more
Source§

impl<T> Same for T

Source§

type Output = T

Should always be Self
§

impl<T> ScopeObject for T
where + T: Send + Sync + Debug + DeserializeOwned + 'static,

§

type Error = Error

The error type.
§

fn deserialize<R>( + _app: &AppHandle<R>, + raw: Value, +) -> Result<T, <T as ScopeObject>::Error>
where + R: Runtime,

Deserialize the raw scope value.
Source§

impl<T> Serialize for T
where + T: Serialize + ?Sized,

Source§

fn erased_serialize(&self, serializer: &mut dyn Serializer) -> Result<(), Error>

Source§

fn do_erased_serialize( + &self, + serializer: &mut dyn Serializer, +) -> Result<(), ErrorImpl>

Source§

impl<T> ToOwned for T
where + T: Clone,

Source§

type Owned = T

The resulting type after obtaining ownership.
Source§

fn to_owned(&self) -> T

Creates owned data from borrowed data, usually by cloning. Read more
Source§

fn clone_into(&self, target: &mut T)

Uses borrowed data to replace owned data, usually by cloning. Read more
Source§

impl<T, U> TryFrom<U> for T
where + U: Into<T>,

Source§

type Error = Infallible

The type returned in the event of a conversion error.
Source§

fn try_from(value: U) -> Result<T, <T as TryFrom<U>>::Error>

Performs the conversion.
Source§

impl<T, U> TryInto<U> for T
where + U: TryFrom<T>,

Source§

type Error = <U as TryFrom<T>>::Error

The type returned in the event of a conversion error.
Source§

fn try_into(self) -> Result<U, <U as TryFrom<T>>::Error>

Performs the conversion.
§

impl<V, T> VZip<V> for T
where + V: MultiLane<T>,

§

fn vzip(self) -> V

§

impl<T> WithSubscriber for T

§

fn with_subscriber<S>(self, subscriber: S) -> WithDispatch<Self>
where + S: Into<Dispatch>,

Attaches the provided Subscriber to this type, returning a +[WithDispatch] wrapper. Read more
§

fn with_current_subscriber(self) -> WithDispatch<Self>

Attaches the current default Subscriber to this type, returning a +[WithDispatch] wrapper. Read more
Source§

impl<T> DeserializeOwned for T
where + T: for<'de> Deserialize<'de>,

§

impl<T> UserEvent for T
where + T: Debug + Clone + Send + 'static,

\ No newline at end of file diff --git a/api/jellytau_lib/playback_mode/fn.start_position_ticks_from_seconds.html b/api/jellytau_lib/playback_mode/fn.start_position_ticks_from_seconds.html new file mode 100644 index 00000000..e60ed569 --- /dev/null +++ b/api/jellytau_lib/playback_mode/fn.start_position_ticks_from_seconds.html @@ -0,0 +1,7 @@ +start_position_ticks_from_seconds in jellytau_lib::playback_mode - RustSkip to main content

start_position_ticks_from_seconds

Function start_position_ticks_from_seconds 

Source
fn start_position_ticks_from_seconds(position_seconds: f64) -> Option<i64>
Expand description

Convert a live playback position (seconds) into the StartPositionTicks to +hand to a remote session, or None if we’re effectively at the start.

+

Pure helper so the resume-position math is unit-testable without a remote +session or HTTP. The source of position_seconds matters too: callers +must pass the live backend position (PlayerController::position()), not the +snapshot embedded in PlayerState, which is stale mid-track on Android.

+
\ No newline at end of file diff --git a/api/jellytau_lib/playback_mode/index.html b/api/jellytau_lib/playback_mode/index.html new file mode 100644 index 00000000..a269f433 --- /dev/null +++ b/api/jellytau_lib/playback_mode/index.html @@ -0,0 +1,4 @@ +jellytau_lib::playback_mode - RustSkip to main content

Module playback_mode

Module playback_mode 

Source

Structs§

PlatformRemoteVolumeControl 🔒
Production hook: forwards to the Android JNI bridge; no-op elsewhere.
PlaybackModeManager
Manages playback mode transfers between local and remote sessions

Enums§

PlaybackMode
Playback mode - local device, remote session, or idle

Constants§

DEFAULT_REMOTE_VOLUME 🔒
Volume level (0-100) the remote volume slider starts at. The real level is +corrected by the session poller once the remote session reports its volume.
RESUME_THRESHOLD_SECONDS 🔒
Below this many seconds we treat the position as “at the start” and don’t +send a resume position, so a fresh track casts from 0 rather than ~0.
TICKS_PER_SECOND 🔒
Number of Jellyfin ticks per second (100ns units).

Traits§

RemoteVolumeControl
Platform hook for attaching/detaching the OS remote-volume control.

Functions§

start_position_ticks_from_seconds 🔒
Convert a live playback position (seconds) into the StartPositionTicks to +hand to a remote session, or None if we’re effectively at the start.
\ No newline at end of file diff --git a/api/jellytau_lib/playback_mode/sidebar-items.js b/api/jellytau_lib/playback_mode/sidebar-items.js new file mode 100644 index 00000000..87cbe04d --- /dev/null +++ b/api/jellytau_lib/playback_mode/sidebar-items.js @@ -0,0 +1 @@ +window.SIDEBAR_ITEMS = {"constant":["DEFAULT_REMOTE_VOLUME","RESUME_THRESHOLD_SECONDS","TICKS_PER_SECOND"],"enum":["PlaybackMode"],"fn":["start_position_ticks_from_seconds"],"struct":["PlatformRemoteVolumeControl","PlaybackModeManager"],"trait":["RemoteVolumeControl"]}; \ No newline at end of file diff --git a/api/jellytau_lib/playback_mode/struct.PlatformRemoteVolumeControl.html b/api/jellytau_lib/playback_mode/struct.PlatformRemoteVolumeControl.html new file mode 100644 index 00000000..c50e08cc --- /dev/null +++ b/api/jellytau_lib/playback_mode/struct.PlatformRemoteVolumeControl.html @@ -0,0 +1,25 @@ +PlatformRemoteVolumeControl in jellytau_lib::playback_mode - RustSkip to main content

PlatformRemoteVolumeControl

Struct PlatformRemoteVolumeControl 

Source
struct PlatformRemoteVolumeControl;
Expand description

Production hook: forwards to the Android JNI bridge; no-op elsewhere.

+

Trait Implementations§

Source§

impl RemoteVolumeControl for PlatformRemoteVolumeControl

Source§

fn enable(&self, initial_volume: i32)

Attach remote-volume control (and, on Android, start the playback service).
Source§

fn disable(&self)

Return volume control to the local device speaker.

Auto Trait Implementations§

Blanket Implementations§

Source§

impl<T> Any for T
where + T: 'static + ?Sized,

Source§

fn type_id(&self) -> TypeId

Gets the TypeId of self. Read more
Source§

impl<T> Borrow<T> for T
where + T: ?Sized,

Source§

fn borrow(&self) -> &T

Immutably borrows from an owned value. Read more
Source§

impl<T> BorrowMut<T> for T
where + T: ?Sized,

Source§

fn borrow_mut(&mut self) -> &mut T

Mutably borrows from an owned value. Read more
Source§

impl<T> From<T> for T

Source§

fn from(t: T) -> T

Returns the argument unchanged.

+
§

impl<T> Instrument for T

§

fn instrument(self, span: Span) -> Instrumented<Self>

Instruments this type with the provided [Span], returning an +Instrumented wrapper. Read more
§

fn in_current_span(self) -> Instrumented<Self>

Instruments this type with the current Span, returning an +Instrumented wrapper. Read more
Source§

impl<T, U> Into<U> for T
where + U: From<T>,

Source§

fn into(self) -> U

Calls U::from(self).

+

That is, this conversion is whatever the implementation of +From<T> for U chooses to do.

+
§

impl<T> PolicyExt for T
where + T: ?Sized,

§

fn and<P, B, E>(self, other: P) -> And<T, P>
where + T: Policy<B, E>, + P: Policy<B, E>,

Create a new Policy that returns [Action::Follow] only if self and other return +Action::Follow. Read more
§

fn or<P, B, E>(self, other: P) -> Or<T, P>
where + T: Policy<B, E>, + P: Policy<B, E>,

Create a new Policy that returns [Action::Follow] if either self or other returns +Action::Follow. Read more
Source§

impl<T> Same for T

Source§

type Output = T

Should always be Self
Source§

impl<T, U> TryFrom<U> for T
where + U: Into<T>,

Source§

type Error = Infallible

The type returned in the event of a conversion error.
Source§

fn try_from(value: U) -> Result<T, <T as TryFrom<U>>::Error>

Performs the conversion.
Source§

impl<T, U> TryInto<U> for T
where + U: TryFrom<T>,

Source§

type Error = <U as TryFrom<T>>::Error

The type returned in the event of a conversion error.
Source§

fn try_into(self) -> Result<U, <U as TryFrom<T>>::Error>

Performs the conversion.
§

impl<V, T> VZip<V> for T
where + V: MultiLane<T>,

§

fn vzip(self) -> V

§

impl<T> WithSubscriber for T

§

fn with_subscriber<S>(self, subscriber: S) -> WithDispatch<Self>
where + S: Into<Dispatch>,

Attaches the provided Subscriber to this type, returning a +[WithDispatch] wrapper. Read more
§

fn with_current_subscriber(self) -> WithDispatch<Self>

Attaches the current default Subscriber to this type, returning a +[WithDispatch] wrapper. Read more
\ No newline at end of file diff --git a/api/jellytau_lib/playback_mode/struct.PlaybackModeManager.html b/api/jellytau_lib/playback_mode/struct.PlaybackModeManager.html new file mode 100644 index 00000000..4199e87d --- /dev/null +++ b/api/jellytau_lib/playback_mode/struct.PlaybackModeManager.html @@ -0,0 +1,106 @@ +PlaybackModeManager in jellytau_lib::playback_mode - RustSkip to main content

PlaybackModeManager

Struct PlaybackModeManager 

Source
pub struct PlaybackModeManager {
+    jellyfin_client: Arc<Mutex<Option<JellyfinClient>>>,
+    player_controller: Arc<Mutex<PlayerController>>,
+    current_mode: Arc<RwLock<PlaybackMode>>,
+    is_transferring: Arc<AtomicBool>,
+    event_emitter: Arc<Mutex<Option<Arc<dyn PlayerEventEmitter>>>>,
+    remote_volume: Arc<dyn RemoteVolumeControl>,
+}
Expand description

Manages playback mode transfers between local and remote sessions

+

Fields§

§jellyfin_client: Arc<Mutex<Option<JellyfinClient>>>§player_controller: Arc<Mutex<PlayerController>>§current_mode: Arc<RwLock<PlaybackMode>>§is_transferring: Arc<AtomicBool>§event_emitter: Arc<Mutex<Option<Arc<dyn PlayerEventEmitter>>>>

Optional emitter used to notify the frontend when the mode changes, so its +mirror store stays in sync with this authoritative one. None in tests.

+
§remote_volume: Arc<dyn RemoteVolumeControl>

Platform hook for OS-level remote volume routing (swapped in tests).

+

Implementations§

Source§

impl PlaybackModeManager

Source

pub fn new( + jellyfin_client: Arc<Mutex<Option<JellyfinClient>>>, + player_controller: Arc<TokioMutex<PlayerController>>, +) -> Self

Create a new playback mode manager

+
Source

pub fn set_event_emitter(&self, emitter: Arc<dyn PlayerEventEmitter>)

Wire the event emitter so set_mode notifies the frontend. Called once +during setup; safe to leave unset (tests do), in which case mode changes +simply aren’t broadcast.

+
Source

pub fn get_mode(&self) -> PlaybackMode

Get current playback mode

+
Source

pub fn set_mode(&self, mode: PlaybackMode)

Set playback mode (internal use).

+

Broadcasts a PlaybackModeChanged event when the mode actually changes so +the frontend’s mirror store reconciles to this authoritative value. The +write lock is released before emitting to avoid holding it across the +emitter call.

+

Also owns OS volume routing, which is derived from the transition +rather than from each call site: entering remote mode attaches the remote +volume control, and any exit from remote mode hands it back to the local +speaker. Doing this per-call-site is what caused the bug where stopping a +remote session (player_stop → Idle) left Android stuck on the remote +volume slider — only the transfer-to-local path tore it down.

+

TRACES: UR-010 | DR-059, IR-021

+
Source

fn enable_remote_control(&self)

Start the Android playback service and hand it remote-volume control.

+

Must run on EVERY transition into remote mode, because it is what starts +the foreground service. Without a running service there is no media +notification (the lockscreen card is missing) AND system volume buttons +aren’t intercepted for the remote session (remote volume control dead). +Both symptoms share this one cause, so this must not be skipped on any +remote-entry path (notably the empty-queue early return in +transfer_to_remote_inner). No-op / non-Android builds do nothing.

+

set_mode already arms this on entry into remote mode; +calling it again is harmless (the service start is idempotent) and keeps +the guarantee when the mode was already remote, which set_mode skips as +a no-op transition.

+
Source

pub fn is_transferring(&self) -> bool

Check if currently transferring

+
Source

pub fn set_transferring(&self, transferring: bool)

Set the transferring flag directly.

+

The remote->local transfer is driven from the frontend in two steps +(player_play_tracks to start local playback, then +playback_mode_transfer_to_local to stop the remote). The first step’s +routing depends on this flag: while it’s set, player_play_tracks plays +locally instead of casting back to the remote session. The frontend must +raise the flag before that first call and lower it when the sequence is +done (or aborts), so it can’t be left stuck on.

+
Source

pub async fn send_remote_volume_command( + &self, + command: &str, + volume: i32, +) -> Result<(), String>

Send volume command to remote session +Commands: “SetVolume”, “VolumeUp”, “VolumeDown”

+
Source

fn extract_jellyfin_ids( + &self, + items: &[MediaItem], + original_index: usize, +) -> Result<(Vec<String>, usize), String>

Extract Jellyfin item IDs from queue items +Returns (item_ids, adjusted_current_index)

+
Source

pub async fn transfer_to_remote( + &self, + session_id: String, + position_override: Option<f64>, +) -> Result<(), String>

Transfer playback from local device to remote Jellyfin session

+
Source

async fn transfer_to_remote_inner( + &self, + session_id: &str, + position_override: Option<f64>, +) -> Result<(), String>

Source

pub async fn transfer_to_local( + &self, + current_item_id: String, + position_ticks: i64, +) -> Result<(), String>

Transfer playback from remote session back to local device

+
Source

async fn transfer_to_local_inner( + &self, + current_item_id: &str, + position_ticks: i64, +) -> Result<(), String>

Auto Trait Implementations§

Blanket Implementations§

Source§

impl<T> Any for T
where + T: 'static + ?Sized,

Source§

fn type_id(&self) -> TypeId

Gets the TypeId of self. Read more
Source§

impl<T> Borrow<T> for T
where + T: ?Sized,

Source§

fn borrow(&self) -> &T

Immutably borrows from an owned value. Read more
Source§

impl<T> BorrowMut<T> for T
where + T: ?Sized,

Source§

fn borrow_mut(&mut self) -> &mut T

Mutably borrows from an owned value. Read more
Source§

impl<T> From<T> for T

Source§

fn from(t: T) -> T

Returns the argument unchanged.

+
§

impl<T> Instrument for T

§

fn instrument(self, span: Span) -> Instrumented<Self>

Instruments this type with the provided [Span], returning an +Instrumented wrapper. Read more
§

fn in_current_span(self) -> Instrumented<Self>

Instruments this type with the current Span, returning an +Instrumented wrapper. Read more
Source§

impl<T, U> Into<U> for T
where + U: From<T>,

Source§

fn into(self) -> U

Calls U::from(self).

+

That is, this conversion is whatever the implementation of +From<T> for U chooses to do.

+
§

impl<T> PolicyExt for T
where + T: ?Sized,

§

fn and<P, B, E>(self, other: P) -> And<T, P>
where + T: Policy<B, E>, + P: Policy<B, E>,

Create a new Policy that returns [Action::Follow] only if self and other return +Action::Follow. Read more
§

fn or<P, B, E>(self, other: P) -> Or<T, P>
where + T: Policy<B, E>, + P: Policy<B, E>,

Create a new Policy that returns [Action::Follow] if either self or other returns +Action::Follow. Read more
Source§

impl<T> Same for T

Source§

type Output = T

Should always be Self
Source§

impl<T, U> TryFrom<U> for T
where + U: Into<T>,

Source§

type Error = Infallible

The type returned in the event of a conversion error.
Source§

fn try_from(value: U) -> Result<T, <T as TryFrom<U>>::Error>

Performs the conversion.
Source§

impl<T, U> TryInto<U> for T
where + U: TryFrom<T>,

Source§

type Error = <U as TryFrom<T>>::Error

The type returned in the event of a conversion error.
Source§

fn try_into(self) -> Result<U, <U as TryFrom<T>>::Error>

Performs the conversion.
§

impl<V, T> VZip<V> for T
where + V: MultiLane<T>,

§

fn vzip(self) -> V

§

impl<T> WithSubscriber for T

§

fn with_subscriber<S>(self, subscriber: S) -> WithDispatch<Self>
where + S: Into<Dispatch>,

Attaches the provided Subscriber to this type, returning a +[WithDispatch] wrapper. Read more
§

fn with_current_subscriber(self) -> WithDispatch<Self>

Attaches the current default Subscriber to this type, returning a +[WithDispatch] wrapper. Read more
\ No newline at end of file diff --git a/api/jellytau_lib/playback_mode/trait.RemoteVolumeControl.html b/api/jellytau_lib/playback_mode/trait.RemoteVolumeControl.html new file mode 100644 index 00000000..89046658 --- /dev/null +++ b/api/jellytau_lib/playback_mode/trait.RemoteVolumeControl.html @@ -0,0 +1,14 @@ +RemoteVolumeControl in jellytau_lib::playback_mode - RustSkip to main content

RemoteVolumeControl

Trait RemoteVolumeControl 

Source
pub trait RemoteVolumeControl: Send + Sync {
+    // Required methods
+    fn enable(&self, initial_volume: i32);
+    fn disable(&self);
+}
Expand description

Platform hook for attaching/detaching the OS remote-volume control.

+

On Android, entering remote mode hands the MediaSession a +VolumeProviderCompat so hardware volume buttons and the system slider drive +the remote session; leaving remote mode must hand it back to the local +media stream. Behind a trait so the routing rule (see +PlaybackModeManager::set_mode) is unit-testable off-device — the real +implementation is JNI and only exists on Android.

+

Required Methods§

Source

fn enable(&self, initial_volume: i32)

Attach remote-volume control (and, on Android, start the playback service).

+
Source

fn disable(&self)

Return volume control to the local device speaker.

+

Implementors§

\ No newline at end of file diff --git a/api/jellytau_lib/playback_reporting/index.html b/api/jellytau_lib/playback_reporting/index.html new file mode 100644 index 00000000..b5c026d5 --- /dev/null +++ b/api/jellytau_lib/playback_reporting/index.html @@ -0,0 +1 @@ +jellytau_lib::playback_reporting - RustSkip to main content

Module playback_reporting

Module playback_reporting 

Source

Re-exports§

pub use reporter::PlaybackContext;
pub use reporter::PlaybackOperation;
pub use reporter::PlaybackReporter;
pub use sync_processor::SyncProcessor;
pub use throttle::EventThrottler;

Modules§

reporter
Playback reporter implementation
sync_processor
Sync queue processor with retry logic and exponential backoff
throttle
Event throttler for position update reporting
\ No newline at end of file diff --git a/api/jellytau_lib/playback_reporting/reporter/enum.PlaybackOperation.html b/api/jellytau_lib/playback_reporting/reporter/enum.PlaybackOperation.html new file mode 100644 index 00000000..f483cf1b --- /dev/null +++ b/api/jellytau_lib/playback_reporting/reporter/enum.PlaybackOperation.html @@ -0,0 +1,46 @@ +PlaybackOperation in jellytau_lib::playback_reporting::reporter - RustSkip to main content

PlaybackOperation

Enum PlaybackOperation 

Source
pub enum PlaybackOperation {
+    Start {
+        item_id: String,
+        position_ticks: i64,
+        context: Option<PlaybackContext>,
+    },
+    Progress {
+        item_id: String,
+        position_ticks: i64,
+        is_paused: bool,
+    },
+    Stopped {
+        item_id: String,
+        position_ticks: i64,
+    },
+    MarkPlayed {
+        item_id: String,
+    },
+}
Expand description

Playback operation types

+

Variants§

§

Start

Fields

§item_id: String
§position_ticks: i64
§

Progress

Fields

§item_id: String
§position_ticks: i64
§is_paused: bool
§

Stopped

Fields

§item_id: String
§position_ticks: i64
§

MarkPlayed

Fields

§item_id: String

Trait Implementations§

Source§

impl Clone for PlaybackOperation

Source§

fn clone(&self) -> PlaybackOperation

Returns a duplicate of the value. Read more
1.0.0 (const: unstable) · Source§

fn clone_from(&mut self, source: &Self)

Performs copy-assignment from source. Read more
Source§

impl Debug for PlaybackOperation

Source§

fn fmt(&self, f: &mut Formatter<'_>) -> Result

Formats the value using the given formatter. Read more

Auto Trait Implementations§

Blanket Implementations§

Source§

impl<T> Any for T
where + T: 'static + ?Sized,

Source§

fn type_id(&self) -> TypeId

Gets the TypeId of self. Read more
Source§

impl<T> Borrow<T> for T
where + T: ?Sized,

Source§

fn borrow(&self) -> &T

Immutably borrows from an owned value. Read more
Source§

impl<T> BorrowMut<T> for T
where + T: ?Sized,

Source§

fn borrow_mut(&mut self) -> &mut T

Mutably borrows from an owned value. Read more
Source§

impl<T> CloneToUninit for T
where + T: Clone,

Source§

unsafe fn clone_to_uninit(&self, dest: *mut u8)

🔬This is a nightly-only experimental API. (clone_to_uninit)
Performs copy-assignment from self to dest. Read more
Source§

impl<T> From<T> for T

Source§

fn from(t: T) -> T

Returns the argument unchanged.

+
§

impl<T> Instrument for T

§

fn instrument(self, span: Span) -> Instrumented<Self>

Instruments this type with the provided [Span], returning an +Instrumented wrapper. Read more
§

fn in_current_span(self) -> Instrumented<Self>

Instruments this type with the current Span, returning an +Instrumented wrapper. Read more
Source§

impl<T, U> Into<U> for T
where + U: From<T>,

Source§

fn into(self) -> U

Calls U::from(self).

+

That is, this conversion is whatever the implementation of +From<T> for U chooses to do.

+
§

impl<T> PolicyExt for T
where + T: ?Sized,

§

fn and<P, B, E>(self, other: P) -> And<T, P>
where + T: Policy<B, E>, + P: Policy<B, E>,

Create a new Policy that returns [Action::Follow] only if self and other return +Action::Follow. Read more
§

fn or<P, B, E>(self, other: P) -> Or<T, P>
where + T: Policy<B, E>, + P: Policy<B, E>,

Create a new Policy that returns [Action::Follow] if either self or other returns +Action::Follow. Read more
Source§

impl<T> Same for T

Source§

type Output = T

Should always be Self
Source§

impl<T> ToOwned for T
where + T: Clone,

Source§

type Owned = T

The resulting type after obtaining ownership.
Source§

fn to_owned(&self) -> T

Creates owned data from borrowed data, usually by cloning. Read more
Source§

fn clone_into(&self, target: &mut T)

Uses borrowed data to replace owned data, usually by cloning. Read more
Source§

impl<T, U> TryFrom<U> for T
where + U: Into<T>,

Source§

type Error = Infallible

The type returned in the event of a conversion error.
Source§

fn try_from(value: U) -> Result<T, <T as TryFrom<U>>::Error>

Performs the conversion.
Source§

impl<T, U> TryInto<U> for T
where + U: TryFrom<T>,

Source§

type Error = <U as TryFrom<T>>::Error

The type returned in the event of a conversion error.
Source§

fn try_into(self) -> Result<U, <U as TryFrom<T>>::Error>

Performs the conversion.
§

impl<V, T> VZip<V> for T
where + V: MultiLane<T>,

§

fn vzip(self) -> V

§

impl<T> WithSubscriber for T

§

fn with_subscriber<S>(self, subscriber: S) -> WithDispatch<Self>
where + S: Into<Dispatch>,

Attaches the provided Subscriber to this type, returning a +[WithDispatch] wrapper. Read more
§

fn with_current_subscriber(self) -> WithDispatch<Self>

Attaches the current default Subscriber to this type, returning a +[WithDispatch] wrapper. Read more
§

impl<T> UserEvent for T
where + T: Debug + Clone + Send + 'static,

\ No newline at end of file diff --git a/api/jellytau_lib/playback_reporting/reporter/index.html b/api/jellytau_lib/playback_reporting/reporter/index.html new file mode 100644 index 00000000..e4dfea61 --- /dev/null +++ b/api/jellytau_lib/playback_reporting/reporter/index.html @@ -0,0 +1,4 @@ +jellytau_lib::playback_reporting::reporter - RustSkip to main content

Module reporter

Module reporter 

Source
Expand description

Playback reporter implementation

+

This module is fully implemented but not yet integrated with the player. +Dead code warnings are suppressed until integration is complete.

+

Structs§

PlaybackContext
Playback context information
PlaybackReporter
Main playback reporter that handles dual sync (local DB + server)

Enums§

PlaybackOperation
Playback operation types
\ No newline at end of file diff --git a/api/jellytau_lib/playback_reporting/reporter/sidebar-items.js b/api/jellytau_lib/playback_reporting/reporter/sidebar-items.js new file mode 100644 index 00000000..6e27b95f --- /dev/null +++ b/api/jellytau_lib/playback_reporting/reporter/sidebar-items.js @@ -0,0 +1 @@ +window.SIDEBAR_ITEMS = {"enum":["PlaybackOperation"],"struct":["PlaybackContext","PlaybackReporter"]}; \ No newline at end of file diff --git a/api/jellytau_lib/playback_reporting/reporter/struct.PlaybackContext.html b/api/jellytau_lib/playback_reporting/reporter/struct.PlaybackContext.html new file mode 100644 index 00000000..fd75b6b0 --- /dev/null +++ b/api/jellytau_lib/playback_reporting/reporter/struct.PlaybackContext.html @@ -0,0 +1,31 @@ +PlaybackContext in jellytau_lib::playback_reporting::reporter - RustSkip to main content

PlaybackContext

Struct PlaybackContext 

Source
pub struct PlaybackContext {
+    pub context_type: String,
+    pub context_id: Option<String>,
+}
Expand description

Playback context information

+

Fields§

§context_type: String§context_id: Option<String>

Trait Implementations§

Source§

impl Clone for PlaybackContext

Source§

fn clone(&self) -> PlaybackContext

Returns a duplicate of the value. Read more
1.0.0 (const: unstable) · Source§

fn clone_from(&mut self, source: &Self)

Performs copy-assignment from source. Read more
Source§

impl Debug for PlaybackContext

Source§

fn fmt(&self, f: &mut Formatter<'_>) -> Result

Formats the value using the given formatter. Read more

Auto Trait Implementations§

Blanket Implementations§

Source§

impl<T> Any for T
where + T: 'static + ?Sized,

Source§

fn type_id(&self) -> TypeId

Gets the TypeId of self. Read more
Source§

impl<T> Borrow<T> for T
where + T: ?Sized,

Source§

fn borrow(&self) -> &T

Immutably borrows from an owned value. Read more
Source§

impl<T> BorrowMut<T> for T
where + T: ?Sized,

Source§

fn borrow_mut(&mut self) -> &mut T

Mutably borrows from an owned value. Read more
Source§

impl<T> CloneToUninit for T
where + T: Clone,

Source§

unsafe fn clone_to_uninit(&self, dest: *mut u8)

🔬This is a nightly-only experimental API. (clone_to_uninit)
Performs copy-assignment from self to dest. Read more
Source§

impl<T> From<T> for T

Source§

fn from(t: T) -> T

Returns the argument unchanged.

+
§

impl<T> Instrument for T

§

fn instrument(self, span: Span) -> Instrumented<Self>

Instruments this type with the provided [Span], returning an +Instrumented wrapper. Read more
§

fn in_current_span(self) -> Instrumented<Self>

Instruments this type with the current Span, returning an +Instrumented wrapper. Read more
Source§

impl<T, U> Into<U> for T
where + U: From<T>,

Source§

fn into(self) -> U

Calls U::from(self).

+

That is, this conversion is whatever the implementation of +From<T> for U chooses to do.

+
§

impl<T> PolicyExt for T
where + T: ?Sized,

§

fn and<P, B, E>(self, other: P) -> And<T, P>
where + T: Policy<B, E>, + P: Policy<B, E>,

Create a new Policy that returns [Action::Follow] only if self and other return +Action::Follow. Read more
§

fn or<P, B, E>(self, other: P) -> Or<T, P>
where + T: Policy<B, E>, + P: Policy<B, E>,

Create a new Policy that returns [Action::Follow] if either self or other returns +Action::Follow. Read more
Source§

impl<T> Same for T

Source§

type Output = T

Should always be Self
Source§

impl<T> ToOwned for T
where + T: Clone,

Source§

type Owned = T

The resulting type after obtaining ownership.
Source§

fn to_owned(&self) -> T

Creates owned data from borrowed data, usually by cloning. Read more
Source§

fn clone_into(&self, target: &mut T)

Uses borrowed data to replace owned data, usually by cloning. Read more
Source§

impl<T, U> TryFrom<U> for T
where + U: Into<T>,

Source§

type Error = Infallible

The type returned in the event of a conversion error.
Source§

fn try_from(value: U) -> Result<T, <T as TryFrom<U>>::Error>

Performs the conversion.
Source§

impl<T, U> TryInto<U> for T
where + U: TryFrom<T>,

Source§

type Error = <U as TryFrom<T>>::Error

The type returned in the event of a conversion error.
Source§

fn try_into(self) -> Result<U, <U as TryFrom<T>>::Error>

Performs the conversion.
§

impl<V, T> VZip<V> for T
where + V: MultiLane<T>,

§

fn vzip(self) -> V

§

impl<T> WithSubscriber for T

§

fn with_subscriber<S>(self, subscriber: S) -> WithDispatch<Self>
where + S: Into<Dispatch>,

Attaches the provided Subscriber to this type, returning a +[WithDispatch] wrapper. Read more
§

fn with_current_subscriber(self) -> WithDispatch<Self>

Attaches the current default Subscriber to this type, returning a +[WithDispatch] wrapper. Read more
§

impl<T> UserEvent for T
where + T: Debug + Clone + Send + 'static,

\ No newline at end of file diff --git a/api/jellytau_lib/playback_reporting/reporter/struct.PlaybackReporter.html b/api/jellytau_lib/playback_reporting/reporter/struct.PlaybackReporter.html new file mode 100644 index 00000000..3354f7bc --- /dev/null +++ b/api/jellytau_lib/playback_reporting/reporter/struct.PlaybackReporter.html @@ -0,0 +1,57 @@ +PlaybackReporter in jellytau_lib::playback_reporting::reporter - RustSkip to main content

PlaybackReporter

Struct PlaybackReporter 

Source
pub struct PlaybackReporter {
+    db_service: Arc<RusqliteService>,
+    jellyfin_client: Arc<Mutex<Option<JellyfinClient>>>,
+    user_id: String,
+}
Expand description

Main playback reporter that handles dual sync (local DB + server)

+

Fields§

§db_service: Arc<RusqliteService>§jellyfin_client: Arc<Mutex<Option<JellyfinClient>>>§user_id: String

Implementations§

Source§

impl PlaybackReporter

Source

pub fn new( + db_service: Arc<RusqliteService>, + jellyfin_client: Arc<TokioMutex<Option<JellyfinClient>>>, + user_id: String, +) -> Self

Creates a new PlaybackReporter

+
Source

pub async fn report( + &self, + operation: PlaybackOperation, + is_online: bool, +) -> Result<(), String>

Reports a playback operation (dual sync: local DB + server)

+

Always updates local DB first, then attempts server sync if online. +If server sync fails, operation is queued for retry.

+
Source

async fn update_local_db( + &self, + operation: &PlaybackOperation, +) -> Result<(), String>

Updates local database with playback info

+
Source

async fn sync_to_server( + &self, + operation: &PlaybackOperation, +) -> Result<(), String>

Syncs to Jellyfin server

+
Source

async fn queue_for_sync( + &self, + operation: &PlaybackOperation, +) -> Result<(), String>

Queues operation for later sync

+
Source

async fn mark_synced(&self, item_id: &str) -> Result<(), String>

Marks an item as synced in the local database

+
Source

fn get_item_id(&self, operation: &PlaybackOperation) -> Option<String>

Extracts item_id from operation

+

Trait Implementations§

Source§

impl Clone for PlaybackReporter

Source§

fn clone(&self) -> Self

Returns a duplicate of the value. Read more
1.0.0 (const: unstable) · Source§

fn clone_from(&mut self, source: &Self)

Performs copy-assignment from source. Read more

Auto Trait Implementations§

Blanket Implementations§

Source§

impl<T> Any for T
where + T: 'static + ?Sized,

Source§

fn type_id(&self) -> TypeId

Gets the TypeId of self. Read more
Source§

impl<T> Borrow<T> for T
where + T: ?Sized,

Source§

fn borrow(&self) -> &T

Immutably borrows from an owned value. Read more
Source§

impl<T> BorrowMut<T> for T
where + T: ?Sized,

Source§

fn borrow_mut(&mut self) -> &mut T

Mutably borrows from an owned value. Read more
Source§

impl<T> CloneToUninit for T
where + T: Clone,

Source§

unsafe fn clone_to_uninit(&self, dest: *mut u8)

🔬This is a nightly-only experimental API. (clone_to_uninit)
Performs copy-assignment from self to dest. Read more
Source§

impl<T> From<T> for T

Source§

fn from(t: T) -> T

Returns the argument unchanged.

+
§

impl<T> Instrument for T

§

fn instrument(self, span: Span) -> Instrumented<Self>

Instruments this type with the provided [Span], returning an +Instrumented wrapper. Read more
§

fn in_current_span(self) -> Instrumented<Self>

Instruments this type with the current Span, returning an +Instrumented wrapper. Read more
Source§

impl<T, U> Into<U> for T
where + U: From<T>,

Source§

fn into(self) -> U

Calls U::from(self).

+

That is, this conversion is whatever the implementation of +From<T> for U chooses to do.

+
§

impl<T> PolicyExt for T
where + T: ?Sized,

§

fn and<P, B, E>(self, other: P) -> And<T, P>
where + T: Policy<B, E>, + P: Policy<B, E>,

Create a new Policy that returns [Action::Follow] only if self and other return +Action::Follow. Read more
§

fn or<P, B, E>(self, other: P) -> Or<T, P>
where + T: Policy<B, E>, + P: Policy<B, E>,

Create a new Policy that returns [Action::Follow] if either self or other returns +Action::Follow. Read more
Source§

impl<T> Same for T

Source§

type Output = T

Should always be Self
Source§

impl<T> ToOwned for T
where + T: Clone,

Source§

type Owned = T

The resulting type after obtaining ownership.
Source§

fn to_owned(&self) -> T

Creates owned data from borrowed data, usually by cloning. Read more
Source§

fn clone_into(&self, target: &mut T)

Uses borrowed data to replace owned data, usually by cloning. Read more
Source§

impl<T, U> TryFrom<U> for T
where + U: Into<T>,

Source§

type Error = Infallible

The type returned in the event of a conversion error.
Source§

fn try_from(value: U) -> Result<T, <T as TryFrom<U>>::Error>

Performs the conversion.
Source§

impl<T, U> TryInto<U> for T
where + U: TryFrom<T>,

Source§

type Error = <U as TryFrom<T>>::Error

The type returned in the event of a conversion error.
Source§

fn try_into(self) -> Result<U, <U as TryFrom<T>>::Error>

Performs the conversion.
§

impl<V, T> VZip<V> for T
where + V: MultiLane<T>,

§

fn vzip(self) -> V

§

impl<T> WithSubscriber for T

§

fn with_subscriber<S>(self, subscriber: S) -> WithDispatch<Self>
where + S: Into<Dispatch>,

Attaches the provided Subscriber to this type, returning a +[WithDispatch] wrapper. Read more
§

fn with_current_subscriber(self) -> WithDispatch<Self>

Attaches the current default Subscriber to this type, returning a +[WithDispatch] wrapper. Read more
\ No newline at end of file diff --git a/api/jellytau_lib/playback_reporting/sidebar-items.js b/api/jellytau_lib/playback_reporting/sidebar-items.js new file mode 100644 index 00000000..a20b7c31 --- /dev/null +++ b/api/jellytau_lib/playback_reporting/sidebar-items.js @@ -0,0 +1 @@ +window.SIDEBAR_ITEMS = {"mod":["reporter","sync_processor","throttle"]}; \ No newline at end of file diff --git a/api/jellytau_lib/playback_reporting/sync_processor/index.html b/api/jellytau_lib/playback_reporting/sync_processor/index.html new file mode 100644 index 00000000..4348c7ee --- /dev/null +++ b/api/jellytau_lib/playback_reporting/sync_processor/index.html @@ -0,0 +1,4 @@ +jellytau_lib::playback_reporting::sync_processor - RustSkip to main content

Module sync_processor

Module sync_processor 

Source
Expand description

Sync queue processor with retry logic and exponential backoff

+

This is a placeholder implementation. Full implementation will be added +when the reporter is integrated. Dead code warnings are suppressed.

+

Structs§

SyncConfig
Configuration for sync processor
SyncProcessor
Sync queue processor that handles retry logic with exponential backoff
\ No newline at end of file diff --git a/api/jellytau_lib/playback_reporting/sync_processor/sidebar-items.js b/api/jellytau_lib/playback_reporting/sync_processor/sidebar-items.js new file mode 100644 index 00000000..efaaac58 --- /dev/null +++ b/api/jellytau_lib/playback_reporting/sync_processor/sidebar-items.js @@ -0,0 +1 @@ +window.SIDEBAR_ITEMS = {"struct":["SyncConfig","SyncProcessor"]}; \ No newline at end of file diff --git a/api/jellytau_lib/playback_reporting/sync_processor/struct.SyncConfig.html b/api/jellytau_lib/playback_reporting/sync_processor/struct.SyncConfig.html new file mode 100644 index 00000000..b34ac439 --- /dev/null +++ b/api/jellytau_lib/playback_reporting/sync_processor/struct.SyncConfig.html @@ -0,0 +1,30 @@ +SyncConfig in jellytau_lib::playback_reporting::sync_processor - RustSkip to main content

SyncConfig

Struct SyncConfig 

Source
pub struct SyncConfig {
+    pub max_retries: u32,
+    pub base_retry_delay_ms: u64,
+    pub batch_size: usize,
+}
Expand description

Configuration for sync processor

+

Fields§

§max_retries: u32§base_retry_delay_ms: u64§batch_size: usize

Trait Implementations§

Source§

impl Default for SyncConfig

Source§

fn default() -> Self

Returns the “default value” for a type. Read more

Auto Trait Implementations§

Blanket Implementations§

Source§

impl<T> Any for T
where + T: 'static + ?Sized,

Source§

fn type_id(&self) -> TypeId

Gets the TypeId of self. Read more
Source§

impl<T> Borrow<T> for T
where + T: ?Sized,

Source§

fn borrow(&self) -> &T

Immutably borrows from an owned value. Read more
Source§

impl<T> BorrowMut<T> for T
where + T: ?Sized,

Source§

fn borrow_mut(&mut self) -> &mut T

Mutably borrows from an owned value. Read more
Source§

impl<T> From<T> for T

Source§

fn from(t: T) -> T

Returns the argument unchanged.

+
§

impl<T> Instrument for T

§

fn instrument(self, span: Span) -> Instrumented<Self>

Instruments this type with the provided [Span], returning an +Instrumented wrapper. Read more
§

fn in_current_span(self) -> Instrumented<Self>

Instruments this type with the current Span, returning an +Instrumented wrapper. Read more
Source§

impl<T, U> Into<U> for T
where + U: From<T>,

Source§

fn into(self) -> U

Calls U::from(self).

+

That is, this conversion is whatever the implementation of +From<T> for U chooses to do.

+
§

impl<T> NoneValue for T
where + T: Default,

§

type NoneType = T

§

fn null_value() -> T

The none-equivalent value.
§

impl<T> PolicyExt for T
where + T: ?Sized,

§

fn and<P, B, E>(self, other: P) -> And<T, P>
where + T: Policy<B, E>, + P: Policy<B, E>,

Create a new Policy that returns [Action::Follow] only if self and other return +Action::Follow. Read more
§

fn or<P, B, E>(self, other: P) -> Or<T, P>
where + T: Policy<B, E>, + P: Policy<B, E>,

Create a new Policy that returns [Action::Follow] if either self or other returns +Action::Follow. Read more
Source§

impl<T> Same for T

Source§

type Output = T

Should always be Self
Source§

impl<T, U> TryFrom<U> for T
where + U: Into<T>,

Source§

type Error = Infallible

The type returned in the event of a conversion error.
Source§

fn try_from(value: U) -> Result<T, <T as TryFrom<U>>::Error>

Performs the conversion.
Source§

impl<T, U> TryInto<U> for T
where + U: TryFrom<T>,

Source§

type Error = <U as TryFrom<T>>::Error

The type returned in the event of a conversion error.
Source§

fn try_into(self) -> Result<U, <U as TryFrom<T>>::Error>

Performs the conversion.
§

impl<V, T> VZip<V> for T
where + V: MultiLane<T>,

§

fn vzip(self) -> V

§

impl<T> WithSubscriber for T

§

fn with_subscriber<S>(self, subscriber: S) -> WithDispatch<Self>
where + S: Into<Dispatch>,

Attaches the provided Subscriber to this type, returning a +[WithDispatch] wrapper. Read more
§

fn with_current_subscriber(self) -> WithDispatch<Self>

Attaches the current default Subscriber to this type, returning a +[WithDispatch] wrapper. Read more
\ No newline at end of file diff --git a/api/jellytau_lib/playback_reporting/sync_processor/struct.SyncProcessor.html b/api/jellytau_lib/playback_reporting/sync_processor/struct.SyncProcessor.html new file mode 100644 index 00000000..654a9934 --- /dev/null +++ b/api/jellytau_lib/playback_reporting/sync_processor/struct.SyncProcessor.html @@ -0,0 +1,43 @@ +SyncProcessor in jellytau_lib::playback_reporting::sync_processor - RustSkip to main content

SyncProcessor

Struct SyncProcessor 

Source
pub struct SyncProcessor {
+    _db_service: Arc<RusqliteService>,
+    _jellyfin_client: Arc<Mutex<Option<JellyfinClient>>>,
+    _repository: Arc<dyn MediaRepository>,
+    _processing: Arc<Mutex<bool>>,
+    _cancelled: Arc<AtomicBool>,
+    _config: SyncConfig,
+}
Expand description

Sync queue processor that handles retry logic with exponential backoff

+

This is a placeholder implementation. Full implementation will be added +in a subsequent task following the plan.

+

Fields§

§_db_service: Arc<RusqliteService>§_jellyfin_client: Arc<Mutex<Option<JellyfinClient>>>§_repository: Arc<dyn MediaRepository>§_processing: Arc<Mutex<bool>>§_cancelled: Arc<AtomicBool>§_config: SyncConfig

Implementations§

Source§

impl SyncProcessor

Source

pub fn new( + db_service: Arc<RusqliteService>, + jellyfin_client: Arc<TokioMutex<Option<JellyfinClient>>>, + repository: Arc<dyn MediaRepository>, +) -> Self

Creates a new SyncProcessor

+
Source

pub async fn start(&self) -> Result<(), String>

Starts the sync processor

+
Source

pub async fn stop(&self) -> Result<(), String>

Stops the sync processor

+
Source

pub async fn process_queue(&self) -> Result<(), String>

Processes the sync queue once

+
Source

fn _calculate_backoff(&self, retry_count: u32) -> Duration

Calculates exponential backoff delay

+

Auto Trait Implementations§

Blanket Implementations§

Source§

impl<T> Any for T
where + T: 'static + ?Sized,

Source§

fn type_id(&self) -> TypeId

Gets the TypeId of self. Read more
Source§

impl<T> Borrow<T> for T
where + T: ?Sized,

Source§

fn borrow(&self) -> &T

Immutably borrows from an owned value. Read more
Source§

impl<T> BorrowMut<T> for T
where + T: ?Sized,

Source§

fn borrow_mut(&mut self) -> &mut T

Mutably borrows from an owned value. Read more
Source§

impl<T> From<T> for T

Source§

fn from(t: T) -> T

Returns the argument unchanged.

+
§

impl<T> Instrument for T

§

fn instrument(self, span: Span) -> Instrumented<Self>

Instruments this type with the provided [Span], returning an +Instrumented wrapper. Read more
§

fn in_current_span(self) -> Instrumented<Self>

Instruments this type with the current Span, returning an +Instrumented wrapper. Read more
Source§

impl<T, U> Into<U> for T
where + U: From<T>,

Source§

fn into(self) -> U

Calls U::from(self).

+

That is, this conversion is whatever the implementation of +From<T> for U chooses to do.

+
§

impl<T> PolicyExt for T
where + T: ?Sized,

§

fn and<P, B, E>(self, other: P) -> And<T, P>
where + T: Policy<B, E>, + P: Policy<B, E>,

Create a new Policy that returns [Action::Follow] only if self and other return +Action::Follow. Read more
§

fn or<P, B, E>(self, other: P) -> Or<T, P>
where + T: Policy<B, E>, + P: Policy<B, E>,

Create a new Policy that returns [Action::Follow] if either self or other returns +Action::Follow. Read more
Source§

impl<T> Same for T

Source§

type Output = T

Should always be Self
Source§

impl<T, U> TryFrom<U> for T
where + U: Into<T>,

Source§

type Error = Infallible

The type returned in the event of a conversion error.
Source§

fn try_from(value: U) -> Result<T, <T as TryFrom<U>>::Error>

Performs the conversion.
Source§

impl<T, U> TryInto<U> for T
where + U: TryFrom<T>,

Source§

type Error = <U as TryFrom<T>>::Error

The type returned in the event of a conversion error.
Source§

fn try_into(self) -> Result<U, <U as TryFrom<T>>::Error>

Performs the conversion.
§

impl<V, T> VZip<V> for T
where + V: MultiLane<T>,

§

fn vzip(self) -> V

§

impl<T> WithSubscriber for T

§

fn with_subscriber<S>(self, subscriber: S) -> WithDispatch<Self>
where + S: Into<Dispatch>,

Attaches the provided Subscriber to this type, returning a +[WithDispatch] wrapper. Read more
§

fn with_current_subscriber(self) -> WithDispatch<Self>

Attaches the current default Subscriber to this type, returning a +[WithDispatch] wrapper. Read more
\ No newline at end of file diff --git a/api/jellytau_lib/playback_reporting/throttle/index.html b/api/jellytau_lib/playback_reporting/throttle/index.html new file mode 100644 index 00000000..80e5827e --- /dev/null +++ b/api/jellytau_lib/playback_reporting/throttle/index.html @@ -0,0 +1,4 @@ +jellytau_lib::playback_reporting::throttle - RustSkip to main content

Module throttle

Module throttle 

Source
Expand description

Event throttler for position update reporting

+

This module is fully implemented but not yet integrated with the player. +Dead code warnings are suppressed until integration is complete.

+

Structs§

EventThrottler
Event throttler to prevent spam from frequent position updates.
\ No newline at end of file diff --git a/api/jellytau_lib/playback_reporting/throttle/sidebar-items.js b/api/jellytau_lib/playback_reporting/throttle/sidebar-items.js new file mode 100644 index 00000000..66c29341 --- /dev/null +++ b/api/jellytau_lib/playback_reporting/throttle/sidebar-items.js @@ -0,0 +1 @@ +window.SIDEBAR_ITEMS = {"struct":["EventThrottler"]}; \ No newline at end of file diff --git a/api/jellytau_lib/playback_reporting/throttle/struct.EventThrottler.html b/api/jellytau_lib/playback_reporting/throttle/struct.EventThrottler.html new file mode 100644 index 00000000..3b3c1202 --- /dev/null +++ b/api/jellytau_lib/playback_reporting/throttle/struct.EventThrottler.html @@ -0,0 +1,39 @@ +EventThrottler in jellytau_lib::playback_reporting::throttle - RustSkip to main content

EventThrottler

Struct EventThrottler 

Source
pub struct EventThrottler {
+    last_report_time: Arc<Mutex<HashMap<String, Instant>>>,
+    throttle_duration: Duration,
+}
Expand description

Event throttler to prevent spam from frequent position updates.

+

Tracks the last report time for each item and ensures reports are only +sent at most once per throttle duration (default 30 seconds).

+

Fields§

§last_report_time: Arc<Mutex<HashMap<String, Instant>>>§throttle_duration: Duration

Implementations§

Source§

impl EventThrottler

Source

pub fn new() -> Self

Creates a new EventThrottler with 30 second default interval

+
Source

pub fn with_duration(duration: Duration) -> Self

Creates a new EventThrottler with custom interval

+
Source

pub fn should_report(&self, item_id: &str) -> bool

Checks if enough time has elapsed since the last report for this item

+
Source

pub fn mark_reported(&self, item_id: &str)

Marks the item as reported at the current time

+
Source

pub fn clear(&self)

Clears all tracked report times

+
Source

pub fn clear_item(&self, item_id: &str)

Removes a specific item from tracking

+

Trait Implementations§

Source§

impl Clone for EventThrottler

Source§

fn clone(&self) -> Self

Returns a duplicate of the value. Read more
1.0.0 (const: unstable) · Source§

fn clone_from(&mut self, source: &Self)

Performs copy-assignment from source. Read more
Source§

impl Default for EventThrottler

Source§

fn default() -> Self

Returns the “default value” for a type. Read more

Auto Trait Implementations§

Blanket Implementations§

Source§

impl<T> Any for T
where + T: 'static + ?Sized,

Source§

fn type_id(&self) -> TypeId

Gets the TypeId of self. Read more
Source§

impl<T> Borrow<T> for T
where + T: ?Sized,

Source§

fn borrow(&self) -> &T

Immutably borrows from an owned value. Read more
Source§

impl<T> BorrowMut<T> for T
where + T: ?Sized,

Source§

fn borrow_mut(&mut self) -> &mut T

Mutably borrows from an owned value. Read more
Source§

impl<T> CloneToUninit for T
where + T: Clone,

Source§

unsafe fn clone_to_uninit(&self, dest: *mut u8)

🔬This is a nightly-only experimental API. (clone_to_uninit)
Performs copy-assignment from self to dest. Read more
Source§

impl<T> From<T> for T

Source§

fn from(t: T) -> T

Returns the argument unchanged.

+
§

impl<T> Instrument for T

§

fn instrument(self, span: Span) -> Instrumented<Self>

Instruments this type with the provided [Span], returning an +Instrumented wrapper. Read more
§

fn in_current_span(self) -> Instrumented<Self>

Instruments this type with the current Span, returning an +Instrumented wrapper. Read more
Source§

impl<T, U> Into<U> for T
where + U: From<T>,

Source§

fn into(self) -> U

Calls U::from(self).

+

That is, this conversion is whatever the implementation of +From<T> for U chooses to do.

+
§

impl<T> NoneValue for T
where + T: Default,

§

type NoneType = T

§

fn null_value() -> T

The none-equivalent value.
§

impl<T> PolicyExt for T
where + T: ?Sized,

§

fn and<P, B, E>(self, other: P) -> And<T, P>
where + T: Policy<B, E>, + P: Policy<B, E>,

Create a new Policy that returns [Action::Follow] only if self and other return +Action::Follow. Read more
§

fn or<P, B, E>(self, other: P) -> Or<T, P>
where + T: Policy<B, E>, + P: Policy<B, E>,

Create a new Policy that returns [Action::Follow] if either self or other returns +Action::Follow. Read more
Source§

impl<T> Same for T

Source§

type Output = T

Should always be Self
Source§

impl<T> ToOwned for T
where + T: Clone,

Source§

type Owned = T

The resulting type after obtaining ownership.
Source§

fn to_owned(&self) -> T

Creates owned data from borrowed data, usually by cloning. Read more
Source§

fn clone_into(&self, target: &mut T)

Uses borrowed data to replace owned data, usually by cloning. Read more
Source§

impl<T, U> TryFrom<U> for T
where + U: Into<T>,

Source§

type Error = Infallible

The type returned in the event of a conversion error.
Source§

fn try_from(value: U) -> Result<T, <T as TryFrom<U>>::Error>

Performs the conversion.
Source§

impl<T, U> TryInto<U> for T
where + U: TryFrom<T>,

Source§

type Error = <U as TryFrom<T>>::Error

The type returned in the event of a conversion error.
Source§

fn try_into(self) -> Result<U, <U as TryFrom<T>>::Error>

Performs the conversion.
§

impl<V, T> VZip<V> for T
where + V: MultiLane<T>,

§

fn vzip(self) -> V

§

impl<T> WithSubscriber for T

§

fn with_subscriber<S>(self, subscriber: S) -> WithDispatch<Self>
where + S: Into<Dispatch>,

Attaches the provided Subscriber to this type, returning a +[WithDispatch] wrapper. Read more
§

fn with_current_subscriber(self) -> WithDispatch<Self>

Attaches the current default Subscriber to this type, returning a +[WithDispatch] wrapper. Read more
\ No newline at end of file diff --git a/api/jellytau_lib/player/autoplay/enum.AutoplayDecision.html b/api/jellytau_lib/player/autoplay/enum.AutoplayDecision.html new file mode 100644 index 00000000..b02c0d34 --- /dev/null +++ b/api/jellytau_lib/player/autoplay/enum.AutoplayDecision.html @@ -0,0 +1,59 @@ +AutoplayDecision in jellytau_lib::player::autoplay - RustSkip to main content

AutoplayDecision

Enum AutoplayDecision 

Source
pub enum AutoplayDecision {
+    Stop,
+    AdvanceToNext,
+    ResumeStream {
+        position: f64,
+    },
+    ShowNextEpisodePopup {
+        current_episode: MediaItem,
+        next_episode: MediaItem,
+        countdown_seconds: u32,
+        auto_advance: bool,
+    },
+}
Expand description

Autoplay decision result - determines what happens after playback ends

+

Variants§

§

Stop

Stop playback (no next item or timer expired)

+
§

AdvanceToNext

Advance to next track in queue (for audio/movies)

+
§

ResumeStream

The stream ended well short of the item’s runtime — the connection +dropped, not the media. Re-open the same stream at position instead of +running any end-of-item logic (UR-040).

+

Fields

§position: f64
§

ShowNextEpisodePopup

Show next episode popup with countdown

+

Fields

§current_episode: MediaItem
§next_episode: MediaItem
§countdown_seconds: u32
§auto_advance: bool

Trait Implementations§

Source§

impl Clone for AutoplayDecision

Source§

fn clone(&self) -> AutoplayDecision

Returns a duplicate of the value. Read more
1.0.0 (const: unstable) · Source§

fn clone_from(&mut self, source: &Self)

Performs copy-assignment from source. Read more
Source§

impl Debug for AutoplayDecision

Source§

fn fmt(&self, f: &mut Formatter<'_>) -> Result

Formats the value using the given formatter. Read more
Source§

impl NamedType for AutoplayDecision

Source§

fn sid() -> SpectaID

Source§

fn named_data_type( + type_map: &mut TypeCollection, + generics: &[DataType], +) -> NamedDataType

this is equivalent to [Type::inline] but returns a [NamedDataType] instead.
Source§

fn definition_named_data_type(type_map: &mut TypeCollection) -> NamedDataType

this is equivalent to [Type::definition] but returns a [NamedDataType] instead.
Source§

impl Serialize for AutoplayDecision

Source§

fn serialize<__S>(&self, __serializer: __S) -> Result<__S::Ok, __S::Error>
where + __S: Serializer,

Serialize this value into the given Serde serializer. Read more
Source§

impl Type for AutoplayDecision

Source§

fn inline(type_map: &mut TypeCollection, generics: Generics<'_>) -> DataType

Returns the definition of a type using the provided generics. Read more
Source§

fn reference(type_map: &mut TypeCollection, generics: &[DataType]) -> Reference

Generates a datatype corresponding to a reference to this type, +as determined by its category. Getting a reference to a type implies that +it should belong in the type map (since it has to be referenced from somewhere), +so the output of definition will be put into the type map.
Source§

impl Flatten for AutoplayDecision

Auto Trait Implementations§

Blanket Implementations§

Source§

impl<T> Any for T
where + T: 'static + ?Sized,

Source§

fn type_id(&self) -> TypeId

Gets the TypeId of self. Read more
Source§

impl<T> Borrow<T> for T
where + T: ?Sized,

Source§

fn borrow(&self) -> &T

Immutably borrows from an owned value. Read more
Source§

impl<T> BorrowMut<T> for T
where + T: ?Sized,

Source§

fn borrow_mut(&mut self) -> &mut T

Mutably borrows from an owned value. Read more
Source§

impl<T> CloneToUninit for T
where + T: Clone,

Source§

unsafe fn clone_to_uninit(&self, dest: *mut u8)

🔬This is a nightly-only experimental API. (clone_to_uninit)
Performs copy-assignment from self to dest. Read more
Source§

impl<T> From<T> for T

Source§

fn from(t: T) -> T

Returns the argument unchanged.

+
§

impl<T> FunctionArg for T
where + T: Type,

§

fn to_datatype(type_map: &mut TypeCollection) -> Option<DataType>

Gets the type of an argument as a [DataType]. Read more
§

impl<T> Instrument for T

§

fn instrument(self, span: Span) -> Instrumented<Self>

Instruments this type with the provided [Span], returning an +Instrumented wrapper. Read more
§

fn in_current_span(self) -> Instrumented<Self>

Instruments this type with the current Span, returning an +Instrumented wrapper. Read more
Source§

impl<T, U> Into<U> for T
where + U: From<T>,

Source§

fn into(self) -> U

Calls U::from(self).

+

That is, this conversion is whatever the implementation of +From<T> for U chooses to do.

+
§

impl<T> IpcResponse for T
where + T: Serialize,

§

fn body(self) -> Result<InvokeResponseBody, Error>

Resolve the IPC response body.
§

impl<T> PolicyExt for T
where + T: ?Sized,

§

fn and<P, B, E>(self, other: P) -> And<T, P>
where + T: Policy<B, E>, + P: Policy<B, E>,

Create a new Policy that returns [Action::Follow] only if self and other return +Action::Follow. Read more
§

fn or<P, B, E>(self, other: P) -> Or<T, P>
where + T: Policy<B, E>, + P: Policy<B, E>,

Create a new Policy that returns [Action::Follow] if either self or other returns +Action::Follow. Read more
Source§

impl<T> Same for T

Source§

type Output = T

Should always be Self
Source§

impl<T> Serialize for T
where + T: Serialize + ?Sized,

Source§

fn erased_serialize(&self, serializer: &mut dyn Serializer) -> Result<(), Error>

Source§

fn do_erased_serialize( + &self, + serializer: &mut dyn Serializer, +) -> Result<(), ErrorImpl>

Source§

impl<T> ToOwned for T
where + T: Clone,

Source§

type Owned = T

The resulting type after obtaining ownership.
Source§

fn to_owned(&self) -> T

Creates owned data from borrowed data, usually by cloning. Read more
Source§

fn clone_into(&self, target: &mut T)

Uses borrowed data to replace owned data, usually by cloning. Read more
Source§

impl<T, U> TryFrom<U> for T
where + U: Into<T>,

Source§

type Error = Infallible

The type returned in the event of a conversion error.
Source§

fn try_from(value: U) -> Result<T, <T as TryFrom<U>>::Error>

Performs the conversion.
Source§

impl<T, U> TryInto<U> for T
where + U: TryFrom<T>,

Source§

type Error = <U as TryFrom<T>>::Error

The type returned in the event of a conversion error.
Source§

fn try_into(self) -> Result<U, <U as TryFrom<T>>::Error>

Performs the conversion.
§

impl<V, T> VZip<V> for T
where + V: MultiLane<T>,

§

fn vzip(self) -> V

§

impl<T> WithSubscriber for T

§

fn with_subscriber<S>(self, subscriber: S) -> WithDispatch<Self>
where + S: Into<Dispatch>,

Attaches the provided Subscriber to this type, returning a +[WithDispatch] wrapper. Read more
§

fn with_current_subscriber(self) -> WithDispatch<Self>

Attaches the current default Subscriber to this type, returning a +[WithDispatch] wrapper. Read more
§

impl<T> UserEvent for T
where + T: Debug + Clone + Send + 'static,

\ No newline at end of file diff --git a/api/jellytau_lib/player/autoplay/index.html b/api/jellytau_lib/player/autoplay/index.html new file mode 100644 index 00000000..e40c8623 --- /dev/null +++ b/api/jellytau_lib/player/autoplay/index.html @@ -0,0 +1 @@ +jellytau_lib::player::autoplay - RustSkip to main content

Module autoplay

Module autoplay 

Source

Structs§

AutoplaySettings
Autoplay settings (controls next episode behavior)

Enums§

AutoplayDecision
Autoplay decision result - determines what happens after playback ends
\ No newline at end of file diff --git a/api/jellytau_lib/player/autoplay/sidebar-items.js b/api/jellytau_lib/player/autoplay/sidebar-items.js new file mode 100644 index 00000000..1ca41655 --- /dev/null +++ b/api/jellytau_lib/player/autoplay/sidebar-items.js @@ -0,0 +1 @@ +window.SIDEBAR_ITEMS = {"enum":["AutoplayDecision"],"struct":["AutoplaySettings"]}; \ No newline at end of file diff --git a/api/jellytau_lib/player/autoplay/struct.AutoplaySettings.html b/api/jellytau_lib/player/autoplay/struct.AutoplaySettings.html new file mode 100644 index 00000000..e4a1ac3d --- /dev/null +++ b/api/jellytau_lib/player/autoplay/struct.AutoplaySettings.html @@ -0,0 +1,59 @@ +AutoplaySettings in jellytau_lib::player::autoplay - RustSkip to main content

AutoplaySettings

Struct AutoplaySettings 

Source
pub struct AutoplaySettings {
+    pub enabled: bool,
+    pub countdown_seconds: u32,
+    pub max_episodes: u32,
+}
Expand description

Autoplay settings (controls next episode behavior)

+

Fields§

§enabled: bool

Whether autoplay is enabled for next episodes

+
§countdown_seconds: u32

Countdown duration in seconds before auto-playing next episode

+
§max_episodes: u32

Maximum number of episodes to auto-play consecutively (0 = unlimited)

+

Implementations§

Source§

impl AutoplaySettings

Source

pub fn with_validated_countdown(self) -> Self

Validate and clamp countdown seconds to reasonable range (5-30 seconds)

+

Trait Implementations§

Source§

impl Clone for AutoplaySettings

Source§

fn clone(&self) -> AutoplaySettings

Returns a duplicate of the value. Read more
1.0.0 (const: unstable) · Source§

fn clone_from(&mut self, source: &Self)

Performs copy-assignment from source. Read more
Source§

impl Debug for AutoplaySettings

Source§

fn fmt(&self, f: &mut Formatter<'_>) -> Result

Formats the value using the given formatter. Read more
Source§

impl Default for AutoplaySettings

Source§

fn default() -> Self

Returns the “default value” for a type. Read more
Source§

impl<'de> Deserialize<'de> for AutoplaySettings

Source§

fn deserialize<__D>(__deserializer: __D) -> Result<Self, __D::Error>
where + __D: Deserializer<'de>,

Deserialize this value from the given Serde deserializer. Read more
Source§

impl NamedType for AutoplaySettings

Source§

fn sid() -> SpectaID

Source§

fn named_data_type( + type_map: &mut TypeCollection, + generics: &[DataType], +) -> NamedDataType

this is equivalent to [Type::inline] but returns a [NamedDataType] instead.
Source§

fn definition_named_data_type(type_map: &mut TypeCollection) -> NamedDataType

this is equivalent to [Type::definition] but returns a [NamedDataType] instead.
Source§

impl Serialize for AutoplaySettings

Source§

fn serialize<__S>(&self, __serializer: __S) -> Result<__S::Ok, __S::Error>
where + __S: Serializer,

Serialize this value into the given Serde serializer. Read more
Source§

impl Type for AutoplaySettings

Source§

fn inline(type_map: &mut TypeCollection, generics: Generics<'_>) -> DataType

Returns the definition of a type using the provided generics. Read more
Source§

fn reference(type_map: &mut TypeCollection, generics: &[DataType]) -> Reference

Generates a datatype corresponding to a reference to this type, +as determined by its category. Getting a reference to a type implies that +it should belong in the type map (since it has to be referenced from somewhere), +so the output of definition will be put into the type map.
Source§

impl Flatten for AutoplaySettings

Auto Trait Implementations§

Blanket Implementations§

Source§

impl<T> Any for T
where + T: 'static + ?Sized,

Source§

fn type_id(&self) -> TypeId

Gets the TypeId of self. Read more
Source§

impl<T> Borrow<T> for T
where + T: ?Sized,

Source§

fn borrow(&self) -> &T

Immutably borrows from an owned value. Read more
Source§

impl<T> BorrowMut<T> for T
where + T: ?Sized,

Source§

fn borrow_mut(&mut self) -> &mut T

Mutably borrows from an owned value. Read more
Source§

impl<T> CloneToUninit for T
where + T: Clone,

Source§

unsafe fn clone_to_uninit(&self, dest: *mut u8)

🔬This is a nightly-only experimental API. (clone_to_uninit)
Performs copy-assignment from self to dest. Read more
§

impl<'de, D, R> CommandArg<'de, R> for D
where + D: Deserialize<'de>, + R: Runtime,

§

fn from_command(command: CommandItem<'de, R>) -> Result<D, InvokeError>

Derives an instance of Self from the [CommandItem]. Read more
Source§

impl<T> From<T> for T

Source§

fn from(t: T) -> T

Returns the argument unchanged.

+
§

impl<T> FunctionArg for T
where + T: Type,

§

fn to_datatype(type_map: &mut TypeCollection) -> Option<DataType>

Gets the type of an argument as a [DataType]. Read more
§

impl<T> Instrument for T

§

fn instrument(self, span: Span) -> Instrumented<Self>

Instruments this type with the provided [Span], returning an +Instrumented wrapper. Read more
§

fn in_current_span(self) -> Instrumented<Self>

Instruments this type with the current Span, returning an +Instrumented wrapper. Read more
Source§

impl<T, U> Into<U> for T
where + U: From<T>,

Source§

fn into(self) -> U

Calls U::from(self).

+

That is, this conversion is whatever the implementation of +From<T> for U chooses to do.

+
§

impl<T> IpcResponse for T
where + T: Serialize,

§

fn body(self) -> Result<InvokeResponseBody, Error>

Resolve the IPC response body.
§

impl<T> NoneValue for T
where + T: Default,

§

type NoneType = T

§

fn null_value() -> T

The none-equivalent value.
§

impl<T> PolicyExt for T
where + T: ?Sized,

§

fn and<P, B, E>(self, other: P) -> And<T, P>
where + T: Policy<B, E>, + P: Policy<B, E>,

Create a new Policy that returns [Action::Follow] only if self and other return +Action::Follow. Read more
§

fn or<P, B, E>(self, other: P) -> Or<T, P>
where + T: Policy<B, E>, + P: Policy<B, E>,

Create a new Policy that returns [Action::Follow] if either self or other returns +Action::Follow. Read more
Source§

impl<T> Same for T

Source§

type Output = T

Should always be Self
§

impl<T> ScopeObject for T
where + T: Send + Sync + Debug + DeserializeOwned + 'static,

§

type Error = Error

The error type.
§

fn deserialize<R>( + _app: &AppHandle<R>, + raw: Value, +) -> Result<T, <T as ScopeObject>::Error>
where + R: Runtime,

Deserialize the raw scope value.
Source§

impl<T> Serialize for T
where + T: Serialize + ?Sized,

Source§

fn erased_serialize(&self, serializer: &mut dyn Serializer) -> Result<(), Error>

Source§

fn do_erased_serialize( + &self, + serializer: &mut dyn Serializer, +) -> Result<(), ErrorImpl>

Source§

impl<T> ToOwned for T
where + T: Clone,

Source§

type Owned = T

The resulting type after obtaining ownership.
Source§

fn to_owned(&self) -> T

Creates owned data from borrowed data, usually by cloning. Read more
Source§

fn clone_into(&self, target: &mut T)

Uses borrowed data to replace owned data, usually by cloning. Read more
Source§

impl<T, U> TryFrom<U> for T
where + U: Into<T>,

Source§

type Error = Infallible

The type returned in the event of a conversion error.
Source§

fn try_from(value: U) -> Result<T, <T as TryFrom<U>>::Error>

Performs the conversion.
Source§

impl<T, U> TryInto<U> for T
where + U: TryFrom<T>,

Source§

type Error = <U as TryFrom<T>>::Error

The type returned in the event of a conversion error.
Source§

fn try_into(self) -> Result<U, <U as TryFrom<T>>::Error>

Performs the conversion.
§

impl<V, T> VZip<V> for T
where + V: MultiLane<T>,

§

fn vzip(self) -> V

§

impl<T> WithSubscriber for T

§

fn with_subscriber<S>(self, subscriber: S) -> WithDispatch<Self>
where + S: Into<Dispatch>,

Attaches the provided Subscriber to this type, returning a +[WithDispatch] wrapper. Read more
§

fn with_current_subscriber(self) -> WithDispatch<Self>

Attaches the current default Subscriber to this type, returning a +[WithDispatch] wrapper. Read more
Source§

impl<T> DeserializeOwned for T
where + T: for<'de> Deserialize<'de>,

§

impl<T> UserEvent for T
where + T: Debug + Clone + Send + 'static,

\ No newline at end of file diff --git a/api/jellytau_lib/player/backend/index.html b/api/jellytau_lib/player/backend/index.html new file mode 100644 index 00000000..431a89a6 --- /dev/null +++ b/api/jellytau_lib/player/backend/index.html @@ -0,0 +1 @@ +jellytau_lib::player::backend - RustSkip to main content

Module backend

Module backend 

Source

Structs§

NullBackend
Null player backend (for testing or when no real player is available)
PlayerError
Error type for player operations

Traits§

PlayerBackend
Player backend trait - implemented by platform-specific players
\ No newline at end of file diff --git a/api/jellytau_lib/player/backend/sidebar-items.js b/api/jellytau_lib/player/backend/sidebar-items.js new file mode 100644 index 00000000..2d27d008 --- /dev/null +++ b/api/jellytau_lib/player/backend/sidebar-items.js @@ -0,0 +1 @@ +window.SIDEBAR_ITEMS = {"struct":["NullBackend","PlayerError"],"trait":["PlayerBackend"]}; \ No newline at end of file diff --git a/api/jellytau_lib/player/backend/struct.NullBackend.html b/api/jellytau_lib/player/backend/struct.NullBackend.html new file mode 100644 index 00000000..ae191df0 --- /dev/null +++ b/api/jellytau_lib/player/backend/struct.NullBackend.html @@ -0,0 +1,45 @@ +NullBackend in jellytau_lib::player::backend - RustSkip to main content

NullBackend

Struct NullBackend 

Source
pub struct NullBackend {
+    state: PlayerState,
+    volume: f32,
+    position: f64,
+    duration: Option<f64>,
+    audio_settings: AudioSettings,
+}
Expand description

Null player backend (for testing or when no real player is available)

+

@req: DR-004 - PlayerBackend trait (mock implementation for testing)

+

Fields§

§state: PlayerState§volume: f32§position: f64§duration: Option<f64>§audio_settings: AudioSettings

Implementations§

Source§

impl NullBackend

Source

pub fn new() -> Self

Trait Implementations§

Source§

impl Default for NullBackend

Source§

fn default() -> Self

Returns the “default value” for a type. Read more
Source§

impl PlayerBackend for NullBackend

Source§

fn load(&mut self, media: &MediaItem) -> Result<(), PlayerError>

Load a media item for playback +TRACES: UR-005
Source§

fn play(&mut self) -> Result<(), PlayerError>

Start or resume playback +TRACES: UR-005
Source§

fn pause(&mut self) -> Result<(), PlayerError>

Pause playback +TRACES: UR-005
Source§

fn stop(&mut self) -> Result<(), PlayerError>

Stop playback and unload media +TRACES: UR-005
Source§

fn seek(&mut self, position: f64) -> Result<(), PlayerError>

Seek to a position in seconds +TRACES: UR-005
Source§

fn set_volume(&mut self, volume: f32) -> Result<(), PlayerError>

Set volume (0.0 - 1.0) +TRACES: UR-016
Source§

fn position(&self) -> f64

Get current playback position in seconds
Source§

fn duration(&self) -> Option<f64>

Get total duration in seconds
Source§

fn state(&self) -> PlayerState

Get current player state
Source§

fn volume(&self) -> f32

Get current volume
Source§

fn set_audio_settings( + &mut self, + settings: &AudioSettings, +) -> Result<(), PlayerError>

Apply audio settings (crossfade, gapless, normalization) Read more
Source§

fn audio_settings(&self) -> AudioSettings

Get current audio settings Read more
Source§

fn set_audio_track(&mut self, _stream_index: i32) -> Result<(), PlayerError>

Set the active audio track by stream index Read more
Source§

fn set_subtitle_track( + &mut self, + _stream_index: Option<i32>, +) -> Result<(), PlayerError>

Set the active subtitle track by stream index (None to disable subtitles) Read more

Auto Trait Implementations§

Blanket Implementations§

Source§

impl<T> Any for T
where + T: 'static + ?Sized,

Source§

fn type_id(&self) -> TypeId

Gets the TypeId of self. Read more
Source§

impl<T> Borrow<T> for T
where + T: ?Sized,

Source§

fn borrow(&self) -> &T

Immutably borrows from an owned value. Read more
Source§

impl<T> BorrowMut<T> for T
where + T: ?Sized,

Source§

fn borrow_mut(&mut self) -> &mut T

Mutably borrows from an owned value. Read more
Source§

impl<T> From<T> for T

Source§

fn from(t: T) -> T

Returns the argument unchanged.

+
§

impl<T> Instrument for T

§

fn instrument(self, span: Span) -> Instrumented<Self>

Instruments this type with the provided [Span], returning an +Instrumented wrapper. Read more
§

fn in_current_span(self) -> Instrumented<Self>

Instruments this type with the current Span, returning an +Instrumented wrapper. Read more
Source§

impl<T, U> Into<U> for T
where + U: From<T>,

Source§

fn into(self) -> U

Calls U::from(self).

+

That is, this conversion is whatever the implementation of +From<T> for U chooses to do.

+
§

impl<T> NoneValue for T
where + T: Default,

§

type NoneType = T

§

fn null_value() -> T

The none-equivalent value.
§

impl<T> PolicyExt for T
where + T: ?Sized,

§

fn and<P, B, E>(self, other: P) -> And<T, P>
where + T: Policy<B, E>, + P: Policy<B, E>,

Create a new Policy that returns [Action::Follow] only if self and other return +Action::Follow. Read more
§

fn or<P, B, E>(self, other: P) -> Or<T, P>
where + T: Policy<B, E>, + P: Policy<B, E>,

Create a new Policy that returns [Action::Follow] if either self or other returns +Action::Follow. Read more
Source§

impl<T> Same for T

Source§

type Output = T

Should always be Self
Source§

impl<T, U> TryFrom<U> for T
where + U: Into<T>,

Source§

type Error = Infallible

The type returned in the event of a conversion error.
Source§

fn try_from(value: U) -> Result<T, <T as TryFrom<U>>::Error>

Performs the conversion.
Source§

impl<T, U> TryInto<U> for T
where + U: TryFrom<T>,

Source§

type Error = <U as TryFrom<T>>::Error

The type returned in the event of a conversion error.
Source§

fn try_into(self) -> Result<U, <U as TryFrom<T>>::Error>

Performs the conversion.
§

impl<V, T> VZip<V> for T
where + V: MultiLane<T>,

§

fn vzip(self) -> V

§

impl<T> WithSubscriber for T

§

fn with_subscriber<S>(self, subscriber: S) -> WithDispatch<Self>
where + S: Into<Dispatch>,

Attaches the provided Subscriber to this type, returning a +[WithDispatch] wrapper. Read more
§

fn with_current_subscriber(self) -> WithDispatch<Self>

Attaches the current default Subscriber to this type, returning a +[WithDispatch] wrapper. Read more
\ No newline at end of file diff --git a/api/jellytau_lib/player/backend/struct.PlayerError.html b/api/jellytau_lib/player/backend/struct.PlayerError.html new file mode 100644 index 00000000..97aa954a --- /dev/null +++ b/api/jellytau_lib/player/backend/struct.PlayerError.html @@ -0,0 +1,35 @@ +PlayerError in jellytau_lib::player::backend - RustSkip to main content

PlayerError

Struct PlayerError 

Source
pub struct PlayerError {
+    pub message: String,
+}
Expand description

Error type for player operations

+

Fields§

§message: String

Implementations§

Trait Implementations§

Source§

impl Clone for PlayerError

Source§

fn clone(&self) -> PlayerError

Returns a duplicate of the value. Read more
1.0.0 (const: unstable) · Source§

fn clone_from(&mut self, source: &Self)

Performs copy-assignment from source. Read more
Source§

impl Debug for PlayerError

Source§

fn fmt(&self, f: &mut Formatter<'_>) -> Result

Formats the value using the given formatter. Read more
Source§

impl Display for PlayerError

Source§

fn fmt(&self, f: &mut Formatter<'_>) -> Result

Formats the value using the given formatter. Read more
Source§

impl Error for PlayerError

1.30.0 · Source§

fn source(&self) -> Option<&(dyn Error + 'static)>

Returns the lower-level source of this error, if any. Read more
1.0.0 · Source§

fn description(&self) -> &str

👎Deprecated since 1.42.0:

use the Display impl or to_string()

+
1.0.0 · Source§

fn cause(&self) -> Option<&dyn Error>

👎Deprecated since 1.33.0:

replaced by Error::source, which can support downcasting

+
Source§

fn provide<'a>(&'a self, request: &mut Request<'a>)

🔬This is a nightly-only experimental API. (error_generic_member_access)
Provides type-based access to context intended for error reports. Read more

Auto Trait Implementations§

Blanket Implementations§

Source§

impl<T> Any for T
where + T: 'static + ?Sized,

Source§

fn type_id(&self) -> TypeId

Gets the TypeId of self. Read more
Source§

impl<T> Borrow<T> for T
where + T: ?Sized,

Source§

fn borrow(&self) -> &T

Immutably borrows from an owned value. Read more
Source§

impl<T> BorrowMut<T> for T
where + T: ?Sized,

Source§

fn borrow_mut(&mut self) -> &mut T

Mutably borrows from an owned value. Read more
Source§

impl<T> CloneToUninit for T
where + T: Clone,

Source§

unsafe fn clone_to_uninit(&self, dest: *mut u8)

🔬This is a nightly-only experimental API. (clone_to_uninit)
Performs copy-assignment from self to dest. Read more
Source§

impl<T> From<T> for T

Source§

fn from(t: T) -> T

Returns the argument unchanged.

+
§

impl<T> Instrument for T

§

fn instrument(self, span: Span) -> Instrumented<Self>

Instruments this type with the provided [Span], returning an +Instrumented wrapper. Read more
§

fn in_current_span(self) -> Instrumented<Self>

Instruments this type with the current Span, returning an +Instrumented wrapper. Read more
Source§

impl<T, U> Into<U> for T
where + U: From<T>,

Source§

fn into(self) -> U

Calls U::from(self).

+

That is, this conversion is whatever the implementation of +From<T> for U chooses to do.

+
§

impl<T> PolicyExt for T
where + T: ?Sized,

§

fn and<P, B, E>(self, other: P) -> And<T, P>
where + T: Policy<B, E>, + P: Policy<B, E>,

Create a new Policy that returns [Action::Follow] only if self and other return +Action::Follow. Read more
§

fn or<P, B, E>(self, other: P) -> Or<T, P>
where + T: Policy<B, E>, + P: Policy<B, E>,

Create a new Policy that returns [Action::Follow] if either self or other returns +Action::Follow. Read more
Source§

impl<T> Same for T

Source§

type Output = T

Should always be Self
Source§

impl<T> ToOwned for T
where + T: Clone,

Source§

type Owned = T

The resulting type after obtaining ownership.
Source§

fn to_owned(&self) -> T

Creates owned data from borrowed data, usually by cloning. Read more
Source§

fn clone_into(&self, target: &mut T)

Uses borrowed data to replace owned data, usually by cloning. Read more
Source§

impl<T> ToString for T
where + T: Display + ?Sized,

Source§

fn to_string(&self) -> String

Converts the given value to a String. Read more
§

impl<T> ToStringFallible for T
where + T: Display,

§

fn try_to_string(&self) -> Result<String, TryReserveError>

ToString::to_string, but without panic on OOM.

+
Source§

impl<T, U> TryFrom<U> for T
where + U: Into<T>,

Source§

type Error = Infallible

The type returned in the event of a conversion error.
Source§

fn try_from(value: U) -> Result<T, <T as TryFrom<U>>::Error>

Performs the conversion.
Source§

impl<T, U> TryInto<U> for T
where + U: TryFrom<T>,

Source§

type Error = <U as TryFrom<T>>::Error

The type returned in the event of a conversion error.
Source§

fn try_into(self) -> Result<U, <U as TryFrom<T>>::Error>

Performs the conversion.
§

impl<V, T> VZip<V> for T
where + V: MultiLane<T>,

§

fn vzip(self) -> V

§

impl<T> WithSubscriber for T

§

fn with_subscriber<S>(self, subscriber: S) -> WithDispatch<Self>
where + S: Into<Dispatch>,

Attaches the provided Subscriber to this type, returning a +[WithDispatch] wrapper. Read more
§

fn with_current_subscriber(self) -> WithDispatch<Self>

Attaches the current default Subscriber to this type, returning a +[WithDispatch] wrapper. Read more
§

impl<T> UserEvent for T
where + T: Debug + Clone + Send + 'static,

\ No newline at end of file diff --git a/api/jellytau_lib/player/backend/trait.PlayerBackend.html b/api/jellytau_lib/player/backend/trait.PlayerBackend.html new file mode 100644 index 00000000..71503cc6 --- /dev/null +++ b/api/jellytau_lib/player/backend/trait.PlayerBackend.html @@ -0,0 +1,72 @@ +PlayerBackend in jellytau_lib::player::backend - RustSkip to main content

PlayerBackend

Trait PlayerBackend 

Source
pub trait PlayerBackend: Send + Sync {
+
Show 14 methods // Required methods + fn load(&mut self, media: &MediaItem) -> Result<(), PlayerError>; + fn play(&mut self) -> Result<(), PlayerError>; + fn pause(&mut self) -> Result<(), PlayerError>; + fn stop(&mut self) -> Result<(), PlayerError>; + fn seek(&mut self, position: f64) -> Result<(), PlayerError>; + fn set_volume(&mut self, volume: f32) -> Result<(), PlayerError>; + fn position(&self) -> f64; + fn duration(&self) -> Option<f64>; + fn state(&self) -> PlayerState; + fn volume(&self) -> f32; + + // Provided methods + fn set_audio_settings( + &mut self, + _settings: &AudioSettings, + ) -> Result<(), PlayerError> { ... } + fn audio_settings(&self) -> AudioSettings { ... } + fn set_audio_track(&mut self, _stream_index: i32) -> Result<(), PlayerError> { ... } + fn set_subtitle_track( + &mut self, + _stream_index: Option<i32>, + ) -> Result<(), PlayerError> { ... } +
}
Expand description

Player backend trait - implemented by platform-specific players

+

TRACES: UR-003, UR-004 | IR-003, IR-004 | DR-004

+

Required Methods§

Source

fn load(&mut self, media: &MediaItem) -> Result<(), PlayerError>

Load a media item for playback +TRACES: UR-005

+
Source

fn play(&mut self) -> Result<(), PlayerError>

Start or resume playback +TRACES: UR-005

+
Source

fn pause(&mut self) -> Result<(), PlayerError>

Pause playback +TRACES: UR-005

+
Source

fn stop(&mut self) -> Result<(), PlayerError>

Stop playback and unload media +TRACES: UR-005

+
Source

fn seek(&mut self, position: f64) -> Result<(), PlayerError>

Seek to a position in seconds +TRACES: UR-005

+
Source

fn set_volume(&mut self, volume: f32) -> Result<(), PlayerError>

Set volume (0.0 - 1.0) +TRACES: UR-016

+
Source

fn position(&self) -> f64

Get current playback position in seconds

+
Source

fn duration(&self) -> Option<f64>

Get total duration in seconds

+
Source

fn state(&self) -> PlayerState

Get current player state

+
Source

fn volume(&self) -> f32

Get current volume

+

Provided Methods§

Source

fn set_audio_settings( + &mut self, + _settings: &AudioSettings, +) -> Result<(), PlayerError>

Apply audio settings (crossfade, gapless, normalization)

+

@req-partial: UR-031 (Linux only) - Crossfade between audio tracks +@req-partial: UR-032 (Linux only) - Gapless playback for seamless album listening +@req-partial: UR-033 (Linux only) - Volume normalization to prevent volume jumps +@req: DR-034 - Crossfade engine with configurable duration (0-12s) +@req: DR-035 - Gapless playback between sequential tracks +@req: DR-036 - Volume normalization with preset levels (Loud/Normal/Quiet)

+
Source

fn audio_settings(&self) -> AudioSettings

Get current audio settings

+

@req: DR-034 - Crossfade engine +@req: DR-035 - Gapless playback +@req: DR-036 - Volume normalization

+
Source

fn set_audio_track(&mut self, _stream_index: i32) -> Result<(), PlayerError>

Set the active audio track by stream index

+

Overridden by the Android (ExoPlayer) backend. MpvBackend deliberately +does not override it — MPV is the audio-only backend here, so it keeps +this not_implemented() default and the Linux video path switches track by +re-opening the stream instead (player_switch_audio_track).

+

TRACES: UR-021 | IR-019, DR-024

+
Source

fn set_subtitle_track( + &mut self, + _stream_index: Option<i32>, +) -> Result<(), PlayerError>

Set the active subtitle track by stream index (None to disable subtitles)

+

Overridden by the Android (ExoPlayer) backend. MpvBackend deliberately +does not override it, so it keeps this not_implemented() default; +the Linux video path renders subtitles as <track> children of the +WebKitGTK HTML5 <video> element and never calls this.

+

TRACES: UR-020 | IR-018, DR-023

+

Implementors§

\ No newline at end of file diff --git a/api/jellytau_lib/player/background_policy/enum.BackgroundAction.html b/api/jellytau_lib/player/background_policy/enum.BackgroundAction.html new file mode 100644 index 00000000..e58148d7 --- /dev/null +++ b/api/jellytau_lib/player/background_policy/enum.BackgroundAction.html @@ -0,0 +1,65 @@ +BackgroundAction in jellytau_lib::player::background_policy - RustSkip to main content

BackgroundAction

Enum BackgroundAction 

Source
pub enum BackgroundAction {
+    KeepPlaying,
+    HandOffToAudio,
+    Pause,
+}
Expand description

What the player should do when the app is backgrounded.

+

Variants§

§

KeepPlaying

Carry on. Music, and video the user explicitly asked to keep hearing +while it is in a picture-in-picture window.

+
§

HandOffToAudio

Swap the video stream for an audio-only one and keep playing.

+
§

Pause

Stop making sound. The user did not ask for background playback.

+

Trait Implementations§

Source§

impl Clone for BackgroundAction

Source§

fn clone(&self) -> BackgroundAction

Returns a duplicate of the value. Read more
1.0.0 (const: unstable) · Source§

fn clone_from(&mut self, source: &Self)

Performs copy-assignment from source. Read more
Source§

impl Debug for BackgroundAction

Source§

fn fmt(&self, f: &mut Formatter<'_>) -> Result

Formats the value using the given formatter. Read more
Source§

impl<'de> Deserialize<'de> for BackgroundAction

Source§

fn deserialize<__D>(__deserializer: __D) -> Result<Self, __D::Error>
where + __D: Deserializer<'de>,

Deserialize this value from the given Serde deserializer. Read more
Source§

impl NamedType for BackgroundAction

Source§

fn sid() -> SpectaID

Source§

fn named_data_type( + type_map: &mut TypeCollection, + generics: &[DataType], +) -> NamedDataType

this is equivalent to [Type::inline] but returns a [NamedDataType] instead.
Source§

fn definition_named_data_type(type_map: &mut TypeCollection) -> NamedDataType

this is equivalent to [Type::definition] but returns a [NamedDataType] instead.
Source§

impl PartialEq for BackgroundAction

Source§

fn eq(&self, other: &BackgroundAction) -> bool

Tests for self and other values to be equal, and is used by ==.
1.0.0 (const: unstable) · Source§

fn ne(&self, other: &Rhs) -> bool

Tests for !=. The default implementation is almost always sufficient, +and should not be overridden without very good reason.
Source§

impl Serialize for BackgroundAction

Source§

fn serialize<__S>(&self, __serializer: __S) -> Result<__S::Ok, __S::Error>
where + __S: Serializer,

Serialize this value into the given Serde serializer. Read more
Source§

impl Type for BackgroundAction

Source§

fn inline(type_map: &mut TypeCollection, generics: Generics<'_>) -> DataType

Returns the definition of a type using the provided generics. Read more
Source§

fn reference(type_map: &mut TypeCollection, generics: &[DataType]) -> Reference

Generates a datatype corresponding to a reference to this type, +as determined by its category. Getting a reference to a type implies that +it should belong in the type map (since it has to be referenced from somewhere), +so the output of definition will be put into the type map.
Source§

impl Copy for BackgroundAction

Source§

impl Eq for BackgroundAction

Source§

impl StructuralPartialEq for BackgroundAction

Auto Trait Implementations§

Blanket Implementations§

Source§

impl<T> Any for T
where + T: 'static + ?Sized,

Source§

fn type_id(&self) -> TypeId

Gets the TypeId of self. Read more
Source§

impl<T> Borrow<T> for T
where + T: ?Sized,

Source§

fn borrow(&self) -> &T

Immutably borrows from an owned value. Read more
Source§

impl<T> BorrowMut<T> for T
where + T: ?Sized,

Source§

fn borrow_mut(&mut self) -> &mut T

Mutably borrows from an owned value. Read more
Source§

impl<T> CloneToUninit for T
where + T: Clone,

Source§

unsafe fn clone_to_uninit(&self, dest: *mut u8)

🔬This is a nightly-only experimental API. (clone_to_uninit)
Performs copy-assignment from self to dest. Read more
§

impl<'de, D, R> CommandArg<'de, R> for D
where + D: Deserialize<'de>, + R: Runtime,

§

fn from_command(command: CommandItem<'de, R>) -> Result<D, InvokeError>

Derives an instance of Self from the [CommandItem]. Read more
§

impl<Q, K> Equivalent<K> for Q
where + Q: Eq + ?Sized, + K: Borrow<Q> + ?Sized,

§

fn equivalent(&self, key: &K) -> bool

Checks if this value is equivalent to the given key. Read more
§

impl<Q, K> Equivalent<K> for Q
where + Q: Eq + ?Sized, + K: Borrow<Q> + ?Sized,

§

fn equivalent(&self, key: &K) -> bool

Compare self to key and return true if they are equal.
§

impl<Q, K> Equivalent<K> for Q
where + Q: Eq + ?Sized, + K: Borrow<Q> + ?Sized,

§

fn equivalent(&self, key: &K) -> bool

Checks if this value is equivalent to the given key. Read more
Source§

impl<T> From<T> for T

Source§

fn from(t: T) -> T

Returns the argument unchanged.

+
§

impl<T> FunctionArg for T
where + T: Type,

§

fn to_datatype(type_map: &mut TypeCollection) -> Option<DataType>

Gets the type of an argument as a [DataType]. Read more
§

impl<T> Instrument for T

§

fn instrument(self, span: Span) -> Instrumented<Self>

Instruments this type with the provided [Span], returning an +Instrumented wrapper. Read more
§

fn in_current_span(self) -> Instrumented<Self>

Instruments this type with the current Span, returning an +Instrumented wrapper. Read more
Source§

impl<T, U> Into<U> for T
where + U: From<T>,

Source§

fn into(self) -> U

Calls U::from(self).

+

That is, this conversion is whatever the implementation of +From<T> for U chooses to do.

+
§

impl<T> IpcResponse for T
where + T: Serialize,

§

fn body(self) -> Result<InvokeResponseBody, Error>

Resolve the IPC response body.
§

impl<T> PolicyExt for T
where + T: ?Sized,

§

fn and<P, B, E>(self, other: P) -> And<T, P>
where + T: Policy<B, E>, + P: Policy<B, E>,

Create a new Policy that returns [Action::Follow] only if self and other return +Action::Follow. Read more
§

fn or<P, B, E>(self, other: P) -> Or<T, P>
where + T: Policy<B, E>, + P: Policy<B, E>,

Create a new Policy that returns [Action::Follow] if either self or other returns +Action::Follow. Read more
Source§

impl<T> Same for T

Source§

type Output = T

Should always be Self
§

impl<T> ScopeObject for T
where + T: Send + Sync + Debug + DeserializeOwned + 'static,

§

type Error = Error

The error type.
§

fn deserialize<R>( + _app: &AppHandle<R>, + raw: Value, +) -> Result<T, <T as ScopeObject>::Error>
where + R: Runtime,

Deserialize the raw scope value.
Source§

impl<T> Serialize for T
where + T: Serialize + ?Sized,

Source§

fn erased_serialize(&self, serializer: &mut dyn Serializer) -> Result<(), Error>

Source§

fn do_erased_serialize( + &self, + serializer: &mut dyn Serializer, +) -> Result<(), ErrorImpl>

Source§

impl<T> ToOwned for T
where + T: Clone,

Source§

type Owned = T

The resulting type after obtaining ownership.
Source§

fn to_owned(&self) -> T

Creates owned data from borrowed data, usually by cloning. Read more
Source§

fn clone_into(&self, target: &mut T)

Uses borrowed data to replace owned data, usually by cloning. Read more
Source§

impl<T, U> TryFrom<U> for T
where + U: Into<T>,

Source§

type Error = Infallible

The type returned in the event of a conversion error.
Source§

fn try_from(value: U) -> Result<T, <T as TryFrom<U>>::Error>

Performs the conversion.
Source§

impl<T, U> TryInto<U> for T
where + U: TryFrom<T>,

Source§

type Error = <U as TryFrom<T>>::Error

The type returned in the event of a conversion error.
Source§

fn try_into(self) -> Result<U, <U as TryFrom<T>>::Error>

Performs the conversion.
§

impl<V, T> VZip<V> for T
where + V: MultiLane<T>,

§

fn vzip(self) -> V

§

impl<T> WithSubscriber for T

§

fn with_subscriber<S>(self, subscriber: S) -> WithDispatch<Self>
where + S: Into<Dispatch>,

Attaches the provided Subscriber to this type, returning a +[WithDispatch] wrapper. Read more
§

fn with_current_subscriber(self) -> WithDispatch<Self>

Attaches the current default Subscriber to this type, returning a +[WithDispatch] wrapper. Read more
Source§

impl<T> DeserializeOwned for T
where + T: for<'de> Deserialize<'de>,

§

impl<T> UserEvent for T
where + T: Debug + Clone + Send + 'static,

\ No newline at end of file diff --git a/api/jellytau_lib/player/background_policy/fn.background_action.html b/api/jellytau_lib/player/background_policy/fn.background_action.html new file mode 100644 index 00000000..787dfb56 --- /dev/null +++ b/api/jellytau_lib/player/background_policy/fn.background_action.html @@ -0,0 +1,14 @@ +background_action in jellytau_lib::player::background_policy - RustSkip to main content

background_action

Function background_action 

Source
pub fn background_action(
+    is_video: bool,
+    background_audio_armed: bool,
+    in_picture_in_picture: bool,
+) -> BackgroundAction
Expand description

Decide what backgrounding should do.

+
    +
  • is_video — whether the current item has a picture to lose. Music is +never paused by backgrounding; that is what a music player is for.
  • +
  • background_audio_armed — the per-player toggle (UR-040).
  • +
  • in_picture_in_picture — the app is not “gone”, it is in a floating +window and still visible. Pausing here would break PiP (UR-041).
  • +
+

TRACES: UR-040, UR-041 | DR-224 | UT-211

+
\ No newline at end of file diff --git a/api/jellytau_lib/player/background_policy/fn.is_video_media.html b/api/jellytau_lib/player/background_policy/fn.is_video_media.html new file mode 100644 index 00000000..e5fa96f4 --- /dev/null +++ b/api/jellytau_lib/player/background_policy/fn.is_video_media.html @@ -0,0 +1,3 @@ +is_video_media in jellytau_lib::player::background_policy - RustSkip to main content

is_video_media

Function is_video_media 

Source
pub fn is_video_media(media_type: MediaType) -> bool
Expand description

Whether a media type has a picture that backgrounding would throw away.

+

TRACES: UR-040 | DR-224

+
\ No newline at end of file diff --git a/api/jellytau_lib/player/background_policy/index.html b/api/jellytau_lib/player/background_policy/index.html new file mode 100644 index 00000000..9e1e5ecc --- /dev/null +++ b/api/jellytau_lib/player/background_policy/index.html @@ -0,0 +1,22 @@ +jellytau_lib::player::background_policy - RustSkip to main content

Module background_policy

Module background_policy 

Source
Expand description

What playback should do when the app stops being visible.

+

TRACES: UR-040 | DR-224 | UT-211

+

§The defect this exists for

+

The per-player background-audio toggle (UR-040) was built for the WebView +<video> path, where backgrounding the app kills the decode: the toggle +decided whether to hand off to a native audio stream or let playback die.

+

Native video then became the default renderer (DR-188). On that path playback +runs through ExoPlayer inside a MediaSessionService — a foreground media +service whose entire purpose is to keep playing when the app is not visible. +Nothing stops it, and nothing in the codebase paused playback on background.

+

So locking the screen kept the audio playing whether or not the toggle was +on. The toggle governed a handoff that no longer had anything to hand off +from: there was no gap in playback to bridge. A user who had never touched +it got background audio anyway, which is the bug as reported.

+

§Why the decision lives in Rust

+

It depends on what the item is (a video keeps its picture; music has none +to lose) and on a user setting — domain questions, not presentation ones, and +the answer must be identical for both renderers. The frontend and the Android +activity carry it out; neither decides it. Putting the rule in either would +have reproduced exactly the split that caused this: two renderers, two +behaviours, one toggle that only reached one of them.

+

Enums§

BackgroundAction
What the player should do when the app is backgrounded.

Functions§

background_action
Decide what backgrounding should do.
is_video_media
Whether a media type has a picture that backgrounding would throw away.
\ No newline at end of file diff --git a/api/jellytau_lib/player/background_policy/sidebar-items.js b/api/jellytau_lib/player/background_policy/sidebar-items.js new file mode 100644 index 00000000..5aaa3492 --- /dev/null +++ b/api/jellytau_lib/player/background_policy/sidebar-items.js @@ -0,0 +1 @@ +window.SIDEBAR_ITEMS = {"enum":["BackgroundAction"],"fn":["background_action","is_video_media"]}; \ No newline at end of file diff --git a/api/jellytau_lib/player/constant.RESUME_BACKOFF_STEP_SECS.html b/api/jellytau_lib/player/constant.RESUME_BACKOFF_STEP_SECS.html new file mode 100644 index 00000000..871c735b --- /dev/null +++ b/api/jellytau_lib/player/constant.RESUME_BACKOFF_STEP_SECS.html @@ -0,0 +1,7 @@ +RESUME_BACKOFF_STEP_SECS in jellytau_lib::player - RustSkip to main content

RESUME_BACKOFF_STEP_SECS

Constant RESUME_BACKOFF_STEP_SECS 

Source
const RESUME_BACKOFF_STEP_SECS: u64 = 2;
Expand description

Seconds added per attempt before retrying a stream that failed with an error.

+

Attempt 1 waits this long, attempt 2 twice as long, and so on — a spread that +covers roughly a quarter-minute of outage across the retry budget without +leaving the user staring at a dead notification when the network is truly gone. +Only read by the Android error callback (#[cfg(android)]), but compiled +and unit-tested on the host, hence allow(dead_code) off-Android.

+
\ No newline at end of file diff --git a/api/jellytau_lib/player/events/enum.PlayerStatusEvent.html b/api/jellytau_lib/player/events/enum.PlayerStatusEvent.html new file mode 100644 index 00000000..e0b39cb5 --- /dev/null +++ b/api/jellytau_lib/player/events/enum.PlayerStatusEvent.html @@ -0,0 +1,222 @@ +PlayerStatusEvent in jellytau_lib::player::events - RustSkip to main content

PlayerStatusEvent

Enum PlayerStatusEvent 

Source
pub enum PlayerStatusEvent {
+
Show 18 variants PositionUpdate { + position: f64, + duration: f64, + }, + StateChanged { + state: String, + media_id: Option<String>, + }, + MediaLoaded { + duration: f64, + }, + PlaybackEnded, + Buffering { + percent: u8, + }, + Error { + message: String, + recoverable: bool, + }, + VolumeChanged { + volume: f32, + muted: bool, + }, + SleepTimerChanged { + mode: SleepTimerMode, + remaining_seconds: u32, + }, + SleepTimerExpired, + ShowNextEpisodePopup { + current_episode: MediaItem, + next_episode: MediaItem, + countdown_seconds: u32, + auto_advance: bool, + }, + CountdownTick { + remaining_seconds: u32, + }, + QueueChanged { + items: Vec<MediaItem>, + current_index: Option<usize>, + shuffle: bool, + repeat: RepeatMode, + has_next: bool, + has_previous: bool, + }, + SessionChanged { + session: MediaSessionType, + }, + SessionsUpdated { + sessions: Vec<SessionInfo>, + }, + PlaybackModeChanged { + mode: String, + session_id: Option<String>, + }, + RemoteDisconnectRequested, + ControlCommand { + action: String, + position: Option<f64>, + }, + WebviewAudioLoad { + url: String, + media_id: Option<String>, + position: f64, + autoplay: bool, + }, +
}
Expand description

Events emitted by the player backend to the frontend via Tauri events.

+

These are distinct from PlayerEvent in state.rs, which handles internal +state machine transitions.

+

TRACES: UR-005, UR-019, UR-023, UR-026 | DR-001, DR-028, DR-047

+

Variants§

§

PositionUpdate

Playback position updated (emitted periodically during playback)

+

Fields

§position: f64

Current position in seconds

+
§duration: f64

Total duration in seconds

+
§

StateChanged

Player state changed

+

Fields

§state: String

New state: “playing”, “paused”, “stopped”, “loading”, “idle”

+
§media_id: Option<String>

ID of the current media item, if any

+
§

MediaLoaded

Media has finished loading and is ready to play

+

Fields

§duration: f64

Total duration in seconds

+
§

PlaybackEnded

Playback has ended naturally (reached end of media)

+
§

Buffering

Buffering state changed

+

Fields

§percent: u8

Buffering progress (0-100)

+
§

Error

An error occurred during playback

+

Fields

§message: String

Error message

+
§recoverable: bool

Whether the error is recoverable

+
§

VolumeChanged

Volume changed

+

Fields

§volume: f32

New volume level (0.0-1.0)

+
§muted: bool

Whether audio is muted

+
§

SleepTimerChanged

Sleep timer state changed

+

Fields

§mode: SleepTimerMode

Sleep timer mode

+
§remaining_seconds: u32

Remaining seconds (for time-based timer)

+
§

SleepTimerExpired

Time-based sleep timer expired: playback must stop. The backend stops +its own (MPV/ExoPlayer) playback, but HTML5 video on Linux plays in the +webview outside the backend’s control — the frontend pauses it on this +event.

+
§

ShowNextEpisodePopup

Show next episode popup with countdown

+

Fields

§current_episode: MediaItem

Current episode that just finished

+
§next_episode: MediaItem

Next episode to play

+
§countdown_seconds: u32

Countdown duration in seconds

+
§auto_advance: bool

Whether to auto-advance when countdown reaches 0

+
§

CountdownTick

Countdown tick (emitted every second during autoplay countdown)

+

Fields

§remaining_seconds: u32

Remaining seconds in countdown

+
§

QueueChanged

Queue changed (items added, removed, reordered, or playback mode changed)

+

Fields

§items: Vec<MediaItem>

All items in the queue

+
§current_index: Option<usize>

Current item index

+
§shuffle: bool

Whether shuffle is enabled

+
§repeat: RepeatMode

Current repeat mode

+
§has_next: bool

Whether there’s a next track available

+
§has_previous: bool

Whether there’s a previous track available

+
§

SessionChanged

Media session changed (activity context changed: Audio/Movie/TvShow/Idle)

+

Fields

§session: MediaSessionType

Current session state

+
§

SessionsUpdated

Remote sessions updated (for cast/remote control UI)

+

Fields

§sessions: Vec<SessionInfo>

All active controllable sessions from Jellyfin

+
§

PlaybackModeChanged

The authoritative playback mode changed in the Rust backend.

+

The Rust PlaybackModeManager is the single source of truth for which +device playback commands route to (local vs a remote session). The +frontend keeps a mirror store for the UI; without this event that mirror +drifts out of sync (e.g. a mode transition happens inside a transfer or a +local stop that the frontend never learns about), and controls then route +to the wrong device — the classic “it keeps playing on the remote” bug. +The frontend reconciles its store to this payload whenever it fires.

+

Fields

§mode: String

New mode: “local”, “remote”, or “idle”.

+
§session_id: Option<String>

Session id when mode == "remote", otherwise None.

+
§

RemoteDisconnectRequested

The user asked to disconnect from the remote session and resume locally.

+

Emitted when the lockscreen Stop button is pressed while casting. The +frontend owns the two-step remote->local transfer (it must reload the +media item locally), so the native side only signals intent here.

+
§

ControlCommand

Backend-originated control command targeting the active frontend player +adapter (the HTML5

+

Fields

§action: String

One of: “play”, “pause”, “stop”, “seek”.

+
§position: Option<f64>

Target position in seconds (only meaningful for “seek”).

+
§

WebviewAudioLoad

Ask the frontend webview <audio> element to load and play a stream.

+

Emitted by WebviewAudioBackend on platforms with no native audio +backend (e.g. Windows): audio-only playback is rendered by an <audio> +element in the webview, mirroring how all video already renders through +the webview <video>. The element then reports its state/position back +through the player_report_* commands, so the Rust controller stays the +single source of truth. Subsequent play/pause/seek/stop reach the element +via ControlCommand.

+

Fields

§url: String

Stream URL for the <audio> element to play.

+
§media_id: Option<String>

Jellyfin item id, used as the media_id when reporting state back.

+
§position: f64

Resume position in seconds (0 = start from the beginning).

+
§autoplay: bool

Whether to begin playing immediately after loading.

+

Trait Implementations§

Source§

impl Clone for PlayerStatusEvent

Source§

fn clone(&self) -> PlayerStatusEvent

Returns a duplicate of the value. Read more
1.0.0 (const: unstable) · Source§

fn clone_from(&mut self, source: &Self)

Performs copy-assignment from source. Read more
Source§

impl Debug for PlayerStatusEvent

Source§

fn fmt(&self, f: &mut Formatter<'_>) -> Result

Formats the value using the given formatter. Read more
Source§

impl<'de> Deserialize<'de> for PlayerStatusEvent

Source§

fn deserialize<__D>(__deserializer: __D) -> Result<Self, __D::Error>
where + __D: Deserializer<'de>,

Deserialize this value from the given Serde deserializer. Read more
Source§

impl Event for PlayerStatusEvent

Source§

const NAME: &'static str = "player-status-event"

The unique name for this event. Derived from the struct’s name via the Event derive macro.
§

fn listen<F, R, H>(handle: &H, handler: F) -> u32
where + R: Runtime, + H: Listener<R> + Manager<R>, + F: Fn(TypedEvent<Self>) + Send + 'static, + Self: DeserializeOwned,

Listen to an emitted event on this manager.
§

fn listen_any<F, R, H>(handle: &H, handler: F) -> u32
where + R: Runtime, + H: Listener<R> + Manager<R>, + F: Fn(TypedEvent<Self>) + Send + 'static, + Self: DeserializeOwned,

Listen to an emitted event to any target.
§

fn once<F, R, H>(handle: &H, handler: F) -> u32
where + R: Runtime, + H: Listener<R> + Manager<R>, + F: FnOnce(TypedEvent<Self>) + Send + 'static, + Self: DeserializeOwned,

Listen to an event on this manager only once.
§

fn once_any<F, R, H>(handle: &H, handler: F) -> u32
where + R: Runtime, + H: Listener<R> + Manager<R>, + F: FnOnce(TypedEvent<Self>) + Send + 'static, + Self: DeserializeOwned,

Listens once to an emitted event to any target . Read more
§

fn emit<R, H>(&self, handle: &H) -> Result<(), Error>
where + R: Runtime, + H: Emitter<R> + Manager<R>, + Self: Serialize + Clone,

Emits an event to all targets.
§

fn emit_to<R, H, I>(&self, handle: &H, target: I) -> Result<(), Error>
where + R: Runtime, + H: Emitter<R> + Manager<R>, + I: Into<EventTarget>, + Self: Serialize + Clone,

Emits an event to all targets matching the given target.
§

fn emit_filter<F, R, H>(&self, handle: &H, filter: F) -> Result<(), Error>
where + R: Runtime, + H: Emitter<R> + Manager<R>, + F: Fn(&EventTarget) -> bool, + Self: Serialize + Clone,

Emits an event to all targets based on the given filter.
Source§

impl NamedType for PlayerStatusEvent

Source§

fn sid() -> SpectaID

Source§

fn named_data_type( + type_map: &mut TypeCollection, + generics: &[DataType], +) -> NamedDataType

this is equivalent to [Type::inline] but returns a [NamedDataType] instead.
Source§

fn definition_named_data_type(type_map: &mut TypeCollection) -> NamedDataType

this is equivalent to [Type::definition] but returns a [NamedDataType] instead.
Source§

impl Serialize for PlayerStatusEvent

Source§

fn serialize<__S>(&self, __serializer: __S) -> Result<__S::Ok, __S::Error>
where + __S: Serializer,

Serialize this value into the given Serde serializer. Read more
Source§

impl Type for PlayerStatusEvent

Source§

fn inline(type_map: &mut TypeCollection, generics: Generics<'_>) -> DataType

Returns the definition of a type using the provided generics. Read more
Source§

fn reference(type_map: &mut TypeCollection, generics: &[DataType]) -> Reference

Generates a datatype corresponding to a reference to this type, +as determined by its category. Getting a reference to a type implies that +it should belong in the type map (since it has to be referenced from somewhere), +so the output of definition will be put into the type map.
Source§

impl Flatten for PlayerStatusEvent

Auto Trait Implementations§

Blanket Implementations§

Source§

impl<T> Any for T
where + T: 'static + ?Sized,

Source§

fn type_id(&self) -> TypeId

Gets the TypeId of self. Read more
Source§

impl<T> Borrow<T> for T
where + T: ?Sized,

Source§

fn borrow(&self) -> &T

Immutably borrows from an owned value. Read more
Source§

impl<T> BorrowMut<T> for T
where + T: ?Sized,

Source§

fn borrow_mut(&mut self) -> &mut T

Mutably borrows from an owned value. Read more
Source§

impl<T> CloneToUninit for T
where + T: Clone,

Source§

unsafe fn clone_to_uninit(&self, dest: *mut u8)

🔬This is a nightly-only experimental API. (clone_to_uninit)
Performs copy-assignment from self to dest. Read more
§

impl<'de, D, R> CommandArg<'de, R> for D
where + D: Deserialize<'de>, + R: Runtime,

§

fn from_command(command: CommandItem<'de, R>) -> Result<D, InvokeError>

Derives an instance of Self from the [CommandItem]. Read more
Source§

impl<T> From<T> for T

Source§

fn from(t: T) -> T

Returns the argument unchanged.

+
§

impl<T> FunctionArg for T
where + T: Type,

§

fn to_datatype(type_map: &mut TypeCollection) -> Option<DataType>

Gets the type of an argument as a [DataType]. Read more
§

impl<T> Instrument for T

§

fn instrument(self, span: Span) -> Instrumented<Self>

Instruments this type with the provided [Span], returning an +Instrumented wrapper. Read more
§

fn in_current_span(self) -> Instrumented<Self>

Instruments this type with the current Span, returning an +Instrumented wrapper. Read more
Source§

impl<T, U> Into<U> for T
where + U: From<T>,

Source§

fn into(self) -> U

Calls U::from(self).

+

That is, this conversion is whatever the implementation of +From<T> for U chooses to do.

+
§

impl<T> IpcResponse for T
where + T: Serialize,

§

fn body(self) -> Result<InvokeResponseBody, Error>

Resolve the IPC response body.
§

impl<T> PolicyExt for T
where + T: ?Sized,

§

fn and<P, B, E>(self, other: P) -> And<T, P>
where + T: Policy<B, E>, + P: Policy<B, E>,

Create a new Policy that returns [Action::Follow] only if self and other return +Action::Follow. Read more
§

fn or<P, B, E>(self, other: P) -> Or<T, P>
where + T: Policy<B, E>, + P: Policy<B, E>,

Create a new Policy that returns [Action::Follow] if either self or other returns +Action::Follow. Read more
Source§

impl<T> Same for T

Source§

type Output = T

Should always be Self
§

impl<T> ScopeObject for T
where + T: Send + Sync + Debug + DeserializeOwned + 'static,

§

type Error = Error

The error type.
§

fn deserialize<R>( + _app: &AppHandle<R>, + raw: Value, +) -> Result<T, <T as ScopeObject>::Error>
where + R: Runtime,

Deserialize the raw scope value.
Source§

impl<T> Serialize for T
where + T: Serialize + ?Sized,

Source§

fn erased_serialize(&self, serializer: &mut dyn Serializer) -> Result<(), Error>

Source§

fn do_erased_serialize( + &self, + serializer: &mut dyn Serializer, +) -> Result<(), ErrorImpl>

Source§

impl<T> ToOwned for T
where + T: Clone,

Source§

type Owned = T

The resulting type after obtaining ownership.
Source§

fn to_owned(&self) -> T

Creates owned data from borrowed data, usually by cloning. Read more
Source§

fn clone_into(&self, target: &mut T)

Uses borrowed data to replace owned data, usually by cloning. Read more
Source§

impl<T, U> TryFrom<U> for T
where + U: Into<T>,

Source§

type Error = Infallible

The type returned in the event of a conversion error.
Source§

fn try_from(value: U) -> Result<T, <T as TryFrom<U>>::Error>

Performs the conversion.
Source§

impl<T, U> TryInto<U> for T
where + U: TryFrom<T>,

Source§

type Error = <U as TryFrom<T>>::Error

The type returned in the event of a conversion error.
Source§

fn try_into(self) -> Result<U, <U as TryFrom<T>>::Error>

Performs the conversion.
§

impl<V, T> VZip<V> for T
where + V: MultiLane<T>,

§

fn vzip(self) -> V

§

impl<T> WithSubscriber for T

§

fn with_subscriber<S>(self, subscriber: S) -> WithDispatch<Self>
where + S: Into<Dispatch>,

Attaches the provided Subscriber to this type, returning a +[WithDispatch] wrapper. Read more
§

fn with_current_subscriber(self) -> WithDispatch<Self>

Attaches the current default Subscriber to this type, returning a +[WithDispatch] wrapper. Read more
Source§

impl<T> DeserializeOwned for T
where + T: for<'de> Deserialize<'de>,

§

impl<T> UserEvent for T
where + T: Debug + Clone + Send + 'static,

\ No newline at end of file diff --git a/api/jellytau_lib/player/events/index.html b/api/jellytau_lib/player/events/index.html new file mode 100644 index 00000000..a84d1f19 --- /dev/null +++ b/api/jellytau_lib/player/events/index.html @@ -0,0 +1,5 @@ +jellytau_lib::player::events - RustSkip to main content

Module events

Module events 

Source
Expand description

Player events for frontend communication via Tauri events.

+

These events are emitted from the player backend to notify the frontend +of playback state changes, position updates, etc.

+

TRACES: UR-005, UR-019, UR-023, UR-026 | DR-001, DR-028, DR-047

+

Structs§

TauriEventEmitter
Tauri-based implementation of PlayerEventEmitter.

Enums§

PlayerStatusEvent
Events emitted by the player backend to the frontend via Tauri events.

Traits§

PlayerEventEmitter
Trait for emitting player events to the frontend.

Type Aliases§

SharedEventEmitter
Thread-safe wrapper for event emitters.
\ No newline at end of file diff --git a/api/jellytau_lib/player/events/sidebar-items.js b/api/jellytau_lib/player/events/sidebar-items.js new file mode 100644 index 00000000..c8fe16f8 --- /dev/null +++ b/api/jellytau_lib/player/events/sidebar-items.js @@ -0,0 +1 @@ +window.SIDEBAR_ITEMS = {"enum":["PlayerStatusEvent"],"struct":["TauriEventEmitter"],"trait":["PlayerEventEmitter"],"type":["SharedEventEmitter"]}; \ No newline at end of file diff --git a/api/jellytau_lib/player/events/struct.TauriEventEmitter.html b/api/jellytau_lib/player/events/struct.TauriEventEmitter.html new file mode 100644 index 00000000..fd07b3e7 --- /dev/null +++ b/api/jellytau_lib/player/events/struct.TauriEventEmitter.html @@ -0,0 +1,29 @@ +TauriEventEmitter in jellytau_lib::player::events - RustSkip to main content

TauriEventEmitter

Struct TauriEventEmitter 

Source
pub struct TauriEventEmitter {
+    app_handle: AppHandle,
+}
Expand description

Tauri-based implementation of PlayerEventEmitter.

+

Uses Tauri’s AppHandle::emit() to broadcast events to all windows.

+

Fields§

§app_handle: AppHandle

Implementations§

Source§

impl TauriEventEmitter

Source

pub fn new(app_handle: AppHandle) -> Self

Create a new TauriEventEmitter with the given app handle.

+

Trait Implementations§

Source§

impl PlayerEventEmitter for TauriEventEmitter

Source§

fn emit(&self, event: PlayerStatusEvent)

Emit a player status event to the frontend

Auto Trait Implementations§

Blanket Implementations§

Source§

impl<T> Any for T
where + T: 'static + ?Sized,

Source§

fn type_id(&self) -> TypeId

Gets the TypeId of self. Read more
Source§

impl<T> Borrow<T> for T
where + T: ?Sized,

Source§

fn borrow(&self) -> &T

Immutably borrows from an owned value. Read more
Source§

impl<T> BorrowMut<T> for T
where + T: ?Sized,

Source§

fn borrow_mut(&mut self) -> &mut T

Mutably borrows from an owned value. Read more
Source§

impl<T> From<T> for T

Source§

fn from(t: T) -> T

Returns the argument unchanged.

+
§

impl<T> Instrument for T

§

fn instrument(self, span: Span) -> Instrumented<Self>

Instruments this type with the provided [Span], returning an +Instrumented wrapper. Read more
§

fn in_current_span(self) -> Instrumented<Self>

Instruments this type with the current Span, returning an +Instrumented wrapper. Read more
Source§

impl<T, U> Into<U> for T
where + U: From<T>,

Source§

fn into(self) -> U

Calls U::from(self).

+

That is, this conversion is whatever the implementation of +From<T> for U chooses to do.

+
§

impl<T> PolicyExt for T
where + T: ?Sized,

§

fn and<P, B, E>(self, other: P) -> And<T, P>
where + T: Policy<B, E>, + P: Policy<B, E>,

Create a new Policy that returns [Action::Follow] only if self and other return +Action::Follow. Read more
§

fn or<P, B, E>(self, other: P) -> Or<T, P>
where + T: Policy<B, E>, + P: Policy<B, E>,

Create a new Policy that returns [Action::Follow] if either self or other returns +Action::Follow. Read more
Source§

impl<T> Same for T

Source§

type Output = T

Should always be Self
Source§

impl<T, U> TryFrom<U> for T
where + U: Into<T>,

Source§

type Error = Infallible

The type returned in the event of a conversion error.
Source§

fn try_from(value: U) -> Result<T, <T as TryFrom<U>>::Error>

Performs the conversion.
Source§

impl<T, U> TryInto<U> for T
where + U: TryFrom<T>,

Source§

type Error = <U as TryFrom<T>>::Error

The type returned in the event of a conversion error.
Source§

fn try_into(self) -> Result<U, <U as TryFrom<T>>::Error>

Performs the conversion.
§

impl<V, T> VZip<V> for T
where + V: MultiLane<T>,

§

fn vzip(self) -> V

§

impl<T> WithSubscriber for T

§

fn with_subscriber<S>(self, subscriber: S) -> WithDispatch<Self>
where + S: Into<Dispatch>,

Attaches the provided Subscriber to this type, returning a +[WithDispatch] wrapper. Read more
§

fn with_current_subscriber(self) -> WithDispatch<Self>

Attaches the current default Subscriber to this type, returning a +[WithDispatch] wrapper. Read more
\ No newline at end of file diff --git a/api/jellytau_lib/player/events/trait.PlayerEventEmitter.html b/api/jellytau_lib/player/events/trait.PlayerEventEmitter.html new file mode 100644 index 00000000..68cb411a --- /dev/null +++ b/api/jellytau_lib/player/events/trait.PlayerEventEmitter.html @@ -0,0 +1,8 @@ +PlayerEventEmitter in jellytau_lib::player::events - RustSkip to main content

PlayerEventEmitter

Trait PlayerEventEmitter 

Source
pub trait PlayerEventEmitter: Send + Sync {
+    // Required method
+    fn emit(&self, event: PlayerStatusEvent);
+}
Expand description

Trait for emitting player events to the frontend.

+

This abstraction allows backends to emit events without depending +directly on Tauri, making them easier to test.

+

Required Methods§

Source

fn emit(&self, event: PlayerStatusEvent)

Emit a player status event to the frontend

+

Implementors§

\ No newline at end of file diff --git a/api/jellytau_lib/player/events/type.SharedEventEmitter.html b/api/jellytau_lib/player/events/type.SharedEventEmitter.html new file mode 100644 index 00000000..fac3662e --- /dev/null +++ b/api/jellytau_lib/player/events/type.SharedEventEmitter.html @@ -0,0 +1,2 @@ +SharedEventEmitter in jellytau_lib::player::events - RustSkip to main content

SharedEventEmitter

Type Alias SharedEventEmitter 

Source
pub type SharedEventEmitter = Arc<dyn PlayerEventEmitter>;
Expand description

Thread-safe wrapper for event emitters.

+

Aliased Type§

pub struct SharedEventEmitter { /* private fields */ }
\ No newline at end of file diff --git a/api/jellytau_lib/player/fn.completion_report_position.html b/api/jellytau_lib/player/fn.completion_report_position.html new file mode 100644 index 00000000..7dea7e5c --- /dev/null +++ b/api/jellytau_lib/player/fn.completion_report_position.html @@ -0,0 +1,8 @@ +completion_report_position in jellytau_lib::player - RustSkip to main content

completion_report_position

Function completion_report_position 

Source
fn completion_report_position(runtime: Option<f64>, last_position: f64) -> f64
Expand description

The position to report when a stream ends naturally.

+

The item’s runtime when we know it, because the point of the report is to say +the episode finished and Jellyfin decides that by percentage — the last +position actually observed can be seconds short, and on a handoff whose ticks +stopped early it can be nowhere near the end. Without a runtime the best +available answer is where playback got to.

+

TRACES: UR-025, UR-040 | DR-179 | UT-179

+
\ No newline at end of file diff --git a/api/jellytau_lib/player/fn.set_lockscreen_position_offset.html b/api/jellytau_lib/player/fn.set_lockscreen_position_offset.html new file mode 100644 index 00000000..e9de5cd1 --- /dev/null +++ b/api/jellytau_lib/player/fn.set_lockscreen_position_offset.html @@ -0,0 +1,8 @@ +set_lockscreen_position_offset in jellytau_lib::player - RustSkip to main content

set_lockscreen_position_offset

Function set_lockscreen_position_offset 

Source
pub fn set_lockscreen_position_offset(
+    _offset_seconds: f64,
+) -> Result<(), String>
Expand description

Set the base offset (seconds) added to positions reported to the Android +lockscreen scrubber. Used by the background-audio handoff: the audio stream +starts at the handoff point (StartTimeTicks), so ExoPlayer’s position is +relative and must be shifted back to absolute to match the full duration. +Pass 0.0 to clear on exit. No-op off Android.

+
\ No newline at end of file diff --git a/api/jellytau_lib/player/fn.update_lockscreen_metadata.html b/api/jellytau_lib/player/fn.update_lockscreen_metadata.html new file mode 100644 index 00000000..d0fb52a8 --- /dev/null +++ b/api/jellytau_lib/player/fn.update_lockscreen_metadata.html @@ -0,0 +1,8 @@ +update_lockscreen_metadata in jellytau_lib::player - RustSkip to main content

update_lockscreen_metadata

Function update_lockscreen_metadata 

Source
pub fn update_lockscreen_metadata(
+    _meta: &LockscreenMetadata,
+) -> Result<(), String>
Expand description

Push now-playing metadata to the Android lockscreen. No-op off Android, so the +session poller can call it unconditionally and stay platform-agnostic.

+

No-op on Linux specifically because there is no MPRIS/D-Bus publisher — see +IR-005, which is still Planned.

+

TRACES: UR-006 | IR-006

+
\ No newline at end of file diff --git a/api/jellytau_lib/player/index.html b/api/jellytau_lib/player/index.html new file mode 100644 index 00000000..6a6113f2 --- /dev/null +++ b/api/jellytau_lib/player/index.html @@ -0,0 +1,6 @@ +jellytau_lib::player - RustSkip to main content

Module player

Module player 

Source

Re-exports§

pub use autoplay::AutoplayDecision;
pub use autoplay::AutoplaySettings;
pub use backend::NullBackend;
pub use backend::PlayerBackend;
pub use backend::PlayerError;
pub use events::PlayerEventEmitter;
pub use events::PlayerStatusEvent;
pub use events::TauriEventEmitter;
pub use media::MediaItem;
pub use media::MediaSource;
pub use media::MediaType;
pub use media::QueueContext;
pub use media::SubtitleTrack;
pub use queue::QueueManager;
pub use queue::RepeatMode;
pub use seek::determine_video_seek_strategy;
pub use seek::VideoSeekStrategy;
pub use session::MediaSessionManager;
pub use session::MediaSessionType;
pub use sleep_timer::SleepTimerMode;
pub use sleep_timer::SleepTimerState;
pub use state::EndReason;
pub use state::PlayerState;
pub use mpv_backend::MpvBackend;

Modules§

autoplay
backend
background_policy
What playback should do when the app stops being visible.
events
Player events for frontend communication via Tauri events.
media
mpv_backend
queue
seek
Video seek strategy decision logic.
session
sleep_timer
state
stream_end
Telling a finished stream apart from a truncated one.

Structs§

LockscreenMetadata
Metadata for the lockscreen / media notification.
PlayerController
Central player controller that coordinates playback
ReporterSink 🔒
The production sink: hands each operation to the PlaybackReporter.

Constants§

RESUME_BACKOFF_STEP_SECS 🔒
Seconds added per attempt before retrying a stream that failed with an error.

Traits§

PlaybackReportSink
Where the player’s playback reports go.

Functions§

completion_report_position 🔒
The position to report when a stream ends naturally.
set_lockscreen_position_offset
Set the base offset (seconds) added to positions reported to the Android +lockscreen scrubber. Used by the background-audio handoff: the audio stream +starts at the handoff point (StartTimeTicks), so ExoPlayer’s position is +relative and must be shifted back to absolute to match the full duration. +Pass 0.0 to clear on exit. No-op off Android.
update_lockscreen_metadata
Push now-playing metadata to the Android lockscreen. No-op off Android, so the +session poller can call it unconditionally and stay platform-agnostic.
\ No newline at end of file diff --git a/api/jellytau_lib/player/media/enum.MediaSource.html b/api/jellytau_lib/player/media/enum.MediaSource.html new file mode 100644 index 00000000..b6c6723f --- /dev/null +++ b/api/jellytau_lib/player/media/enum.MediaSource.html @@ -0,0 +1,67 @@ +MediaSource in jellytau_lib::player::media - RustSkip to main content

MediaSource

Enum MediaSource 

Source
pub enum MediaSource {
+    Remote {
+        stream_url: String,
+        jellyfin_item_id: String,
+    },
+    Local {
+        file_path: PathBuf,
+        jellyfin_item_id: Option<String>,
+    },
+    DirectUrl {
+        url: String,
+    },
+}
Expand description

TRACES: UR-002, UR-003, UR-004, UR-011 | DR-003

+

Variants§

§

Remote

Streaming from Jellyfin server

+

Fields

§stream_url: String
§jellyfin_item_id: String
§

Local

Downloaded/cached locally

+

Fields

§file_path: PathBuf
§jellyfin_item_id: Option<String>

Original Jellyfin ID for sync-back

+
§

DirectUrl

Direct URL (e.g., channel plugins)

+

Fields

Trait Implementations§

Source§

impl Clone for MediaSource

Source§

fn clone(&self) -> MediaSource

Returns a duplicate of the value. Read more
1.0.0 (const: unstable) · Source§

fn clone_from(&mut self, source: &Self)

Performs copy-assignment from source. Read more
Source§

impl Debug for MediaSource

Source§

fn fmt(&self, f: &mut Formatter<'_>) -> Result

Formats the value using the given formatter. Read more
Source§

impl<'de> Deserialize<'de> for MediaSource

Source§

fn deserialize<__D>(__deserializer: __D) -> Result<Self, __D::Error>
where + __D: Deserializer<'de>,

Deserialize this value from the given Serde deserializer. Read more
Source§

impl NamedType for MediaSource

Source§

fn sid() -> SpectaID

Source§

fn named_data_type( + type_map: &mut TypeCollection, + generics: &[DataType], +) -> NamedDataType

this is equivalent to [Type::inline] but returns a [NamedDataType] instead.
Source§

fn definition_named_data_type(type_map: &mut TypeCollection) -> NamedDataType

this is equivalent to [Type::definition] but returns a [NamedDataType] instead.
Source§

impl PartialEq for MediaSource

Source§

fn eq(&self, other: &MediaSource) -> bool

Tests for self and other values to be equal, and is used by ==.
1.0.0 (const: unstable) · Source§

fn ne(&self, other: &Rhs) -> bool

Tests for !=. The default implementation is almost always sufficient, +and should not be overridden without very good reason.
Source§

impl Serialize for MediaSource

Source§

fn serialize<__S>(&self, __serializer: __S) -> Result<__S::Ok, __S::Error>
where + __S: Serializer,

Serialize this value into the given Serde serializer. Read more
Source§

impl Type for MediaSource

Source§

fn inline(type_map: &mut TypeCollection, generics: Generics<'_>) -> DataType

Returns the definition of a type using the provided generics. Read more
Source§

fn reference(type_map: &mut TypeCollection, generics: &[DataType]) -> Reference

Generates a datatype corresponding to a reference to this type, +as determined by its category. Getting a reference to a type implies that +it should belong in the type map (since it has to be referenced from somewhere), +so the output of definition will be put into the type map.
Source§

impl Flatten for MediaSource

Source§

impl StructuralPartialEq for MediaSource

Auto Trait Implementations§

Blanket Implementations§

Source§

impl<T> Any for T
where + T: 'static + ?Sized,

Source§

fn type_id(&self) -> TypeId

Gets the TypeId of self. Read more
Source§

impl<T> Borrow<T> for T
where + T: ?Sized,

Source§

fn borrow(&self) -> &T

Immutably borrows from an owned value. Read more
Source§

impl<T> BorrowMut<T> for T
where + T: ?Sized,

Source§

fn borrow_mut(&mut self) -> &mut T

Mutably borrows from an owned value. Read more
Source§

impl<T> CloneToUninit for T
where + T: Clone,

Source§

unsafe fn clone_to_uninit(&self, dest: *mut u8)

🔬This is a nightly-only experimental API. (clone_to_uninit)
Performs copy-assignment from self to dest. Read more
§

impl<'de, D, R> CommandArg<'de, R> for D
where + D: Deserialize<'de>, + R: Runtime,

§

fn from_command(command: CommandItem<'de, R>) -> Result<D, InvokeError>

Derives an instance of Self from the [CommandItem]. Read more
Source§

impl<T> From<T> for T

Source§

fn from(t: T) -> T

Returns the argument unchanged.

+
§

impl<T> FunctionArg for T
where + T: Type,

§

fn to_datatype(type_map: &mut TypeCollection) -> Option<DataType>

Gets the type of an argument as a [DataType]. Read more
§

impl<T> Instrument for T

§

fn instrument(self, span: Span) -> Instrumented<Self>

Instruments this type with the provided [Span], returning an +Instrumented wrapper. Read more
§

fn in_current_span(self) -> Instrumented<Self>

Instruments this type with the current Span, returning an +Instrumented wrapper. Read more
Source§

impl<T, U> Into<U> for T
where + U: From<T>,

Source§

fn into(self) -> U

Calls U::from(self).

+

That is, this conversion is whatever the implementation of +From<T> for U chooses to do.

+
§

impl<T> IpcResponse for T
where + T: Serialize,

§

fn body(self) -> Result<InvokeResponseBody, Error>

Resolve the IPC response body.
§

impl<T> PolicyExt for T
where + T: ?Sized,

§

fn and<P, B, E>(self, other: P) -> And<T, P>
where + T: Policy<B, E>, + P: Policy<B, E>,

Create a new Policy that returns [Action::Follow] only if self and other return +Action::Follow. Read more
§

fn or<P, B, E>(self, other: P) -> Or<T, P>
where + T: Policy<B, E>, + P: Policy<B, E>,

Create a new Policy that returns [Action::Follow] if either self or other returns +Action::Follow. Read more
Source§

impl<T> Same for T

Source§

type Output = T

Should always be Self
§

impl<T> ScopeObject for T
where + T: Send + Sync + Debug + DeserializeOwned + 'static,

§

type Error = Error

The error type.
§

fn deserialize<R>( + _app: &AppHandle<R>, + raw: Value, +) -> Result<T, <T as ScopeObject>::Error>
where + R: Runtime,

Deserialize the raw scope value.
Source§

impl<T> Serialize for T
where + T: Serialize + ?Sized,

Source§

fn erased_serialize(&self, serializer: &mut dyn Serializer) -> Result<(), Error>

Source§

fn do_erased_serialize( + &self, + serializer: &mut dyn Serializer, +) -> Result<(), ErrorImpl>

Source§

impl<T> ToOwned for T
where + T: Clone,

Source§

type Owned = T

The resulting type after obtaining ownership.
Source§

fn to_owned(&self) -> T

Creates owned data from borrowed data, usually by cloning. Read more
Source§

fn clone_into(&self, target: &mut T)

Uses borrowed data to replace owned data, usually by cloning. Read more
Source§

impl<T, U> TryFrom<U> for T
where + U: Into<T>,

Source§

type Error = Infallible

The type returned in the event of a conversion error.
Source§

fn try_from(value: U) -> Result<T, <T as TryFrom<U>>::Error>

Performs the conversion.
Source§

impl<T, U> TryInto<U> for T
where + U: TryFrom<T>,

Source§

type Error = <U as TryFrom<T>>::Error

The type returned in the event of a conversion error.
Source§

fn try_into(self) -> Result<U, <U as TryFrom<T>>::Error>

Performs the conversion.
§

impl<V, T> VZip<V> for T
where + V: MultiLane<T>,

§

fn vzip(self) -> V

§

impl<T> WithSubscriber for T

§

fn with_subscriber<S>(self, subscriber: S) -> WithDispatch<Self>
where + S: Into<Dispatch>,

Attaches the provided Subscriber to this type, returning a +[WithDispatch] wrapper. Read more
§

fn with_current_subscriber(self) -> WithDispatch<Self>

Attaches the current default Subscriber to this type, returning a +[WithDispatch] wrapper. Read more
Source§

impl<T> DeserializeOwned for T
where + T: for<'de> Deserialize<'de>,

§

impl<T> UserEvent for T
where + T: Debug + Clone + Send + 'static,

\ No newline at end of file diff --git a/api/jellytau_lib/player/media/enum.MediaType.html b/api/jellytau_lib/player/media/enum.MediaType.html new file mode 100644 index 00000000..36c0752d --- /dev/null +++ b/api/jellytau_lib/player/media/enum.MediaType.html @@ -0,0 +1,59 @@ +MediaType in jellytau_lib::player::media - RustSkip to main content

MediaType

Enum MediaType 

Source
pub enum MediaType {
+    Audio,
+    Video,
+}

Variants§

§

Audio

§

Video

Trait Implementations§

Source§

impl Clone for MediaType

Source§

fn clone(&self) -> MediaType

Returns a duplicate of the value. Read more
1.0.0 (const: unstable) · Source§

fn clone_from(&mut self, source: &Self)

Performs copy-assignment from source. Read more
Source§

impl Debug for MediaType

Source§

fn fmt(&self, f: &mut Formatter<'_>) -> Result

Formats the value using the given formatter. Read more
Source§

impl<'de> Deserialize<'de> for MediaType

Source§

fn deserialize<__D>(__deserializer: __D) -> Result<Self, __D::Error>
where + __D: Deserializer<'de>,

Deserialize this value from the given Serde deserializer. Read more
Source§

impl NamedType for MediaType

Source§

fn sid() -> SpectaID

Source§

fn named_data_type( + type_map: &mut TypeCollection, + generics: &[DataType], +) -> NamedDataType

this is equivalent to [Type::inline] but returns a [NamedDataType] instead.
Source§

fn definition_named_data_type(type_map: &mut TypeCollection) -> NamedDataType

this is equivalent to [Type::definition] but returns a [NamedDataType] instead.
Source§

impl PartialEq for MediaType

Source§

fn eq(&self, other: &MediaType) -> bool

Tests for self and other values to be equal, and is used by ==.
1.0.0 (const: unstable) · Source§

fn ne(&self, other: &Rhs) -> bool

Tests for !=. The default implementation is almost always sufficient, +and should not be overridden without very good reason.
Source§

impl Serialize for MediaType

Source§

fn serialize<__S>(&self, __serializer: __S) -> Result<__S::Ok, __S::Error>
where + __S: Serializer,

Serialize this value into the given Serde serializer. Read more
Source§

impl Type for MediaType

Source§

fn inline(type_map: &mut TypeCollection, generics: Generics<'_>) -> DataType

Returns the definition of a type using the provided generics. Read more
Source§

fn reference(type_map: &mut TypeCollection, generics: &[DataType]) -> Reference

Generates a datatype corresponding to a reference to this type, +as determined by its category. Getting a reference to a type implies that +it should belong in the type map (since it has to be referenced from somewhere), +so the output of definition will be put into the type map.
Source§

impl Copy for MediaType

Source§

impl Eq for MediaType

Source§

impl StructuralPartialEq for MediaType

Auto Trait Implementations§

Blanket Implementations§

Source§

impl<T> Any for T
where + T: 'static + ?Sized,

Source§

fn type_id(&self) -> TypeId

Gets the TypeId of self. Read more
Source§

impl<T> Borrow<T> for T
where + T: ?Sized,

Source§

fn borrow(&self) -> &T

Immutably borrows from an owned value. Read more
Source§

impl<T> BorrowMut<T> for T
where + T: ?Sized,

Source§

fn borrow_mut(&mut self) -> &mut T

Mutably borrows from an owned value. Read more
Source§

impl<T> CloneToUninit for T
where + T: Clone,

Source§

unsafe fn clone_to_uninit(&self, dest: *mut u8)

🔬This is a nightly-only experimental API. (clone_to_uninit)
Performs copy-assignment from self to dest. Read more
§

impl<'de, D, R> CommandArg<'de, R> for D
where + D: Deserialize<'de>, + R: Runtime,

§

fn from_command(command: CommandItem<'de, R>) -> Result<D, InvokeError>

Derives an instance of Self from the [CommandItem]. Read more
§

impl<Q, K> Equivalent<K> for Q
where + Q: Eq + ?Sized, + K: Borrow<Q> + ?Sized,

§

fn equivalent(&self, key: &K) -> bool

Checks if this value is equivalent to the given key. Read more
§

impl<Q, K> Equivalent<K> for Q
where + Q: Eq + ?Sized, + K: Borrow<Q> + ?Sized,

§

fn equivalent(&self, key: &K) -> bool

Compare self to key and return true if they are equal.
§

impl<Q, K> Equivalent<K> for Q
where + Q: Eq + ?Sized, + K: Borrow<Q> + ?Sized,

§

fn equivalent(&self, key: &K) -> bool

Checks if this value is equivalent to the given key. Read more
Source§

impl<T> From<T> for T

Source§

fn from(t: T) -> T

Returns the argument unchanged.

+
§

impl<T> FunctionArg for T
where + T: Type,

§

fn to_datatype(type_map: &mut TypeCollection) -> Option<DataType>

Gets the type of an argument as a [DataType]. Read more
§

impl<T> Instrument for T

§

fn instrument(self, span: Span) -> Instrumented<Self>

Instruments this type with the provided [Span], returning an +Instrumented wrapper. Read more
§

fn in_current_span(self) -> Instrumented<Self>

Instruments this type with the current Span, returning an +Instrumented wrapper. Read more
Source§

impl<T, U> Into<U> for T
where + U: From<T>,

Source§

fn into(self) -> U

Calls U::from(self).

+

That is, this conversion is whatever the implementation of +From<T> for U chooses to do.

+
§

impl<T> IpcResponse for T
where + T: Serialize,

§

fn body(self) -> Result<InvokeResponseBody, Error>

Resolve the IPC response body.
§

impl<T> PolicyExt for T
where + T: ?Sized,

§

fn and<P, B, E>(self, other: P) -> And<T, P>
where + T: Policy<B, E>, + P: Policy<B, E>,

Create a new Policy that returns [Action::Follow] only if self and other return +Action::Follow. Read more
§

fn or<P, B, E>(self, other: P) -> Or<T, P>
where + T: Policy<B, E>, + P: Policy<B, E>,

Create a new Policy that returns [Action::Follow] if either self or other returns +Action::Follow. Read more
Source§

impl<T> Same for T

Source§

type Output = T

Should always be Self
§

impl<T> ScopeObject for T
where + T: Send + Sync + Debug + DeserializeOwned + 'static,

§

type Error = Error

The error type.
§

fn deserialize<R>( + _app: &AppHandle<R>, + raw: Value, +) -> Result<T, <T as ScopeObject>::Error>
where + R: Runtime,

Deserialize the raw scope value.
Source§

impl<T> Serialize for T
where + T: Serialize + ?Sized,

Source§

fn erased_serialize(&self, serializer: &mut dyn Serializer) -> Result<(), Error>

Source§

fn do_erased_serialize( + &self, + serializer: &mut dyn Serializer, +) -> Result<(), ErrorImpl>

Source§

impl<T> ToOwned for T
where + T: Clone,

Source§

type Owned = T

The resulting type after obtaining ownership.
Source§

fn to_owned(&self) -> T

Creates owned data from borrowed data, usually by cloning. Read more
Source§

fn clone_into(&self, target: &mut T)

Uses borrowed data to replace owned data, usually by cloning. Read more
Source§

impl<T, U> TryFrom<U> for T
where + U: Into<T>,

Source§

type Error = Infallible

The type returned in the event of a conversion error.
Source§

fn try_from(value: U) -> Result<T, <T as TryFrom<U>>::Error>

Performs the conversion.
Source§

impl<T, U> TryInto<U> for T
where + U: TryFrom<T>,

Source§

type Error = <U as TryFrom<T>>::Error

The type returned in the event of a conversion error.
Source§

fn try_into(self) -> Result<U, <U as TryFrom<T>>::Error>

Performs the conversion.
§

impl<V, T> VZip<V> for T
where + V: MultiLane<T>,

§

fn vzip(self) -> V

§

impl<T> WithSubscriber for T

§

fn with_subscriber<S>(self, subscriber: S) -> WithDispatch<Self>
where + S: Into<Dispatch>,

Attaches the provided Subscriber to this type, returning a +[WithDispatch] wrapper. Read more
§

fn with_current_subscriber(self) -> WithDispatch<Self>

Attaches the current default Subscriber to this type, returning a +[WithDispatch] wrapper. Read more
Source§

impl<T> DeserializeOwned for T
where + T: for<'de> Deserialize<'de>,

§

impl<T> UserEvent for T
where + T: Debug + Clone + Send + 'static,

\ No newline at end of file diff --git a/api/jellytau_lib/player/media/enum.QueueContext.html b/api/jellytau_lib/player/media/enum.QueueContext.html new file mode 100644 index 00000000..bb69b025 --- /dev/null +++ b/api/jellytau_lib/player/media/enum.QueueContext.html @@ -0,0 +1,67 @@ +QueueContext in jellytau_lib::player::media - RustSkip to main content

QueueContext

Enum QueueContext 

Source
pub enum QueueContext {
+    Album {
+        album_id: String,
+        album_name: String,
+    },
+    Playlist {
+        playlist_id: String,
+        playlist_name: String,
+    },
+    Custom,
+}
Expand description

Context for the current queue - where did the queue items come from? +This is used for remote playback transfer to send album/playlist context.

+

Variants§

§

Album

Playing from a specific album

+

Fields

§album_id: String
§album_name: String
§

Playlist

Playing from a specific playlist

+

Fields

§playlist_id: String
§playlist_name: String
§

Custom

Custom queue (search results, manual queue, etc.) +Will create a temporary playlist on remote transfer

+

Trait Implementations§

Source§

impl Clone for QueueContext

Source§

fn clone(&self) -> QueueContext

Returns a duplicate of the value. Read more
1.0.0 (const: unstable) · Source§

fn clone_from(&mut self, source: &Self)

Performs copy-assignment from source. Read more
Source§

impl Debug for QueueContext

Source§

fn fmt(&self, f: &mut Formatter<'_>) -> Result

Formats the value using the given formatter. Read more
Source§

impl Default for QueueContext

Source§

fn default() -> QueueContext

Returns the “default value” for a type. Read more
Source§

impl<'de> Deserialize<'de> for QueueContext

Source§

fn deserialize<__D>(__deserializer: __D) -> Result<Self, __D::Error>
where + __D: Deserializer<'de>,

Deserialize this value from the given Serde deserializer. Read more
Source§

impl NamedType for QueueContext

Source§

fn sid() -> SpectaID

Source§

fn named_data_type( + type_map: &mut TypeCollection, + generics: &[DataType], +) -> NamedDataType

this is equivalent to [Type::inline] but returns a [NamedDataType] instead.
Source§

fn definition_named_data_type(type_map: &mut TypeCollection) -> NamedDataType

this is equivalent to [Type::definition] but returns a [NamedDataType] instead.
Source§

impl PartialEq for QueueContext

Source§

fn eq(&self, other: &QueueContext) -> bool

Tests for self and other values to be equal, and is used by ==.
1.0.0 (const: unstable) · Source§

fn ne(&self, other: &Rhs) -> bool

Tests for !=. The default implementation is almost always sufficient, +and should not be overridden without very good reason.
Source§

impl Serialize for QueueContext

Source§

fn serialize<__S>(&self, __serializer: __S) -> Result<__S::Ok, __S::Error>
where + __S: Serializer,

Serialize this value into the given Serde serializer. Read more
Source§

impl Type for QueueContext

Source§

fn inline(type_map: &mut TypeCollection, generics: Generics<'_>) -> DataType

Returns the definition of a type using the provided generics. Read more
Source§

fn reference(type_map: &mut TypeCollection, generics: &[DataType]) -> Reference

Generates a datatype corresponding to a reference to this type, +as determined by its category. Getting a reference to a type implies that +it should belong in the type map (since it has to be referenced from somewhere), +so the output of definition will be put into the type map.
Source§

impl Flatten for QueueContext

Source§

impl StructuralPartialEq for QueueContext

Auto Trait Implementations§

Blanket Implementations§

Source§

impl<T> Any for T
where + T: 'static + ?Sized,

Source§

fn type_id(&self) -> TypeId

Gets the TypeId of self. Read more
Source§

impl<T> Borrow<T> for T
where + T: ?Sized,

Source§

fn borrow(&self) -> &T

Immutably borrows from an owned value. Read more
Source§

impl<T> BorrowMut<T> for T
where + T: ?Sized,

Source§

fn borrow_mut(&mut self) -> &mut T

Mutably borrows from an owned value. Read more
Source§

impl<T> CloneToUninit for T
where + T: Clone,

Source§

unsafe fn clone_to_uninit(&self, dest: *mut u8)

🔬This is a nightly-only experimental API. (clone_to_uninit)
Performs copy-assignment from self to dest. Read more
§

impl<'de, D, R> CommandArg<'de, R> for D
where + D: Deserialize<'de>, + R: Runtime,

§

fn from_command(command: CommandItem<'de, R>) -> Result<D, InvokeError>

Derives an instance of Self from the [CommandItem]. Read more
Source§

impl<T> From<T> for T

Source§

fn from(t: T) -> T

Returns the argument unchanged.

+
§

impl<T> FunctionArg for T
where + T: Type,

§

fn to_datatype(type_map: &mut TypeCollection) -> Option<DataType>

Gets the type of an argument as a [DataType]. Read more
§

impl<T> Instrument for T

§

fn instrument(self, span: Span) -> Instrumented<Self>

Instruments this type with the provided [Span], returning an +Instrumented wrapper. Read more
§

fn in_current_span(self) -> Instrumented<Self>

Instruments this type with the current Span, returning an +Instrumented wrapper. Read more
Source§

impl<T, U> Into<U> for T
where + U: From<T>,

Source§

fn into(self) -> U

Calls U::from(self).

+

That is, this conversion is whatever the implementation of +From<T> for U chooses to do.

+
§

impl<T> IpcResponse for T
where + T: Serialize,

§

fn body(self) -> Result<InvokeResponseBody, Error>

Resolve the IPC response body.
§

impl<T> NoneValue for T
where + T: Default,

§

type NoneType = T

§

fn null_value() -> T

The none-equivalent value.
§

impl<T> PolicyExt for T
where + T: ?Sized,

§

fn and<P, B, E>(self, other: P) -> And<T, P>
where + T: Policy<B, E>, + P: Policy<B, E>,

Create a new Policy that returns [Action::Follow] only if self and other return +Action::Follow. Read more
§

fn or<P, B, E>(self, other: P) -> Or<T, P>
where + T: Policy<B, E>, + P: Policy<B, E>,

Create a new Policy that returns [Action::Follow] if either self or other returns +Action::Follow. Read more
Source§

impl<T> Same for T

Source§

type Output = T

Should always be Self
§

impl<T> ScopeObject for T
where + T: Send + Sync + Debug + DeserializeOwned + 'static,

§

type Error = Error

The error type.
§

fn deserialize<R>( + _app: &AppHandle<R>, + raw: Value, +) -> Result<T, <T as ScopeObject>::Error>
where + R: Runtime,

Deserialize the raw scope value.
Source§

impl<T> Serialize for T
where + T: Serialize + ?Sized,

Source§

fn erased_serialize(&self, serializer: &mut dyn Serializer) -> Result<(), Error>

Source§

fn do_erased_serialize( + &self, + serializer: &mut dyn Serializer, +) -> Result<(), ErrorImpl>

Source§

impl<T> ToOwned for T
where + T: Clone,

Source§

type Owned = T

The resulting type after obtaining ownership.
Source§

fn to_owned(&self) -> T

Creates owned data from borrowed data, usually by cloning. Read more
Source§

fn clone_into(&self, target: &mut T)

Uses borrowed data to replace owned data, usually by cloning. Read more
Source§

impl<T, U> TryFrom<U> for T
where + U: Into<T>,

Source§

type Error = Infallible

The type returned in the event of a conversion error.
Source§

fn try_from(value: U) -> Result<T, <T as TryFrom<U>>::Error>

Performs the conversion.
Source§

impl<T, U> TryInto<U> for T
where + U: TryFrom<T>,

Source§

type Error = <U as TryFrom<T>>::Error

The type returned in the event of a conversion error.
Source§

fn try_into(self) -> Result<U, <U as TryFrom<T>>::Error>

Performs the conversion.
§

impl<V, T> VZip<V> for T
where + V: MultiLane<T>,

§

fn vzip(self) -> V

§

impl<T> WithSubscriber for T

§

fn with_subscriber<S>(self, subscriber: S) -> WithDispatch<Self>
where + S: Into<Dispatch>,

Attaches the provided Subscriber to this type, returning a +[WithDispatch] wrapper. Read more
§

fn with_current_subscriber(self) -> WithDispatch<Self>

Attaches the current default Subscriber to this type, returning a +[WithDispatch] wrapper. Read more
Source§

impl<T> DeserializeOwned for T
where + T: for<'de> Deserialize<'de>,

§

impl<T> UserEvent for T
where + T: Debug + Clone + Send + 'static,

\ No newline at end of file diff --git a/api/jellytau_lib/player/media/index.html b/api/jellytau_lib/player/media/index.html new file mode 100644 index 00000000..6a5fb141 --- /dev/null +++ b/api/jellytau_lib/player/media/index.html @@ -0,0 +1,2 @@ +jellytau_lib::player::media - RustSkip to main content

Module media

Module media 

Source

Structs§

MediaItem
Represents a media item that can be played
SubtitleTrack
Represents a subtitle track

Enums§

MediaSource
TRACES: UR-002, UR-003, UR-004, UR-011 | DR-003
MediaType
QueueContext
Context for the current queue - where did the queue items come from? +This is used for remote playback transfer to send album/playlist context.
\ No newline at end of file diff --git a/api/jellytau_lib/player/media/sidebar-items.js b/api/jellytau_lib/player/media/sidebar-items.js new file mode 100644 index 00000000..e8f6bb2c --- /dev/null +++ b/api/jellytau_lib/player/media/sidebar-items.js @@ -0,0 +1 @@ +window.SIDEBAR_ITEMS = {"enum":["MediaSource","MediaType","QueueContext"],"struct":["MediaItem","SubtitleTrack"]}; \ No newline at end of file diff --git a/api/jellytau_lib/player/media/struct.MediaItem.html b/api/jellytau_lib/player/media/struct.MediaItem.html new file mode 100644 index 00000000..6109929a --- /dev/null +++ b/api/jellytau_lib/player/media/struct.MediaItem.html @@ -0,0 +1,105 @@ +MediaItem in jellytau_lib::player::media - RustSkip to main content

MediaItem

Struct MediaItem 

Source
pub struct MediaItem {
Show 24 fields + pub id: String, + pub title: String, + pub name: Option<String>, + pub artist: Option<String>, + pub album: Option<String>, + pub album_name: Option<String>, + pub album_id: Option<String>, + pub artist_items: Option<Vec<ArtistItem>>, + pub artists: Option<Vec<String>>, + pub primary_image_tag: Option<String>, + pub image_id: Option<String>, + pub item_type: Option<String>, + pub playlist_id: Option<String>, + pub duration: Option<f64>, + pub artwork_url: Option<String>, + pub media_type: MediaType, + pub source: MediaSource, + pub video_codec: Option<String>, + pub needs_transcoding: bool, + pub video_width: Option<u32>, + pub video_height: Option<u32>, + pub subtitles: Vec<SubtitleTrack>, + pub series_id: Option<String>, + pub server_id: Option<String>, +
}
Expand description

Represents a media item that can be played

+

TRACES: UR-003, UR-004 | DR-002

+

Fields§

§id: String

Unique identifier

+
§title: String

Display title

+
§name: Option<String>

Name (alias for title - for frontend compatibility)

+
§artist: Option<String>

Artist name(s) for audio

+
§album: Option<String>

Album name for audio

+
§album_name: Option<String>

Album name (alias - for frontend compatibility)

+
§album_id: Option<String>

Album ID (Jellyfin ID) for remote transfer context

+
§artist_items: Option<Vec<ArtistItem>>

Artist items with IDs for clickable links

+
§artists: Option<Vec<String>>

Artists as array of strings (fallback when artist_items not available)

+
§primary_image_tag: Option<String>

Primary image tag for artwork.

+

Legacy Jellyfin name; being replaced by image_id (same value). Dual-carried +while the frontend migrates (docs/specs/frontend-domain-model.md).

+
§image_id: Option<String>

Neutral image identifier the frontend resolves to a URL — replaces +primary_image_tag.

+
§item_type: Option<String>

Item type (Audio, Movie, Episode, etc.)

+
§playlist_id: Option<String>

Playlist ID (Jellyfin ID) for remote transfer context

+
§duration: Option<f64>

Duration in seconds

+
§artwork_url: Option<String>

URL or path to artwork image

+
§media_type: MediaType

Type of media

+
§source: MediaSource

Source of the media

+
§video_codec: Option<String>

Video codec (e.g., “h264”, “hevc”) for video media

+
§needs_transcoding: bool

Whether the video requires server-side transcoding

+
§video_width: Option<u32>

Video width in pixels

+
§video_height: Option<u32>

Video height in pixels

+
§subtitles: Vec<SubtitleTrack>

Available subtitle tracks

+
§series_id: Option<String>

Series ID (for TV show episodes) - used for series audio preferences

+
§server_id: Option<String>

Server ID - used for series audio preferences

+

Implementations§

Source§

impl MediaItem

Source

pub fn jellyfin_id(&self) -> Option<&str>

Get the Jellyfin item ID if available

+

Trait Implementations§

Source§

impl Clone for MediaItem

Source§

fn clone(&self) -> MediaItem

Returns a duplicate of the value. Read more
1.0.0 (const: unstable) · Source§

fn clone_from(&mut self, source: &Self)

Performs copy-assignment from source. Read more
Source§

impl Debug for MediaItem

Source§

fn fmt(&self, f: &mut Formatter<'_>) -> Result

Formats the value using the given formatter. Read more
Source§

impl<'de> Deserialize<'de> for MediaItem

Source§

fn deserialize<__D>(__deserializer: __D) -> Result<Self, __D::Error>
where + __D: Deserializer<'de>,

Deserialize this value from the given Serde deserializer. Read more
Source§

impl From<&MediaItem> for MergedMediaItem

Source§

fn from(item: &MediaItem) -> Self

Converts to this type from the input type.
Source§

impl NamedType for MediaItem

Source§

fn sid() -> SpectaID

Source§

fn named_data_type( + type_map: &mut TypeCollection, + generics: &[DataType], +) -> NamedDataType

this is equivalent to [Type::inline] but returns a [NamedDataType] instead.
Source§

fn definition_named_data_type(type_map: &mut TypeCollection) -> NamedDataType

this is equivalent to [Type::definition] but returns a [NamedDataType] instead.
Source§

impl PartialEq for MediaItem

Source§

fn eq(&self, other: &MediaItem) -> bool

Tests for self and other values to be equal, and is used by ==.
1.0.0 (const: unstable) · Source§

fn ne(&self, other: &Rhs) -> bool

Tests for !=. The default implementation is almost always sufficient, +and should not be overridden without very good reason.
Source§

impl Serialize for MediaItem

Source§

fn serialize<__S>(&self, __serializer: __S) -> Result<__S::Ok, __S::Error>
where + __S: Serializer,

Serialize this value into the given Serde serializer. Read more
Source§

impl Type for MediaItem

Source§

fn inline(type_map: &mut TypeCollection, generics: Generics<'_>) -> DataType

Returns the definition of a type using the provided generics. Read more
Source§

fn reference(type_map: &mut TypeCollection, generics: &[DataType]) -> Reference

Generates a datatype corresponding to a reference to this type, +as determined by its category. Getting a reference to a type implies that +it should belong in the type map (since it has to be referenced from somewhere), +so the output of definition will be put into the type map.
Source§

impl Flatten for MediaItem

Source§

impl StructuralPartialEq for MediaItem

Auto Trait Implementations§

Blanket Implementations§

Source§

impl<T> Any for T
where + T: 'static + ?Sized,

Source§

fn type_id(&self) -> TypeId

Gets the TypeId of self. Read more
Source§

impl<T> Borrow<T> for T
where + T: ?Sized,

Source§

fn borrow(&self) -> &T

Immutably borrows from an owned value. Read more
Source§

impl<T> BorrowMut<T> for T
where + T: ?Sized,

Source§

fn borrow_mut(&mut self) -> &mut T

Mutably borrows from an owned value. Read more
Source§

impl<T> CloneToUninit for T
where + T: Clone,

Source§

unsafe fn clone_to_uninit(&self, dest: *mut u8)

🔬This is a nightly-only experimental API. (clone_to_uninit)
Performs copy-assignment from self to dest. Read more
§

impl<'de, D, R> CommandArg<'de, R> for D
where + D: Deserialize<'de>, + R: Runtime,

§

fn from_command(command: CommandItem<'de, R>) -> Result<D, InvokeError>

Derives an instance of Self from the [CommandItem]. Read more
Source§

impl<T> From<T> for T

Source§

fn from(t: T) -> T

Returns the argument unchanged.

+
§

impl<T> FunctionArg for T
where + T: Type,

§

fn to_datatype(type_map: &mut TypeCollection) -> Option<DataType>

Gets the type of an argument as a [DataType]. Read more
§

impl<T> Instrument for T

§

fn instrument(self, span: Span) -> Instrumented<Self>

Instruments this type with the provided [Span], returning an +Instrumented wrapper. Read more
§

fn in_current_span(self) -> Instrumented<Self>

Instruments this type with the current Span, returning an +Instrumented wrapper. Read more
Source§

impl<T, U> Into<U> for T
where + U: From<T>,

Source§

fn into(self) -> U

Calls U::from(self).

+

That is, this conversion is whatever the implementation of +From<T> for U chooses to do.

+
§

impl<T> IpcResponse for T
where + T: Serialize,

§

fn body(self) -> Result<InvokeResponseBody, Error>

Resolve the IPC response body.
§

impl<T> PolicyExt for T
where + T: ?Sized,

§

fn and<P, B, E>(self, other: P) -> And<T, P>
where + T: Policy<B, E>, + P: Policy<B, E>,

Create a new Policy that returns [Action::Follow] only if self and other return +Action::Follow. Read more
§

fn or<P, B, E>(self, other: P) -> Or<T, P>
where + T: Policy<B, E>, + P: Policy<B, E>,

Create a new Policy that returns [Action::Follow] if either self or other returns +Action::Follow. Read more
Source§

impl<T> Same for T

Source§

type Output = T

Should always be Self
§

impl<T> ScopeObject for T
where + T: Send + Sync + Debug + DeserializeOwned + 'static,

§

type Error = Error

The error type.
§

fn deserialize<R>( + _app: &AppHandle<R>, + raw: Value, +) -> Result<T, <T as ScopeObject>::Error>
where + R: Runtime,

Deserialize the raw scope value.
Source§

impl<T> Serialize for T
where + T: Serialize + ?Sized,

Source§

fn erased_serialize(&self, serializer: &mut dyn Serializer) -> Result<(), Error>

Source§

fn do_erased_serialize( + &self, + serializer: &mut dyn Serializer, +) -> Result<(), ErrorImpl>

Source§

impl<T> ToOwned for T
where + T: Clone,

Source§

type Owned = T

The resulting type after obtaining ownership.
Source§

fn to_owned(&self) -> T

Creates owned data from borrowed data, usually by cloning. Read more
Source§

fn clone_into(&self, target: &mut T)

Uses borrowed data to replace owned data, usually by cloning. Read more
Source§

impl<T, U> TryFrom<U> for T
where + U: Into<T>,

Source§

type Error = Infallible

The type returned in the event of a conversion error.
Source§

fn try_from(value: U) -> Result<T, <T as TryFrom<U>>::Error>

Performs the conversion.
Source§

impl<T, U> TryInto<U> for T
where + U: TryFrom<T>,

Source§

type Error = <U as TryFrom<T>>::Error

The type returned in the event of a conversion error.
Source§

fn try_into(self) -> Result<U, <U as TryFrom<T>>::Error>

Performs the conversion.
§

impl<V, T> VZip<V> for T
where + V: MultiLane<T>,

§

fn vzip(self) -> V

§

impl<T> WithSubscriber for T

§

fn with_subscriber<S>(self, subscriber: S) -> WithDispatch<Self>
where + S: Into<Dispatch>,

Attaches the provided Subscriber to this type, returning a +[WithDispatch] wrapper. Read more
§

fn with_current_subscriber(self) -> WithDispatch<Self>

Attaches the current default Subscriber to this type, returning a +[WithDispatch] wrapper. Read more
Source§

impl<T> DeserializeOwned for T
where + T: for<'de> Deserialize<'de>,

§

impl<T> UserEvent for T
where + T: Debug + Clone + Send + 'static,

\ No newline at end of file diff --git a/api/jellytau_lib/player/media/struct.SubtitleTrack.html b/api/jellytau_lib/player/media/struct.SubtitleTrack.html new file mode 100644 index 00000000..b37eb151 --- /dev/null +++ b/api/jellytau_lib/player/media/struct.SubtitleTrack.html @@ -0,0 +1,78 @@ +SubtitleTrack in jellytau_lib::player::media - RustSkip to main content

SubtitleTrack

Struct SubtitleTrack 

Source
pub struct SubtitleTrack {
+    pub index: i32,
+    pub url: String,
+    pub language: Option<String>,
+    pub label: Option<String>,
+    pub mime_type: String,
+}
Expand description

Represents a subtitle track

+

🔴 Do not add #[serde(rename_all = "camelCase")] here. This is the one +struct in the player that deliberately keeps snake_case on the wire, because +the same serialization feeds two consumers that both spell mime_type:

+
    +
  • the JNI boundary — player/android/mod.rs serializes MediaItem::subtitles +with serde_json and hands the string to JellyTauPlayer.loadWithMetadata, +whose parser reads url, language, label and optString("mime_type");
  • +
  • the IPC boundary — PlayItemRequest::subtitles deserializes this same type +from the frontend, and the generated binding (SubtitleTrack in +bindings.ts) therefore also declares mime_type.
  • +
+

Renaming would not break the build and would not fail the IPC: Kotlin’s +optString would just fall back to its default MIME type for every track, so +the failure would be silent. UT-146 asserts the serialized keys.

+

TRACES: UR-020 | IR-016, JA-008 | UT-146

+

Fields§

§index: i32

Stream index in the media source

+
§url: String

Subtitle URL

+
§language: Option<String>

Language code (e.g., “eng”, “spa”)

+
§label: Option<String>

Display title

+
§mime_type: String

MIME type (e.g., “text/vtt”, “application/x-subrip”). +Snake_case on purpose — see the note on the struct.

+

Trait Implementations§

Source§

impl Clone for SubtitleTrack

Source§

fn clone(&self) -> SubtitleTrack

Returns a duplicate of the value. Read more
1.0.0 (const: unstable) · Source§

fn clone_from(&mut self, source: &Self)

Performs copy-assignment from source. Read more
Source§

impl Debug for SubtitleTrack

Source§

fn fmt(&self, f: &mut Formatter<'_>) -> Result

Formats the value using the given formatter. Read more
Source§

impl<'de> Deserialize<'de> for SubtitleTrack

Source§

fn deserialize<__D>(__deserializer: __D) -> Result<Self, __D::Error>
where + __D: Deserializer<'de>,

Deserialize this value from the given Serde deserializer. Read more
Source§

impl NamedType for SubtitleTrack

Source§

fn sid() -> SpectaID

Source§

fn named_data_type( + type_map: &mut TypeCollection, + generics: &[DataType], +) -> NamedDataType

this is equivalent to [Type::inline] but returns a [NamedDataType] instead.
Source§

fn definition_named_data_type(type_map: &mut TypeCollection) -> NamedDataType

this is equivalent to [Type::definition] but returns a [NamedDataType] instead.
Source§

impl PartialEq for SubtitleTrack

Source§

fn eq(&self, other: &SubtitleTrack) -> bool

Tests for self and other values to be equal, and is used by ==.
1.0.0 (const: unstable) · Source§

fn ne(&self, other: &Rhs) -> bool

Tests for !=. The default implementation is almost always sufficient, +and should not be overridden without very good reason.
Source§

impl Serialize for SubtitleTrack

Source§

fn serialize<__S>(&self, __serializer: __S) -> Result<__S::Ok, __S::Error>
where + __S: Serializer,

Serialize this value into the given Serde serializer. Read more
Source§

impl Type for SubtitleTrack

Source§

fn inline(type_map: &mut TypeCollection, generics: Generics<'_>) -> DataType

Returns the definition of a type using the provided generics. Read more
Source§

fn reference(type_map: &mut TypeCollection, generics: &[DataType]) -> Reference

Generates a datatype corresponding to a reference to this type, +as determined by its category. Getting a reference to a type implies that +it should belong in the type map (since it has to be referenced from somewhere), +so the output of definition will be put into the type map.
Source§

impl Flatten for SubtitleTrack

Source§

impl StructuralPartialEq for SubtitleTrack

Auto Trait Implementations§

Blanket Implementations§

Source§

impl<T> Any for T
where + T: 'static + ?Sized,

Source§

fn type_id(&self) -> TypeId

Gets the TypeId of self. Read more
Source§

impl<T> Borrow<T> for T
where + T: ?Sized,

Source§

fn borrow(&self) -> &T

Immutably borrows from an owned value. Read more
Source§

impl<T> BorrowMut<T> for T
where + T: ?Sized,

Source§

fn borrow_mut(&mut self) -> &mut T

Mutably borrows from an owned value. Read more
Source§

impl<T> CloneToUninit for T
where + T: Clone,

Source§

unsafe fn clone_to_uninit(&self, dest: *mut u8)

🔬This is a nightly-only experimental API. (clone_to_uninit)
Performs copy-assignment from self to dest. Read more
§

impl<'de, D, R> CommandArg<'de, R> for D
where + D: Deserialize<'de>, + R: Runtime,

§

fn from_command(command: CommandItem<'de, R>) -> Result<D, InvokeError>

Derives an instance of Self from the [CommandItem]. Read more
Source§

impl<T> From<T> for T

Source§

fn from(t: T) -> T

Returns the argument unchanged.

+
§

impl<T> FunctionArg for T
where + T: Type,

§

fn to_datatype(type_map: &mut TypeCollection) -> Option<DataType>

Gets the type of an argument as a [DataType]. Read more
§

impl<T> Instrument for T

§

fn instrument(self, span: Span) -> Instrumented<Self>

Instruments this type with the provided [Span], returning an +Instrumented wrapper. Read more
§

fn in_current_span(self) -> Instrumented<Self>

Instruments this type with the current Span, returning an +Instrumented wrapper. Read more
Source§

impl<T, U> Into<U> for T
where + U: From<T>,

Source§

fn into(self) -> U

Calls U::from(self).

+

That is, this conversion is whatever the implementation of +From<T> for U chooses to do.

+
§

impl<T> IpcResponse for T
where + T: Serialize,

§

fn body(self) -> Result<InvokeResponseBody, Error>

Resolve the IPC response body.
§

impl<T> PolicyExt for T
where + T: ?Sized,

§

fn and<P, B, E>(self, other: P) -> And<T, P>
where + T: Policy<B, E>, + P: Policy<B, E>,

Create a new Policy that returns [Action::Follow] only if self and other return +Action::Follow. Read more
§

fn or<P, B, E>(self, other: P) -> Or<T, P>
where + T: Policy<B, E>, + P: Policy<B, E>,

Create a new Policy that returns [Action::Follow] if either self or other returns +Action::Follow. Read more
Source§

impl<T> Same for T

Source§

type Output = T

Should always be Self
§

impl<T> ScopeObject for T
where + T: Send + Sync + Debug + DeserializeOwned + 'static,

§

type Error = Error

The error type.
§

fn deserialize<R>( + _app: &AppHandle<R>, + raw: Value, +) -> Result<T, <T as ScopeObject>::Error>
where + R: Runtime,

Deserialize the raw scope value.
Source§

impl<T> Serialize for T
where + T: Serialize + ?Sized,

Source§

fn erased_serialize(&self, serializer: &mut dyn Serializer) -> Result<(), Error>

Source§

fn do_erased_serialize( + &self, + serializer: &mut dyn Serializer, +) -> Result<(), ErrorImpl>

Source§

impl<T> ToOwned for T
where + T: Clone,

Source§

type Owned = T

The resulting type after obtaining ownership.
Source§

fn to_owned(&self) -> T

Creates owned data from borrowed data, usually by cloning. Read more
Source§

fn clone_into(&self, target: &mut T)

Uses borrowed data to replace owned data, usually by cloning. Read more
Source§

impl<T, U> TryFrom<U> for T
where + U: Into<T>,

Source§

type Error = Infallible

The type returned in the event of a conversion error.
Source§

fn try_from(value: U) -> Result<T, <T as TryFrom<U>>::Error>

Performs the conversion.
Source§

impl<T, U> TryInto<U> for T
where + U: TryFrom<T>,

Source§

type Error = <U as TryFrom<T>>::Error

The type returned in the event of a conversion error.
Source§

fn try_into(self) -> Result<U, <U as TryFrom<T>>::Error>

Performs the conversion.
§

impl<V, T> VZip<V> for T
where + V: MultiLane<T>,

§

fn vzip(self) -> V

§

impl<T> WithSubscriber for T

§

fn with_subscriber<S>(self, subscriber: S) -> WithDispatch<Self>
where + S: Into<Dispatch>,

Attaches the provided Subscriber to this type, returning a +[WithDispatch] wrapper. Read more
§

fn with_current_subscriber(self) -> WithDispatch<Self>

Attaches the current default Subscriber to this type, returning a +[WithDispatch] wrapper. Read more
Source§

impl<T> DeserializeOwned for T
where + T: for<'de> Deserialize<'de>,

§

impl<T> UserEvent for T
where + T: Debug + Clone + Send + 'static,

\ No newline at end of file diff --git a/api/jellytau_lib/player/mpv_backend/constant.NORMALIZE_REF_LUFS.html b/api/jellytau_lib/player/mpv_backend/constant.NORMALIZE_REF_LUFS.html new file mode 100644 index 00000000..fd9bdb71 --- /dev/null +++ b/api/jellytau_lib/player/mpv_backend/constant.NORMALIZE_REF_LUFS.html @@ -0,0 +1,2 @@ +NORMALIZE_REF_LUFS in jellytau_lib::player::mpv_backend - RustSkip to main content

NORMALIZE_REF_LUFS

Constant NORMALIZE_REF_LUFS 

Source
const NORMALIZE_REF_LUFS: f32 = -14.0;
Expand description

Reference loudness the peak table is anchored at (Normal preset, −14 LUFS).

+
\ No newline at end of file diff --git a/api/jellytau_lib/player/mpv_backend/constant.NORMALIZE_REF_PEAK.html b/api/jellytau_lib/player/mpv_backend/constant.NORMALIZE_REF_PEAK.html new file mode 100644 index 00000000..373a3262 --- /dev/null +++ b/api/jellytau_lib/player/mpv_backend/constant.NORMALIZE_REF_PEAK.html @@ -0,0 +1,3 @@ +NORMALIZE_REF_PEAK in jellytau_lib::player::mpv_backend - RustSkip to main content

NORMALIZE_REF_PEAK

Constant NORMALIZE_REF_PEAK 

Source
const NORMALIZE_REF_PEAK: f32 = 0.87;
Expand description

Reference peak (dynaudnorm p, linear amplitude) for the default +VolumeLevel::Normal (−14 LUFS) target, leaving −1.2 dB of headroom.

+
\ No newline at end of file diff --git a/api/jellytau_lib/player/mpv_backend/fn.build_af_filter.html b/api/jellytau_lib/player/mpv_backend/fn.build_af_filter.html new file mode 100644 index 00000000..8d17fdb0 --- /dev/null +++ b/api/jellytau_lib/player/mpv_backend/fn.build_af_filter.html @@ -0,0 +1,7 @@ +build_af_filter in jellytau_lib::player::mpv_backend - RustSkip to main content

build_af_filter

Function build_af_filter 

Source
fn build_af_filter(settings: &AudioSettings) -> String
Expand description

Build the full MPV af (audio filter) value from the audio settings.

+

Combines the equalizer peaking bands and the loudness-normalization filter +into a single lavfi graph, because MPV exposes one af property. The +normalizer runs after the EQ so it levels the post-EQ signal. Returns an +empty string when neither feature contributes a filter, which clears af.

+

TRACES: UR-027, UR-033 | IR-020, DR-036

+
\ No newline at end of file diff --git a/api/jellytau_lib/player/mpv_backend/fn.detect_audio_system.html b/api/jellytau_lib/player/mpv_backend/fn.detect_audio_system.html new file mode 100644 index 00000000..d64cde92 --- /dev/null +++ b/api/jellytau_lib/player/mpv_backend/fn.detect_audio_system.html @@ -0,0 +1,2 @@ +detect_audio_system in jellytau_lib::player::mpv_backend - RustSkip to main content

detect_audio_system

Function detect_audio_system 

Source
fn detect_audio_system() -> String
Expand description

Detect which audio system is available on the system

+
\ No newline at end of file diff --git a/api/jellytau_lib/player/mpv_backend/fn.eq_filter_entries.html b/api/jellytau_lib/player/mpv_backend/fn.eq_filter_entries.html new file mode 100644 index 00000000..0b01760b --- /dev/null +++ b/api/jellytau_lib/player/mpv_backend/fn.eq_filter_entries.html @@ -0,0 +1,8 @@ +eq_filter_entries in jellytau_lib::player::mpv_backend - RustSkip to main content

eq_filter_entries

Function eq_filter_entries 

Source
fn eq_filter_entries(enabled: bool, bands: &[f32]) -> Vec<String>
Expand description

Peaking-EQ filter entries (unwrapped), one ffmpeg equalizer (two-pole +peaking) per band with a non-zero gain, e.g. +equalizer=f=31:width_type=o:width=1:g=5. Returns an empty vec when the EQ +is disabled or every gain is ~0. Gains are assumed already normalised by +AudioSettings::with_equalizer_normalised; bands beyond EQ_BANDS are +ignored.

+

TRACES: UR-027 | IR-020

+
\ No newline at end of file diff --git a/api/jellytau_lib/player/mpv_backend/fn.get_stream_url.html b/api/jellytau_lib/player/mpv_backend/fn.get_stream_url.html new file mode 100644 index 00000000..108cbf5b --- /dev/null +++ b/api/jellytau_lib/player/mpv_backend/fn.get_stream_url.html @@ -0,0 +1,2 @@ +get_stream_url in jellytau_lib::player::mpv_backend - RustSkip to main content

get_stream_url

Function get_stream_url 

Source
fn get_stream_url(media: &MediaItem) -> String
Expand description

Helper to get stream URL from MediaItem

+
\ No newline at end of file diff --git a/api/jellytau_lib/player/mpv_backend/fn.normalize_filter_entry.html b/api/jellytau_lib/player/mpv_backend/fn.normalize_filter_entry.html new file mode 100644 index 00000000..200b9484 --- /dev/null +++ b/api/jellytau_lib/player/mpv_backend/fn.normalize_filter_entry.html @@ -0,0 +1,12 @@ +normalize_filter_entry in jellytau_lib::player::mpv_backend - RustSkip to main content

normalize_filter_entry

Function normalize_filter_entry 

Source
fn normalize_filter_entry(enabled: bool, level: VolumeLevel) -> Option<String>
Expand description

The loudness-normalization filter entry (unwrapped), or None when +normalization is disabled. Uses ffmpeg’s dynaudnorm, a gentle real-time +dynamic normalizer that avoids the gain “pumping” loudnorm’s single-pass +mode can produce on very dynamic material.

+

dynaudnorm targets a peak amplitude (p, linear 0–1), not a LUFS value, +so the Loud/Normal/Quiet presets become approximate: each preset’s LUFS +offset from the Normal reference is applied as a dB offset to the reference +peak, preserving the Loud > Normal > Quiet ordering. g=15 (gaussian window +size) further smooths gain changes; the peak is clamped to a safe (0, 0.99] +so loud presets never request full-scale.

+

TRACES: UR-033 | DR-036

+
\ No newline at end of file diff --git a/api/jellytau_lib/player/mpv_backend/index.html b/api/jellytau_lib/player/mpv_backend/index.html new file mode 100644 index 00000000..0c2f5c26 --- /dev/null +++ b/api/jellytau_lib/player/mpv_backend/index.html @@ -0,0 +1,10 @@ +jellytau_lib::player::mpv_backend - RustSkip to main content

Module mpv_backend

Module mpv_backend 

Source

Structs§

InternalState 🔒
MpvBackend
MPV-based player backend for Linux

Constants§

NORMALIZE_REF_LUFS 🔒
Reference loudness the peak table is anchored at (Normal preset, −14 LUFS).
NORMALIZE_REF_PEAK 🔒
Reference peak (dynaudnorm p, linear amplitude) for the default +VolumeLevel::Normal (−14 LUFS) target, leaving −1.2 dB of headroom.

Functions§

build_af_filter 🔒
Build the full MPV af (audio filter) value from the audio settings.
detect_audio_system 🔒
Detect which audio system is available on the system
eq_filter_entries 🔒
Peaking-EQ filter entries (unwrapped), one ffmpeg equalizer (two-pole +peaking) per band with a non-zero gain, e.g. +equalizer=f=31:width_type=o:width=1:g=5. Returns an empty vec when the EQ +is disabled or every gain is ~0. Gains are assumed already normalised by +AudioSettings::with_equalizer_normalised; bands beyond EQ_BANDS are +ignored.
get_stream_url 🔒
Helper to get stream URL from MediaItem
normalize_filter_entry 🔒
The loudness-normalization filter entry (unwrapped), or None when +normalization is disabled. Uses ffmpeg’s dynaudnorm, a gentle real-time +dynamic normalizer that avoids the gain “pumping” loudnorm’s single-pass +mode can produce on very dynamic material.
\ No newline at end of file diff --git a/api/jellytau_lib/player/mpv_backend/sidebar-items.js b/api/jellytau_lib/player/mpv_backend/sidebar-items.js new file mode 100644 index 00000000..0b25d701 --- /dev/null +++ b/api/jellytau_lib/player/mpv_backend/sidebar-items.js @@ -0,0 +1 @@ +window.SIDEBAR_ITEMS = {"constant":["NORMALIZE_REF_LUFS","NORMALIZE_REF_PEAK"],"fn":["build_af_filter","detect_audio_system","eq_filter_entries","get_stream_url","normalize_filter_entry"],"struct":["InternalState","MpvBackend"]}; \ No newline at end of file diff --git a/api/jellytau_lib/player/mpv_backend/struct.InternalState.html b/api/jellytau_lib/player/mpv_backend/struct.InternalState.html new file mode 100644 index 00000000..fdaa173e --- /dev/null +++ b/api/jellytau_lib/player/mpv_backend/struct.InternalState.html @@ -0,0 +1,27 @@ +InternalState in jellytau_lib::player::mpv_backend - RustSkip to main content

InternalState

Struct InternalState 

Source
struct InternalState {
+    current_media: Option<MediaItem>,
+    volume: f32,
+}

Fields§

§current_media: Option<MediaItem>§volume: f32

Auto Trait Implementations§

Blanket Implementations§

Source§

impl<T> Any for T
where + T: 'static + ?Sized,

Source§

fn type_id(&self) -> TypeId

Gets the TypeId of self. Read more
Source§

impl<T> Borrow<T> for T
where + T: ?Sized,

Source§

fn borrow(&self) -> &T

Immutably borrows from an owned value. Read more
Source§

impl<T> BorrowMut<T> for T
where + T: ?Sized,

Source§

fn borrow_mut(&mut self) -> &mut T

Mutably borrows from an owned value. Read more
Source§

impl<T> From<T> for T

Source§

fn from(t: T) -> T

Returns the argument unchanged.

+
§

impl<T> Instrument for T

§

fn instrument(self, span: Span) -> Instrumented<Self>

Instruments this type with the provided [Span], returning an +Instrumented wrapper. Read more
§

fn in_current_span(self) -> Instrumented<Self>

Instruments this type with the current Span, returning an +Instrumented wrapper. Read more
Source§

impl<T, U> Into<U> for T
where + U: From<T>,

Source§

fn into(self) -> U

Calls U::from(self).

+

That is, this conversion is whatever the implementation of +From<T> for U chooses to do.

+
§

impl<T> PolicyExt for T
where + T: ?Sized,

§

fn and<P, B, E>(self, other: P) -> And<T, P>
where + T: Policy<B, E>, + P: Policy<B, E>,

Create a new Policy that returns [Action::Follow] only if self and other return +Action::Follow. Read more
§

fn or<P, B, E>(self, other: P) -> Or<T, P>
where + T: Policy<B, E>, + P: Policy<B, E>,

Create a new Policy that returns [Action::Follow] if either self or other returns +Action::Follow. Read more
Source§

impl<T> Same for T

Source§

type Output = T

Should always be Self
Source§

impl<T, U> TryFrom<U> for T
where + U: Into<T>,

Source§

type Error = Infallible

The type returned in the event of a conversion error.
Source§

fn try_from(value: U) -> Result<T, <T as TryFrom<U>>::Error>

Performs the conversion.
Source§

impl<T, U> TryInto<U> for T
where + U: TryFrom<T>,

Source§

type Error = <U as TryFrom<T>>::Error

The type returned in the event of a conversion error.
Source§

fn try_into(self) -> Result<U, <U as TryFrom<T>>::Error>

Performs the conversion.
§

impl<V, T> VZip<V> for T
where + V: MultiLane<T>,

§

fn vzip(self) -> V

§

impl<T> WithSubscriber for T

§

fn with_subscriber<S>(self, subscriber: S) -> WithDispatch<Self>
where + S: Into<Dispatch>,

Attaches the provided Subscriber to this type, returning a +[WithDispatch] wrapper. Read more
§

fn with_current_subscriber(self) -> WithDispatch<Self>

Attaches the current default Subscriber to this type, returning a +[WithDispatch] wrapper. Read more
\ No newline at end of file diff --git a/api/jellytau_lib/player/mpv_backend/struct.MpvBackend.html b/api/jellytau_lib/player/mpv_backend/struct.MpvBackend.html new file mode 100644 index 00000000..97cff321 --- /dev/null +++ b/api/jellytau_lib/player/mpv_backend/struct.MpvBackend.html @@ -0,0 +1,69 @@ +MpvBackend in jellytau_lib::player::mpv_backend - RustSkip to main content

MpvBackend

Struct MpvBackend 

Source
pub struct MpvBackend {
+    mpv: Arc<Mpv>,
+    state: Arc<Mutex<InternalState>>,
+    event_emitter: Option<Arc<dyn PlayerEventEmitter>>,
+    audio_settings: AudioSettings,
+    playback_reporter: Arc<Mutex<Option<PlaybackReporter>>>,
+    position_throttler: Arc<EventThrottler>,
+    last_seek_time: Arc<AtomicU64>,
+    observed: Arc<Mutex<ObservedTime>>,
+}
Expand description

MPV-based player backend for Linux

+

Uses libmpv for audio playback with full control over playback state, +position tracking, and event handling.

+

Fields§

§mpv: Arc<Mpv>§state: Arc<Mutex<InternalState>>§event_emitter: Option<Arc<dyn PlayerEventEmitter>>§audio_settings: AudioSettings§playback_reporter: Arc<Mutex<Option<PlaybackReporter>>>§position_throttler: Arc<EventThrottler>§last_seek_time: Arc<AtomicU64>§observed: Arc<Mutex<ObservedTime>>

Last position/duration seen while a file was loaded.

+

time-pos and duration are live properties of the loaded file: at +EOF MPV unloads it and both stop resolving, so reading them straight +through reported 0.0 / unknown exactly when end-of-file handling needed to +know where playback reached. See ObservedTime.

+

Implementations§

Source§

impl MpvBackend

Source

pub fn new( + event_emitter: Option<Arc<dyn PlayerEventEmitter>>, + playback_reporter: Arc<TokioMutex<Option<PlaybackReporter>>>, + position_throttler: Arc<EventThrottler>, +) -> Result<Self, PlayerError>

Create a new MPV backend

+
Source

fn start_event_loop(&self)

Start the MPV event loop in a background thread

+

Trait Implementations§

Source§

impl Drop for MpvBackend

Source§

fn drop(&mut self)

Executes the destructor for this type. Read more
Source§

fn pin_drop(self: Pin<&mut Self>)

🔬This is a nightly-only experimental API. (pin_ergonomics)
Execute the destructor for this type, but different to Drop::drop, it requires self +to be pinned. Read more
Source§

impl PlayerBackend for MpvBackend

Source§

fn position(&self) -> f64

Current position — the live time-pos, or the last one observed while a +file was loaded.

+

The fallback is the point: time-pos is a property of the loaded file, +so at EOF it stops resolving and a bare unwrap_or(0.0) reported 0:00 at +exactly the moment end-of-file handling asks where playback reached.

+

TRACES: UR-005 | DR-130 | UT-121

+
Source§

fn duration(&self) -> Option<f64>

Total duration — live, or the last one observed. Unloaded at EOF for the +same reason as position.

+

TRACES: UR-005 | DR-130 | UT-121

+
Source§

fn load(&mut self, media: &MediaItem) -> Result<(), PlayerError>

Load a media item for playback +TRACES: UR-005
Source§

fn play(&mut self) -> Result<(), PlayerError>

Start or resume playback +TRACES: UR-005
Source§

fn pause(&mut self) -> Result<(), PlayerError>

Pause playback +TRACES: UR-005
Source§

fn stop(&mut self) -> Result<(), PlayerError>

Stop playback and unload media +TRACES: UR-005
Source§

fn seek(&mut self, position: f64) -> Result<(), PlayerError>

Seek to a position in seconds +TRACES: UR-005
Source§

fn set_volume(&mut self, volume: f32) -> Result<(), PlayerError>

Set volume (0.0 - 1.0) +TRACES: UR-016
Source§

fn state(&self) -> PlayerState

Get current player state
Source§

fn volume(&self) -> f32

Get current volume
Source§

fn set_audio_settings( + &mut self, + settings: &AudioSettings, +) -> Result<(), PlayerError>

Apply audio settings (crossfade, gapless, normalization) Read more
Source§

fn audio_settings(&self) -> AudioSettings

Get current audio settings Read more
Source§

fn set_audio_track(&mut self, _stream_index: i32) -> Result<(), PlayerError>

Set the active audio track by stream index Read more
Source§

fn set_subtitle_track( + &mut self, + _stream_index: Option<i32>, +) -> Result<(), PlayerError>

Set the active subtitle track by stream index (None to disable subtitles) Read more

Auto Trait Implementations§

Blanket Implementations§

Source§

impl<T> Any for T
where + T: 'static + ?Sized,

Source§

fn type_id(&self) -> TypeId

Gets the TypeId of self. Read more
Source§

impl<T> Borrow<T> for T
where + T: ?Sized,

Source§

fn borrow(&self) -> &T

Immutably borrows from an owned value. Read more
Source§

impl<T> BorrowMut<T> for T
where + T: ?Sized,

Source§

fn borrow_mut(&mut self) -> &mut T

Mutably borrows from an owned value. Read more
Source§

impl<T> From<T> for T

Source§

fn from(t: T) -> T

Returns the argument unchanged.

+
§

impl<T> Instrument for T

§

fn instrument(self, span: Span) -> Instrumented<Self>

Instruments this type with the provided [Span], returning an +Instrumented wrapper. Read more
§

fn in_current_span(self) -> Instrumented<Self>

Instruments this type with the current Span, returning an +Instrumented wrapper. Read more
Source§

impl<T, U> Into<U> for T
where + U: From<T>,

Source§

fn into(self) -> U

Calls U::from(self).

+

That is, this conversion is whatever the implementation of +From<T> for U chooses to do.

+
§

impl<T> PolicyExt for T
where + T: ?Sized,

§

fn and<P, B, E>(self, other: P) -> And<T, P>
where + T: Policy<B, E>, + P: Policy<B, E>,

Create a new Policy that returns [Action::Follow] only if self and other return +Action::Follow. Read more
§

fn or<P, B, E>(self, other: P) -> Or<T, P>
where + T: Policy<B, E>, + P: Policy<B, E>,

Create a new Policy that returns [Action::Follow] if either self or other returns +Action::Follow. Read more
Source§

impl<T> Same for T

Source§

type Output = T

Should always be Self
Source§

impl<T, U> TryFrom<U> for T
where + U: Into<T>,

Source§

type Error = Infallible

The type returned in the event of a conversion error.
Source§

fn try_from(value: U) -> Result<T, <T as TryFrom<U>>::Error>

Performs the conversion.
Source§

impl<T, U> TryInto<U> for T
where + U: TryFrom<T>,

Source§

type Error = <U as TryFrom<T>>::Error

The type returned in the event of a conversion error.
Source§

fn try_into(self) -> Result<U, <U as TryFrom<T>>::Error>

Performs the conversion.
§

impl<V, T> VZip<V> for T
where + V: MultiLane<T>,

§

fn vzip(self) -> V

§

impl<T> WithSubscriber for T

§

fn with_subscriber<S>(self, subscriber: S) -> WithDispatch<Self>
where + S: Into<Dispatch>,

Attaches the provided Subscriber to this type, returning a +[WithDispatch] wrapper. Read more
§

fn with_current_subscriber(self) -> WithDispatch<Self>

Attaches the current default Subscriber to this type, returning a +[WithDispatch] wrapper. Read more
\ No newline at end of file diff --git a/api/jellytau_lib/player/queue/enum.AddPosition.html b/api/jellytau_lib/player/queue/enum.AddPosition.html new file mode 100644 index 00000000..860fea3a --- /dev/null +++ b/api/jellytau_lib/player/queue/enum.AddPosition.html @@ -0,0 +1,40 @@ +AddPosition in jellytau_lib::player::queue - RustSkip to main content

AddPosition

Enum AddPosition 

Source
pub enum AddPosition {
+    Next,
+    End,
+}
Expand description

Position to add items to the queue

+

Variants§

§

Next

Add immediately after current item

+
§

End

Add at the end of the queue

+

Trait Implementations§

Source§

impl Clone for AddPosition

Source§

fn clone(&self) -> AddPosition

Returns a duplicate of the value. Read more
1.0.0 (const: unstable) · Source§

fn clone_from(&mut self, source: &Self)

Performs copy-assignment from source. Read more
Source§

impl Debug for AddPosition

Source§

fn fmt(&self, f: &mut Formatter<'_>) -> Result

Formats the value using the given formatter. Read more
Source§

impl PartialEq for AddPosition

Source§

fn eq(&self, other: &AddPosition) -> bool

Tests for self and other values to be equal, and is used by ==.
1.0.0 (const: unstable) · Source§

fn ne(&self, other: &Rhs) -> bool

Tests for !=. The default implementation is almost always sufficient, +and should not be overridden without very good reason.
Source§

impl Copy for AddPosition

Source§

impl Eq for AddPosition

Source§

impl StructuralPartialEq for AddPosition

Auto Trait Implementations§

Blanket Implementations§

Source§

impl<T> Any for T
where + T: 'static + ?Sized,

Source§

fn type_id(&self) -> TypeId

Gets the TypeId of self. Read more
Source§

impl<T> Borrow<T> for T
where + T: ?Sized,

Source§

fn borrow(&self) -> &T

Immutably borrows from an owned value. Read more
Source§

impl<T> BorrowMut<T> for T
where + T: ?Sized,

Source§

fn borrow_mut(&mut self) -> &mut T

Mutably borrows from an owned value. Read more
Source§

impl<T> CloneToUninit for T
where + T: Clone,

Source§

unsafe fn clone_to_uninit(&self, dest: *mut u8)

🔬This is a nightly-only experimental API. (clone_to_uninit)
Performs copy-assignment from self to dest. Read more
§

impl<Q, K> Equivalent<K> for Q
where + Q: Eq + ?Sized, + K: Borrow<Q> + ?Sized,

§

fn equivalent(&self, key: &K) -> bool

Checks if this value is equivalent to the given key. Read more
§

impl<Q, K> Equivalent<K> for Q
where + Q: Eq + ?Sized, + K: Borrow<Q> + ?Sized,

§

fn equivalent(&self, key: &K) -> bool

Compare self to key and return true if they are equal.
§

impl<Q, K> Equivalent<K> for Q
where + Q: Eq + ?Sized, + K: Borrow<Q> + ?Sized,

§

fn equivalent(&self, key: &K) -> bool

Checks if this value is equivalent to the given key. Read more
Source§

impl<T> From<T> for T

Source§

fn from(t: T) -> T

Returns the argument unchanged.

+
§

impl<T> Instrument for T

§

fn instrument(self, span: Span) -> Instrumented<Self>

Instruments this type with the provided [Span], returning an +Instrumented wrapper. Read more
§

fn in_current_span(self) -> Instrumented<Self>

Instruments this type with the current Span, returning an +Instrumented wrapper. Read more
Source§

impl<T, U> Into<U> for T
where + U: From<T>,

Source§

fn into(self) -> U

Calls U::from(self).

+

That is, this conversion is whatever the implementation of +From<T> for U chooses to do.

+
§

impl<T> PolicyExt for T
where + T: ?Sized,

§

fn and<P, B, E>(self, other: P) -> And<T, P>
where + T: Policy<B, E>, + P: Policy<B, E>,

Create a new Policy that returns [Action::Follow] only if self and other return +Action::Follow. Read more
§

fn or<P, B, E>(self, other: P) -> Or<T, P>
where + T: Policy<B, E>, + P: Policy<B, E>,

Create a new Policy that returns [Action::Follow] if either self or other returns +Action::Follow. Read more
Source§

impl<T> Same for T

Source§

type Output = T

Should always be Self
Source§

impl<T> ToOwned for T
where + T: Clone,

Source§

type Owned = T

The resulting type after obtaining ownership.
Source§

fn to_owned(&self) -> T

Creates owned data from borrowed data, usually by cloning. Read more
Source§

fn clone_into(&self, target: &mut T)

Uses borrowed data to replace owned data, usually by cloning. Read more
Source§

impl<T, U> TryFrom<U> for T
where + U: Into<T>,

Source§

type Error = Infallible

The type returned in the event of a conversion error.
Source§

fn try_from(value: U) -> Result<T, <T as TryFrom<U>>::Error>

Performs the conversion.
Source§

impl<T, U> TryInto<U> for T
where + U: TryFrom<T>,

Source§

type Error = <U as TryFrom<T>>::Error

The type returned in the event of a conversion error.
Source§

fn try_into(self) -> Result<U, <U as TryFrom<T>>::Error>

Performs the conversion.
§

impl<V, T> VZip<V> for T
where + V: MultiLane<T>,

§

fn vzip(self) -> V

§

impl<T> WithSubscriber for T

§

fn with_subscriber<S>(self, subscriber: S) -> WithDispatch<Self>
where + S: Into<Dispatch>,

Attaches the provided Subscriber to this type, returning a +[WithDispatch] wrapper. Read more
§

fn with_current_subscriber(self) -> WithDispatch<Self>

Attaches the current default Subscriber to this type, returning a +[WithDispatch] wrapper. Read more
§

impl<T> UserEvent for T
where + T: Debug + Clone + Send + 'static,

\ No newline at end of file diff --git a/api/jellytau_lib/player/queue/enum.RepeatMode.html b/api/jellytau_lib/player/queue/enum.RepeatMode.html new file mode 100644 index 00000000..13304133 --- /dev/null +++ b/api/jellytau_lib/player/queue/enum.RepeatMode.html @@ -0,0 +1,63 @@ +RepeatMode in jellytau_lib::player::queue - RustSkip to main content

RepeatMode

Enum RepeatMode 

Source
pub enum RepeatMode {
+    Off,
+    All,
+    One,
+}
Expand description

Repeat mode for the queue

+

TRACES: UR-005 | DR-005

+

Variants§

§

Off

§

All

§

One

Trait Implementations§

Source§

impl Clone for RepeatMode

Source§

fn clone(&self) -> RepeatMode

Returns a duplicate of the value. Read more
1.0.0 (const: unstable) · Source§

fn clone_from(&mut self, source: &Self)

Performs copy-assignment from source. Read more
Source§

impl Debug for RepeatMode

Source§

fn fmt(&self, f: &mut Formatter<'_>) -> Result

Formats the value using the given formatter. Read more
Source§

impl Default for RepeatMode

Source§

fn default() -> RepeatMode

Returns the “default value” for a type. Read more
Source§

impl<'de> Deserialize<'de> for RepeatMode

Source§

fn deserialize<__D>(__deserializer: __D) -> Result<Self, __D::Error>
where + __D: Deserializer<'de>,

Deserialize this value from the given Serde deserializer. Read more
Source§

impl NamedType for RepeatMode

Source§

fn sid() -> SpectaID

Source§

fn named_data_type( + type_map: &mut TypeCollection, + generics: &[DataType], +) -> NamedDataType

this is equivalent to [Type::inline] but returns a [NamedDataType] instead.
Source§

fn definition_named_data_type(type_map: &mut TypeCollection) -> NamedDataType

this is equivalent to [Type::definition] but returns a [NamedDataType] instead.
Source§

impl PartialEq for RepeatMode

Source§

fn eq(&self, other: &RepeatMode) -> bool

Tests for self and other values to be equal, and is used by ==.
1.0.0 (const: unstable) · Source§

fn ne(&self, other: &Rhs) -> bool

Tests for !=. The default implementation is almost always sufficient, +and should not be overridden without very good reason.
Source§

impl Serialize for RepeatMode

Source§

fn serialize<__S>(&self, __serializer: __S) -> Result<__S::Ok, __S::Error>
where + __S: Serializer,

Serialize this value into the given Serde serializer. Read more
Source§

impl Type for RepeatMode

Source§

fn inline(type_map: &mut TypeCollection, generics: Generics<'_>) -> DataType

Returns the definition of a type using the provided generics. Read more
Source§

fn reference(type_map: &mut TypeCollection, generics: &[DataType]) -> Reference

Generates a datatype corresponding to a reference to this type, +as determined by its category. Getting a reference to a type implies that +it should belong in the type map (since it has to be referenced from somewhere), +so the output of definition will be put into the type map.
Source§

impl Copy for RepeatMode

Source§

impl Eq for RepeatMode

Source§

impl StructuralPartialEq for RepeatMode

Auto Trait Implementations§

Blanket Implementations§

Source§

impl<T> Any for T
where + T: 'static + ?Sized,

Source§

fn type_id(&self) -> TypeId

Gets the TypeId of self. Read more
Source§

impl<T> Borrow<T> for T
where + T: ?Sized,

Source§

fn borrow(&self) -> &T

Immutably borrows from an owned value. Read more
Source§

impl<T> BorrowMut<T> for T
where + T: ?Sized,

Source§

fn borrow_mut(&mut self) -> &mut T

Mutably borrows from an owned value. Read more
Source§

impl<T> CloneToUninit for T
where + T: Clone,

Source§

unsafe fn clone_to_uninit(&self, dest: *mut u8)

🔬This is a nightly-only experimental API. (clone_to_uninit)
Performs copy-assignment from self to dest. Read more
§

impl<'de, D, R> CommandArg<'de, R> for D
where + D: Deserialize<'de>, + R: Runtime,

§

fn from_command(command: CommandItem<'de, R>) -> Result<D, InvokeError>

Derives an instance of Self from the [CommandItem]. Read more
§

impl<Q, K> Equivalent<K> for Q
where + Q: Eq + ?Sized, + K: Borrow<Q> + ?Sized,

§

fn equivalent(&self, key: &K) -> bool

Checks if this value is equivalent to the given key. Read more
§

impl<Q, K> Equivalent<K> for Q
where + Q: Eq + ?Sized, + K: Borrow<Q> + ?Sized,

§

fn equivalent(&self, key: &K) -> bool

Compare self to key and return true if they are equal.
§

impl<Q, K> Equivalent<K> for Q
where + Q: Eq + ?Sized, + K: Borrow<Q> + ?Sized,

§

fn equivalent(&self, key: &K) -> bool

Checks if this value is equivalent to the given key. Read more
Source§

impl<T> From<T> for T

Source§

fn from(t: T) -> T

Returns the argument unchanged.

+
§

impl<T> FunctionArg for T
where + T: Type,

§

fn to_datatype(type_map: &mut TypeCollection) -> Option<DataType>

Gets the type of an argument as a [DataType]. Read more
§

impl<T> Instrument for T

§

fn instrument(self, span: Span) -> Instrumented<Self>

Instruments this type with the provided [Span], returning an +Instrumented wrapper. Read more
§

fn in_current_span(self) -> Instrumented<Self>

Instruments this type with the current Span, returning an +Instrumented wrapper. Read more
Source§

impl<T, U> Into<U> for T
where + U: From<T>,

Source§

fn into(self) -> U

Calls U::from(self).

+

That is, this conversion is whatever the implementation of +From<T> for U chooses to do.

+
§

impl<T> IpcResponse for T
where + T: Serialize,

§

fn body(self) -> Result<InvokeResponseBody, Error>

Resolve the IPC response body.
§

impl<T> NoneValue for T
where + T: Default,

§

type NoneType = T

§

fn null_value() -> T

The none-equivalent value.
§

impl<T> PolicyExt for T
where + T: ?Sized,

§

fn and<P, B, E>(self, other: P) -> And<T, P>
where + T: Policy<B, E>, + P: Policy<B, E>,

Create a new Policy that returns [Action::Follow] only if self and other return +Action::Follow. Read more
§

fn or<P, B, E>(self, other: P) -> Or<T, P>
where + T: Policy<B, E>, + P: Policy<B, E>,

Create a new Policy that returns [Action::Follow] if either self or other returns +Action::Follow. Read more
Source§

impl<T> Same for T

Source§

type Output = T

Should always be Self
§

impl<T> ScopeObject for T
where + T: Send + Sync + Debug + DeserializeOwned + 'static,

§

type Error = Error

The error type.
§

fn deserialize<R>( + _app: &AppHandle<R>, + raw: Value, +) -> Result<T, <T as ScopeObject>::Error>
where + R: Runtime,

Deserialize the raw scope value.
Source§

impl<T> Serialize for T
where + T: Serialize + ?Sized,

Source§

fn erased_serialize(&self, serializer: &mut dyn Serializer) -> Result<(), Error>

Source§

fn do_erased_serialize( + &self, + serializer: &mut dyn Serializer, +) -> Result<(), ErrorImpl>

Source§

impl<T> ToOwned for T
where + T: Clone,

Source§

type Owned = T

The resulting type after obtaining ownership.
Source§

fn to_owned(&self) -> T

Creates owned data from borrowed data, usually by cloning. Read more
Source§

fn clone_into(&self, target: &mut T)

Uses borrowed data to replace owned data, usually by cloning. Read more
Source§

impl<T, U> TryFrom<U> for T
where + U: Into<T>,

Source§

type Error = Infallible

The type returned in the event of a conversion error.
Source§

fn try_from(value: U) -> Result<T, <T as TryFrom<U>>::Error>

Performs the conversion.
Source§

impl<T, U> TryInto<U> for T
where + U: TryFrom<T>,

Source§

type Error = <U as TryFrom<T>>::Error

The type returned in the event of a conversion error.
Source§

fn try_into(self) -> Result<U, <U as TryFrom<T>>::Error>

Performs the conversion.
§

impl<V, T> VZip<V> for T
where + V: MultiLane<T>,

§

fn vzip(self) -> V

§

impl<T> WithSubscriber for T

§

fn with_subscriber<S>(self, subscriber: S) -> WithDispatch<Self>
where + S: Into<Dispatch>,

Attaches the provided Subscriber to this type, returning a +[WithDispatch] wrapper. Read more
§

fn with_current_subscriber(self) -> WithDispatch<Self>

Attaches the current default Subscriber to this type, returning a +[WithDispatch] wrapper. Read more
Source§

impl<T> DeserializeOwned for T
where + T: for<'de> Deserialize<'de>,

§

impl<T> UserEvent for T
where + T: Debug + Clone + Send + 'static,

\ No newline at end of file diff --git a/api/jellytau_lib/player/queue/index.html b/api/jellytau_lib/player/queue/index.html new file mode 100644 index 00000000..861a98a2 --- /dev/null +++ b/api/jellytau_lib/player/queue/index.html @@ -0,0 +1 @@ +jellytau_lib::player::queue - RustSkip to main content

Module queue

Module queue 

Source

Structs§

QueueManager
Queue manager for playlist functionality

Enums§

AddPosition
Position to add items to the queue
RepeatMode
Repeat mode for the queue
\ No newline at end of file diff --git a/api/jellytau_lib/player/queue/sidebar-items.js b/api/jellytau_lib/player/queue/sidebar-items.js new file mode 100644 index 00000000..46b4b31f --- /dev/null +++ b/api/jellytau_lib/player/queue/sidebar-items.js @@ -0,0 +1 @@ +window.SIDEBAR_ITEMS = {"enum":["AddPosition","RepeatMode"],"struct":["QueueManager"]}; \ No newline at end of file diff --git a/api/jellytau_lib/player/queue/struct.QueueManager.html b/api/jellytau_lib/player/queue/struct.QueueManager.html new file mode 100644 index 00000000..38e2c77f --- /dev/null +++ b/api/jellytau_lib/player/queue/struct.QueueManager.html @@ -0,0 +1,108 @@ +QueueManager in jellytau_lib::player::queue - RustSkip to main content

QueueManager

Struct QueueManager 

Source
pub struct QueueManager {
+    items: Vec<MediaItem>,
+    current_index: Option<usize>,
+    shuffle: bool,
+    repeat: RepeatMode,
+    shuffle_order: Vec<usize>,
+    history: Vec<usize>,
+    context: QueueContext,
+}
Expand description

Queue manager for playlist functionality

+

TRACES: UR-005, UR-015 | DR-005, DR-020

+

Fields§

§items: Vec<MediaItem>

All items in the queue

+
§current_index: Option<usize>

Current item index

+
§shuffle: bool

Whether shuffle is enabled

+
§repeat: RepeatMode

Current repeat mode

+
§shuffle_order: Vec<usize>

Shuffled order of indices (used when shuffle is on)

+
§history: Vec<usize>

History of played indices (for going back with shuffle)

+
§context: QueueContext

Context for the queue (album, playlist, or custom) +Used for remote playback transfer to maintain album/playlist context

+

Implementations§

Source§

impl QueueManager

Source

pub fn new() -> Self

Source

pub fn items(&self) -> &[MediaItem]

Get all items in the queue

+
Source

pub fn items_mut(&mut self) -> &mut [MediaItem]

Mutable access to queue items.

+

Used to re-point streaming entries at a completed local download +without disturbing queue order, shuffle state, or the current index.

+
Source

pub fn current_index(&self) -> Option<usize>

Get the current item index

+
Source

pub fn current(&self) -> Option<&MediaItem>

Get the current item

+
Source

pub fn is_shuffle(&self) -> bool

Check if shuffle is enabled

+
Source

pub fn repeat_mode(&self) -> RepeatMode

Get the current repeat mode

+
Source

pub fn context(&self) -> &QueueContext

Get the current queue context (album, playlist, or custom)

+
Source

pub fn set_context(&mut self, context: QueueContext)

Set the queue context

+
Source

pub fn set_queue(&mut self, items: Vec<MediaItem>, start_index: usize)

Set the queue with new items (resets context to Custom)

+
Source

pub fn set_queue_with_context( + &mut self, + items: Vec<MediaItem>, + start_index: usize, + context: QueueContext, +)

Set the queue with new items and explicit context

+
Source

pub fn clear(&mut self)

Clear the queue entirely, returning it to the empty state.

+

Used when playback genuinely stops (sleep timer fires, or the queue ends +with repeat off) so the frontend’s currentQueueItem becomes null and +the mini player hides. History and shuffle order are reset too.

+
Source

pub fn add(&mut self, items: Vec<MediaItem>, position: AddPosition)

Add items to the queue

+
Source

pub fn remove(&mut self, index: usize) -> Option<MediaItem>

Remove an item from the queue

+
Source

pub fn next(&mut self) -> Option<&MediaItem>

Move to the next item

+
Source

pub fn previous(&mut self) -> Option<&MediaItem>

Move to the previous item

+
Source

pub fn skip_to(&mut self, index: usize) -> Option<&MediaItem>

Skip to a specific index

+
Source

pub fn toggle_shuffle(&mut self)

Toggle shuffle mode

+
Source

pub fn cycle_repeat(&mut self)

Cycle through repeat modes

+
Source

pub fn has_next(&self) -> bool

Check if there’s a next item available

+
Source

pub fn has_previous(&self) -> bool

Check if there’s a previous item available

+
Source

pub fn get_upcoming(&self, count: usize) -> Vec<&MediaItem>

Get the next N upcoming items (for preloading) +Returns items that will play after the current item, respecting shuffle order

+
Source

pub fn move_item(&mut self, from_index: usize, to_index: usize) -> bool

Move an item from one index to another

+
Source

pub fn update_current_stream_url(&mut self, new_url: String) -> bool

Update the stream URL of the current item (for transcoded seeking) +Returns true if the update was successful

+
Source

fn generate_shuffle_order( + &self, + length: usize, + start_index: Option<usize>, +) -> Vec<usize>

Generate a shuffle order, optionally starting from a specific index

+

Trait Implementations§

Source§

impl Clone for QueueManager

Source§

fn clone(&self) -> QueueManager

Returns a duplicate of the value. Read more
1.0.0 (const: unstable) · Source§

fn clone_from(&mut self, source: &Self)

Performs copy-assignment from source. Read more
Source§

impl Debug for QueueManager

Source§

fn fmt(&self, f: &mut Formatter<'_>) -> Result

Formats the value using the given formatter. Read more
Source§

impl Default for QueueManager

Source§

fn default() -> Self

Returns the “default value” for a type. Read more
Source§

impl<'de> Deserialize<'de> for QueueManager

Source§

fn deserialize<__D>(__deserializer: __D) -> Result<Self, __D::Error>
where + __D: Deserializer<'de>,

Deserialize this value from the given Serde deserializer. Read more
Source§

impl NamedType for QueueManager

Source§

fn sid() -> SpectaID

Source§

fn named_data_type( + type_map: &mut TypeCollection, + generics: &[DataType], +) -> NamedDataType

this is equivalent to [Type::inline] but returns a [NamedDataType] instead.
Source§

fn definition_named_data_type(type_map: &mut TypeCollection) -> NamedDataType

this is equivalent to [Type::definition] but returns a [NamedDataType] instead.
Source§

impl Serialize for QueueManager

Source§

fn serialize<__S>(&self, __serializer: __S) -> Result<__S::Ok, __S::Error>
where + __S: Serializer,

Serialize this value into the given Serde serializer. Read more
Source§

impl Type for QueueManager

Source§

fn inline(type_map: &mut TypeCollection, generics: Generics<'_>) -> DataType

Returns the definition of a type using the provided generics. Read more
Source§

fn reference(type_map: &mut TypeCollection, generics: &[DataType]) -> Reference

Generates a datatype corresponding to a reference to this type, +as determined by its category. Getting a reference to a type implies that +it should belong in the type map (since it has to be referenced from somewhere), +so the output of definition will be put into the type map.
Source§

impl Flatten for QueueManager

Auto Trait Implementations§

Blanket Implementations§

Source§

impl<T> Any for T
where + T: 'static + ?Sized,

Source§

fn type_id(&self) -> TypeId

Gets the TypeId of self. Read more
Source§

impl<T> Borrow<T> for T
where + T: ?Sized,

Source§

fn borrow(&self) -> &T

Immutably borrows from an owned value. Read more
Source§

impl<T> BorrowMut<T> for T
where + T: ?Sized,

Source§

fn borrow_mut(&mut self) -> &mut T

Mutably borrows from an owned value. Read more
Source§

impl<T> CloneToUninit for T
where + T: Clone,

Source§

unsafe fn clone_to_uninit(&self, dest: *mut u8)

🔬This is a nightly-only experimental API. (clone_to_uninit)
Performs copy-assignment from self to dest. Read more
§

impl<'de, D, R> CommandArg<'de, R> for D
where + D: Deserialize<'de>, + R: Runtime,

§

fn from_command(command: CommandItem<'de, R>) -> Result<D, InvokeError>

Derives an instance of Self from the [CommandItem]. Read more
Source§

impl<T> From<T> for T

Source§

fn from(t: T) -> T

Returns the argument unchanged.

+
§

impl<T> FunctionArg for T
where + T: Type,

§

fn to_datatype(type_map: &mut TypeCollection) -> Option<DataType>

Gets the type of an argument as a [DataType]. Read more
§

impl<T> Instrument for T

§

fn instrument(self, span: Span) -> Instrumented<Self>

Instruments this type with the provided [Span], returning an +Instrumented wrapper. Read more
§

fn in_current_span(self) -> Instrumented<Self>

Instruments this type with the current Span, returning an +Instrumented wrapper. Read more
Source§

impl<T, U> Into<U> for T
where + U: From<T>,

Source§

fn into(self) -> U

Calls U::from(self).

+

That is, this conversion is whatever the implementation of +From<T> for U chooses to do.

+
§

impl<T> IpcResponse for T
where + T: Serialize,

§

fn body(self) -> Result<InvokeResponseBody, Error>

Resolve the IPC response body.
§

impl<T> NoneValue for T
where + T: Default,

§

type NoneType = T

§

fn null_value() -> T

The none-equivalent value.
§

impl<T> PolicyExt for T
where + T: ?Sized,

§

fn and<P, B, E>(self, other: P) -> And<T, P>
where + T: Policy<B, E>, + P: Policy<B, E>,

Create a new Policy that returns [Action::Follow] only if self and other return +Action::Follow. Read more
§

fn or<P, B, E>(self, other: P) -> Or<T, P>
where + T: Policy<B, E>, + P: Policy<B, E>,

Create a new Policy that returns [Action::Follow] if either self or other returns +Action::Follow. Read more
Source§

impl<T> Same for T

Source§

type Output = T

Should always be Self
§

impl<T> ScopeObject for T
where + T: Send + Sync + Debug + DeserializeOwned + 'static,

§

type Error = Error

The error type.
§

fn deserialize<R>( + _app: &AppHandle<R>, + raw: Value, +) -> Result<T, <T as ScopeObject>::Error>
where + R: Runtime,

Deserialize the raw scope value.
Source§

impl<T> Serialize for T
where + T: Serialize + ?Sized,

Source§

fn erased_serialize(&self, serializer: &mut dyn Serializer) -> Result<(), Error>

Source§

fn do_erased_serialize( + &self, + serializer: &mut dyn Serializer, +) -> Result<(), ErrorImpl>

Source§

impl<T> ToOwned for T
where + T: Clone,

Source§

type Owned = T

The resulting type after obtaining ownership.
Source§

fn to_owned(&self) -> T

Creates owned data from borrowed data, usually by cloning. Read more
Source§

fn clone_into(&self, target: &mut T)

Uses borrowed data to replace owned data, usually by cloning. Read more
Source§

impl<T, U> TryFrom<U> for T
where + U: Into<T>,

Source§

type Error = Infallible

The type returned in the event of a conversion error.
Source§

fn try_from(value: U) -> Result<T, <T as TryFrom<U>>::Error>

Performs the conversion.
Source§

impl<T, U> TryInto<U> for T
where + U: TryFrom<T>,

Source§

type Error = <U as TryFrom<T>>::Error

The type returned in the event of a conversion error.
Source§

fn try_into(self) -> Result<U, <U as TryFrom<T>>::Error>

Performs the conversion.
§

impl<V, T> VZip<V> for T
where + V: MultiLane<T>,

§

fn vzip(self) -> V

§

impl<T> WithSubscriber for T

§

fn with_subscriber<S>(self, subscriber: S) -> WithDispatch<Self>
where + S: Into<Dispatch>,

Attaches the provided Subscriber to this type, returning a +[WithDispatch] wrapper. Read more
§

fn with_current_subscriber(self) -> WithDispatch<Self>

Attaches the current default Subscriber to this type, returning a +[WithDispatch] wrapper. Read more
Source§

impl<T> DeserializeOwned for T
where + T: for<'de> Deserialize<'de>,

§

impl<T> UserEvent for T
where + T: Debug + Clone + Send + 'static,

\ No newline at end of file diff --git a/api/jellytau_lib/player/seek/constant.SKIP_BACK_SECONDS.html b/api/jellytau_lib/player/seek/constant.SKIP_BACK_SECONDS.html new file mode 100644 index 00000000..c9080675 --- /dev/null +++ b/api/jellytau_lib/player/seek/constant.SKIP_BACK_SECONDS.html @@ -0,0 +1,4 @@ +SKIP_BACK_SECONDS in jellytau_lib::player::seek - RustSkip to main content

SKIP_BACK_SECONDS

Constant SKIP_BACK_SECONDS 

Source
pub const SKIP_BACK_SECONDS: f64 = 10.0;
Expand description

How far a lockscreen skip-back jumps while background audio owns playback.

+

Deliberately shorter than the forward jump: the back button is used to replay +dialogue just missed, not to travel.

+
\ No newline at end of file diff --git a/api/jellytau_lib/player/seek/constant.SKIP_FORWARD_SECONDS.html b/api/jellytau_lib/player/seek/constant.SKIP_FORWARD_SECONDS.html new file mode 100644 index 00000000..082644cb --- /dev/null +++ b/api/jellytau_lib/player/seek/constant.SKIP_FORWARD_SECONDS.html @@ -0,0 +1,2 @@ +SKIP_FORWARD_SECONDS in jellytau_lib::player::seek - RustSkip to main content

SKIP_FORWARD_SECONDS

Constant SKIP_FORWARD_SECONDS 

Source
pub const SKIP_FORWARD_SECONDS: f64 = 30.0;
Expand description

How far a lockscreen skip-forward jumps while background audio owns playback.

+
\ No newline at end of file diff --git a/api/jellytau_lib/player/seek/enum.SkipAction.html b/api/jellytau_lib/player/seek/enum.SkipAction.html new file mode 100644 index 00000000..865fcc59 --- /dev/null +++ b/api/jellytau_lib/player/seek/enum.SkipAction.html @@ -0,0 +1,34 @@ +SkipAction in jellytau_lib::player::seek - RustSkip to main content

SkipAction

Enum SkipAction 

Source
pub enum SkipAction {
+    Advance,
+    SeekTo(f64),
+}
Expand description

What a lockscreen skip button means for the playback that is actually running.

+

Variants§

§

Advance

Move to the next/previous queue entry — a track, or an episode.

+
§

SeekTo(f64)

Scrub within the current item, to this absolute position in seconds.

+

Trait Implementations§

Source§

impl Clone for SkipAction

Source§

fn clone(&self) -> SkipAction

Returns a duplicate of the value. Read more
1.0.0 (const: unstable) · Source§

fn clone_from(&mut self, source: &Self)

Performs copy-assignment from source. Read more
Source§

impl Debug for SkipAction

Source§

fn fmt(&self, f: &mut Formatter<'_>) -> Result

Formats the value using the given formatter. Read more
Source§

impl PartialEq for SkipAction

Source§

fn eq(&self, other: &SkipAction) -> bool

Tests for self and other values to be equal, and is used by ==.
1.0.0 (const: unstable) · Source§

fn ne(&self, other: &Rhs) -> bool

Tests for !=. The default implementation is almost always sufficient, +and should not be overridden without very good reason.
Source§

impl Copy for SkipAction

Source§

impl StructuralPartialEq for SkipAction

Auto Trait Implementations§

Blanket Implementations§

Source§

impl<T> Any for T
where + T: 'static + ?Sized,

Source§

fn type_id(&self) -> TypeId

Gets the TypeId of self. Read more
Source§

impl<T> Borrow<T> for T
where + T: ?Sized,

Source§

fn borrow(&self) -> &T

Immutably borrows from an owned value. Read more
Source§

impl<T> BorrowMut<T> for T
where + T: ?Sized,

Source§

fn borrow_mut(&mut self) -> &mut T

Mutably borrows from an owned value. Read more
Source§

impl<T> CloneToUninit for T
where + T: Clone,

Source§

unsafe fn clone_to_uninit(&self, dest: *mut u8)

🔬This is a nightly-only experimental API. (clone_to_uninit)
Performs copy-assignment from self to dest. Read more
Source§

impl<T> From<T> for T

Source§

fn from(t: T) -> T

Returns the argument unchanged.

+
§

impl<T> Instrument for T

§

fn instrument(self, span: Span) -> Instrumented<Self>

Instruments this type with the provided [Span], returning an +Instrumented wrapper. Read more
§

fn in_current_span(self) -> Instrumented<Self>

Instruments this type with the current Span, returning an +Instrumented wrapper. Read more
Source§

impl<T, U> Into<U> for T
where + U: From<T>,

Source§

fn into(self) -> U

Calls U::from(self).

+

That is, this conversion is whatever the implementation of +From<T> for U chooses to do.

+
§

impl<T> PolicyExt for T
where + T: ?Sized,

§

fn and<P, B, E>(self, other: P) -> And<T, P>
where + T: Policy<B, E>, + P: Policy<B, E>,

Create a new Policy that returns [Action::Follow] only if self and other return +Action::Follow. Read more
§

fn or<P, B, E>(self, other: P) -> Or<T, P>
where + T: Policy<B, E>, + P: Policy<B, E>,

Create a new Policy that returns [Action::Follow] if either self or other returns +Action::Follow. Read more
Source§

impl<T> Same for T

Source§

type Output = T

Should always be Self
Source§

impl<T> ToOwned for T
where + T: Clone,

Source§

type Owned = T

The resulting type after obtaining ownership.
Source§

fn to_owned(&self) -> T

Creates owned data from borrowed data, usually by cloning. Read more
Source§

fn clone_into(&self, target: &mut T)

Uses borrowed data to replace owned data, usually by cloning. Read more
Source§

impl<T, U> TryFrom<U> for T
where + U: Into<T>,

Source§

type Error = Infallible

The type returned in the event of a conversion error.
Source§

fn try_from(value: U) -> Result<T, <T as TryFrom<U>>::Error>

Performs the conversion.
Source§

impl<T, U> TryInto<U> for T
where + U: TryFrom<T>,

Source§

type Error = <U as TryFrom<T>>::Error

The type returned in the event of a conversion error.
Source§

fn try_into(self) -> Result<U, <U as TryFrom<T>>::Error>

Performs the conversion.
§

impl<V, T> VZip<V> for T
where + V: MultiLane<T>,

§

fn vzip(self) -> V

§

impl<T> WithSubscriber for T

§

fn with_subscriber<S>(self, subscriber: S) -> WithDispatch<Self>
where + S: Into<Dispatch>,

Attaches the provided Subscriber to this type, returning a +[WithDispatch] wrapper. Read more
§

fn with_current_subscriber(self) -> WithDispatch<Self>

Attaches the current default Subscriber to this type, returning a +[WithDispatch] wrapper. Read more
§

impl<T> UserEvent for T
where + T: Debug + Clone + Send + 'static,

\ No newline at end of file diff --git a/api/jellytau_lib/player/seek/enum.VideoSeekStrategy.html b/api/jellytau_lib/player/seek/enum.VideoSeekStrategy.html new file mode 100644 index 00000000..5fe87694 --- /dev/null +++ b/api/jellytau_lib/player/seek/enum.VideoSeekStrategy.html @@ -0,0 +1,46 @@ +VideoSeekStrategy in jellytau_lib::player::seek - RustSkip to main content

VideoSeekStrategy

Enum VideoSeekStrategy 

Source
pub enum VideoSeekStrategy {
+    LocalNativeSeek,
+    Html5NativeSeek,
+    BackendNativeSeek,
+    Html5ReloadStream,
+    BackendReloadStream,
+}
Expand description

Seek strategy for video playback, derived from a stream’s characteristics.

+

Variants§

§

LocalNativeSeek

Local file - always use native seek on backend

+
§

Html5NativeSeek

HLS or direct stream with HTML5 - frontend handles seek, skip backend

+
§

BackendNativeSeek

HLS or direct stream with native backend - backend handles seek

+
§

Html5ReloadStream

Transcoded non-HLS with HTML5 - reload stream, frontend handles

+
§

BackendReloadStream

Transcoded non-HLS with native backend - reload stream, backend handles

+

Trait Implementations§

Source§

impl Clone for VideoSeekStrategy

Source§

fn clone(&self) -> VideoSeekStrategy

Returns a duplicate of the value. Read more
1.0.0 (const: unstable) · Source§

fn clone_from(&mut self, source: &Self)

Performs copy-assignment from source. Read more
Source§

impl Debug for VideoSeekStrategy

Source§

fn fmt(&self, f: &mut Formatter<'_>) -> Result

Formats the value using the given formatter. Read more
Source§

impl PartialEq for VideoSeekStrategy

Source§

fn eq(&self, other: &VideoSeekStrategy) -> bool

Tests for self and other values to be equal, and is used by ==.
1.0.0 (const: unstable) · Source§

fn ne(&self, other: &Rhs) -> bool

Tests for !=. The default implementation is almost always sufficient, +and should not be overridden without very good reason.
Source§

impl Copy for VideoSeekStrategy

Source§

impl Eq for VideoSeekStrategy

Source§

impl StructuralPartialEq for VideoSeekStrategy

Auto Trait Implementations§

Blanket Implementations§

Source§

impl<T> Any for T
where + T: 'static + ?Sized,

Source§

fn type_id(&self) -> TypeId

Gets the TypeId of self. Read more
Source§

impl<T> Borrow<T> for T
where + T: ?Sized,

Source§

fn borrow(&self) -> &T

Immutably borrows from an owned value. Read more
Source§

impl<T> BorrowMut<T> for T
where + T: ?Sized,

Source§

fn borrow_mut(&mut self) -> &mut T

Mutably borrows from an owned value. Read more
Source§

impl<T> CloneToUninit for T
where + T: Clone,

Source§

unsafe fn clone_to_uninit(&self, dest: *mut u8)

🔬This is a nightly-only experimental API. (clone_to_uninit)
Performs copy-assignment from self to dest. Read more
§

impl<Q, K> Equivalent<K> for Q
where + Q: Eq + ?Sized, + K: Borrow<Q> + ?Sized,

§

fn equivalent(&self, key: &K) -> bool

Checks if this value is equivalent to the given key. Read more
§

impl<Q, K> Equivalent<K> for Q
where + Q: Eq + ?Sized, + K: Borrow<Q> + ?Sized,

§

fn equivalent(&self, key: &K) -> bool

Compare self to key and return true if they are equal.
§

impl<Q, K> Equivalent<K> for Q
where + Q: Eq + ?Sized, + K: Borrow<Q> + ?Sized,

§

fn equivalent(&self, key: &K) -> bool

Checks if this value is equivalent to the given key. Read more
Source§

impl<T> From<T> for T

Source§

fn from(t: T) -> T

Returns the argument unchanged.

+
§

impl<T> Instrument for T

§

fn instrument(self, span: Span) -> Instrumented<Self>

Instruments this type with the provided [Span], returning an +Instrumented wrapper. Read more
§

fn in_current_span(self) -> Instrumented<Self>

Instruments this type with the current Span, returning an +Instrumented wrapper. Read more
Source§

impl<T, U> Into<U> for T
where + U: From<T>,

Source§

fn into(self) -> U

Calls U::from(self).

+

That is, this conversion is whatever the implementation of +From<T> for U chooses to do.

+
§

impl<T> PolicyExt for T
where + T: ?Sized,

§

fn and<P, B, E>(self, other: P) -> And<T, P>
where + T: Policy<B, E>, + P: Policy<B, E>,

Create a new Policy that returns [Action::Follow] only if self and other return +Action::Follow. Read more
§

fn or<P, B, E>(self, other: P) -> Or<T, P>
where + T: Policy<B, E>, + P: Policy<B, E>,

Create a new Policy that returns [Action::Follow] if either self or other returns +Action::Follow. Read more
Source§

impl<T> Same for T

Source§

type Output = T

Should always be Self
Source§

impl<T> ToOwned for T
where + T: Clone,

Source§

type Owned = T

The resulting type after obtaining ownership.
Source§

fn to_owned(&self) -> T

Creates owned data from borrowed data, usually by cloning. Read more
Source§

fn clone_into(&self, target: &mut T)

Uses borrowed data to replace owned data, usually by cloning. Read more
Source§

impl<T, U> TryFrom<U> for T
where + U: Into<T>,

Source§

type Error = Infallible

The type returned in the event of a conversion error.
Source§

fn try_from(value: U) -> Result<T, <T as TryFrom<U>>::Error>

Performs the conversion.
Source§

impl<T, U> TryInto<U> for T
where + U: TryFrom<T>,

Source§

type Error = <U as TryFrom<T>>::Error

The type returned in the event of a conversion error.
Source§

fn try_into(self) -> Result<U, <U as TryFrom<T>>::Error>

Performs the conversion.
§

impl<V, T> VZip<V> for T
where + V: MultiLane<T>,

§

fn vzip(self) -> V

§

impl<T> WithSubscriber for T

§

fn with_subscriber<S>(self, subscriber: S) -> WithDispatch<Self>
where + S: Into<Dispatch>,

Attaches the provided Subscriber to this type, returning a +[WithDispatch] wrapper. Read more
§

fn with_current_subscriber(self) -> WithDispatch<Self>

Attaches the current default Subscriber to this type, returning a +[WithDispatch] wrapper. Read more
§

impl<T> UserEvent for T
where + T: Debug + Clone + Send + 'static,

\ No newline at end of file diff --git a/api/jellytau_lib/player/seek/fn.determine_video_seek_strategy.html b/api/jellytau_lib/player/seek/fn.determine_video_seek_strategy.html new file mode 100644 index 00000000..4c34a1a0 --- /dev/null +++ b/api/jellytau_lib/player/seek/fn.determine_video_seek_strategy.html @@ -0,0 +1,15 @@ +determine_video_seek_strategy in jellytau_lib::player::seek - RustSkip to main content

determine_video_seek_strategy

Function determine_video_seek_strategy 

Source
pub fn determine_video_seek_strategy(
+    is_local: bool,
+    is_hls: bool,
+    needs_transcoding: bool,
+    use_html5: bool,
+) -> VideoSeekStrategy
Expand description

Determine the video seek strategy based on stream characteristics.

+

This is a pure function extracted for testability.

+

§Arguments

+
    +
  • is_local - Whether the file is a local download
  • +
  • is_hls - Whether the stream URL contains “.m3u8” (HLS stream)
  • +
  • needs_transcoding - Whether the content needs transcoding
  • +
  • use_html5 - Whether frontend is using HTML5 video element
  • +
+
\ No newline at end of file diff --git a/api/jellytau_lib/player/seek/fn.resolve_skip_action.html b/api/jellytau_lib/player/seek/fn.resolve_skip_action.html new file mode 100644 index 00000000..6e996484 --- /dev/null +++ b/api/jellytau_lib/player/seek/fn.resolve_skip_action.html @@ -0,0 +1,20 @@ +resolve_skip_action in jellytau_lib::player::seek - RustSkip to main content

resolve_skip_action

Function resolve_skip_action 

Source
pub fn resolve_skip_action(
+    is_next: bool,
+    is_background_audio: bool,
+    position: f64,
+    duration: Option<f64>,
+) -> SkipAction
Expand description

Decide whether a lockscreen skip advances the queue or scrubs the current item.

+

Music gets queue advance, which is what the buttons look like they do. A video +whose audio is playing through a background-audio handoff (UR-040) gets a +relative scrub instead: there is no meaningful “next track” inside a film, and +jumping to the next episode because the user wanted to re-hear a line is a +much worse outcome than a scrub.

+

is_background_audio is the whole test, and it is sufficient on its own — +the handoff exists only for video, and an episode played through it reports +MediaType::Audio, so media type cannot distinguish this case (see the note +at PlayerController::auto_advance_to_next_episode).

+

Clamped to [0, duration] so a skip near either end lands in the item rather +than at a negative offset or past the end, which some backends treat as EOF +and would turn a scrub into an unintended advance.

+

TRACES: UR-040, UR-006 | DR-201

+
\ No newline at end of file diff --git a/api/jellytau_lib/player/seek/index.html b/api/jellytau_lib/player/seek/index.html new file mode 100644 index 00000000..4ebb0e28 --- /dev/null +++ b/api/jellytau_lib/player/seek/index.html @@ -0,0 +1,5 @@ +jellytau_lib::player::seek - RustSkip to main content

Module seek

Module seek 

Source
Expand description

Video seek strategy decision logic.

+

This is pure logic, extracted from the command layer so it can be unit-tested +in the player core. The player_seek_video command translates the resulting +VideoSeekStrategy into a concrete backend/frontend action.

+

Enums§

SkipAction
What a lockscreen skip button means for the playback that is actually running.
VideoSeekStrategy
Seek strategy for video playback, derived from a stream’s characteristics.

Constants§

SKIP_BACK_SECONDS
How far a lockscreen skip-back jumps while background audio owns playback.
SKIP_FORWARD_SECONDS
How far a lockscreen skip-forward jumps while background audio owns playback.

Functions§

determine_video_seek_strategy
Determine the video seek strategy based on stream characteristics.
resolve_skip_action
Decide whether a lockscreen skip advances the queue or scrubs the current item.
\ No newline at end of file diff --git a/api/jellytau_lib/player/seek/sidebar-items.js b/api/jellytau_lib/player/seek/sidebar-items.js new file mode 100644 index 00000000..0d1839d1 --- /dev/null +++ b/api/jellytau_lib/player/seek/sidebar-items.js @@ -0,0 +1 @@ +window.SIDEBAR_ITEMS = {"constant":["SKIP_BACK_SECONDS","SKIP_FORWARD_SECONDS"],"enum":["SkipAction","VideoSeekStrategy"],"fn":["determine_video_seek_strategy","resolve_skip_action"]}; \ No newline at end of file diff --git a/api/jellytau_lib/player/session/enum.MediaSessionType.html b/api/jellytau_lib/player/session/enum.MediaSessionType.html new file mode 100644 index 00000000..4795159d --- /dev/null +++ b/api/jellytau_lib/player/session/enum.MediaSessionType.html @@ -0,0 +1,81 @@ +MediaSessionType in jellytau_lib::player::session - RustSkip to main content

MediaSessionType

Enum MediaSessionType 

Source
pub enum MediaSessionType {
+    Idle,
+    Audio {
+        last_item: Option<MediaItem>,
+        is_active: bool,
+    },
+    Movie {
+        item: MediaItem,
+        is_active: bool,
+    },
+    TvShow {
+        item: MediaItem,
+        series_id: String,
+        is_active: bool,
+    },
+}
Expand description

Media session type tracking the high-level playback context

+

Variants§

§

Idle

No active session - browsing library

+
§

Audio

Audio playback session (music, audiobooks, podcasts) +Persists until explicitly dismissed

+

Fields

§last_item: Option<MediaItem>

Last/current track being played

+
§is_active: bool

True = playing/paused, False = stopped/ended

+
§

Movie

Movie playback (single video, auto-dismiss on end)

+

Fields

§item: MediaItem

Currently loaded movie

+
§is_active: bool

True = playing/paused, False = ended

+
§

TvShow

TV show playback (supports next episode auto-advance)

+

Fields

§item: MediaItem

Currently loaded episode

+
§series_id: String

Series ID for fetching next episodes

+
§is_active: bool

True = playing/paused, False = ended

+

Implementations§

Source§

impl MediaSessionType

Source

pub fn is_active_session(&self) -> bool

Check if this is an active session (any type except Idle)

+
Source

pub fn is_playing_or_paused(&self) -> bool

Check if playback is currently active within the session

+
Source

pub fn current_item(&self) -> Option<&MediaItem>

Get the current media item if any

+

Trait Implementations§

Source§

impl Clone for MediaSessionType

Source§

fn clone(&self) -> MediaSessionType

Returns a duplicate of the value. Read more
1.0.0 (const: unstable) · Source§

fn clone_from(&mut self, source: &Self)

Performs copy-assignment from source. Read more
Source§

impl Debug for MediaSessionType

Source§

fn fmt(&self, f: &mut Formatter<'_>) -> Result

Formats the value using the given formatter. Read more
Source§

impl<'de> Deserialize<'de> for MediaSessionType

Source§

fn deserialize<__D>(__deserializer: __D) -> Result<Self, __D::Error>
where + __D: Deserializer<'de>,

Deserialize this value from the given Serde deserializer. Read more
Source§

impl NamedType for MediaSessionType

Source§

fn sid() -> SpectaID

Source§

fn named_data_type( + type_map: &mut TypeCollection, + generics: &[DataType], +) -> NamedDataType

this is equivalent to [Type::inline] but returns a [NamedDataType] instead.
Source§

fn definition_named_data_type(type_map: &mut TypeCollection) -> NamedDataType

this is equivalent to [Type::definition] but returns a [NamedDataType] instead.
Source§

impl PartialEq for MediaSessionType

Source§

fn eq(&self, other: &MediaSessionType) -> bool

Tests for self and other values to be equal, and is used by ==.
1.0.0 (const: unstable) · Source§

fn ne(&self, other: &Rhs) -> bool

Tests for !=. The default implementation is almost always sufficient, +and should not be overridden without very good reason.
Source§

impl Serialize for MediaSessionType

Source§

fn serialize<__S>(&self, __serializer: __S) -> Result<__S::Ok, __S::Error>
where + __S: Serializer,

Serialize this value into the given Serde serializer. Read more
Source§

impl Type for MediaSessionType

Source§

fn inline(type_map: &mut TypeCollection, generics: Generics<'_>) -> DataType

Returns the definition of a type using the provided generics. Read more
Source§

fn reference(type_map: &mut TypeCollection, generics: &[DataType]) -> Reference

Generates a datatype corresponding to a reference to this type, +as determined by its category. Getting a reference to a type implies that +it should belong in the type map (since it has to be referenced from somewhere), +so the output of definition will be put into the type map.
Source§

impl Flatten for MediaSessionType

Source§

impl StructuralPartialEq for MediaSessionType

Auto Trait Implementations§

Blanket Implementations§

Source§

impl<T> Any for T
where + T: 'static + ?Sized,

Source§

fn type_id(&self) -> TypeId

Gets the TypeId of self. Read more
Source§

impl<T> Borrow<T> for T
where + T: ?Sized,

Source§

fn borrow(&self) -> &T

Immutably borrows from an owned value. Read more
Source§

impl<T> BorrowMut<T> for T
where + T: ?Sized,

Source§

fn borrow_mut(&mut self) -> &mut T

Mutably borrows from an owned value. Read more
Source§

impl<T> CloneToUninit for T
where + T: Clone,

Source§

unsafe fn clone_to_uninit(&self, dest: *mut u8)

🔬This is a nightly-only experimental API. (clone_to_uninit)
Performs copy-assignment from self to dest. Read more
§

impl<'de, D, R> CommandArg<'de, R> for D
where + D: Deserialize<'de>, + R: Runtime,

§

fn from_command(command: CommandItem<'de, R>) -> Result<D, InvokeError>

Derives an instance of Self from the [CommandItem]. Read more
Source§

impl<T> From<T> for T

Source§

fn from(t: T) -> T

Returns the argument unchanged.

+
§

impl<T> FunctionArg for T
where + T: Type,

§

fn to_datatype(type_map: &mut TypeCollection) -> Option<DataType>

Gets the type of an argument as a [DataType]. Read more
§

impl<T> Instrument for T

§

fn instrument(self, span: Span) -> Instrumented<Self>

Instruments this type with the provided [Span], returning an +Instrumented wrapper. Read more
§

fn in_current_span(self) -> Instrumented<Self>

Instruments this type with the current Span, returning an +Instrumented wrapper. Read more
Source§

impl<T, U> Into<U> for T
where + U: From<T>,

Source§

fn into(self) -> U

Calls U::from(self).

+

That is, this conversion is whatever the implementation of +From<T> for U chooses to do.

+
§

impl<T> IpcResponse for T
where + T: Serialize,

§

fn body(self) -> Result<InvokeResponseBody, Error>

Resolve the IPC response body.
§

impl<T> PolicyExt for T
where + T: ?Sized,

§

fn and<P, B, E>(self, other: P) -> And<T, P>
where + T: Policy<B, E>, + P: Policy<B, E>,

Create a new Policy that returns [Action::Follow] only if self and other return +Action::Follow. Read more
§

fn or<P, B, E>(self, other: P) -> Or<T, P>
where + T: Policy<B, E>, + P: Policy<B, E>,

Create a new Policy that returns [Action::Follow] if either self or other returns +Action::Follow. Read more
Source§

impl<T> Same for T

Source§

type Output = T

Should always be Self
§

impl<T> ScopeObject for T
where + T: Send + Sync + Debug + DeserializeOwned + 'static,

§

type Error = Error

The error type.
§

fn deserialize<R>( + _app: &AppHandle<R>, + raw: Value, +) -> Result<T, <T as ScopeObject>::Error>
where + R: Runtime,

Deserialize the raw scope value.
Source§

impl<T> Serialize for T
where + T: Serialize + ?Sized,

Source§

fn erased_serialize(&self, serializer: &mut dyn Serializer) -> Result<(), Error>

Source§

fn do_erased_serialize( + &self, + serializer: &mut dyn Serializer, +) -> Result<(), ErrorImpl>

Source§

impl<T> ToOwned for T
where + T: Clone,

Source§

type Owned = T

The resulting type after obtaining ownership.
Source§

fn to_owned(&self) -> T

Creates owned data from borrowed data, usually by cloning. Read more
Source§

fn clone_into(&self, target: &mut T)

Uses borrowed data to replace owned data, usually by cloning. Read more
Source§

impl<T, U> TryFrom<U> for T
where + U: Into<T>,

Source§

type Error = Infallible

The type returned in the event of a conversion error.
Source§

fn try_from(value: U) -> Result<T, <T as TryFrom<U>>::Error>

Performs the conversion.
Source§

impl<T, U> TryInto<U> for T
where + U: TryFrom<T>,

Source§

type Error = <U as TryFrom<T>>::Error

The type returned in the event of a conversion error.
Source§

fn try_into(self) -> Result<U, <U as TryFrom<T>>::Error>

Performs the conversion.
§

impl<V, T> VZip<V> for T
where + V: MultiLane<T>,

§

fn vzip(self) -> V

§

impl<T> WithSubscriber for T

§

fn with_subscriber<S>(self, subscriber: S) -> WithDispatch<Self>
where + S: Into<Dispatch>,

Attaches the provided Subscriber to this type, returning a +[WithDispatch] wrapper. Read more
§

fn with_current_subscriber(self) -> WithDispatch<Self>

Attaches the current default Subscriber to this type, returning a +[WithDispatch] wrapper. Read more
Source§

impl<T> DeserializeOwned for T
where + T: for<'de> Deserialize<'de>,

§

impl<T> UserEvent for T
where + T: Debug + Clone + Send + 'static,

\ No newline at end of file diff --git a/api/jellytau_lib/player/session/index.html b/api/jellytau_lib/player/session/index.html new file mode 100644 index 00000000..b1c075c0 --- /dev/null +++ b/api/jellytau_lib/player/session/index.html @@ -0,0 +1 @@ +jellytau_lib::player::session - RustSkip to main content

Module session

Module session 

Source

Structs§

MediaSessionManager
Manages media session state transitions

Enums§

MediaSessionType
Media session type tracking the high-level playback context
\ No newline at end of file diff --git a/api/jellytau_lib/player/session/sidebar-items.js b/api/jellytau_lib/player/session/sidebar-items.js new file mode 100644 index 00000000..d1864f50 --- /dev/null +++ b/api/jellytau_lib/player/session/sidebar-items.js @@ -0,0 +1 @@ +window.SIDEBAR_ITEMS = {"enum":["MediaSessionType"],"struct":["MediaSessionManager"]}; \ No newline at end of file diff --git a/api/jellytau_lib/player/session/struct.MediaSessionManager.html b/api/jellytau_lib/player/session/struct.MediaSessionManager.html new file mode 100644 index 00000000..f6833d04 --- /dev/null +++ b/api/jellytau_lib/player/session/struct.MediaSessionManager.html @@ -0,0 +1,48 @@ +MediaSessionManager in jellytau_lib::player::session - RustSkip to main content

MediaSessionManager

Struct MediaSessionManager 

Source
pub struct MediaSessionManager {
+    current: MediaSessionType,
+}
Expand description

Manages media session state transitions

+

Fields§

§current: MediaSessionType

Implementations§

Source§

impl MediaSessionManager

Source

pub fn new() -> Self

Source

pub fn current(&self) -> &MediaSessionType

Get the current session state

+
Source

pub fn start_audio_session(&mut self, first_item: MediaItem)

Start an audio session with a queue +Transitions: Idle → Audio(active), Any → Audio(active)

+
Source

pub fn update_audio_track(&mut self, item: MediaItem)

Update audio session with new track (during playback)

+
Source

pub fn audio_session_inactive(&mut self)

Mark audio session as inactive (playback ended, queue finished) +Session persists for resume

+
Source

pub fn resume_audio_session(&mut self)

Resume audio session

+
Source

pub fn start_movie_session(&mut self, item: MediaItem)

Start a movie session +Transitions: Any → Movie(active)

+
Source

pub fn movie_session_ended(&mut self)

Mark movie session as inactive (playback ended) +Movie sessions auto-dismiss to Idle

+
Source

pub fn start_tv_session(&mut self, item: MediaItem, series_id: String)

Start a TV show session +Transitions: Any → TvShow(active)

+
Source

pub fn tv_session_episode_ended(&mut self)

Mark TV show session as inactive (episode ended, awaiting next)

+
Source

pub fn tv_session_next_episode(&mut self, next_item: MediaItem)

Advance to next episode in TV session

+
Source

pub fn tv_session_ended(&mut self)

End TV show session (series complete or user dismissed)

+
Source

pub fn dismiss(&mut self)

Dismiss/clear current session (user action) +Transitions: Any → Idle

+
Source

pub fn should_show_miniplayer(&self) -> bool

Check if we should show miniplayer

+
Source

pub fn should_show_video_player(&self) -> bool

Check if we should show video player

+

Trait Implementations§

Source§

impl Default for MediaSessionManager

Source§

fn default() -> Self

Returns the “default value” for a type. Read more

Auto Trait Implementations§

Blanket Implementations§

Source§

impl<T> Any for T
where + T: 'static + ?Sized,

Source§

fn type_id(&self) -> TypeId

Gets the TypeId of self. Read more
Source§

impl<T> Borrow<T> for T
where + T: ?Sized,

Source§

fn borrow(&self) -> &T

Immutably borrows from an owned value. Read more
Source§

impl<T> BorrowMut<T> for T
where + T: ?Sized,

Source§

fn borrow_mut(&mut self) -> &mut T

Mutably borrows from an owned value. Read more
Source§

impl<T> From<T> for T

Source§

fn from(t: T) -> T

Returns the argument unchanged.

+
§

impl<T> Instrument for T

§

fn instrument(self, span: Span) -> Instrumented<Self>

Instruments this type with the provided [Span], returning an +Instrumented wrapper. Read more
§

fn in_current_span(self) -> Instrumented<Self>

Instruments this type with the current Span, returning an +Instrumented wrapper. Read more
Source§

impl<T, U> Into<U> for T
where + U: From<T>,

Source§

fn into(self) -> U

Calls U::from(self).

+

That is, this conversion is whatever the implementation of +From<T> for U chooses to do.

+
§

impl<T> NoneValue for T
where + T: Default,

§

type NoneType = T

§

fn null_value() -> T

The none-equivalent value.
§

impl<T> PolicyExt for T
where + T: ?Sized,

§

fn and<P, B, E>(self, other: P) -> And<T, P>
where + T: Policy<B, E>, + P: Policy<B, E>,

Create a new Policy that returns [Action::Follow] only if self and other return +Action::Follow. Read more
§

fn or<P, B, E>(self, other: P) -> Or<T, P>
where + T: Policy<B, E>, + P: Policy<B, E>,

Create a new Policy that returns [Action::Follow] if either self or other returns +Action::Follow. Read more
Source§

impl<T> Same for T

Source§

type Output = T

Should always be Self
Source§

impl<T, U> TryFrom<U> for T
where + U: Into<T>,

Source§

type Error = Infallible

The type returned in the event of a conversion error.
Source§

fn try_from(value: U) -> Result<T, <T as TryFrom<U>>::Error>

Performs the conversion.
Source§

impl<T, U> TryInto<U> for T
where + U: TryFrom<T>,

Source§

type Error = <U as TryFrom<T>>::Error

The type returned in the event of a conversion error.
Source§

fn try_into(self) -> Result<U, <U as TryFrom<T>>::Error>

Performs the conversion.
§

impl<V, T> VZip<V> for T
where + V: MultiLane<T>,

§

fn vzip(self) -> V

§

impl<T> WithSubscriber for T

§

fn with_subscriber<S>(self, subscriber: S) -> WithDispatch<Self>
where + S: Into<Dispatch>,

Attaches the provided Subscriber to this type, returning a +[WithDispatch] wrapper. Read more
§

fn with_current_subscriber(self) -> WithDispatch<Self>

Attaches the current default Subscriber to this type, returning a +[WithDispatch] wrapper. Read more
\ No newline at end of file diff --git a/api/jellytau_lib/player/sidebar-items.js b/api/jellytau_lib/player/sidebar-items.js new file mode 100644 index 00000000..d530c09a --- /dev/null +++ b/api/jellytau_lib/player/sidebar-items.js @@ -0,0 +1 @@ +window.SIDEBAR_ITEMS = {"constant":["RESUME_BACKOFF_STEP_SECS"],"fn":["completion_report_position","set_lockscreen_position_offset","update_lockscreen_metadata"],"mod":["autoplay","backend","background_policy","events","media","mpv_backend","queue","seek","session","sleep_timer","state","stream_end"],"struct":["LockscreenMetadata","PlayerController","ReporterSink"],"trait":["PlaybackReportSink"]}; \ No newline at end of file diff --git a/api/jellytau_lib/player/sleep_timer/enum.SleepTimerMode.html b/api/jellytau_lib/player/sleep_timer/enum.SleepTimerMode.html new file mode 100644 index 00000000..0548473b --- /dev/null +++ b/api/jellytau_lib/player/sleep_timer/enum.SleepTimerMode.html @@ -0,0 +1,65 @@ +SleepTimerMode in jellytau_lib::player::sleep_timer - RustSkip to main content

SleepTimerMode

Enum SleepTimerMode 

Source
pub enum SleepTimerMode {
+    Off,
+    Time {
+        end_time: i64,
+    },
+    EndOfTrack,
+    Episodes {
+        remaining: u32,
+    },
+}
Expand description

Sleep timer mode - determines when playback should stop +TRACES: UR-026 | DR-029

+

Variants§

§

Off

Timer is off

+
§

Time

Stop after a specific time duration

+

Fields

§end_time: i64
§

EndOfTrack

Stop at the end of current track

+
§

Episodes

Stop after N more episodes complete (TV episodes only, not audio tracks)

+

Fields

§remaining: u32

Trait Implementations§

Source§

impl Clone for SleepTimerMode

Source§

fn clone(&self) -> SleepTimerMode

Returns a duplicate of the value. Read more
1.0.0 (const: unstable) · Source§

fn clone_from(&mut self, source: &Self)

Performs copy-assignment from source. Read more
Source§

impl Debug for SleepTimerMode

Source§

fn fmt(&self, f: &mut Formatter<'_>) -> Result

Formats the value using the given formatter. Read more
Source§

impl<'de> Deserialize<'de> for SleepTimerMode

Source§

fn deserialize<__D>(__deserializer: __D) -> Result<Self, __D::Error>
where + __D: Deserializer<'de>,

Deserialize this value from the given Serde deserializer. Read more
Source§

impl NamedType for SleepTimerMode

Source§

fn sid() -> SpectaID

Source§

fn named_data_type( + type_map: &mut TypeCollection, + generics: &[DataType], +) -> NamedDataType

this is equivalent to [Type::inline] but returns a [NamedDataType] instead.
Source§

fn definition_named_data_type(type_map: &mut TypeCollection) -> NamedDataType

this is equivalent to [Type::definition] but returns a [NamedDataType] instead.
Source§

impl PartialEq for SleepTimerMode

Source§

fn eq(&self, other: &SleepTimerMode) -> bool

Tests for self and other values to be equal, and is used by ==.
1.0.0 (const: unstable) · Source§

fn ne(&self, other: &Rhs) -> bool

Tests for !=. The default implementation is almost always sufficient, +and should not be overridden without very good reason.
Source§

impl Serialize for SleepTimerMode

Source§

fn serialize<__S>(&self, __serializer: __S) -> Result<__S::Ok, __S::Error>
where + __S: Serializer,

Serialize this value into the given Serde serializer. Read more
Source§

impl Type for SleepTimerMode

Source§

fn inline(type_map: &mut TypeCollection, generics: Generics<'_>) -> DataType

Returns the definition of a type using the provided generics. Read more
Source§

fn reference(type_map: &mut TypeCollection, generics: &[DataType]) -> Reference

Generates a datatype corresponding to a reference to this type, +as determined by its category. Getting a reference to a type implies that +it should belong in the type map (since it has to be referenced from somewhere), +so the output of definition will be put into the type map.
Source§

impl Flatten for SleepTimerMode

Source§

impl StructuralPartialEq for SleepTimerMode

Auto Trait Implementations§

Blanket Implementations§

Source§

impl<T> Any for T
where + T: 'static + ?Sized,

Source§

fn type_id(&self) -> TypeId

Gets the TypeId of self. Read more
Source§

impl<T> Borrow<T> for T
where + T: ?Sized,

Source§

fn borrow(&self) -> &T

Immutably borrows from an owned value. Read more
Source§

impl<T> BorrowMut<T> for T
where + T: ?Sized,

Source§

fn borrow_mut(&mut self) -> &mut T

Mutably borrows from an owned value. Read more
Source§

impl<T> CloneToUninit for T
where + T: Clone,

Source§

unsafe fn clone_to_uninit(&self, dest: *mut u8)

🔬This is a nightly-only experimental API. (clone_to_uninit)
Performs copy-assignment from self to dest. Read more
§

impl<'de, D, R> CommandArg<'de, R> for D
where + D: Deserialize<'de>, + R: Runtime,

§

fn from_command(command: CommandItem<'de, R>) -> Result<D, InvokeError>

Derives an instance of Self from the [CommandItem]. Read more
Source§

impl<T> From<T> for T

Source§

fn from(t: T) -> T

Returns the argument unchanged.

+
§

impl<T> FunctionArg for T
where + T: Type,

§

fn to_datatype(type_map: &mut TypeCollection) -> Option<DataType>

Gets the type of an argument as a [DataType]. Read more
§

impl<T> Instrument for T

§

fn instrument(self, span: Span) -> Instrumented<Self>

Instruments this type with the provided [Span], returning an +Instrumented wrapper. Read more
§

fn in_current_span(self) -> Instrumented<Self>

Instruments this type with the current Span, returning an +Instrumented wrapper. Read more
Source§

impl<T, U> Into<U> for T
where + U: From<T>,

Source§

fn into(self) -> U

Calls U::from(self).

+

That is, this conversion is whatever the implementation of +From<T> for U chooses to do.

+
§

impl<T> IpcResponse for T
where + T: Serialize,

§

fn body(self) -> Result<InvokeResponseBody, Error>

Resolve the IPC response body.
§

impl<T> PolicyExt for T
where + T: ?Sized,

§

fn and<P, B, E>(self, other: P) -> And<T, P>
where + T: Policy<B, E>, + P: Policy<B, E>,

Create a new Policy that returns [Action::Follow] only if self and other return +Action::Follow. Read more
§

fn or<P, B, E>(self, other: P) -> Or<T, P>
where + T: Policy<B, E>, + P: Policy<B, E>,

Create a new Policy that returns [Action::Follow] if either self or other returns +Action::Follow. Read more
Source§

impl<T> Same for T

Source§

type Output = T

Should always be Self
§

impl<T> ScopeObject for T
where + T: Send + Sync + Debug + DeserializeOwned + 'static,

§

type Error = Error

The error type.
§

fn deserialize<R>( + _app: &AppHandle<R>, + raw: Value, +) -> Result<T, <T as ScopeObject>::Error>
where + R: Runtime,

Deserialize the raw scope value.
Source§

impl<T> Serialize for T
where + T: Serialize + ?Sized,

Source§

fn erased_serialize(&self, serializer: &mut dyn Serializer) -> Result<(), Error>

Source§

fn do_erased_serialize( + &self, + serializer: &mut dyn Serializer, +) -> Result<(), ErrorImpl>

Source§

impl<T> ToOwned for T
where + T: Clone,

Source§

type Owned = T

The resulting type after obtaining ownership.
Source§

fn to_owned(&self) -> T

Creates owned data from borrowed data, usually by cloning. Read more
Source§

fn clone_into(&self, target: &mut T)

Uses borrowed data to replace owned data, usually by cloning. Read more
Source§

impl<T, U> TryFrom<U> for T
where + U: Into<T>,

Source§

type Error = Infallible

The type returned in the event of a conversion error.
Source§

fn try_from(value: U) -> Result<T, <T as TryFrom<U>>::Error>

Performs the conversion.
Source§

impl<T, U> TryInto<U> for T
where + U: TryFrom<T>,

Source§

type Error = <U as TryFrom<T>>::Error

The type returned in the event of a conversion error.
Source§

fn try_into(self) -> Result<U, <U as TryFrom<T>>::Error>

Performs the conversion.
§

impl<V, T> VZip<V> for T
where + V: MultiLane<T>,

§

fn vzip(self) -> V

§

impl<T> WithSubscriber for T

§

fn with_subscriber<S>(self, subscriber: S) -> WithDispatch<Self>
where + S: Into<Dispatch>,

Attaches the provided Subscriber to this type, returning a +[WithDispatch] wrapper. Read more
§

fn with_current_subscriber(self) -> WithDispatch<Self>

Attaches the current default Subscriber to this type, returning a +[WithDispatch] wrapper. Read more
Source§

impl<T> DeserializeOwned for T
where + T: for<'de> Deserialize<'de>,

§

impl<T> UserEvent for T
where + T: Debug + Clone + Send + 'static,

\ No newline at end of file diff --git a/api/jellytau_lib/player/sleep_timer/index.html b/api/jellytau_lib/player/sleep_timer/index.html new file mode 100644 index 00000000..7b75637f --- /dev/null +++ b/api/jellytau_lib/player/sleep_timer/index.html @@ -0,0 +1,2 @@ +jellytau_lib::player::sleep_timer - RustSkip to main content

Module sleep_timer

Module sleep_timer 

Source

Structs§

SleepTimerState
Sleep timer state

Enums§

SleepTimerMode
Sleep timer mode - determines when playback should stop +TRACES: UR-026 | DR-029
\ No newline at end of file diff --git a/api/jellytau_lib/player/sleep_timer/sidebar-items.js b/api/jellytau_lib/player/sleep_timer/sidebar-items.js new file mode 100644 index 00000000..c0add4f8 --- /dev/null +++ b/api/jellytau_lib/player/sleep_timer/sidebar-items.js @@ -0,0 +1 @@ +window.SIDEBAR_ITEMS = {"enum":["SleepTimerMode"],"struct":["SleepTimerState"]}; \ No newline at end of file diff --git a/api/jellytau_lib/player/sleep_timer/struct.SleepTimerState.html b/api/jellytau_lib/player/sleep_timer/struct.SleepTimerState.html new file mode 100644 index 00000000..7a485a89 --- /dev/null +++ b/api/jellytau_lib/player/sleep_timer/struct.SleepTimerState.html @@ -0,0 +1,59 @@ +SleepTimerState in jellytau_lib::player::sleep_timer - RustSkip to main content

SleepTimerState

Struct SleepTimerState 

Source
pub struct SleepTimerState {
+    pub mode: SleepTimerMode,
+    pub remaining_seconds: u32,
+}
Expand description

Sleep timer state

+

Fields§

§mode: SleepTimerMode§remaining_seconds: u32

Implementations§

Source§

impl SleepTimerState

Source

pub fn is_active(&self) -> bool

Check if timer is active

+
Source

pub fn update_remaining_seconds(&mut self)

Update remaining seconds for time-based timer

+
Source

pub fn decrement_episode(&mut self) -> bool

Decrement episode counter, returns true if should stop +Only counts TV episodes, not audio tracks

+
Source

pub fn cancel(&mut self)

Cancel the timer

+

Trait Implementations§

Source§

impl Clone for SleepTimerState

Source§

fn clone(&self) -> SleepTimerState

Returns a duplicate of the value. Read more
1.0.0 (const: unstable) · Source§

fn clone_from(&mut self, source: &Self)

Performs copy-assignment from source. Read more
Source§

impl Debug for SleepTimerState

Source§

fn fmt(&self, f: &mut Formatter<'_>) -> Result

Formats the value using the given formatter. Read more
Source§

impl Default for SleepTimerState

Source§

fn default() -> Self

Returns the “default value” for a type. Read more
Source§

impl<'de> Deserialize<'de> for SleepTimerState

Source§

fn deserialize<__D>(__deserializer: __D) -> Result<Self, __D::Error>
where + __D: Deserializer<'de>,

Deserialize this value from the given Serde deserializer. Read more
Source§

impl NamedType for SleepTimerState

Source§

fn sid() -> SpectaID

Source§

fn named_data_type( + type_map: &mut TypeCollection, + generics: &[DataType], +) -> NamedDataType

this is equivalent to [Type::inline] but returns a [NamedDataType] instead.
Source§

fn definition_named_data_type(type_map: &mut TypeCollection) -> NamedDataType

this is equivalent to [Type::definition] but returns a [NamedDataType] instead.
Source§

impl Serialize for SleepTimerState

Source§

fn serialize<__S>(&self, __serializer: __S) -> Result<__S::Ok, __S::Error>
where + __S: Serializer,

Serialize this value into the given Serde serializer. Read more
Source§

impl Type for SleepTimerState

Source§

fn inline(type_map: &mut TypeCollection, generics: Generics<'_>) -> DataType

Returns the definition of a type using the provided generics. Read more
Source§

fn reference(type_map: &mut TypeCollection, generics: &[DataType]) -> Reference

Generates a datatype corresponding to a reference to this type, +as determined by its category. Getting a reference to a type implies that +it should belong in the type map (since it has to be referenced from somewhere), +so the output of definition will be put into the type map.
Source§

impl Flatten for SleepTimerState

Auto Trait Implementations§

Blanket Implementations§

Source§

impl<T> Any for T
where + T: 'static + ?Sized,

Source§

fn type_id(&self) -> TypeId

Gets the TypeId of self. Read more
Source§

impl<T> Borrow<T> for T
where + T: ?Sized,

Source§

fn borrow(&self) -> &T

Immutably borrows from an owned value. Read more
Source§

impl<T> BorrowMut<T> for T
where + T: ?Sized,

Source§

fn borrow_mut(&mut self) -> &mut T

Mutably borrows from an owned value. Read more
Source§

impl<T> CloneToUninit for T
where + T: Clone,

Source§

unsafe fn clone_to_uninit(&self, dest: *mut u8)

🔬This is a nightly-only experimental API. (clone_to_uninit)
Performs copy-assignment from self to dest. Read more
§

impl<'de, D, R> CommandArg<'de, R> for D
where + D: Deserialize<'de>, + R: Runtime,

§

fn from_command(command: CommandItem<'de, R>) -> Result<D, InvokeError>

Derives an instance of Self from the [CommandItem]. Read more
Source§

impl<T> From<T> for T

Source§

fn from(t: T) -> T

Returns the argument unchanged.

+
§

impl<T> FunctionArg for T
where + T: Type,

§

fn to_datatype(type_map: &mut TypeCollection) -> Option<DataType>

Gets the type of an argument as a [DataType]. Read more
§

impl<T> Instrument for T

§

fn instrument(self, span: Span) -> Instrumented<Self>

Instruments this type with the provided [Span], returning an +Instrumented wrapper. Read more
§

fn in_current_span(self) -> Instrumented<Self>

Instruments this type with the current Span, returning an +Instrumented wrapper. Read more
Source§

impl<T, U> Into<U> for T
where + U: From<T>,

Source§

fn into(self) -> U

Calls U::from(self).

+

That is, this conversion is whatever the implementation of +From<T> for U chooses to do.

+
§

impl<T> IpcResponse for T
where + T: Serialize,

§

fn body(self) -> Result<InvokeResponseBody, Error>

Resolve the IPC response body.
§

impl<T> NoneValue for T
where + T: Default,

§

type NoneType = T

§

fn null_value() -> T

The none-equivalent value.
§

impl<T> PolicyExt for T
where + T: ?Sized,

§

fn and<P, B, E>(self, other: P) -> And<T, P>
where + T: Policy<B, E>, + P: Policy<B, E>,

Create a new Policy that returns [Action::Follow] only if self and other return +Action::Follow. Read more
§

fn or<P, B, E>(self, other: P) -> Or<T, P>
where + T: Policy<B, E>, + P: Policy<B, E>,

Create a new Policy that returns [Action::Follow] if either self or other returns +Action::Follow. Read more
Source§

impl<T> Same for T

Source§

type Output = T

Should always be Self
§

impl<T> ScopeObject for T
where + T: Send + Sync + Debug + DeserializeOwned + 'static,

§

type Error = Error

The error type.
§

fn deserialize<R>( + _app: &AppHandle<R>, + raw: Value, +) -> Result<T, <T as ScopeObject>::Error>
where + R: Runtime,

Deserialize the raw scope value.
Source§

impl<T> Serialize for T
where + T: Serialize + ?Sized,

Source§

fn erased_serialize(&self, serializer: &mut dyn Serializer) -> Result<(), Error>

Source§

fn do_erased_serialize( + &self, + serializer: &mut dyn Serializer, +) -> Result<(), ErrorImpl>

Source§

impl<T> ToOwned for T
where + T: Clone,

Source§

type Owned = T

The resulting type after obtaining ownership.
Source§

fn to_owned(&self) -> T

Creates owned data from borrowed data, usually by cloning. Read more
Source§

fn clone_into(&self, target: &mut T)

Uses borrowed data to replace owned data, usually by cloning. Read more
Source§

impl<T, U> TryFrom<U> for T
where + U: Into<T>,

Source§

type Error = Infallible

The type returned in the event of a conversion error.
Source§

fn try_from(value: U) -> Result<T, <T as TryFrom<U>>::Error>

Performs the conversion.
Source§

impl<T, U> TryInto<U> for T
where + U: TryFrom<T>,

Source§

type Error = <U as TryFrom<T>>::Error

The type returned in the event of a conversion error.
Source§

fn try_into(self) -> Result<U, <U as TryFrom<T>>::Error>

Performs the conversion.
§

impl<V, T> VZip<V> for T
where + V: MultiLane<T>,

§

fn vzip(self) -> V

§

impl<T> WithSubscriber for T

§

fn with_subscriber<S>(self, subscriber: S) -> WithDispatch<Self>
where + S: Into<Dispatch>,

Attaches the provided Subscriber to this type, returning a +[WithDispatch] wrapper. Read more
§

fn with_current_subscriber(self) -> WithDispatch<Self>

Attaches the current default Subscriber to this type, returning a +[WithDispatch] wrapper. Read more
Source§

impl<T> DeserializeOwned for T
where + T: for<'de> Deserialize<'de>,

§

impl<T> UserEvent for T
where + T: Debug + Clone + Send + 'static,

\ No newline at end of file diff --git a/api/jellytau_lib/player/state/enum.EndReason.html b/api/jellytau_lib/player/state/enum.EndReason.html new file mode 100644 index 00000000..4db65876 --- /dev/null +++ b/api/jellytau_lib/player/state/enum.EndReason.html @@ -0,0 +1,69 @@ +EndReason in jellytau_lib::player::state - RustSkip to main content

EndReason

Enum EndReason 

Source
pub enum EndReason {
+    Finished,
+    UserSkip,
+    UserStop,
+    Error,
+    NewTrackLoaded,
+}
Expand description

Tracks why playback ended to determine autoplay behavior

+

TRACES: UR-005 | DR-001

+

Variants§

§

Finished

Track played to completion (natural end) - trigger autoplay

+
§

UserSkip

User pressed next/previous - already handled, don’t autoplay

+
§

UserStop

User stopped playback - don’t autoplay

+
§

Error

Playback error - don’t autoplay

+
§

NewTrackLoaded

User selected a different track - don’t autoplay

+

Trait Implementations§

Source§

impl Clone for EndReason

Source§

fn clone(&self) -> EndReason

Returns a duplicate of the value. Read more
1.0.0 (const: unstable) · Source§

fn clone_from(&mut self, source: &Self)

Performs copy-assignment from source. Read more
Source§

impl Debug for EndReason

Source§

fn fmt(&self, f: &mut Formatter<'_>) -> Result

Formats the value using the given formatter. Read more
Source§

impl<'de> Deserialize<'de> for EndReason

Source§

fn deserialize<__D>(__deserializer: __D) -> Result<Self, __D::Error>
where + __D: Deserializer<'de>,

Deserialize this value from the given Serde deserializer. Read more
Source§

impl NamedType for EndReason

Source§

fn sid() -> SpectaID

Source§

fn named_data_type( + type_map: &mut TypeCollection, + generics: &[DataType], +) -> NamedDataType

this is equivalent to [Type::inline] but returns a [NamedDataType] instead.
Source§

fn definition_named_data_type(type_map: &mut TypeCollection) -> NamedDataType

this is equivalent to [Type::definition] but returns a [NamedDataType] instead.
Source§

impl PartialEq for EndReason

Source§

fn eq(&self, other: &EndReason) -> bool

Tests for self and other values to be equal, and is used by ==.
1.0.0 (const: unstable) · Source§

fn ne(&self, other: &Rhs) -> bool

Tests for !=. The default implementation is almost always sufficient, +and should not be overridden without very good reason.
Source§

impl Serialize for EndReason

Source§

fn serialize<__S>(&self, __serializer: __S) -> Result<__S::Ok, __S::Error>
where + __S: Serializer,

Serialize this value into the given Serde serializer. Read more
Source§

impl Type for EndReason

Source§

fn inline(type_map: &mut TypeCollection, generics: Generics<'_>) -> DataType

Returns the definition of a type using the provided generics. Read more
Source§

fn reference(type_map: &mut TypeCollection, generics: &[DataType]) -> Reference

Generates a datatype corresponding to a reference to this type, +as determined by its category. Getting a reference to a type implies that +it should belong in the type map (since it has to be referenced from somewhere), +so the output of definition will be put into the type map.
Source§

impl Copy for EndReason

Source§

impl Eq for EndReason

Source§

impl StructuralPartialEq for EndReason

Auto Trait Implementations§

Blanket Implementations§

Source§

impl<T> Any for T
where + T: 'static + ?Sized,

Source§

fn type_id(&self) -> TypeId

Gets the TypeId of self. Read more
Source§

impl<T> Borrow<T> for T
where + T: ?Sized,

Source§

fn borrow(&self) -> &T

Immutably borrows from an owned value. Read more
Source§

impl<T> BorrowMut<T> for T
where + T: ?Sized,

Source§

fn borrow_mut(&mut self) -> &mut T

Mutably borrows from an owned value. Read more
Source§

impl<T> CloneToUninit for T
where + T: Clone,

Source§

unsafe fn clone_to_uninit(&self, dest: *mut u8)

🔬This is a nightly-only experimental API. (clone_to_uninit)
Performs copy-assignment from self to dest. Read more
§

impl<'de, D, R> CommandArg<'de, R> for D
where + D: Deserialize<'de>, + R: Runtime,

§

fn from_command(command: CommandItem<'de, R>) -> Result<D, InvokeError>

Derives an instance of Self from the [CommandItem]. Read more
§

impl<Q, K> Equivalent<K> for Q
where + Q: Eq + ?Sized, + K: Borrow<Q> + ?Sized,

§

fn equivalent(&self, key: &K) -> bool

Checks if this value is equivalent to the given key. Read more
§

impl<Q, K> Equivalent<K> for Q
where + Q: Eq + ?Sized, + K: Borrow<Q> + ?Sized,

§

fn equivalent(&self, key: &K) -> bool

Compare self to key and return true if they are equal.
§

impl<Q, K> Equivalent<K> for Q
where + Q: Eq + ?Sized, + K: Borrow<Q> + ?Sized,

§

fn equivalent(&self, key: &K) -> bool

Checks if this value is equivalent to the given key. Read more
Source§

impl<T> From<T> for T

Source§

fn from(t: T) -> T

Returns the argument unchanged.

+
§

impl<T> FunctionArg for T
where + T: Type,

§

fn to_datatype(type_map: &mut TypeCollection) -> Option<DataType>

Gets the type of an argument as a [DataType]. Read more
§

impl<T> Instrument for T

§

fn instrument(self, span: Span) -> Instrumented<Self>

Instruments this type with the provided [Span], returning an +Instrumented wrapper. Read more
§

fn in_current_span(self) -> Instrumented<Self>

Instruments this type with the current Span, returning an +Instrumented wrapper. Read more
Source§

impl<T, U> Into<U> for T
where + U: From<T>,

Source§

fn into(self) -> U

Calls U::from(self).

+

That is, this conversion is whatever the implementation of +From<T> for U chooses to do.

+
§

impl<T> IpcResponse for T
where + T: Serialize,

§

fn body(self) -> Result<InvokeResponseBody, Error>

Resolve the IPC response body.
§

impl<T> PolicyExt for T
where + T: ?Sized,

§

fn and<P, B, E>(self, other: P) -> And<T, P>
where + T: Policy<B, E>, + P: Policy<B, E>,

Create a new Policy that returns [Action::Follow] only if self and other return +Action::Follow. Read more
§

fn or<P, B, E>(self, other: P) -> Or<T, P>
where + T: Policy<B, E>, + P: Policy<B, E>,

Create a new Policy that returns [Action::Follow] if either self or other returns +Action::Follow. Read more
Source§

impl<T> Same for T

Source§

type Output = T

Should always be Self
§

impl<T> ScopeObject for T
where + T: Send + Sync + Debug + DeserializeOwned + 'static,

§

type Error = Error

The error type.
§

fn deserialize<R>( + _app: &AppHandle<R>, + raw: Value, +) -> Result<T, <T as ScopeObject>::Error>
where + R: Runtime,

Deserialize the raw scope value.
Source§

impl<T> Serialize for T
where + T: Serialize + ?Sized,

Source§

fn erased_serialize(&self, serializer: &mut dyn Serializer) -> Result<(), Error>

Source§

fn do_erased_serialize( + &self, + serializer: &mut dyn Serializer, +) -> Result<(), ErrorImpl>

Source§

impl<T> ToOwned for T
where + T: Clone,

Source§

type Owned = T

The resulting type after obtaining ownership.
Source§

fn to_owned(&self) -> T

Creates owned data from borrowed data, usually by cloning. Read more
Source§

fn clone_into(&self, target: &mut T)

Uses borrowed data to replace owned data, usually by cloning. Read more
Source§

impl<T, U> TryFrom<U> for T
where + U: Into<T>,

Source§

type Error = Infallible

The type returned in the event of a conversion error.
Source§

fn try_from(value: U) -> Result<T, <T as TryFrom<U>>::Error>

Performs the conversion.
Source§

impl<T, U> TryInto<U> for T
where + U: TryFrom<T>,

Source§

type Error = <U as TryFrom<T>>::Error

The type returned in the event of a conversion error.
Source§

fn try_into(self) -> Result<U, <U as TryFrom<T>>::Error>

Performs the conversion.
§

impl<V, T> VZip<V> for T
where + V: MultiLane<T>,

§

fn vzip(self) -> V

§

impl<T> WithSubscriber for T

§

fn with_subscriber<S>(self, subscriber: S) -> WithDispatch<Self>
where + S: Into<Dispatch>,

Attaches the provided Subscriber to this type, returning a +[WithDispatch] wrapper. Read more
§

fn with_current_subscriber(self) -> WithDispatch<Self>

Attaches the current default Subscriber to this type, returning a +[WithDispatch] wrapper. Read more
Source§

impl<T> DeserializeOwned for T
where + T: for<'de> Deserialize<'de>,

§

impl<T> UserEvent for T
where + T: Debug + Clone + Send + 'static,

\ No newline at end of file diff --git a/api/jellytau_lib/player/state/enum.PlayerState.html b/api/jellytau_lib/player/state/enum.PlayerState.html new file mode 100644 index 00000000..652ac87c --- /dev/null +++ b/api/jellytau_lib/player/state/enum.PlayerState.html @@ -0,0 +1,92 @@ +PlayerState in jellytau_lib::player::state - RustSkip to main content

PlayerState

Enum PlayerState 

Source
pub enum PlayerState {
+    Idle,
+    Loading {
+        media: MediaItem,
+    },
+    Playing {
+        media: MediaItem,
+        position: f64,
+        duration: f64,
+    },
+    Paused {
+        media: MediaItem,
+        position: f64,
+        duration: f64,
+    },
+    Seeking {
+        media: MediaItem,
+        target: f64,
+    },
+    Error {
+        media: Option<MediaItem>,
+        error: String,
+    },
+}
Expand description

Player state machine (6 states: Idle, Loading, Playing, Paused, Seeking, Error)

+

TRACES: UR-005 | DR-001

+

Variants§

§

Idle

No media loaded

+
§

Loading

Media is being loaded/buffered

+

Fields

§

Playing

Media is playing

+

Fields

§position: f64

Current position in seconds

+
§duration: f64

Total duration in seconds

+
§

Paused

Media is paused

+

Fields

§position: f64

Current position in seconds

+
§duration: f64

Total duration in seconds

+
§

Seeking

Seeking to a new position

+

Fields

§target: f64

Target position in seconds

+
§

Error

An error occurred

+

Fields

§error: String

Implementations§

Source§

impl PlayerState

Source

pub fn position(&self) -> Option<f64>

Get the current playback position if available.

+

Note: this is the position snapshot embedded in the state at the last +state transition, not the live backend position. For an up-to-date +value use PlayerController::position().

+
Source

pub fn is_playing(&self) -> bool

Check if the player is currently playing

+
Source

pub fn is_paused(&self) -> bool

Check if the player is currently paused

+

Trait Implementations§

Source§

impl Clone for PlayerState

Source§

fn clone(&self) -> PlayerState

Returns a duplicate of the value. Read more
1.0.0 (const: unstable) · Source§

fn clone_from(&mut self, source: &Self)

Performs copy-assignment from source. Read more
Source§

impl Debug for PlayerState

Source§

fn fmt(&self, f: &mut Formatter<'_>) -> Result

Formats the value using the given formatter. Read more
Source§

impl Default for PlayerState

Source§

fn default() -> PlayerState

Returns the “default value” for a type. Read more
Source§

impl<'de> Deserialize<'de> for PlayerState

Source§

fn deserialize<__D>(__deserializer: __D) -> Result<Self, __D::Error>
where + __D: Deserializer<'de>,

Deserialize this value from the given Serde deserializer. Read more
Source§

impl NamedType for PlayerState

Source§

fn sid() -> SpectaID

Source§

fn named_data_type( + type_map: &mut TypeCollection, + generics: &[DataType], +) -> NamedDataType

this is equivalent to [Type::inline] but returns a [NamedDataType] instead.
Source§

fn definition_named_data_type(type_map: &mut TypeCollection) -> NamedDataType

this is equivalent to [Type::definition] but returns a [NamedDataType] instead.
Source§

impl Serialize for PlayerState

Source§

fn serialize<__S>(&self, __serializer: __S) -> Result<__S::Ok, __S::Error>
where + __S: Serializer,

Serialize this value into the given Serde serializer. Read more
Source§

impl Type for PlayerState

Source§

fn inline(type_map: &mut TypeCollection, generics: Generics<'_>) -> DataType

Returns the definition of a type using the provided generics. Read more
Source§

fn reference(type_map: &mut TypeCollection, generics: &[DataType]) -> Reference

Generates a datatype corresponding to a reference to this type, +as determined by its category. Getting a reference to a type implies that +it should belong in the type map (since it has to be referenced from somewhere), +so the output of definition will be put into the type map.
Source§

impl Flatten for PlayerState

Auto Trait Implementations§

Blanket Implementations§

Source§

impl<T> Any for T
where + T: 'static + ?Sized,

Source§

fn type_id(&self) -> TypeId

Gets the TypeId of self. Read more
Source§

impl<T> Borrow<T> for T
where + T: ?Sized,

Source§

fn borrow(&self) -> &T

Immutably borrows from an owned value. Read more
Source§

impl<T> BorrowMut<T> for T
where + T: ?Sized,

Source§

fn borrow_mut(&mut self) -> &mut T

Mutably borrows from an owned value. Read more
Source§

impl<T> CloneToUninit for T
where + T: Clone,

Source§

unsafe fn clone_to_uninit(&self, dest: *mut u8)

🔬This is a nightly-only experimental API. (clone_to_uninit)
Performs copy-assignment from self to dest. Read more
§

impl<'de, D, R> CommandArg<'de, R> for D
where + D: Deserialize<'de>, + R: Runtime,

§

fn from_command(command: CommandItem<'de, R>) -> Result<D, InvokeError>

Derives an instance of Self from the [CommandItem]. Read more
Source§

impl<T> From<T> for T

Source§

fn from(t: T) -> T

Returns the argument unchanged.

+
§

impl<T> FunctionArg for T
where + T: Type,

§

fn to_datatype(type_map: &mut TypeCollection) -> Option<DataType>

Gets the type of an argument as a [DataType]. Read more
§

impl<T> Instrument for T

§

fn instrument(self, span: Span) -> Instrumented<Self>

Instruments this type with the provided [Span], returning an +Instrumented wrapper. Read more
§

fn in_current_span(self) -> Instrumented<Self>

Instruments this type with the current Span, returning an +Instrumented wrapper. Read more
Source§

impl<T, U> Into<U> for T
where + U: From<T>,

Source§

fn into(self) -> U

Calls U::from(self).

+

That is, this conversion is whatever the implementation of +From<T> for U chooses to do.

+
§

impl<T> IpcResponse for T
where + T: Serialize,

§

fn body(self) -> Result<InvokeResponseBody, Error>

Resolve the IPC response body.
§

impl<T> NoneValue for T
where + T: Default,

§

type NoneType = T

§

fn null_value() -> T

The none-equivalent value.
§

impl<T> PolicyExt for T
where + T: ?Sized,

§

fn and<P, B, E>(self, other: P) -> And<T, P>
where + T: Policy<B, E>, + P: Policy<B, E>,

Create a new Policy that returns [Action::Follow] only if self and other return +Action::Follow. Read more
§

fn or<P, B, E>(self, other: P) -> Or<T, P>
where + T: Policy<B, E>, + P: Policy<B, E>,

Create a new Policy that returns [Action::Follow] if either self or other returns +Action::Follow. Read more
Source§

impl<T> Same for T

Source§

type Output = T

Should always be Self
§

impl<T> ScopeObject for T
where + T: Send + Sync + Debug + DeserializeOwned + 'static,

§

type Error = Error

The error type.
§

fn deserialize<R>( + _app: &AppHandle<R>, + raw: Value, +) -> Result<T, <T as ScopeObject>::Error>
where + R: Runtime,

Deserialize the raw scope value.
Source§

impl<T> Serialize for T
where + T: Serialize + ?Sized,

Source§

fn erased_serialize(&self, serializer: &mut dyn Serializer) -> Result<(), Error>

Source§

fn do_erased_serialize( + &self, + serializer: &mut dyn Serializer, +) -> Result<(), ErrorImpl>

Source§

impl<T> ToOwned for T
where + T: Clone,

Source§

type Owned = T

The resulting type after obtaining ownership.
Source§

fn to_owned(&self) -> T

Creates owned data from borrowed data, usually by cloning. Read more
Source§

fn clone_into(&self, target: &mut T)

Uses borrowed data to replace owned data, usually by cloning. Read more
Source§

impl<T, U> TryFrom<U> for T
where + U: Into<T>,

Source§

type Error = Infallible

The type returned in the event of a conversion error.
Source§

fn try_from(value: U) -> Result<T, <T as TryFrom<U>>::Error>

Performs the conversion.
Source§

impl<T, U> TryInto<U> for T
where + U: TryFrom<T>,

Source§

type Error = <U as TryFrom<T>>::Error

The type returned in the event of a conversion error.
Source§

fn try_into(self) -> Result<U, <U as TryFrom<T>>::Error>

Performs the conversion.
§

impl<V, T> VZip<V> for T
where + V: MultiLane<T>,

§

fn vzip(self) -> V

§

impl<T> WithSubscriber for T

§

fn with_subscriber<S>(self, subscriber: S) -> WithDispatch<Self>
where + S: Into<Dispatch>,

Attaches the provided Subscriber to this type, returning a +[WithDispatch] wrapper. Read more
§

fn with_current_subscriber(self) -> WithDispatch<Self>

Attaches the current default Subscriber to this type, returning a +[WithDispatch] wrapper. Read more
Source§

impl<T> DeserializeOwned for T
where + T: for<'de> Deserialize<'de>,

§

impl<T> UserEvent for T
where + T: Debug + Clone + Send + 'static,

\ No newline at end of file diff --git a/api/jellytau_lib/player/state/index.html b/api/jellytau_lib/player/state/index.html new file mode 100644 index 00000000..ad27703b --- /dev/null +++ b/api/jellytau_lib/player/state/index.html @@ -0,0 +1 @@ +jellytau_lib::player::state - RustSkip to main content

Module state

Module state 

Source

Enums§

EndReason
Tracks why playback ended to determine autoplay behavior
PlayerState
Player state machine (6 states: Idle, Loading, Playing, Paused, Seeking, Error)
\ No newline at end of file diff --git a/api/jellytau_lib/player/state/sidebar-items.js b/api/jellytau_lib/player/state/sidebar-items.js new file mode 100644 index 00000000..22c605de --- /dev/null +++ b/api/jellytau_lib/player/state/sidebar-items.js @@ -0,0 +1 @@ +window.SIDEBAR_ITEMS = {"enum":["EndReason","PlayerState"]}; \ No newline at end of file diff --git a/api/jellytau_lib/player/stream_end/constant.MAX_STALLED_RESUME_ATTEMPTS.html b/api/jellytau_lib/player/stream_end/constant.MAX_STALLED_RESUME_ATTEMPTS.html new file mode 100644 index 00000000..7d0c26e5 --- /dev/null +++ b/api/jellytau_lib/player/stream_end/constant.MAX_STALLED_RESUME_ATTEMPTS.html @@ -0,0 +1,5 @@ +MAX_STALLED_RESUME_ATTEMPTS in jellytau_lib::player::stream_end - RustSkip to main content

MAX_STALLED_RESUME_ATTEMPTS

Constant MAX_STALLED_RESUME_ATTEMPTS 

Source
pub const MAX_STALLED_RESUME_ATTEMPTS: u32 = 3;
Expand description

Consecutive resume attempts allowed at the same position before giving up.

+

A resume re-opens the same URL, so a server that is genuinely gone would +otherwise end → resume → end forever. Progress past the last attempt resets +the budget (see ResumeTracker), so this only bounds stuck retries.

+
\ No newline at end of file diff --git a/api/jellytau_lib/player/stream_end/constant.RESUME_PROGRESS_EPSILON_SECS.html b/api/jellytau_lib/player/stream_end/constant.RESUME_PROGRESS_EPSILON_SECS.html new file mode 100644 index 00000000..ff68ad15 --- /dev/null +++ b/api/jellytau_lib/player/stream_end/constant.RESUME_PROGRESS_EPSILON_SECS.html @@ -0,0 +1,3 @@ +RESUME_PROGRESS_EPSILON_SECS in jellytau_lib::player::stream_end - RustSkip to main content

RESUME_PROGRESS_EPSILON_SECS

Constant RESUME_PROGRESS_EPSILON_SECS 

Source
const RESUME_PROGRESS_EPSILON_SECS: f64 = 1.0;
Expand description

Position change that counts as “this is a different playback context” — +either the resume made progress, or a different item is loaded.

+
\ No newline at end of file diff --git a/api/jellytau_lib/player/stream_end/constant.TRUNCATED_STREAM_TOLERANCE_SECS.html b/api/jellytau_lib/player/stream_end/constant.TRUNCATED_STREAM_TOLERANCE_SECS.html new file mode 100644 index 00000000..692895ec --- /dev/null +++ b/api/jellytau_lib/player/stream_end/constant.TRUNCATED_STREAM_TOLERANCE_SECS.html @@ -0,0 +1,10 @@ +TRUNCATED_STREAM_TOLERANCE_SECS in jellytau_lib::player::stream_end - RustSkip to main content

TRUNCATED_STREAM_TOLERANCE_SECS

Constant TRUNCATED_STREAM_TOLERANCE_SECS 

Source
pub const TRUNCATED_STREAM_TOLERANCE_SECS: f64 = 10.0;
Expand description

How far short of the item’s runtime a stream may end and still count as a +natural finish.

+

Sized to swallow the two sources of slack in the comparison — the position +poll is up to 250 ms stale, and Jellyfin’s reported runtime can disagree with +the transcoded output by a second or two — while staying far below the +minutes-long gap a dropped connection leaves. Erring long is the safe +direction: a false “finished” is the bug we are fixing, whereas a false +“truncated” only re-opens the stream for its last few seconds and then ends +again normally.

+
\ No newline at end of file diff --git a/api/jellytau_lib/player/stream_end/fn.is_audio_only_video.html b/api/jellytau_lib/player/stream_end/fn.is_audio_only_video.html new file mode 100644 index 00000000..0da6996a --- /dev/null +++ b/api/jellytau_lib/player/stream_end/fn.is_audio_only_video.html @@ -0,0 +1,5 @@ +is_audio_only_video in jellytau_lib::player::stream_end - RustSkip to main content

is_audio_only_video

Function is_audio_only_video 

Source
pub fn is_audio_only_video(item: &MediaItem) -> bool
Expand description

A video item played through the native audio path — i.e. the background +audio-only handoff, the only place a length-less progressive transcode is +used. Jellyfin’s item-type taxonomy stays in Rust (CLAUDE.md).

+

TRACES: UR-040 | DR-129, DR-203 | UT-117, UT-200

+
\ No newline at end of file diff --git a/api/jellytau_lib/player/stream_end/fn.is_truncated_end.html b/api/jellytau_lib/player/stream_end/fn.is_truncated_end.html new file mode 100644 index 00000000..562addc1 --- /dev/null +++ b/api/jellytau_lib/player/stream_end/fn.is_truncated_end.html @@ -0,0 +1,14 @@ +is_truncated_end in jellytau_lib::player::stream_end - RustSkip to main content

is_truncated_end

Function is_truncated_end 

Source
pub fn is_truncated_end(
+    position: f64,
+    duration: Option<f64>,
+    tolerance: f64,
+) -> bool
Expand description

Did this end-of-stream happen far enough short of the item’s runtime to be a +truncation rather than a finish?

+

position and duration must be on the same timeline — for a handoff stream +built with StartTimeTicks, that means the absolute position (handoff base

+
    +
  • the player’s relative position) against the item’s full runtime.
  • +
+

An unknown or non-positive duration answers false: with nothing to +compare against, the reported end is taken at face value (previous behaviour).

+
\ No newline at end of file diff --git a/api/jellytau_lib/player/stream_end/fn.player_retry_restarts_stream.html b/api/jellytau_lib/player/stream_end/fn.player_retry_restarts_stream.html new file mode 100644 index 00000000..ffa1f5a6 --- /dev/null +++ b/api/jellytau_lib/player/stream_end/fn.player_retry_restarts_stream.html @@ -0,0 +1,29 @@ +player_retry_restarts_stream in jellytau_lib::player::stream_end - RustSkip to main content

player_retry_restarts_stream

Function player_retry_restarts_stream 

Source
pub fn player_retry_restarts_stream(item: &MediaItem) -> bool
Expand description

Would the player’s own load-error retry restart this stream from its +beginning? If so the retry must be switched off and recovery left to +crate::player::PlayerController::recoverable_error_resume.

+

ExoPlayer resumes a failed load in place only when it knows where “in place” +is: ProgressiveMediaPeriod.configureRetry keeps the load position when the +content length is known or the extractor produced a seek map with a +duration, and otherwise treats the source as live — the data at the URL is +assumed to have changed, so it resets every sample queue and re-requests the +URL from offset 0.

+

The handoff transcode satisfies neither condition: it is chunked (no +Content-Length) and a live mp3 encode carries no Xing header, so the +player reports its duration as unset — visible in logcat as every position +tick reading <position> / 0.0. Its URL carries StartTimeTicks = the +handoff point, so restarting it from offset 0 restarts the episode at the +handoff point, and playback then runs on from there. Nothing surfaces: no +error, no STATE_ENDED, so neither the truncation path nor the error path of +DR-129 is consulted, and the app’s only sign of it is a position that jumps +backwards. That is the “it randomly jumps back to where audio-only started” +the user sees, and how random it is depends on whether a network blip happens +to land while a load is in flight rather than while the ~50s buffer covers it.

+

A retry that can only restart the stream is worth less than no retry at all: +declining it turns the silent rewind into a recoverable error, which +recoverable_error_resume answers by re-opening the stream at the position +playback actually reached (StartTimeTicks rewritten, backoff and attempt +budget included). Every other source keeps the player’s retry: a static file +and an HLS playlist both declare their timeline, so ExoPlayer resumes them +exactly where the load failed.

+

TRACES: UR-040, UR-004 | DR-203 | UT-200

+
\ No newline at end of file diff --git a/api/jellytau_lib/player/stream_end/fn.with_start_time.html b/api/jellytau_lib/player/stream_end/fn.with_start_time.html new file mode 100644 index 00000000..99b95b9f --- /dev/null +++ b/api/jellytau_lib/player/stream_end/fn.with_start_time.html @@ -0,0 +1,7 @@ +with_start_time in jellytau_lib::player::stream_end - RustSkip to main content

with_start_time

Function with_start_time 

Source
pub fn with_start_time(url: &str, position_seconds: f64) -> String
Expand description

Rewrite an audio-only stream URL to start at position_seconds.

+

Resuming re-opens the stream we were already playing, so the URL is edited +in place rather than rebuilt from the repository: every other parameter — +AudioStreamIndex (the track the user picked in the video player), +MediaSourceId, api_key — is carried over untouched, and no network call +is needed to recover from a network failure.

+
\ No newline at end of file diff --git a/api/jellytau_lib/player/stream_end/index.html b/api/jellytau_lib/player/stream_end/index.html new file mode 100644 index 00000000..92d3865e --- /dev/null +++ b/api/jellytau_lib/player/stream_end/index.html @@ -0,0 +1,27 @@ +jellytau_lib::player::stream_end - RustSkip to main content

Module stream_end

Module stream_end 

Source
Expand description

Telling a finished stream apart from a truncated one.

+

TRACES: UR-040 | DR-129 | UT-117

+

Background audio-only playback of a video item streams a progressive mp3 +transcode over plain HTTP (see +OnlineRepository::build_audio_only_stream_url_for_video). That response has +no reliable length — a live transcode is chunked — so when the connection +drops mid-episode the data source simply sees end-of-input. ExoPlayer cannot +distinguish that from the real end of the media and reports +Player.STATE_ENDED, which the app then treats as “the episode finished”.

+

The user-visible damage is not the missed advance itself. Playback parks in +ExoPlayer’s STATE_ENDED, and the next play intent from the lockscreen, +notification or a Bluetooth reconnect goes through media3’s +Util.handlePlayButtonAction, which seeks an ENDED player to its default +position before playing — so the episode starts over from 0:00. On a +flaky connection that reads as “it randomly restarts the episode”.

+

The player itself has no way to know; the duration does. Jellyfin gives us +the item’s real runtime, so an end reported well short of it is a truncation, +not a finish — and the right response is to re-open the stream where it died, +which is the “buffer and resume” the user expects.

+

Structs§

ObservedTime
The last playback time actually observed while media was loaded.
ResumeTracker
Budget for consecutive resume attempts that make no progress.

Constants§

MAX_STALLED_RESUME_ATTEMPTS
Consecutive resume attempts allowed at the same position before giving up.
RESUME_PROGRESS_EPSILON_SECS 🔒
Position change that counts as “this is a different playback context” — +either the resume made progress, or a different item is loaded.
TRUNCATED_STREAM_TOLERANCE_SECS
How far short of the item’s runtime a stream may end and still count as a +natural finish.

Functions§

is_audio_only_video
A video item played through the native audio path — i.e. the background +audio-only handoff, the only place a length-less progressive transcode is +used. Jellyfin’s item-type taxonomy stays in Rust (CLAUDE.md).
is_truncated_end
Did this end-of-stream happen far enough short of the item’s runtime to be a +truncation rather than a finish?
player_retry_restarts_stream
Would the player’s own load-error retry restart this stream from its +beginning? If so the retry must be switched off and recovery left to +crate::player::PlayerController::recoverable_error_resume.
with_start_time
Rewrite an audio-only stream URL to start at position_seconds.
\ No newline at end of file diff --git a/api/jellytau_lib/player/stream_end/sidebar-items.js b/api/jellytau_lib/player/stream_end/sidebar-items.js new file mode 100644 index 00000000..103b3a75 --- /dev/null +++ b/api/jellytau_lib/player/stream_end/sidebar-items.js @@ -0,0 +1 @@ +window.SIDEBAR_ITEMS = {"constant":["MAX_STALLED_RESUME_ATTEMPTS","RESUME_PROGRESS_EPSILON_SECS","TRUNCATED_STREAM_TOLERANCE_SECS"],"fn":["is_audio_only_video","is_truncated_end","player_retry_restarts_stream","with_start_time"],"struct":["ObservedTime","ResumeTracker"]}; \ No newline at end of file diff --git a/api/jellytau_lib/player/stream_end/struct.ObservedTime.html b/api/jellytau_lib/player/stream_end/struct.ObservedTime.html new file mode 100644 index 00000000..cdf4caaa --- /dev/null +++ b/api/jellytau_lib/player/stream_end/struct.ObservedTime.html @@ -0,0 +1,50 @@ +ObservedTime in jellytau_lib::player::stream_end - RustSkip to main content

ObservedTime

Struct ObservedTime 

Source
pub struct ObservedTime {
+    position: f64,
+    duration: Option<f64>,
+}
Expand description

The last playback time actually observed while media was loaded.

+

Some backends expose position and duration as live properties of the +loaded file — MPV’s time-pos and duration stop resolving the moment it +unloads the file at EOF. Reading them straight through means that at exactly +the moment end-of-file handling wants to know where playback got to, the +answer is 0.0 / unknown: the player appears to rewind to 0:00 as it ends.

+

The polling thread records here, and the accessors fall back to it, so an EOF +reads as the last timestamp rather than as zero.

+

Fields§

§position: f64§duration: Option<f64>

Implementations§

Source§

impl ObservedTime

Source

pub fn record(&mut self, position: f64, duration: f64)

Record a live reading. Non-positive durations are treated as unknown — +that is how a backend reports “not established yet”, not a real zero.

+
Source

pub fn record_position(&mut self, position: f64)

Record a position alone, e.g. straight after a seek, before the next poll.

+
Source

pub fn reset(&mut self)

Forget everything — a different file is loading, and the previous one’s +timestamp must not leak into it.

+
Source

pub fn position_or_last(&self, live: Option<f64>) -> f64

The live reading if there is one, else the last observed value.

+
Source

pub fn last_position(&self) -> f64

The last observed position, with no live reading to prefer — the case +where the reporter is the only source there is (webview-rendered media, +which the native backend cannot see at all).

+
Source

pub fn last_duration(&self) -> Option<f64>

The last observed duration, if one was ever established.

+
Source

pub fn duration_or_last(&self, live: Option<f64>) -> Option<f64>

The live reading if there is one, else the last observed value.

+

Trait Implementations§

Source§

impl Clone for ObservedTime

Source§

fn clone(&self) -> ObservedTime

Returns a duplicate of the value. Read more
1.0.0 (const: unstable) · Source§

fn clone_from(&mut self, source: &Self)

Performs copy-assignment from source. Read more
Source§

impl Debug for ObservedTime

Source§

fn fmt(&self, f: &mut Formatter<'_>) -> Result

Formats the value using the given formatter. Read more
Source§

impl Default for ObservedTime

Source§

fn default() -> ObservedTime

Returns the “default value” for a type. Read more
Source§

impl Copy for ObservedTime

Auto Trait Implementations§

Blanket Implementations§

Source§

impl<T> Any for T
where + T: 'static + ?Sized,

Source§

fn type_id(&self) -> TypeId

Gets the TypeId of self. Read more
Source§

impl<T> Borrow<T> for T
where + T: ?Sized,

Source§

fn borrow(&self) -> &T

Immutably borrows from an owned value. Read more
Source§

impl<T> BorrowMut<T> for T
where + T: ?Sized,

Source§

fn borrow_mut(&mut self) -> &mut T

Mutably borrows from an owned value. Read more
Source§

impl<T> CloneToUninit for T
where + T: Clone,

Source§

unsafe fn clone_to_uninit(&self, dest: *mut u8)

🔬This is a nightly-only experimental API. (clone_to_uninit)
Performs copy-assignment from self to dest. Read more
Source§

impl<T> From<T> for T

Source§

fn from(t: T) -> T

Returns the argument unchanged.

+
§

impl<T> Instrument for T

§

fn instrument(self, span: Span) -> Instrumented<Self>

Instruments this type with the provided [Span], returning an +Instrumented wrapper. Read more
§

fn in_current_span(self) -> Instrumented<Self>

Instruments this type with the current Span, returning an +Instrumented wrapper. Read more
Source§

impl<T, U> Into<U> for T
where + U: From<T>,

Source§

fn into(self) -> U

Calls U::from(self).

+

That is, this conversion is whatever the implementation of +From<T> for U chooses to do.

+
§

impl<T> NoneValue for T
where + T: Default,

§

type NoneType = T

§

fn null_value() -> T

The none-equivalent value.
§

impl<T> PolicyExt for T
where + T: ?Sized,

§

fn and<P, B, E>(self, other: P) -> And<T, P>
where + T: Policy<B, E>, + P: Policy<B, E>,

Create a new Policy that returns [Action::Follow] only if self and other return +Action::Follow. Read more
§

fn or<P, B, E>(self, other: P) -> Or<T, P>
where + T: Policy<B, E>, + P: Policy<B, E>,

Create a new Policy that returns [Action::Follow] if either self or other returns +Action::Follow. Read more
Source§

impl<T> Same for T

Source§

type Output = T

Should always be Self
Source§

impl<T> ToOwned for T
where + T: Clone,

Source§

type Owned = T

The resulting type after obtaining ownership.
Source§

fn to_owned(&self) -> T

Creates owned data from borrowed data, usually by cloning. Read more
Source§

fn clone_into(&self, target: &mut T)

Uses borrowed data to replace owned data, usually by cloning. Read more
Source§

impl<T, U> TryFrom<U> for T
where + U: Into<T>,

Source§

type Error = Infallible

The type returned in the event of a conversion error.
Source§

fn try_from(value: U) -> Result<T, <T as TryFrom<U>>::Error>

Performs the conversion.
Source§

impl<T, U> TryInto<U> for T
where + U: TryFrom<T>,

Source§

type Error = <U as TryFrom<T>>::Error

The type returned in the event of a conversion error.
Source§

fn try_into(self) -> Result<U, <U as TryFrom<T>>::Error>

Performs the conversion.
§

impl<V, T> VZip<V> for T
where + V: MultiLane<T>,

§

fn vzip(self) -> V

§

impl<T> WithSubscriber for T

§

fn with_subscriber<S>(self, subscriber: S) -> WithDispatch<Self>
where + S: Into<Dispatch>,

Attaches the provided Subscriber to this type, returning a +[WithDispatch] wrapper. Read more
§

fn with_current_subscriber(self) -> WithDispatch<Self>

Attaches the current default Subscriber to this type, returning a +[WithDispatch] wrapper. Read more
§

impl<T> UserEvent for T
where + T: Debug + Clone + Send + 'static,

\ No newline at end of file diff --git a/api/jellytau_lib/player/stream_end/struct.ResumeTracker.html b/api/jellytau_lib/player/stream_end/struct.ResumeTracker.html new file mode 100644 index 00000000..197ed483 --- /dev/null +++ b/api/jellytau_lib/player/stream_end/struct.ResumeTracker.html @@ -0,0 +1,37 @@ +ResumeTracker in jellytau_lib::player::stream_end - RustSkip to main content

ResumeTracker

Struct ResumeTracker 

Source
pub struct ResumeTracker {
+    last_position: Option<f64>,
+    attempts: u32,
+}
Expand description

Budget for consecutive resume attempts that make no progress.

+

Held by the player controller across ends of the same stream. Any position +change larger than RESUME_PROGRESS_EPSILON_SECS — the resume played on, +or a different item was loaded — is a fresh context and refills the budget.

+

Fields§

§last_position: Option<f64>§attempts: u32

Implementations§

Source§

impl ResumeTracker

Source

pub fn allow_attempt(&mut self, position: f64) -> Option<u32>

Record an attempt at position, returning its 1-based number — or None +once the budget is spent. Callers use the number to back off: a stream +that failed twice at the same spot is waiting on something slower than an +immediate retry can outrun.

+
Source

pub fn reset(&mut self)

Forget the budget — a new item is playing, so nothing is stuck.

+

Trait Implementations§

Source§

impl Debug for ResumeTracker

Source§

fn fmt(&self, f: &mut Formatter<'_>) -> Result

Formats the value using the given formatter. Read more
Source§

impl Default for ResumeTracker

Source§

fn default() -> ResumeTracker

Returns the “default value” for a type. Read more

Auto Trait Implementations§

Blanket Implementations§

Source§

impl<T> Any for T
where + T: 'static + ?Sized,

Source§

fn type_id(&self) -> TypeId

Gets the TypeId of self. Read more
Source§

impl<T> Borrow<T> for T
where + T: ?Sized,

Source§

fn borrow(&self) -> &T

Immutably borrows from an owned value. Read more
Source§

impl<T> BorrowMut<T> for T
where + T: ?Sized,

Source§

fn borrow_mut(&mut self) -> &mut T

Mutably borrows from an owned value. Read more
Source§

impl<T> From<T> for T

Source§

fn from(t: T) -> T

Returns the argument unchanged.

+
§

impl<T> Instrument for T

§

fn instrument(self, span: Span) -> Instrumented<Self>

Instruments this type with the provided [Span], returning an +Instrumented wrapper. Read more
§

fn in_current_span(self) -> Instrumented<Self>

Instruments this type with the current Span, returning an +Instrumented wrapper. Read more
Source§

impl<T, U> Into<U> for T
where + U: From<T>,

Source§

fn into(self) -> U

Calls U::from(self).

+

That is, this conversion is whatever the implementation of +From<T> for U chooses to do.

+
§

impl<T> NoneValue for T
where + T: Default,

§

type NoneType = T

§

fn null_value() -> T

The none-equivalent value.
§

impl<T> PolicyExt for T
where + T: ?Sized,

§

fn and<P, B, E>(self, other: P) -> And<T, P>
where + T: Policy<B, E>, + P: Policy<B, E>,

Create a new Policy that returns [Action::Follow] only if self and other return +Action::Follow. Read more
§

fn or<P, B, E>(self, other: P) -> Or<T, P>
where + T: Policy<B, E>, + P: Policy<B, E>,

Create a new Policy that returns [Action::Follow] if either self or other returns +Action::Follow. Read more
Source§

impl<T> Same for T

Source§

type Output = T

Should always be Self
Source§

impl<T, U> TryFrom<U> for T
where + U: Into<T>,

Source§

type Error = Infallible

The type returned in the event of a conversion error.
Source§

fn try_from(value: U) -> Result<T, <T as TryFrom<U>>::Error>

Performs the conversion.
Source§

impl<T, U> TryInto<U> for T
where + U: TryFrom<T>,

Source§

type Error = <U as TryFrom<T>>::Error

The type returned in the event of a conversion error.
Source§

fn try_into(self) -> Result<U, <U as TryFrom<T>>::Error>

Performs the conversion.
§

impl<V, T> VZip<V> for T
where + V: MultiLane<T>,

§

fn vzip(self) -> V

§

impl<T> WithSubscriber for T

§

fn with_subscriber<S>(self, subscriber: S) -> WithDispatch<Self>
where + S: Into<Dispatch>,

Attaches the provided Subscriber to this type, returning a +[WithDispatch] wrapper. Read more
§

fn with_current_subscriber(self) -> WithDispatch<Self>

Attaches the current default Subscriber to this type, returning a +[WithDispatch] wrapper. Read more
\ No newline at end of file diff --git a/api/jellytau_lib/player/struct.LockscreenMetadata.html b/api/jellytau_lib/player/struct.LockscreenMetadata.html new file mode 100644 index 00000000..56d5f787 --- /dev/null +++ b/api/jellytau_lib/player/struct.LockscreenMetadata.html @@ -0,0 +1,42 @@ +LockscreenMetadata in jellytau_lib::player - RustSkip to main content

LockscreenMetadata

Struct LockscreenMetadata 

Source
pub struct LockscreenMetadata {
+    pub title: String,
+    pub artist: String,
+    pub album: Option<String>,
+    pub duration_ms: i64,
+    pub position_ms: i64,
+    pub is_playing: bool,
+}
Expand description

Metadata for the lockscreen / media notification.

+

Used to drive the Android MediaSession from Rust in remote (cast) mode, where +the local ExoPlayer is idle and so can’t supply now-playing info. The session +poller fills this in from the remote Jellyfin session and pushes it to the +notification so the lockscreen stays in sync while casting.

+

TRACES: UR-006 | IR-006

+

Fields§

§title: String§artist: String§album: Option<String>§duration_ms: i64

Track duration in milliseconds.

+
§position_ms: i64

Current playback position in milliseconds.

+
§is_playing: bool

Trait Implementations§

Source§

impl Clone for LockscreenMetadata

Source§

fn clone(&self) -> LockscreenMetadata

Returns a duplicate of the value. Read more
1.0.0 (const: unstable) · Source§

fn clone_from(&mut self, source: &Self)

Performs copy-assignment from source. Read more
Source§

impl Debug for LockscreenMetadata

Source§

fn fmt(&self, f: &mut Formatter<'_>) -> Result

Formats the value using the given formatter. Read more

Auto Trait Implementations§

Blanket Implementations§

Source§

impl<T> Any for T
where + T: 'static + ?Sized,

Source§

fn type_id(&self) -> TypeId

Gets the TypeId of self. Read more
Source§

impl<T> Borrow<T> for T
where + T: ?Sized,

Source§

fn borrow(&self) -> &T

Immutably borrows from an owned value. Read more
Source§

impl<T> BorrowMut<T> for T
where + T: ?Sized,

Source§

fn borrow_mut(&mut self) -> &mut T

Mutably borrows from an owned value. Read more
Source§

impl<T> CloneToUninit for T
where + T: Clone,

Source§

unsafe fn clone_to_uninit(&self, dest: *mut u8)

🔬This is a nightly-only experimental API. (clone_to_uninit)
Performs copy-assignment from self to dest. Read more
Source§

impl<T> From<T> for T

Source§

fn from(t: T) -> T

Returns the argument unchanged.

+
§

impl<T> Instrument for T

§

fn instrument(self, span: Span) -> Instrumented<Self>

Instruments this type with the provided [Span], returning an +Instrumented wrapper. Read more
§

fn in_current_span(self) -> Instrumented<Self>

Instruments this type with the current Span, returning an +Instrumented wrapper. Read more
Source§

impl<T, U> Into<U> for T
where + U: From<T>,

Source§

fn into(self) -> U

Calls U::from(self).

+

That is, this conversion is whatever the implementation of +From<T> for U chooses to do.

+
§

impl<T> PolicyExt for T
where + T: ?Sized,

§

fn and<P, B, E>(self, other: P) -> And<T, P>
where + T: Policy<B, E>, + P: Policy<B, E>,

Create a new Policy that returns [Action::Follow] only if self and other return +Action::Follow. Read more
§

fn or<P, B, E>(self, other: P) -> Or<T, P>
where + T: Policy<B, E>, + P: Policy<B, E>,

Create a new Policy that returns [Action::Follow] if either self or other returns +Action::Follow. Read more
Source§

impl<T> Same for T

Source§

type Output = T

Should always be Self
Source§

impl<T> ToOwned for T
where + T: Clone,

Source§

type Owned = T

The resulting type after obtaining ownership.
Source§

fn to_owned(&self) -> T

Creates owned data from borrowed data, usually by cloning. Read more
Source§

fn clone_into(&self, target: &mut T)

Uses borrowed data to replace owned data, usually by cloning. Read more
Source§

impl<T, U> TryFrom<U> for T
where + U: Into<T>,

Source§

type Error = Infallible

The type returned in the event of a conversion error.
Source§

fn try_from(value: U) -> Result<T, <T as TryFrom<U>>::Error>

Performs the conversion.
Source§

impl<T, U> TryInto<U> for T
where + U: TryFrom<T>,

Source§

type Error = <U as TryFrom<T>>::Error

The type returned in the event of a conversion error.
Source§

fn try_into(self) -> Result<U, <U as TryFrom<T>>::Error>

Performs the conversion.
§

impl<V, T> VZip<V> for T
where + V: MultiLane<T>,

§

fn vzip(self) -> V

§

impl<T> WithSubscriber for T

§

fn with_subscriber<S>(self, subscriber: S) -> WithDispatch<Self>
where + S: Into<Dispatch>,

Attaches the provided Subscriber to this type, returning a +[WithDispatch] wrapper. Read more
§

fn with_current_subscriber(self) -> WithDispatch<Self>

Attaches the current default Subscriber to this type, returning a +[WithDispatch] wrapper. Read more
§

impl<T> UserEvent for T
where + T: Debug + Clone + Send + 'static,

\ No newline at end of file diff --git a/api/jellytau_lib/player/struct.PlayerController.html b/api/jellytau_lib/player/struct.PlayerController.html new file mode 100644 index 00000000..61293db9 --- /dev/null +++ b/api/jellytau_lib/player/struct.PlayerController.html @@ -0,0 +1,416 @@ +PlayerController in jellytau_lib::player - RustSkip to main content

PlayerController

Struct PlayerController 

Source
pub struct PlayerController {
Show 19 fields + backend: Arc<Mutex<Box<dyn PlayerBackend>>>, + queue: Arc<Mutex<QueueManager>>, + jellyfin_client: Arc<Mutex<Option<JellyfinClient>>>, + muted: bool, + sleep_timer: Arc<Mutex<SleepTimerState>>, + autoplay_settings: Arc<Mutex<AutoplaySettings>>, + repository: Arc<Mutex<Option<Arc<dyn MediaRepository>>>>, + event_emitter: Arc<Mutex<Option<Arc<dyn PlayerEventEmitter>>>>, + countdown_cancel: Arc<Mutex<Option<Arc<Mutex<bool>>>>>, + playback_reporter: Arc<Mutex<Option<PlaybackReporter>>>, + reports: Arc<Mutex<Arc<dyn PlaybackReportSink>>>, + position_throttler: Arc<EventThrottler>, + end_reason: Arc<Mutex<Option<EndReason>>>, + autoplay_episode_count: Arc<Mutex<u32>>, + background_audio_base: Arc<Mutex<f64>>, + background_audio_active: Arc<Mutex<bool>>, + stream_resume: Arc<Mutex<ResumeTracker>>, + html5_playing: Arc<Mutex<Option<bool>>>, + reported_time: Arc<Mutex<ObservedTime>>, +
}
Expand description

Central player controller that coordinates playback

+

Fields§

§backend: Arc<Mutex<Box<dyn PlayerBackend>>>§queue: Arc<Mutex<QueueManager>>§jellyfin_client: Arc<Mutex<Option<JellyfinClient>>>§muted: bool§sleep_timer: Arc<Mutex<SleepTimerState>>§autoplay_settings: Arc<Mutex<AutoplaySettings>>§repository: Arc<Mutex<Option<Arc<dyn MediaRepository>>>>§event_emitter: Arc<Mutex<Option<Arc<dyn PlayerEventEmitter>>>>§countdown_cancel: Arc<Mutex<Option<Arc<Mutex<bool>>>>>§playback_reporter: Arc<Mutex<Option<PlaybackReporter>>>§reports: Arc<Mutex<Arc<dyn PlaybackReportSink>>>§position_throttler: Arc<EventThrottler>§end_reason: Arc<Mutex<Option<EndReason>>>§autoplay_episode_count: Arc<Mutex<u32>>§background_audio_base: Arc<Mutex<f64>>§background_audio_active: Arc<Mutex<bool>>§stream_resume: Arc<Mutex<ResumeTracker>>§html5_playing: Arc<Mutex<Option<bool>>>§reported_time: Arc<Mutex<ObservedTime>>

Implementations§

Source§

impl PlayerController

Source

pub fn new( + backend: Box<dyn PlayerBackend>, + playback_reporter: Arc<TokioMutex<Option<PlaybackReporter>>>, + position_throttler: Arc<EventThrottler>, +) -> Self

Source

pub fn set_jellyfin_client(&self, client: Option<JellyfinClient>)

Configure the Jellyfin API client for automatic playback reporting

+
Source

pub fn jellyfin_client(&self) -> Arc<Mutex<Option<JellyfinClient>>>

Get a reference to the Jellyfin client (for remote session control)

+
Source

pub fn set_repository(&self, repo: Arc<dyn MediaRepository>)

Configure the media repository used for next-episode lookups.

+

The Android ExoPlayer ended-callback calls on_playback_ended with no +repository handle (unlike the Linux HTML5 path, which passes one per +call), so the controller needs a repository of its own or episode +autoplay silently decides Stop.

+
Source

pub async fn set_playback_reporter(&self, reporter: Option<PlaybackReporter>)

Configure the playback reporter for dual sync (local DB + server). +Called from player_configure_jellyfin on login/restore/reauth.

+
Source

pub fn playback_reporter(&self) -> Arc<TokioMutex<Option<PlaybackReporter>>>

Get a reference to the playback reporter (for backend position updates) +Will be used when position update hooks are added to backends

+
Source

pub fn position_throttler(&self) -> Arc<EventThrottler>

Get a reference to the position throttler (for backend position updates) +Will be used when position update hooks are added to backends

+
Source

fn set_end_reason(&self, reason: EndReason)

Set the end reason for the next playback end event

+
Source

fn take_end_reason(&self) -> Option<EndReason>

Get and clear the current end reason

+
Source

fn peek_end_reason(&self) -> Option<EndReason>

Read the end reason WITHOUT consuming it.

+

take_end_reason has an owner: on Android the JNI ended-callback consumes +the NewTrackLoaded every load sets, and the frontend’s echoed call is the +one that sees None and decides. The truncated-stream check runs in both +calls and must not disturb that hand-off, so it peeks.

+
Source

fn note_sleep_timer_stop(end_reason: &Arc<Mutex<Option<EndReason>>>)

Record that playback is being stopped by an expiring sleep timer.

+

Stopping the backend makes it fire its ended callback (ExoPlayer does on +Android), which lands in on_playback_ended. Without an end reason that +reads as a natural finish and autoplay advances — defeating the timer. +UserStop is the honest label: the stop was user-initiated, just via the +timer they set rather than the stop button.

+

Takes the shared slot rather than &self so the sleep-timer thread — +which owns clones, not the controller — records it the same way.

+

TRACES: UR-023, UR-026 | DR-029

+
Source

fn increment_autoplay_count(&self) -> bool

Increment autoplay episode counter. Returns true if limit is reached.

+
Source

fn reset_autoplay_count(&self)

Reset autoplay episode counter (called on manual play actions)

+
Source

pub fn play_item(&self, item: MediaItem) -> Result<(), PlayerError>

Load and play a single item (also sets the queue to contain only this item)

+
Source

pub fn set_current_item(&self, item: MediaItem) -> Result<(), PlayerError>

Set the current queue item without loading it into the playback backend.

+

Used on platforms where video is rendered outside the native backend +(Linux WebKitGTK HTML5

+
Source

pub fn load_and_play(&self, item: &MediaItem) -> Result<(), PlayerError>

Load and play an item without modifying the queue +Use this when the queue is already set up and you just want to play a specific item from it

+
Source

pub fn play_queue( + &self, + items: Vec<MediaItem>, + start_index: usize, +) -> Result<(), PlayerError>

Set the queue and start playing from the specified index

+
Source

pub fn play_queue_from( + &self, + items: Vec<MediaItem>, + start_index: usize, + start_position: Option<f64>, +) -> Result<(), PlayerError>

Set the queue and start playing from the specified index, optionally +resuming the starting track at start_position (seconds).

+

The seek happens immediately after load so the backend never audibly +starts at 0 and there’s no race against a fixed delay. Used when taking +over playback from a remote session.

+
Source

pub fn set_queue( + &self, + items: Vec<MediaItem>, + start_index: usize, +) -> Result<(), PlayerError>

Replace the queue without starting local playback.

+

Used when we’re controlling a remote session: the tracks play on the +remote device, but we keep the local queue in sync so the UI reflects +what’s playing and a later transfer-to-local has the queue to resume.

+
Source

pub fn is_html5_active(&self) -> bool

True while webview-rendered media (HTML5 <video>/<audio>) is the real +player, so transport must be routed to it rather than the native backend.

+

TRACES: UR-005 | DR-097

+
Source

pub fn html5_is_playing(&self) -> bool

Whether the webview element last reported itself as playing. Meaningless +unless Self::is_html5_active is true.

+

TRACES: UR-005 | DR-097

+
Source

fn emit_html5_control(&self, action: &str)

Send a transport intent to the webview element that is rendering media.

+
Source

pub fn play(&self) -> Result<(), PlayerError>

Play/resume playback

+
Source

pub fn pause(&self) -> Result<(), PlayerError>

Pause playback

+
Source

pub fn toggle_playback(&self) -> Result<(), PlayerError>

Toggle play/pause.

+

The decision is made HERE, from authoritative state — the reported webview +state for HTML5-rendered media, or the native backend’s state otherwise. +The frontend must never decide this from the DOM (see DR-097).

+

TRACES: UR-005 | DR-097

+
Source

pub fn stop(&self) -> Result<(), PlayerError>

Stop playback

+
Source

fn report_stopped_at(&self, jellyfin_id: String, position: f64)

Tell Jellyfin playback stopped at position, unless that position is +zero.

+

Jellyfin stores the reported position as the resume point, so a zero is +not a harmless no-op — it is an instruction to forget where the viewer +was. And it is never information: nobody watched zero seconds of +anything, so every zero this app ever sent came from asking a player that +was not rendering the media (webview video, or a handoff whose first tick +had not landed). On a device trace, 14 of 14 stop reports in 35 minutes +were zeroes, one of them 40s after the frontend had correctly reported +15:22 for the same episode.

+

TRACES: UR-025, UR-005 | DR-179 | UT-178

+
Source

pub fn next(&self) -> Result<(), PlayerError>

Skip to next track

+

Note: load_and_play sets EndReason::NewTrackLoaded to prevent autoplay +from triggering when the current track’s EndFile event fires

+
Source

pub fn previous(&self) -> Result<(), PlayerError>

Skip to previous track

+

Note: load_and_play sets EndReason::NewTrackLoaded to prevent autoplay +from triggering when the current track’s EndFile event fires

+
Source

pub fn seek(&self, position: f64) -> Result<(), PlayerError>

Seek to a position in seconds, on the player’s own timeline.

+

During a background-audio handoff that timeline is relative to the handoff +point, so this is not the call a lockscreen scrub or a UI seek wants — use +seek_absolute, which speaks the episode’s +timeline and is what every caller outside the player itself means.

+
Source

pub async fn seek_absolute(&self, position: f64) -> Result<(), String>

Seek to an absolute position on the item’s own timeline.

+

This is the boundary every outside seek comes through — the UI, the +lockscreen scrubber, a headset gesture — because all of them are looking +at the whole episode, not at whatever fragment of it the player happens to +be streaming.

+

Outside a background-audio handoff the two timelines are the same and this +is an ordinary seek. Inside one they differ by the handoff base, and the +stream cannot be seeked at all: /Audio/{id}/universal is a chunked +transcode with no length, so ExoPlayer either refuses or clamps — and a +clamped seek lands at stream zero, which is the handoff point. That is the +“jumps back to where I locked the screen” symptom. Honouring the seek means +re-opening the URL at the new position, which is exactly what the +truncation recovery already does, so it shares resume_stream_at.

+

TRACES: UR-040, UR-005 | DR-159 | UT-155

+
Source

pub fn set_volume(&self, volume: f32) -> Result<(), PlayerError>

Set volume (0.0 - 1.0)

+
Source

pub fn set_audio_track(&self, stream_index: i32) -> Result<(), PlayerError>

Set the active audio track by stream index

+
Source

pub fn set_subtitle_track( + &self, + stream_index: Option<i32>, +) -> Result<(), PlayerError>

Set the active subtitle track by stream index (None to disable subtitles)

+
Source

pub fn state(&self) -> PlayerState

Get current state

+
Source

pub fn position(&self) -> f64

Get current position

+
Source

pub fn absolute_position(&self) -> f64

The position on the item’s own timeline, whatever is rendering it.

+

This is what every outbound position must be taken from — the resume point +sent to Jellyfin, the point the video reloads at when a handoff ends, the +truncation comparison. position() alone answers for exactly one of the +three ways this app plays media, and reads 0 for the other two:

+
    +
  • Webview <video>/<audio>: nothing is loaded into the native +backend, so its position is a permanent 0. The element’s own reports are +the only reading there is.
  • +
  • Background-audio handoff: the audio-only stream’s zero is the +handoff point, and the base is added at the native tick boundary +(DR-159) — so before the first tick lands, nothing has applied it. +Flooring at the base is exact rather than approximate: the stream cannot +physically be behind its own starting point.
  • +
  • Native playback: the backend is authoritative and both other terms +are zero, so the max is its own value.
  • +
+

Returning to the foreground during that pre-first-tick window is what +restarted an episode from 0:00 and wiped its server-side resume point.

+

TRACES: UR-040, UR-005, UR-025 | DR-178 | UT-176, UT-177

+
Source

pub fn observed_duration(&self) -> Option<f64>

The duration last reported by webview-rendered media, if any.

+

TRACES: UR-005 | DR-178 | UT-177

+
Source

fn clear_reported_time(&self)

Forget what webview-rendered media reported.

+

Called wherever that element stops being the player — it was torn down, +a handoff took over, or a different item is loading. A stale position +outliving its element would be reported against whatever plays next.

+

TRACES: UR-005 | DR-178 | UT-177

+
Source

pub fn set_report_sink(&self, sink: Arc<dyn PlaybackReportSink>)

Replace the sink playback reports go to. Tests capture; production wires +the PlaybackReporter at construction and never swaps it.

+

TRACES: UR-025 | DR-179

+
Source

fn report(&self, operation: PlaybackOperation)

Send a playback report. Fire-and-forget by contract, so callers can do +this while holding nothing and waiting for nothing.

+

TRACES: UR-025 | DR-179

+
Source

fn current_jellyfin_id(&self) -> Option<String>

The Jellyfin id of whatever is currently queued, if it has one.

+
Source

pub fn duration(&self) -> Option<f64>

Get duration.

+

Falls back to what webview-rendered media reported for the same reason +absolute_position does: on that path nothing +is loaded into the native backend, so its duration is None and the +element’s report is the only one there is.

+

TRACES: UR-005 | DR-178

+
Source

pub fn queue(&self) -> Arc<Mutex<QueueManager>>

Get queue reference

+
Source

pub fn current_is_audio_episode(&self) -> bool

True when the current item is a TV episode being played in audio-only +(background) mode — i.e. an item_type == "Episode" item loaded as +MediaType::Audio. Used to decide whether the backend must drive the +next-episode advance itself (the frontend is suspended in the background).

+

Only called from the Android autoplay dispatch (#[cfg(android)]), but +compiled and unit-tested on the host, hence allow(dead_code) off-Android.

+
Source

pub fn clear_queue(&self)

Clear the queue entirely (used when playback genuinely stops, e.g. the +sleep timer fires or the queue ends with repeat off). Pair with +emit_queue_changed so the frontend hides the mini player.

+
Source

pub fn toggle_shuffle(&self)

Toggle shuffle

+
Source

pub fn cycle_repeat(&self)

Cycle repeat mode

+
Source

pub fn is_shuffle(&self) -> bool

Check if shuffle is enabled

+
Source

pub fn repeat_mode(&self) -> RepeatMode

Get repeat mode

+
Source

pub fn volume(&self) -> f32

Get current volume (0.0 - 1.0)

+
Source

pub fn muted(&self) -> bool

Check if muted

+
Source

pub fn set_audio_settings( + &mut self, + settings: &AudioSettings, +) -> Result<(), PlayerError>

Set audio settings (crossfade, gapless, normalization)

+
Source

pub fn audio_settings(&self) -> AudioSettings

Get current audio settings

+
Source

pub fn set_event_emitter(&self, emitter: Arc<dyn PlayerEventEmitter>)

Set the event emitter for notifications

+
Source

pub fn event_emitter(&self) -> Option<Arc<dyn PlayerEventEmitter>>

Get the event emitter

+
Source

pub fn sleep_timer_state(&self) -> SleepTimerState

Get sleep timer state

+
Source

pub fn set_sleep_timer(&self, mode: SleepTimerMode)

Set sleep timer mode (in-memory only, not persisted)

+
Source

pub fn cancel_sleep_timer(&self)

Cancel sleep timer

+
Source

fn start_timer_thread(&self)

Start background timer thread for sleep timer countdown updates

+
Source

fn emit_sleep_timer_changed(&self)

Emit sleep timer changed event to frontend

+
Source

pub fn emit_queue_changed(&self)

Emit queue changed event to frontend

+
Source

pub fn report_html5_state(&self, state: String, media_id: Option<String>)

Report an HTML5

+

Re-emits a StateChanged event identical to what MpvBackend/ExoPlayer +would emit, so playerEvents.ts needs no HTML5-specific branch.

+
Source

pub fn report_html5_position(&self, position: f64, duration: f64)

Report an HTML5

+

Re-emits a PositionUpdate event mirroring the native backends’ periodic +position updates (the adapter is expected to throttle to ~250ms like MPV).

+
Source

fn report_progress_throttled(&self, position: f64)

Send a throttled progress report to Jellyfin.

+

Progress is what makes a position survive anything other than a clean +exit — a crash, a swipe-away, a battery death — and lets another device +resume mid-episode. Webview-rendered media reported none: the frontend +service writes progress to the local DB only, and Rust had no position +for it to report. A device trace covering 35 minutes of playback hit +/Sessions/Playing/Progress exactly zero times.

+

The throttler is the one the controller already owned for this purpose +(30s per item), so ticks arriving four times a second cost one request +per half-minute.

+

TRACES: UR-005, UR-025 | DR-179 | UT-180

+
Source

pub fn report_html5_media_loaded(&self, duration: f64)

Report that the HTML5

+
Source

pub fn autoplay_settings(&self) -> AutoplaySettings

Get autoplay settings

+
Source

pub fn set_autoplay_settings(&self, settings: AutoplaySettings)

Set autoplay settings (in-memory only, persistence handled by command layer)

+
Source

pub fn cancel_autoplay_countdown(&self)

Cancel active autoplay countdown

+
Source

pub async fn on_playback_ended(&self) -> Result<AutoplayDecision, String>

Handle playback ended event - decides what to do next

+

Only triggers autoplay if the track finished naturally (EndReason::Finished or None). +If EndReason is NewTrackLoaded, UserStop, UserSkip, or Error, returns Stop without autoplay.

+
Source

fn report_completion(&self, item: &MediaItem)

Report an item that just finished as stopped at its runtime, so Jellyfin +marks it played.

+

Jellyfin decides “watched” from the PlaybackStopped report and its +position — no report, no completion, however much of the episode was +actually heard. In the foreground the frontend sends one when the +<video> ends. In background audio-only mode there is nobody: the webview +is suspended and its element was torn down at the handoff, while the +backend drove the advance to the next episode and said nothing about the +one that ended. An episode listened to end-to-end on the lockscreen +therefore never counted, and (before DR-179) was often reset to 0 by the +stop report that followed.

+

Scoped to the audio-only handoff — the case the frontend provably cannot +report — so foreground playback keeps its single existing report rather +than gaining a second one. Music tracks ending natively remain +unreported; that is the same gap through a different door and wants its +own change.

+

TRACES: UR-040, UR-025 | DR-179 | UT-179

+
Source

pub fn set_background_audio_base(&self, seconds: f64)

Record the base offset of a background-audio handoff (the position the +video was handed off at, which is the audio stream’s zero).

+

TRACES: UR-040 | DR-052

+
Source

pub fn enter_background_audio(&self, position: f64)

Enter a background-audio handoff at position (the video’s position, and +therefore the audio stream’s zero).

+

Hands transport authority to the native audio player: the webview +<video> is about to be torn down, so its last reports — including the +pause the teardown itself fires — must not keep it looking like the +player. Without this the lockscreen pause emitted a ControlCommand at a +dead element and the audio played straight through it.

+

TRACES: UR-040, UR-005 | DR-052, DR-097

+
Source

pub fn exit_background_audio(&self) -> f64

Leave a background-audio handoff, returning the base offset to add to the +native player’s relative position.

+

The webview <video> becomes the player again once it reloads, so its +reports are honoured from here on.

+

TRACES: UR-040, UR-005 | DR-052, DR-097

+
Source

pub fn is_background_audio_active(&self) -> bool

True while the native audio player owns playback via a background-audio +handoff.

+

TRACES: UR-040 | DR-052

+
Source

pub fn take_background_audio_base(&self) -> f64

Read and clear the background-audio base offset.

+

TRACES: UR-040 | DR-052

+
Source

pub async fn auto_advance_to_next_episode( + &self, + next_episode: MediaItem, + countdown_seconds: u32, +)

Perform the auto-advance for a ShowNextEpisodePopup decision.

+

Single place both end-of-playback dispatchers agree on: the Android JNI +callback (nativeOnPlaybackEnded) and the frontend-invoked command +(player_on_playback_ended). They used to each carry their own copy of +this branch, and the command’s copy was missing the background-audio case +entirely — so an audio-only episode ending while backgrounded only ever +started a countdown that nothing could act on.

+

TRACES: UR-040, UR-023 | DR-052

+
Source

fn is_audio_only_video(item: &MediaItem) -> bool

A video item played through the native audio path — i.e. the background +audio-only handoff, the only place a length-less progressive transcode is +used. Jellyfin’s item-type taxonomy stays in Rust (CLAUDE.md).

+
Source

fn claim_stream_resume(&self) -> Option<(f64, u32)>

Claim a resume attempt for the current stream, returning the absolute +position to re-open at and the 1-based attempt number. None when the +current item cannot meaningfully be re-requested, or when retrying at this +position has stopped helping.

+

Only Remote sources qualify. A downloaded file cannot fail because of +the network, so re-opening one would paper over a real read error; a +DirectUrl is a plugin’s endpoint with no Jellyfin item behind it.

+

The player’s position is relative to the stream’s own zero (the handoff +URL’s StartTimeTicks), so the base is added back to get an absolute one. +It is zero for everything else, where positions are already absolute.

+

TRACES: UR-040, UR-004 | DR-129 | UT-117

+
Source

fn truncated_stream_resume_position(&self) -> Option<f64>

The absolute position to re-open the current stream at, when the reported +end was really a dropped connection — None when the end looks genuine, +when this is not an audio-only handoff, or when retrying has stopped +helping.

+

TRACES: UR-040 | DR-129 | UT-117

+
Source

pub fn recoverable_error_resume(&self) -> Option<(f64, u64)>

Where to re-open the current stream after a recoverable playback error, +plus how many seconds to wait first.

+

The media was decoding fine a moment ago, so a mid-playback failure on a +server stream is the network — and stopping the player (the previous +behaviour, via the frontend’s error handler) turns a hiccup into “playback +just died”. Applies to every streamed item, not only the audio-only +handoff: music and video reach here instead of the truncation path because +their streams declare a length, so a cut connection surfaces as an error +rather than a phantom end.

+

The wait grows with the attempt number so a short outage has time to +clear, and the shared budget stops the retries when it doesn’t.

+

Called from the Android error callback, which decides in-process, and from +player_recover_stream, which is how the same decision reaches the +backends whose event thread has no controller to call — MPV is built +before the controller exists, so on Linux the error is emitted, echoed by +the frontend, and decided here.

+

TRACES: UR-040, UR-004 | DR-129, DR-130 | UT-117

+
Source

pub async fn resume_stream_at(&self, position: f64) -> Result<(), String>

Re-open the current stream at position after the network cut it short.

+

Single place every dispatcher agrees on, for the same reason +auto_advance_to_next_episode is: the Android JNI callbacks and the +frontend-invoked command must not disagree about what a failed stream +means. None of them emits PlaybackEnded for this, so nothing downstream +clears the queue or tears the session down — from the outside this is a +buffering hiccup, which is what it actually was.

+

Reloads the item in place rather than through play_item, which +replaces the queue with a single item: recovering a track that way would +throw away the rest of the album, turning a network blip into lost state.

+

Two shapes of stream, two ways back to position:

+
    +
  • The audio-only handoff’s /Audio/{id}/universal transcode is chunked +with no length, so it cannot be seeked. Its URL is rewritten to start at +the position instead — edited, not rebuilt from the repository, since it +already carries the user’s audio track and media source and recovering +from a network failure must not itself need a network round-trip.
  • +
  • Everything else (a static file with byte ranges, an HLS playlist) +declares its whole timeline, so re-preparing the URL it already has and +seeking lands in the right place — and leaves any transcode session +behind it alone.
  • +
+

TRACES: UR-040, UR-004 | DR-129 | UT-117

+
Source

pub async fn advance_to_next_episode_audio_only( + &self, + next_episode_id: &str, +) -> Result<(), String>

Advance to the next episode while playing audio-only in the background.

+

The normal autoplay-next path navigates the frontend to /player/<id>, +which is unavailable when the app is backgrounded and the WebView is +suspended. This drives the advance entirely in the backend: build the next +episode’s audio-only stream URL and load it into the native audio player, +so playback continues without any frontend involvement (UR-040).

+

next_episode_id is the Jellyfin item ID of the episode to play next.

+

Reached through auto_advance_to_next_episode, which gates it on +current_is_audio_episode() — only ever true after a background-audio +handoff (Android), but compiled and unit-tested on every platform. +TRACES: UR-040, UR-023 | DR-052

+
Source

pub async fn on_video_playback_ended( + &self, + item_id: &str, + repo: Arc<dyn MediaRepository>, +) -> Result<AutoplayDecision, String>

Handle video playback ended from HTML5 video element.

+

HTML5 video plays independently of the Rust backend, so the backend +queue has no knowledge of the video item. This method bypasses the +queue lookup and end_reason check, using the provided Jellyfin item ID +to look up the item and check for next episodes.

+
Source

async fn is_episode_item(&self, item: &MediaItem) -> bool

Check if a media item is an episode (has Jellyfin ID to query).

+

An explicit item_type == "Episode" wins so that a TV episode handed off +to the audio path for background playback (UR-040) is still recognised as +an episode — otherwise autoplay would fall through to the queue-based +audio path, find nothing next, and stop at the episode boundary. When the +type is unknown we fall back to the historical heuristic (video == episode).

+
Source

async fn fetch_next_episode_for_item( + &self, + item_id: &str, + repo: &Arc<dyn MediaRepository>, +) -> Result<Option<(MediaItem, MediaItem)>, String>

Fetch next episode for a series by looking up the season’s episodes +sorted by index number and picking the one after the current episode.

+

This is deterministic and doesn’t depend on Jellyfin’s “Next Up” API +(which relies on watch history that may not be updated yet due to +the async nature of playback progress reporting).

+
Source

pub fn start_autoplay_countdown( + &self, + _next_item: MediaItem, + countdown_seconds: u32, +)

Start autoplay countdown thread

+

Trait Implementations§

Source§

impl Default for PlayerController

Source§

fn default() -> Self

Returns the “default value” for a type. Read more

Auto Trait Implementations§

Blanket Implementations§

Source§

impl<T> Any for T
where + T: 'static + ?Sized,

Source§

fn type_id(&self) -> TypeId

Gets the TypeId of self. Read more
Source§

impl<T> Borrow<T> for T
where + T: ?Sized,

Source§

fn borrow(&self) -> &T

Immutably borrows from an owned value. Read more
Source§

impl<T> BorrowMut<T> for T
where + T: ?Sized,

Source§

fn borrow_mut(&mut self) -> &mut T

Mutably borrows from an owned value. Read more
Source§

impl<T> From<T> for T

Source§

fn from(t: T) -> T

Returns the argument unchanged.

+
§

impl<T> Instrument for T

§

fn instrument(self, span: Span) -> Instrumented<Self>

Instruments this type with the provided [Span], returning an +Instrumented wrapper. Read more
§

fn in_current_span(self) -> Instrumented<Self>

Instruments this type with the current Span, returning an +Instrumented wrapper. Read more
Source§

impl<T, U> Into<U> for T
where + U: From<T>,

Source§

fn into(self) -> U

Calls U::from(self).

+

That is, this conversion is whatever the implementation of +From<T> for U chooses to do.

+
§

impl<T> NoneValue for T
where + T: Default,

§

type NoneType = T

§

fn null_value() -> T

The none-equivalent value.
§

impl<T> PolicyExt for T
where + T: ?Sized,

§

fn and<P, B, E>(self, other: P) -> And<T, P>
where + T: Policy<B, E>, + P: Policy<B, E>,

Create a new Policy that returns [Action::Follow] only if self and other return +Action::Follow. Read more
§

fn or<P, B, E>(self, other: P) -> Or<T, P>
where + T: Policy<B, E>, + P: Policy<B, E>,

Create a new Policy that returns [Action::Follow] if either self or other returns +Action::Follow. Read more
Source§

impl<T> Same for T

Source§

type Output = T

Should always be Self
Source§

impl<T, U> TryFrom<U> for T
where + U: Into<T>,

Source§

type Error = Infallible

The type returned in the event of a conversion error.
Source§

fn try_from(value: U) -> Result<T, <T as TryFrom<U>>::Error>

Performs the conversion.
Source§

impl<T, U> TryInto<U> for T
where + U: TryFrom<T>,

Source§

type Error = <U as TryFrom<T>>::Error

The type returned in the event of a conversion error.
Source§

fn try_into(self) -> Result<U, <U as TryFrom<T>>::Error>

Performs the conversion.
§

impl<V, T> VZip<V> for T
where + V: MultiLane<T>,

§

fn vzip(self) -> V

§

impl<T> WithSubscriber for T

§

fn with_subscriber<S>(self, subscriber: S) -> WithDispatch<Self>
where + S: Into<Dispatch>,

Attaches the provided Subscriber to this type, returning a +[WithDispatch] wrapper. Read more
§

fn with_current_subscriber(self) -> WithDispatch<Self>

Attaches the current default Subscriber to this type, returning a +[WithDispatch] wrapper. Read more
\ No newline at end of file diff --git a/api/jellytau_lib/player/struct.ReporterSink.html b/api/jellytau_lib/player/struct.ReporterSink.html new file mode 100644 index 00000000..4bb20408 --- /dev/null +++ b/api/jellytau_lib/player/struct.ReporterSink.html @@ -0,0 +1,30 @@ +ReporterSink in jellytau_lib::player - RustSkip to main content

ReporterSink

Struct ReporterSink 

Source
struct ReporterSink {
+    reporter: Arc<Mutex<Option<PlaybackReporter>>>,
+}
Expand description

The production sink: hands each operation to the PlaybackReporter.

+

Reports originate on whatever thread playback ended or ticked on — including +JNI callbacks with no Tokio runtime attached — so the spawn falls back to a +throwaway runtime on its own thread rather than assuming one is current.

+

Fields§

§reporter: Arc<Mutex<Option<PlaybackReporter>>>

Trait Implementations§

Source§

impl PlaybackReportSink for ReporterSink

Source§

fn send(&self, operation: PlaybackOperation)

Deliver operation. Must not block the caller.

Auto Trait Implementations§

Blanket Implementations§

Source§

impl<T> Any for T
where + T: 'static + ?Sized,

Source§

fn type_id(&self) -> TypeId

Gets the TypeId of self. Read more
Source§

impl<T> Borrow<T> for T
where + T: ?Sized,

Source§

fn borrow(&self) -> &T

Immutably borrows from an owned value. Read more
Source§

impl<T> BorrowMut<T> for T
where + T: ?Sized,

Source§

fn borrow_mut(&mut self) -> &mut T

Mutably borrows from an owned value. Read more
Source§

impl<T> From<T> for T

Source§

fn from(t: T) -> T

Returns the argument unchanged.

+
§

impl<T> Instrument for T

§

fn instrument(self, span: Span) -> Instrumented<Self>

Instruments this type with the provided [Span], returning an +Instrumented wrapper. Read more
§

fn in_current_span(self) -> Instrumented<Self>

Instruments this type with the current Span, returning an +Instrumented wrapper. Read more
Source§

impl<T, U> Into<U> for T
where + U: From<T>,

Source§

fn into(self) -> U

Calls U::from(self).

+

That is, this conversion is whatever the implementation of +From<T> for U chooses to do.

+
§

impl<T> PolicyExt for T
where + T: ?Sized,

§

fn and<P, B, E>(self, other: P) -> And<T, P>
where + T: Policy<B, E>, + P: Policy<B, E>,

Create a new Policy that returns [Action::Follow] only if self and other return +Action::Follow. Read more
§

fn or<P, B, E>(self, other: P) -> Or<T, P>
where + T: Policy<B, E>, + P: Policy<B, E>,

Create a new Policy that returns [Action::Follow] if either self or other returns +Action::Follow. Read more
Source§

impl<T> Same for T

Source§

type Output = T

Should always be Self
Source§

impl<T, U> TryFrom<U> for T
where + U: Into<T>,

Source§

type Error = Infallible

The type returned in the event of a conversion error.
Source§

fn try_from(value: U) -> Result<T, <T as TryFrom<U>>::Error>

Performs the conversion.
Source§

impl<T, U> TryInto<U> for T
where + U: TryFrom<T>,

Source§

type Error = <U as TryFrom<T>>::Error

The type returned in the event of a conversion error.
Source§

fn try_into(self) -> Result<U, <U as TryFrom<T>>::Error>

Performs the conversion.
§

impl<V, T> VZip<V> for T
where + V: MultiLane<T>,

§

fn vzip(self) -> V

§

impl<T> WithSubscriber for T

§

fn with_subscriber<S>(self, subscriber: S) -> WithDispatch<Self>
where + S: Into<Dispatch>,

Attaches the provided Subscriber to this type, returning a +[WithDispatch] wrapper. Read more
§

fn with_current_subscriber(self) -> WithDispatch<Self>

Attaches the current default Subscriber to this type, returning a +[WithDispatch] wrapper. Read more
\ No newline at end of file diff --git a/api/jellytau_lib/player/trait.PlaybackReportSink.html b/api/jellytau_lib/player/trait.PlaybackReportSink.html new file mode 100644 index 00000000..ea8d0013 --- /dev/null +++ b/api/jellytau_lib/player/trait.PlaybackReportSink.html @@ -0,0 +1,13 @@ +PlaybackReportSink in jellytau_lib::player - RustSkip to main content

PlaybackReportSink

Trait PlaybackReportSink 

Source
pub trait PlaybackReportSink: Send + Sync {
+    // Required method
+    fn send(&self, operation: PlaybackOperation);
+}
Expand description

Where the player’s playback reports go.

+

The controller’s side of reporting is “send this, don’t make me wait”: a slow +or failing sync must never stall playback, so every send is fire-and-forget. +Production wires this to PlaybackReporter (local DB, server sync, offline +queueing); tests capture the operations instead of standing up a database and +an HTTP client, which is what let the missing reports below be written as +failing tests rather than found on a device.

+

TRACES: UR-025 | DR-179

+

Required Methods§

Source

fn send(&self, operation: PlaybackOperation)

Deliver operation. Must not block the caller.

+

Implementors§

\ No newline at end of file diff --git a/api/jellytau_lib/repository/device_profile/constant.EXTERNAL_SUBTITLE_FORMATS.html b/api/jellytau_lib/repository/device_profile/constant.EXTERNAL_SUBTITLE_FORMATS.html new file mode 100644 index 00000000..6aa76897 --- /dev/null +++ b/api/jellytau_lib/repository/device_profile/constant.EXTERNAL_SUBTITLE_FORMATS.html @@ -0,0 +1,6 @@ +EXTERNAL_SUBTITLE_FORMATS in jellytau_lib::repository::device_profile - RustSkip to main content

EXTERNAL_SUBTITLE_FORMATS

Constant EXTERNAL_SUBTITLE_FORMATS 

Source
const EXTERNAL_SUBTITLE_FORMATS: &[&str];
Expand description

Subtitle formats we can render ourselves, delivered as an external sidecar +track rather than painted into the video.

+

Every entry here is text. Image-based subtitles (PGS, DVD, DVB) are +deliberately absent: they are bitmaps, so the only way a server can show them +on a client that cannot composite them is to burn them into the picture.

+
\ No newline at end of file diff --git a/api/jellytau_lib/repository/device_profile/constant.FALLBACK_AUDIO_CHANNELS.html b/api/jellytau_lib/repository/device_profile/constant.FALLBACK_AUDIO_CHANNELS.html new file mode 100644 index 00000000..d72ef62a --- /dev/null +++ b/api/jellytau_lib/repository/device_profile/constant.FALLBACK_AUDIO_CHANNELS.html @@ -0,0 +1,3 @@ +FALLBACK_AUDIO_CHANNELS in jellytau_lib::repository::device_profile - RustSkip to main content

FALLBACK_AUDIO_CHANNELS

Constant FALLBACK_AUDIO_CHANNELS 

Source
const FALLBACK_AUDIO_CHANNELS: u32 = 2;
Expand description

Channel count assumed when the platform cannot tell us — every audio route +can voice stereo, so it is the only safe floor.

+
\ No newline at end of file diff --git a/api/jellytau_lib/repository/device_profile/constant.FALLBACK_AUDIO_CODEC.html b/api/jellytau_lib/repository/device_profile/constant.FALLBACK_AUDIO_CODEC.html new file mode 100644 index 00000000..be2401fe --- /dev/null +++ b/api/jellytau_lib/repository/device_profile/constant.FALLBACK_AUDIO_CODEC.html @@ -0,0 +1,4 @@ +FALLBACK_AUDIO_CODEC in jellytau_lib::repository::device_profile - RustSkip to main content

FALLBACK_AUDIO_CODEC

Constant FALLBACK_AUDIO_CODEC 

Source
const FALLBACK_AUDIO_CODEC: &str = "aac";
Expand description

The codec claimed when a device reports nothing we can use. Every renderer +decodes AAC, and claiming something is what makes the server transcode to +it rather than give up.

+
\ No newline at end of file diff --git a/api/jellytau_lib/repository/device_profile/constant.MAX_SUPPORTED_AUDIO_CHANNELS.html b/api/jellytau_lib/repository/device_profile/constant.MAX_SUPPORTED_AUDIO_CHANNELS.html new file mode 100644 index 00000000..a96dbd29 --- /dev/null +++ b/api/jellytau_lib/repository/device_profile/constant.MAX_SUPPORTED_AUDIO_CHANNELS.html @@ -0,0 +1,3 @@ +MAX_SUPPORTED_AUDIO_CHANNELS in jellytau_lib::repository::device_profile - RustSkip to main content

MAX_SUPPORTED_AUDIO_CHANNELS

Constant MAX_SUPPORTED_AUDIO_CHANNELS 

Source
const MAX_SUPPORTED_AUDIO_CHANNELS: u32 = 8;
Expand description

Upper bound we are willing to claim. Jellyfin profiles top out at 7.1, and a +nonsense reading from a driver should not become a nonsense profile.

+
\ No newline at end of file diff --git a/api/jellytau_lib/repository/device_profile/constant.NO_SUBTITLE_STREAM.html b/api/jellytau_lib/repository/device_profile/constant.NO_SUBTITLE_STREAM.html new file mode 100644 index 00000000..77372863 --- /dev/null +++ b/api/jellytau_lib/repository/device_profile/constant.NO_SUBTITLE_STREAM.html @@ -0,0 +1,5 @@ +NO_SUBTITLE_STREAM in jellytau_lib::repository::device_profile - RustSkip to main content

NO_SUBTITLE_STREAM

Constant NO_SUBTITLE_STREAM 

Source
pub const NO_SUBTITLE_STREAM: i32 = -1;
Expand description

Jellyfin’s sentinel for “negotiate no subtitle stream at all”.

+

Omitting SubtitleStreamIndex does not mean this: the server then applies +the source’s default/forced flags and picks a track itself. See +playback_subtitle_stream_index for why that is never what we want.

+
\ No newline at end of file diff --git a/api/jellytau_lib/repository/device_profile/constant.SUBTITLE_QUERY_KEYS.html b/api/jellytau_lib/repository/device_profile/constant.SUBTITLE_QUERY_KEYS.html new file mode 100644 index 00000000..fc21ecdb --- /dev/null +++ b/api/jellytau_lib/repository/device_profile/constant.SUBTITLE_QUERY_KEYS.html @@ -0,0 +1,5 @@ +SUBTITLE_QUERY_KEYS in jellytau_lib::repository::device_profile - RustSkip to main content

SUBTITLE_QUERY_KEYS

Constant SUBTITLE_QUERY_KEYS 

Source
const SUBTITLE_QUERY_KEYS: &[&str];
Expand description

Query keys through which a stream URL can carry a subtitle decision.

+

Jellyfin binds query keys case-insensitively, so the match has to be too — +the server itself mixes casing (SubtitleStreamIndex but +alwaysBurnInSubtitleWhenTranscoding).

+
\ No newline at end of file diff --git a/api/jellytau_lib/repository/device_profile/constant.WEBVIEW_AUDIO_CODECS.html b/api/jellytau_lib/repository/device_profile/constant.WEBVIEW_AUDIO_CODECS.html new file mode 100644 index 00000000..42bea536 --- /dev/null +++ b/api/jellytau_lib/repository/device_profile/constant.WEBVIEW_AUDIO_CODECS.html @@ -0,0 +1,4 @@ +WEBVIEW_AUDIO_CODECS in jellytau_lib::repository::device_profile - RustSkip to main content

WEBVIEW_AUDIO_CODECS

Constant WEBVIEW_AUDIO_CODECS 

Source
const WEBVIEW_AUDIO_CODECS: &[&str];
Expand description

Audio codecs the webview’s <video> element can decode.

+

Deliberately narrower than what the platform reports: see +video_audio_codecs.

+
\ No newline at end of file diff --git a/api/jellytau_lib/repository/device_profile/fn.audio_forces_transcode.html b/api/jellytau_lib/repository/device_profile/fn.audio_forces_transcode.html new file mode 100644 index 00000000..97086a93 --- /dev/null +++ b/api/jellytau_lib/repository/device_profile/fn.audio_forces_transcode.html @@ -0,0 +1,14 @@ +audio_forces_transcode in jellytau_lib::repository::device_profile - RustSkip to main content

audio_forces_transcode

Function audio_forces_transcode 

Source
pub fn audio_forces_transcode(streams: &[(Option<&str>, bool)]) -> bool
Expand description

Decide whether we must transcode regardless of what the server negotiated, +given the source’s audio streams as (codec, is_default) in source order.

+

Advertising a narrow profile (video_audio_codecs) is necessary but not +sufficient: Jellyfin 10.11.5 enforces a DirectPlayProfile’s container and +video codec but ignores its audio codec — an E-AC-3 track is offered for +direct play even when the profile lists only AAC, and neither a VideoAudio +CodecProfile nor MaxAudioChannels changes that. So the client cannot +delegate this decision; it knows what its own renderer can decode and must +apply that itself.

+

The track that matters is the one the server will actually serve (see +served_audio_codec). An unknown codec is left alone — forcing a transcode +on a guess would burn server CPU for files that play.

+

TRACES: UR-004 | DR-149 | UT-148

+
\ No newline at end of file diff --git a/api/jellytau_lib/repository/device_profile/fn.clamp_max_audio_channels.html b/api/jellytau_lib/repository/device_profile/fn.clamp_max_audio_channels.html new file mode 100644 index 00000000..6cc9aa9e --- /dev/null +++ b/api/jellytau_lib/repository/device_profile/fn.clamp_max_audio_channels.html @@ -0,0 +1,11 @@ +clamp_max_audio_channels in jellytau_lib::repository::device_profile - RustSkip to main content

clamp_max_audio_channels

Function clamp_max_audio_channels 

Source
pub fn clamp_max_audio_channels(reported: Option<u32>) -> u32
Expand description

Decide the MaxAudioChannels to advertise, given what the current audio +route reported.

+

Without this constraint Jellyfin is free to direct-play a 5.1 or 7.1 track to +a sink that only has two channels. What the user hears then is device +dependent and rarely correct — a failed AudioSink configuration (silence), +or centre-channel dialogue folded away to near-inaudibility. Naming the real +channel count makes the server downmix instead, which is always audible.

+

A missing or zero reading means “route not established yet”, not “no audio”: +fall back to stereo rather than claiming a capability we have not seen.

+

TRACES: UR-004 | DR-141 | UT-141

+
\ No newline at end of file diff --git a/api/jellytau_lib/repository/device_profile/fn.max_audio_channels.html b/api/jellytau_lib/repository/device_profile/fn.max_audio_channels.html new file mode 100644 index 00000000..e4f01a91 --- /dev/null +++ b/api/jellytau_lib/repository/device_profile/fn.max_audio_channels.html @@ -0,0 +1,4 @@ +max_audio_channels in jellytau_lib::repository::device_profile - RustSkip to main content

max_audio_channels

Function max_audio_channels 

Source
pub fn max_audio_channels() -> u32
Expand description

The channel cap for this device, reading the platform’s report where one +exists.

+

TRACES: UR-004 | DR-141 | UT-141

+
\ No newline at end of file diff --git a/api/jellytau_lib/repository/device_profile/fn.playback_subtitle_stream_index.html b/api/jellytau_lib/repository/device_profile/fn.playback_subtitle_stream_index.html new file mode 100644 index 00000000..d20b5d31 --- /dev/null +++ b/api/jellytau_lib/repository/device_profile/fn.playback_subtitle_stream_index.html @@ -0,0 +1,13 @@ +playback_subtitle_stream_index in jellytau_lib::repository::device_profile - RustSkip to main content

playback_subtitle_stream_index

Function playback_subtitle_stream_index 

Source
pub fn playback_subtitle_stream_index() -> i32
Expand description

The SubtitleStreamIndex to negotiate with: always “none”.

+

The reported bug: a source with an E-AC-3 track and a PGSSUB default +subtitle track. Sending no index let the server honour that default, and since +PGS cannot go out as a sidecar it chose SubtitleMethod=Encode — burn-in. +That turned an audio-only transcode (the HEVC video was directly supported) +into a full HEVC→h264 re-encode, which the server could not sustain in real +time: the buffer never grew beyond one segment and playback stalled every few +seconds, taking seeking down with it.

+

Asking for no subtitle stream costs nothing, because the app never wanted the +server’s composited version — it fetches the text tracks separately and +renders them itself (UR-020).

+

TRACES: UR-020, UR-004 | DR-176 | UT-168

+
\ No newline at end of file diff --git a/api/jellytau_lib/repository/device_profile/fn.served_audio_codec.html b/api/jellytau_lib/repository/device_profile/fn.served_audio_codec.html new file mode 100644 index 00000000..4eb7bd1f --- /dev/null +++ b/api/jellytau_lib/repository/device_profile/fn.served_audio_codec.html @@ -0,0 +1,10 @@ +served_audio_codec in jellytau_lib::repository::device_profile - RustSkip to main content

served_audio_codec

Function served_audio_codec 

Source
pub fn served_audio_codec<'a>(
+    streams: &[(Option<&'a str>, bool)],
+) -> Option<&'a str>
Expand description

The codec of the audio track the server will actually serve, given the +source’s audio streams as (codec, is_default) in source order: the default, +or the first when none is marked.

+

None means “nothing to judge” — no audio streams, or the server named no +codec for the one it would serve. Both callers of this rule treat that as +leave-well-alone, never as a licence to assume compatibility.

+

TRACES: UR-004, UR-071 | DR-149, DR-171 | UT-148, UT-166

+
\ No newline at end of file diff --git a/api/jellytau_lib/repository/device_profile/fn.subtitle_forces_burn_in.html b/api/jellytau_lib/repository/device_profile/fn.subtitle_forces_burn_in.html new file mode 100644 index 00000000..0442f102 --- /dev/null +++ b/api/jellytau_lib/repository/device_profile/fn.subtitle_forces_burn_in.html @@ -0,0 +1,7 @@ +subtitle_forces_burn_in in jellytau_lib::repository::device_profile - RustSkip to main content

subtitle_forces_burn_in

Function subtitle_forces_burn_in 

Source
pub fn subtitle_forces_burn_in(codec: &str) -> bool
Expand description

Whether asking the server to serve this subtitle codec forces it to burn the +subtitle into the picture.

+

Burn-in is not a subtitle cost — it is a video cost. It rules out remuxing +the video stream, so a source we would otherwise have passed through untouched +gets fully re-encoded frame by frame.

+

TRACES: UR-020 | DR-176 | UT-168

+
\ No newline at end of file diff --git a/api/jellytau_lib/repository/device_profile/fn.subtitle_profiles.html b/api/jellytau_lib/repository/device_profile/fn.subtitle_profiles.html new file mode 100644 index 00000000..dfc4bd2b --- /dev/null +++ b/api/jellytau_lib/repository/device_profile/fn.subtitle_profiles.html @@ -0,0 +1,5 @@ +subtitle_profiles in jellytau_lib::repository::device_profile - RustSkip to main content

subtitle_profiles

Function subtitle_profiles 

Source
pub fn subtitle_profiles() -> Vec<(&'static str, &'static str)>
Expand description

The SubtitleProfile entries to advertise, as (format, method).

+

All External: the app fetches subtitle tracks itself and renders them over +the video (UR-020), so it never needs the server to composite them.

+

TRACES: UR-020 | DR-176 | UT-168

+
\ No newline at end of file diff --git a/api/jellytau_lib/repository/device_profile/fn.subtitle_supports_external_delivery.html b/api/jellytau_lib/repository/device_profile/fn.subtitle_supports_external_delivery.html new file mode 100644 index 00000000..bb3a5206 --- /dev/null +++ b/api/jellytau_lib/repository/device_profile/fn.subtitle_supports_external_delivery.html @@ -0,0 +1,10 @@ +subtitle_supports_external_delivery in jellytau_lib::repository::device_profile - RustSkip to main content

subtitle_supports_external_delivery

Function subtitle_supports_external_delivery 

Source
pub fn subtitle_supports_external_delivery(codec: Option<&str>) -> bool
Expand description

Whether a subtitle in this format can reach the app as a sidecar it draws +itself — the same verdict as subtitle_forces_burn_in, from the reader’s +side, and the one a subtitle picker needs.

+

Since the app asks for burn-in nowhere (see +playback_subtitle_stream_index), a format that only burn-in could deliver +is one it can never display. An unnamed format is treated as undeliverable +rather than guessed at: offering a track and drawing nothing is worse than +not offering it.

+

TRACES: UR-020 | DR-176 | UT-168

+
\ No newline at end of file diff --git a/api/jellytau_lib/repository/device_profile/fn.video_audio_codecs.html b/api/jellytau_lib/repository/device_profile/fn.video_audio_codecs.html new file mode 100644 index 00000000..2c530d8f --- /dev/null +++ b/api/jellytau_lib/repository/device_profile/fn.video_audio_codecs.html @@ -0,0 +1,21 @@ +video_audio_codecs in jellytau_lib::repository::device_profile - RustSkip to main content

video_audio_codecs

Function video_audio_codecs 

Source
pub fn video_audio_codecs(detected: &str) -> String
Expand description

Narrow a detected audio-codec list to what the renderer that will actually +play the video can decode.

+

The platform list comes from MediaCodecList, which describes ExoPlayer — +but the webview <video> element may be what renders the video, and +Chromium/WebKit decode a much smaller set than the platform does. Advertising +the raw list makes Jellyfin direct-play a track the webview cannot decode, and +the user gets picture with no sound.

+

Which renderer gets it is not fixed: Linux is always the element, and Android +follows experimentalNativeVideo, which took ExoPlayer as its default in +DR-161 but is a user setting either way. So the narrow list is the only one +that holds on both sides of that switch. The cost is a Dolby-licensed Android +device transcoding an E-AC-3 track its ExoPlayer could have direct-played; +the alternative is silence for everyone the switch lands the other way, which +is the bug this exists to prevent.

+

The gap is widest on devices whose vendor licenses Dolby: a phone with +c2.dolby.eac3.decoder reports eac3, so it — and only it — gets a silent +direct play where a leaner device is transcoded to AAC and plays fine.

+

This applies to the video direct-play profile only. Audio-only playback +really is ExoPlayer’s, so its profile keeps the full platform list.

+

TRACES: UR-004 | DR-148 | UT-142

+
\ No newline at end of file diff --git a/api/jellytau_lib/repository/device_profile/fn.webview_can_decode_audio.html b/api/jellytau_lib/repository/device_profile/fn.webview_can_decode_audio.html new file mode 100644 index 00000000..b964b696 --- /dev/null +++ b/api/jellytau_lib/repository/device_profile/fn.webview_can_decode_audio.html @@ -0,0 +1,3 @@ +webview_can_decode_audio in jellytau_lib::repository::device_profile - RustSkip to main content

webview_can_decode_audio

Function webview_can_decode_audio 

Source
pub fn webview_can_decode_audio(codec: &str) -> bool
Expand description

Whether the webview <video> element can decode this audio codec.

+

TRACES: UR-004 | DR-149 | UT-148

+
\ No newline at end of file diff --git a/api/jellytau_lib/repository/device_profile/fn.without_server_chosen_subtitle.html b/api/jellytau_lib/repository/device_profile/fn.without_server_chosen_subtitle.html new file mode 100644 index 00000000..14ab5ba1 --- /dev/null +++ b/api/jellytau_lib/repository/device_profile/fn.without_server_chosen_subtitle.html @@ -0,0 +1,12 @@ +without_server_chosen_subtitle in jellytau_lib::repository::device_profile - RustSkip to main content

without_server_chosen_subtitle

Function without_server_chosen_subtitle 

Source
pub fn without_server_chosen_subtitle(url: &str) -> String
Expand description

Rewrite a stream URL so it asks for no subtitle, whoever built it.

+

playback_subtitle_stream_index only governs the URLs this app builds. +When PlaybackInfo answers with a TranscodingUrl, the URL was built by the +server from its own subtitle verdict, and we play it verbatim — so a server +that picked a track anyway (a live channel opened without an index, a source +whose default is image-based) hands us SubtitleMethod=Encode, and the +burn-in the negotiation just declined comes back through the URL. Burn-in is +a video cost: it rules out remuxing and forces a full re-encode.

+

Stripping the keys is not enough on its own — an absent index is not “none”, +it is “you choose” — so the sentinel is always appended.

+

TRACES: UR-020, UR-004 | DR-176 | UT-168

+
\ No newline at end of file diff --git a/api/jellytau_lib/repository/device_profile/index.html b/api/jellytau_lib/repository/device_profile/index.html new file mode 100644 index 00000000..22d9a386 --- /dev/null +++ b/api/jellytau_lib/repository/device_profile/index.html @@ -0,0 +1,21 @@ +jellytau_lib::repository::device_profile - RustSkip to main content

Module device_profile

Module device_profile 

Source
Expand description

Device-profile policy: turning what a device reports about its audio +output into the constraints we send Jellyfin.

+

The platform layer reports raw facts (what MediaCodecList enumerates, how +many channels the current audio route accepts); deciding what those facts +mean for a DeviceProfile is domain logic and lives here, on the Rust side +of the boundary, where it is testable without a device.

+

Constants§

EXTERNAL_SUBTITLE_FORMATS 🔒
Subtitle formats we can render ourselves, delivered as an external sidecar +track rather than painted into the video.
FALLBACK_AUDIO_CHANNELS 🔒
Channel count assumed when the platform cannot tell us — every audio route +can voice stereo, so it is the only safe floor.
FALLBACK_AUDIO_CODEC 🔒
The codec claimed when a device reports nothing we can use. Every renderer +decodes AAC, and claiming something is what makes the server transcode to +it rather than give up.
MAX_SUPPORTED_AUDIO_CHANNELS 🔒
Upper bound we are willing to claim. Jellyfin profiles top out at 7.1, and a +nonsense reading from a driver should not become a nonsense profile.
NO_SUBTITLE_STREAM
Jellyfin’s sentinel for “negotiate no subtitle stream at all”.
SUBTITLE_QUERY_KEYS 🔒
Query keys through which a stream URL can carry a subtitle decision.
WEBVIEW_AUDIO_CODECS 🔒
Audio codecs the webview’s <video> element can decode.

Functions§

audio_forces_transcode
Decide whether we must transcode regardless of what the server negotiated, +given the source’s audio streams as (codec, is_default) in source order.
clamp_max_audio_channels
Decide the MaxAudioChannels to advertise, given what the current audio +route reported.
max_audio_channels
The channel cap for this device, reading the platform’s report where one +exists.
playback_subtitle_stream_index
The SubtitleStreamIndex to negotiate with: always “none”.
served_audio_codec
The codec of the audio track the server will actually serve, given the +source’s audio streams as (codec, is_default) in source order: the default, +or the first when none is marked.
subtitle_forces_burn_in
Whether asking the server to serve this subtitle codec forces it to burn the +subtitle into the picture.
subtitle_profiles
The SubtitleProfile entries to advertise, as (format, method).
subtitle_supports_external_delivery
Whether a subtitle in this format can reach the app as a sidecar it draws +itself — the same verdict as subtitle_forces_burn_in, from the reader’s +side, and the one a subtitle picker needs.
video_audio_codecs
Narrow a detected audio-codec list to what the renderer that will actually +play the video can decode.
webview_can_decode_audio
Whether the webview <video> element can decode this audio codec.
without_server_chosen_subtitle
Rewrite a stream URL so it asks for no subtitle, whoever built it.
\ No newline at end of file diff --git a/api/jellytau_lib/repository/device_profile/sidebar-items.js b/api/jellytau_lib/repository/device_profile/sidebar-items.js new file mode 100644 index 00000000..791162c1 --- /dev/null +++ b/api/jellytau_lib/repository/device_profile/sidebar-items.js @@ -0,0 +1 @@ +window.SIDEBAR_ITEMS = {"constant":["EXTERNAL_SUBTITLE_FORMATS","FALLBACK_AUDIO_CHANNELS","FALLBACK_AUDIO_CODEC","MAX_SUPPORTED_AUDIO_CHANNELS","NO_SUBTITLE_STREAM","SUBTITLE_QUERY_KEYS","WEBVIEW_AUDIO_CODECS"],"fn":["audio_forces_transcode","clamp_max_audio_channels","max_audio_channels","playback_subtitle_stream_index","served_audio_codec","subtitle_forces_burn_in","subtitle_profiles","subtitle_supports_external_delivery","video_audio_codecs","webview_can_decode_audio","without_server_chosen_subtitle"]}; \ No newline at end of file diff --git a/api/jellytau_lib/repository/exclusions/fn.excluded_item_ids.html b/api/jellytau_lib/repository/exclusions/fn.excluded_item_ids.html new file mode 100644 index 00000000..e9b69998 --- /dev/null +++ b/api/jellytau_lib/repository/exclusions/fn.excluded_item_ids.html @@ -0,0 +1,3 @@ +excluded_item_ids in jellytau_lib::repository::exclusions - RustSkip to main content

excluded_item_ids

Function excluded_item_ids 

Source
pub fn excluded_item_ids() -> Vec<String>
Expand description

The excluded set as currently applied, normalised.

+

TRACES: UR-076 | DR-209

+
\ No newline at end of file diff --git a/api/jellytau_lib/repository/exclusions/fn.excluded_snapshot.html b/api/jellytau_lib/repository/exclusions/fn.excluded_snapshot.html new file mode 100644 index 00000000..1c23c52b --- /dev/null +++ b/api/jellytau_lib/repository/exclusions/fn.excluded_snapshot.html @@ -0,0 +1,3 @@ +excluded_snapshot in jellytau_lib::repository::exclusions - RustSkip to main content

excluded_snapshot

Function excluded_snapshot 

Source
fn excluded_snapshot() -> HashSet<String>
Expand description

Snapshot of the excluded set, taken once per list so a long listing does not +re-lock per item.

+
\ No newline at end of file diff --git a/api/jellytau_lib/repository/exclusions/fn.is_excluded_by.html b/api/jellytau_lib/repository/exclusions/fn.is_excluded_by.html new file mode 100644 index 00000000..081cf1db --- /dev/null +++ b/api/jellytau_lib/repository/exclusions/fn.is_excluded_by.html @@ -0,0 +1,12 @@ +is_excluded_by in jellytau_lib::repository::exclusions - RustSkip to main content

is_excluded_by

Function is_excluded_by 

Source
pub fn is_excluded_by(excluded: &HashSet<String>, item: &MediaItem) -> bool
Expand description

Whether item falls under one of excluded.

+

The set is passed in rather than read from the global so the rule itself is a +pure function and can be tested without touching process state.

+

An item matches on its own id or on any of the links it carries back to a +container: parent, album, library, series or season, and its artist entries. +That covers the shapes a hidden folder actually reaches a listing in — the +folder itself in a container listing, its albums (whose parent_id is the +folder), and their tracks (whose album_id is the album). It is deliberately +link-based rather than a full ancestry walk: the repository has no ancestor +index, and walking one would cost a round trip per row.

+

TRACES: UR-076 | DR-209

+
\ No newline at end of file diff --git a/api/jellytau_lib/repository/exclusions/fn.normalise_id.html b/api/jellytau_lib/repository/exclusions/fn.normalise_id.html new file mode 100644 index 00000000..17c30ce7 --- /dev/null +++ b/api/jellytau_lib/repository/exclusions/fn.normalise_id.html @@ -0,0 +1,5 @@ +normalise_id in jellytau_lib::repository::exclusions - RustSkip to main content

normalise_id

Function normalise_id 

Source
fn normalise_id(id: &str) -> String
Expand description

Jellyfin writes the same GUID both dashed and undashed depending on the +endpoint, and ids arriving over IPC may carry stray whitespace. Comparing a +canonical form means a stored id keeps matching whichever spelling a query +happens to return.

+
\ No newline at end of file diff --git a/api/jellytau_lib/repository/exclusions/fn.set_excluded_item_ids.html b/api/jellytau_lib/repository/exclusions/fn.set_excluded_item_ids.html new file mode 100644 index 00000000..404cded8 --- /dev/null +++ b/api/jellytau_lib/repository/exclusions/fn.set_excluded_item_ids.html @@ -0,0 +1,4 @@ +set_excluded_item_ids in jellytau_lib::repository::exclusions - RustSkip to main content

set_excluded_item_ids

Function set_excluded_item_ids 

Source
pub fn set_excluded_item_ids(ids: &[String])
Expand description

Replace the excluded set. Ids are normalised, de-duplicated and blanks +dropped, so a malformed value can never hide more than it names.

+

TRACES: UR-076 | DR-209

+
\ No newline at end of file diff --git a/api/jellytau_lib/repository/exclusions/index.html b/api/jellytau_lib/repository/exclusions/index.html new file mode 100644 index 00000000..4bfe64d6 --- /dev/null +++ b/api/jellytau_lib/repository/exclusions/index.html @@ -0,0 +1,25 @@ +jellytau_lib::repository::exclusions - RustSkip to main content

Module exclusions

Module exclusions 

Source
Expand description

User-chosen browsing exclusions (UR-076 / DR-209). +Library folders the user has chosen to keep out of browsing.

+

Some people file things inside a library that they never want to see while +browsing it — a “Podcasts” folder sitting in the music library is the +canonical case: its albums and tracks leak into album, artist, track and +playlist listings even though the user thinks of them as a different medium.

+

This is a domain rule, not a presentation one: what an item belongs to, and +therefore whether a query should return it, is decided here in the repository +layer so every query path agrees. The predecessor of this module was a +frontend filter that dropped anything literally named “Podcasts” — one user’s +folder layout, keyed on an English string, shipped to everyone. Excluding by +id instead of name is what makes the setting survive a rename, a +translation, or two folders sharing a name.

+

The excluded set is process-wide rather than a field on a repository for the +same reason as online::STREAMING_QUALITY: it is a preference about this +user’s browsing, not about a server session, so it must survive a repository +being rebuilt on re-login. It is written by the settings command and restored +from the database at startup.

+

TRACES: UR-076 | DR-209

+

Statics§

EXCLUDED_IDS 🔒
Ids (normalised — see normalise_id) of items the user has hidden.

Traits§

ExcludeHidden
Drop the user’s hidden items from a repository result.

Functions§

excluded_item_ids
The excluded set as currently applied, normalised.
excluded_snapshot 🔒
Snapshot of the excluded set, taken once per list so a long listing does not +re-lock per item.
is_excluded_by
Whether item falls under one of excluded.
normalise_id 🔒
Jellyfin writes the same GUID both dashed and undashed depending on the +endpoint, and ids arriving over IPC may carry stray whitespace. Comparing a +canonical form means a stored id keeps matching whichever spelling a query +happens to return.
set_excluded_item_ids
Replace the excluded set. Ids are normalised, de-duplicated and blanks +dropped, so a malformed value can never hide more than it names.
\ No newline at end of file diff --git a/api/jellytau_lib/repository/exclusions/sidebar-items.js b/api/jellytau_lib/repository/exclusions/sidebar-items.js new file mode 100644 index 00000000..d0164017 --- /dev/null +++ b/api/jellytau_lib/repository/exclusions/sidebar-items.js @@ -0,0 +1 @@ +window.SIDEBAR_ITEMS = {"fn":["excluded_item_ids","excluded_snapshot","is_excluded_by","normalise_id","set_excluded_item_ids"],"static":["EXCLUDED_IDS"],"trait":["ExcludeHidden"]}; \ No newline at end of file diff --git a/api/jellytau_lib/repository/exclusions/static.EXCLUDED_IDS.html b/api/jellytau_lib/repository/exclusions/static.EXCLUDED_IDS.html new file mode 100644 index 00000000..9d35f64f --- /dev/null +++ b/api/jellytau_lib/repository/exclusions/static.EXCLUDED_IDS.html @@ -0,0 +1,4 @@ +EXCLUDED_IDS in jellytau_lib::repository::exclusions - RustSkip to main content

EXCLUDED_IDS

Static EXCLUDED_IDS 

Source
static EXCLUDED_IDS: RwLock<Vec<String>>
Expand description

Ids (normalised — see normalise_id) of items the user has hidden.

+

Empty by default: nobody inherits somebody else’s folder layout.

+

TRACES: UR-076 | DR-209

+
\ No newline at end of file diff --git a/api/jellytau_lib/repository/exclusions/trait.ExcludeHidden.html b/api/jellytau_lib/repository/exclusions/trait.ExcludeHidden.html new file mode 100644 index 00000000..8f2df3e0 --- /dev/null +++ b/api/jellytau_lib/repository/exclusions/trait.ExcludeHidden.html @@ -0,0 +1,10 @@ +ExcludeHidden in jellytau_lib::repository::exclusions - RustSkip to main content

ExcludeHidden

Trait ExcludeHidden 

Source
pub trait ExcludeHidden: Sized {
+    // Required method
+    fn without_excluded(self) -> Self;
+}
Expand description

Drop the user’s hidden items from a repository result.

+

Implemented as a trait so the hybrid repository’s generic result helpers — +where the cache and server legs of every cache-first race converge — can +apply it to whatever they are carrying, instead of each query having to +remember to.

+

TRACES: UR-076 | DR-209

+

Required Methods§

Source

fn without_excluded(self) -> Self

Dyn Compatibility§

This trait is not dyn compatible.

In older versions of Rust, dyn compatibility was called "object safety", so this trait is not object safe.

Implementations on Foreign Types§

Source§

impl ExcludeHidden for Vec<MediaItem>

Source§

fn without_excluded(self) -> Self

Implementors§

\ No newline at end of file diff --git a/api/jellytau_lib/repository/fn.resolve_video_download_url.html b/api/jellytau_lib/repository/fn.resolve_video_download_url.html new file mode 100644 index 00000000..35a32fdb --- /dev/null +++ b/api/jellytau_lib/repository/fn.resolve_video_download_url.html @@ -0,0 +1,12 @@ +resolve_video_download_url in jellytau_lib::repository - RustSkip to main content

resolve_video_download_url

Function resolve_video_download_url 

Source
pub async fn resolve_video_download_url(
+    repo: &dyn MediaRepository,
+    item_id: &str,
+    quality: &str,
+    media_source_id: Option<&str>,
+) -> String
Expand description

Resolve the download URL for a video, applying the audio-codec policy that +keeps the saved file playable offline (DR-171).

+

Every video download goes through here rather than calling the builder +directly: the builder is pure and cannot look the codec up, and a caller that +forgets to is exactly how the silent downloads shipped.

+

TRACES: UR-071 | DR-171

+
\ No newline at end of file diff --git a/api/jellytau_lib/repository/fn.served_audio_codec.html b/api/jellytau_lib/repository/fn.served_audio_codec.html new file mode 100644 index 00000000..4739178b --- /dev/null +++ b/api/jellytau_lib/repository/fn.served_audio_codec.html @@ -0,0 +1,11 @@ +served_audio_codec in jellytau_lib::repository - RustSkip to main content

served_audio_codec

Function served_audio_codec 

Source
pub async fn served_audio_codec(
+    repo: &dyn MediaRepository,
+    item_id: &str,
+) -> Option<String>
Expand description

The audio codec the server would serve for item_id — the default track, or +the first when none is marked, matching the track Jellyfin picks.

+

None when the item has no audio, names no codec, or cannot be fetched. A +caller must read that as “unknown”, never as “fine”: it is the input to a +policy that only adds a transcode, so an unknown codec leaves behaviour +exactly as it was.

+

TRACES: UR-071 | DR-171 | UT-166

+
\ No newline at end of file diff --git a/api/jellytau_lib/repository/hybrid/index.html b/api/jellytau_lib/repository/hybrid/index.html new file mode 100644 index 00000000..f9d58a97 --- /dev/null +++ b/api/jellytau_lib/repository/hybrid/index.html @@ -0,0 +1 @@ +jellytau_lib::repository::hybrid - RustSkip to main content

Module hybrid

Module hybrid 

Source

Structs§

HybridRepository
Hybrid repository combining online and offline data sources
\ No newline at end of file diff --git a/api/jellytau_lib/repository/hybrid/sidebar-items.js b/api/jellytau_lib/repository/hybrid/sidebar-items.js new file mode 100644 index 00000000..3603dbfc --- /dev/null +++ b/api/jellytau_lib/repository/hybrid/sidebar-items.js @@ -0,0 +1 @@ +window.SIDEBAR_ITEMS = {"struct":["HybridRepository"]}; \ No newline at end of file diff --git a/api/jellytau_lib/repository/hybrid/struct.HybridRepository.html b/api/jellytau_lib/repository/hybrid/struct.HybridRepository.html new file mode 100644 index 00000000..1a3a28a3 --- /dev/null +++ b/api/jellytau_lib/repository/hybrid/struct.HybridRepository.html @@ -0,0 +1,485 @@ +HybridRepository in jellytau_lib::repository::hybrid - RustSkip to main content

HybridRepository

Struct HybridRepository 

Source
pub struct HybridRepository {
+    online: Arc<OnlineRepository>,
+    offline: Arc<OfflineRepository>,
+}
Expand description

Hybrid repository combining online and offline data sources

+

Uses cache-first parallel racing strategy:

+
    +
  • Runs SQLite cache and HTTP server queries in parallel
  • +
  • Cache has 100ms timeout for fast feedback
  • +
  • Returns cache result if it has meaningful content
  • +
  • Falls back to server result if cache is empty/stale
  • +
+

@req: UR-002 - Access media when online or offline +@req: DR-012 - Local database for media metadata cache +@req: DR-013 - Repository pattern for online/offline data access

+

Fields§

§online: Arc<OnlineRepository>§offline: Arc<OfflineRepository>

Implementations§

Source§

impl HybridRepository

Source

pub fn new(online: OnlineRepository, offline: OfflineRepository) -> Self

Source

pub fn user_id(&self) -> &str

The signed-in user this repository acts for.

+

TRACES: UR-069 | DR-120

+
Source

pub async fn download_bytes(&self, url: &str) -> Result<Vec<u8>, String>

Download raw bytes from a URL using the shared authenticated HTTP client. +Delegates to online repository for connection reuse and proper auth.

+
Source

pub async fn prune_stale_catalog( + &self, + cutoff: &str, + item_types: &[String], +) -> Result<usize, RepoError>

Remove catalog entries the server no longer has. Cache-only, so it goes +straight to the offline repository. Callers must only invoke this after a +crawl in which every library succeeded — see +OfflineRepository::prune_stale_catalog for why a partial crawl must not +sweep.

+

TRACES: UR-065 | DR-110

+
Source

pub async fn get_jray_actors( + &self, + item_id: &str, + t: f64, +) -> Result<Vec<JRayActor>, RepoError>

Query the JRay plugin for actors on screen at time t. Online-only +(the plugin lives on the Jellyfin server); empty when JRay isn’t present.

+
Source

pub async fn get_video_stream_url( + &self, + item_id: &str, + media_source_id: Option<&str>, + audio_stream_index: Option<i32>, +) -> Result<String, RepoError>

Get video stream URL. This method is online-only since offline playback +uses local file paths.

+

Takes no start position: the URL is an HLS playlist spanning the whole +item, and a position on it would 400 every segment — see +OnlineRepository::get_video_stream_url. Resume by seeking after load.

+
Source

pub async fn get_audio_only_stream_url_for_video( + &self, + item_id: &str, + media_source_id: Option<&str>, + start_time_seconds: Option<f64>, + audio_stream_index: Option<i32>, +) -> Result<String, RepoError>

Get an audio-only stream URL for a video item (background-audio handoff). +Online-only, like get_video_stream_url.

+

TRACES: UR-040 | JA-032

+
Source

pub async fn get_album_tracks( + &self, + album_id: &str, +) -> Result<Vec<MediaItem>, RepoError>

Every track of an album, asked of the server rather than the cache.

+

Deliberately not get_items, which is cache-first: it answers from SQLite +the moment the cache has any content. That is right for browsing and wrong +for deciding what to download, because a partial or unlinked cache then +decides how much of the album gets queued while the user is told the whole +album is downloading. Downloading is the one operation that must know the +album’s complete contents.

+

Errors when the server cannot answer (offline); the caller falls back to +the local catalog and the rows are queued either way, resolving on +reconnect. Server results are written back to the cache, so browsing +benefits from the round trip too.

+

TRACES: UR-018, UR-055 | DR-173

+
Source

pub async fn get_items_unfiltered( + &self, + parent_id: &str, + options: Option<GetItemsOptions>, +) -> Result<SearchResult, RepoError>

Immediate children of a container with the user’s browsing exclusions +not applied.

+

Exists for the exclusion picker in settings. Everything else in this +repository hides what the user has hidden, which would make the setting +one-way: a folder already excluded would vanish from the list of folders +to exclude and could never be un-hidden. Server-first so the picker sees +the real library, falling back to the cache when unreachable.

+

TRACES: UR-076 | DR-209

+
Source

pub async fn search_cache_only( + &self, + query: &str, + options: Option<SearchOptions>, +) -> Result<SearchResult, RepoError>

Search only the local SQLite cache (downloaded content).

+

Fast (100ms timeout) — used to render instant results before the server +responds. Returns an empty result rather than erroring on timeout so the +caller can still fall through to the server.

+
Source

pub async fn get_favorites_cache_only( + &self, + scope: SearchScope, + options: Option<GetItemsOptions>, +) -> Result<SearchResult, RepoError>

Favourites held locally, without touching the server. Backs the instant +leg of the two-phase favourites read in the command layer.

+

TRACES: UR-067 | DR-115

+
Source

pub async fn get_favorites_server_only( + &self, + scope: SearchScope, + options: Option<GetItemsOptions>, +) -> Result<SearchResult, RepoError>

Favourites straight from the server, persisted to the cache on the way +through — which is also what mirrors their favourite flags into +user_data (DR-114), so the next offline read agrees with the server.

+

TRACES: UR-067 | DR-115

+
Source

pub async fn cache_items_from_server( + &self, + parent_id: &str, + options: Option<GetItemsOptions>, +) -> Result<Vec<MediaItem>, RepoError>

Fetch a folder’s items from the live server and persist them to the +offline cache synchronously (unlike get_items, which saves in a +fire-and-forget background task after a 100ms cache race).

+

Used by the full-catalog pre-sync (sync_full_catalog) to deterministically +walk every library while online so the whole catalog is browsable — greyed +out — offline. Returns the items fetched so the caller can recurse into +containers. Server-only: errors if unreachable.

+
Source

pub async fn get_downloaded_items( + &self, + parent_id: &str, + options: Option<GetItemsOptions>, +) -> Result<SearchResult, RepoError>

Browse downloaded content only — the dedicated Downloads surface.

+

Bypasses the cache/server merge entirely and reads the offline repository +directly, so an empty result is authoritative (“nothing downloaded here”) +and never falls through to the server (DR-080). Available online too — a +user who is reachable still wants to browse what’s on the device.

+

TRACES: UR-055 | DR-082, DR-083

+
Source

pub async fn get_downloaded_libraries(&self) -> Result<Vec<Library>, RepoError>

Libraries that contain downloaded content (offline-only, authoritative).

+

TRACES: UR-055 | DR-082

+
Source

pub async fn get_download_disk_usage( + &self, +) -> Result<DownloadDiskUsage, RepoError>

On-disk usage of downloaded content, for the disk-usage display.

+

TRACES: UR-056 | DR-085

+
Source

pub async fn search_server_only( + &self, + query: &str, + options: Option<SearchOptions>, +) -> Result<SearchResult, RepoError>

Search only the live Jellyfin server (full library).

+
Source

pub fn merge_search_results( + cache: SearchResult, + server: SearchResult, +) -> SearchResult

Merge cache and server search results into a single de-duplicated list.

+

Ordering: local (cached/downloaded) items first, then server-only items +appended. On a duplicate id, the server’s item wins (fresher, more +complete metadata) but keeps the local item’s earlier position.

+
Source

async fn parallel_race<T, F1, F2>( + &self, + cache_future: F1, + server_future: F2, +) -> Result<T, RepoError>
where + T: MeaningfulContent + ExcludeHidden + Clone + Send + 'static, + F1: Future<Output = Result<T, RepoError>> + Send, + F2: Future<Output = Result<T, RepoError>> + Send,

Cache-first query: try cache, fall back to server on miss.

+
    +
  1. Check cache (100ms timeout applied by caller via cache_with_timeout)
  2. +
  3. If cache has meaningful content → return immediately (fast path)
  4. +
  5. If cache is empty/stale → query server (fresh data)
  6. +
  7. If server fails → return cache even if empty (offline fallback)
  8. +
+

Both legs are passed through ExcludeHidden before the “does the cache +have content?” question is asked. This is the single place the cache and +server results of a cache-first query converge, so applying the user’s +browsing exclusions here covers every query built on it at once — and +filtering before the content check is what makes a cache page holding +nothing but hidden items fall through to the server instead of being +served as an empty listing.

+

@req: UR-002 - Access media when online or offline +@req: DR-013 - Repository pattern for online/offline data access

+

TRACES: UR-002, UR-076 | DR-013, DR-209

+
Source

async fn race_with_refresh<T, F1, F2, R>( + &self, + cache_future: F1, + server_future: F2, + on_cache_hit: R, +) -> Result<T, RepoError>
where + T: MeaningfulContent + ExcludeHidden + Clone + Send + 'static, + F1: Future<Output = Result<T, RepoError>> + Send, + F2: Future<Output = Result<T, RepoError>> + Send, + R: FnOnce(),

Self::parallel_race, plus a callback fired on the fast path so the +caller can refresh the cache in the background.

+

A plain cache hit answers from data that may be arbitrarily old, which +is right for the response and wrong for what it leaves behind: per-user +state (watch positions, favourites) only reaches the local tables when a +server result is cached, so a surface that always hits cache never learns +what another device did. get_items had a bespoke version of this; this +is the same idea, reusable.

+

The callback runs only on a cache hit — on a miss the server result is +already being fetched and cached by the normal path.

+

TRACES: UR-002, UR-025, UR-076 | DR-155, DR-209

+
Source

async fn cache_with_timeout<T>( + &self, + future: impl Future<Output = Result<T, RepoError>> + Send, +) -> Result<T, RepoError>

Simple timeout wrapper for cache queries (100ms timeout)

+

@req: DR-013 - Repository pattern (cache-first with timeout)

+

Trait Implementations§

Source§

impl MediaRepository for HybridRepository

Source§

fn get_item<'life0, 'life1, 'async_trait>( + &'life0 self, + item_id: &'life1 str, +) -> Pin<Box<dyn Future<Output = Result<MediaItem, RepoError>> + Send + 'async_trait>>
where + Self: 'async_trait, + 'life0: 'async_trait, + 'life1: 'async_trait,

A single item, cache-first — and, on a cache hit, refreshed in the +background so the stored copy keeps up with the server.

+

The background refresh is what carries per-user state home: caching an +item runs mirror_user_data, which is the only path by which a watch +position set on another device reaches the local user_data row the +resume check reads. Without it a cache hit returned this device’s own +stale position forever and cross-device resume silently did nothing — +get_items already refreshes this way, so browsing a season worked +while opening the episode directly did not.

+

The refreshed value lands for the next read rather than this one: the +point of the cache-first race is to answer immediately.

+

TRACES: UR-025, UR-002 | DR-155 | UT-152

+
Source§

fn get_favorites<'life0, 'async_trait>( + &'life0 self, + scope: SearchScope, + options: Option<GetItemsOptions>, +) -> Pin<Box<dyn Future<Output = Result<SearchResult, RepoError>> + Send + 'async_trait>>
where + Self: 'async_trait, + 'life0: 'async_trait,

TRACES: UR-067 | DR-115

+
Source§

fn get_libraries<'life0, 'async_trait>( + &'life0 self, +) -> Pin<Box<dyn Future<Output = Result<Vec<Library>, RepoError>> + Send + 'async_trait>>
where + Self: 'async_trait, + 'life0: 'async_trait,

Get all libraries Read more
Source§

fn get_items<'life0, 'life1, 'async_trait>( + &'life0 self, + parent_id: &'life1 str, + options: Option<GetItemsOptions>, +) -> Pin<Box<dyn Future<Output = Result<SearchResult, RepoError>> + Send + 'async_trait>>
where + Self: 'async_trait, + 'life0: 'async_trait, + 'life1: 'async_trait,

Get items in a library or parent Read more
Source§

fn get_latest_items<'life0, 'life1, 'async_trait>( + &'life0 self, + parent_id: &'life1 str, + limit: Option<usize>, +) -> Pin<Box<dyn Future<Output = Result<Vec<MediaItem>, RepoError>> + Send + 'async_trait>>
where + Self: 'async_trait, + 'life0: 'async_trait, + 'life1: 'async_trait,

Get latest items in a library Read more
Source§

fn get_resume_items<'life0, 'life1, 'async_trait>( + &'life0 self, + parent_id: Option<&'life1 str>, + limit: Option<usize>, +) -> Pin<Box<dyn Future<Output = Result<Vec<MediaItem>, RepoError>> + Send + 'async_trait>>
where + Self: 'async_trait, + 'life0: 'async_trait, + 'life1: 'async_trait,

Get resume items (continue watching/listening) Read more
Source§

fn get_next_up_episodes<'life0, 'life1, 'async_trait>( + &'life0 self, + series_id: Option<&'life1 str>, + limit: Option<usize>, +) -> Pin<Box<dyn Future<Output = Result<Vec<MediaItem>, RepoError>> + Send + 'async_trait>>
where + Self: 'async_trait, + 'life0: 'async_trait, + 'life1: 'async_trait,

Get next up episodes Read more
Source§

fn get_recently_played_audio<'life0, 'async_trait>( + &'life0 self, + limit: Option<usize>, +) -> Pin<Box<dyn Future<Output = Result<Vec<MediaItem>, RepoError>> + Send + 'async_trait>>
where + Self: 'async_trait, + 'life0: 'async_trait,

Get recently played audio
Source§

fn get_resume_movies<'life0, 'async_trait>( + &'life0 self, + limit: Option<usize>, +) -> Pin<Box<dyn Future<Output = Result<Vec<MediaItem>, RepoError>> + Send + 'async_trait>>
where + Self: 'async_trait, + 'life0: 'async_trait,

Get resume movies
Source§

fn get_rediscover_albums<'life0, 'life1, 'async_trait>( + &'life0 self, + parent_id: Option<&'life1 str>, + limit: Option<usize>, +) -> Pin<Box<dyn Future<Output = Result<Vec<MediaItem>, RepoError>> + Send + 'async_trait>>
where + Self: 'async_trait, + 'life0: 'async_trait, + 'life1: 'async_trait,

Get albums the user has played, but not recently (“rediscover” / haven’t +listened to in a while). Returns albums sorted by least-recently played +first, optionally restricted to a parent library.
Source§

fn get_genres<'life0, 'life1, 'async_trait>( + &'life0 self, + parent_id: Option<&'life1 str>, +) -> Pin<Box<dyn Future<Output = Result<Vec<Genre>, RepoError>> + Send + 'async_trait>>
where + Self: 'async_trait, + 'life0: 'async_trait, + 'life1: 'async_trait,

Get genres
Source§

fn search<'life0, 'life1, 'async_trait>( + &'life0 self, + query: &'life1 str, + options: Option<SearchOptions>, +) -> Pin<Box<dyn Future<Output = Result<SearchResult, RepoError>> + Send + 'async_trait>>
where + Self: 'async_trait, + 'life0: 'async_trait, + 'life1: 'async_trait,

Search for items Read more
Source§

fn get_playback_info<'life0, 'life1, 'async_trait>( + &'life0 self, + item_id: &'life1 str, +) -> Pin<Box<dyn Future<Output = Result<PlaybackInfo, RepoError>> + Send + 'async_trait>>
where + Self: 'async_trait, + 'life0: 'async_trait, + 'life1: 'async_trait,

Get playback info for streaming Read more
Source§

fn get_audio_stream_url<'life0, 'life1, 'async_trait>( + &'life0 self, + item_id: &'life1 str, +) -> Pin<Box<dyn Future<Output = Result<String, RepoError>> + Send + 'async_trait>>
where + Self: 'async_trait, + 'life0: 'async_trait, + 'life1: 'async_trait,

Get audio stream URL for a track Read more
Source§

fn get_audio_only_stream_url_for_video<'life0, 'life1, 'life2, 'async_trait>( + &'life0 self, + item_id: &'life1 str, + media_source_id: Option<&'life2 str>, + start_time_seconds: Option<f64>, + audio_stream_index: Option<i32>, +) -> Pin<Box<dyn Future<Output = Result<String, RepoError>> + Send + 'async_trait>>
where + Self: 'async_trait, + 'life0: 'async_trait, + 'life1: 'async_trait, + 'life2: 'async_trait,

Get an audio-only stream URL for a video item (background-audio handoff). Read more
Source§

fn get_live_tv_channels<'life0, 'async_trait>( + &'life0 self, +) -> Pin<Box<dyn Future<Output = Result<Vec<MediaItem>, RepoError>> + Send + 'async_trait>>
where + Self: 'async_trait, + 'life0: 'async_trait,

Get Live TV channels (broadcast / IPTV) for browsing.
Source§

fn get_channels<'life0, 'async_trait>( + &'life0 self, +) -> Pin<Box<dyn Future<Output = Result<SearchResult, RepoError>> + Send + 'async_trait>>
where + Self: 'async_trait, + 'life0: 'async_trait,

Get the root list of plugin “Channels” (Jellyfin Channels feature). +Drill-down into a channel reuses get_items(channel_id, ...).
Source§

fn open_live_stream<'life0, 'life1, 'async_trait>( + &'life0 self, + item_id: &'life1 str, +) -> Pin<Box<dyn Future<Output = Result<LiveStreamInfo, RepoError>> + Send + 'async_trait>>
where + Self: 'async_trait, + 'life0: 'async_trait, + 'life1: 'async_trait,

Open a live stream (Live TV channel or live channel item) for playback. Read more
Source§

fn report_playback_start<'life0, 'life1, 'async_trait>( + &'life0 self, + item_id: &'life1 str, + position_ticks: i64, +) -> Pin<Box<dyn Future<Output = Result<(), RepoError>> + Send + 'async_trait>>
where + Self: 'async_trait, + 'life0: 'async_trait, + 'life1: 'async_trait,

Report playback start Read more
Source§

fn report_playback_progress<'life0, 'life1, 'async_trait>( + &'life0 self, + item_id: &'life1 str, + position_ticks: i64, +) -> Pin<Box<dyn Future<Output = Result<(), RepoError>> + Send + 'async_trait>>
where + Self: 'async_trait, + 'life0: 'async_trait, + 'life1: 'async_trait,

Report playback progress Read more
Source§

fn report_playback_stopped<'life0, 'life1, 'async_trait>( + &'life0 self, + item_id: &'life1 str, + position_ticks: i64, +) -> Pin<Box<dyn Future<Output = Result<(), RepoError>> + Send + 'async_trait>>
where + Self: 'async_trait, + 'life0: 'async_trait, + 'life1: 'async_trait,

Report playback stopped Read more
Source§

fn get_image_url( + &self, + item_id: &str, + image_type: ImageType, + options: Option<ImageOptions>, +) -> String

Get image URL (synchronous - just constructs URL)
Source§

fn get_subtitle_url( + &self, + item_id: &str, + media_source_id: &str, + stream_index: i32, + format: &str, +) -> String

Get subtitle URL (synchronous - just constructs URL) +Called by frontend via Tauri invoke (getSubtitleUrl in VideoPlayer.svelte)
Source§

fn get_video_download_url( + &self, + item_id: &str, + quality: &str, + media_source_id: Option<&str>, + source_audio_codec: Option<&str>, +) -> String

Build the URL a video download is fetched from. Synchronous — it only +constructs a URL, so it stays testable without a server. Reach it through +resolve_video_download_url rather than calling it directly. Read more
Source§

fn mark_favorite<'life0, 'life1, 'async_trait>( + &'life0 self, + item_id: &'life1 str, +) -> Pin<Box<dyn Future<Output = Result<(), RepoError>> + Send + 'async_trait>>
where + Self: 'async_trait, + 'life0: 'async_trait, + 'life1: 'async_trait,

Mark item as favorite
Source§

fn unmark_favorite<'life0, 'life1, 'async_trait>( + &'life0 self, + item_id: &'life1 str, +) -> Pin<Box<dyn Future<Output = Result<(), RepoError>> + Send + 'async_trait>>
where + Self: 'async_trait, + 'life0: 'async_trait, + 'life1: 'async_trait,

Unmark item as favorite
Source§

fn clear_watch_history<'life0, 'life1, 'async_trait>( + &'life0 self, + item_id: &'life1 str, +) -> Pin<Box<dyn Future<Output = Result<(), RepoError>> + Send + 'async_trait>>
where + Self: 'async_trait, + 'life0: 'async_trait, + 'life1: 'async_trait,

Erase the viewer’s watch history for an item: clear its played flag and +its resume position. On a container (series, season) this applies to +everything inside it, so a series is returned to “never watched” and +reopens on its premiere. Read more
Source§

fn mark_played<'life0, 'life1, 'async_trait>( + &'life0 self, + item_id: &'life1 str, +) -> Pin<Box<dyn Future<Output = Result<(), RepoError>> + Send + 'async_trait>>
where + Self: 'async_trait, + 'life0: 'async_trait, + 'life1: 'async_trait,

Mark an item played — the inverse of clear_watch_history. Needed by the +sync-queue drain, which replays mark_played rows queued while the +server was unreachable; reporting a stop at a made-up position was the +previous stand-in and does not set the played flag reliably. Read more
Source§

fn get_person<'life0, 'life1, 'async_trait>( + &'life0 self, + person_id: &'life1 str, +) -> Pin<Box<dyn Future<Output = Result<MediaItem, RepoError>> + Send + 'async_trait>>
where + Self: 'async_trait, + 'life0: 'async_trait, + 'life1: 'async_trait,

Get person details
Source§

fn get_items_by_person<'life0, 'life1, 'async_trait>( + &'life0 self, + person_id: &'life1 str, + options: Option<GetItemsOptions>, +) -> Pin<Box<dyn Future<Output = Result<SearchResult, RepoError>> + Send + 'async_trait>>
where + Self: 'async_trait, + 'life0: 'async_trait, + 'life1: 'async_trait,

Get items by person (filmography)
Source§

fn get_similar_items<'life0, 'life1, 'async_trait>( + &'life0 self, + item_id: &'life1 str, + limit: Option<usize>, +) -> Pin<Box<dyn Future<Output = Result<SearchResult, RepoError>> + Send + 'async_trait>>
where + Self: 'async_trait, + 'life0: 'async_trait, + 'life1: 'async_trait,

Get similar/related items for a movie or show Read more
Source§

fn create_playlist<'life0, 'life1, 'life2, 'async_trait>( + &'life0 self, + name: &'life1 str, + item_ids: &'life2 [String], +) -> Pin<Box<dyn Future<Output = Result<PlaylistCreatedResult, RepoError>> + Send + 'async_trait>>
where + Self: 'async_trait, + 'life0: 'async_trait, + 'life1: 'async_trait, + 'life2: 'async_trait,

Create a new playlist on the server Read more
Source§

fn delete_playlist<'life0, 'life1, 'async_trait>( + &'life0 self, + playlist_id: &'life1 str, +) -> Pin<Box<dyn Future<Output = Result<(), RepoError>> + Send + 'async_trait>>
where + Self: 'async_trait, + 'life0: 'async_trait, + 'life1: 'async_trait,

Delete a playlist Read more
Source§

fn rename_playlist<'life0, 'life1, 'life2, 'async_trait>( + &'life0 self, + playlist_id: &'life1 str, + name: &'life2 str, +) -> Pin<Box<dyn Future<Output = Result<(), RepoError>> + Send + 'async_trait>>
where + Self: 'async_trait, + 'life0: 'async_trait, + 'life1: 'async_trait, + 'life2: 'async_trait,

Rename a playlist Read more
Source§

fn get_playlist_items<'life0, 'life1, 'async_trait>( + &'life0 self, + playlist_id: &'life1 str, +) -> Pin<Box<dyn Future<Output = Result<Vec<PlaylistEntry>, RepoError>> + Send + 'async_trait>>
where + Self: 'async_trait, + 'life0: 'async_trait, + 'life1: 'async_trait,

Get playlist items with PlaylistItemId (needed for remove/reorder) Read more
Source§

fn add_to_playlist<'life0, 'life1, 'life2, 'async_trait>( + &'life0 self, + playlist_id: &'life1 str, + item_ids: &'life2 [String], +) -> Pin<Box<dyn Future<Output = Result<(), RepoError>> + Send + 'async_trait>>
where + Self: 'async_trait, + 'life0: 'async_trait, + 'life1: 'async_trait, + 'life2: 'async_trait,

Add items to a playlist Read more
Source§

fn remove_from_playlist<'life0, 'life1, 'life2, 'async_trait>( + &'life0 self, + playlist_id: &'life1 str, + entry_ids: &'life2 [String], +) -> Pin<Box<dyn Future<Output = Result<(), RepoError>> + Send + 'async_trait>>
where + Self: 'async_trait, + 'life0: 'async_trait, + 'life1: 'async_trait, + 'life2: 'async_trait,

Remove items from a playlist using entry IDs (PlaylistItemId, NOT media item IDs) Read more
Source§

fn move_playlist_item<'life0, 'life1, 'life2, 'async_trait>( + &'life0 self, + playlist_id: &'life1 str, + item_id: &'life2 str, + new_index: u32, +) -> Pin<Box<dyn Future<Output = Result<(), RepoError>> + Send + 'async_trait>>
where + Self: 'async_trait, + 'life0: 'async_trait, + 'life1: 'async_trait, + 'life2: 'async_trait,

Move a playlist item to a new position Read more

Auto Trait Implementations§

Blanket Implementations§

Source§

impl<T> Any for T
where + T: 'static + ?Sized,

Source§

fn type_id(&self) -> TypeId

Gets the TypeId of self. Read more
Source§

impl<T> Borrow<T> for T
where + T: ?Sized,

Source§

fn borrow(&self) -> &T

Immutably borrows from an owned value. Read more
Source§

impl<T> BorrowMut<T> for T
where + T: ?Sized,

Source§

fn borrow_mut(&mut self) -> &mut T

Mutably borrows from an owned value. Read more
Source§

impl<T> From<T> for T

Source§

fn from(t: T) -> T

Returns the argument unchanged.

+
§

impl<T> Instrument for T

§

fn instrument(self, span: Span) -> Instrumented<Self>

Instruments this type with the provided [Span], returning an +Instrumented wrapper. Read more
§

fn in_current_span(self) -> Instrumented<Self>

Instruments this type with the current Span, returning an +Instrumented wrapper. Read more
Source§

impl<T, U> Into<U> for T
where + U: From<T>,

Source§

fn into(self) -> U

Calls U::from(self).

+

That is, this conversion is whatever the implementation of +From<T> for U chooses to do.

+
§

impl<T> PolicyExt for T
where + T: ?Sized,

§

fn and<P, B, E>(self, other: P) -> And<T, P>
where + T: Policy<B, E>, + P: Policy<B, E>,

Create a new Policy that returns [Action::Follow] only if self and other return +Action::Follow. Read more
§

fn or<P, B, E>(self, other: P) -> Or<T, P>
where + T: Policy<B, E>, + P: Policy<B, E>,

Create a new Policy that returns [Action::Follow] if either self or other returns +Action::Follow. Read more
Source§

impl<T> Same for T

Source§

type Output = T

Should always be Self
Source§

impl<T, U> TryFrom<U> for T
where + U: Into<T>,

Source§

type Error = Infallible

The type returned in the event of a conversion error.
Source§

fn try_from(value: U) -> Result<T, <T as TryFrom<U>>::Error>

Performs the conversion.
Source§

impl<T, U> TryInto<U> for T
where + U: TryFrom<T>,

Source§

type Error = <U as TryFrom<T>>::Error

The type returned in the event of a conversion error.
Source§

fn try_into(self) -> Result<U, <U as TryFrom<T>>::Error>

Performs the conversion.
§

impl<V, T> VZip<V> for T
where + V: MultiLane<T>,

§

fn vzip(self) -> V

§

impl<T> WithSubscriber for T

§

fn with_subscriber<S>(self, subscriber: S) -> WithDispatch<Self>
where + S: Into<Dispatch>,

Attaches the provided Subscriber to this type, returning a +[WithDispatch] wrapper. Read more
§

fn with_current_subscriber(self) -> WithDispatch<Self>

Attaches the current default Subscriber to this type, returning a +[WithDispatch] wrapper. Read more
\ No newline at end of file diff --git a/api/jellytau_lib/repository/index.html b/api/jellytau_lib/repository/index.html new file mode 100644 index 00000000..86ce05a3 --- /dev/null +++ b/api/jellytau_lib/repository/index.html @@ -0,0 +1,5 @@ +jellytau_lib::repository - RustSkip to main content

Module repository

Module repository 

Source

Re-exports§

pub use hybrid::HybridRepository;
pub use offline::OfflineRepository;
pub use online::JRayActor;
pub use online::OnlineRepository;
pub use types::*;

Modules§

device_profile
Device-profile policy: turning what a device reports about its audio +output into the constraints we send Jellyfin.
exclusions
User-chosen browsing exclusions (UR-076 / DR-209). +Library folders the user has chosen to keep out of browsing.
hybrid
offline
online
TRACES: UR-002, UR-007 | DR-013 | IR-010
series_progress
Where a viewer is in a TV series.
types

Traits§

MediaRepository
Repository trait for media access (online, offline, or hybrid)

Functions§

resolve_video_download_url
Resolve the download URL for a video, applying the audio-codec policy that +keeps the saved file playable offline (DR-171).
served_audio_codec
The audio codec the server would serve for item_id — the default track, or +the first when none is marked, matching the track Jellyfin picks.
\ No newline at end of file diff --git a/api/jellytau_lib/repository/offline/fn.build_fts_prefix_query.html b/api/jellytau_lib/repository/offline/fn.build_fts_prefix_query.html new file mode 100644 index 00000000..cb4fdcb4 --- /dev/null +++ b/api/jellytau_lib/repository/offline/fn.build_fts_prefix_query.html @@ -0,0 +1,13 @@ +build_fts_prefix_query in jellytau_lib::repository::offline - RustSkip to main content

build_fts_prefix_query

Function build_fts_prefix_query 

Source
fn build_fts_prefix_query(query: &str) -> Option<String>
Expand description

Build a safe FTS5 prefix query from raw user input.

+

Every whitespace-separated token is emitted as a quoted phrase, so +punctuation the user types (apostrophes in Bob's Burgers, hyphens in +Spider-Man, the / in AC/DC) is treated as data rather than FTS5 +operator syntax — unquoted, those characters make MATCH raise a syntax +error and the whole search fails. The final token carries the * prefix +operator so results appear while the user is still typing; earlier tokens are +implicitly ANDed, which preserves the pre-existing matching semantics.

+

Returns None when the input holds nothing searchable (empty, or pure +punctuation), so callers skip the query rather than handing FTS5 a string it +will reject — search("") is a real call site, used to list all playlists.

+

TRACES: UR-065 | DR-108 | UT-111

+
\ No newline at end of file diff --git a/api/jellytau_lib/repository/offline/fn.include_catalog_browse.html b/api/jellytau_lib/repository/offline/fn.include_catalog_browse.html new file mode 100644 index 00000000..ff3f7965 --- /dev/null +++ b/api/jellytau_lib/repository/offline/fn.include_catalog_browse.html @@ -0,0 +1,8 @@ +include_catalog_browse in jellytau_lib::repository::offline - RustSkip to main content

include_catalog_browse

Function include_catalog_browse 

Source
pub fn include_catalog_browse() -> bool
Expand description

Whether offline get_items currently includes the synced-but-not-downloaded +catalog (the greyed-out browse view). Mirrors set_include_catalog_browse.

+

Exposed so the hybrid repo can tell “cache is cold, ask the server” from +“user asked for downloads only and there are none here”: when this is false, +an empty offline get_items is authoritative and must not fall through to +the server. See hybrid.rs get_items.

+

TRACES: UR-052 | DR-078, DR-080

+
\ No newline at end of file diff --git a/api/jellytau_lib/repository/offline/fn.row_to_cached_item.html b/api/jellytau_lib/repository/offline/fn.row_to_cached_item.html new file mode 100644 index 00000000..5e13d9cc --- /dev/null +++ b/api/jellytau_lib/repository/offline/fn.row_to_cached_item.html @@ -0,0 +1 @@ +row_to_cached_item in jellytau_lib::repository::offline - RustSkip to main content

row_to_cached_item

Function row_to_cached_item 

Source
fn row_to_cached_item(row: &Row<'_>) -> Result<CachedItem>
\ No newline at end of file diff --git a/api/jellytau_lib/repository/offline/fn.set_include_catalog_browse.html b/api/jellytau_lib/repository/offline/fn.set_include_catalog_browse.html new file mode 100644 index 00000000..03e1b0d2 --- /dev/null +++ b/api/jellytau_lib/repository/offline/fn.set_include_catalog_browse.html @@ -0,0 +1,5 @@ +set_include_catalog_browse in jellytau_lib::repository::offline - RustSkip to main content

set_include_catalog_browse

Function set_include_catalog_browse 

Source
pub fn set_include_catalog_browse(include: bool)
Expand description

Set whether offline get_items includes non-downloaded (synced-only) catalog +items. Called from the frontend: true when online or when the offline +“Show all server media” toggle is on; false when offline with the toggle +off (show downloaded/local media only).

+
\ No newline at end of file diff --git a/api/jellytau_lib/repository/offline/index.html b/api/jellytau_lib/repository/offline/index.html new file mode 100644 index 00000000..273c12b0 --- /dev/null +++ b/api/jellytau_lib/repository/offline/index.html @@ -0,0 +1,8 @@ +jellytau_lib::repository::offline - RustSkip to main content

Module offline

Module offline 

Source

Structs§

CachedItem 🔒
OfflineRepository

Statics§

INCLUDE_CATALOG_BROWSE 🔒
Whether offline library queries may include catalog items that are merely +browsed/synced but not downloaded (the greyed-out “browse the whole server” +view). Defaults to true so online browsing (which reads this same cache as +a fast path) still sees the full catalog.

Functions§

build_fts_prefix_query 🔒
Build a safe FTS5 prefix query from raw user input.
include_catalog_browse
Whether offline get_items currently includes the synced-but-not-downloaded +catalog (the greyed-out browse view). Mirrors set_include_catalog_browse.
row_to_cached_item 🔒
set_include_catalog_browse
Set whether offline get_items includes non-downloaded (synced-only) catalog +items. Called from the frontend: true when online or when the offline +“Show all server media” toggle is on; false when offline with the toggle +off (show downloaded/local media only).
\ No newline at end of file diff --git a/api/jellytau_lib/repository/offline/sidebar-items.js b/api/jellytau_lib/repository/offline/sidebar-items.js new file mode 100644 index 00000000..859e19f1 --- /dev/null +++ b/api/jellytau_lib/repository/offline/sidebar-items.js @@ -0,0 +1 @@ +window.SIDEBAR_ITEMS = {"fn":["build_fts_prefix_query","include_catalog_browse","row_to_cached_item","set_include_catalog_browse"],"static":["INCLUDE_CATALOG_BROWSE"],"struct":["CachedItem","OfflineRepository"]}; \ No newline at end of file diff --git a/api/jellytau_lib/repository/offline/static.INCLUDE_CATALOG_BROWSE.html b/api/jellytau_lib/repository/offline/static.INCLUDE_CATALOG_BROWSE.html new file mode 100644 index 00000000..0c8fc932 --- /dev/null +++ b/api/jellytau_lib/repository/offline/static.INCLUDE_CATALOG_BROWSE.html @@ -0,0 +1,11 @@ +INCLUDE_CATALOG_BROWSE in jellytau_lib::repository::offline - RustSkip to main content

INCLUDE_CATALOG_BROWSE

Static INCLUDE_CATALOG_BROWSE 

Source
static INCLUDE_CATALOG_BROWSE: AtomicBool
Expand description

Whether offline library queries may include catalog items that are merely +browsed/synced but not downloaded (the greyed-out “browse the whole server” +view). Defaults to true so online browsing (which reads this same cache as +a fast path) still sees the full catalog.

+

While offline, the frontend drives this from the “Show all server media” +toggle: OFF means library pages show only downloaded/local media, ON reveals +the full greyed-out catalog. See set_include_catalog_browse and the +showServerCatalog UI flag. Fixes the bug where offline library pages showed +every server item regardless of the toggle.

+

TRACES: UR-052 | DR-078

+
\ No newline at end of file diff --git a/api/jellytau_lib/repository/offline/struct.CachedItem.html b/api/jellytau_lib/repository/offline/struct.CachedItem.html new file mode 100644 index 00000000..327d8a88 --- /dev/null +++ b/api/jellytau_lib/repository/offline/struct.CachedItem.html @@ -0,0 +1,52 @@ +CachedItem in jellytau_lib::repository::offline - RustSkip to main content

CachedItem

Struct CachedItem 

Source
struct CachedItem {
Show 27 fields + id: String, + name: String, + item_type: String, + is_folder: bool, + server_id: String, + parent_id: Option<String>, + library_id: Option<String>, + overview: Option<String>, + genres: Option<String>, + runtime_ticks: Option<i64>, + production_year: Option<i32>, + premiere_date: Option<String>, + community_rating: Option<f64>, + official_rating: Option<String>, + primary_image_tag: Option<String>, + backdrop_image_tags: Option<Vec<String>>, + parent_backdrop_image_tags: Option<Vec<String>>, + album_id: Option<String>, + album_name: Option<String>, + album_artist: Option<String>, + artists: Option<String>, + index_number: Option<i32>, + series_id: Option<String>, + series_name: Option<String>, + season_id: Option<String>, + season_name: Option<String>, + parent_index_number: Option<i32>, +
}

Fields§

§id: String§name: String§item_type: String§is_folder: bool§server_id: String§parent_id: Option<String>§library_id: Option<String>§overview: Option<String>§genres: Option<String>§runtime_ticks: Option<i64>§production_year: Option<i32>§premiere_date: Option<String>§community_rating: Option<f64>§official_rating: Option<String>§primary_image_tag: Option<String>§backdrop_image_tags: Option<Vec<String>>§parent_backdrop_image_tags: Option<Vec<String>>§album_id: Option<String>§album_name: Option<String>§album_artist: Option<String>§artists: Option<String>§index_number: Option<i32>§series_id: Option<String>§series_name: Option<String>§season_id: Option<String>§season_name: Option<String>§parent_index_number: Option<i32>

Trait Implementations§

Source§

impl Debug for CachedItem

Source§

fn fmt(&self, f: &mut Formatter<'_>) -> Result

Formats the value using the given formatter. Read more

Auto Trait Implementations§

Blanket Implementations§

Source§

impl<T> Any for T
where + T: 'static + ?Sized,

Source§

fn type_id(&self) -> TypeId

Gets the TypeId of self. Read more
Source§

impl<T> Borrow<T> for T
where + T: ?Sized,

Source§

fn borrow(&self) -> &T

Immutably borrows from an owned value. Read more
Source§

impl<T> BorrowMut<T> for T
where + T: ?Sized,

Source§

fn borrow_mut(&mut self) -> &mut T

Mutably borrows from an owned value. Read more
Source§

impl<T> From<T> for T

Source§

fn from(t: T) -> T

Returns the argument unchanged.

+
§

impl<T> Instrument for T

§

fn instrument(self, span: Span) -> Instrumented<Self>

Instruments this type with the provided [Span], returning an +Instrumented wrapper. Read more
§

fn in_current_span(self) -> Instrumented<Self>

Instruments this type with the current Span, returning an +Instrumented wrapper. Read more
Source§

impl<T, U> Into<U> for T
where + U: From<T>,

Source§

fn into(self) -> U

Calls U::from(self).

+

That is, this conversion is whatever the implementation of +From<T> for U chooses to do.

+
§

impl<T> PolicyExt for T
where + T: ?Sized,

§

fn and<P, B, E>(self, other: P) -> And<T, P>
where + T: Policy<B, E>, + P: Policy<B, E>,

Create a new Policy that returns [Action::Follow] only if self and other return +Action::Follow. Read more
§

fn or<P, B, E>(self, other: P) -> Or<T, P>
where + T: Policy<B, E>, + P: Policy<B, E>,

Create a new Policy that returns [Action::Follow] if either self or other returns +Action::Follow. Read more
Source§

impl<T> Same for T

Source§

type Output = T

Should always be Self
Source§

impl<T, U> TryFrom<U> for T
where + U: Into<T>,

Source§

type Error = Infallible

The type returned in the event of a conversion error.
Source§

fn try_from(value: U) -> Result<T, <T as TryFrom<U>>::Error>

Performs the conversion.
Source§

impl<T, U> TryInto<U> for T
where + U: TryFrom<T>,

Source§

type Error = <U as TryFrom<T>>::Error

The type returned in the event of a conversion error.
Source§

fn try_into(self) -> Result<U, <U as TryFrom<T>>::Error>

Performs the conversion.
§

impl<V, T> VZip<V> for T
where + V: MultiLane<T>,

§

fn vzip(self) -> V

§

impl<T> WithSubscriber for T

§

fn with_subscriber<S>(self, subscriber: S) -> WithDispatch<Self>
where + S: Into<Dispatch>,

Attaches the provided Subscriber to this type, returning a +[WithDispatch] wrapper. Read more
§

fn with_current_subscriber(self) -> WithDispatch<Self>

Attaches the current default Subscriber to this type, returning a +[WithDispatch] wrapper. Read more
\ No newline at end of file diff --git a/api/jellytau_lib/repository/offline/struct.OfflineRepository.html b/api/jellytau_lib/repository/offline/struct.OfflineRepository.html new file mode 100644 index 00000000..abec1075 --- /dev/null +++ b/api/jellytau_lib/repository/offline/struct.OfflineRepository.html @@ -0,0 +1,457 @@ +OfflineRepository in jellytau_lib::repository::offline - RustSkip to main content

OfflineRepository

Struct OfflineRepository 

Source
pub struct OfflineRepository {
+    db_service: Arc<RusqliteService>,
+    server_id: String,
+    user_id: String,
+}

Fields§

§db_service: Arc<RusqliteService>§server_id: String§user_id: String

Implementations§

Source§

impl OfflineRepository

Source

pub fn new( + db_service: Arc<RusqliteService>, + server_id: String, + user_id: String, +) -> Self

Source

fn cached_item_to_media_item( + item: CachedItem, + user_data: Option<UserData>, +) -> MediaItem

Helper to convert CachedItem from storage to MediaItem

+
Source

async fn get_user_data(&self, item_id: &str) -> Option<UserData>

Get user data for an item (playback position, favorite, etc.)

+
Source§

impl OfflineRepository

Source

const LIBRARY_HOLDS_ITEM: &'static str = "( + (l.collection_type = 'music' AND i.item_type IN ('MusicAlbum', 'MusicArtist', 'Audio')) + OR (l.collection_type = 'movies' AND i.item_type = 'Movie') + OR (l.collection_type = 'tvshows' AND i.item_type IN ('Series', 'Season', 'Episode')) + OR l.collection_type IS NULL + OR l.collection_type NOT IN ('music', 'movies', 'tvshows') + )"

SQL fragment: the set of item ids that are “on the device” — playable +items with a completed download, plus containers (album/series/season) +that have at least one downloaded child. This is the get_items CTE with +the synced-but-not-downloaded catalog branch deliberately excluded, so it +is authoritative regardless of the process-wide catalog-browse flag.

+

Whether cached item i belongs to library l, decided by media kind.

+

The cache leaves library_id/parent_id NULL on every item +([[offline-libraries-never-cached]]), so there is no link to follow: a +library’s collection_type and an item’s item_type are the only things +that can associate them. This is Jellyfin taxonomy and therefore lives in +Rust, never in the frontend.

+

It is a named constant because it is needed in two places that must agree +— which library appears in the Downloaded list, and which items appear +inside it. They disagreed: the listing query used this mapping while the +browse query only checked that the requested library existed, so opening +any library showed every downloaded top-level item on the server.

+

A library of some other (or unknown) type keeps everything, since there is +no mapping to narrow it by and hiding its contents would be worse.

+

TRACES: UR-055 | DR-082, DR-167

+
Source

const DOWNLOADED_ITEMS_CTE: &'static str = " + WITH downloaded_items AS ( + SELECT DISTINCT i.id + FROM items i + INNER JOIN downloads d ON i.id = d.item_id + WHERE d.status = 'completed' + AND i.item_type IN ('Audio', 'Movie', 'Episode') + + UNION + + SELECT DISTINCT i.id + FROM items i + INNER JOIN items children ON (children.parent_id = i.id OR children.album_id = i.id OR children.season_id = i.id OR children.series_id = i.id) + INNER JOIN downloads d ON children.id = d.item_id + WHERE d.status = 'completed' + AND i.item_type IN ('MusicAlbum', 'Series', 'Season', 'BoxSet', 'Folder', 'CollectionFolder') + )"

TRACES: UR-055 | DR-082, DR-083

+
Source

pub async fn prune_stale_catalog( + &self, + cutoff: &str, + item_types: &[String], +) -> Result<usize, RepoError>

Remove catalog entries the server no longer has (mark-and-sweep).

+

save_to_cache stamps every row it writes with a fresh synced_at, so +after a complete crawl anything still on the server carries a timestamp +newer than cutoff (taken before the crawl began) and anything deleted +server-side kept its older one. Sweeping by timestamp avoids binding the +crawl’s entire id set, which would blow past SQLite’s variable limit on a +large library.

+

Three exclusions, each load-bearing:

+
    +
  • Only item_types the crawl actually requested. The crawl asks for +CATALOG_ITEM_TYPES; rows of any other type (artists, playlists, the +Folder parent stubs save_to_cache inserts) are never refreshed by +it, so sweeping by age alone would delete every one of them.
  • +
  • Anything a completed download depends on — the downloaded item +itself, and any container with a downloaded child. The user has those +bytes on disk; dropping the row would orphan the file.
  • +
  • Callers must only invoke this after a crawl in which every library +succeeded. sync_full_catalog is best-effort per library, and +items.parent_id is ON DELETE CASCADE, so sweeping after a partial +crawl could cascade an entire series away because one request timed out.
  • +
+

Returns the number of rows removed.

+

TRACES: UR-065 | DR-110 | UT-113

+
Source

async fn search_people( + &self, + fts_query: &str, + limit: usize, +) -> Result<Vec<MediaItem>, RepoError>

Full-text search over cached people (cast and crew).

+

People are stored in their own table rather than in items, so search +has to look them up separately and adapt them to MediaItem. Availability +gating deliberately does not apply: a person is metadata, never a +download, so there is nothing to be offline about.

+

TRACES: UR-065, UR-060 | DR-111 | UT-114

+
Source

pub async fn save_to_cache( + &self, + parent_id: &str, + items: &[MediaItem], +) -> Result<usize, RepoError>

Save browsed items to cache for faster subsequent loading

+

This persists metadata for items that were browsed (not necessarily downloaded). +Items are marked with current timestamp for freshness tracking.

+
Source

async fn save_to_cache_impl( + &self, + parent_id: &str, + items: &[MediaItem], + now: &str, +) -> Result<usize, RepoError>

Source

async fn mirror_user_data( + &self, + item: &MediaItem, + now: &str, +) -> Result<(), RepoError>

Mirror the server’s per-user state for an item into the local +user_data table, so favourites marked — and positions watched — on any +other client are visible here, including offline, where the local table +is the only source.

+

The WHERE user_data.pending_sync = 0 on the conflict clause is the +conflict rule: a change made while the server was unreachable is still +waiting to be pushed, and must not be clobbered by the stale value the +server is still reporting. For a position that means it is never pulled +backwards by a server that has not yet heard where we got to.

+

Each field is mirrored only when the server actually reported it — +COALESCE(excluded.x, user_data.x) keeps the stored value for anything +absent, and a row with neither field is skipped outright rather than +written as zeroes, which would fabricate an “unfavourited, unwatched” +record from an endpoint that simply omits UserData.

+

The position half is what makes cross-device resume work: the resume +check reads this table alone, so before it was mirrored an item watched +elsewhere resumed from whatever this device last saw, or not at all.

+

TRACES: UR-025, UR-069 | DR-114, DR-155 | UT-102, UT-152

+
Source

pub async fn save_libraries_to_cache( + &self, + libraries: &[Library], +) -> Result<usize, RepoError>

Cache the library (view) list from the server into the local database. +Called by HybridRepository after a successful online fetch so the list is +available offline. Without this, the libraries table stays empty and +offline startup shows no libraries at all.

+
Source

pub async fn save_genres_to_cache( + &self, + parent_id: Option<&str>, + genres: &[Genre], +) -> Result<usize, RepoError>

Cache the full server genre catalog for a library, so offline (and the +hybrid cache-first race) can return the complete list instead of only the +genres derivable from locally-cached albums. Replaces the scope’s rows +wholesale so genres removed on the server don’t linger.

+
Source

pub async fn get_downloaded_items( + &self, + parent_id: &str, + options: Option<GetItemsOptions>, +) -> Result<SearchResult, RepoError>

Downloaded-only browse: items under parent_id that are on the device.

+

Unlike MediaRepository::get_items, this never includes the +synced-but-not-downloaded catalog and never consults the process-wide +INCLUDE_CATALOG_BROWSE flag — it is the dedicated Downloads surface. +An empty result is authoritative (“nothing downloaded here”), so the +hybrid repo must call this directly rather than racing the server.

+

TRACES: UR-055 | DR-082, DR-083

+
Source

pub async fn get_downloaded_libraries(&self) -> Result<Vec<Library>, RepoError>

Libraries that contain at least one downloaded item. Libraries with +nothing on the device are omitted, so the Downloaded surface only lists +libraries the user actually has offline content in.

+

TRACES: UR-055 | DR-082

+
Source

pub async fn get_download_disk_usage( + &self, +) -> Result<DownloadDiskUsage, RepoError>

On-disk bytes for downloaded content, for the disk-usage display.

+

Returns one entry per container or leaf that appears in the Downloaded +browse: a leaf’s own file_size, a container’s summed downloaded +descendants — plus the device total and item (leaf) count. This is pure +aggregation over downloads.file_size, not new tracking.

+

TRACES: UR-056 | DR-085

+
Source

pub async fn save_playlist_items_to_cache( + &self, + playlist_id: &str, + entries: &[PlaylistEntry], +) -> Result<(), RepoError>

Cache playlist items from server into local database +Called by HybridRepository after fetching from online

+

Trait Implementations§

Source§

impl MediaRepository for OfflineRepository

Source§

fn get_favorites<'life0, 'async_trait>( + &'life0 self, + scope: SearchScope, + options: Option<GetItemsOptions>, +) -> Pin<Box<dyn Future<Output = Result<SearchResult, RepoError>> + Send + 'async_trait>>
where + Self: 'async_trait, + 'life0: 'async_trait,

Favourites held locally — the ones mirrored from the server by +save_to_cache plus anything favourited on this device.

+

Gated by the same available_items rules as browsing, so with “Show all +server media” off this returns favourites that are actually on the +device rather than the whole favourited catalog (DR-080).

+

TRACES: UR-067 | DR-115 | UT-101

+
Source§

fn get_libraries<'life0, 'async_trait>( + &'life0 self, +) -> Pin<Box<dyn Future<Output = Result<Vec<Library>, RepoError>> + Send + 'async_trait>>
where + Self: 'async_trait, + 'life0: 'async_trait,

Get all libraries Read more
Source§

fn get_items<'life0, 'life1, 'async_trait>( + &'life0 self, + parent_id: &'life1 str, + options: Option<GetItemsOptions>, +) -> Pin<Box<dyn Future<Output = Result<SearchResult, RepoError>> + Send + 'async_trait>>
where + Self: 'async_trait, + 'life0: 'async_trait, + 'life1: 'async_trait,

Get items in a library or parent Read more
Source§

fn get_item<'life0, 'life1, 'async_trait>( + &'life0 self, + item_id: &'life1 str, +) -> Pin<Box<dyn Future<Output = Result<MediaItem, RepoError>> + Send + 'async_trait>>
where + Self: 'async_trait, + 'life0: 'async_trait, + 'life1: 'async_trait,

Get a single item by ID Read more
Source§

fn get_latest_items<'life0, 'life1, 'async_trait>( + &'life0 self, + parent_id: &'life1 str, + limit: Option<usize>, +) -> Pin<Box<dyn Future<Output = Result<Vec<MediaItem>, RepoError>> + Send + 'async_trait>>
where + Self: 'async_trait, + 'life0: 'async_trait, + 'life1: 'async_trait,

Get latest items in a library Read more
Source§

fn get_resume_items<'life0, 'life1, 'async_trait>( + &'life0 self, + parent_id: Option<&'life1 str>, + limit: Option<usize>, +) -> Pin<Box<dyn Future<Output = Result<Vec<MediaItem>, RepoError>> + Send + 'async_trait>>
where + Self: 'async_trait, + 'life0: 'async_trait, + 'life1: 'async_trait,

Get resume items (continue watching/listening) Read more
Source§

fn get_next_up_episodes<'life0, 'life1, 'async_trait>( + &'life0 self, + _series_id: Option<&'life1 str>, + _limit: Option<usize>, +) -> Pin<Box<dyn Future<Output = Result<Vec<MediaItem>, RepoError>> + Send + 'async_trait>>
where + Self: 'async_trait, + 'life0: 'async_trait, + 'life1: 'async_trait,

Get next up episodes Read more
Source§

fn get_recently_played_audio<'life0, 'async_trait>( + &'life0 self, + limit: Option<usize>, +) -> Pin<Box<dyn Future<Output = Result<Vec<MediaItem>, RepoError>> + Send + 'async_trait>>
where + Self: 'async_trait, + 'life0: 'async_trait,

Get recently played audio
Source§

fn get_rediscover_albums<'life0, 'life1, 'async_trait>( + &'life0 self, + _parent_id: Option<&'life1 str>, + _limit: Option<usize>, +) -> Pin<Box<dyn Future<Output = Result<Vec<MediaItem>, RepoError>> + Send + 'async_trait>>
where + Self: 'async_trait, + 'life0: 'async_trait, + 'life1: 'async_trait,

Get albums the user has played, but not recently (“rediscover” / haven’t +listened to in a while). Returns albums sorted by least-recently played +first, optionally restricted to a parent library.
Source§

fn get_resume_movies<'life0, 'async_trait>( + &'life0 self, + limit: Option<usize>, +) -> Pin<Box<dyn Future<Output = Result<Vec<MediaItem>, RepoError>> + Send + 'async_trait>>
where + Self: 'async_trait, + 'life0: 'async_trait,

Get resume movies
Source§

fn get_genres<'life0, 'life1, 'async_trait>( + &'life0 self, + parent_id: Option<&'life1 str>, +) -> Pin<Box<dyn Future<Output = Result<Vec<Genre>, RepoError>> + Send + 'async_trait>>
where + Self: 'async_trait, + 'life0: 'async_trait, + 'life1: 'async_trait,

Get genres
Source§

fn search<'life0, 'life1, 'async_trait>( + &'life0 self, + query: &'life1 str, + options: Option<SearchOptions>, +) -> Pin<Box<dyn Future<Output = Result<SearchResult, RepoError>> + Send + 'async_trait>>
where + Self: 'async_trait, + 'life0: 'async_trait, + 'life1: 'async_trait,

Search for items Read more
Source§

fn get_playback_info<'life0, 'life1, 'async_trait>( + &'life0 self, + _item_id: &'life1 str, +) -> Pin<Box<dyn Future<Output = Result<PlaybackInfo, RepoError>> + Send + 'async_trait>>
where + Self: 'async_trait, + 'life0: 'async_trait, + 'life1: 'async_trait,

Get playback info for streaming Read more
Source§

fn get_audio_stream_url<'life0, 'life1, 'async_trait>( + &'life0 self, + _item_id: &'life1 str, +) -> Pin<Box<dyn Future<Output = Result<String, RepoError>> + Send + 'async_trait>>
where + Self: 'async_trait, + 'life0: 'async_trait, + 'life1: 'async_trait,

Get audio stream URL for a track Read more
Source§

fn get_audio_only_stream_url_for_video<'life0, 'life1, 'life2, 'async_trait>( + &'life0 self, + _item_id: &'life1 str, + _media_source_id: Option<&'life2 str>, + _start_time_seconds: Option<f64>, + _audio_stream_index: Option<i32>, +) -> Pin<Box<dyn Future<Output = Result<String, RepoError>> + Send + 'async_trait>>
where + Self: 'async_trait, + 'life0: 'async_trait, + 'life1: 'async_trait, + 'life2: 'async_trait,

Get an audio-only stream URL for a video item (background-audio handoff). Read more
Source§

fn get_live_tv_channels<'life0, 'async_trait>( + &'life0 self, +) -> Pin<Box<dyn Future<Output = Result<Vec<MediaItem>, RepoError>> + Send + 'async_trait>>
where + Self: 'async_trait, + 'life0: 'async_trait,

Get Live TV channels (broadcast / IPTV) for browsing.
Source§

fn get_channels<'life0, 'async_trait>( + &'life0 self, +) -> Pin<Box<dyn Future<Output = Result<SearchResult, RepoError>> + Send + 'async_trait>>
where + Self: 'async_trait, + 'life0: 'async_trait,

Get the root list of plugin “Channels” (Jellyfin Channels feature). +Drill-down into a channel reuses get_items(channel_id, ...).
Source§

fn open_live_stream<'life0, 'life1, 'async_trait>( + &'life0 self, + _item_id: &'life1 str, +) -> Pin<Box<dyn Future<Output = Result<LiveStreamInfo, RepoError>> + Send + 'async_trait>>
where + Self: 'async_trait, + 'life0: 'async_trait, + 'life1: 'async_trait,

Open a live stream (Live TV channel or live channel item) for playback. Read more
Source§

fn report_playback_start<'life0, 'life1, 'async_trait>( + &'life0 self, + _item_id: &'life1 str, + _position_ticks: i64, +) -> Pin<Box<dyn Future<Output = Result<(), RepoError>> + Send + 'async_trait>>
where + Self: 'async_trait, + 'life0: 'async_trait, + 'life1: 'async_trait,

Report playback start Read more
Source§

fn report_playback_progress<'life0, 'life1, 'async_trait>( + &'life0 self, + _item_id: &'life1 str, + _position_ticks: i64, +) -> Pin<Box<dyn Future<Output = Result<(), RepoError>> + Send + 'async_trait>>
where + Self: 'async_trait, + 'life0: 'async_trait, + 'life1: 'async_trait,

Report playback progress Read more
Source§

fn report_playback_stopped<'life0, 'life1, 'async_trait>( + &'life0 self, + _item_id: &'life1 str, + _position_ticks: i64, +) -> Pin<Box<dyn Future<Output = Result<(), RepoError>> + Send + 'async_trait>>
where + Self: 'async_trait, + 'life0: 'async_trait, + 'life1: 'async_trait,

Report playback stopped Read more
Source§

fn get_image_url( + &self, + item_id: &str, + image_type: ImageType, + options: Option<ImageOptions>, +) -> String

Get image URL (synchronous - just constructs URL)
Source§

fn get_subtitle_url( + &self, + _item_id: &str, + _media_source_id: &str, + _stream_index: i32, + _format: &str, +) -> String

Get subtitle URL (synchronous - just constructs URL) +Called by frontend via Tauri invoke (getSubtitleUrl in VideoPlayer.svelte)
Source§

fn get_video_download_url( + &self, + _item_id: &str, + _quality: &str, + _media_source_id: Option<&str>, + _source_audio_codec: Option<&str>, +) -> String

Build the URL a video download is fetched from. Synchronous — it only +constructs a URL, so it stays testable without a server. Reach it through +resolve_video_download_url rather than calling it directly. Read more
Source§

fn mark_favorite<'life0, 'life1, 'async_trait>( + &'life0 self, + _item_id: &'life1 str, +) -> Pin<Box<dyn Future<Output = Result<(), RepoError>> + Send + 'async_trait>>
where + Self: 'async_trait, + 'life0: 'async_trait, + 'life1: 'async_trait,

Mark item as favorite
Source§

fn unmark_favorite<'life0, 'life1, 'async_trait>( + &'life0 self, + _item_id: &'life1 str, +) -> Pin<Box<dyn Future<Output = Result<(), RepoError>> + Send + 'async_trait>>
where + Self: 'async_trait, + 'life0: 'async_trait, + 'life1: 'async_trait,

Unmark item as favorite
Source§

fn clear_watch_history<'life0, 'life1, 'async_trait>( + &'life0 self, + _item_id: &'life1 str, +) -> Pin<Box<dyn Future<Output = Result<(), RepoError>> + Send + 'async_trait>>
where + Self: 'async_trait, + 'life0: 'async_trait, + 'life1: 'async_trait,

Erase the viewer’s watch history for an item: clear its played flag and +its resume position. On a container (series, season) this applies to +everything inside it, so a series is returned to “never watched” and +reopens on its premiere. Read more
Source§

fn mark_played<'life0, 'life1, 'async_trait>( + &'life0 self, + _item_id: &'life1 str, +) -> Pin<Box<dyn Future<Output = Result<(), RepoError>> + Send + 'async_trait>>
where + Self: 'async_trait, + 'life0: 'async_trait, + 'life1: 'async_trait,

Mark an item played — the inverse of clear_watch_history. Needed by the +sync-queue drain, which replays mark_played rows queued while the +server was unreachable; reporting a stop at a made-up position was the +previous stand-in and does not set the played flag reliably. Read more
Source§

fn get_person<'life0, 'life1, 'async_trait>( + &'life0 self, + person_id: &'life1 str, +) -> Pin<Box<dyn Future<Output = Result<MediaItem, RepoError>> + Send + 'async_trait>>
where + Self: 'async_trait, + 'life0: 'async_trait, + 'life1: 'async_trait,

Get person details
Source§

fn get_items_by_person<'life0, 'life1, 'async_trait>( + &'life0 self, + person_id: &'life1 str, + options: Option<GetItemsOptions>, +) -> Pin<Box<dyn Future<Output = Result<SearchResult, RepoError>> + Send + 'async_trait>>
where + Self: 'async_trait, + 'life0: 'async_trait, + 'life1: 'async_trait,

Get items by person (filmography)
Source§

fn get_similar_items<'life0, 'life1, 'async_trait>( + &'life0 self, + _item_id: &'life1 str, + _limit: Option<usize>, +) -> Pin<Box<dyn Future<Output = Result<SearchResult, RepoError>> + Send + 'async_trait>>
where + Self: 'async_trait, + 'life0: 'async_trait, + 'life1: 'async_trait,

Get similar/related items for a movie or show Read more
Source§

fn create_playlist<'life0, 'life1, 'life2, 'async_trait>( + &'life0 self, + name: &'life1 str, + item_ids: &'life2 [String], +) -> Pin<Box<dyn Future<Output = Result<PlaylistCreatedResult, RepoError>> + Send + 'async_trait>>
where + Self: 'async_trait, + 'life0: 'async_trait, + 'life1: 'async_trait, + 'life2: 'async_trait,

Create a new playlist on the server Read more
Source§

fn delete_playlist<'life0, 'life1, 'async_trait>( + &'life0 self, + playlist_id: &'life1 str, +) -> Pin<Box<dyn Future<Output = Result<(), RepoError>> + Send + 'async_trait>>
where + Self: 'async_trait, + 'life0: 'async_trait, + 'life1: 'async_trait,

Delete a playlist Read more
Source§

fn rename_playlist<'life0, 'life1, 'life2, 'async_trait>( + &'life0 self, + playlist_id: &'life1 str, + name: &'life2 str, +) -> Pin<Box<dyn Future<Output = Result<(), RepoError>> + Send + 'async_trait>>
where + Self: 'async_trait, + 'life0: 'async_trait, + 'life1: 'async_trait, + 'life2: 'async_trait,

Rename a playlist Read more
Source§

fn get_playlist_items<'life0, 'life1, 'async_trait>( + &'life0 self, + playlist_id: &'life1 str, +) -> Pin<Box<dyn Future<Output = Result<Vec<PlaylistEntry>, RepoError>> + Send + 'async_trait>>
where + Self: 'async_trait, + 'life0: 'async_trait, + 'life1: 'async_trait,

Get playlist items with PlaylistItemId (needed for remove/reorder) Read more
Source§

fn add_to_playlist<'life0, 'life1, 'life2, 'async_trait>( + &'life0 self, + playlist_id: &'life1 str, + item_ids: &'life2 [String], +) -> Pin<Box<dyn Future<Output = Result<(), RepoError>> + Send + 'async_trait>>
where + Self: 'async_trait, + 'life0: 'async_trait, + 'life1: 'async_trait, + 'life2: 'async_trait,

Add items to a playlist Read more
Source§

fn remove_from_playlist<'life0, 'life1, 'life2, 'async_trait>( + &'life0 self, + playlist_id: &'life1 str, + entry_ids: &'life2 [String], +) -> Pin<Box<dyn Future<Output = Result<(), RepoError>> + Send + 'async_trait>>
where + Self: 'async_trait, + 'life0: 'async_trait, + 'life1: 'async_trait, + 'life2: 'async_trait,

Remove items from a playlist using entry IDs (PlaylistItemId, NOT media item IDs) Read more
Source§

fn move_playlist_item<'life0, 'life1, 'life2, 'async_trait>( + &'life0 self, + playlist_id: &'life1 str, + item_id: &'life2 str, + new_index: u32, +) -> Pin<Box<dyn Future<Output = Result<(), RepoError>> + Send + 'async_trait>>
where + Self: 'async_trait, + 'life0: 'async_trait, + 'life1: 'async_trait, + 'life2: 'async_trait,

Move a playlist item to a new position Read more

Auto Trait Implementations§

Blanket Implementations§

Source§

impl<T> Any for T
where + T: 'static + ?Sized,

Source§

fn type_id(&self) -> TypeId

Gets the TypeId of self. Read more
Source§

impl<T> Borrow<T> for T
where + T: ?Sized,

Source§

fn borrow(&self) -> &T

Immutably borrows from an owned value. Read more
Source§

impl<T> BorrowMut<T> for T
where + T: ?Sized,

Source§

fn borrow_mut(&mut self) -> &mut T

Mutably borrows from an owned value. Read more
Source§

impl<T> From<T> for T

Source§

fn from(t: T) -> T

Returns the argument unchanged.

+
§

impl<T> Instrument for T

§

fn instrument(self, span: Span) -> Instrumented<Self>

Instruments this type with the provided [Span], returning an +Instrumented wrapper. Read more
§

fn in_current_span(self) -> Instrumented<Self>

Instruments this type with the current Span, returning an +Instrumented wrapper. Read more
Source§

impl<T, U> Into<U> for T
where + U: From<T>,

Source§

fn into(self) -> U

Calls U::from(self).

+

That is, this conversion is whatever the implementation of +From<T> for U chooses to do.

+
§

impl<T> PolicyExt for T
where + T: ?Sized,

§

fn and<P, B, E>(self, other: P) -> And<T, P>
where + T: Policy<B, E>, + P: Policy<B, E>,

Create a new Policy that returns [Action::Follow] only if self and other return +Action::Follow. Read more
§

fn or<P, B, E>(self, other: P) -> Or<T, P>
where + T: Policy<B, E>, + P: Policy<B, E>,

Create a new Policy that returns [Action::Follow] if either self or other returns +Action::Follow. Read more
Source§

impl<T> Same for T

Source§

type Output = T

Should always be Self
Source§

impl<T, U> TryFrom<U> for T
where + U: Into<T>,

Source§

type Error = Infallible

The type returned in the event of a conversion error.
Source§

fn try_from(value: U) -> Result<T, <T as TryFrom<U>>::Error>

Performs the conversion.
Source§

impl<T, U> TryInto<U> for T
where + U: TryFrom<T>,

Source§

type Error = <U as TryFrom<T>>::Error

The type returned in the event of a conversion error.
Source§

fn try_into(self) -> Result<U, <U as TryFrom<T>>::Error>

Performs the conversion.
§

impl<V, T> VZip<V> for T
where + V: MultiLane<T>,

§

fn vzip(self) -> V

§

impl<T> WithSubscriber for T

§

fn with_subscriber<S>(self, subscriber: S) -> WithDispatch<Self>
where + S: Into<Dispatch>,

Attaches the provided Subscriber to this type, returning a +[WithDispatch] wrapper. Read more
§

fn with_current_subscriber(self) -> WithDispatch<Self>

Attaches the current default Subscriber to this type, returning a +[WithDispatch] wrapper. Read more
\ No newline at end of file diff --git a/api/jellytau_lib/repository/online/constant.DEVICE_ID.html b/api/jellytau_lib/repository/online/constant.DEVICE_ID.html new file mode 100644 index 00000000..d8c61cab --- /dev/null +++ b/api/jellytau_lib/repository/online/constant.DEVICE_ID.html @@ -0,0 +1,4 @@ +DEVICE_ID in jellytau_lib::repository::online - RustSkip to main content

DEVICE_ID

Constant DEVICE_ID 

Source
const DEVICE_ID: &str = "jellytau-tauri";
Expand description

Every request this app makes identifies the same device, so the device id +alone cannot tell two transcodes of the same item apart — see +begin_video_play_session.

+
\ No newline at end of file diff --git a/api/jellytau_lib/repository/online/enum.ImageTags.html b/api/jellytau_lib/repository/online/enum.ImageTags.html new file mode 100644 index 00000000..3cab31f9 --- /dev/null +++ b/api/jellytau_lib/repository/online/enum.ImageTags.html @@ -0,0 +1,38 @@ +ImageTags in jellytau_lib::repository::online - RustSkip to main content

ImageTags

Enum ImageTags 

Source
enum ImageTags {
+    Map(HashMap<String, String>),
+    Structured {
+        primary: Option<String>,
+    },
+}

Variants§

§

Map(HashMap<String, String>)

§

Structured

Fields

§primary: Option<String>

Implementations§

Trait Implementations§

Source§

impl Debug for ImageTags

Source§

fn fmt(&self, f: &mut Formatter<'_>) -> Result

Formats the value using the given formatter. Read more
Source§

impl<'de> Deserialize<'de> for ImageTags

Source§

fn deserialize<__D>(__deserializer: __D) -> Result<Self, __D::Error>
where + __D: Deserializer<'de>,

Deserialize this value from the given Serde deserializer. Read more

Auto Trait Implementations§

Blanket Implementations§

Source§

impl<T> Any for T
where + T: 'static + ?Sized,

Source§

fn type_id(&self) -> TypeId

Gets the TypeId of self. Read more
Source§

impl<T> Borrow<T> for T
where + T: ?Sized,

Source§

fn borrow(&self) -> &T

Immutably borrows from an owned value. Read more
Source§

impl<T> BorrowMut<T> for T
where + T: ?Sized,

Source§

fn borrow_mut(&mut self) -> &mut T

Mutably borrows from an owned value. Read more
§

impl<'de, D, R> CommandArg<'de, R> for D
where + D: Deserialize<'de>, + R: Runtime,

§

fn from_command(command: CommandItem<'de, R>) -> Result<D, InvokeError>

Derives an instance of Self from the [CommandItem]. Read more
Source§

impl<T> From<T> for T

Source§

fn from(t: T) -> T

Returns the argument unchanged.

+
§

impl<T> Instrument for T

§

fn instrument(self, span: Span) -> Instrumented<Self>

Instruments this type with the provided [Span], returning an +Instrumented wrapper. Read more
§

fn in_current_span(self) -> Instrumented<Self>

Instruments this type with the current Span, returning an +Instrumented wrapper. Read more
Source§

impl<T, U> Into<U> for T
where + U: From<T>,

Source§

fn into(self) -> U

Calls U::from(self).

+

That is, this conversion is whatever the implementation of +From<T> for U chooses to do.

+
§

impl<T> PolicyExt for T
where + T: ?Sized,

§

fn and<P, B, E>(self, other: P) -> And<T, P>
where + T: Policy<B, E>, + P: Policy<B, E>,

Create a new Policy that returns [Action::Follow] only if self and other return +Action::Follow. Read more
§

fn or<P, B, E>(self, other: P) -> Or<T, P>
where + T: Policy<B, E>, + P: Policy<B, E>,

Create a new Policy that returns [Action::Follow] if either self or other returns +Action::Follow. Read more
Source§

impl<T> Same for T

Source§

type Output = T

Should always be Self
§

impl<T> ScopeObject for T
where + T: Send + Sync + Debug + DeserializeOwned + 'static,

§

type Error = Error

The error type.
§

fn deserialize<R>( + _app: &AppHandle<R>, + raw: Value, +) -> Result<T, <T as ScopeObject>::Error>
where + R: Runtime,

Deserialize the raw scope value.
Source§

impl<T, U> TryFrom<U> for T
where + U: Into<T>,

Source§

type Error = Infallible

The type returned in the event of a conversion error.
Source§

fn try_from(value: U) -> Result<T, <T as TryFrom<U>>::Error>

Performs the conversion.
Source§

impl<T, U> TryInto<U> for T
where + U: TryFrom<T>,

Source§

type Error = <U as TryFrom<T>>::Error

The type returned in the event of a conversion error.
Source§

fn try_into(self) -> Result<U, <U as TryFrom<T>>::Error>

Performs the conversion.
§

impl<V, T> VZip<V> for T
where + V: MultiLane<T>,

§

fn vzip(self) -> V

§

impl<T> WithSubscriber for T

§

fn with_subscriber<S>(self, subscriber: S) -> WithDispatch<Self>
where + S: Into<Dispatch>,

Attaches the provided Subscriber to this type, returning a +[WithDispatch] wrapper. Read more
§

fn with_current_subscriber(self) -> WithDispatch<Self>

Attaches the current default Subscriber to this type, returning a +[WithDispatch] wrapper. Read more
Source§

impl<T> DeserializeOwned for T
where + T: for<'de> Deserialize<'de>,

\ No newline at end of file diff --git a/api/jellytau_lib/repository/online/fn.adopt_video_play_session.html b/api/jellytau_lib/repository/online/fn.adopt_video_play_session.html new file mode 100644 index 00000000..41c5fe9e --- /dev/null +++ b/api/jellytau_lib/repository/online/fn.adopt_video_play_session.html @@ -0,0 +1,8 @@ +adopt_video_play_session in jellytau_lib::repository::online - RustSkip to main content

adopt_video_play_session

Function adopt_video_play_session 

Source
pub fn adopt_video_play_session(session_id: String) -> Option<String>
Expand description

Take ownership of a transcode this process did not build a URL for, returning +the session it replaces.

+

When PlaybackInfo answers with a TranscodingUrl the server has already +started the job and named the session; that id is the only handle on it we +will ever have. Without adopting it, the first re-open of that stream has no +previous session to stop and collides with the very job that was playing.

+

TRACES: UR-074 | DR-177 | UT-173

+
\ No newline at end of file diff --git a/api/jellytau_lib/repository/online/fn.begin_video_play_session.html b/api/jellytau_lib/repository/online/fn.begin_video_play_session.html new file mode 100644 index 00000000..a0813b7f --- /dev/null +++ b/api/jellytau_lib/repository/online/fn.begin_video_play_session.html @@ -0,0 +1,11 @@ +begin_video_play_session in jellytau_lib::repository::online - RustSkip to main content

begin_video_play_session

Function begin_video_play_session 

Source
pub fn begin_video_play_session() -> (String, Option<String>)
Expand description

Claim a transcode identity for a stream about to be opened, returning the new +PlaySessionId and the one it replaces (if any).

+

Jellyfin keys a transcode job by device and play session. Without a session +id every open of the same item on this device looked like the same job, so +re-opening a stream — a quality switch, a transcoded seek, an audio-track +switch — left the old ffmpeg running and the server intermittently rejected +segment requests for the new one (400 on hls1/main/0.ts) while the two +fought over one transcode path. The caller stops the returned previous +session before the new stream’s segments are fetched.

+

TRACES: UR-074 | DR-177 | UT-173

+
\ No newline at end of file diff --git a/api/jellytau_lib/repository/online/fn.build_favorites_endpoint.html b/api/jellytau_lib/repository/online/fn.build_favorites_endpoint.html new file mode 100644 index 00000000..f7be7925 --- /dev/null +++ b/api/jellytau_lib/repository/online/fn.build_favorites_endpoint.html @@ -0,0 +1,12 @@ +build_favorites_endpoint in jellytau_lib::repository::online - RustSkip to main content

build_favorites_endpoint

Function build_favorites_endpoint 

Source
fn build_favorites_endpoint(
+    user_id: &str,
+    scope: SearchScope,
+    options: Option<&GetItemsOptions>,
+) -> String
Expand description

Build the Jellyfin endpoint for a favourites listing.

+

Pulled out of get_favorites so the query can be asserted without an HTTP +server. scope is expanded here — SearchScope::All yields None, and the +IncludeItemTypes filter is then omitted entirely rather than sent as a +union, which would silently drop every type nobody enumerated (see +SearchScope::item_types).

+

TRACES: UR-067 | DR-115, JA-033 | UT-100

+
\ No newline at end of file diff --git a/api/jellytau_lib/repository/online/fn.build_get_items_endpoint.html b/api/jellytau_lib/repository/online/fn.build_get_items_endpoint.html new file mode 100644 index 00000000..891c85c0 --- /dev/null +++ b/api/jellytau_lib/repository/online/fn.build_get_items_endpoint.html @@ -0,0 +1,9 @@ +build_get_items_endpoint in jellytau_lib::repository::online - RustSkip to main content

build_get_items_endpoint

Function build_get_items_endpoint 

Source
fn build_get_items_endpoint(
+    user_id: &str,
+    parent_id: &str,
+    options: Option<&GetItemsOptions>,
+) -> String
Expand description

Build the Jellyfin endpoint for a folder listing.

+

Extracted from get_items so the query it produces — in particular the +favourites filter — can be asserted without standing up an HTTP server.

+

TRACES: UR-007, UR-067 | DR-116 | UT-104

+
\ No newline at end of file diff --git a/api/jellytau_lib/repository/online/fn.build_latest_items_endpoint.html b/api/jellytau_lib/repository/online/fn.build_latest_items_endpoint.html new file mode 100644 index 00000000..a402175b --- /dev/null +++ b/api/jellytau_lib/repository/online/fn.build_latest_items_endpoint.html @@ -0,0 +1,15 @@ +build_latest_items_endpoint in jellytau_lib::repository::online - RustSkip to main content

build_latest_items_endpoint

Function build_latest_items_endpoint 

Source
fn build_latest_items_endpoint(
+    user_id: &str,
+    parent_id: &str,
+    limit: Option<usize>,
+) -> String
Expand description

Build the Jellyfin endpoint for a “recently added” listing.

+

GroupItems=true is the load-bearing parameter: Jellyfin defaults it to +false, which returns each newly-added leaf separately, so importing one +14-track album pushed 14 rows into “recently added” and buried everything +else. With grouping on, the server collapses children into the container +that was added — an album appears once, while movies (which have no such +container) are unaffected.

+

Pulled out of get_latest_items so the query can be asserted without an +HTTP server, matching build_favorites_endpoint.

+

TRACES: UR-024, UR-034 | IR-024, JA-016

+
\ No newline at end of file diff --git a/api/jellytau_lib/repository/online/fn.build_next_up_endpoint.html b/api/jellytau_lib/repository/online/fn.build_next_up_endpoint.html new file mode 100644 index 00000000..b1577548 --- /dev/null +++ b/api/jellytau_lib/repository/online/fn.build_next_up_endpoint.html @@ -0,0 +1,15 @@ +build_next_up_endpoint in jellytau_lib::repository::online - RustSkip to main content

build_next_up_endpoint

Function build_next_up_endpoint 

Source
fn build_next_up_endpoint(
+    user_id: &str,
+    series_id: Option<&str>,
+    limit: Option<usize>,
+) -> String
Expand description

Build the Jellyfin endpoint for a Next Up listing.

+

EnableResumable=false is the point of this query: the server default is +true, which makes a partially-watched episode its own series’ “next up” — +the very episode /Items/Resume returns — so Continue Watching and Next Up +end up showing the same cards. Next Up should only ever offer episodes the +viewer has not started. Servers predating the parameter ignore it, which is +why the frontend also drops in-progress entries (DR-197).

+

Pulled out of get_next_up_episodes so the query can be asserted without an +HTTP server, matching build_favorites_endpoint.

+

TRACES: UR-023, UR-059 | DR-197, JA-014, JA-036 | UT-190, UT-191

+
\ No newline at end of file diff --git a/api/jellytau_lib/repository/online/fn.set_streaming_quality.html b/api/jellytau_lib/repository/online/fn.set_streaming_quality.html new file mode 100644 index 00000000..ad80060b --- /dev/null +++ b/api/jellytau_lib/repository/online/fn.set_streaming_quality.html @@ -0,0 +1,6 @@ +set_streaming_quality in jellytau_lib::repository::online - RustSkip to main content

set_streaming_quality

Function set_streaming_quality 

Source
pub fn set_streaming_quality(quality: StreamingQuality)
Expand description

Apply a bandwidth ceiling to every subsequently-opened video stream.

+

Streams already playing keep the bitrate they were opened at — a cap is a +property of the URL the server is transcoding for, so changing it mid-stream +requires re-opening at the new quality (player_set_stream_quality).

+

TRACES: UR-074 | DR-162

+
\ No newline at end of file diff --git a/api/jellytau_lib/repository/online/fn.streaming_quality.html b/api/jellytau_lib/repository/online/fn.streaming_quality.html new file mode 100644 index 00000000..5d1d04d8 --- /dev/null +++ b/api/jellytau_lib/repository/online/fn.streaming_quality.html @@ -0,0 +1,3 @@ +streaming_quality in jellytau_lib::repository::online - RustSkip to main content

streaming_quality

Function streaming_quality 

Source
pub fn streaming_quality() -> StreamingQuality
Expand description

The ceiling currently applied to new video streams.

+

TRACES: UR-074 | DR-162

+
\ No newline at end of file diff --git a/api/jellytau_lib/repository/online/index.html b/api/jellytau_lib/repository/online/index.html new file mode 100644 index 00000000..c55ad4a7 --- /dev/null +++ b/api/jellytau_lib/repository/online/index.html @@ -0,0 +1,8 @@ +jellytau_lib::repository::online - RustSkip to main content

Module online

Module online 

Source
Expand description

TRACES: UR-002, UR-007 | DR-013 | IR-010

+

Structs§

CreatePlaylistResponse 🔒
Jellyfin playlist creation response
ItemsResponse 🔒
JRayActor
A single actor returned by the JRay plugin’s “context at time t” endpoint.
JRayContext 🔒
Envelope returned by the JRay jray?t= endpoint. Extra keys (future +locations, trivia, …) are ignored so the client tolerates schema growth.
JellyfinItem 🔒
JellyfinMediaSource 🔒
JellyfinMediaStream 🔒
JellyfinPlaylistItem 🔒
A playlist item from Jellyfin — wraps a regular item with an entry-scoped ID
JellyfinUserData 🔒
Per-user state Jellyfin attaches to an item (favourite, played, resume).
OnlineRepository
Online repository - fetches data from Jellyfin server via HTTP
PlaylistItemsResponse 🔒
Jellyfin playlist items response — items include PlaylistItemId

Enums§

ImageTags 🔒

Constants§

DEVICE_ID 🔒
Every request this app makes identifies the same device, so the device id +alone cannot tell two transcodes of the same item apart — see +begin_video_play_session.

Statics§

STREAMING_QUALITY 🔒
The bandwidth ceiling every video stream this process opens is built against.
VIDEO_PLAY_SESSION 🔒
The PlaySessionId of the video transcode most recently opened, so the next +open can stop it.

Functions§

adopt_video_play_session
Take ownership of a transcode this process did not build a URL for, returning +the session it replaces.
begin_video_play_session
Claim a transcode identity for a stream about to be opened, returning the new +PlaySessionId and the one it replaces (if any).
build_favorites_endpoint 🔒
Build the Jellyfin endpoint for a favourites listing.
build_get_items_endpoint 🔒
Build the Jellyfin endpoint for a folder listing.
build_latest_items_endpoint 🔒
Build the Jellyfin endpoint for a “recently added” listing.
build_next_up_endpoint 🔒
Build the Jellyfin endpoint for a Next Up listing.
set_streaming_quality
Apply a bandwidth ceiling to every subsequently-opened video stream.
streaming_quality
The ceiling currently applied to new video streams.
\ No newline at end of file diff --git a/api/jellytau_lib/repository/online/sidebar-items.js b/api/jellytau_lib/repository/online/sidebar-items.js new file mode 100644 index 00000000..391da6b9 --- /dev/null +++ b/api/jellytau_lib/repository/online/sidebar-items.js @@ -0,0 +1 @@ +window.SIDEBAR_ITEMS = {"constant":["DEVICE_ID"],"enum":["ImageTags"],"fn":["adopt_video_play_session","begin_video_play_session","build_favorites_endpoint","build_get_items_endpoint","build_latest_items_endpoint","build_next_up_endpoint","set_streaming_quality","streaming_quality"],"static":["STREAMING_QUALITY","VIDEO_PLAY_SESSION"],"struct":["CreatePlaylistResponse","ItemsResponse","JRayActor","JRayContext","JellyfinItem","JellyfinMediaSource","JellyfinMediaStream","JellyfinPlaylistItem","JellyfinUserData","OnlineRepository","PlaylistItemsResponse"]}; \ No newline at end of file diff --git a/api/jellytau_lib/repository/online/static.STREAMING_QUALITY.html b/api/jellytau_lib/repository/online/static.STREAMING_QUALITY.html new file mode 100644 index 00000000..f020ebb9 --- /dev/null +++ b/api/jellytau_lib/repository/online/static.STREAMING_QUALITY.html @@ -0,0 +1,11 @@ +STREAMING_QUALITY in jellytau_lib::repository::online - RustSkip to main content

STREAMING_QUALITY

Static STREAMING_QUALITY 

Source
static STREAMING_QUALITY: RwLock<StreamingQuality>
Expand description

The bandwidth ceiling every video stream this process opens is built against.

+

Process-wide rather than a field on OnlineRepository because it is a user +preference about this device’s connection, not about a server session: it +must survive a repository being rebuilt on re-login, and every URL builder and +the PlaybackInfo negotiation have to agree on it or the cap leaks (the +negotiation would authorise a direct play the URL builder then never gets to +constrain). Same shape as offline::INCLUDE_CATALOG_BROWSE.

+

Set from player_set_video_settings / player_set_stream_quality, and +restored from the database at startup.

+

TRACES: UR-074 | DR-162

+
\ No newline at end of file diff --git a/api/jellytau_lib/repository/online/static.VIDEO_PLAY_SESSION.html b/api/jellytau_lib/repository/online/static.VIDEO_PLAY_SESSION.html new file mode 100644 index 00000000..61784fff --- /dev/null +++ b/api/jellytau_lib/repository/online/static.VIDEO_PLAY_SESSION.html @@ -0,0 +1,7 @@ +VIDEO_PLAY_SESSION in jellytau_lib::repository::online - RustSkip to main content

VIDEO_PLAY_SESSION

Static VIDEO_PLAY_SESSION 

Source
static VIDEO_PLAY_SESSION: RwLock<Option<String>>
Expand description

The PlaySessionId of the video transcode most recently opened, so the next +open can stop it.

+

Process-wide for the same reason as STREAMING_QUALITY: it describes what +this device currently has running on the server, and must survive the +repository being rebuilt on re-login.

+

TRACES: UR-074 | DR-162

+
\ No newline at end of file diff --git a/api/jellytau_lib/repository/online/struct.CreatePlaylistResponse.html b/api/jellytau_lib/repository/online/struct.CreatePlaylistResponse.html new file mode 100644 index 00000000..47087341 --- /dev/null +++ b/api/jellytau_lib/repository/online/struct.CreatePlaylistResponse.html @@ -0,0 +1,36 @@ +CreatePlaylistResponse in jellytau_lib::repository::online - RustSkip to main content

CreatePlaylistResponse

Struct CreatePlaylistResponse 

Source
struct CreatePlaylistResponse {
+    id: String,
+}
Expand description

Jellyfin playlist creation response

+

Fields§

§id: String

Trait Implementations§

Source§

impl Debug for CreatePlaylistResponse

Source§

fn fmt(&self, f: &mut Formatter<'_>) -> Result

Formats the value using the given formatter. Read more
Source§

impl<'de> Deserialize<'de> for CreatePlaylistResponse

Source§

fn deserialize<__D>(__deserializer: __D) -> Result<Self, __D::Error>
where + __D: Deserializer<'de>,

Deserialize this value from the given Serde deserializer. Read more

Auto Trait Implementations§

Blanket Implementations§

Source§

impl<T> Any for T
where + T: 'static + ?Sized,

Source§

fn type_id(&self) -> TypeId

Gets the TypeId of self. Read more
Source§

impl<T> Borrow<T> for T
where + T: ?Sized,

Source§

fn borrow(&self) -> &T

Immutably borrows from an owned value. Read more
Source§

impl<T> BorrowMut<T> for T
where + T: ?Sized,

Source§

fn borrow_mut(&mut self) -> &mut T

Mutably borrows from an owned value. Read more
§

impl<'de, D, R> CommandArg<'de, R> for D
where + D: Deserialize<'de>, + R: Runtime,

§

fn from_command(command: CommandItem<'de, R>) -> Result<D, InvokeError>

Derives an instance of Self from the [CommandItem]. Read more
Source§

impl<T> From<T> for T

Source§

fn from(t: T) -> T

Returns the argument unchanged.

+
§

impl<T> Instrument for T

§

fn instrument(self, span: Span) -> Instrumented<Self>

Instruments this type with the provided [Span], returning an +Instrumented wrapper. Read more
§

fn in_current_span(self) -> Instrumented<Self>

Instruments this type with the current Span, returning an +Instrumented wrapper. Read more
Source§

impl<T, U> Into<U> for T
where + U: From<T>,

Source§

fn into(self) -> U

Calls U::from(self).

+

That is, this conversion is whatever the implementation of +From<T> for U chooses to do.

+
§

impl<T> PolicyExt for T
where + T: ?Sized,

§

fn and<P, B, E>(self, other: P) -> And<T, P>
where + T: Policy<B, E>, + P: Policy<B, E>,

Create a new Policy that returns [Action::Follow] only if self and other return +Action::Follow. Read more
§

fn or<P, B, E>(self, other: P) -> Or<T, P>
where + T: Policy<B, E>, + P: Policy<B, E>,

Create a new Policy that returns [Action::Follow] if either self or other returns +Action::Follow. Read more
Source§

impl<T> Same for T

Source§

type Output = T

Should always be Self
§

impl<T> ScopeObject for T
where + T: Send + Sync + Debug + DeserializeOwned + 'static,

§

type Error = Error

The error type.
§

fn deserialize<R>( + _app: &AppHandle<R>, + raw: Value, +) -> Result<T, <T as ScopeObject>::Error>
where + R: Runtime,

Deserialize the raw scope value.
Source§

impl<T, U> TryFrom<U> for T
where + U: Into<T>,

Source§

type Error = Infallible

The type returned in the event of a conversion error.
Source§

fn try_from(value: U) -> Result<T, <T as TryFrom<U>>::Error>

Performs the conversion.
Source§

impl<T, U> TryInto<U> for T
where + U: TryFrom<T>,

Source§

type Error = <U as TryFrom<T>>::Error

The type returned in the event of a conversion error.
Source§

fn try_into(self) -> Result<U, <U as TryFrom<T>>::Error>

Performs the conversion.
§

impl<V, T> VZip<V> for T
where + V: MultiLane<T>,

§

fn vzip(self) -> V

§

impl<T> WithSubscriber for T

§

fn with_subscriber<S>(self, subscriber: S) -> WithDispatch<Self>
where + S: Into<Dispatch>,

Attaches the provided Subscriber to this type, returning a +[WithDispatch] wrapper. Read more
§

fn with_current_subscriber(self) -> WithDispatch<Self>

Attaches the current default Subscriber to this type, returning a +[WithDispatch] wrapper. Read more
Source§

impl<T> DeserializeOwned for T
where + T: for<'de> Deserialize<'de>,

\ No newline at end of file diff --git a/api/jellytau_lib/repository/online/struct.ItemsResponse.html b/api/jellytau_lib/repository/online/struct.ItemsResponse.html new file mode 100644 index 00000000..ccf4ed61 --- /dev/null +++ b/api/jellytau_lib/repository/online/struct.ItemsResponse.html @@ -0,0 +1,36 @@ +ItemsResponse in jellytau_lib::repository::online - RustSkip to main content

ItemsResponse

Struct ItemsResponse 

Source
struct ItemsResponse {
+    items: Vec<JellyfinItem>,
+    total_record_count: usize,
+}

Fields§

§items: Vec<JellyfinItem>§total_record_count: usize

Trait Implementations§

Source§

impl Debug for ItemsResponse

Source§

fn fmt(&self, f: &mut Formatter<'_>) -> Result

Formats the value using the given formatter. Read more
Source§

impl<'de> Deserialize<'de> for ItemsResponse

Source§

fn deserialize<__D>(__deserializer: __D) -> Result<Self, __D::Error>
where + __D: Deserializer<'de>,

Deserialize this value from the given Serde deserializer. Read more

Auto Trait Implementations§

Blanket Implementations§

Source§

impl<T> Any for T
where + T: 'static + ?Sized,

Source§

fn type_id(&self) -> TypeId

Gets the TypeId of self. Read more
Source§

impl<T> Borrow<T> for T
where + T: ?Sized,

Source§

fn borrow(&self) -> &T

Immutably borrows from an owned value. Read more
Source§

impl<T> BorrowMut<T> for T
where + T: ?Sized,

Source§

fn borrow_mut(&mut self) -> &mut T

Mutably borrows from an owned value. Read more
§

impl<'de, D, R> CommandArg<'de, R> for D
where + D: Deserialize<'de>, + R: Runtime,

§

fn from_command(command: CommandItem<'de, R>) -> Result<D, InvokeError>

Derives an instance of Self from the [CommandItem]. Read more
Source§

impl<T> From<T> for T

Source§

fn from(t: T) -> T

Returns the argument unchanged.

+
§

impl<T> Instrument for T

§

fn instrument(self, span: Span) -> Instrumented<Self>

Instruments this type with the provided [Span], returning an +Instrumented wrapper. Read more
§

fn in_current_span(self) -> Instrumented<Self>

Instruments this type with the current Span, returning an +Instrumented wrapper. Read more
Source§

impl<T, U> Into<U> for T
where + U: From<T>,

Source§

fn into(self) -> U

Calls U::from(self).

+

That is, this conversion is whatever the implementation of +From<T> for U chooses to do.

+
§

impl<T> PolicyExt for T
where + T: ?Sized,

§

fn and<P, B, E>(self, other: P) -> And<T, P>
where + T: Policy<B, E>, + P: Policy<B, E>,

Create a new Policy that returns [Action::Follow] only if self and other return +Action::Follow. Read more
§

fn or<P, B, E>(self, other: P) -> Or<T, P>
where + T: Policy<B, E>, + P: Policy<B, E>,

Create a new Policy that returns [Action::Follow] if either self or other returns +Action::Follow. Read more
Source§

impl<T> Same for T

Source§

type Output = T

Should always be Self
§

impl<T> ScopeObject for T
where + T: Send + Sync + Debug + DeserializeOwned + 'static,

§

type Error = Error

The error type.
§

fn deserialize<R>( + _app: &AppHandle<R>, + raw: Value, +) -> Result<T, <T as ScopeObject>::Error>
where + R: Runtime,

Deserialize the raw scope value.
Source§

impl<T, U> TryFrom<U> for T
where + U: Into<T>,

Source§

type Error = Infallible

The type returned in the event of a conversion error.
Source§

fn try_from(value: U) -> Result<T, <T as TryFrom<U>>::Error>

Performs the conversion.
Source§

impl<T, U> TryInto<U> for T
where + U: TryFrom<T>,

Source§

type Error = <U as TryFrom<T>>::Error

The type returned in the event of a conversion error.
Source§

fn try_into(self) -> Result<U, <U as TryFrom<T>>::Error>

Performs the conversion.
§

impl<V, T> VZip<V> for T
where + V: MultiLane<T>,

§

fn vzip(self) -> V

§

impl<T> WithSubscriber for T

§

fn with_subscriber<S>(self, subscriber: S) -> WithDispatch<Self>
where + S: Into<Dispatch>,

Attaches the provided Subscriber to this type, returning a +[WithDispatch] wrapper. Read more
§

fn with_current_subscriber(self) -> WithDispatch<Self>

Attaches the current default Subscriber to this type, returning a +[WithDispatch] wrapper. Read more
Source§

impl<T> DeserializeOwned for T
where + T: for<'de> Deserialize<'de>,

\ No newline at end of file diff --git a/api/jellytau_lib/repository/online/struct.JRayActor.html b/api/jellytau_lib/repository/online/struct.JRayActor.html new file mode 100644 index 00000000..0339a047 --- /dev/null +++ b/api/jellytau_lib/repository/online/struct.JRayActor.html @@ -0,0 +1,58 @@ +JRayActor in jellytau_lib::repository::online - RustSkip to main content

JRayActor

Struct JRayActor 

Source
pub struct JRayActor {
+    pub name: String,
+    pub imdb_id: String,
+    pub tmdb_id: String,
+    pub jellyfin_id: String,
+}
Expand description

A single actor returned by the JRay plugin’s “context at time t” endpoint.

+

Mirrors the actors[] objects from GET /Plugins/JRay/Items/{id}/jray?t=. +jellyfin_id (a Jellyfin Person item GUID) is preferred for navigation; +the IMDb/TMDb ids are informational fallbacks. Unknown ids are "".

+

Fields§

§name: String§imdb_id: String§tmdb_id: String§jellyfin_id: String

Trait Implementations§

Source§

impl Clone for JRayActor

Source§

fn clone(&self) -> JRayActor

Returns a duplicate of the value. Read more
1.0.0 (const: unstable) · Source§

fn clone_from(&mut self, source: &Self)

Performs copy-assignment from source. Read more
Source§

impl Debug for JRayActor

Source§

fn fmt(&self, f: &mut Formatter<'_>) -> Result

Formats the value using the given formatter. Read more
Source§

impl<'de> Deserialize<'de> for JRayActor

Source§

fn deserialize<__D>(__deserializer: __D) -> Result<Self, __D::Error>
where + __D: Deserializer<'de>,

Deserialize this value from the given Serde deserializer. Read more
Source§

impl NamedType for JRayActor

Source§

fn sid() -> SpectaID

Source§

fn named_data_type( + type_map: &mut TypeCollection, + generics: &[DataType], +) -> NamedDataType

this is equivalent to [Type::inline] but returns a [NamedDataType] instead.
Source§

fn definition_named_data_type(type_map: &mut TypeCollection) -> NamedDataType

this is equivalent to [Type::definition] but returns a [NamedDataType] instead.
Source§

impl Serialize for JRayActor

Source§

fn serialize<__S>(&self, __serializer: __S) -> Result<__S::Ok, __S::Error>
where + __S: Serializer,

Serialize this value into the given Serde serializer. Read more
Source§

impl Type for JRayActor

Source§

fn inline(type_map: &mut TypeCollection, generics: Generics<'_>) -> DataType

Returns the definition of a type using the provided generics. Read more
Source§

fn reference(type_map: &mut TypeCollection, generics: &[DataType]) -> Reference

Generates a datatype corresponding to a reference to this type, +as determined by its category. Getting a reference to a type implies that +it should belong in the type map (since it has to be referenced from somewhere), +so the output of definition will be put into the type map.
Source§

impl Flatten for JRayActor

Auto Trait Implementations§

Blanket Implementations§

Source§

impl<T> Any for T
where + T: 'static + ?Sized,

Source§

fn type_id(&self) -> TypeId

Gets the TypeId of self. Read more
Source§

impl<T> Borrow<T> for T
where + T: ?Sized,

Source§

fn borrow(&self) -> &T

Immutably borrows from an owned value. Read more
Source§

impl<T> BorrowMut<T> for T
where + T: ?Sized,

Source§

fn borrow_mut(&mut self) -> &mut T

Mutably borrows from an owned value. Read more
Source§

impl<T> CloneToUninit for T
where + T: Clone,

Source§

unsafe fn clone_to_uninit(&self, dest: *mut u8)

🔬This is a nightly-only experimental API. (clone_to_uninit)
Performs copy-assignment from self to dest. Read more
§

impl<'de, D, R> CommandArg<'de, R> for D
where + D: Deserialize<'de>, + R: Runtime,

§

fn from_command(command: CommandItem<'de, R>) -> Result<D, InvokeError>

Derives an instance of Self from the [CommandItem]. Read more
Source§

impl<T> From<T> for T

Source§

fn from(t: T) -> T

Returns the argument unchanged.

+
§

impl<T> FunctionArg for T
where + T: Type,

§

fn to_datatype(type_map: &mut TypeCollection) -> Option<DataType>

Gets the type of an argument as a [DataType]. Read more
§

impl<T> Instrument for T

§

fn instrument(self, span: Span) -> Instrumented<Self>

Instruments this type with the provided [Span], returning an +Instrumented wrapper. Read more
§

fn in_current_span(self) -> Instrumented<Self>

Instruments this type with the current Span, returning an +Instrumented wrapper. Read more
Source§

impl<T, U> Into<U> for T
where + U: From<T>,

Source§

fn into(self) -> U

Calls U::from(self).

+

That is, this conversion is whatever the implementation of +From<T> for U chooses to do.

+
§

impl<T> IpcResponse for T
where + T: Serialize,

§

fn body(self) -> Result<InvokeResponseBody, Error>

Resolve the IPC response body.
§

impl<T> PolicyExt for T
where + T: ?Sized,

§

fn and<P, B, E>(self, other: P) -> And<T, P>
where + T: Policy<B, E>, + P: Policy<B, E>,

Create a new Policy that returns [Action::Follow] only if self and other return +Action::Follow. Read more
§

fn or<P, B, E>(self, other: P) -> Or<T, P>
where + T: Policy<B, E>, + P: Policy<B, E>,

Create a new Policy that returns [Action::Follow] if either self or other returns +Action::Follow. Read more
Source§

impl<T> Same for T

Source§

type Output = T

Should always be Self
§

impl<T> ScopeObject for T
where + T: Send + Sync + Debug + DeserializeOwned + 'static,

§

type Error = Error

The error type.
§

fn deserialize<R>( + _app: &AppHandle<R>, + raw: Value, +) -> Result<T, <T as ScopeObject>::Error>
where + R: Runtime,

Deserialize the raw scope value.
Source§

impl<T> Serialize for T
where + T: Serialize + ?Sized,

Source§

fn erased_serialize(&self, serializer: &mut dyn Serializer) -> Result<(), Error>

Source§

fn do_erased_serialize( + &self, + serializer: &mut dyn Serializer, +) -> Result<(), ErrorImpl>

Source§

impl<T> ToOwned for T
where + T: Clone,

Source§

type Owned = T

The resulting type after obtaining ownership.
Source§

fn to_owned(&self) -> T

Creates owned data from borrowed data, usually by cloning. Read more
Source§

fn clone_into(&self, target: &mut T)

Uses borrowed data to replace owned data, usually by cloning. Read more
Source§

impl<T, U> TryFrom<U> for T
where + U: Into<T>,

Source§

type Error = Infallible

The type returned in the event of a conversion error.
Source§

fn try_from(value: U) -> Result<T, <T as TryFrom<U>>::Error>

Performs the conversion.
Source§

impl<T, U> TryInto<U> for T
where + U: TryFrom<T>,

Source§

type Error = <U as TryFrom<T>>::Error

The type returned in the event of a conversion error.
Source§

fn try_into(self) -> Result<U, <U as TryFrom<T>>::Error>

Performs the conversion.
§

impl<V, T> VZip<V> for T
where + V: MultiLane<T>,

§

fn vzip(self) -> V

§

impl<T> WithSubscriber for T

§

fn with_subscriber<S>(self, subscriber: S) -> WithDispatch<Self>
where + S: Into<Dispatch>,

Attaches the provided Subscriber to this type, returning a +[WithDispatch] wrapper. Read more
§

fn with_current_subscriber(self) -> WithDispatch<Self>

Attaches the current default Subscriber to this type, returning a +[WithDispatch] wrapper. Read more
Source§

impl<T> DeserializeOwned for T
where + T: for<'de> Deserialize<'de>,

§

impl<T> UserEvent for T
where + T: Debug + Clone + Send + 'static,

\ No newline at end of file diff --git a/api/jellytau_lib/repository/online/struct.JRayContext.html b/api/jellytau_lib/repository/online/struct.JRayContext.html new file mode 100644 index 00000000..2234104e --- /dev/null +++ b/api/jellytau_lib/repository/online/struct.JRayContext.html @@ -0,0 +1,40 @@ +JRayContext in jellytau_lib::repository::online - RustSkip to main content

JRayContext

Struct JRayContext 

Source
struct JRayContext {
+    actors: Vec<JRayActor>,
+}
Expand description

Envelope returned by the JRay jray?t= endpoint. Extra keys (future +locations, trivia, …) are ignored so the client tolerates schema growth.

+

Fields§

§actors: Vec<JRayActor>

Trait Implementations§

Source§

impl Clone for JRayContext

Source§

fn clone(&self) -> JRayContext

Returns a duplicate of the value. Read more
1.0.0 (const: unstable) · Source§

fn clone_from(&mut self, source: &Self)

Performs copy-assignment from source. Read more
Source§

impl Debug for JRayContext

Source§

fn fmt(&self, f: &mut Formatter<'_>) -> Result

Formats the value using the given formatter. Read more
Source§

impl<'de> Deserialize<'de> for JRayContext

Source§

fn deserialize<__D>(__deserializer: __D) -> Result<Self, __D::Error>
where + __D: Deserializer<'de>,

Deserialize this value from the given Serde deserializer. Read more

Auto Trait Implementations§

Blanket Implementations§

Source§

impl<T> Any for T
where + T: 'static + ?Sized,

Source§

fn type_id(&self) -> TypeId

Gets the TypeId of self. Read more
Source§

impl<T> Borrow<T> for T
where + T: ?Sized,

Source§

fn borrow(&self) -> &T

Immutably borrows from an owned value. Read more
Source§

impl<T> BorrowMut<T> for T
where + T: ?Sized,

Source§

fn borrow_mut(&mut self) -> &mut T

Mutably borrows from an owned value. Read more
Source§

impl<T> CloneToUninit for T
where + T: Clone,

Source§

unsafe fn clone_to_uninit(&self, dest: *mut u8)

🔬This is a nightly-only experimental API. (clone_to_uninit)
Performs copy-assignment from self to dest. Read more
§

impl<'de, D, R> CommandArg<'de, R> for D
where + D: Deserialize<'de>, + R: Runtime,

§

fn from_command(command: CommandItem<'de, R>) -> Result<D, InvokeError>

Derives an instance of Self from the [CommandItem]. Read more
Source§

impl<T> From<T> for T

Source§

fn from(t: T) -> T

Returns the argument unchanged.

+
§

impl<T> Instrument for T

§

fn instrument(self, span: Span) -> Instrumented<Self>

Instruments this type with the provided [Span], returning an +Instrumented wrapper. Read more
§

fn in_current_span(self) -> Instrumented<Self>

Instruments this type with the current Span, returning an +Instrumented wrapper. Read more
Source§

impl<T, U> Into<U> for T
where + U: From<T>,

Source§

fn into(self) -> U

Calls U::from(self).

+

That is, this conversion is whatever the implementation of +From<T> for U chooses to do.

+
§

impl<T> PolicyExt for T
where + T: ?Sized,

§

fn and<P, B, E>(self, other: P) -> And<T, P>
where + T: Policy<B, E>, + P: Policy<B, E>,

Create a new Policy that returns [Action::Follow] only if self and other return +Action::Follow. Read more
§

fn or<P, B, E>(self, other: P) -> Or<T, P>
where + T: Policy<B, E>, + P: Policy<B, E>,

Create a new Policy that returns [Action::Follow] if either self or other returns +Action::Follow. Read more
Source§

impl<T> Same for T

Source§

type Output = T

Should always be Self
§

impl<T> ScopeObject for T
where + T: Send + Sync + Debug + DeserializeOwned + 'static,

§

type Error = Error

The error type.
§

fn deserialize<R>( + _app: &AppHandle<R>, + raw: Value, +) -> Result<T, <T as ScopeObject>::Error>
where + R: Runtime,

Deserialize the raw scope value.
Source§

impl<T> ToOwned for T
where + T: Clone,

Source§

type Owned = T

The resulting type after obtaining ownership.
Source§

fn to_owned(&self) -> T

Creates owned data from borrowed data, usually by cloning. Read more
Source§

fn clone_into(&self, target: &mut T)

Uses borrowed data to replace owned data, usually by cloning. Read more
Source§

impl<T, U> TryFrom<U> for T
where + U: Into<T>,

Source§

type Error = Infallible

The type returned in the event of a conversion error.
Source§

fn try_from(value: U) -> Result<T, <T as TryFrom<U>>::Error>

Performs the conversion.
Source§

impl<T, U> TryInto<U> for T
where + U: TryFrom<T>,

Source§

type Error = <U as TryFrom<T>>::Error

The type returned in the event of a conversion error.
Source§

fn try_into(self) -> Result<U, <U as TryFrom<T>>::Error>

Performs the conversion.
§

impl<V, T> VZip<V> for T
where + V: MultiLane<T>,

§

fn vzip(self) -> V

§

impl<T> WithSubscriber for T

§

fn with_subscriber<S>(self, subscriber: S) -> WithDispatch<Self>
where + S: Into<Dispatch>,

Attaches the provided Subscriber to this type, returning a +[WithDispatch] wrapper. Read more
§

fn with_current_subscriber(self) -> WithDispatch<Self>

Attaches the current default Subscriber to this type, returning a +[WithDispatch] wrapper. Read more
Source§

impl<T> DeserializeOwned for T
where + T: for<'de> Deserialize<'de>,

§

impl<T> UserEvent for T
where + T: Debug + Clone + Send + 'static,

\ No newline at end of file diff --git a/api/jellytau_lib/repository/online/struct.JellyfinItem.html b/api/jellytau_lib/repository/online/struct.JellyfinItem.html new file mode 100644 index 00000000..fa52b815 --- /dev/null +++ b/api/jellytau_lib/repository/online/struct.JellyfinItem.html @@ -0,0 +1,64 @@ +JellyfinItem in jellytau_lib::repository::online - RustSkip to main content

JellyfinItem

Struct JellyfinItem 

Source
struct JellyfinItem {
Show 30 fields + id: String, + name: String, + item_type: String, + is_folder: bool, + parent_id: Option<String>, + overview: Option<String>, + genres: Option<Vec<String>>, + production_year: Option<i32>, + premiere_date: Option<String>, + community_rating: Option<f64>, + official_rating: Option<String>, + run_time_ticks: Option<i64>, + image_tags: Option<ImageTags>, + backdrop_image_tags: Option<Vec<String>>, + parent_backdrop_image_tags: Option<Vec<String>>, + album_id: Option<String>, + album: Option<String>, + album_artist: Option<String>, + artists: Option<Vec<String>>, + artist_items: Option<Vec<ArtistItem>>, + index_number: Option<i32>, + parent_index_number: Option<i32>, + series_id: Option<String>, + series_name: Option<String>, + season_id: Option<String>, + season_name: Option<String>, + media_streams: Option<Vec<JellyfinMediaStream>>, + media_sources: Option<Vec<JellyfinMediaSource>>, + people: Option<Vec<Person>>, + user_data: Option<JellyfinUserData>, +
}

Fields§

§id: String§name: String§item_type: String§is_folder: bool§parent_id: Option<String>§overview: Option<String>§genres: Option<Vec<String>>§production_year: Option<i32>§premiere_date: Option<String>§community_rating: Option<f64>§official_rating: Option<String>§run_time_ticks: Option<i64>§image_tags: Option<ImageTags>§backdrop_image_tags: Option<Vec<String>>§parent_backdrop_image_tags: Option<Vec<String>>§album_id: Option<String>§album: Option<String>§album_artist: Option<String>§artists: Option<Vec<String>>§artist_items: Option<Vec<ArtistItem>>§index_number: Option<i32>§parent_index_number: Option<i32>§series_id: Option<String>§series_name: Option<String>§season_id: Option<String>§season_name: Option<String>§media_streams: Option<Vec<JellyfinMediaStream>>§media_sources: Option<Vec<JellyfinMediaSource>>§people: Option<Vec<Person>>§user_data: Option<JellyfinUserData>

Implementations§

Trait Implementations§

Source§

impl Debug for JellyfinItem

Source§

fn fmt(&self, f: &mut Formatter<'_>) -> Result

Formats the value using the given formatter. Read more
Source§

impl<'de> Deserialize<'de> for JellyfinItem

Source§

fn deserialize<__D>(__deserializer: __D) -> Result<Self, __D::Error>
where + __D: Deserializer<'de>,

Deserialize this value from the given Serde deserializer. Read more

Auto Trait Implementations§

Blanket Implementations§

Source§

impl<T> Any for T
where + T: 'static + ?Sized,

Source§

fn type_id(&self) -> TypeId

Gets the TypeId of self. Read more
Source§

impl<T> Borrow<T> for T
where + T: ?Sized,

Source§

fn borrow(&self) -> &T

Immutably borrows from an owned value. Read more
Source§

impl<T> BorrowMut<T> for T
where + T: ?Sized,

Source§

fn borrow_mut(&mut self) -> &mut T

Mutably borrows from an owned value. Read more
§

impl<'de, D, R> CommandArg<'de, R> for D
where + D: Deserialize<'de>, + R: Runtime,

§

fn from_command(command: CommandItem<'de, R>) -> Result<D, InvokeError>

Derives an instance of Self from the [CommandItem]. Read more
Source§

impl<T> From<T> for T

Source§

fn from(t: T) -> T

Returns the argument unchanged.

+
§

impl<T> Instrument for T

§

fn instrument(self, span: Span) -> Instrumented<Self>

Instruments this type with the provided [Span], returning an +Instrumented wrapper. Read more
§

fn in_current_span(self) -> Instrumented<Self>

Instruments this type with the current Span, returning an +Instrumented wrapper. Read more
Source§

impl<T, U> Into<U> for T
where + U: From<T>,

Source§

fn into(self) -> U

Calls U::from(self).

+

That is, this conversion is whatever the implementation of +From<T> for U chooses to do.

+
§

impl<T> PolicyExt for T
where + T: ?Sized,

§

fn and<P, B, E>(self, other: P) -> And<T, P>
where + T: Policy<B, E>, + P: Policy<B, E>,

Create a new Policy that returns [Action::Follow] only if self and other return +Action::Follow. Read more
§

fn or<P, B, E>(self, other: P) -> Or<T, P>
where + T: Policy<B, E>, + P: Policy<B, E>,

Create a new Policy that returns [Action::Follow] if either self or other returns +Action::Follow. Read more
Source§

impl<T> Same for T

Source§

type Output = T

Should always be Self
§

impl<T> ScopeObject for T
where + T: Send + Sync + Debug + DeserializeOwned + 'static,

§

type Error = Error

The error type.
§

fn deserialize<R>( + _app: &AppHandle<R>, + raw: Value, +) -> Result<T, <T as ScopeObject>::Error>
where + R: Runtime,

Deserialize the raw scope value.
Source§

impl<T, U> TryFrom<U> for T
where + U: Into<T>,

Source§

type Error = Infallible

The type returned in the event of a conversion error.
Source§

fn try_from(value: U) -> Result<T, <T as TryFrom<U>>::Error>

Performs the conversion.
Source§

impl<T, U> TryInto<U> for T
where + U: TryFrom<T>,

Source§

type Error = <U as TryFrom<T>>::Error

The type returned in the event of a conversion error.
Source§

fn try_into(self) -> Result<U, <U as TryFrom<T>>::Error>

Performs the conversion.
§

impl<V, T> VZip<V> for T
where + V: MultiLane<T>,

§

fn vzip(self) -> V

§

impl<T> WithSubscriber for T

§

fn with_subscriber<S>(self, subscriber: S) -> WithDispatch<Self>
where + S: Into<Dispatch>,

Attaches the provided Subscriber to this type, returning a +[WithDispatch] wrapper. Read more
§

fn with_current_subscriber(self) -> WithDispatch<Self>

Attaches the current default Subscriber to this type, returning a +[WithDispatch] wrapper. Read more
Source§

impl<T> DeserializeOwned for T
where + T: for<'de> Deserialize<'de>,

\ No newline at end of file diff --git a/api/jellytau_lib/repository/online/struct.JellyfinMediaSource.html b/api/jellytau_lib/repository/online/struct.JellyfinMediaSource.html new file mode 100644 index 00000000..b139ffa0 --- /dev/null +++ b/api/jellytau_lib/repository/online/struct.JellyfinMediaSource.html @@ -0,0 +1,46 @@ +JellyfinMediaSource in jellytau_lib::repository::online - RustSkip to main content

JellyfinMediaSource

Struct JellyfinMediaSource 

Source
struct JellyfinMediaSource {
+    id: String,
+    name: String,
+    container: Option<String>,
+    size: Option<i64>,
+    bitrate: Option<i32>,
+    supports_direct_play: bool,
+    supports_direct_stream: bool,
+    supports_transcoding: bool,
+    direct_stream_url: Option<String>,
+}

Fields§

§id: String§name: String§container: Option<String>§size: Option<i64>§bitrate: Option<i32>§supports_direct_play: bool§supports_direct_stream: bool§supports_transcoding: bool§direct_stream_url: Option<String>

Trait Implementations§

Source§

impl Clone for JellyfinMediaSource

Source§

fn clone(&self) -> JellyfinMediaSource

Returns a duplicate of the value. Read more
1.0.0 (const: unstable) · Source§

fn clone_from(&mut self, source: &Self)

Performs copy-assignment from source. Read more
Source§

impl Debug for JellyfinMediaSource

Source§

fn fmt(&self, f: &mut Formatter<'_>) -> Result

Formats the value using the given formatter. Read more
Source§

impl<'de> Deserialize<'de> for JellyfinMediaSource

Source§

fn deserialize<__D>(__deserializer: __D) -> Result<Self, __D::Error>
where + __D: Deserializer<'de>,

Deserialize this value from the given Serde deserializer. Read more

Auto Trait Implementations§

Blanket Implementations§

Source§

impl<T> Any for T
where + T: 'static + ?Sized,

Source§

fn type_id(&self) -> TypeId

Gets the TypeId of self. Read more
Source§

impl<T> Borrow<T> for T
where + T: ?Sized,

Source§

fn borrow(&self) -> &T

Immutably borrows from an owned value. Read more
Source§

impl<T> BorrowMut<T> for T
where + T: ?Sized,

Source§

fn borrow_mut(&mut self) -> &mut T

Mutably borrows from an owned value. Read more
Source§

impl<T> CloneToUninit for T
where + T: Clone,

Source§

unsafe fn clone_to_uninit(&self, dest: *mut u8)

🔬This is a nightly-only experimental API. (clone_to_uninit)
Performs copy-assignment from self to dest. Read more
§

impl<'de, D, R> CommandArg<'de, R> for D
where + D: Deserialize<'de>, + R: Runtime,

§

fn from_command(command: CommandItem<'de, R>) -> Result<D, InvokeError>

Derives an instance of Self from the [CommandItem]. Read more
Source§

impl<T> From<T> for T

Source§

fn from(t: T) -> T

Returns the argument unchanged.

+
§

impl<T> Instrument for T

§

fn instrument(self, span: Span) -> Instrumented<Self>

Instruments this type with the provided [Span], returning an +Instrumented wrapper. Read more
§

fn in_current_span(self) -> Instrumented<Self>

Instruments this type with the current Span, returning an +Instrumented wrapper. Read more
Source§

impl<T, U> Into<U> for T
where + U: From<T>,

Source§

fn into(self) -> U

Calls U::from(self).

+

That is, this conversion is whatever the implementation of +From<T> for U chooses to do.

+
§

impl<T> PolicyExt for T
where + T: ?Sized,

§

fn and<P, B, E>(self, other: P) -> And<T, P>
where + T: Policy<B, E>, + P: Policy<B, E>,

Create a new Policy that returns [Action::Follow] only if self and other return +Action::Follow. Read more
§

fn or<P, B, E>(self, other: P) -> Or<T, P>
where + T: Policy<B, E>, + P: Policy<B, E>,

Create a new Policy that returns [Action::Follow] if either self or other returns +Action::Follow. Read more
Source§

impl<T> Same for T

Source§

type Output = T

Should always be Self
§

impl<T> ScopeObject for T
where + T: Send + Sync + Debug + DeserializeOwned + 'static,

§

type Error = Error

The error type.
§

fn deserialize<R>( + _app: &AppHandle<R>, + raw: Value, +) -> Result<T, <T as ScopeObject>::Error>
where + R: Runtime,

Deserialize the raw scope value.
Source§

impl<T> ToOwned for T
where + T: Clone,

Source§

type Owned = T

The resulting type after obtaining ownership.
Source§

fn to_owned(&self) -> T

Creates owned data from borrowed data, usually by cloning. Read more
Source§

fn clone_into(&self, target: &mut T)

Uses borrowed data to replace owned data, usually by cloning. Read more
Source§

impl<T, U> TryFrom<U> for T
where + U: Into<T>,

Source§

type Error = Infallible

The type returned in the event of a conversion error.
Source§

fn try_from(value: U) -> Result<T, <T as TryFrom<U>>::Error>

Performs the conversion.
Source§

impl<T, U> TryInto<U> for T
where + U: TryFrom<T>,

Source§

type Error = <U as TryFrom<T>>::Error

The type returned in the event of a conversion error.
Source§

fn try_into(self) -> Result<U, <U as TryFrom<T>>::Error>

Performs the conversion.
§

impl<V, T> VZip<V> for T
where + V: MultiLane<T>,

§

fn vzip(self) -> V

§

impl<T> WithSubscriber for T

§

fn with_subscriber<S>(self, subscriber: S) -> WithDispatch<Self>
where + S: Into<Dispatch>,

Attaches the provided Subscriber to this type, returning a +[WithDispatch] wrapper. Read more
§

fn with_current_subscriber(self) -> WithDispatch<Self>

Attaches the current default Subscriber to this type, returning a +[WithDispatch] wrapper. Read more
Source§

impl<T> DeserializeOwned for T
where + T: for<'de> Deserialize<'de>,

§

impl<T> UserEvent for T
where + T: Debug + Clone + Send + 'static,

\ No newline at end of file diff --git a/api/jellytau_lib/repository/online/struct.JellyfinMediaStream.html b/api/jellytau_lib/repository/online/struct.JellyfinMediaStream.html new file mode 100644 index 00000000..87387392 --- /dev/null +++ b/api/jellytau_lib/repository/online/struct.JellyfinMediaStream.html @@ -0,0 +1,44 @@ +JellyfinMediaStream in jellytau_lib::repository::online - RustSkip to main content

JellyfinMediaStream

Struct JellyfinMediaStream 

Source
struct JellyfinMediaStream {
+    stream_type: String,
+    codec: Option<String>,
+    language: Option<String>,
+    display_title: Option<String>,
+    index: i32,
+    is_default: bool,
+    is_forced: bool,
+}

Fields§

§stream_type: String§codec: Option<String>§language: Option<String>§display_title: Option<String>§index: i32§is_default: bool§is_forced: bool

Trait Implementations§

Source§

impl Clone for JellyfinMediaStream

Source§

fn clone(&self) -> JellyfinMediaStream

Returns a duplicate of the value. Read more
1.0.0 (const: unstable) · Source§

fn clone_from(&mut self, source: &Self)

Performs copy-assignment from source. Read more
Source§

impl Debug for JellyfinMediaStream

Source§

fn fmt(&self, f: &mut Formatter<'_>) -> Result

Formats the value using the given formatter. Read more
Source§

impl<'de> Deserialize<'de> for JellyfinMediaStream

Source§

fn deserialize<__D>(__deserializer: __D) -> Result<Self, __D::Error>
where + __D: Deserializer<'de>,

Deserialize this value from the given Serde deserializer. Read more

Auto Trait Implementations§

Blanket Implementations§

Source§

impl<T> Any for T
where + T: 'static + ?Sized,

Source§

fn type_id(&self) -> TypeId

Gets the TypeId of self. Read more
Source§

impl<T> Borrow<T> for T
where + T: ?Sized,

Source§

fn borrow(&self) -> &T

Immutably borrows from an owned value. Read more
Source§

impl<T> BorrowMut<T> for T
where + T: ?Sized,

Source§

fn borrow_mut(&mut self) -> &mut T

Mutably borrows from an owned value. Read more
Source§

impl<T> CloneToUninit for T
where + T: Clone,

Source§

unsafe fn clone_to_uninit(&self, dest: *mut u8)

🔬This is a nightly-only experimental API. (clone_to_uninit)
Performs copy-assignment from self to dest. Read more
§

impl<'de, D, R> CommandArg<'de, R> for D
where + D: Deserialize<'de>, + R: Runtime,

§

fn from_command(command: CommandItem<'de, R>) -> Result<D, InvokeError>

Derives an instance of Self from the [CommandItem]. Read more
Source§

impl<T> From<T> for T

Source§

fn from(t: T) -> T

Returns the argument unchanged.

+
§

impl<T> Instrument for T

§

fn instrument(self, span: Span) -> Instrumented<Self>

Instruments this type with the provided [Span], returning an +Instrumented wrapper. Read more
§

fn in_current_span(self) -> Instrumented<Self>

Instruments this type with the current Span, returning an +Instrumented wrapper. Read more
Source§

impl<T, U> Into<U> for T
where + U: From<T>,

Source§

fn into(self) -> U

Calls U::from(self).

+

That is, this conversion is whatever the implementation of +From<T> for U chooses to do.

+
§

impl<T> PolicyExt for T
where + T: ?Sized,

§

fn and<P, B, E>(self, other: P) -> And<T, P>
where + T: Policy<B, E>, + P: Policy<B, E>,

Create a new Policy that returns [Action::Follow] only if self and other return +Action::Follow. Read more
§

fn or<P, B, E>(self, other: P) -> Or<T, P>
where + T: Policy<B, E>, + P: Policy<B, E>,

Create a new Policy that returns [Action::Follow] if either self or other returns +Action::Follow. Read more
Source§

impl<T> Same for T

Source§

type Output = T

Should always be Self
§

impl<T> ScopeObject for T
where + T: Send + Sync + Debug + DeserializeOwned + 'static,

§

type Error = Error

The error type.
§

fn deserialize<R>( + _app: &AppHandle<R>, + raw: Value, +) -> Result<T, <T as ScopeObject>::Error>
where + R: Runtime,

Deserialize the raw scope value.
Source§

impl<T> ToOwned for T
where + T: Clone,

Source§

type Owned = T

The resulting type after obtaining ownership.
Source§

fn to_owned(&self) -> T

Creates owned data from borrowed data, usually by cloning. Read more
Source§

fn clone_into(&self, target: &mut T)

Uses borrowed data to replace owned data, usually by cloning. Read more
Source§

impl<T, U> TryFrom<U> for T
where + U: Into<T>,

Source§

type Error = Infallible

The type returned in the event of a conversion error.
Source§

fn try_from(value: U) -> Result<T, <T as TryFrom<U>>::Error>

Performs the conversion.
Source§

impl<T, U> TryInto<U> for T
where + U: TryFrom<T>,

Source§

type Error = <U as TryFrom<T>>::Error

The type returned in the event of a conversion error.
Source§

fn try_into(self) -> Result<U, <U as TryFrom<T>>::Error>

Performs the conversion.
§

impl<V, T> VZip<V> for T
where + V: MultiLane<T>,

§

fn vzip(self) -> V

§

impl<T> WithSubscriber for T

§

fn with_subscriber<S>(self, subscriber: S) -> WithDispatch<Self>
where + S: Into<Dispatch>,

Attaches the provided Subscriber to this type, returning a +[WithDispatch] wrapper. Read more
§

fn with_current_subscriber(self) -> WithDispatch<Self>

Attaches the current default Subscriber to this type, returning a +[WithDispatch] wrapper. Read more
Source§

impl<T> DeserializeOwned for T
where + T: for<'de> Deserialize<'de>,

§

impl<T> UserEvent for T
where + T: Debug + Clone + Send + 'static,

\ No newline at end of file diff --git a/api/jellytau_lib/repository/online/struct.JellyfinPlaylistItem.html b/api/jellytau_lib/repository/online/struct.JellyfinPlaylistItem.html new file mode 100644 index 00000000..044a1e7a --- /dev/null +++ b/api/jellytau_lib/repository/online/struct.JellyfinPlaylistItem.html @@ -0,0 +1,37 @@ +JellyfinPlaylistItem in jellytau_lib::repository::online - RustSkip to main content

JellyfinPlaylistItem

Struct JellyfinPlaylistItem 

Source
struct JellyfinPlaylistItem {
+    playlist_item_id: String,
+    item: JellyfinItem,
+}
Expand description

A playlist item from Jellyfin — wraps a regular item with an entry-scoped ID

+

Fields§

§playlist_item_id: String§item: JellyfinItem

Trait Implementations§

Source§

impl Debug for JellyfinPlaylistItem

Source§

fn fmt(&self, f: &mut Formatter<'_>) -> Result

Formats the value using the given formatter. Read more
Source§

impl<'de> Deserialize<'de> for JellyfinPlaylistItem

Source§

fn deserialize<__D>(__deserializer: __D) -> Result<Self, __D::Error>
where + __D: Deserializer<'de>,

Deserialize this value from the given Serde deserializer. Read more

Auto Trait Implementations§

Blanket Implementations§

Source§

impl<T> Any for T
where + T: 'static + ?Sized,

Source§

fn type_id(&self) -> TypeId

Gets the TypeId of self. Read more
Source§

impl<T> Borrow<T> for T
where + T: ?Sized,

Source§

fn borrow(&self) -> &T

Immutably borrows from an owned value. Read more
Source§

impl<T> BorrowMut<T> for T
where + T: ?Sized,

Source§

fn borrow_mut(&mut self) -> &mut T

Mutably borrows from an owned value. Read more
§

impl<'de, D, R> CommandArg<'de, R> for D
where + D: Deserialize<'de>, + R: Runtime,

§

fn from_command(command: CommandItem<'de, R>) -> Result<D, InvokeError>

Derives an instance of Self from the [CommandItem]. Read more
Source§

impl<T> From<T> for T

Source§

fn from(t: T) -> T

Returns the argument unchanged.

+
§

impl<T> Instrument for T

§

fn instrument(self, span: Span) -> Instrumented<Self>

Instruments this type with the provided [Span], returning an +Instrumented wrapper. Read more
§

fn in_current_span(self) -> Instrumented<Self>

Instruments this type with the current Span, returning an +Instrumented wrapper. Read more
Source§

impl<T, U> Into<U> for T
where + U: From<T>,

Source§

fn into(self) -> U

Calls U::from(self).

+

That is, this conversion is whatever the implementation of +From<T> for U chooses to do.

+
§

impl<T> PolicyExt for T
where + T: ?Sized,

§

fn and<P, B, E>(self, other: P) -> And<T, P>
where + T: Policy<B, E>, + P: Policy<B, E>,

Create a new Policy that returns [Action::Follow] only if self and other return +Action::Follow. Read more
§

fn or<P, B, E>(self, other: P) -> Or<T, P>
where + T: Policy<B, E>, + P: Policy<B, E>,

Create a new Policy that returns [Action::Follow] if either self or other returns +Action::Follow. Read more
Source§

impl<T> Same for T

Source§

type Output = T

Should always be Self
§

impl<T> ScopeObject for T
where + T: Send + Sync + Debug + DeserializeOwned + 'static,

§

type Error = Error

The error type.
§

fn deserialize<R>( + _app: &AppHandle<R>, + raw: Value, +) -> Result<T, <T as ScopeObject>::Error>
where + R: Runtime,

Deserialize the raw scope value.
Source§

impl<T, U> TryFrom<U> for T
where + U: Into<T>,

Source§

type Error = Infallible

The type returned in the event of a conversion error.
Source§

fn try_from(value: U) -> Result<T, <T as TryFrom<U>>::Error>

Performs the conversion.
Source§

impl<T, U> TryInto<U> for T
where + U: TryFrom<T>,

Source§

type Error = <U as TryFrom<T>>::Error

The type returned in the event of a conversion error.
Source§

fn try_into(self) -> Result<U, <U as TryFrom<T>>::Error>

Performs the conversion.
§

impl<V, T> VZip<V> for T
where + V: MultiLane<T>,

§

fn vzip(self) -> V

§

impl<T> WithSubscriber for T

§

fn with_subscriber<S>(self, subscriber: S) -> WithDispatch<Self>
where + S: Into<Dispatch>,

Attaches the provided Subscriber to this type, returning a +[WithDispatch] wrapper. Read more
§

fn with_current_subscriber(self) -> WithDispatch<Self>

Attaches the current default Subscriber to this type, returning a +[WithDispatch] wrapper. Read more
Source§

impl<T> DeserializeOwned for T
where + T: for<'de> Deserialize<'de>,

\ No newline at end of file diff --git a/api/jellytau_lib/repository/online/struct.JellyfinUserData.html b/api/jellytau_lib/repository/online/struct.JellyfinUserData.html new file mode 100644 index 00000000..6b2635e5 --- /dev/null +++ b/api/jellytau_lib/repository/online/struct.JellyfinUserData.html @@ -0,0 +1,50 @@ +JellyfinUserData in jellytau_lib::repository::online - RustSkip to main content

JellyfinUserData

Struct JellyfinUserData 

Source
struct JellyfinUserData {
+    playback_position_ticks: Option<i64>,
+    is_played: Option<bool>,
+    is_favorite: Option<bool>,
+    play_count: Option<i32>,
+    last_played_date: Option<String>,
+}
Expand description

Per-user state Jellyfin attaches to an item (favourite, played, resume).

+

Returned on every /Users/{uid}/Items* response; we additionally name +UserData in the Fields= list so the shape is explicit rather than +dependent on the server’s default field set.

+

PlaybackPositionTicks is the server’s resume position for the item, and the +only place it is published — Jellyfin has no per-item “resume position” +endpoint, so reading UserData is how a resume point is obtained.

+

TRACES: UR-019, UR-069 | DR-113, JA-013, JA-034 | UT-099

+

Fields§

§playback_position_ticks: Option<i64>§is_played: Option<bool>§is_favorite: Option<bool>§play_count: Option<i32>§last_played_date: Option<String>

Trait Implementations§

Source§

impl Clone for JellyfinUserData

Source§

fn clone(&self) -> JellyfinUserData

Returns a duplicate of the value. Read more
1.0.0 (const: unstable) · Source§

fn clone_from(&mut self, source: &Self)

Performs copy-assignment from source. Read more
Source§

impl Debug for JellyfinUserData

Source§

fn fmt(&self, f: &mut Formatter<'_>) -> Result

Formats the value using the given formatter. Read more
Source§

impl<'de> Deserialize<'de> for JellyfinUserData

Source§

fn deserialize<__D>(__deserializer: __D) -> Result<Self, __D::Error>
where + __D: Deserializer<'de>,

Deserialize this value from the given Serde deserializer. Read more
Source§

impl From<JellyfinUserData> for UserData

Source§

fn from(jf: JellyfinUserData) -> Self

Converts to this type from the input type.

Auto Trait Implementations§

Blanket Implementations§

Source§

impl<T> Any for T
where + T: 'static + ?Sized,

Source§

fn type_id(&self) -> TypeId

Gets the TypeId of self. Read more
Source§

impl<T> Borrow<T> for T
where + T: ?Sized,

Source§

fn borrow(&self) -> &T

Immutably borrows from an owned value. Read more
Source§

impl<T> BorrowMut<T> for T
where + T: ?Sized,

Source§

fn borrow_mut(&mut self) -> &mut T

Mutably borrows from an owned value. Read more
Source§

impl<T> CloneToUninit for T
where + T: Clone,

Source§

unsafe fn clone_to_uninit(&self, dest: *mut u8)

🔬This is a nightly-only experimental API. (clone_to_uninit)
Performs copy-assignment from self to dest. Read more
§

impl<'de, D, R> CommandArg<'de, R> for D
where + D: Deserialize<'de>, + R: Runtime,

§

fn from_command(command: CommandItem<'de, R>) -> Result<D, InvokeError>

Derives an instance of Self from the [CommandItem]. Read more
Source§

impl<T> From<T> for T

Source§

fn from(t: T) -> T

Returns the argument unchanged.

+
§

impl<T> Instrument for T

§

fn instrument(self, span: Span) -> Instrumented<Self>

Instruments this type with the provided [Span], returning an +Instrumented wrapper. Read more
§

fn in_current_span(self) -> Instrumented<Self>

Instruments this type with the current Span, returning an +Instrumented wrapper. Read more
Source§

impl<T, U> Into<U> for T
where + U: From<T>,

Source§

fn into(self) -> U

Calls U::from(self).

+

That is, this conversion is whatever the implementation of +From<T> for U chooses to do.

+
§

impl<T> PolicyExt for T
where + T: ?Sized,

§

fn and<P, B, E>(self, other: P) -> And<T, P>
where + T: Policy<B, E>, + P: Policy<B, E>,

Create a new Policy that returns [Action::Follow] only if self and other return +Action::Follow. Read more
§

fn or<P, B, E>(self, other: P) -> Or<T, P>
where + T: Policy<B, E>, + P: Policy<B, E>,

Create a new Policy that returns [Action::Follow] if either self or other returns +Action::Follow. Read more
Source§

impl<T> Same for T

Source§

type Output = T

Should always be Self
§

impl<T> ScopeObject for T
where + T: Send + Sync + Debug + DeserializeOwned + 'static,

§

type Error = Error

The error type.
§

fn deserialize<R>( + _app: &AppHandle<R>, + raw: Value, +) -> Result<T, <T as ScopeObject>::Error>
where + R: Runtime,

Deserialize the raw scope value.
Source§

impl<T> ToOwned for T
where + T: Clone,

Source§

type Owned = T

The resulting type after obtaining ownership.
Source§

fn to_owned(&self) -> T

Creates owned data from borrowed data, usually by cloning. Read more
Source§

fn clone_into(&self, target: &mut T)

Uses borrowed data to replace owned data, usually by cloning. Read more
Source§

impl<T, U> TryFrom<U> for T
where + U: Into<T>,

Source§

type Error = Infallible

The type returned in the event of a conversion error.
Source§

fn try_from(value: U) -> Result<T, <T as TryFrom<U>>::Error>

Performs the conversion.
Source§

impl<T, U> TryInto<U> for T
where + U: TryFrom<T>,

Source§

type Error = <U as TryFrom<T>>::Error

The type returned in the event of a conversion error.
Source§

fn try_into(self) -> Result<U, <U as TryFrom<T>>::Error>

Performs the conversion.
§

impl<V, T> VZip<V> for T
where + V: MultiLane<T>,

§

fn vzip(self) -> V

§

impl<T> WithSubscriber for T

§

fn with_subscriber<S>(self, subscriber: S) -> WithDispatch<Self>
where + S: Into<Dispatch>,

Attaches the provided Subscriber to this type, returning a +[WithDispatch] wrapper. Read more
§

fn with_current_subscriber(self) -> WithDispatch<Self>

Attaches the current default Subscriber to this type, returning a +[WithDispatch] wrapper. Read more
Source§

impl<T> DeserializeOwned for T
where + T: for<'de> Deserialize<'de>,

§

impl<T> UserEvent for T
where + T: Debug + Clone + Send + 'static,

\ No newline at end of file diff --git a/api/jellytau_lib/repository/online/struct.OnlineRepository.html b/api/jellytau_lib/repository/online/struct.OnlineRepository.html new file mode 100644 index 00000000..ec48767b --- /dev/null +++ b/api/jellytau_lib/repository/online/struct.OnlineRepository.html @@ -0,0 +1,444 @@ +OnlineRepository in jellytau_lib::repository::online - RustSkip to main content

OnlineRepository

Struct OnlineRepository 

Source
pub struct OnlineRepository {
+    http_client: Arc<HttpClient>,
+    server_url: String,
+    user_id: String,
+    access_token: String,
+    connectivity: Option<ConnectivityReporter>,
+}
Expand description

Online repository - fetches data from Jellyfin server via HTTP

+

Fields§

§http_client: Arc<HttpClient>§server_url: String§user_id: String§access_token: String§connectivity: Option<ConnectivityReporter>

Reports the outcome of every server request to the connectivity monitor. +This is the source of truth for the offline/online banner. None in +tests / contexts where connectivity tracking isn’t wired up.

+

Implementations§

Source§

impl OnlineRepository

Source

pub fn user_id(&self) -> &str

The signed-in user these requests are made as. Needed by the favourites +drain, which reads this user’s queued rows. TRACES: UR-069 | DR-120

+
Source

pub fn new( + http_client: Arc<HttpClient>, + server_url: String, + user_id: String, + access_token: String, +) -> Self

Source

pub fn with_connectivity(self, reporter: ConnectivityReporter) -> Self

Attach a connectivity reporter so server outcomes drive the reachability +state observed by the UI. See report_outcome.

+
Source

async fn report_outcome<T>(&self, result: &Result<T, RepoError>)

Feed a request outcome into the connectivity monitor.

+

Classification (matches docs/architecture/07-connectivity.md):

+
    +
  • Ok / Authentication / NotFound / Server → the server answered, +so it is reachable → report_success (instant recovery).
  • +
  • Network → connection-level failure → report_network_failure +(subject to the time-window debounce before going offline).
  • +
  • Database → not a server signal → ignored.
  • +
+
Source

fn auth_header(&self) -> String

Build authorization header

+
Source

pub async fn download_bytes(&self, url: &str) -> Result<Vec<u8>, String>

Download raw bytes from a URL using the shared authenticated HTTP client. +Used by thumbnail cache to download images with proper auth and connection reuse.

+
Source

pub async fn get_jray_actors( + &self, + item_id: &str, + t: f64, +) -> Result<Vec<JRayActor>, RepoError>

Query the JRay plugin for the actors on screen at time t (seconds) in +the given item. Returns an empty list when the plugin isn’t installed or +has no truth data for the item (HTTP 404), so callers can treat “no JRay” +and “nobody on screen” identically. Other failures propagate.

+
Source

async fn get_json<T: for<'de> Deserialize<'de>>( + &self, + endpoint: &str, +) -> Result<T, RepoError>

Make authenticated GET request

+
Source

async fn get_json_inner<T: for<'de> Deserialize<'de>>( + &self, + endpoint: &str, +) -> Result<T, RepoError>

Source

async fn post_json<T: Serialize>( + &self, + endpoint: &str, + body: &T, +) -> Result<(), RepoError>

Make authenticated POST request

+
Source

async fn post_json_inner<T: Serialize>( + &self, + endpoint: &str, + body: &T, +) -> Result<(), RepoError>

Source

async fn post_json_response<T: Serialize, R: for<'de> Deserialize<'de>>( + &self, + endpoint: &str, + body: &T, +) -> Result<R, RepoError>

Make authenticated POST request and return response

+
Source

async fn post_json_response_inner<T: Serialize, R: for<'de> Deserialize<'de>>( + &self, + endpoint: &str, + body: &T, +) -> Result<R, RepoError>

Source

async fn stop_transcode(&self, play_session_id: &str)

Ask the server to tear down a transcode this device started.

+

Best-effort and deliberately un-retried: it runs on the path that opens a +replacement stream, so a slow or failed stop must not delay playback. The +worst case if it does fail is the job Jellyfin would have reaped on its +own idle timer anyway — the new stream still has its own session id, so it +no longer collides with the old one.

+

TRACES: UR-074 | DR-177

+
Source

pub async fn get_video_stream_url( + &self, + item_id: &str, + media_source_id: Option<&str>, + audio_stream_index: Option<i32>, +) -> Result<String, RepoError>

Get a video stream URL (initial play, resume, transcoded seeking, +audio-track switching).

+

Returns an HLS master playlist (/Videos/{id}/master.m3u8) transcoded to +h264/aac. HLS is used rather than a progressive stream.mp4 because the +HTML5 <video> element (via HLS.js) starts playing within seconds and can +seek within the stream, whereas a progressive MP4 transcode of HEVC source +forces the server to transcode the whole file before playback can begin — +which manifests as playback never starting.

+

There is deliberately no start-position parameter. A playlist covers +the whole item and asking for segment N is the seek, so a position would +be redundant — and actively fatal: Jellyfin builds every segment URI by +echoing this playlist’s query string into it, while its segment handler +rejects StartTimeTicks > 0 outright (ArgumentException400). One +resume position here therefore 400s every segment of the stream, which +presents as a resumed episode that simply never plays while the same +episode from the beginning is fine. Resume by seeking the player once it +has loaded. (The progressive /Audio/universal builder below has no +segments and keeps its StartTimeTicks.)

+

The stream is built against the current streaming_quality ceiling: +MaxStreamingBitrate/VideoBitrate/AudioBitrate, plus a MaxHeight +that suits the budget. Original keeps the historical 20/18 Mbps +allowance, which is a transcode ceiling rather than a user-facing limit.

+

TRACES: UR-004, UR-074 | DR-140, DR-162, DR-177, DR-181 | UT-130, UT-156, UT-173, UT-182

+
Source

pub async fn build_audio_only_stream_url_for_video( + &self, + item_id: &str, + media_source_id: Option<&str>, + start_time_seconds: Option<f64>, + audio_stream_index: Option<i32>, +) -> Result<String, RepoError>

Get an audio-only stream URL for a video item, for the +background-audio handoff (UR-040).

+

TRACES: UR-040 | JA-032, DR-140 | UT-059, UT-130

+

This deliberately targets /Audio/{id}/universal, NOT the video stream: +the server extracts/transcodes only the item’s audio track and streams +pure audio bytes — no video frames reach the device, so there is no client +video decode while backgrounded. Do NOT “optimize” this to reuse the +/Videos/.../master.m3u8 URL: that would keep the device decoding video, +defeating the entire point of the feature.

+

AudioStreamIndex carries the user’s currently-selected audio track over +from the video player; StartTimeTicks resumes at the handoff position. +universal lets the server pick direct-play vs transcode per codec/device.

+

The stream is a progressive container (mp3 over plain HTTP), NOT HLS: +ExoPlayer plays this natively, whereas an HLS/ts transcode on the +/universal endpoint (no .m3u8 in the path) fails its progressive +loader with ERROR_CODE_PARSING_CONTAINER_UNSUPPORTED. mp3 is universally +decodable and supports mid-stream StartTimeTicks.

+

Trait Implementations§

Source§

impl MediaRepository for OnlineRepository

Source§

fn get_item<'life0, 'life1, 'async_trait>( + &'life0 self, + item_id: &'life1 str, +) -> Pin<Box<dyn Future<Output = Result<MediaItem, RepoError>> + Send + 'async_trait>>
where + Self: 'async_trait, + 'life0: 'async_trait, + 'life1: 'async_trait,

Fetch one item with every field the detail and player screens need.

+

The Fields= list is the load-bearing part: Jellyfin omits these unless +they are named. MediaStreams is what makes the item’s audio and +subtitle tracks knowable at all — there is no separate “tracks” +endpoint, so this single call is how the player learns which audio tracks +an item offers (to_media_item maps them, and the player’s selector +filters them by kind). People is likewise how cast and crew are +obtained.

+

TRACES: UR-021, UR-035 | IR-016, IR-022, JA-005, JA-009

+
Source§

fn get_resume_items<'life0, 'life1, 'async_trait>( + &'life0 self, + parent_id: Option<&'life1 str>, + limit: Option<usize>, +) -> Pin<Box<dyn Future<Output = Result<Vec<MediaItem>, RepoError>> + Send + 'async_trait>>
where + Self: 'async_trait, + 'life0: 'async_trait, + 'life1: 'async_trait,

Continue Watching: the items this user has started and not finished.

+

/Users/{uid}/Items/Resume is the server-side answer to both “what goes +in the Continue Watching row” and “where was this left off” — each item +carries its own UserData.PlaybackPositionTicks, which is why UserData +is named in Fields= rather than left to the server’s default field set.

+

TRACES: UR-019, UR-023 | IR-024, JA-013, JA-015

+
Source§

fn get_next_up_episodes<'life0, 'life1, 'async_trait>( + &'life0 self, + series_id: Option<&'life1 str>, + limit: Option<usize>, +) -> Pin<Box<dyn Future<Output = Result<Vec<MediaItem>, RepoError>> + Send + 'async_trait>>
where + Self: 'async_trait, + 'life0: 'async_trait, + 'life1: 'async_trait,

“Next Up”: the episode that follows the ones this user has finished, +per series — the Shows-scoped counterpart to Continue Watching.

+

TRACES: UR-023, UR-059 | IR-024, JA-014

+
Source§

fn get_resume_movies<'life0, 'async_trait>( + &'life0 self, + limit: Option<usize>, +) -> Pin<Box<dyn Future<Output = Result<Vec<MediaItem>, RepoError>> + Send + 'async_trait>>
where + Self: 'async_trait, + 'life0: 'async_trait,

Continue Watching, narrowed to movies — the home screen’s movie row and +the movie library’s own hero both want the unfinished films without the +episodes mixed in.

+

TRACES: UR-019, UR-034 | IR-024, JA-013, JA-015

+
Source§

fn search<'life0, 'life1, 'async_trait>( + &'life0 self, + query: &'life1 str, + options: Option<SearchOptions>, +) -> Pin<Box<dyn Future<Output = Result<SearchResult, RepoError>> + Send + 'async_trait>>
where + Self: 'async_trait, + 'life0: 'async_trait, + 'life1: 'async_trait,

Search every library the user can see.

+

Recursive=true with no ParentId is what makes this cross-library +rather than folder-scoped; a caller narrowing the search passes the item +types through SearchOptions (already expanded from an opaque +SearchScope on this side of the boundary).

+

TRACES: UR-008 | IR-010, JA-006

+
Source§

fn get_video_download_url( + &self, + item_id: &str, + quality: &str, + media_source_id: Option<&str>, + source_audio_codec: Option<&str>, +) -> String

TRACES: UR-071 | DR-123

+
Source§

fn get_favorites<'life0, 'async_trait>( + &'life0 self, + scope: SearchScope, + options: Option<GetItemsOptions>, +) -> Pin<Box<dyn Future<Output = Result<SearchResult, RepoError>> + Send + 'async_trait>>
where + Self: 'async_trait, + 'life0: 'async_trait,

TRACES: UR-067 | DR-115, JA-033 | UT-100

+
Source§

fn unmark_favorite<'life0, 'life1, 'async_trait>( + &'life0 self, + item_id: &'life1 str, +) -> Pin<Box<dyn Future<Output = Result<(), RepoError>> + Send + 'async_trait>>
where + Self: 'async_trait, + 'life0: 'async_trait, + 'life1: 'async_trait,

Un-favourite an item: the same /Users/{uid}/FavoriteItems/{id} resource +as Self::mark_favorite, removed rather than posted. Written out by +hand rather than through post_json because it is the one favourite call +that needs DELETE.

+

TRACES: UR-017 | JA-018, DR-021

+
Source§

fn clear_watch_history<'life0, 'life1, 'async_trait>( + &'life0 self, + item_id: &'life1 str, +) -> Pin<Box<dyn Future<Output = Result<(), RepoError>> + Send + 'async_trait>>
where + Self: 'async_trait, + 'life0: 'async_trait, + 'life1: 'async_trait,

DELETE /Users/{userId}/PlayedItems/{itemId} — Jellyfin’s “mark +unplayed”, which also zeroes the resume position. On a folder (series, +season) the server applies it recursively to the children.

+

TRACES: UR-064 | DR-106, JA-033

+
Source§

fn mark_played<'life0, 'life1, 'async_trait>( + &'life0 self, + item_id: &'life1 str, +) -> Pin<Box<dyn Future<Output = Result<(), RepoError>> + Send + 'async_trait>>
where + Self: 'async_trait, + 'life0: 'async_trait, + 'life1: 'async_trait,

POST /Users/{userId}/PlayedItems/{itemId} — the mirror image of +clear_watch_history.

+

TRACES: UR-025 | DR-131 | JA-035

+
Source§

fn get_person<'life0, 'life1, 'async_trait>( + &'life0 self, + person_id: &'life1 str, +) -> Pin<Box<dyn Future<Output = Result<MediaItem, RepoError>> + Send + 'async_trait>>
where + Self: 'async_trait, + 'life0: 'async_trait, + 'life1: 'async_trait,

A single Person item (actor, director, …) by id.

+

Jellyfin models people as ordinary items, so this is the plain item +endpoint rather than anything under /Persons; the cast entries returned +on an item’s People field carry the ids this is called with.

+

TRACES: UR-035, UR-036 | IR-022, JA-030

+
Source§

fn get_items_by_person<'life0, 'life1, 'async_trait>( + &'life0 self, + person_id: &'life1 str, + options: Option<GetItemsOptions>, +) -> Pin<Box<dyn Future<Output = Result<SearchResult, RepoError>> + Send + 'async_trait>>
where + Self: 'async_trait, + 'life0: 'async_trait, + 'life1: 'async_trait,

A person’s filmography — every item they are credited on.

+

TRACES: UR-036 | IR-022, JA-031

+
Source§

fn get_libraries<'life0, 'async_trait>( + &'life0 self, +) -> Pin<Box<dyn Future<Output = Result<Vec<Library>, RepoError>> + Send + 'async_trait>>
where + Self: 'async_trait, + 'life0: 'async_trait,

Get all libraries Read more
Source§

fn get_items<'life0, 'life1, 'async_trait>( + &'life0 self, + parent_id: &'life1 str, + options: Option<GetItemsOptions>, +) -> Pin<Box<dyn Future<Output = Result<SearchResult, RepoError>> + Send + 'async_trait>>
where + Self: 'async_trait, + 'life0: 'async_trait, + 'life1: 'async_trait,

Get items in a library or parent Read more
Source§

fn get_latest_items<'life0, 'life1, 'async_trait>( + &'life0 self, + parent_id: &'life1 str, + limit: Option<usize>, +) -> Pin<Box<dyn Future<Output = Result<Vec<MediaItem>, RepoError>> + Send + 'async_trait>>
where + Self: 'async_trait, + 'life0: 'async_trait, + 'life1: 'async_trait,

Get latest items in a library Read more
Source§

fn get_recently_played_audio<'life0, 'async_trait>( + &'life0 self, + limit: Option<usize>, +) -> Pin<Box<dyn Future<Output = Result<Vec<MediaItem>, RepoError>> + Send + 'async_trait>>
where + Self: 'async_trait, + 'life0: 'async_trait,

Get recently played audio
Source§

fn get_rediscover_albums<'life0, 'life1, 'async_trait>( + &'life0 self, + parent_id: Option<&'life1 str>, + limit: Option<usize>, +) -> Pin<Box<dyn Future<Output = Result<Vec<MediaItem>, RepoError>> + Send + 'async_trait>>
where + Self: 'async_trait, + 'life0: 'async_trait, + 'life1: 'async_trait,

Get albums the user has played, but not recently (“rediscover” / haven’t +listened to in a while). Returns albums sorted by least-recently played +first, optionally restricted to a parent library.
Source§

fn get_genres<'life0, 'life1, 'async_trait>( + &'life0 self, + parent_id: Option<&'life1 str>, +) -> Pin<Box<dyn Future<Output = Result<Vec<Genre>, RepoError>> + Send + 'async_trait>>
where + Self: 'async_trait, + 'life0: 'async_trait, + 'life1: 'async_trait,

Get genres
Source§

fn get_playback_info<'life0, 'life1, 'async_trait>( + &'life0 self, + item_id: &'life1 str, +) -> Pin<Box<dyn Future<Output = Result<PlaybackInfo, RepoError>> + Send + 'async_trait>>
where + Self: 'async_trait, + 'life0: 'async_trait, + 'life1: 'async_trait,

Get playback info for streaming Read more
Source§

fn get_audio_stream_url<'life0, 'life1, 'async_trait>( + &'life0 self, + item_id: &'life1 str, +) -> Pin<Box<dyn Future<Output = Result<String, RepoError>> + Send + 'async_trait>>
where + Self: 'async_trait, + 'life0: 'async_trait, + 'life1: 'async_trait,

Get audio stream URL for a track Read more
Source§

fn get_audio_only_stream_url_for_video<'life0, 'life1, 'life2, 'async_trait>( + &'life0 self, + item_id: &'life1 str, + media_source_id: Option<&'life2 str>, + start_time_seconds: Option<f64>, + audio_stream_index: Option<i32>, +) -> Pin<Box<dyn Future<Output = Result<String, RepoError>> + Send + 'async_trait>>
where + Self: 'async_trait, + 'life0: 'async_trait, + 'life1: 'async_trait, + 'life2: 'async_trait,

Get an audio-only stream URL for a video item (background-audio handoff). Read more
Source§

fn get_live_tv_channels<'life0, 'async_trait>( + &'life0 self, +) -> Pin<Box<dyn Future<Output = Result<Vec<MediaItem>, RepoError>> + Send + 'async_trait>>
where + Self: 'async_trait, + 'life0: 'async_trait,

Get Live TV channels (broadcast / IPTV) for browsing.
Source§

fn get_channels<'life0, 'async_trait>( + &'life0 self, +) -> Pin<Box<dyn Future<Output = Result<SearchResult, RepoError>> + Send + 'async_trait>>
where + Self: 'async_trait, + 'life0: 'async_trait,

Get the root list of plugin “Channels” (Jellyfin Channels feature). +Drill-down into a channel reuses get_items(channel_id, ...).
Source§

fn open_live_stream<'life0, 'life1, 'async_trait>( + &'life0 self, + item_id: &'life1 str, +) -> Pin<Box<dyn Future<Output = Result<LiveStreamInfo, RepoError>> + Send + 'async_trait>>
where + Self: 'async_trait, + 'life0: 'async_trait, + 'life1: 'async_trait,

Open a live stream (Live TV channel or live channel item) for playback. Read more
Source§

fn report_playback_start<'life0, 'life1, 'async_trait>( + &'life0 self, + item_id: &'life1 str, + position_ticks: i64, +) -> Pin<Box<dyn Future<Output = Result<(), RepoError>> + Send + 'async_trait>>
where + Self: 'async_trait, + 'life0: 'async_trait, + 'life1: 'async_trait,

Report playback start Read more
Source§

fn report_playback_progress<'life0, 'life1, 'async_trait>( + &'life0 self, + item_id: &'life1 str, + position_ticks: i64, +) -> Pin<Box<dyn Future<Output = Result<(), RepoError>> + Send + 'async_trait>>
where + Self: 'async_trait, + 'life0: 'async_trait, + 'life1: 'async_trait,

Report playback progress Read more
Source§

fn report_playback_stopped<'life0, 'life1, 'async_trait>( + &'life0 self, + item_id: &'life1 str, + position_ticks: i64, +) -> Pin<Box<dyn Future<Output = Result<(), RepoError>> + Send + 'async_trait>>
where + Self: 'async_trait, + 'life0: 'async_trait, + 'life1: 'async_trait,

Report playback stopped Read more
Source§

fn get_image_url( + &self, + item_id: &str, + image_type: ImageType, + options: Option<ImageOptions>, +) -> String

Get image URL (synchronous - just constructs URL)
Source§

fn get_subtitle_url( + &self, + item_id: &str, + media_source_id: &str, + stream_index: i32, + format: &str, +) -> String

Get subtitle URL (synchronous - just constructs URL) +Called by frontend via Tauri invoke (getSubtitleUrl in VideoPlayer.svelte)
Source§

fn mark_favorite<'life0, 'life1, 'async_trait>( + &'life0 self, + item_id: &'life1 str, +) -> Pin<Box<dyn Future<Output = Result<(), RepoError>> + Send + 'async_trait>>
where + Self: 'async_trait, + 'life0: 'async_trait, + 'life1: 'async_trait,

Mark item as favorite
Source§

fn get_similar_items<'life0, 'life1, 'async_trait>( + &'life0 self, + item_id: &'life1 str, + limit: Option<usize>, +) -> Pin<Box<dyn Future<Output = Result<SearchResult, RepoError>> + Send + 'async_trait>>
where + Self: 'async_trait, + 'life0: 'async_trait, + 'life1: 'async_trait,

Get similar/related items for a movie or show Read more
Source§

fn create_playlist<'life0, 'life1, 'life2, 'async_trait>( + &'life0 self, + name: &'life1 str, + item_ids: &'life2 [String], +) -> Pin<Box<dyn Future<Output = Result<PlaylistCreatedResult, RepoError>> + Send + 'async_trait>>
where + Self: 'async_trait, + 'life0: 'async_trait, + 'life1: 'async_trait, + 'life2: 'async_trait,

Create a new playlist on the server Read more
Source§

fn delete_playlist<'life0, 'life1, 'async_trait>( + &'life0 self, + playlist_id: &'life1 str, +) -> Pin<Box<dyn Future<Output = Result<(), RepoError>> + Send + 'async_trait>>
where + Self: 'async_trait, + 'life0: 'async_trait, + 'life1: 'async_trait,

Delete a playlist Read more
Source§

fn rename_playlist<'life0, 'life1, 'life2, 'async_trait>( + &'life0 self, + playlist_id: &'life1 str, + name: &'life2 str, +) -> Pin<Box<dyn Future<Output = Result<(), RepoError>> + Send + 'async_trait>>
where + Self: 'async_trait, + 'life0: 'async_trait, + 'life1: 'async_trait, + 'life2: 'async_trait,

Rename a playlist Read more
Source§

fn get_playlist_items<'life0, 'life1, 'async_trait>( + &'life0 self, + playlist_id: &'life1 str, +) -> Pin<Box<dyn Future<Output = Result<Vec<PlaylistEntry>, RepoError>> + Send + 'async_trait>>
where + Self: 'async_trait, + 'life0: 'async_trait, + 'life1: 'async_trait,

Get playlist items with PlaylistItemId (needed for remove/reorder) Read more
Source§

fn add_to_playlist<'life0, 'life1, 'life2, 'async_trait>( + &'life0 self, + playlist_id: &'life1 str, + item_ids: &'life2 [String], +) -> Pin<Box<dyn Future<Output = Result<(), RepoError>> + Send + 'async_trait>>
where + Self: 'async_trait, + 'life0: 'async_trait, + 'life1: 'async_trait, + 'life2: 'async_trait,

Add items to a playlist Read more
Source§

fn remove_from_playlist<'life0, 'life1, 'life2, 'async_trait>( + &'life0 self, + playlist_id: &'life1 str, + entry_ids: &'life2 [String], +) -> Pin<Box<dyn Future<Output = Result<(), RepoError>> + Send + 'async_trait>>
where + Self: 'async_trait, + 'life0: 'async_trait, + 'life1: 'async_trait, + 'life2: 'async_trait,

Remove items from a playlist using entry IDs (PlaylistItemId, NOT media item IDs) Read more
Source§

fn move_playlist_item<'life0, 'life1, 'life2, 'async_trait>( + &'life0 self, + playlist_id: &'life1 str, + item_id: &'life2 str, + new_index: u32, +) -> Pin<Box<dyn Future<Output = Result<(), RepoError>> + Send + 'async_trait>>
where + Self: 'async_trait, + 'life0: 'async_trait, + 'life1: 'async_trait, + 'life2: 'async_trait,

Move a playlist item to a new position Read more

Auto Trait Implementations§

Blanket Implementations§

Source§

impl<T> Any for T
where + T: 'static + ?Sized,

Source§

fn type_id(&self) -> TypeId

Gets the TypeId of self. Read more
Source§

impl<T> Borrow<T> for T
where + T: ?Sized,

Source§

fn borrow(&self) -> &T

Immutably borrows from an owned value. Read more
Source§

impl<T> BorrowMut<T> for T
where + T: ?Sized,

Source§

fn borrow_mut(&mut self) -> &mut T

Mutably borrows from an owned value. Read more
Source§

impl<T> From<T> for T

Source§

fn from(t: T) -> T

Returns the argument unchanged.

+
§

impl<T> Instrument for T

§

fn instrument(self, span: Span) -> Instrumented<Self>

Instruments this type with the provided [Span], returning an +Instrumented wrapper. Read more
§

fn in_current_span(self) -> Instrumented<Self>

Instruments this type with the current Span, returning an +Instrumented wrapper. Read more
Source§

impl<T, U> Into<U> for T
where + U: From<T>,

Source§

fn into(self) -> U

Calls U::from(self).

+

That is, this conversion is whatever the implementation of +From<T> for U chooses to do.

+
§

impl<T> PolicyExt for T
where + T: ?Sized,

§

fn and<P, B, E>(self, other: P) -> And<T, P>
where + T: Policy<B, E>, + P: Policy<B, E>,

Create a new Policy that returns [Action::Follow] only if self and other return +Action::Follow. Read more
§

fn or<P, B, E>(self, other: P) -> Or<T, P>
where + T: Policy<B, E>, + P: Policy<B, E>,

Create a new Policy that returns [Action::Follow] if either self or other returns +Action::Follow. Read more
Source§

impl<T> Same for T

Source§

type Output = T

Should always be Self
Source§

impl<T, U> TryFrom<U> for T
where + U: Into<T>,

Source§

type Error = Infallible

The type returned in the event of a conversion error.
Source§

fn try_from(value: U) -> Result<T, <T as TryFrom<U>>::Error>

Performs the conversion.
Source§

impl<T, U> TryInto<U> for T
where + U: TryFrom<T>,

Source§

type Error = <U as TryFrom<T>>::Error

The type returned in the event of a conversion error.
Source§

fn try_into(self) -> Result<U, <U as TryFrom<T>>::Error>

Performs the conversion.
§

impl<V, T> VZip<V> for T
where + V: MultiLane<T>,

§

fn vzip(self) -> V

§

impl<T> WithSubscriber for T

§

fn with_subscriber<S>(self, subscriber: S) -> WithDispatch<Self>
where + S: Into<Dispatch>,

Attaches the provided Subscriber to this type, returning a +[WithDispatch] wrapper. Read more
§

fn with_current_subscriber(self) -> WithDispatch<Self>

Attaches the current default Subscriber to this type, returning a +[WithDispatch] wrapper. Read more
\ No newline at end of file diff --git a/api/jellytau_lib/repository/online/struct.PlaylistItemsResponse.html b/api/jellytau_lib/repository/online/struct.PlaylistItemsResponse.html new file mode 100644 index 00000000..7f75d4d8 --- /dev/null +++ b/api/jellytau_lib/repository/online/struct.PlaylistItemsResponse.html @@ -0,0 +1,37 @@ +PlaylistItemsResponse in jellytau_lib::repository::online - RustSkip to main content

PlaylistItemsResponse

Struct PlaylistItemsResponse 

Source
struct PlaylistItemsResponse {
+    items: Vec<JellyfinPlaylistItem>,
+    total_record_count: usize,
+}
Expand description

Jellyfin playlist items response — items include PlaylistItemId

+

Fields§

§items: Vec<JellyfinPlaylistItem>§total_record_count: usize

Trait Implementations§

Source§

impl Debug for PlaylistItemsResponse

Source§

fn fmt(&self, f: &mut Formatter<'_>) -> Result

Formats the value using the given formatter. Read more
Source§

impl<'de> Deserialize<'de> for PlaylistItemsResponse

Source§

fn deserialize<__D>(__deserializer: __D) -> Result<Self, __D::Error>
where + __D: Deserializer<'de>,

Deserialize this value from the given Serde deserializer. Read more

Auto Trait Implementations§

Blanket Implementations§

Source§

impl<T> Any for T
where + T: 'static + ?Sized,

Source§

fn type_id(&self) -> TypeId

Gets the TypeId of self. Read more
Source§

impl<T> Borrow<T> for T
where + T: ?Sized,

Source§

fn borrow(&self) -> &T

Immutably borrows from an owned value. Read more
Source§

impl<T> BorrowMut<T> for T
where + T: ?Sized,

Source§

fn borrow_mut(&mut self) -> &mut T

Mutably borrows from an owned value. Read more
§

impl<'de, D, R> CommandArg<'de, R> for D
where + D: Deserialize<'de>, + R: Runtime,

§

fn from_command(command: CommandItem<'de, R>) -> Result<D, InvokeError>

Derives an instance of Self from the [CommandItem]. Read more
Source§

impl<T> From<T> for T

Source§

fn from(t: T) -> T

Returns the argument unchanged.

+
§

impl<T> Instrument for T

§

fn instrument(self, span: Span) -> Instrumented<Self>

Instruments this type with the provided [Span], returning an +Instrumented wrapper. Read more
§

fn in_current_span(self) -> Instrumented<Self>

Instruments this type with the current Span, returning an +Instrumented wrapper. Read more
Source§

impl<T, U> Into<U> for T
where + U: From<T>,

Source§

fn into(self) -> U

Calls U::from(self).

+

That is, this conversion is whatever the implementation of +From<T> for U chooses to do.

+
§

impl<T> PolicyExt for T
where + T: ?Sized,

§

fn and<P, B, E>(self, other: P) -> And<T, P>
where + T: Policy<B, E>, + P: Policy<B, E>,

Create a new Policy that returns [Action::Follow] only if self and other return +Action::Follow. Read more
§

fn or<P, B, E>(self, other: P) -> Or<T, P>
where + T: Policy<B, E>, + P: Policy<B, E>,

Create a new Policy that returns [Action::Follow] if either self or other returns +Action::Follow. Read more
Source§

impl<T> Same for T

Source§

type Output = T

Should always be Self
§

impl<T> ScopeObject for T
where + T: Send + Sync + Debug + DeserializeOwned + 'static,

§

type Error = Error

The error type.
§

fn deserialize<R>( + _app: &AppHandle<R>, + raw: Value, +) -> Result<T, <T as ScopeObject>::Error>
where + R: Runtime,

Deserialize the raw scope value.
Source§

impl<T, U> TryFrom<U> for T
where + U: Into<T>,

Source§

type Error = Infallible

The type returned in the event of a conversion error.
Source§

fn try_from(value: U) -> Result<T, <T as TryFrom<U>>::Error>

Performs the conversion.
Source§

impl<T, U> TryInto<U> for T
where + U: TryFrom<T>,

Source§

type Error = <U as TryFrom<T>>::Error

The type returned in the event of a conversion error.
Source§

fn try_into(self) -> Result<U, <U as TryFrom<T>>::Error>

Performs the conversion.
§

impl<V, T> VZip<V> for T
where + V: MultiLane<T>,

§

fn vzip(self) -> V

§

impl<T> WithSubscriber for T

§

fn with_subscriber<S>(self, subscriber: S) -> WithDispatch<Self>
where + S: Into<Dispatch>,

Attaches the provided Subscriber to this type, returning a +[WithDispatch] wrapper. Read more
§

fn with_current_subscriber(self) -> WithDispatch<Self>

Attaches the current default Subscriber to this type, returning a +[WithDispatch] wrapper. Read more
Source§

impl<T> DeserializeOwned for T
where + T: for<'de> Deserialize<'de>,

\ No newline at end of file diff --git a/api/jellytau_lib/repository/series_progress/constant.MAX_PROGRESS_FRACTION.html b/api/jellytau_lib/repository/series_progress/constant.MAX_PROGRESS_FRACTION.html new file mode 100644 index 00000000..486d1886 --- /dev/null +++ b/api/jellytau_lib/repository/series_progress/constant.MAX_PROGRESS_FRACTION.html @@ -0,0 +1,3 @@ +MAX_PROGRESS_FRACTION in jellytau_lib::repository::series_progress - RustSkip to main content

MAX_PROGRESS_FRACTION

Constant MAX_PROGRESS_FRACTION 

Source
const MAX_PROGRESS_FRACTION: f64 = 0.95;
Expand description

Above this fraction watched, an episode is effectively finished; resuming it +would drop the viewer into the closing credits.

+
\ No newline at end of file diff --git a/api/jellytau_lib/repository/series_progress/constant.MIN_PROGRESS_FRACTION.html b/api/jellytau_lib/repository/series_progress/constant.MIN_PROGRESS_FRACTION.html new file mode 100644 index 00000000..160852c7 --- /dev/null +++ b/api/jellytau_lib/repository/series_progress/constant.MIN_PROGRESS_FRACTION.html @@ -0,0 +1,3 @@ +MIN_PROGRESS_FRACTION in jellytau_lib::repository::series_progress - RustSkip to main content

MIN_PROGRESS_FRACTION

Constant MIN_PROGRESS_FRACTION 

Source
const MIN_PROGRESS_FRACTION: f64 = 0.01;
Expand description

Below this fraction watched, a position is a false start rather than +progress — the same threshold the resume dialog uses.

+
\ No newline at end of file diff --git a/api/jellytau_lib/repository/series_progress/constant.SPECIALS_SEASON.html b/api/jellytau_lib/repository/series_progress/constant.SPECIALS_SEASON.html new file mode 100644 index 00000000..dd3a10fe --- /dev/null +++ b/api/jellytau_lib/repository/series_progress/constant.SPECIALS_SEASON.html @@ -0,0 +1,2 @@ +SPECIALS_SEASON in jellytau_lib::repository::series_progress - RustSkip to main content

SPECIALS_SEASON

Constant SPECIALS_SEASON 

Source
const SPECIALS_SEASON: i32 = 0;
Expand description

Jellyfin files specials under season 0.

+
\ No newline at end of file diff --git a/api/jellytau_lib/repository/series_progress/fn.belongs_to_series.html b/api/jellytau_lib/repository/series_progress/fn.belongs_to_series.html new file mode 100644 index 00000000..ddea68a0 --- /dev/null +++ b/api/jellytau_lib/repository/series_progress/fn.belongs_to_series.html @@ -0,0 +1 @@ +belongs_to_series in jellytau_lib::repository::series_progress - RustSkip to main content

belongs_to_series

Function belongs_to_series 

Source
fn belongs_to_series(item: &MediaItem, series_id: &str) -> bool
\ No newline at end of file diff --git a/api/jellytau_lib/repository/series_progress/fn.fetch_series_episodes.html b/api/jellytau_lib/repository/series_progress/fn.fetch_series_episodes.html new file mode 100644 index 00000000..56903e79 --- /dev/null +++ b/api/jellytau_lib/repository/series_progress/fn.fetch_series_episodes.html @@ -0,0 +1,8 @@ +fetch_series_episodes in jellytau_lib::repository::series_progress - RustSkip to main content

fetch_series_episodes

Function fetch_series_episodes 

Source
pub async fn fetch_series_episodes(
+    repo: &dyn MediaRepository,
+    series_id: &str,
+) -> Result<Vec<MediaItem>, RepoError>
Expand description

Every episode of a series, in series order.

+

Jellyfin hangs episodes off season folders, except for “flat” series whose +children are episodes directly. Both shapes are provider vocabulary, so the +fan-out and the fallback live here rather than in the frontend.

+
\ No newline at end of file diff --git a/api/jellytau_lib/repository/series_progress/fn.is_episode.html b/api/jellytau_lib/repository/series_progress/fn.is_episode.html new file mode 100644 index 00000000..f4cd2119 --- /dev/null +++ b/api/jellytau_lib/repository/series_progress/fn.is_episode.html @@ -0,0 +1 @@ +is_episode in jellytau_lib::repository::series_progress - RustSkip to main content

is_episode

Function is_episode 

Source
fn is_episode(item: &MediaItem) -> bool
\ No newline at end of file diff --git a/api/jellytau_lib/repository/series_progress/fn.is_in_progress.html b/api/jellytau_lib/repository/series_progress/fn.is_in_progress.html new file mode 100644 index 00000000..508c172b --- /dev/null +++ b/api/jellytau_lib/repository/series_progress/fn.is_in_progress.html @@ -0,0 +1,2 @@ +is_in_progress in jellytau_lib::repository::series_progress - RustSkip to main content

is_in_progress

Function is_in_progress 

Source
fn is_in_progress(item: &MediaItem) -> bool
Expand description

Is this episode genuinely part-watched (not a false start, not finished)?

+
\ No newline at end of file diff --git a/api/jellytau_lib/repository/series_progress/fn.is_played.html b/api/jellytau_lib/repository/series_progress/fn.is_played.html new file mode 100644 index 00000000..c93de6bf --- /dev/null +++ b/api/jellytau_lib/repository/series_progress/fn.is_played.html @@ -0,0 +1 @@ +is_played in jellytau_lib::repository::series_progress - RustSkip to main content

is_played

Function is_played 

Source
fn is_played(item: &MediaItem) -> bool
\ No newline at end of file diff --git a/api/jellytau_lib/repository/series_progress/fn.is_season.html b/api/jellytau_lib/repository/series_progress/fn.is_season.html new file mode 100644 index 00000000..b87f5ff0 --- /dev/null +++ b/api/jellytau_lib/repository/series_progress/fn.is_season.html @@ -0,0 +1 @@ +is_season in jellytau_lib::repository::series_progress - RustSkip to main content

is_season

Function is_season 

Source
fn is_season(item: &MediaItem) -> bool
\ No newline at end of file diff --git a/api/jellytau_lib/repository/series_progress/fn.list_options.html b/api/jellytau_lib/repository/series_progress/fn.list_options.html new file mode 100644 index 00000000..c96bf47e --- /dev/null +++ b/api/jellytau_lib/repository/series_progress/fn.list_options.html @@ -0,0 +1 @@ +list_options in jellytau_lib::repository::series_progress - RustSkip to main content

list_options

Function list_options 

Source
fn list_options() -> Option<GetItemsOptions>
\ No newline at end of file diff --git a/api/jellytau_lib/repository/series_progress/fn.pick_current_episode.html b/api/jellytau_lib/repository/series_progress/fn.pick_current_episode.html new file mode 100644 index 00000000..e61620ea --- /dev/null +++ b/api/jellytau_lib/repository/series_progress/fn.pick_current_episode.html @@ -0,0 +1,29 @@ +pick_current_episode in jellytau_lib::repository::series_progress - RustSkip to main content

pick_current_episode

Function pick_current_episode 

Source
pub fn pick_current_episode(
+    series_id: &str,
+    episodes: &[MediaItem],
+    next_up: &[MediaItem],
+    resume: &[MediaItem],
+) -> Option<MediaItem>
Expand description

The episode a viewer should land on when they open series_id.

+

Order of preference, and why:

+
    +
  1. An episode in progress. That is literally where playback stopped; +Next Up would skip past it. On a tie the earliest in series order wins, so +a viewer who dipped into a later episode still returns to the one they are +working through.
  2. +
  3. The server’s Next Up for this series — it accounts for watch history +we do not cache locally.
  4. +
  5. The episode after the furthest-watched one, falling back to the first +unwatched episode when nothing has been watched or the series is finished. +This is the offline path: OfflineRepository::get_next_up_episodes +returns an empty vec, so without this rung the whole feature would be +online-only. It deliberately does not return the first unwatched +episode outright — an unwatched episode behind the viewer’s furthest +point was skipped on purpose, and sending them back to it is the bug +DR-101 was reopened for.
  6. +
  7. The first episode, so a never-watched series opens on its premiere +rather than on nothing.
  8. +
+

next_up / resume entries are honoured even when absent from episodes +(the season fan-out can miss an id the server returns), but only when they +belong to this series.

+
\ No newline at end of file diff --git a/api/jellytau_lib/repository/series_progress/fn.resolve_current_episode.html b/api/jellytau_lib/repository/series_progress/fn.resolve_current_episode.html new file mode 100644 index 00000000..98903fe3 --- /dev/null +++ b/api/jellytau_lib/repository/series_progress/fn.resolve_current_episode.html @@ -0,0 +1,7 @@ +resolve_current_episode in jellytau_lib::repository::series_progress - RustSkip to main content

resolve_current_episode

Function resolve_current_episode 

Source
pub async fn resolve_current_episode(
+    repo: &dyn MediaRepository,
+    series_id: &str,
+) -> Result<Option<MediaItem>, RepoError>
Expand description

Resolve the current episode, fetching everything the policy needs.

+

Next Up and resume are best-effort: offline they fail or come back empty, and +pick_current_episode has fallbacks for exactly that.

+
\ No newline at end of file diff --git a/api/jellytau_lib/repository/series_progress/fn.season_rank.html b/api/jellytau_lib/repository/series_progress/fn.season_rank.html new file mode 100644 index 00000000..72b36505 --- /dev/null +++ b/api/jellytau_lib/repository/series_progress/fn.season_rank.html @@ -0,0 +1,4 @@ +season_rank in jellytau_lib::repository::series_progress - RustSkip to main content

season_rank

Function season_rank 

Source
fn season_rank(season: Option<i32>) -> i64
Expand description

Sort key for a season number. Specials sort after every numbered season: +a viewer works through S1, S2, … and only then the extras, so season 0 must +not lead just because 0 < 1.

+
\ No newline at end of file diff --git a/api/jellytau_lib/repository/series_progress/fn.sort_series_order.html b/api/jellytau_lib/repository/series_progress/fn.sort_series_order.html new file mode 100644 index 00000000..7bab9fa7 --- /dev/null +++ b/api/jellytau_lib/repository/series_progress/fn.sort_series_order.html @@ -0,0 +1,3 @@ +sort_series_order in jellytau_lib::repository::series_progress - RustSkip to main content

sort_series_order

Function sort_series_order 

Source
pub fn sort_series_order(episodes: &mut [MediaItem])
Expand description

Order episodes as the series is watched: season ascending, then episode, +specials last.

+
\ No newline at end of file diff --git a/api/jellytau_lib/repository/series_progress/index.html b/api/jellytau_lib/repository/series_progress/index.html new file mode 100644 index 00000000..12920ff8 --- /dev/null +++ b/api/jellytau_lib/repository/series_progress/index.html @@ -0,0 +1,15 @@ +jellytau_lib::repository::series_progress - RustSkip to main content

Module series_progress

Module series_progress 

Source
Expand description

Where a viewer is in a TV series.

+

This is domain policy, not presentation: it encodes what Jellyfin’s user-data +means (“in progress”, “played”) and what Jellyfin’s season numbering means +(season 0 is specials). The frontend asks for the current episode and +renders it; it does not get to decide what “current” means.

+

Split into a pure half (pick_current_episode, sort_series_order) and an +I/O half (fetch_series_episodes, resolve_current_episode) so the policy +can be unit-tested without standing up a repository.

+

TRACES: UR-062 | DR-101

+

Constants§

MAX_PROGRESS_FRACTION 🔒
Above this fraction watched, an episode is effectively finished; resuming it +would drop the viewer into the closing credits.
MIN_PROGRESS_FRACTION 🔒
Below this fraction watched, a position is a false start rather than +progress — the same threshold the resume dialog uses.
SPECIALS_SEASON 🔒
Jellyfin files specials under season 0.

Functions§

belongs_to_series 🔒
fetch_series_episodes
Every episode of a series, in series order.
is_episode 🔒
is_in_progress 🔒
Is this episode genuinely part-watched (not a false start, not finished)?
is_played 🔒
is_season 🔒
list_options 🔒
pick_current_episode
The episode a viewer should land on when they open series_id.
resolve_current_episode
Resolve the current episode, fetching everything the policy needs.
season_rank 🔒
Sort key for a season number. Specials sort after every numbered season: +a viewer works through S1, S2, … and only then the extras, so season 0 must +not lead just because 0 < 1.
sort_series_order
Order episodes as the series is watched: season ascending, then episode, +specials last.
\ No newline at end of file diff --git a/api/jellytau_lib/repository/series_progress/sidebar-items.js b/api/jellytau_lib/repository/series_progress/sidebar-items.js new file mode 100644 index 00000000..a089ddb6 --- /dev/null +++ b/api/jellytau_lib/repository/series_progress/sidebar-items.js @@ -0,0 +1 @@ +window.SIDEBAR_ITEMS = {"constant":["MAX_PROGRESS_FRACTION","MIN_PROGRESS_FRACTION","SPECIALS_SEASON"],"fn":["belongs_to_series","fetch_series_episodes","is_episode","is_in_progress","is_played","is_season","list_options","pick_current_episode","resolve_current_episode","season_rank","sort_series_order"]}; \ No newline at end of file diff --git a/api/jellytau_lib/repository/sidebar-items.js b/api/jellytau_lib/repository/sidebar-items.js new file mode 100644 index 00000000..39065429 --- /dev/null +++ b/api/jellytau_lib/repository/sidebar-items.js @@ -0,0 +1 @@ +window.SIDEBAR_ITEMS = {"fn":["resolve_video_download_url","served_audio_codec"],"mod":["device_profile","exclusions","hybrid","offline","online","series_progress","types"],"trait":["MediaRepository"]}; \ No newline at end of file diff --git a/api/jellytau_lib/repository/trait.MediaRepository.html b/api/jellytau_lib/repository/trait.MediaRepository.html new file mode 100644 index 00000000..9630ffd6 --- /dev/null +++ b/api/jellytau_lib/repository/trait.MediaRepository.html @@ -0,0 +1,649 @@ +MediaRepository in jellytau_lib::repository - RustSkip to main content

MediaRepository

Trait MediaRepository 

Source
pub trait MediaRepository: Send + Sync {
+
Show 38 methods // Required methods + fn get_libraries<'life0, 'async_trait>( + &'life0 self, + ) -> Pin<Box<dyn Future<Output = Result<Vec<Library>, RepoError>> + Send + 'async_trait>> + where Self: 'async_trait, + 'life0: 'async_trait; + fn get_items<'life0, 'life1, 'async_trait>( + &'life0 self, + parent_id: &'life1 str, + options: Option<GetItemsOptions>, + ) -> Pin<Box<dyn Future<Output = Result<SearchResult, RepoError>> + Send + 'async_trait>> + where Self: 'async_trait, + 'life0: 'async_trait, + 'life1: 'async_trait; + fn get_item<'life0, 'life1, 'async_trait>( + &'life0 self, + item_id: &'life1 str, + ) -> Pin<Box<dyn Future<Output = Result<MediaItem, RepoError>> + Send + 'async_trait>> + where Self: 'async_trait, + 'life0: 'async_trait, + 'life1: 'async_trait; + fn get_latest_items<'life0, 'life1, 'async_trait>( + &'life0 self, + parent_id: &'life1 str, + limit: Option<usize>, + ) -> Pin<Box<dyn Future<Output = Result<Vec<MediaItem>, RepoError>> + Send + 'async_trait>> + where Self: 'async_trait, + 'life0: 'async_trait, + 'life1: 'async_trait; + fn get_resume_items<'life0, 'life1, 'async_trait>( + &'life0 self, + parent_id: Option<&'life1 str>, + limit: Option<usize>, + ) -> Pin<Box<dyn Future<Output = Result<Vec<MediaItem>, RepoError>> + Send + 'async_trait>> + where Self: 'async_trait, + 'life0: 'async_trait, + 'life1: 'async_trait; + fn get_next_up_episodes<'life0, 'life1, 'async_trait>( + &'life0 self, + series_id: Option<&'life1 str>, + limit: Option<usize>, + ) -> Pin<Box<dyn Future<Output = Result<Vec<MediaItem>, RepoError>> + Send + 'async_trait>> + where Self: 'async_trait, + 'life0: 'async_trait, + 'life1: 'async_trait; + fn get_recently_played_audio<'life0, 'async_trait>( + &'life0 self, + limit: Option<usize>, + ) -> Pin<Box<dyn Future<Output = Result<Vec<MediaItem>, RepoError>> + Send + 'async_trait>> + where Self: 'async_trait, + 'life0: 'async_trait; + fn get_rediscover_albums<'life0, 'life1, 'async_trait>( + &'life0 self, + parent_id: Option<&'life1 str>, + limit: Option<usize>, + ) -> Pin<Box<dyn Future<Output = Result<Vec<MediaItem>, RepoError>> + Send + 'async_trait>> + where Self: 'async_trait, + 'life0: 'async_trait, + 'life1: 'async_trait; + fn get_resume_movies<'life0, 'async_trait>( + &'life0 self, + limit: Option<usize>, + ) -> Pin<Box<dyn Future<Output = Result<Vec<MediaItem>, RepoError>> + Send + 'async_trait>> + where Self: 'async_trait, + 'life0: 'async_trait; + fn get_genres<'life0, 'life1, 'async_trait>( + &'life0 self, + parent_id: Option<&'life1 str>, + ) -> Pin<Box<dyn Future<Output = Result<Vec<Genre>, RepoError>> + Send + 'async_trait>> + where Self: 'async_trait, + 'life0: 'async_trait, + 'life1: 'async_trait; + fn search<'life0, 'life1, 'async_trait>( + &'life0 self, + query: &'life1 str, + options: Option<SearchOptions>, + ) -> Pin<Box<dyn Future<Output = Result<SearchResult, RepoError>> + Send + 'async_trait>> + where Self: 'async_trait, + 'life0: 'async_trait, + 'life1: 'async_trait; + fn get_playback_info<'life0, 'life1, 'async_trait>( + &'life0 self, + item_id: &'life1 str, + ) -> Pin<Box<dyn Future<Output = Result<PlaybackInfo, RepoError>> + Send + 'async_trait>> + where Self: 'async_trait, + 'life0: 'async_trait, + 'life1: 'async_trait; + fn get_audio_stream_url<'life0, 'life1, 'async_trait>( + &'life0 self, + item_id: &'life1 str, + ) -> Pin<Box<dyn Future<Output = Result<String, RepoError>> + Send + 'async_trait>> + where Self: 'async_trait, + 'life0: 'async_trait, + 'life1: 'async_trait; + fn get_audio_only_stream_url_for_video<'life0, 'life1, 'life2, 'async_trait>( + &'life0 self, + item_id: &'life1 str, + media_source_id: Option<&'life2 str>, + start_time_seconds: Option<f64>, + audio_stream_index: Option<i32>, + ) -> Pin<Box<dyn Future<Output = Result<String, RepoError>> + Send + 'async_trait>> + where Self: 'async_trait, + 'life0: 'async_trait, + 'life1: 'async_trait, + 'life2: 'async_trait; + fn get_live_tv_channels<'life0, 'async_trait>( + &'life0 self, + ) -> Pin<Box<dyn Future<Output = Result<Vec<MediaItem>, RepoError>> + Send + 'async_trait>> + where Self: 'async_trait, + 'life0: 'async_trait; + fn get_channels<'life0, 'async_trait>( + &'life0 self, + ) -> Pin<Box<dyn Future<Output = Result<SearchResult, RepoError>> + Send + 'async_trait>> + where Self: 'async_trait, + 'life0: 'async_trait; + fn open_live_stream<'life0, 'life1, 'async_trait>( + &'life0 self, + item_id: &'life1 str, + ) -> Pin<Box<dyn Future<Output = Result<LiveStreamInfo, RepoError>> + Send + 'async_trait>> + where Self: 'async_trait, + 'life0: 'async_trait, + 'life1: 'async_trait; + fn report_playback_start<'life0, 'life1, 'async_trait>( + &'life0 self, + item_id: &'life1 str, + position_ticks: i64, + ) -> Pin<Box<dyn Future<Output = Result<(), RepoError>> + Send + 'async_trait>> + where Self: 'async_trait, + 'life0: 'async_trait, + 'life1: 'async_trait; + fn report_playback_progress<'life0, 'life1, 'async_trait>( + &'life0 self, + item_id: &'life1 str, + position_ticks: i64, + ) -> Pin<Box<dyn Future<Output = Result<(), RepoError>> + Send + 'async_trait>> + where Self: 'async_trait, + 'life0: 'async_trait, + 'life1: 'async_trait; + fn report_playback_stopped<'life0, 'life1, 'async_trait>( + &'life0 self, + item_id: &'life1 str, + position_ticks: i64, + ) -> Pin<Box<dyn Future<Output = Result<(), RepoError>> + Send + 'async_trait>> + where Self: 'async_trait, + 'life0: 'async_trait, + 'life1: 'async_trait; + fn get_image_url( + &self, + item_id: &str, + image_type: ImageType, + options: Option<ImageOptions>, + ) -> String; + fn get_subtitle_url( + &self, + item_id: &str, + media_source_id: &str, + stream_index: i32, + format: &str, + ) -> String; + fn get_video_download_url( + &self, + item_id: &str, + quality: &str, + media_source_id: Option<&str>, + source_audio_codec: Option<&str>, + ) -> String; + fn mark_favorite<'life0, 'life1, 'async_trait>( + &'life0 self, + item_id: &'life1 str, + ) -> Pin<Box<dyn Future<Output = Result<(), RepoError>> + Send + 'async_trait>> + where Self: 'async_trait, + 'life0: 'async_trait, + 'life1: 'async_trait; + fn unmark_favorite<'life0, 'life1, 'async_trait>( + &'life0 self, + item_id: &'life1 str, + ) -> Pin<Box<dyn Future<Output = Result<(), RepoError>> + Send + 'async_trait>> + where Self: 'async_trait, + 'life0: 'async_trait, + 'life1: 'async_trait; + fn get_favorites<'life0, 'async_trait>( + &'life0 self, + scope: SearchScope, + options: Option<GetItemsOptions>, + ) -> Pin<Box<dyn Future<Output = Result<SearchResult, RepoError>> + Send + 'async_trait>> + where Self: 'async_trait, + 'life0: 'async_trait; + fn clear_watch_history<'life0, 'life1, 'async_trait>( + &'life0 self, + item_id: &'life1 str, + ) -> Pin<Box<dyn Future<Output = Result<(), RepoError>> + Send + 'async_trait>> + where Self: 'async_trait, + 'life0: 'async_trait, + 'life1: 'async_trait; + fn mark_played<'life0, 'life1, 'async_trait>( + &'life0 self, + item_id: &'life1 str, + ) -> Pin<Box<dyn Future<Output = Result<(), RepoError>> + Send + 'async_trait>> + where Self: 'async_trait, + 'life0: 'async_trait, + 'life1: 'async_trait; + fn get_person<'life0, 'life1, 'async_trait>( + &'life0 self, + person_id: &'life1 str, + ) -> Pin<Box<dyn Future<Output = Result<MediaItem, RepoError>> + Send + 'async_trait>> + where Self: 'async_trait, + 'life0: 'async_trait, + 'life1: 'async_trait; + fn get_items_by_person<'life0, 'life1, 'async_trait>( + &'life0 self, + person_id: &'life1 str, + options: Option<GetItemsOptions>, + ) -> Pin<Box<dyn Future<Output = Result<SearchResult, RepoError>> + Send + 'async_trait>> + where Self: 'async_trait, + 'life0: 'async_trait, + 'life1: 'async_trait; + fn get_similar_items<'life0, 'life1, 'async_trait>( + &'life0 self, + item_id: &'life1 str, + limit: Option<usize>, + ) -> Pin<Box<dyn Future<Output = Result<SearchResult, RepoError>> + Send + 'async_trait>> + where Self: 'async_trait, + 'life0: 'async_trait, + 'life1: 'async_trait; + fn create_playlist<'life0, 'life1, 'life2, 'async_trait>( + &'life0 self, + name: &'life1 str, + item_ids: &'life2 [String], + ) -> Pin<Box<dyn Future<Output = Result<PlaylistCreatedResult, RepoError>> + Send + 'async_trait>> + where Self: 'async_trait, + 'life0: 'async_trait, + 'life1: 'async_trait, + 'life2: 'async_trait; + fn delete_playlist<'life0, 'life1, 'async_trait>( + &'life0 self, + playlist_id: &'life1 str, + ) -> Pin<Box<dyn Future<Output = Result<(), RepoError>> + Send + 'async_trait>> + where Self: 'async_trait, + 'life0: 'async_trait, + 'life1: 'async_trait; + fn rename_playlist<'life0, 'life1, 'life2, 'async_trait>( + &'life0 self, + playlist_id: &'life1 str, + name: &'life2 str, + ) -> Pin<Box<dyn Future<Output = Result<(), RepoError>> + Send + 'async_trait>> + where Self: 'async_trait, + 'life0: 'async_trait, + 'life1: 'async_trait, + 'life2: 'async_trait; + fn get_playlist_items<'life0, 'life1, 'async_trait>( + &'life0 self, + playlist_id: &'life1 str, + ) -> Pin<Box<dyn Future<Output = Result<Vec<PlaylistEntry>, RepoError>> + Send + 'async_trait>> + where Self: 'async_trait, + 'life0: 'async_trait, + 'life1: 'async_trait; + fn add_to_playlist<'life0, 'life1, 'life2, 'async_trait>( + &'life0 self, + playlist_id: &'life1 str, + item_ids: &'life2 [String], + ) -> Pin<Box<dyn Future<Output = Result<(), RepoError>> + Send + 'async_trait>> + where Self: 'async_trait, + 'life0: 'async_trait, + 'life1: 'async_trait, + 'life2: 'async_trait; + fn remove_from_playlist<'life0, 'life1, 'life2, 'async_trait>( + &'life0 self, + playlist_id: &'life1 str, + entry_ids: &'life2 [String], + ) -> Pin<Box<dyn Future<Output = Result<(), RepoError>> + Send + 'async_trait>> + where Self: 'async_trait, + 'life0: 'async_trait, + 'life1: 'async_trait, + 'life2: 'async_trait; + fn move_playlist_item<'life0, 'life1, 'life2, 'async_trait>( + &'life0 self, + playlist_id: &'life1 str, + item_id: &'life2 str, + new_index: u32, + ) -> Pin<Box<dyn Future<Output = Result<(), RepoError>> + Send + 'async_trait>> + where Self: 'async_trait, + 'life0: 'async_trait, + 'life1: 'async_trait, + 'life2: 'async_trait; +
}
Expand description

Repository trait for media access (online, offline, or hybrid)

+

@req: UR-002 - Access media when online or offline +@req: UR-007 - Navigate media in library +@req: UR-008 - Search media across libraries +@req: IR-010 - Jellyfin API client for library browsing +@req: DR-012 - Local database for media metadata cache +@req: DR-013 - Repository pattern for online/offline data access

+

Required Methods§

Source

fn get_libraries<'life0, 'async_trait>( + &'life0 self, +) -> Pin<Box<dyn Future<Output = Result<Vec<Library>, RepoError>> + Send + 'async_trait>>
where + Self: 'async_trait, + 'life0: 'async_trait,

Get all libraries

+

@req: UR-007 - Navigate media in library +@req: JA-003 - Get user library views

+
Source

fn get_items<'life0, 'life1, 'async_trait>( + &'life0 self, + parent_id: &'life1 str, + options: Option<GetItemsOptions>, +) -> Pin<Box<dyn Future<Output = Result<SearchResult, RepoError>> + Send + 'async_trait>>
where + Self: 'async_trait, + 'life0: 'async_trait, + 'life1: 'async_trait,

Get items in a library or parent

+

@req: UR-007 - Navigate media in library +@req: JA-004 - Get library items (paginated)

+
Source

fn get_item<'life0, 'life1, 'async_trait>( + &'life0 self, + item_id: &'life1 str, +) -> Pin<Box<dyn Future<Output = Result<MediaItem, RepoError>> + Send + 'async_trait>>
where + Self: 'async_trait, + 'life0: 'async_trait, + 'life1: 'async_trait,

Get a single item by ID

+

@req: UR-007 - Navigate media in library +@req: JA-005 - Get item details and metadata

+
Source

fn get_latest_items<'life0, 'life1, 'async_trait>( + &'life0 self, + parent_id: &'life1 str, + limit: Option<usize>, +) -> Pin<Box<dyn Future<Output = Result<Vec<MediaItem>, RepoError>> + Send + 'async_trait>>
where + Self: 'async_trait, + 'life0: 'async_trait, + 'life1: 'async_trait,

Get latest items in a library

+

@req: UR-024 - View recently added content on server +@req: JA-016 - Get recently added items

+
Source

fn get_resume_items<'life0, 'life1, 'async_trait>( + &'life0 self, + parent_id: Option<&'life1 str>, + limit: Option<usize>, +) -> Pin<Box<dyn Future<Output = Result<Vec<MediaItem>, RepoError>> + Send + 'async_trait>>
where + Self: 'async_trait, + 'life0: 'async_trait, + 'life1: 'async_trait,

Get resume items (continue watching/listening)

+

@req: UR-019 - Resume playback from where you left off +@req: UR-023 - View “Next Up” / Continue Watching on home screen +@req: JA-015 - Get “Continue Watching” items

+
Source

fn get_next_up_episodes<'life0, 'life1, 'async_trait>( + &'life0 self, + series_id: Option<&'life1 str>, + limit: Option<usize>, +) -> Pin<Box<dyn Future<Output = Result<Vec<MediaItem>, RepoError>> + Send + 'async_trait>>
where + Self: 'async_trait, + 'life0: 'async_trait, + 'life1: 'async_trait,

Get next up episodes

+

@req: UR-023 - View “Next Up” / Continue Watching; auto-play next episode +@req: JA-014 - Get “Next Up” items

+
Source

fn get_recently_played_audio<'life0, 'async_trait>( + &'life0 self, + limit: Option<usize>, +) -> Pin<Box<dyn Future<Output = Result<Vec<MediaItem>, RepoError>> + Send + 'async_trait>>
where + Self: 'async_trait, + 'life0: 'async_trait,

Get recently played audio

+
Source

fn get_rediscover_albums<'life0, 'life1, 'async_trait>( + &'life0 self, + parent_id: Option<&'life1 str>, + limit: Option<usize>, +) -> Pin<Box<dyn Future<Output = Result<Vec<MediaItem>, RepoError>> + Send + 'async_trait>>
where + Self: 'async_trait, + 'life0: 'async_trait, + 'life1: 'async_trait,

Get albums the user has played, but not recently (“rediscover” / haven’t +listened to in a while). Returns albums sorted by least-recently played +first, optionally restricted to a parent library.

+
Source

fn get_resume_movies<'life0, 'async_trait>( + &'life0 self, + limit: Option<usize>, +) -> Pin<Box<dyn Future<Output = Result<Vec<MediaItem>, RepoError>> + Send + 'async_trait>>
where + Self: 'async_trait, + 'life0: 'async_trait,

Get resume movies

+
Source

fn get_genres<'life0, 'life1, 'async_trait>( + &'life0 self, + parent_id: Option<&'life1 str>, +) -> Pin<Box<dyn Future<Output = Result<Vec<Genre>, RepoError>> + Send + 'async_trait>>
where + Self: 'async_trait, + 'life0: 'async_trait, + 'life1: 'async_trait,

Get genres

+
Source

fn search<'life0, 'life1, 'async_trait>( + &'life0 self, + query: &'life1 str, + options: Option<SearchOptions>, +) -> Pin<Box<dyn Future<Output = Result<SearchResult, RepoError>> + Send + 'async_trait>>
where + Self: 'async_trait, + 'life0: 'async_trait, + 'life1: 'async_trait,

Search for items

+

@req: UR-008 - Search media across libraries +@req: JA-006 - Search across libraries

+
Source

fn get_playback_info<'life0, 'life1, 'async_trait>( + &'life0 self, + item_id: &'life1 str, +) -> Pin<Box<dyn Future<Output = Result<PlaybackInfo, RepoError>> + Send + 'async_trait>>
where + Self: 'async_trait, + 'life0: 'async_trait, + 'life1: 'async_trait,

Get playback info for streaming

+

@req: UR-003 - Play videos +@req: UR-004 - Play audio uninterrupted +@req: JA-007 - Get playback info and stream URL

+
Source

fn get_audio_stream_url<'life0, 'life1, 'async_trait>( + &'life0 self, + item_id: &'life1 str, +) -> Pin<Box<dyn Future<Output = Result<String, RepoError>> + Send + 'async_trait>>
where + Self: 'async_trait, + 'life0: 'async_trait, + 'life1: 'async_trait,

Get audio stream URL for a track

+

@req: UR-004 - Play audio uninterrupted +@req: JA-007 - Get playback info and stream URL

+
Source

fn get_audio_only_stream_url_for_video<'life0, 'life1, 'life2, 'async_trait>( + &'life0 self, + item_id: &'life1 str, + media_source_id: Option<&'life2 str>, + start_time_seconds: Option<f64>, + audio_stream_index: Option<i32>, +) -> Pin<Box<dyn Future<Output = Result<String, RepoError>> + Send + 'async_trait>>
where + Self: 'async_trait, + 'life0: 'async_trait, + 'life1: 'async_trait, + 'life2: 'async_trait,

Get an audio-only stream URL for a video item (background-audio handoff).

+

Used when autoplay advances to the next episode while the app is playing a +video in audio-only mode in the background: the backend needs the next +episode’s audio-only URL without any frontend round-trip. Online-only; +offline/cache repositories return an error.

+

TRACES: UR-040 | JA-032

+
Source

fn get_live_tv_channels<'life0, 'async_trait>( + &'life0 self, +) -> Pin<Box<dyn Future<Output = Result<Vec<MediaItem>, RepoError>> + Send + 'async_trait>>
where + Self: 'async_trait, + 'life0: 'async_trait,

Get Live TV channels (broadcast / IPTV) for browsing.

+
Source

fn get_channels<'life0, 'async_trait>( + &'life0 self, +) -> Pin<Box<dyn Future<Output = Result<SearchResult, RepoError>> + Send + 'async_trait>>
where + Self: 'async_trait, + 'life0: 'async_trait,

Get the root list of plugin “Channels” (Jellyfin Channels feature). +Drill-down into a channel reuses get_items(channel_id, ...).

+
Source

fn open_live_stream<'life0, 'life1, 'async_trait>( + &'life0 self, + item_id: &'life1 str, +) -> Pin<Box<dyn Future<Output = Result<LiveStreamInfo, RepoError>> + Send + 'async_trait>>
where + Self: 'async_trait, + 'life0: 'async_trait, + 'life1: 'async_trait,

Open a live stream (Live TV channel or live channel item) for playback.

+

Returns the server transcoding URL plus identifiers needed to manage the +stream. Required before a live channel can be played over HLS.

+
Source

fn report_playback_start<'life0, 'life1, 'async_trait>( + &'life0 self, + item_id: &'life1 str, + position_ticks: i64, +) -> Pin<Box<dyn Future<Output = Result<(), RepoError>> + Send + 'async_trait>>
where + Self: 'async_trait, + 'life0: 'async_trait, + 'life1: 'async_trait,

Report playback start

+

@req: UR-025 - Sync watch history and progress back to Jellyfin +@req: JA-010 - Report playback start

+
Source

fn report_playback_progress<'life0, 'life1, 'async_trait>( + &'life0 self, + item_id: &'life1 str, + position_ticks: i64, +) -> Pin<Box<dyn Future<Output = Result<(), RepoError>> + Send + 'async_trait>>
where + Self: 'async_trait, + 'life0: 'async_trait, + 'life1: 'async_trait,

Report playback progress

+

@req: UR-025 - Sync watch history and progress back to Jellyfin +@req: JA-011 - Report playback progress (periodic)

+
Source

fn report_playback_stopped<'life0, 'life1, 'async_trait>( + &'life0 self, + item_id: &'life1 str, + position_ticks: i64, +) -> Pin<Box<dyn Future<Output = Result<(), RepoError>> + Send + 'async_trait>>
where + Self: 'async_trait, + 'life0: 'async_trait, + 'life1: 'async_trait,

Report playback stopped

+

@req: UR-025 - Sync watch history and progress back to Jellyfin +@req: JA-012 - Report playback stopped

+
Source

fn get_image_url( + &self, + item_id: &str, + image_type: ImageType, + options: Option<ImageOptions>, +) -> String

Get image URL (synchronous - just constructs URL)

+
Source

fn get_subtitle_url( + &self, + item_id: &str, + media_source_id: &str, + stream_index: i32, + format: &str, +) -> String

Get subtitle URL (synchronous - just constructs URL) +Called by frontend via Tauri invoke (getSubtitleUrl in VideoPlayer.svelte)

+
Source

fn get_video_download_url( + &self, + item_id: &str, + quality: &str, + media_source_id: Option<&str>, + source_audio_codec: Option<&str>, +) -> String

Build the URL a video download is fetched from. Synchronous — it only +constructs a URL, so it stays testable without a server. Reach it through +resolve_video_download_url rather than calling it directly.

+

source_audio_codec is the codec of the audio track the server would +serve (see served_audio_codec); None when it is not known. At +original quality it decides whether the file can be copied byte-for-byte +or has to have its audio re-encoded on the way down — a downloaded file is +played back with no server in reach, so it has to be decodable here.

+

TRACES: UR-071 | DR-171

+
Source

fn mark_favorite<'life0, 'life1, 'async_trait>( + &'life0 self, + item_id: &'life1 str, +) -> Pin<Box<dyn Future<Output = Result<(), RepoError>> + Send + 'async_trait>>
where + Self: 'async_trait, + 'life0: 'async_trait, + 'life1: 'async_trait,

Mark item as favorite

+
Source

fn unmark_favorite<'life0, 'life1, 'async_trait>( + &'life0 self, + item_id: &'life1 str, +) -> Pin<Box<dyn Future<Output = Result<(), RepoError>> + Send + 'async_trait>>
where + Self: 'async_trait, + 'life0: 'async_trait, + 'life1: 'async_trait,

Unmark item as favorite

+
Source

fn get_favorites<'life0, 'async_trait>( + &'life0 self, + scope: SearchScope, + options: Option<GetItemsOptions>, +) -> Pin<Box<dyn Future<Output = Result<SearchResult, RepoError>> + Send + 'async_trait>>
where + Self: 'async_trait, + 'life0: 'async_trait,

Everything the viewer has favourited, across every library.

+

Separate from get_items because favourites span libraries and +get_items is ParentId-shaped. scope is the opaque enum the +frontend sends; this layer expands it to item types (DR-063) so no +Jellyfin taxonomy is needed on the other side of the IPC boundary.

+

TRACES: UR-067 | DR-115, JA-033 | UT-100, UT-101

+
Source

fn clear_watch_history<'life0, 'life1, 'async_trait>( + &'life0 self, + item_id: &'life1 str, +) -> Pin<Box<dyn Future<Output = Result<(), RepoError>> + Send + 'async_trait>>
where + Self: 'async_trait, + 'life0: 'async_trait, + 'life1: 'async_trait,

Erase the viewer’s watch history for an item: clear its played flag and +its resume position. On a container (series, season) this applies to +everything inside it, so a series is returned to “never watched” and +reopens on its premiere.

+

TRACES: UR-064 | DR-106

+
Source

fn mark_played<'life0, 'life1, 'async_trait>( + &'life0 self, + item_id: &'life1 str, +) -> Pin<Box<dyn Future<Output = Result<(), RepoError>> + Send + 'async_trait>>
where + Self: 'async_trait, + 'life0: 'async_trait, + 'life1: 'async_trait,

Mark an item played — the inverse of clear_watch_history. Needed by the +sync-queue drain, which replays mark_played rows queued while the +server was unreachable; reporting a stop at a made-up position was the +previous stand-in and does not set the played flag reliably.

+

TRACES: UR-025 | DR-131 | JA-035

+
Source

fn get_person<'life0, 'life1, 'async_trait>( + &'life0 self, + person_id: &'life1 str, +) -> Pin<Box<dyn Future<Output = Result<MediaItem, RepoError>> + Send + 'async_trait>>
where + Self: 'async_trait, + 'life0: 'async_trait, + 'life1: 'async_trait,

Get person details

+
Source

fn get_items_by_person<'life0, 'life1, 'async_trait>( + &'life0 self, + person_id: &'life1 str, + options: Option<GetItemsOptions>, +) -> Pin<Box<dyn Future<Output = Result<SearchResult, RepoError>> + Send + 'async_trait>>
where + Self: 'async_trait, + 'life0: 'async_trait, + 'life1: 'async_trait,

Get items by person (filmography)

+
Source

fn get_similar_items<'life0, 'life1, 'async_trait>( + &'life0 self, + item_id: &'life1 str, + limit: Option<usize>, +) -> Pin<Box<dyn Future<Output = Result<SearchResult, RepoError>> + Send + 'async_trait>>
where + Self: 'async_trait, + 'life0: 'async_trait, + 'life1: 'async_trait,

Get similar/related items for a movie or show

+

@req: UR-009 - Discover similar content based on current item

+
Source

fn create_playlist<'life0, 'life1, 'life2, 'async_trait>( + &'life0 self, + name: &'life1 str, + item_ids: &'life2 [String], +) -> Pin<Box<dyn Future<Output = Result<PlaylistCreatedResult, RepoError>> + Send + 'async_trait>>
where + Self: 'async_trait, + 'life0: 'async_trait, + 'life1: 'async_trait, + 'life2: 'async_trait,

Create a new playlist on the server

+

@req: UR-014 - Make and edit playlists of music that sync back to Jellyfin +@req: JA-019 - Get/create/update playlists

+
Source

fn delete_playlist<'life0, 'life1, 'async_trait>( + &'life0 self, + playlist_id: &'life1 str, +) -> Pin<Box<dyn Future<Output = Result<(), RepoError>> + Send + 'async_trait>>
where + Self: 'async_trait, + 'life0: 'async_trait, + 'life1: 'async_trait,

Delete a playlist

+

@req: UR-014 - Make and edit playlists of music that sync back to Jellyfin +@req: JA-019 - Get/create/update playlists

+
Source

fn rename_playlist<'life0, 'life1, 'life2, 'async_trait>( + &'life0 self, + playlist_id: &'life1 str, + name: &'life2 str, +) -> Pin<Box<dyn Future<Output = Result<(), RepoError>> + Send + 'async_trait>>
where + Self: 'async_trait, + 'life0: 'async_trait, + 'life1: 'async_trait, + 'life2: 'async_trait,

Rename a playlist

+

@req: UR-014 - Make and edit playlists of music that sync back to Jellyfin +@req: JA-019 - Get/create/update playlists

+
Source

fn get_playlist_items<'life0, 'life1, 'async_trait>( + &'life0 self, + playlist_id: &'life1 str, +) -> Pin<Box<dyn Future<Output = Result<Vec<PlaylistEntry>, RepoError>> + Send + 'async_trait>>
where + Self: 'async_trait, + 'life0: 'async_trait, + 'life1: 'async_trait,

Get playlist items with PlaylistItemId (needed for remove/reorder)

+

@req: UR-014 - Make and edit playlists of music that sync back to Jellyfin +@req: JA-019 - Get/create/update playlists

+
Source

fn add_to_playlist<'life0, 'life1, 'life2, 'async_trait>( + &'life0 self, + playlist_id: &'life1 str, + item_ids: &'life2 [String], +) -> Pin<Box<dyn Future<Output = Result<(), RepoError>> + Send + 'async_trait>>
where + Self: 'async_trait, + 'life0: 'async_trait, + 'life1: 'async_trait, + 'life2: 'async_trait,

Add items to a playlist

+

@req: UR-014 - Make and edit playlists of music that sync back to Jellyfin +@req: JA-020 - Add/remove items from playlist

+
Source

fn remove_from_playlist<'life0, 'life1, 'life2, 'async_trait>( + &'life0 self, + playlist_id: &'life1 str, + entry_ids: &'life2 [String], +) -> Pin<Box<dyn Future<Output = Result<(), RepoError>> + Send + 'async_trait>>
where + Self: 'async_trait, + 'life0: 'async_trait, + 'life1: 'async_trait, + 'life2: 'async_trait,

Remove items from a playlist using entry IDs (PlaylistItemId, NOT media item IDs)

+

@req: UR-014 - Make and edit playlists of music that sync back to Jellyfin +@req: JA-020 - Add/remove items from playlist

+
Source

fn move_playlist_item<'life0, 'life1, 'life2, 'async_trait>( + &'life0 self, + playlist_id: &'life1 str, + item_id: &'life2 str, + new_index: u32, +) -> Pin<Box<dyn Future<Output = Result<(), RepoError>> + Send + 'async_trait>>
where + Self: 'async_trait, + 'life0: 'async_trait, + 'life1: 'async_trait, + 'life2: 'async_trait,

Move a playlist item to a new position

+

@req: UR-014 - Make and edit playlists of music that sync back to Jellyfin +@req: JA-020 - Add/remove items from playlist

+

Implementors§

\ No newline at end of file diff --git a/api/jellytau_lib/repository/types/enum.ImageType.html b/api/jellytau_lib/repository/types/enum.ImageType.html new file mode 100644 index 00000000..470adef9 --- /dev/null +++ b/api/jellytau_lib/repository/types/enum.ImageType.html @@ -0,0 +1,56 @@ +ImageType in jellytau_lib::repository::types - RustSkip to main content

ImageType

Enum ImageType 

Source
pub enum ImageType {
+    Primary,
+    Backdrop,
+    Banner,
+    Thumb,
+    Logo,
+}
Expand description

Image type

+

Variants§

§

Primary

§

Backdrop

§

Banner

§

Thumb

Implementations§

Source§

impl ImageType

Source

pub fn as_str(&self) -> &str

Trait Implementations§

Source§

impl Clone for ImageType

Source§

fn clone(&self) -> ImageType

Returns a duplicate of the value. Read more
1.0.0 (const: unstable) · Source§

fn clone_from(&mut self, source: &Self)

Performs copy-assignment from source. Read more
Source§

impl Debug for ImageType

Source§

fn fmt(&self, f: &mut Formatter<'_>) -> Result

Formats the value using the given formatter. Read more
Source§

impl<'de> Deserialize<'de> for ImageType

Source§

fn deserialize<__D>(__deserializer: __D) -> Result<Self, __D::Error>
where + __D: Deserializer<'de>,

Deserialize this value from the given Serde deserializer. Read more
Source§

impl NamedType for ImageType

Source§

fn sid() -> SpectaID

Source§

fn named_data_type( + type_map: &mut TypeCollection, + generics: &[DataType], +) -> NamedDataType

this is equivalent to [Type::inline] but returns a [NamedDataType] instead.
Source§

fn definition_named_data_type(type_map: &mut TypeCollection) -> NamedDataType

this is equivalent to [Type::definition] but returns a [NamedDataType] instead.
Source§

impl Serialize for ImageType

Source§

fn serialize<__S>(&self, __serializer: __S) -> Result<__S::Ok, __S::Error>
where + __S: Serializer,

Serialize this value into the given Serde serializer. Read more
Source§

impl Type for ImageType

Source§

fn inline(type_map: &mut TypeCollection, generics: Generics<'_>) -> DataType

Returns the definition of a type using the provided generics. Read more
Source§

fn reference(type_map: &mut TypeCollection, generics: &[DataType]) -> Reference

Generates a datatype corresponding to a reference to this type, +as determined by its category. Getting a reference to a type implies that +it should belong in the type map (since it has to be referenced from somewhere), +so the output of definition will be put into the type map.

Auto Trait Implementations§

Blanket Implementations§

Source§

impl<T> Any for T
where + T: 'static + ?Sized,

Source§

fn type_id(&self) -> TypeId

Gets the TypeId of self. Read more
Source§

impl<T> Borrow<T> for T
where + T: ?Sized,

Source§

fn borrow(&self) -> &T

Immutably borrows from an owned value. Read more
Source§

impl<T> BorrowMut<T> for T
where + T: ?Sized,

Source§

fn borrow_mut(&mut self) -> &mut T

Mutably borrows from an owned value. Read more
Source§

impl<T> CloneToUninit for T
where + T: Clone,

Source§

unsafe fn clone_to_uninit(&self, dest: *mut u8)

🔬This is a nightly-only experimental API. (clone_to_uninit)
Performs copy-assignment from self to dest. Read more
§

impl<'de, D, R> CommandArg<'de, R> for D
where + D: Deserialize<'de>, + R: Runtime,

§

fn from_command(command: CommandItem<'de, R>) -> Result<D, InvokeError>

Derives an instance of Self from the [CommandItem]. Read more
Source§

impl<T> From<T> for T

Source§

fn from(t: T) -> T

Returns the argument unchanged.

+
§

impl<T> FunctionArg for T
where + T: Type,

§

fn to_datatype(type_map: &mut TypeCollection) -> Option<DataType>

Gets the type of an argument as a [DataType]. Read more
§

impl<T> Instrument for T

§

fn instrument(self, span: Span) -> Instrumented<Self>

Instruments this type with the provided [Span], returning an +Instrumented wrapper. Read more
§

fn in_current_span(self) -> Instrumented<Self>

Instruments this type with the current Span, returning an +Instrumented wrapper. Read more
Source§

impl<T, U> Into<U> for T
where + U: From<T>,

Source§

fn into(self) -> U

Calls U::from(self).

+

That is, this conversion is whatever the implementation of +From<T> for U chooses to do.

+
§

impl<T> IpcResponse for T
where + T: Serialize,

§

fn body(self) -> Result<InvokeResponseBody, Error>

Resolve the IPC response body.
§

impl<T> PolicyExt for T
where + T: ?Sized,

§

fn and<P, B, E>(self, other: P) -> And<T, P>
where + T: Policy<B, E>, + P: Policy<B, E>,

Create a new Policy that returns [Action::Follow] only if self and other return +Action::Follow. Read more
§

fn or<P, B, E>(self, other: P) -> Or<T, P>
where + T: Policy<B, E>, + P: Policy<B, E>,

Create a new Policy that returns [Action::Follow] if either self or other returns +Action::Follow. Read more
Source§

impl<T> Same for T

Source§

type Output = T

Should always be Self
§

impl<T> ScopeObject for T
where + T: Send + Sync + Debug + DeserializeOwned + 'static,

§

type Error = Error

The error type.
§

fn deserialize<R>( + _app: &AppHandle<R>, + raw: Value, +) -> Result<T, <T as ScopeObject>::Error>
where + R: Runtime,

Deserialize the raw scope value.
Source§

impl<T> Serialize for T
where + T: Serialize + ?Sized,

Source§

fn erased_serialize(&self, serializer: &mut dyn Serializer) -> Result<(), Error>

Source§

fn do_erased_serialize( + &self, + serializer: &mut dyn Serializer, +) -> Result<(), ErrorImpl>

Source§

impl<T> ToOwned for T
where + T: Clone,

Source§

type Owned = T

The resulting type after obtaining ownership.
Source§

fn to_owned(&self) -> T

Creates owned data from borrowed data, usually by cloning. Read more
Source§

fn clone_into(&self, target: &mut T)

Uses borrowed data to replace owned data, usually by cloning. Read more
Source§

impl<T, U> TryFrom<U> for T
where + U: Into<T>,

Source§

type Error = Infallible

The type returned in the event of a conversion error.
Source§

fn try_from(value: U) -> Result<T, <T as TryFrom<U>>::Error>

Performs the conversion.
Source§

impl<T, U> TryInto<U> for T
where + U: TryFrom<T>,

Source§

type Error = <U as TryFrom<T>>::Error

The type returned in the event of a conversion error.
Source§

fn try_into(self) -> Result<U, <U as TryFrom<T>>::Error>

Performs the conversion.
§

impl<V, T> VZip<V> for T
where + V: MultiLane<T>,

§

fn vzip(self) -> V

§

impl<T> WithSubscriber for T

§

fn with_subscriber<S>(self, subscriber: S) -> WithDispatch<Self>
where + S: Into<Dispatch>,

Attaches the provided Subscriber to this type, returning a +[WithDispatch] wrapper. Read more
§

fn with_current_subscriber(self) -> WithDispatch<Self>

Attaches the current default Subscriber to this type, returning a +[WithDispatch] wrapper. Read more
Source§

impl<T> DeserializeOwned for T
where + T: for<'de> Deserialize<'de>,

§

impl<T> UserEvent for T
where + T: Debug + Clone + Send + 'static,

\ No newline at end of file diff --git a/api/jellytau_lib/repository/types/enum.RepoError.html b/api/jellytau_lib/repository/types/enum.RepoError.html new file mode 100644 index 00000000..dbcd8dfd --- /dev/null +++ b/api/jellytau_lib/repository/types/enum.RepoError.html @@ -0,0 +1,72 @@ +RepoError in jellytau_lib::repository::types - RustSkip to main content

RepoError

Enum RepoError 

Source
pub enum RepoError {
+    Network {
+        message: String,
+    },
+    Authentication {
+        message: String,
+    },
+    NotFound {
+        message: String,
+    },
+    Server {
+        message: String,
+    },
+    Database {
+        message: String,
+    },
+    Offline,
+}
Expand description

Error types for repository operations

+

Variants§

§

Network

Fields

§message: String
§

Authentication

Fields

§message: String
§

NotFound

Fields

§message: String
§

Server

Fields

§message: String
§

Database

Fields

§message: String
§

Offline

Trait Implementations§

Source§

impl Clone for RepoError

Source§

fn clone(&self) -> RepoError

Returns a duplicate of the value. Read more
1.0.0 (const: unstable) · Source§

fn clone_from(&mut self, source: &Self)

Performs copy-assignment from source. Read more
Source§

impl Debug for RepoError

Source§

fn fmt(&self, f: &mut Formatter<'_>) -> Result

Formats the value using the given formatter. Read more
Source§

impl<'de> Deserialize<'de> for RepoError

Source§

fn deserialize<__D>(__deserializer: __D) -> Result<Self, __D::Error>
where + __D: Deserializer<'de>,

Deserialize this value from the given Serde deserializer. Read more
Source§

impl Display for RepoError

Source§

fn fmt(&self, f: &mut Formatter<'_>) -> Result

Formats the value using the given formatter. Read more
Source§

impl Error for RepoError

1.30.0 · Source§

fn source(&self) -> Option<&(dyn Error + 'static)>

Returns the lower-level source of this error, if any. Read more
1.0.0 · Source§

fn description(&self) -> &str

👎Deprecated since 1.42.0:

use the Display impl or to_string()

+
1.0.0 · Source§

fn cause(&self) -> Option<&dyn Error>

👎Deprecated since 1.33.0:

replaced by Error::source, which can support downcasting

+
Source§

fn provide<'a>(&'a self, request: &mut Request<'a>)

🔬This is a nightly-only experimental API. (error_generic_member_access)
Provides type-based access to context intended for error reports. Read more
Source§

impl NamedType for RepoError

Source§

fn sid() -> SpectaID

Source§

fn named_data_type( + type_map: &mut TypeCollection, + generics: &[DataType], +) -> NamedDataType

this is equivalent to [Type::inline] but returns a [NamedDataType] instead.
Source§

fn definition_named_data_type(type_map: &mut TypeCollection) -> NamedDataType

this is equivalent to [Type::definition] but returns a [NamedDataType] instead.
Source§

impl Serialize for RepoError

Source§

fn serialize<__S>(&self, __serializer: __S) -> Result<__S::Ok, __S::Error>
where + __S: Serializer,

Serialize this value into the given Serde serializer. Read more
Source§

impl Type for RepoError

Source§

fn inline(type_map: &mut TypeCollection, generics: Generics<'_>) -> DataType

Returns the definition of a type using the provided generics. Read more
Source§

fn reference(type_map: &mut TypeCollection, generics: &[DataType]) -> Reference

Generates a datatype corresponding to a reference to this type, +as determined by its category. Getting a reference to a type implies that +it should belong in the type map (since it has to be referenced from somewhere), +so the output of definition will be put into the type map.
Source§

impl Flatten for RepoError

Auto Trait Implementations§

Blanket Implementations§

Source§

impl<T> Any for T
where + T: 'static + ?Sized,

Source§

fn type_id(&self) -> TypeId

Gets the TypeId of self. Read more
Source§

impl<T> Borrow<T> for T
where + T: ?Sized,

Source§

fn borrow(&self) -> &T

Immutably borrows from an owned value. Read more
Source§

impl<T> BorrowMut<T> for T
where + T: ?Sized,

Source§

fn borrow_mut(&mut self) -> &mut T

Mutably borrows from an owned value. Read more
Source§

impl<T> CloneToUninit for T
where + T: Clone,

Source§

unsafe fn clone_to_uninit(&self, dest: *mut u8)

🔬This is a nightly-only experimental API. (clone_to_uninit)
Performs copy-assignment from self to dest. Read more
§

impl<'de, D, R> CommandArg<'de, R> for D
where + D: Deserialize<'de>, + R: Runtime,

§

fn from_command(command: CommandItem<'de, R>) -> Result<D, InvokeError>

Derives an instance of Self from the [CommandItem]. Read more
Source§

impl<T> From<T> for T

Source§

fn from(t: T) -> T

Returns the argument unchanged.

+
§

impl<T> FunctionArg for T
where + T: Type,

§

fn to_datatype(type_map: &mut TypeCollection) -> Option<DataType>

Gets the type of an argument as a [DataType]. Read more
§

impl<T> Instrument for T

§

fn instrument(self, span: Span) -> Instrumented<Self>

Instruments this type with the provided [Span], returning an +Instrumented wrapper. Read more
§

fn in_current_span(self) -> Instrumented<Self>

Instruments this type with the current Span, returning an +Instrumented wrapper. Read more
Source§

impl<T, U> Into<U> for T
where + U: From<T>,

Source§

fn into(self) -> U

Calls U::from(self).

+

That is, this conversion is whatever the implementation of +From<T> for U chooses to do.

+
§

impl<T> IpcResponse for T
where + T: Serialize,

§

fn body(self) -> Result<InvokeResponseBody, Error>

Resolve the IPC response body.
§

impl<T> PolicyExt for T
where + T: ?Sized,

§

fn and<P, B, E>(self, other: P) -> And<T, P>
where + T: Policy<B, E>, + P: Policy<B, E>,

Create a new Policy that returns [Action::Follow] only if self and other return +Action::Follow. Read more
§

fn or<P, B, E>(self, other: P) -> Or<T, P>
where + T: Policy<B, E>, + P: Policy<B, E>,

Create a new Policy that returns [Action::Follow] if either self or other returns +Action::Follow. Read more
Source§

impl<T> Same for T

Source§

type Output = T

Should always be Self
§

impl<T> ScopeObject for T
where + T: Send + Sync + Debug + DeserializeOwned + 'static,

§

type Error = Error

The error type.
§

fn deserialize<R>( + _app: &AppHandle<R>, + raw: Value, +) -> Result<T, <T as ScopeObject>::Error>
where + R: Runtime,

Deserialize the raw scope value.
Source§

impl<T> Serialize for T
where + T: Serialize + ?Sized,

Source§

fn erased_serialize(&self, serializer: &mut dyn Serializer) -> Result<(), Error>

Source§

fn do_erased_serialize( + &self, + serializer: &mut dyn Serializer, +) -> Result<(), ErrorImpl>

Source§

impl<T> ToOwned for T
where + T: Clone,

Source§

type Owned = T

The resulting type after obtaining ownership.
Source§

fn to_owned(&self) -> T

Creates owned data from borrowed data, usually by cloning. Read more
Source§

fn clone_into(&self, target: &mut T)

Uses borrowed data to replace owned data, usually by cloning. Read more
Source§

impl<T> ToString for T
where + T: Display + ?Sized,

Source§

fn to_string(&self) -> String

Converts the given value to a String. Read more
§

impl<T> ToStringFallible for T
where + T: Display,

§

fn try_to_string(&self) -> Result<String, TryReserveError>

ToString::to_string, but without panic on OOM.

+
Source§

impl<T, U> TryFrom<U> for T
where + U: Into<T>,

Source§

type Error = Infallible

The type returned in the event of a conversion error.
Source§

fn try_from(value: U) -> Result<T, <T as TryFrom<U>>::Error>

Performs the conversion.
Source§

impl<T, U> TryInto<U> for T
where + U: TryFrom<T>,

Source§

type Error = <U as TryFrom<T>>::Error

The type returned in the event of a conversion error.
Source§

fn try_into(self) -> Result<U, <U as TryFrom<T>>::Error>

Performs the conversion.
§

impl<V, T> VZip<V> for T
where + V: MultiLane<T>,

§

fn vzip(self) -> V

§

impl<T> WithSubscriber for T

§

fn with_subscriber<S>(self, subscriber: S) -> WithDispatch<Self>
where + S: Into<Dispatch>,

Attaches the provided Subscriber to this type, returning a +[WithDispatch] wrapper. Read more
§

fn with_current_subscriber(self) -> WithDispatch<Self>

Attaches the current default Subscriber to this type, returning a +[WithDispatch] wrapper. Read more
Source§

impl<T> DeserializeOwned for T
where + T: for<'de> Deserialize<'de>,

§

impl<T> UserEvent for T
where + T: Debug + Clone + Send + 'static,

\ No newline at end of file diff --git a/api/jellytau_lib/repository/types/enum.SearchScope.html b/api/jellytau_lib/repository/types/enum.SearchScope.html new file mode 100644 index 00000000..df6bc43b --- /dev/null +++ b/api/jellytau_lib/repository/types/enum.SearchScope.html @@ -0,0 +1,83 @@ +SearchScope in jellytau_lib::repository::types - RustSkip to main content

SearchScope

Enum SearchScope 

Source
pub enum SearchScope {
+    All,
+    Music,
+    Movies,
+    Tv,
+}
Expand description

An opaque search scope the frontend selects; Rust owns what it means.

+

The expansion table below is Jellyfin domain vocabulary: it changes when +Jellyfin adds or renames an item type, never when the UI is redesigned. It +previously lived in the frontend (searchScope.ts), which is the boundary +leak documented in docs/specs/scoped-search-boundary.md. The frontend now +sends the enum and never names an item type in connection with search.

+

TRACES: UR-049 | DR-063

+

Variants§

§

All

§

Music

§

Movies

§

Tv

Implementations§

Source§

impl SearchScope

Source

pub fn item_types(self) -> Option<Vec<String>>

The Jellyfin item types this scope requests, or None for All.

+

All returns None rather than the union of every listed type on +purpose: an explicit includeItemTypes list filters out anything not +named in it, so a union would silently drop People, folders and any type +nobody enumerated. Callers must omit the filter entirely on None.

+

TRACES: UR-049 | DR-063

+
Source

pub fn for_collection_type(collection_type: &str) -> Option<SearchScope>

The scope a library of this Jellyfin CollectionType belongs to, or +None when its contents are not something favourites are browsed by.

+

Same reasoning as item_types: this table is Jellyfin vocabulary and +changes when Jellyfin renames a collection type, not when the library +page is redesigned — so it lives here rather than in the UI that renders +a per-library favourites tile.

+

All is never returned: it is the absence of a category, offered +alongside the libraries rather than derived from one.

+

TRACES: UR-075 | DR-175 | UT-161

+

Trait Implementations§

Source§

impl Clone for SearchScope

Source§

fn clone(&self) -> SearchScope

Returns a duplicate of the value. Read more
1.0.0 (const: unstable) · Source§

fn clone_from(&mut self, source: &Self)

Performs copy-assignment from source. Read more
Source§

impl Debug for SearchScope

Source§

fn fmt(&self, f: &mut Formatter<'_>) -> Result

Formats the value using the given formatter. Read more
Source§

impl<'de> Deserialize<'de> for SearchScope

Source§

fn deserialize<__D>(__deserializer: __D) -> Result<Self, __D::Error>
where + __D: Deserializer<'de>,

Deserialize this value from the given Serde deserializer. Read more
Source§

impl NamedType for SearchScope

Source§

fn sid() -> SpectaID

Source§

fn named_data_type( + type_map: &mut TypeCollection, + generics: &[DataType], +) -> NamedDataType

this is equivalent to [Type::inline] but returns a [NamedDataType] instead.
Source§

fn definition_named_data_type(type_map: &mut TypeCollection) -> NamedDataType

this is equivalent to [Type::definition] but returns a [NamedDataType] instead.
Source§

impl PartialEq for SearchScope

Source§

fn eq(&self, other: &SearchScope) -> bool

Tests for self and other values to be equal, and is used by ==.
1.0.0 (const: unstable) · Source§

fn ne(&self, other: &Rhs) -> bool

Tests for !=. The default implementation is almost always sufficient, +and should not be overridden without very good reason.
Source§

impl Serialize for SearchScope

Source§

fn serialize<__S>(&self, __serializer: __S) -> Result<__S::Ok, __S::Error>
where + __S: Serializer,

Serialize this value into the given Serde serializer. Read more
Source§

impl Type for SearchScope

Source§

fn inline(type_map: &mut TypeCollection, generics: Generics<'_>) -> DataType

Returns the definition of a type using the provided generics. Read more
Source§

fn reference(type_map: &mut TypeCollection, generics: &[DataType]) -> Reference

Generates a datatype corresponding to a reference to this type, +as determined by its category. Getting a reference to a type implies that +it should belong in the type map (since it has to be referenced from somewhere), +so the output of definition will be put into the type map.
Source§

impl Copy for SearchScope

Source§

impl Eq for SearchScope

Source§

impl StructuralPartialEq for SearchScope

Auto Trait Implementations§

Blanket Implementations§

Source§

impl<T> Any for T
where + T: 'static + ?Sized,

Source§

fn type_id(&self) -> TypeId

Gets the TypeId of self. Read more
Source§

impl<T> Borrow<T> for T
where + T: ?Sized,

Source§

fn borrow(&self) -> &T

Immutably borrows from an owned value. Read more
Source§

impl<T> BorrowMut<T> for T
where + T: ?Sized,

Source§

fn borrow_mut(&mut self) -> &mut T

Mutably borrows from an owned value. Read more
Source§

impl<T> CloneToUninit for T
where + T: Clone,

Source§

unsafe fn clone_to_uninit(&self, dest: *mut u8)

🔬This is a nightly-only experimental API. (clone_to_uninit)
Performs copy-assignment from self to dest. Read more
§

impl<'de, D, R> CommandArg<'de, R> for D
where + D: Deserialize<'de>, + R: Runtime,

§

fn from_command(command: CommandItem<'de, R>) -> Result<D, InvokeError>

Derives an instance of Self from the [CommandItem]. Read more
§

impl<Q, K> Equivalent<K> for Q
where + Q: Eq + ?Sized, + K: Borrow<Q> + ?Sized,

§

fn equivalent(&self, key: &K) -> bool

Checks if this value is equivalent to the given key. Read more
§

impl<Q, K> Equivalent<K> for Q
where + Q: Eq + ?Sized, + K: Borrow<Q> + ?Sized,

§

fn equivalent(&self, key: &K) -> bool

Compare self to key and return true if they are equal.
§

impl<Q, K> Equivalent<K> for Q
where + Q: Eq + ?Sized, + K: Borrow<Q> + ?Sized,

§

fn equivalent(&self, key: &K) -> bool

Checks if this value is equivalent to the given key. Read more
Source§

impl<T> From<T> for T

Source§

fn from(t: T) -> T

Returns the argument unchanged.

+
§

impl<T> FunctionArg for T
where + T: Type,

§

fn to_datatype(type_map: &mut TypeCollection) -> Option<DataType>

Gets the type of an argument as a [DataType]. Read more
§

impl<T> Instrument for T

§

fn instrument(self, span: Span) -> Instrumented<Self>

Instruments this type with the provided [Span], returning an +Instrumented wrapper. Read more
§

fn in_current_span(self) -> Instrumented<Self>

Instruments this type with the current Span, returning an +Instrumented wrapper. Read more
Source§

impl<T, U> Into<U> for T
where + U: From<T>,

Source§

fn into(self) -> U

Calls U::from(self).

+

That is, this conversion is whatever the implementation of +From<T> for U chooses to do.

+
§

impl<T> IpcResponse for T
where + T: Serialize,

§

fn body(self) -> Result<InvokeResponseBody, Error>

Resolve the IPC response body.
§

impl<T> PolicyExt for T
where + T: ?Sized,

§

fn and<P, B, E>(self, other: P) -> And<T, P>
where + T: Policy<B, E>, + P: Policy<B, E>,

Create a new Policy that returns [Action::Follow] only if self and other return +Action::Follow. Read more
§

fn or<P, B, E>(self, other: P) -> Or<T, P>
where + T: Policy<B, E>, + P: Policy<B, E>,

Create a new Policy that returns [Action::Follow] if either self or other returns +Action::Follow. Read more
Source§

impl<T> Same for T

Source§

type Output = T

Should always be Self
§

impl<T> ScopeObject for T
where + T: Send + Sync + Debug + DeserializeOwned + 'static,

§

type Error = Error

The error type.
§

fn deserialize<R>( + _app: &AppHandle<R>, + raw: Value, +) -> Result<T, <T as ScopeObject>::Error>
where + R: Runtime,

Deserialize the raw scope value.
Source§

impl<T> Serialize for T
where + T: Serialize + ?Sized,

Source§

fn erased_serialize(&self, serializer: &mut dyn Serializer) -> Result<(), Error>

Source§

fn do_erased_serialize( + &self, + serializer: &mut dyn Serializer, +) -> Result<(), ErrorImpl>

Source§

impl<T> ToOwned for T
where + T: Clone,

Source§

type Owned = T

The resulting type after obtaining ownership.
Source§

fn to_owned(&self) -> T

Creates owned data from borrowed data, usually by cloning. Read more
Source§

fn clone_into(&self, target: &mut T)

Uses borrowed data to replace owned data, usually by cloning. Read more
Source§

impl<T, U> TryFrom<U> for T
where + U: Into<T>,

Source§

type Error = Infallible

The type returned in the event of a conversion error.
Source§

fn try_from(value: U) -> Result<T, <T as TryFrom<U>>::Error>

Performs the conversion.
Source§

impl<T, U> TryInto<U> for T
where + U: TryFrom<T>,

Source§

type Error = <U as TryFrom<T>>::Error

The type returned in the event of a conversion error.
Source§

fn try_into(self) -> Result<U, <U as TryFrom<T>>::Error>

Performs the conversion.
§

impl<V, T> VZip<V> for T
where + V: MultiLane<T>,

§

fn vzip(self) -> V

§

impl<T> WithSubscriber for T

§

fn with_subscriber<S>(self, subscriber: S) -> WithDispatch<Self>
where + S: Into<Dispatch>,

Attaches the provided Subscriber to this type, returning a +[WithDispatch] wrapper. Read more
§

fn with_current_subscriber(self) -> WithDispatch<Self>

Attaches the current default Subscriber to this type, returning a +[WithDispatch] wrapper. Read more
Source§

impl<T> DeserializeOwned for T
where + T: for<'de> Deserialize<'de>,

§

impl<T> UserEvent for T
where + T: Debug + Clone + Send + 'static,

\ No newline at end of file diff --git a/api/jellytau_lib/repository/types/index.html b/api/jellytau_lib/repository/types/index.html new file mode 100644 index 00000000..59747f59 --- /dev/null +++ b/api/jellytau_lib/repository/types/index.html @@ -0,0 +1,2 @@ +jellytau_lib::repository::types - RustSkip to main content

Module types

Module types 

Source

Structs§

ArtistItem
Artist item with ID and name (for clickable artist links)
DownloadDiskUsage
On-disk usage of downloaded content, for the Downloads surface.
Genre
Genre
GetItemsOptions
Options for querying items
ImageOptions
Image options
Library
Library (media collection)
LiveStreamInfo
Live stream information returned from opening a Live TV / channel stream.
MediaItem
Media item
MediaSource
Media source information
MediaStream
Media stream information (audio, video, subtitle tracks)
Person
Person (cast/crew member) - for movies, series, and episodes
PlaybackInfo
Playback information
PlaylistCreatedResult
Result of creating a playlist
PlaylistEntry
Playlist entry — wraps a MediaItem with the Jellyfin PlaylistItemId +needed for remove/reorder operations (distinct from the media item’s ID)
SearchOptions
Options for search queries
SearchResult
Search result with pagination
UserData
User-specific data for an item (playback state, favorites, etc.)

Enums§

ImageType
Image type
RepoError
Error types for repository operations
SearchScope
An opaque search scope the frontend selects; Rust owns what it means.

Traits§

MeaningfulContent
Trait for checking if data has meaningful content
\ No newline at end of file diff --git a/api/jellytau_lib/repository/types/sidebar-items.js b/api/jellytau_lib/repository/types/sidebar-items.js new file mode 100644 index 00000000..a0cf7720 --- /dev/null +++ b/api/jellytau_lib/repository/types/sidebar-items.js @@ -0,0 +1 @@ +window.SIDEBAR_ITEMS = {"enum":["ImageType","RepoError","SearchScope"],"struct":["ArtistItem","DownloadDiskUsage","Genre","GetItemsOptions","ImageOptions","Library","LiveStreamInfo","MediaItem","MediaSource","MediaStream","Person","PlaybackInfo","PlaylistCreatedResult","PlaylistEntry","SearchOptions","SearchResult","UserData"],"trait":["MeaningfulContent"]}; \ No newline at end of file diff --git a/api/jellytau_lib/repository/types/struct.ArtistItem.html b/api/jellytau_lib/repository/types/struct.ArtistItem.html new file mode 100644 index 00000000..2c11e4f0 --- /dev/null +++ b/api/jellytau_lib/repository/types/struct.ArtistItem.html @@ -0,0 +1,60 @@ +ArtistItem in jellytau_lib::repository::types - RustSkip to main content

ArtistItem

Struct ArtistItem 

Source
pub struct ArtistItem {
+    pub id: String,
+    pub name: String,
+}
Expand description

Artist item with ID and name (for clickable artist links)

+

Fields§

§id: String§name: String

Trait Implementations§

Source§

impl Clone for ArtistItem

Source§

fn clone(&self) -> ArtistItem

Returns a duplicate of the value. Read more
1.0.0 (const: unstable) · Source§

fn clone_from(&mut self, source: &Self)

Performs copy-assignment from source. Read more
Source§

impl Debug for ArtistItem

Source§

fn fmt(&self, f: &mut Formatter<'_>) -> Result

Formats the value using the given formatter. Read more
Source§

impl<'de> Deserialize<'de> for ArtistItem

Source§

fn deserialize<__D>(__deserializer: __D) -> Result<Self, __D::Error>
where + __D: Deserializer<'de>,

Deserialize this value from the given Serde deserializer. Read more
Source§

impl NamedType for ArtistItem

Source§

fn sid() -> SpectaID

Source§

fn named_data_type( + type_map: &mut TypeCollection, + generics: &[DataType], +) -> NamedDataType

this is equivalent to [Type::inline] but returns a [NamedDataType] instead.
Source§

fn definition_named_data_type(type_map: &mut TypeCollection) -> NamedDataType

this is equivalent to [Type::definition] but returns a [NamedDataType] instead.
Source§

impl PartialEq for ArtistItem

Source§

fn eq(&self, other: &ArtistItem) -> bool

Tests for self and other values to be equal, and is used by ==.
1.0.0 (const: unstable) · Source§

fn ne(&self, other: &Rhs) -> bool

Tests for !=. The default implementation is almost always sufficient, +and should not be overridden without very good reason.
Source§

impl Serialize for ArtistItem

Source§

fn serialize<__S>(&self, __serializer: __S) -> Result<__S::Ok, __S::Error>
where + __S: Serializer,

Serialize this value into the given Serde serializer. Read more
Source§

impl Type for ArtistItem

Source§

fn inline(type_map: &mut TypeCollection, generics: Generics<'_>) -> DataType

Returns the definition of a type using the provided generics. Read more
Source§

fn reference(type_map: &mut TypeCollection, generics: &[DataType]) -> Reference

Generates a datatype corresponding to a reference to this type, +as determined by its category. Getting a reference to a type implies that +it should belong in the type map (since it has to be referenced from somewhere), +so the output of definition will be put into the type map.
Source§

impl Eq for ArtistItem

Source§

impl Flatten for ArtistItem

Source§

impl StructuralPartialEq for ArtistItem

Auto Trait Implementations§

Blanket Implementations§

Source§

impl<T> Any for T
where + T: 'static + ?Sized,

Source§

fn type_id(&self) -> TypeId

Gets the TypeId of self. Read more
Source§

impl<T> Borrow<T> for T
where + T: ?Sized,

Source§

fn borrow(&self) -> &T

Immutably borrows from an owned value. Read more
Source§

impl<T> BorrowMut<T> for T
where + T: ?Sized,

Source§

fn borrow_mut(&mut self) -> &mut T

Mutably borrows from an owned value. Read more
Source§

impl<T> CloneToUninit for T
where + T: Clone,

Source§

unsafe fn clone_to_uninit(&self, dest: *mut u8)

🔬This is a nightly-only experimental API. (clone_to_uninit)
Performs copy-assignment from self to dest. Read more
§

impl<'de, D, R> CommandArg<'de, R> for D
where + D: Deserialize<'de>, + R: Runtime,

§

fn from_command(command: CommandItem<'de, R>) -> Result<D, InvokeError>

Derives an instance of Self from the [CommandItem]. Read more
§

impl<Q, K> Equivalent<K> for Q
where + Q: Eq + ?Sized, + K: Borrow<Q> + ?Sized,

§

fn equivalent(&self, key: &K) -> bool

Checks if this value is equivalent to the given key. Read more
§

impl<Q, K> Equivalent<K> for Q
where + Q: Eq + ?Sized, + K: Borrow<Q> + ?Sized,

§

fn equivalent(&self, key: &K) -> bool

Compare self to key and return true if they are equal.
§

impl<Q, K> Equivalent<K> for Q
where + Q: Eq + ?Sized, + K: Borrow<Q> + ?Sized,

§

fn equivalent(&self, key: &K) -> bool

Checks if this value is equivalent to the given key. Read more
Source§

impl<T> From<T> for T

Source§

fn from(t: T) -> T

Returns the argument unchanged.

+
§

impl<T> FunctionArg for T
where + T: Type,

§

fn to_datatype(type_map: &mut TypeCollection) -> Option<DataType>

Gets the type of an argument as a [DataType]. Read more
§

impl<T> Instrument for T

§

fn instrument(self, span: Span) -> Instrumented<Self>

Instruments this type with the provided [Span], returning an +Instrumented wrapper. Read more
§

fn in_current_span(self) -> Instrumented<Self>

Instruments this type with the current Span, returning an +Instrumented wrapper. Read more
Source§

impl<T, U> Into<U> for T
where + U: From<T>,

Source§

fn into(self) -> U

Calls U::from(self).

+

That is, this conversion is whatever the implementation of +From<T> for U chooses to do.

+
§

impl<T> IpcResponse for T
where + T: Serialize,

§

fn body(self) -> Result<InvokeResponseBody, Error>

Resolve the IPC response body.
§

impl<T> PolicyExt for T
where + T: ?Sized,

§

fn and<P, B, E>(self, other: P) -> And<T, P>
where + T: Policy<B, E>, + P: Policy<B, E>,

Create a new Policy that returns [Action::Follow] only if self and other return +Action::Follow. Read more
§

fn or<P, B, E>(self, other: P) -> Or<T, P>
where + T: Policy<B, E>, + P: Policy<B, E>,

Create a new Policy that returns [Action::Follow] if either self or other returns +Action::Follow. Read more
Source§

impl<T> Same for T

Source§

type Output = T

Should always be Self
§

impl<T> ScopeObject for T
where + T: Send + Sync + Debug + DeserializeOwned + 'static,

§

type Error = Error

The error type.
§

fn deserialize<R>( + _app: &AppHandle<R>, + raw: Value, +) -> Result<T, <T as ScopeObject>::Error>
where + R: Runtime,

Deserialize the raw scope value.
Source§

impl<T> Serialize for T
where + T: Serialize + ?Sized,

Source§

fn erased_serialize(&self, serializer: &mut dyn Serializer) -> Result<(), Error>

Source§

fn do_erased_serialize( + &self, + serializer: &mut dyn Serializer, +) -> Result<(), ErrorImpl>

Source§

impl<T> ToOwned for T
where + T: Clone,

Source§

type Owned = T

The resulting type after obtaining ownership.
Source§

fn to_owned(&self) -> T

Creates owned data from borrowed data, usually by cloning. Read more
Source§

fn clone_into(&self, target: &mut T)

Uses borrowed data to replace owned data, usually by cloning. Read more
Source§

impl<T, U> TryFrom<U> for T
where + U: Into<T>,

Source§

type Error = Infallible

The type returned in the event of a conversion error.
Source§

fn try_from(value: U) -> Result<T, <T as TryFrom<U>>::Error>

Performs the conversion.
Source§

impl<T, U> TryInto<U> for T
where + U: TryFrom<T>,

Source§

type Error = <U as TryFrom<T>>::Error

The type returned in the event of a conversion error.
Source§

fn try_into(self) -> Result<U, <U as TryFrom<T>>::Error>

Performs the conversion.
§

impl<V, T> VZip<V> for T
where + V: MultiLane<T>,

§

fn vzip(self) -> V

§

impl<T> WithSubscriber for T

§

fn with_subscriber<S>(self, subscriber: S) -> WithDispatch<Self>
where + S: Into<Dispatch>,

Attaches the provided Subscriber to this type, returning a +[WithDispatch] wrapper. Read more
§

fn with_current_subscriber(self) -> WithDispatch<Self>

Attaches the current default Subscriber to this type, returning a +[WithDispatch] wrapper. Read more
Source§

impl<T> DeserializeOwned for T
where + T: for<'de> Deserialize<'de>,

§

impl<T> UserEvent for T
where + T: Debug + Clone + Send + 'static,

\ No newline at end of file diff --git a/api/jellytau_lib/repository/types/struct.DownloadDiskUsage.html b/api/jellytau_lib/repository/types/struct.DownloadDiskUsage.html new file mode 100644 index 00000000..893c7d37 --- /dev/null +++ b/api/jellytau_lib/repository/types/struct.DownloadDiskUsage.html @@ -0,0 +1,65 @@ +DownloadDiskUsage in jellytau_lib::repository::types - RustSkip to main content

DownloadDiskUsage

Struct DownloadDiskUsage 

Source
pub struct DownloadDiskUsage {
+    pub sizes: HashMap<String, i64>,
+    pub partial_containers: HashMap<String, bool>,
+    pub device_total_bytes: i64,
+    pub item_count: u32,
+}
Expand description

On-disk usage of downloaded content, for the Downloads surface.

+

sizes maps an item id (leaf or container) to its bytes on disk: a leaf’s +own file size, a container’s summed downloaded descendants. device_total_bytes +and item_count are the headline figures for the Downloaded surface top bar.

+

TRACES: UR-056 | DR-085

+

Fields§

§sizes: HashMap<String, i64>

item id → bytes on disk (leaf’s own size, or a container’s subtotal).

+
§partial_containers: HashMap<String, bool>

Container id → true when it is only partially downloaded (has cached +children that are not downloaded). Absent/false ⇒ fully downloaded. Lets +the Downloaded surface badge partial vs. full containers.

+
§device_total_bytes: i64

Sum of all downloaded leaf sizes — the device total.

+
§item_count: u32

Number of downloaded leaf items (not containers).

+

Trait Implementations§

Source§

impl Clone for DownloadDiskUsage

Source§

fn clone(&self) -> DownloadDiskUsage

Returns a duplicate of the value. Read more
1.0.0 (const: unstable) · Source§

fn clone_from(&mut self, source: &Self)

Performs copy-assignment from source. Read more
Source§

impl Debug for DownloadDiskUsage

Source§

fn fmt(&self, f: &mut Formatter<'_>) -> Result

Formats the value using the given formatter. Read more
Source§

impl<'de> Deserialize<'de> for DownloadDiskUsage

Source§

fn deserialize<__D>(__deserializer: __D) -> Result<Self, __D::Error>
where + __D: Deserializer<'de>,

Deserialize this value from the given Serde deserializer. Read more
Source§

impl NamedType for DownloadDiskUsage

Source§

fn sid() -> SpectaID

Source§

fn named_data_type( + type_map: &mut TypeCollection, + generics: &[DataType], +) -> NamedDataType

this is equivalent to [Type::inline] but returns a [NamedDataType] instead.
Source§

fn definition_named_data_type(type_map: &mut TypeCollection) -> NamedDataType

this is equivalent to [Type::definition] but returns a [NamedDataType] instead.
Source§

impl Serialize for DownloadDiskUsage

Source§

fn serialize<__S>(&self, __serializer: __S) -> Result<__S::Ok, __S::Error>
where + __S: Serializer,

Serialize this value into the given Serde serializer. Read more
Source§

impl Type for DownloadDiskUsage

Source§

fn inline(type_map: &mut TypeCollection, generics: Generics<'_>) -> DataType

Returns the definition of a type using the provided generics. Read more
Source§

fn reference(type_map: &mut TypeCollection, generics: &[DataType]) -> Reference

Generates a datatype corresponding to a reference to this type, +as determined by its category. Getting a reference to a type implies that +it should belong in the type map (since it has to be referenced from somewhere), +so the output of definition will be put into the type map.
Source§

impl Flatten for DownloadDiskUsage

Auto Trait Implementations§

Blanket Implementations§

Source§

impl<T> Any for T
where + T: 'static + ?Sized,

Source§

fn type_id(&self) -> TypeId

Gets the TypeId of self. Read more
Source§

impl<T> Borrow<T> for T
where + T: ?Sized,

Source§

fn borrow(&self) -> &T

Immutably borrows from an owned value. Read more
Source§

impl<T> BorrowMut<T> for T
where + T: ?Sized,

Source§

fn borrow_mut(&mut self) -> &mut T

Mutably borrows from an owned value. Read more
Source§

impl<T> CloneToUninit for T
where + T: Clone,

Source§

unsafe fn clone_to_uninit(&self, dest: *mut u8)

🔬This is a nightly-only experimental API. (clone_to_uninit)
Performs copy-assignment from self to dest. Read more
§

impl<'de, D, R> CommandArg<'de, R> for D
where + D: Deserialize<'de>, + R: Runtime,

§

fn from_command(command: CommandItem<'de, R>) -> Result<D, InvokeError>

Derives an instance of Self from the [CommandItem]. Read more
Source§

impl<T> From<T> for T

Source§

fn from(t: T) -> T

Returns the argument unchanged.

+
§

impl<T> FunctionArg for T
where + T: Type,

§

fn to_datatype(type_map: &mut TypeCollection) -> Option<DataType>

Gets the type of an argument as a [DataType]. Read more
§

impl<T> Instrument for T

§

fn instrument(self, span: Span) -> Instrumented<Self>

Instruments this type with the provided [Span], returning an +Instrumented wrapper. Read more
§

fn in_current_span(self) -> Instrumented<Self>

Instruments this type with the current Span, returning an +Instrumented wrapper. Read more
Source§

impl<T, U> Into<U> for T
where + U: From<T>,

Source§

fn into(self) -> U

Calls U::from(self).

+

That is, this conversion is whatever the implementation of +From<T> for U chooses to do.

+
§

impl<T> IpcResponse for T
where + T: Serialize,

§

fn body(self) -> Result<InvokeResponseBody, Error>

Resolve the IPC response body.
§

impl<T> PolicyExt for T
where + T: ?Sized,

§

fn and<P, B, E>(self, other: P) -> And<T, P>
where + T: Policy<B, E>, + P: Policy<B, E>,

Create a new Policy that returns [Action::Follow] only if self and other return +Action::Follow. Read more
§

fn or<P, B, E>(self, other: P) -> Or<T, P>
where + T: Policy<B, E>, + P: Policy<B, E>,

Create a new Policy that returns [Action::Follow] if either self or other returns +Action::Follow. Read more
Source§

impl<T> Same for T

Source§

type Output = T

Should always be Self
§

impl<T> ScopeObject for T
where + T: Send + Sync + Debug + DeserializeOwned + 'static,

§

type Error = Error

The error type.
§

fn deserialize<R>( + _app: &AppHandle<R>, + raw: Value, +) -> Result<T, <T as ScopeObject>::Error>
where + R: Runtime,

Deserialize the raw scope value.
Source§

impl<T> Serialize for T
where + T: Serialize + ?Sized,

Source§

fn erased_serialize(&self, serializer: &mut dyn Serializer) -> Result<(), Error>

Source§

fn do_erased_serialize( + &self, + serializer: &mut dyn Serializer, +) -> Result<(), ErrorImpl>

Source§

impl<T> ToOwned for T
where + T: Clone,

Source§

type Owned = T

The resulting type after obtaining ownership.
Source§

fn to_owned(&self) -> T

Creates owned data from borrowed data, usually by cloning. Read more
Source§

fn clone_into(&self, target: &mut T)

Uses borrowed data to replace owned data, usually by cloning. Read more
Source§

impl<T, U> TryFrom<U> for T
where + U: Into<T>,

Source§

type Error = Infallible

The type returned in the event of a conversion error.
Source§

fn try_from(value: U) -> Result<T, <T as TryFrom<U>>::Error>

Performs the conversion.
Source§

impl<T, U> TryInto<U> for T
where + U: TryFrom<T>,

Source§

type Error = <U as TryFrom<T>>::Error

The type returned in the event of a conversion error.
Source§

fn try_into(self) -> Result<U, <U as TryFrom<T>>::Error>

Performs the conversion.
§

impl<V, T> VZip<V> for T
where + V: MultiLane<T>,

§

fn vzip(self) -> V

§

impl<T> WithSubscriber for T

§

fn with_subscriber<S>(self, subscriber: S) -> WithDispatch<Self>
where + S: Into<Dispatch>,

Attaches the provided Subscriber to this type, returning a +[WithDispatch] wrapper. Read more
§

fn with_current_subscriber(self) -> WithDispatch<Self>

Attaches the current default Subscriber to this type, returning a +[WithDispatch] wrapper. Read more
Source§

impl<T> DeserializeOwned for T
where + T: for<'de> Deserialize<'de>,

§

impl<T> UserEvent for T
where + T: Debug + Clone + Send + 'static,

\ No newline at end of file diff --git a/api/jellytau_lib/repository/types/struct.Genre.html b/api/jellytau_lib/repository/types/struct.Genre.html new file mode 100644 index 00000000..2003d060 --- /dev/null +++ b/api/jellytau_lib/repository/types/struct.Genre.html @@ -0,0 +1,57 @@ +Genre in jellytau_lib::repository::types - RustSkip to main content

Genre

Struct Genre 

Source
pub struct Genre {
+    pub id: String,
+    pub name: String,
+    pub album_count: Option<u32>,
+}
Expand description

Genre

+

Fields§

§id: String§name: String§album_count: Option<u32>

Number of albums tagged with this genre, when the backend can supply it +(online only). Lets the frontend rank/pick genres without probing each +one. None when unknown (e.g. offline).

+

Trait Implementations§

Source§

impl Clone for Genre

Source§

fn clone(&self) -> Genre

Returns a duplicate of the value. Read more
1.0.0 (const: unstable) · Source§

fn clone_from(&mut self, source: &Self)

Performs copy-assignment from source. Read more
Source§

impl Debug for Genre

Source§

fn fmt(&self, f: &mut Formatter<'_>) -> Result

Formats the value using the given formatter. Read more
Source§

impl<'de> Deserialize<'de> for Genre

Source§

fn deserialize<__D>(__deserializer: __D) -> Result<Self, __D::Error>
where + __D: Deserializer<'de>,

Deserialize this value from the given Serde deserializer. Read more
Source§

impl NamedType for Genre

Source§

fn sid() -> SpectaID

Source§

fn named_data_type( + type_map: &mut TypeCollection, + generics: &[DataType], +) -> NamedDataType

this is equivalent to [Type::inline] but returns a [NamedDataType] instead.
Source§

fn definition_named_data_type(type_map: &mut TypeCollection) -> NamedDataType

this is equivalent to [Type::definition] but returns a [NamedDataType] instead.
Source§

impl Serialize for Genre

Source§

fn serialize<__S>(&self, __serializer: __S) -> Result<__S::Ok, __S::Error>
where + __S: Serializer,

Serialize this value into the given Serde serializer. Read more
Source§

impl Type for Genre

Source§

fn inline(type_map: &mut TypeCollection, generics: Generics<'_>) -> DataType

Returns the definition of a type using the provided generics. Read more
Source§

fn reference(type_map: &mut TypeCollection, generics: &[DataType]) -> Reference

Generates a datatype corresponding to a reference to this type, +as determined by its category. Getting a reference to a type implies that +it should belong in the type map (since it has to be referenced from somewhere), +so the output of definition will be put into the type map.
Source§

impl Flatten for Genre

Auto Trait Implementations§

§

impl Freeze for Genre

§

impl RefUnwindSafe for Genre

§

impl Send for Genre

§

impl Sync for Genre

§

impl Unpin for Genre

§

impl UnsafeUnpin for Genre

§

impl UnwindSafe for Genre

Blanket Implementations§

Source§

impl<T> Any for T
where + T: 'static + ?Sized,

Source§

fn type_id(&self) -> TypeId

Gets the TypeId of self. Read more
Source§

impl<T> Borrow<T> for T
where + T: ?Sized,

Source§

fn borrow(&self) -> &T

Immutably borrows from an owned value. Read more
Source§

impl<T> BorrowMut<T> for T
where + T: ?Sized,

Source§

fn borrow_mut(&mut self) -> &mut T

Mutably borrows from an owned value. Read more
Source§

impl<T> CloneToUninit for T
where + T: Clone,

Source§

unsafe fn clone_to_uninit(&self, dest: *mut u8)

🔬This is a nightly-only experimental API. (clone_to_uninit)
Performs copy-assignment from self to dest. Read more
§

impl<'de, D, R> CommandArg<'de, R> for D
where + D: Deserialize<'de>, + R: Runtime,

§

fn from_command(command: CommandItem<'de, R>) -> Result<D, InvokeError>

Derives an instance of Self from the [CommandItem]. Read more
Source§

impl<T> From<T> for T

Source§

fn from(t: T) -> T

Returns the argument unchanged.

+
§

impl<T> FunctionArg for T
where + T: Type,

§

fn to_datatype(type_map: &mut TypeCollection) -> Option<DataType>

Gets the type of an argument as a [DataType]. Read more
§

impl<T> Instrument for T

§

fn instrument(self, span: Span) -> Instrumented<Self>

Instruments this type with the provided [Span], returning an +Instrumented wrapper. Read more
§

fn in_current_span(self) -> Instrumented<Self>

Instruments this type with the current Span, returning an +Instrumented wrapper. Read more
Source§

impl<T, U> Into<U> for T
where + U: From<T>,

Source§

fn into(self) -> U

Calls U::from(self).

+

That is, this conversion is whatever the implementation of +From<T> for U chooses to do.

+
§

impl<T> IpcResponse for T
where + T: Serialize,

§

fn body(self) -> Result<InvokeResponseBody, Error>

Resolve the IPC response body.
§

impl<T> PolicyExt for T
where + T: ?Sized,

§

fn and<P, B, E>(self, other: P) -> And<T, P>
where + T: Policy<B, E>, + P: Policy<B, E>,

Create a new Policy that returns [Action::Follow] only if self and other return +Action::Follow. Read more
§

fn or<P, B, E>(self, other: P) -> Or<T, P>
where + T: Policy<B, E>, + P: Policy<B, E>,

Create a new Policy that returns [Action::Follow] if either self or other returns +Action::Follow. Read more
Source§

impl<T> Same for T

Source§

type Output = T

Should always be Self
§

impl<T> ScopeObject for T
where + T: Send + Sync + Debug + DeserializeOwned + 'static,

§

type Error = Error

The error type.
§

fn deserialize<R>( + _app: &AppHandle<R>, + raw: Value, +) -> Result<T, <T as ScopeObject>::Error>
where + R: Runtime,

Deserialize the raw scope value.
Source§

impl<T> Serialize for T
where + T: Serialize + ?Sized,

Source§

fn erased_serialize(&self, serializer: &mut dyn Serializer) -> Result<(), Error>

Source§

fn do_erased_serialize( + &self, + serializer: &mut dyn Serializer, +) -> Result<(), ErrorImpl>

Source§

impl<T> ToOwned for T
where + T: Clone,

Source§

type Owned = T

The resulting type after obtaining ownership.
Source§

fn to_owned(&self) -> T

Creates owned data from borrowed data, usually by cloning. Read more
Source§

fn clone_into(&self, target: &mut T)

Uses borrowed data to replace owned data, usually by cloning. Read more
Source§

impl<T, U> TryFrom<U> for T
where + U: Into<T>,

Source§

type Error = Infallible

The type returned in the event of a conversion error.
Source§

fn try_from(value: U) -> Result<T, <T as TryFrom<U>>::Error>

Performs the conversion.
Source§

impl<T, U> TryInto<U> for T
where + U: TryFrom<T>,

Source§

type Error = <U as TryFrom<T>>::Error

The type returned in the event of a conversion error.
Source§

fn try_into(self) -> Result<U, <U as TryFrom<T>>::Error>

Performs the conversion.
§

impl<V, T> VZip<V> for T
where + V: MultiLane<T>,

§

fn vzip(self) -> V

§

impl<T> WithSubscriber for T

§

fn with_subscriber<S>(self, subscriber: S) -> WithDispatch<Self>
where + S: Into<Dispatch>,

Attaches the provided Subscriber to this type, returning a +[WithDispatch] wrapper. Read more
§

fn with_current_subscriber(self) -> WithDispatch<Self>

Attaches the current default Subscriber to this type, returning a +[WithDispatch] wrapper. Read more
Source§

impl<T> DeserializeOwned for T
where + T: for<'de> Deserialize<'de>,

§

impl<T> UserEvent for T
where + T: Debug + Clone + Send + 'static,

\ No newline at end of file diff --git a/api/jellytau_lib/repository/types/struct.GetItemsOptions.html b/api/jellytau_lib/repository/types/struct.GetItemsOptions.html new file mode 100644 index 00000000..1a5d3ada --- /dev/null +++ b/api/jellytau_lib/repository/types/struct.GetItemsOptions.html @@ -0,0 +1,64 @@ +GetItemsOptions in jellytau_lib::repository::types - RustSkip to main content

GetItemsOptions

Struct GetItemsOptions 

Source
pub struct GetItemsOptions {
+    pub start_index: Option<usize>,
+    pub limit: Option<usize>,
+    pub sort_by: Option<String>,
+    pub sort_order: Option<String>,
+    pub include_item_types: Option<Vec<String>>,
+    pub recursive: Option<bool>,
+    pub fields: Option<Vec<String>>,
+    pub genres: Option<Vec<String>>,
+    pub favorites_only: Option<bool>,
+}
Expand description

Options for querying items

+

Fields§

§start_index: Option<usize>§limit: Option<usize>§sort_by: Option<String>§sort_order: Option<String>§include_item_types: Option<Vec<String>>§recursive: Option<bool>§fields: Option<Vec<String>>§genres: Option<Vec<String>>§favorites_only: Option<bool>

Restrict the listing to favourited items. Backs the per-library +favourites toggle; composes with every other filter here.

+

TRACES: UR-067 | DR-116 | UT-104

+

Trait Implementations§

Source§

impl Clone for GetItemsOptions

Source§

fn clone(&self) -> GetItemsOptions

Returns a duplicate of the value. Read more
1.0.0 (const: unstable) · Source§

fn clone_from(&mut self, source: &Self)

Performs copy-assignment from source. Read more
Source§

impl Debug for GetItemsOptions

Source§

fn fmt(&self, f: &mut Formatter<'_>) -> Result

Formats the value using the given formatter. Read more
Source§

impl Default for GetItemsOptions

Source§

fn default() -> GetItemsOptions

Returns the “default value” for a type. Read more
Source§

impl<'de> Deserialize<'de> for GetItemsOptions

Source§

fn deserialize<__D>(__deserializer: __D) -> Result<Self, __D::Error>
where + __D: Deserializer<'de>,

Deserialize this value from the given Serde deserializer. Read more
Source§

impl NamedType for GetItemsOptions

Source§

fn sid() -> SpectaID

Source§

fn named_data_type( + type_map: &mut TypeCollection, + generics: &[DataType], +) -> NamedDataType

this is equivalent to [Type::inline] but returns a [NamedDataType] instead.
Source§

fn definition_named_data_type(type_map: &mut TypeCollection) -> NamedDataType

this is equivalent to [Type::definition] but returns a [NamedDataType] instead.
Source§

impl Serialize for GetItemsOptions

Source§

fn serialize<__S>(&self, __serializer: __S) -> Result<__S::Ok, __S::Error>
where + __S: Serializer,

Serialize this value into the given Serde serializer. Read more
Source§

impl Type for GetItemsOptions

Source§

fn inline(type_map: &mut TypeCollection, generics: Generics<'_>) -> DataType

Returns the definition of a type using the provided generics. Read more
Source§

fn reference(type_map: &mut TypeCollection, generics: &[DataType]) -> Reference

Generates a datatype corresponding to a reference to this type, +as determined by its category. Getting a reference to a type implies that +it should belong in the type map (since it has to be referenced from somewhere), +so the output of definition will be put into the type map.
Source§

impl Flatten for GetItemsOptions

Auto Trait Implementations§

Blanket Implementations§

Source§

impl<T> Any for T
where + T: 'static + ?Sized,

Source§

fn type_id(&self) -> TypeId

Gets the TypeId of self. Read more
Source§

impl<T> Borrow<T> for T
where + T: ?Sized,

Source§

fn borrow(&self) -> &T

Immutably borrows from an owned value. Read more
Source§

impl<T> BorrowMut<T> for T
where + T: ?Sized,

Source§

fn borrow_mut(&mut self) -> &mut T

Mutably borrows from an owned value. Read more
Source§

impl<T> CloneToUninit for T
where + T: Clone,

Source§

unsafe fn clone_to_uninit(&self, dest: *mut u8)

🔬This is a nightly-only experimental API. (clone_to_uninit)
Performs copy-assignment from self to dest. Read more
§

impl<'de, D, R> CommandArg<'de, R> for D
where + D: Deserialize<'de>, + R: Runtime,

§

fn from_command(command: CommandItem<'de, R>) -> Result<D, InvokeError>

Derives an instance of Self from the [CommandItem]. Read more
Source§

impl<T> From<T> for T

Source§

fn from(t: T) -> T

Returns the argument unchanged.

+
§

impl<T> FunctionArg for T
where + T: Type,

§

fn to_datatype(type_map: &mut TypeCollection) -> Option<DataType>

Gets the type of an argument as a [DataType]. Read more
§

impl<T> Instrument for T

§

fn instrument(self, span: Span) -> Instrumented<Self>

Instruments this type with the provided [Span], returning an +Instrumented wrapper. Read more
§

fn in_current_span(self) -> Instrumented<Self>

Instruments this type with the current Span, returning an +Instrumented wrapper. Read more
Source§

impl<T, U> Into<U> for T
where + U: From<T>,

Source§

fn into(self) -> U

Calls U::from(self).

+

That is, this conversion is whatever the implementation of +From<T> for U chooses to do.

+
§

impl<T> IpcResponse for T
where + T: Serialize,

§

fn body(self) -> Result<InvokeResponseBody, Error>

Resolve the IPC response body.
§

impl<T> NoneValue for T
where + T: Default,

§

type NoneType = T

§

fn null_value() -> T

The none-equivalent value.
§

impl<T> PolicyExt for T
where + T: ?Sized,

§

fn and<P, B, E>(self, other: P) -> And<T, P>
where + T: Policy<B, E>, + P: Policy<B, E>,

Create a new Policy that returns [Action::Follow] only if self and other return +Action::Follow. Read more
§

fn or<P, B, E>(self, other: P) -> Or<T, P>
where + T: Policy<B, E>, + P: Policy<B, E>,

Create a new Policy that returns [Action::Follow] if either self or other returns +Action::Follow. Read more
Source§

impl<T> Same for T

Source§

type Output = T

Should always be Self
§

impl<T> ScopeObject for T
where + T: Send + Sync + Debug + DeserializeOwned + 'static,

§

type Error = Error

The error type.
§

fn deserialize<R>( + _app: &AppHandle<R>, + raw: Value, +) -> Result<T, <T as ScopeObject>::Error>
where + R: Runtime,

Deserialize the raw scope value.
Source§

impl<T> Serialize for T
where + T: Serialize + ?Sized,

Source§

fn erased_serialize(&self, serializer: &mut dyn Serializer) -> Result<(), Error>

Source§

fn do_erased_serialize( + &self, + serializer: &mut dyn Serializer, +) -> Result<(), ErrorImpl>

Source§

impl<T> ToOwned for T
where + T: Clone,

Source§

type Owned = T

The resulting type after obtaining ownership.
Source§

fn to_owned(&self) -> T

Creates owned data from borrowed data, usually by cloning. Read more
Source§

fn clone_into(&self, target: &mut T)

Uses borrowed data to replace owned data, usually by cloning. Read more
Source§

impl<T, U> TryFrom<U> for T
where + U: Into<T>,

Source§

type Error = Infallible

The type returned in the event of a conversion error.
Source§

fn try_from(value: U) -> Result<T, <T as TryFrom<U>>::Error>

Performs the conversion.
Source§

impl<T, U> TryInto<U> for T
where + U: TryFrom<T>,

Source§

type Error = <U as TryFrom<T>>::Error

The type returned in the event of a conversion error.
Source§

fn try_into(self) -> Result<U, <U as TryFrom<T>>::Error>

Performs the conversion.
§

impl<V, T> VZip<V> for T
where + V: MultiLane<T>,

§

fn vzip(self) -> V

§

impl<T> WithSubscriber for T

§

fn with_subscriber<S>(self, subscriber: S) -> WithDispatch<Self>
where + S: Into<Dispatch>,

Attaches the provided Subscriber to this type, returning a +[WithDispatch] wrapper. Read more
§

fn with_current_subscriber(self) -> WithDispatch<Self>

Attaches the current default Subscriber to this type, returning a +[WithDispatch] wrapper. Read more
Source§

impl<T> DeserializeOwned for T
where + T: for<'de> Deserialize<'de>,

§

impl<T> UserEvent for T
where + T: Debug + Clone + Send + 'static,

\ No newline at end of file diff --git a/api/jellytau_lib/repository/types/struct.ImageOptions.html b/api/jellytau_lib/repository/types/struct.ImageOptions.html new file mode 100644 index 00000000..569da094 --- /dev/null +++ b/api/jellytau_lib/repository/types/struct.ImageOptions.html @@ -0,0 +1,56 @@ +ImageOptions in jellytau_lib::repository::types - RustSkip to main content

ImageOptions

Struct ImageOptions 

Source
pub struct ImageOptions {
+    pub max_width: Option<u32>,
+    pub max_height: Option<u32>,
+    pub quality: Option<u32>,
+    pub tag: Option<String>,
+}
Expand description

Image options

+

Fields§

§max_width: Option<u32>§max_height: Option<u32>§quality: Option<u32>§tag: Option<String>

Trait Implementations§

Source§

impl Clone for ImageOptions

Source§

fn clone(&self) -> ImageOptions

Returns a duplicate of the value. Read more
1.0.0 (const: unstable) · Source§

fn clone_from(&mut self, source: &Self)

Performs copy-assignment from source. Read more
Source§

impl Debug for ImageOptions

Source§

fn fmt(&self, f: &mut Formatter<'_>) -> Result

Formats the value using the given formatter. Read more
Source§

impl Default for ImageOptions

Source§

fn default() -> ImageOptions

Returns the “default value” for a type. Read more
Source§

impl<'de> Deserialize<'de> for ImageOptions

Source§

fn deserialize<__D>(__deserializer: __D) -> Result<Self, __D::Error>
where + __D: Deserializer<'de>,

Deserialize this value from the given Serde deserializer. Read more
Source§

impl NamedType for ImageOptions

Source§

fn sid() -> SpectaID

Source§

fn named_data_type( + type_map: &mut TypeCollection, + generics: &[DataType], +) -> NamedDataType

this is equivalent to [Type::inline] but returns a [NamedDataType] instead.
Source§

fn definition_named_data_type(type_map: &mut TypeCollection) -> NamedDataType

this is equivalent to [Type::definition] but returns a [NamedDataType] instead.
Source§

impl Serialize for ImageOptions

Source§

fn serialize<__S>(&self, __serializer: __S) -> Result<__S::Ok, __S::Error>
where + __S: Serializer,

Serialize this value into the given Serde serializer. Read more
Source§

impl Type for ImageOptions

Source§

fn inline(type_map: &mut TypeCollection, generics: Generics<'_>) -> DataType

Returns the definition of a type using the provided generics. Read more
Source§

fn reference(type_map: &mut TypeCollection, generics: &[DataType]) -> Reference

Generates a datatype corresponding to a reference to this type, +as determined by its category. Getting a reference to a type implies that +it should belong in the type map (since it has to be referenced from somewhere), +so the output of definition will be put into the type map.
Source§

impl Flatten for ImageOptions

Auto Trait Implementations§

Blanket Implementations§

Source§

impl<T> Any for T
where + T: 'static + ?Sized,

Source§

fn type_id(&self) -> TypeId

Gets the TypeId of self. Read more
Source§

impl<T> Borrow<T> for T
where + T: ?Sized,

Source§

fn borrow(&self) -> &T

Immutably borrows from an owned value. Read more
Source§

impl<T> BorrowMut<T> for T
where + T: ?Sized,

Source§

fn borrow_mut(&mut self) -> &mut T

Mutably borrows from an owned value. Read more
Source§

impl<T> CloneToUninit for T
where + T: Clone,

Source§

unsafe fn clone_to_uninit(&self, dest: *mut u8)

🔬This is a nightly-only experimental API. (clone_to_uninit)
Performs copy-assignment from self to dest. Read more
§

impl<'de, D, R> CommandArg<'de, R> for D
where + D: Deserialize<'de>, + R: Runtime,

§

fn from_command(command: CommandItem<'de, R>) -> Result<D, InvokeError>

Derives an instance of Self from the [CommandItem]. Read more
Source§

impl<T> From<T> for T

Source§

fn from(t: T) -> T

Returns the argument unchanged.

+
§

impl<T> FunctionArg for T
where + T: Type,

§

fn to_datatype(type_map: &mut TypeCollection) -> Option<DataType>

Gets the type of an argument as a [DataType]. Read more
§

impl<T> Instrument for T

§

fn instrument(self, span: Span) -> Instrumented<Self>

Instruments this type with the provided [Span], returning an +Instrumented wrapper. Read more
§

fn in_current_span(self) -> Instrumented<Self>

Instruments this type with the current Span, returning an +Instrumented wrapper. Read more
Source§

impl<T, U> Into<U> for T
where + U: From<T>,

Source§

fn into(self) -> U

Calls U::from(self).

+

That is, this conversion is whatever the implementation of +From<T> for U chooses to do.

+
§

impl<T> IpcResponse for T
where + T: Serialize,

§

fn body(self) -> Result<InvokeResponseBody, Error>

Resolve the IPC response body.
§

impl<T> NoneValue for T
where + T: Default,

§

type NoneType = T

§

fn null_value() -> T

The none-equivalent value.
§

impl<T> PolicyExt for T
where + T: ?Sized,

§

fn and<P, B, E>(self, other: P) -> And<T, P>
where + T: Policy<B, E>, + P: Policy<B, E>,

Create a new Policy that returns [Action::Follow] only if self and other return +Action::Follow. Read more
§

fn or<P, B, E>(self, other: P) -> Or<T, P>
where + T: Policy<B, E>, + P: Policy<B, E>,

Create a new Policy that returns [Action::Follow] if either self or other returns +Action::Follow. Read more
Source§

impl<T> Same for T

Source§

type Output = T

Should always be Self
§

impl<T> ScopeObject for T
where + T: Send + Sync + Debug + DeserializeOwned + 'static,

§

type Error = Error

The error type.
§

fn deserialize<R>( + _app: &AppHandle<R>, + raw: Value, +) -> Result<T, <T as ScopeObject>::Error>
where + R: Runtime,

Deserialize the raw scope value.
Source§

impl<T> Serialize for T
where + T: Serialize + ?Sized,

Source§

fn erased_serialize(&self, serializer: &mut dyn Serializer) -> Result<(), Error>

Source§

fn do_erased_serialize( + &self, + serializer: &mut dyn Serializer, +) -> Result<(), ErrorImpl>

Source§

impl<T> ToOwned for T
where + T: Clone,

Source§

type Owned = T

The resulting type after obtaining ownership.
Source§

fn to_owned(&self) -> T

Creates owned data from borrowed data, usually by cloning. Read more
Source§

fn clone_into(&self, target: &mut T)

Uses borrowed data to replace owned data, usually by cloning. Read more
Source§

impl<T, U> TryFrom<U> for T
where + U: Into<T>,

Source§

type Error = Infallible

The type returned in the event of a conversion error.
Source§

fn try_from(value: U) -> Result<T, <T as TryFrom<U>>::Error>

Performs the conversion.
Source§

impl<T, U> TryInto<U> for T
where + U: TryFrom<T>,

Source§

type Error = <U as TryFrom<T>>::Error

The type returned in the event of a conversion error.
Source§

fn try_into(self) -> Result<U, <U as TryFrom<T>>::Error>

Performs the conversion.
§

impl<V, T> VZip<V> for T
where + V: MultiLane<T>,

§

fn vzip(self) -> V

§

impl<T> WithSubscriber for T

§

fn with_subscriber<S>(self, subscriber: S) -> WithDispatch<Self>
where + S: Into<Dispatch>,

Attaches the provided Subscriber to this type, returning a +[WithDispatch] wrapper. Read more
§

fn with_current_subscriber(self) -> WithDispatch<Self>

Attaches the current default Subscriber to this type, returning a +[WithDispatch] wrapper. Read more
Source§

impl<T> DeserializeOwned for T
where + T: for<'de> Deserialize<'de>,

§

impl<T> UserEvent for T
where + T: Debug + Clone + Send + 'static,

\ No newline at end of file diff --git a/api/jellytau_lib/repository/types/struct.Library.html b/api/jellytau_lib/repository/types/struct.Library.html new file mode 100644 index 00000000..81b4b2f8 --- /dev/null +++ b/api/jellytau_lib/repository/types/struct.Library.html @@ -0,0 +1,71 @@ +Library in jellytau_lib::repository::types - RustSkip to main content

Library

Struct Library 

Source
pub struct Library {
+    pub id: String,
+    pub name: String,
+    pub collection_type: String,
+    pub image_tag: Option<String>,
+    pub favorites_scope: Option<SearchScope>,
+}
Expand description

Library (media collection)

+

Fields§

§id: String§name: String§collection_type: String§image_tag: Option<String>§favorites_scope: Option<SearchScope>

The favourites scope this library’s contents fall under, or None for a +library kind favourites does not carve up (Live TV, channels, books…).

+

Derived here rather than in the UI: which collection type maps to which +scope is Jellyfin vocabulary, and the frontend must not hold a +collection-type → category table any more than an item-type one. See +SearchScope::for_collection_type.

+

TRACES: UR-075 | DR-175

+

Implementations§

Source§

impl Library

Source

pub fn new( + id: String, + name: String, + collection_type: String, + image_tag: Option<String>, +) -> Self

Build a library, deriving everything that follows from its collection +type. Prefer this over the struct literal so a new derived field cannot +be forgotten at one of the construction sites.

+

Trait Implementations§

Source§

impl Clone for Library

Source§

fn clone(&self) -> Library

Returns a duplicate of the value. Read more
1.0.0 (const: unstable) · Source§

fn clone_from(&mut self, source: &Self)

Performs copy-assignment from source. Read more
Source§

impl Debug for Library

Source§

fn fmt(&self, f: &mut Formatter<'_>) -> Result

Formats the value using the given formatter. Read more
Source§

impl<'de> Deserialize<'de> for Library

Source§

fn deserialize<__D>(__deserializer: __D) -> Result<Self, __D::Error>
where + __D: Deserializer<'de>,

Deserialize this value from the given Serde deserializer. Read more
Source§

impl NamedType for Library

Source§

fn sid() -> SpectaID

Source§

fn named_data_type( + type_map: &mut TypeCollection, + generics: &[DataType], +) -> NamedDataType

this is equivalent to [Type::inline] but returns a [NamedDataType] instead.
Source§

fn definition_named_data_type(type_map: &mut TypeCollection) -> NamedDataType

this is equivalent to [Type::definition] but returns a [NamedDataType] instead.
Source§

impl Serialize for Library

Source§

fn serialize<__S>(&self, __serializer: __S) -> Result<__S::Ok, __S::Error>
where + __S: Serializer,

Serialize this value into the given Serde serializer. Read more
Source§

impl Type for Library

Source§

fn inline(type_map: &mut TypeCollection, generics: Generics<'_>) -> DataType

Returns the definition of a type using the provided generics. Read more
Source§

fn reference(type_map: &mut TypeCollection, generics: &[DataType]) -> Reference

Generates a datatype corresponding to a reference to this type, +as determined by its category. Getting a reference to a type implies that +it should belong in the type map (since it has to be referenced from somewhere), +so the output of definition will be put into the type map.
Source§

impl Flatten for Library

Auto Trait Implementations§

Blanket Implementations§

Source§

impl<T> Any for T
where + T: 'static + ?Sized,

Source§

fn type_id(&self) -> TypeId

Gets the TypeId of self. Read more
Source§

impl<T> Borrow<T> for T
where + T: ?Sized,

Source§

fn borrow(&self) -> &T

Immutably borrows from an owned value. Read more
Source§

impl<T> BorrowMut<T> for T
where + T: ?Sized,

Source§

fn borrow_mut(&mut self) -> &mut T

Mutably borrows from an owned value. Read more
Source§

impl<T> CloneToUninit for T
where + T: Clone,

Source§

unsafe fn clone_to_uninit(&self, dest: *mut u8)

🔬This is a nightly-only experimental API. (clone_to_uninit)
Performs copy-assignment from self to dest. Read more
§

impl<'de, D, R> CommandArg<'de, R> for D
where + D: Deserialize<'de>, + R: Runtime,

§

fn from_command(command: CommandItem<'de, R>) -> Result<D, InvokeError>

Derives an instance of Self from the [CommandItem]. Read more
Source§

impl<T> From<T> for T

Source§

fn from(t: T) -> T

Returns the argument unchanged.

+
§

impl<T> FunctionArg for T
where + T: Type,

§

fn to_datatype(type_map: &mut TypeCollection) -> Option<DataType>

Gets the type of an argument as a [DataType]. Read more
§

impl<T> Instrument for T

§

fn instrument(self, span: Span) -> Instrumented<Self>

Instruments this type with the provided [Span], returning an +Instrumented wrapper. Read more
§

fn in_current_span(self) -> Instrumented<Self>

Instruments this type with the current Span, returning an +Instrumented wrapper. Read more
Source§

impl<T, U> Into<U> for T
where + U: From<T>,

Source§

fn into(self) -> U

Calls U::from(self).

+

That is, this conversion is whatever the implementation of +From<T> for U chooses to do.

+
§

impl<T> IpcResponse for T
where + T: Serialize,

§

fn body(self) -> Result<InvokeResponseBody, Error>

Resolve the IPC response body.
§

impl<T> PolicyExt for T
where + T: ?Sized,

§

fn and<P, B, E>(self, other: P) -> And<T, P>
where + T: Policy<B, E>, + P: Policy<B, E>,

Create a new Policy that returns [Action::Follow] only if self and other return +Action::Follow. Read more
§

fn or<P, B, E>(self, other: P) -> Or<T, P>
where + T: Policy<B, E>, + P: Policy<B, E>,

Create a new Policy that returns [Action::Follow] if either self or other returns +Action::Follow. Read more
Source§

impl<T> Same for T

Source§

type Output = T

Should always be Self
§

impl<T> ScopeObject for T
where + T: Send + Sync + Debug + DeserializeOwned + 'static,

§

type Error = Error

The error type.
§

fn deserialize<R>( + _app: &AppHandle<R>, + raw: Value, +) -> Result<T, <T as ScopeObject>::Error>
where + R: Runtime,

Deserialize the raw scope value.
Source§

impl<T> Serialize for T
where + T: Serialize + ?Sized,

Source§

fn erased_serialize(&self, serializer: &mut dyn Serializer) -> Result<(), Error>

Source§

fn do_erased_serialize( + &self, + serializer: &mut dyn Serializer, +) -> Result<(), ErrorImpl>

Source§

impl<T> ToOwned for T
where + T: Clone,

Source§

type Owned = T

The resulting type after obtaining ownership.
Source§

fn to_owned(&self) -> T

Creates owned data from borrowed data, usually by cloning. Read more
Source§

fn clone_into(&self, target: &mut T)

Uses borrowed data to replace owned data, usually by cloning. Read more
Source§

impl<T, U> TryFrom<U> for T
where + U: Into<T>,

Source§

type Error = Infallible

The type returned in the event of a conversion error.
Source§

fn try_from(value: U) -> Result<T, <T as TryFrom<U>>::Error>

Performs the conversion.
Source§

impl<T, U> TryInto<U> for T
where + U: TryFrom<T>,

Source§

type Error = <U as TryFrom<T>>::Error

The type returned in the event of a conversion error.
Source§

fn try_into(self) -> Result<U, <U as TryFrom<T>>::Error>

Performs the conversion.
§

impl<V, T> VZip<V> for T
where + V: MultiLane<T>,

§

fn vzip(self) -> V

§

impl<T> WithSubscriber for T

§

fn with_subscriber<S>(self, subscriber: S) -> WithDispatch<Self>
where + S: Into<Dispatch>,

Attaches the provided Subscriber to this type, returning a +[WithDispatch] wrapper. Read more
§

fn with_current_subscriber(self) -> WithDispatch<Self>

Attaches the current default Subscriber to this type, returning a +[WithDispatch] wrapper. Read more
Source§

impl<T> DeserializeOwned for T
where + T: for<'de> Deserialize<'de>,

§

impl<T> UserEvent for T
where + T: Debug + Clone + Send + 'static,

\ No newline at end of file diff --git a/api/jellytau_lib/repository/types/struct.LiveStreamInfo.html b/api/jellytau_lib/repository/types/struct.LiveStreamInfo.html new file mode 100644 index 00000000..da06916d --- /dev/null +++ b/api/jellytau_lib/repository/types/struct.LiveStreamInfo.html @@ -0,0 +1,58 @@ +LiveStreamInfo in jellytau_lib::repository::types - RustSkip to main content

LiveStreamInfo

Struct LiveStreamInfo 

Source
pub struct LiveStreamInfo {
+    pub stream_url: String,
+    pub play_session_id: Option<String>,
+    pub live_stream_id: Option<String>,
+    pub media_source_id: Option<String>,
+}
Expand description

Live stream information returned from opening a Live TV / channel stream.

+

Unlike on-demand video, a live channel must be “opened” before it can be +streamed; the server returns a transcoding URL (already absolute) plus a +live_stream_id that can later be used to close the stream.

+

Fields§

§stream_url: String§play_session_id: Option<String>§live_stream_id: Option<String>§media_source_id: Option<String>

Trait Implementations§

Source§

impl Clone for LiveStreamInfo

Source§

fn clone(&self) -> LiveStreamInfo

Returns a duplicate of the value. Read more
1.0.0 (const: unstable) · Source§

fn clone_from(&mut self, source: &Self)

Performs copy-assignment from source. Read more
Source§

impl Debug for LiveStreamInfo

Source§

fn fmt(&self, f: &mut Formatter<'_>) -> Result

Formats the value using the given formatter. Read more
Source§

impl<'de> Deserialize<'de> for LiveStreamInfo

Source§

fn deserialize<__D>(__deserializer: __D) -> Result<Self, __D::Error>
where + __D: Deserializer<'de>,

Deserialize this value from the given Serde deserializer. Read more
Source§

impl NamedType for LiveStreamInfo

Source§

fn sid() -> SpectaID

Source§

fn named_data_type( + type_map: &mut TypeCollection, + generics: &[DataType], +) -> NamedDataType

this is equivalent to [Type::inline] but returns a [NamedDataType] instead.
Source§

fn definition_named_data_type(type_map: &mut TypeCollection) -> NamedDataType

this is equivalent to [Type::definition] but returns a [NamedDataType] instead.
Source§

impl Serialize for LiveStreamInfo

Source§

fn serialize<__S>(&self, __serializer: __S) -> Result<__S::Ok, __S::Error>
where + __S: Serializer,

Serialize this value into the given Serde serializer. Read more
Source§

impl Type for LiveStreamInfo

Source§

fn inline(type_map: &mut TypeCollection, generics: Generics<'_>) -> DataType

Returns the definition of a type using the provided generics. Read more
Source§

fn reference(type_map: &mut TypeCollection, generics: &[DataType]) -> Reference

Generates a datatype corresponding to a reference to this type, +as determined by its category. Getting a reference to a type implies that +it should belong in the type map (since it has to be referenced from somewhere), +so the output of definition will be put into the type map.
Source§

impl Flatten for LiveStreamInfo

Auto Trait Implementations§

Blanket Implementations§

Source§

impl<T> Any for T
where + T: 'static + ?Sized,

Source§

fn type_id(&self) -> TypeId

Gets the TypeId of self. Read more
Source§

impl<T> Borrow<T> for T
where + T: ?Sized,

Source§

fn borrow(&self) -> &T

Immutably borrows from an owned value. Read more
Source§

impl<T> BorrowMut<T> for T
where + T: ?Sized,

Source§

fn borrow_mut(&mut self) -> &mut T

Mutably borrows from an owned value. Read more
Source§

impl<T> CloneToUninit for T
where + T: Clone,

Source§

unsafe fn clone_to_uninit(&self, dest: *mut u8)

🔬This is a nightly-only experimental API. (clone_to_uninit)
Performs copy-assignment from self to dest. Read more
§

impl<'de, D, R> CommandArg<'de, R> for D
where + D: Deserialize<'de>, + R: Runtime,

§

fn from_command(command: CommandItem<'de, R>) -> Result<D, InvokeError>

Derives an instance of Self from the [CommandItem]. Read more
Source§

impl<T> From<T> for T

Source§

fn from(t: T) -> T

Returns the argument unchanged.

+
§

impl<T> FunctionArg for T
where + T: Type,

§

fn to_datatype(type_map: &mut TypeCollection) -> Option<DataType>

Gets the type of an argument as a [DataType]. Read more
§

impl<T> Instrument for T

§

fn instrument(self, span: Span) -> Instrumented<Self>

Instruments this type with the provided [Span], returning an +Instrumented wrapper. Read more
§

fn in_current_span(self) -> Instrumented<Self>

Instruments this type with the current Span, returning an +Instrumented wrapper. Read more
Source§

impl<T, U> Into<U> for T
where + U: From<T>,

Source§

fn into(self) -> U

Calls U::from(self).

+

That is, this conversion is whatever the implementation of +From<T> for U chooses to do.

+
§

impl<T> IpcResponse for T
where + T: Serialize,

§

fn body(self) -> Result<InvokeResponseBody, Error>

Resolve the IPC response body.
§

impl<T> PolicyExt for T
where + T: ?Sized,

§

fn and<P, B, E>(self, other: P) -> And<T, P>
where + T: Policy<B, E>, + P: Policy<B, E>,

Create a new Policy that returns [Action::Follow] only if self and other return +Action::Follow. Read more
§

fn or<P, B, E>(self, other: P) -> Or<T, P>
where + T: Policy<B, E>, + P: Policy<B, E>,

Create a new Policy that returns [Action::Follow] if either self or other returns +Action::Follow. Read more
Source§

impl<T> Same for T

Source§

type Output = T

Should always be Self
§

impl<T> ScopeObject for T
where + T: Send + Sync + Debug + DeserializeOwned + 'static,

§

type Error = Error

The error type.
§

fn deserialize<R>( + _app: &AppHandle<R>, + raw: Value, +) -> Result<T, <T as ScopeObject>::Error>
where + R: Runtime,

Deserialize the raw scope value.
Source§

impl<T> Serialize for T
where + T: Serialize + ?Sized,

Source§

fn erased_serialize(&self, serializer: &mut dyn Serializer) -> Result<(), Error>

Source§

fn do_erased_serialize( + &self, + serializer: &mut dyn Serializer, +) -> Result<(), ErrorImpl>

Source§

impl<T> ToOwned for T
where + T: Clone,

Source§

type Owned = T

The resulting type after obtaining ownership.
Source§

fn to_owned(&self) -> T

Creates owned data from borrowed data, usually by cloning. Read more
Source§

fn clone_into(&self, target: &mut T)

Uses borrowed data to replace owned data, usually by cloning. Read more
Source§

impl<T, U> TryFrom<U> for T
where + U: Into<T>,

Source§

type Error = Infallible

The type returned in the event of a conversion error.
Source§

fn try_from(value: U) -> Result<T, <T as TryFrom<U>>::Error>

Performs the conversion.
Source§

impl<T, U> TryInto<U> for T
where + U: TryFrom<T>,

Source§

type Error = <U as TryFrom<T>>::Error

The type returned in the event of a conversion error.
Source§

fn try_into(self) -> Result<U, <U as TryFrom<T>>::Error>

Performs the conversion.
§

impl<V, T> VZip<V> for T
where + V: MultiLane<T>,

§

fn vzip(self) -> V

§

impl<T> WithSubscriber for T

§

fn with_subscriber<S>(self, subscriber: S) -> WithDispatch<Self>
where + S: Into<Dispatch>,

Attaches the provided Subscriber to this type, returning a +[WithDispatch] wrapper. Read more
§

fn with_current_subscriber(self) -> WithDispatch<Self>

Attaches the current default Subscriber to this type, returning a +[WithDispatch] wrapper. Read more
Source§

impl<T> DeserializeOwned for T
where + T: for<'de> Deserialize<'de>,

§

impl<T> UserEvent for T
where + T: Debug + Clone + Send + 'static,

\ No newline at end of file diff --git a/api/jellytau_lib/repository/types/struct.MediaItem.html b/api/jellytau_lib/repository/types/struct.MediaItem.html new file mode 100644 index 00000000..33bb1320 --- /dev/null +++ b/api/jellytau_lib/repository/types/struct.MediaItem.html @@ -0,0 +1,114 @@ +MediaItem in jellytau_lib::repository::types - RustSkip to main content

MediaItem

Struct MediaItem 

Source
pub struct MediaItem {
Show 35 fields + pub id: String, + pub name: String, + pub item_type: String, + pub kind: MediaKind, + pub is_folder: bool, + pub server_id: String, + pub parent_id: Option<String>, + pub library_id: Option<String>, + pub overview: Option<String>, + pub genres: Option<Vec<String>>, + pub production_year: Option<i32>, + pub premiere_date: Option<String>, + pub community_rating: Option<f64>, + pub official_rating: Option<String>, + pub runtime_ticks: Option<i64>, + pub duration_ms: Option<i64>, + pub primary_image_tag: Option<String>, + pub image_id: Option<String>, + pub backdrop_image_tags: Option<Vec<String>>, + pub parent_backdrop_image_tags: Option<Vec<String>>, + pub album_id: Option<String>, + pub album_name: Option<String>, + pub album_artist: Option<String>, + pub artists: Option<Vec<String>>, + pub artist_items: Option<Vec<ArtistItem>>, + pub index_number: Option<i32>, + pub parent_index_number: Option<i32>, + pub series_id: Option<String>, + pub series_name: Option<String>, + pub season_id: Option<String>, + pub season_name: Option<String>, + pub user_data: Option<UserData>, + pub media_streams: Option<Vec<MediaStream>>, + pub media_sources: Option<Vec<MediaSource>>, + pub people: Option<Vec<Person>>, +
}
Expand description

Media item

+

Fields§

§id: String§name: String§item_type: String

Legacy Jellyfin item-type string ("Audio", "MusicAlbum", …).

+

Dual-carry migration (docs/specs/frontend-domain-model.md): kind below +is the neutral replacement. This field stays while the frontend migrates +off it, then is removed in a later phase. New Rust code should read +kind, not this.

+
§kind: MediaKind

Provider-neutral classification — the replacement for item_type. +Populated by the Jellyfin mapping; defaults to Other for the handful of +construction sites that have not been migrated yet.

+
§is_folder: bool

Whether this item is a folder/container (vs a playable leaf). Used to +decide whether a channel item drills into a list or plays directly.

+
§server_id: String§parent_id: Option<String>§library_id: Option<String>§overview: Option<String>§genres: Option<Vec<String>>§production_year: Option<i32>§premiere_date: Option<String>

ISO-8601 release/air date (Jellyfin PremiereDate). Used to sort +podcast episodes by release date.

+
§community_rating: Option<f64>§official_rating: Option<String>§runtime_ticks: Option<i64>

Legacy Jellyfin duration in ticks (100 ns units). Being replaced by +duration_ms; dual-carried while the frontend migrates +(docs/specs/frontend-domain-model.md). New code should read duration_ms.

+
§duration_ms: Option<i64>

Duration in milliseconds — the neutral replacement for runtime_ticks. +Ticks never reach the frontend; this does.

+
§primary_image_tag: Option<String>

Legacy Jellyfin primary image tag. Being replaced by image_id; +dual-carried while the frontend migrates. New code should read image_id.

+
§image_id: Option<String>

Neutral image identifier the frontend resolves to a URL via the image +command — the replacement for primary_image_tag. Same value today +(Jellyfin’s tag is the id); the rename removes the provider term.

+
§backdrop_image_tags: Option<Vec<String>>§parent_backdrop_image_tags: Option<Vec<String>>§album_id: Option<String>§album_name: Option<String>§album_artist: Option<String>§artists: Option<Vec<String>>§artist_items: Option<Vec<ArtistItem>>§index_number: Option<i32>§parent_index_number: Option<i32>§series_id: Option<String>§series_name: Option<String>§season_id: Option<String>§season_name: Option<String>§user_data: Option<UserData>§media_streams: Option<Vec<MediaStream>>§media_sources: Option<Vec<MediaSource>>§people: Option<Vec<Person>>

Trait Implementations§

Source§

impl Clone for MediaItem

Source§

fn clone(&self) -> MediaItem

Returns a duplicate of the value. Read more
1.0.0 (const: unstable) · Source§

fn clone_from(&mut self, source: &Self)

Performs copy-assignment from source. Read more
Source§

impl Debug for MediaItem

Source§

fn fmt(&self, f: &mut Formatter<'_>) -> Result

Formats the value using the given formatter. Read more
Source§

impl Default for MediaItem

Source§

fn default() -> MediaItem

Returns the “default value” for a type. Read more
Source§

impl<'de> Deserialize<'de> for MediaItem

Source§

fn deserialize<__D>(__deserializer: __D) -> Result<Self, __D::Error>
where + __D: Deserializer<'de>,

Deserialize this value from the given Serde deserializer. Read more
Source§

impl ExcludeHidden for MediaItem

Source§

fn without_excluded(self) -> Self

A single item fetched by id is never hidden.

+

Exclusion hides things from browsing. An item asked for by id was +navigated to deliberately, or is being resolved by the player or a +download — answering “not found” there would break playback of anything +inside a hidden folder rather than merely tidying a listing.

+
Source§

impl From<&MediaItem> for AlbumTrack

Source§

fn from(item: &MediaItem) -> Self

Converts to this type from the input type.
Source§

impl MeaningfulContent for MediaItem

Source§

impl NamedType for MediaItem

Source§

fn sid() -> SpectaID

Source§

fn named_data_type( + type_map: &mut TypeCollection, + generics: &[DataType], +) -> NamedDataType

this is equivalent to [Type::inline] but returns a [NamedDataType] instead.
Source§

fn definition_named_data_type(type_map: &mut TypeCollection) -> NamedDataType

this is equivalent to [Type::definition] but returns a [NamedDataType] instead.
Source§

impl Serialize for MediaItem

Source§

fn serialize<__S>(&self, __serializer: __S) -> Result<__S::Ok, __S::Error>
where + __S: Serializer,

Serialize this value into the given Serde serializer. Read more
Source§

impl Type for MediaItem

Source§

fn inline(type_map: &mut TypeCollection, generics: Generics<'_>) -> DataType

Returns the definition of a type using the provided generics. Read more
Source§

fn reference(type_map: &mut TypeCollection, generics: &[DataType]) -> Reference

Generates a datatype corresponding to a reference to this type, +as determined by its category. Getting a reference to a type implies that +it should belong in the type map (since it has to be referenced from somewhere), +so the output of definition will be put into the type map.
Source§

impl Flatten for MediaItem

Auto Trait Implementations§

Blanket Implementations§

Source§

impl<T> Any for T
where + T: 'static + ?Sized,

Source§

fn type_id(&self) -> TypeId

Gets the TypeId of self. Read more
Source§

impl<T> Borrow<T> for T
where + T: ?Sized,

Source§

fn borrow(&self) -> &T

Immutably borrows from an owned value. Read more
Source§

impl<T> BorrowMut<T> for T
where + T: ?Sized,

Source§

fn borrow_mut(&mut self) -> &mut T

Mutably borrows from an owned value. Read more
Source§

impl<T> CloneToUninit for T
where + T: Clone,

Source§

unsafe fn clone_to_uninit(&self, dest: *mut u8)

🔬This is a nightly-only experimental API. (clone_to_uninit)
Performs copy-assignment from self to dest. Read more
§

impl<'de, D, R> CommandArg<'de, R> for D
where + D: Deserialize<'de>, + R: Runtime,

§

fn from_command(command: CommandItem<'de, R>) -> Result<D, InvokeError>

Derives an instance of Self from the [CommandItem]. Read more
Source§

impl<T> From<T> for T

Source§

fn from(t: T) -> T

Returns the argument unchanged.

+
§

impl<T> FunctionArg for T
where + T: Type,

§

fn to_datatype(type_map: &mut TypeCollection) -> Option<DataType>

Gets the type of an argument as a [DataType]. Read more
§

impl<T> Instrument for T

§

fn instrument(self, span: Span) -> Instrumented<Self>

Instruments this type with the provided [Span], returning an +Instrumented wrapper. Read more
§

fn in_current_span(self) -> Instrumented<Self>

Instruments this type with the current Span, returning an +Instrumented wrapper. Read more
Source§

impl<T, U> Into<U> for T
where + U: From<T>,

Source§

fn into(self) -> U

Calls U::from(self).

+

That is, this conversion is whatever the implementation of +From<T> for U chooses to do.

+
§

impl<T> IpcResponse for T
where + T: Serialize,

§

fn body(self) -> Result<InvokeResponseBody, Error>

Resolve the IPC response body.
§

impl<T> NoneValue for T
where + T: Default,

§

type NoneType = T

§

fn null_value() -> T

The none-equivalent value.
§

impl<T> PolicyExt for T
where + T: ?Sized,

§

fn and<P, B, E>(self, other: P) -> And<T, P>
where + T: Policy<B, E>, + P: Policy<B, E>,

Create a new Policy that returns [Action::Follow] only if self and other return +Action::Follow. Read more
§

fn or<P, B, E>(self, other: P) -> Or<T, P>
where + T: Policy<B, E>, + P: Policy<B, E>,

Create a new Policy that returns [Action::Follow] if either self or other returns +Action::Follow. Read more
Source§

impl<T> Same for T

Source§

type Output = T

Should always be Self
§

impl<T> ScopeObject for T
where + T: Send + Sync + Debug + DeserializeOwned + 'static,

§

type Error = Error

The error type.
§

fn deserialize<R>( + _app: &AppHandle<R>, + raw: Value, +) -> Result<T, <T as ScopeObject>::Error>
where + R: Runtime,

Deserialize the raw scope value.
Source§

impl<T> Serialize for T
where + T: Serialize + ?Sized,

Source§

fn erased_serialize(&self, serializer: &mut dyn Serializer) -> Result<(), Error>

Source§

fn do_erased_serialize( + &self, + serializer: &mut dyn Serializer, +) -> Result<(), ErrorImpl>

Source§

impl<T> ToOwned for T
where + T: Clone,

Source§

type Owned = T

The resulting type after obtaining ownership.
Source§

fn to_owned(&self) -> T

Creates owned data from borrowed data, usually by cloning. Read more
Source§

fn clone_into(&self, target: &mut T)

Uses borrowed data to replace owned data, usually by cloning. Read more
Source§

impl<T, U> TryFrom<U> for T
where + U: Into<T>,

Source§

type Error = Infallible

The type returned in the event of a conversion error.
Source§

fn try_from(value: U) -> Result<T, <T as TryFrom<U>>::Error>

Performs the conversion.
Source§

impl<T, U> TryInto<U> for T
where + U: TryFrom<T>,

Source§

type Error = <U as TryFrom<T>>::Error

The type returned in the event of a conversion error.
Source§

fn try_into(self) -> Result<U, <U as TryFrom<T>>::Error>

Performs the conversion.
§

impl<V, T> VZip<V> for T
where + V: MultiLane<T>,

§

fn vzip(self) -> V

§

impl<T> WithSubscriber for T

§

fn with_subscriber<S>(self, subscriber: S) -> WithDispatch<Self>
where + S: Into<Dispatch>,

Attaches the provided Subscriber to this type, returning a +[WithDispatch] wrapper. Read more
§

fn with_current_subscriber(self) -> WithDispatch<Self>

Attaches the current default Subscriber to this type, returning a +[WithDispatch] wrapper. Read more
Source§

impl<T> DeserializeOwned for T
where + T: for<'de> Deserialize<'de>,

§

impl<T> UserEvent for T
where + T: Debug + Clone + Send + 'static,

\ No newline at end of file diff --git a/api/jellytau_lib/repository/types/struct.MediaSource.html b/api/jellytau_lib/repository/types/struct.MediaSource.html new file mode 100644 index 00000000..651204de --- /dev/null +++ b/api/jellytau_lib/repository/types/struct.MediaSource.html @@ -0,0 +1,60 @@ +MediaSource in jellytau_lib::repository::types - RustSkip to main content

MediaSource

Struct MediaSource 

Source
pub struct MediaSource {
+    pub id: String,
+    pub name: String,
+    pub container: Option<String>,
+    pub size: Option<i64>,
+    pub bitrate: Option<i32>,
+    pub supports_direct_play: bool,
+    pub supports_direct_stream: bool,
+    pub supports_transcoding: bool,
+    pub direct_stream_url: Option<String>,
+}
Expand description

Media source information

+

Fields§

§id: String§name: String§container: Option<String>§size: Option<i64>§bitrate: Option<i32>§supports_direct_play: bool§supports_direct_stream: bool§supports_transcoding: bool§direct_stream_url: Option<String>

Trait Implementations§

Source§

impl Clone for MediaSource

Source§

fn clone(&self) -> MediaSource

Returns a duplicate of the value. Read more
1.0.0 (const: unstable) · Source§

fn clone_from(&mut self, source: &Self)

Performs copy-assignment from source. Read more
Source§

impl Debug for MediaSource

Source§

fn fmt(&self, f: &mut Formatter<'_>) -> Result

Formats the value using the given formatter. Read more
Source§

impl<'de> Deserialize<'de> for MediaSource

Source§

fn deserialize<__D>(__deserializer: __D) -> Result<Self, __D::Error>
where + __D: Deserializer<'de>,

Deserialize this value from the given Serde deserializer. Read more
Source§

impl NamedType for MediaSource

Source§

fn sid() -> SpectaID

Source§

fn named_data_type( + type_map: &mut TypeCollection, + generics: &[DataType], +) -> NamedDataType

this is equivalent to [Type::inline] but returns a [NamedDataType] instead.
Source§

fn definition_named_data_type(type_map: &mut TypeCollection) -> NamedDataType

this is equivalent to [Type::definition] but returns a [NamedDataType] instead.
Source§

impl Serialize for MediaSource

Source§

fn serialize<__S>(&self, __serializer: __S) -> Result<__S::Ok, __S::Error>
where + __S: Serializer,

Serialize this value into the given Serde serializer. Read more
Source§

impl Type for MediaSource

Source§

fn inline(type_map: &mut TypeCollection, generics: Generics<'_>) -> DataType

Returns the definition of a type using the provided generics. Read more
Source§

fn reference(type_map: &mut TypeCollection, generics: &[DataType]) -> Reference

Generates a datatype corresponding to a reference to this type, +as determined by its category. Getting a reference to a type implies that +it should belong in the type map (since it has to be referenced from somewhere), +so the output of definition will be put into the type map.
Source§

impl Flatten for MediaSource

Auto Trait Implementations§

Blanket Implementations§

Source§

impl<T> Any for T
where + T: 'static + ?Sized,

Source§

fn type_id(&self) -> TypeId

Gets the TypeId of self. Read more
Source§

impl<T> Borrow<T> for T
where + T: ?Sized,

Source§

fn borrow(&self) -> &T

Immutably borrows from an owned value. Read more
Source§

impl<T> BorrowMut<T> for T
where + T: ?Sized,

Source§

fn borrow_mut(&mut self) -> &mut T

Mutably borrows from an owned value. Read more
Source§

impl<T> CloneToUninit for T
where + T: Clone,

Source§

unsafe fn clone_to_uninit(&self, dest: *mut u8)

🔬This is a nightly-only experimental API. (clone_to_uninit)
Performs copy-assignment from self to dest. Read more
§

impl<'de, D, R> CommandArg<'de, R> for D
where + D: Deserialize<'de>, + R: Runtime,

§

fn from_command(command: CommandItem<'de, R>) -> Result<D, InvokeError>

Derives an instance of Self from the [CommandItem]. Read more
Source§

impl<T> From<T> for T

Source§

fn from(t: T) -> T

Returns the argument unchanged.

+
§

impl<T> FunctionArg for T
where + T: Type,

§

fn to_datatype(type_map: &mut TypeCollection) -> Option<DataType>

Gets the type of an argument as a [DataType]. Read more
§

impl<T> Instrument for T

§

fn instrument(self, span: Span) -> Instrumented<Self>

Instruments this type with the provided [Span], returning an +Instrumented wrapper. Read more
§

fn in_current_span(self) -> Instrumented<Self>

Instruments this type with the current Span, returning an +Instrumented wrapper. Read more
Source§

impl<T, U> Into<U> for T
where + U: From<T>,

Source§

fn into(self) -> U

Calls U::from(self).

+

That is, this conversion is whatever the implementation of +From<T> for U chooses to do.

+
§

impl<T> IpcResponse for T
where + T: Serialize,

§

fn body(self) -> Result<InvokeResponseBody, Error>

Resolve the IPC response body.
§

impl<T> PolicyExt for T
where + T: ?Sized,

§

fn and<P, B, E>(self, other: P) -> And<T, P>
where + T: Policy<B, E>, + P: Policy<B, E>,

Create a new Policy that returns [Action::Follow] only if self and other return +Action::Follow. Read more
§

fn or<P, B, E>(self, other: P) -> Or<T, P>
where + T: Policy<B, E>, + P: Policy<B, E>,

Create a new Policy that returns [Action::Follow] if either self or other returns +Action::Follow. Read more
Source§

impl<T> Same for T

Source§

type Output = T

Should always be Self
§

impl<T> ScopeObject for T
where + T: Send + Sync + Debug + DeserializeOwned + 'static,

§

type Error = Error

The error type.
§

fn deserialize<R>( + _app: &AppHandle<R>, + raw: Value, +) -> Result<T, <T as ScopeObject>::Error>
where + R: Runtime,

Deserialize the raw scope value.
Source§

impl<T> Serialize for T
where + T: Serialize + ?Sized,

Source§

fn erased_serialize(&self, serializer: &mut dyn Serializer) -> Result<(), Error>

Source§

fn do_erased_serialize( + &self, + serializer: &mut dyn Serializer, +) -> Result<(), ErrorImpl>

Source§

impl<T> ToOwned for T
where + T: Clone,

Source§

type Owned = T

The resulting type after obtaining ownership.
Source§

fn to_owned(&self) -> T

Creates owned data from borrowed data, usually by cloning. Read more
Source§

fn clone_into(&self, target: &mut T)

Uses borrowed data to replace owned data, usually by cloning. Read more
Source§

impl<T, U> TryFrom<U> for T
where + U: Into<T>,

Source§

type Error = Infallible

The type returned in the event of a conversion error.
Source§

fn try_from(value: U) -> Result<T, <T as TryFrom<U>>::Error>

Performs the conversion.
Source§

impl<T, U> TryInto<U> for T
where + U: TryFrom<T>,

Source§

type Error = <U as TryFrom<T>>::Error

The type returned in the event of a conversion error.
Source§

fn try_into(self) -> Result<U, <U as TryFrom<T>>::Error>

Performs the conversion.
§

impl<V, T> VZip<V> for T
where + V: MultiLane<T>,

§

fn vzip(self) -> V

§

impl<T> WithSubscriber for T

§

fn with_subscriber<S>(self, subscriber: S) -> WithDispatch<Self>
where + S: Into<Dispatch>,

Attaches the provided Subscriber to this type, returning a +[WithDispatch] wrapper. Read more
§

fn with_current_subscriber(self) -> WithDispatch<Self>

Attaches the current default Subscriber to this type, returning a +[WithDispatch] wrapper. Read more
Source§

impl<T> DeserializeOwned for T
where + T: for<'de> Deserialize<'de>,

§

impl<T> UserEvent for T
where + T: Debug + Clone + Send + 'static,

\ No newline at end of file diff --git a/api/jellytau_lib/repository/types/struct.MediaStream.html b/api/jellytau_lib/repository/types/struct.MediaStream.html new file mode 100644 index 00000000..736cf423 --- /dev/null +++ b/api/jellytau_lib/repository/types/struct.MediaStream.html @@ -0,0 +1,71 @@ +MediaStream in jellytau_lib::repository::types - RustSkip to main content

MediaStream

Struct MediaStream 

Source
pub struct MediaStream {
+    pub stream_type: String,
+    pub kind: StreamKind,
+    pub codec: Option<String>,
+    pub language: Option<String>,
+    pub display_title: Option<String>,
+    pub index: i32,
+    pub is_default: bool,
+    pub is_forced: bool,
+    pub supports_external_delivery: Option<bool>,
+}
Expand description

Media stream information (audio, video, subtitle tracks)

+

Fields§

§stream_type: String

Legacy Jellyfin stream type string (“Audio”/“Video”/“Subtitle”). Being +replaced by kind; dual-carried while the frontend migrates.

+
§kind: StreamKind

Provider-neutral stream classification — replaces stream_type.

+
§codec: Option<String>§language: Option<String>§display_title: Option<String>§index: i32§is_default: bool§is_forced: bool§supports_external_delivery: Option<bool>

Whether this stream can reach the app as a sidecar it renders itself.

+

None for anything that is not a subtitle — the question does not apply, +and false there would read like a verdict. For a subtitle it is the +difference between a track the app can draw and one only the server could +have shown, by burning it into the picture (DR-176) — which this app never +asks it to do. The vocabulary of which formats those are stays in Rust; +the frontend only reads the answer.

+

TRACES: UR-020 | DR-176 | UT-168

+

Trait Implementations§

Source§

impl Clone for MediaStream

Source§

fn clone(&self) -> MediaStream

Returns a duplicate of the value. Read more
1.0.0 (const: unstable) · Source§

fn clone_from(&mut self, source: &Self)

Performs copy-assignment from source. Read more
Source§

impl Debug for MediaStream

Source§

fn fmt(&self, f: &mut Formatter<'_>) -> Result

Formats the value using the given formatter. Read more
Source§

impl<'de> Deserialize<'de> for MediaStream

Source§

fn deserialize<__D>(__deserializer: __D) -> Result<Self, __D::Error>
where + __D: Deserializer<'de>,

Deserialize this value from the given Serde deserializer. Read more
Source§

impl NamedType for MediaStream

Source§

fn sid() -> SpectaID

Source§

fn named_data_type( + type_map: &mut TypeCollection, + generics: &[DataType], +) -> NamedDataType

this is equivalent to [Type::inline] but returns a [NamedDataType] instead.
Source§

fn definition_named_data_type(type_map: &mut TypeCollection) -> NamedDataType

this is equivalent to [Type::definition] but returns a [NamedDataType] instead.
Source§

impl Serialize for MediaStream

Source§

fn serialize<__S>(&self, __serializer: __S) -> Result<__S::Ok, __S::Error>
where + __S: Serializer,

Serialize this value into the given Serde serializer. Read more
Source§

impl Type for MediaStream

Source§

fn inline(type_map: &mut TypeCollection, generics: Generics<'_>) -> DataType

Returns the definition of a type using the provided generics. Read more
Source§

fn reference(type_map: &mut TypeCollection, generics: &[DataType]) -> Reference

Generates a datatype corresponding to a reference to this type, +as determined by its category. Getting a reference to a type implies that +it should belong in the type map (since it has to be referenced from somewhere), +so the output of definition will be put into the type map.
Source§

impl Flatten for MediaStream

Auto Trait Implementations§

Blanket Implementations§

Source§

impl<T> Any for T
where + T: 'static + ?Sized,

Source§

fn type_id(&self) -> TypeId

Gets the TypeId of self. Read more
Source§

impl<T> Borrow<T> for T
where + T: ?Sized,

Source§

fn borrow(&self) -> &T

Immutably borrows from an owned value. Read more
Source§

impl<T> BorrowMut<T> for T
where + T: ?Sized,

Source§

fn borrow_mut(&mut self) -> &mut T

Mutably borrows from an owned value. Read more
Source§

impl<T> CloneToUninit for T
where + T: Clone,

Source§

unsafe fn clone_to_uninit(&self, dest: *mut u8)

🔬This is a nightly-only experimental API. (clone_to_uninit)
Performs copy-assignment from self to dest. Read more
§

impl<'de, D, R> CommandArg<'de, R> for D
where + D: Deserialize<'de>, + R: Runtime,

§

fn from_command(command: CommandItem<'de, R>) -> Result<D, InvokeError>

Derives an instance of Self from the [CommandItem]. Read more
Source§

impl<T> From<T> for T

Source§

fn from(t: T) -> T

Returns the argument unchanged.

+
§

impl<T> FunctionArg for T
where + T: Type,

§

fn to_datatype(type_map: &mut TypeCollection) -> Option<DataType>

Gets the type of an argument as a [DataType]. Read more
§

impl<T> Instrument for T

§

fn instrument(self, span: Span) -> Instrumented<Self>

Instruments this type with the provided [Span], returning an +Instrumented wrapper. Read more
§

fn in_current_span(self) -> Instrumented<Self>

Instruments this type with the current Span, returning an +Instrumented wrapper. Read more
Source§

impl<T, U> Into<U> for T
where + U: From<T>,

Source§

fn into(self) -> U

Calls U::from(self).

+

That is, this conversion is whatever the implementation of +From<T> for U chooses to do.

+
§

impl<T> IpcResponse for T
where + T: Serialize,

§

fn body(self) -> Result<InvokeResponseBody, Error>

Resolve the IPC response body.
§

impl<T> PolicyExt for T
where + T: ?Sized,

§

fn and<P, B, E>(self, other: P) -> And<T, P>
where + T: Policy<B, E>, + P: Policy<B, E>,

Create a new Policy that returns [Action::Follow] only if self and other return +Action::Follow. Read more
§

fn or<P, B, E>(self, other: P) -> Or<T, P>
where + T: Policy<B, E>, + P: Policy<B, E>,

Create a new Policy that returns [Action::Follow] if either self or other returns +Action::Follow. Read more
Source§

impl<T> Same for T

Source§

type Output = T

Should always be Self
§

impl<T> ScopeObject for T
where + T: Send + Sync + Debug + DeserializeOwned + 'static,

§

type Error = Error

The error type.
§

fn deserialize<R>( + _app: &AppHandle<R>, + raw: Value, +) -> Result<T, <T as ScopeObject>::Error>
where + R: Runtime,

Deserialize the raw scope value.
Source§

impl<T> Serialize for T
where + T: Serialize + ?Sized,

Source§

fn erased_serialize(&self, serializer: &mut dyn Serializer) -> Result<(), Error>

Source§

fn do_erased_serialize( + &self, + serializer: &mut dyn Serializer, +) -> Result<(), ErrorImpl>

Source§

impl<T> ToOwned for T
where + T: Clone,

Source§

type Owned = T

The resulting type after obtaining ownership.
Source§

fn to_owned(&self) -> T

Creates owned data from borrowed data, usually by cloning. Read more
Source§

fn clone_into(&self, target: &mut T)

Uses borrowed data to replace owned data, usually by cloning. Read more
Source§

impl<T, U> TryFrom<U> for T
where + U: Into<T>,

Source§

type Error = Infallible

The type returned in the event of a conversion error.
Source§

fn try_from(value: U) -> Result<T, <T as TryFrom<U>>::Error>

Performs the conversion.
Source§

impl<T, U> TryInto<U> for T
where + U: TryFrom<T>,

Source§

type Error = <U as TryFrom<T>>::Error

The type returned in the event of a conversion error.
Source§

fn try_into(self) -> Result<U, <U as TryFrom<T>>::Error>

Performs the conversion.
§

impl<V, T> VZip<V> for T
where + V: MultiLane<T>,

§

fn vzip(self) -> V

§

impl<T> WithSubscriber for T

§

fn with_subscriber<S>(self, subscriber: S) -> WithDispatch<Self>
where + S: Into<Dispatch>,

Attaches the provided Subscriber to this type, returning a +[WithDispatch] wrapper. Read more
§

fn with_current_subscriber(self) -> WithDispatch<Self>

Attaches the current default Subscriber to this type, returning a +[WithDispatch] wrapper. Read more
Source§

impl<T> DeserializeOwned for T
where + T: for<'de> Deserialize<'de>,

§

impl<T> UserEvent for T
where + T: Debug + Clone + Send + 'static,

\ No newline at end of file diff --git a/api/jellytau_lib/repository/types/struct.Person.html b/api/jellytau_lib/repository/types/struct.Person.html new file mode 100644 index 00000000..8c5459e2 --- /dev/null +++ b/api/jellytau_lib/repository/types/struct.Person.html @@ -0,0 +1,69 @@ +Person in jellytau_lib::repository::types - RustSkip to main content

Person

Struct Person 

Source
pub struct Person {
+    pub id: String,
+    pub name: String,
+    pub person_type: String,
+    pub role: Option<String>,
+    pub primary_image_tag: Option<String>,
+}
Expand description

Person (cast/crew member) - for movies, series, and episodes

+

Fields§

§id: String

Deserializes from API’s “Id” field (PascalCase), serializes as “id” (camelCase to frontend)

+
§name: String

Deserializes from API’s “Name” field (PascalCase), serializes as “name” (camelCase to frontend)

+
§person_type: String

Person type from Jellyfin API (Actor, Director, Writer, etc.) +Deserializes from API’s “Type” field (PascalCase), serializes as “type” (camelCase to frontend)

+
§role: Option<String>

Deserializes from API’s “Role” field (PascalCase), serializes as “role” (camelCase to frontend)

+
§primary_image_tag: Option<String>

Deserializes from API’s “PrimaryImageTag” field (PascalCase), serializes as “primaryImageTag” (camelCase to frontend)

+

Trait Implementations§

Source§

impl Clone for Person

Source§

fn clone(&self) -> Person

Returns a duplicate of the value. Read more
1.0.0 (const: unstable) · Source§

fn clone_from(&mut self, source: &Self)

Performs copy-assignment from source. Read more
Source§

impl Debug for Person

Source§

fn fmt(&self, f: &mut Formatter<'_>) -> Result

Formats the value using the given formatter. Read more
Source§

impl<'de> Deserialize<'de> for Person

Source§

fn deserialize<__D>(__deserializer: __D) -> Result<Self, __D::Error>
where + __D: Deserializer<'de>,

Deserialize this value from the given Serde deserializer. Read more
Source§

impl NamedType for Person

Source§

fn sid() -> SpectaID

Source§

fn named_data_type( + type_map: &mut TypeCollection, + generics: &[DataType], +) -> NamedDataType

this is equivalent to [Type::inline] but returns a [NamedDataType] instead.
Source§

fn definition_named_data_type(type_map: &mut TypeCollection) -> NamedDataType

this is equivalent to [Type::definition] but returns a [NamedDataType] instead.
Source§

impl PartialEq for Person

Source§

fn eq(&self, other: &Person) -> bool

Tests for self and other values to be equal, and is used by ==.
1.0.0 (const: unstable) · Source§

fn ne(&self, other: &Rhs) -> bool

Tests for !=. The default implementation is almost always sufficient, +and should not be overridden without very good reason.
Source§

impl Serialize for Person

Source§

fn serialize<__S>(&self, __serializer: __S) -> Result<__S::Ok, __S::Error>
where + __S: Serializer,

Serialize this value into the given Serde serializer. Read more
Source§

impl Type for Person

Source§

fn inline(type_map: &mut TypeCollection, generics: Generics<'_>) -> DataType

Returns the definition of a type using the provided generics. Read more
Source§

fn reference(type_map: &mut TypeCollection, generics: &[DataType]) -> Reference

Generates a datatype corresponding to a reference to this type, +as determined by its category. Getting a reference to a type implies that +it should belong in the type map (since it has to be referenced from somewhere), +so the output of definition will be put into the type map.
Source§

impl Eq for Person

Source§

impl Flatten for Person

Source§

impl StructuralPartialEq for Person

Auto Trait Implementations§

Blanket Implementations§

Source§

impl<T> Any for T
where + T: 'static + ?Sized,

Source§

fn type_id(&self) -> TypeId

Gets the TypeId of self. Read more
Source§

impl<T> Borrow<T> for T
where + T: ?Sized,

Source§

fn borrow(&self) -> &T

Immutably borrows from an owned value. Read more
Source§

impl<T> BorrowMut<T> for T
where + T: ?Sized,

Source§

fn borrow_mut(&mut self) -> &mut T

Mutably borrows from an owned value. Read more
Source§

impl<T> CloneToUninit for T
where + T: Clone,

Source§

unsafe fn clone_to_uninit(&self, dest: *mut u8)

🔬This is a nightly-only experimental API. (clone_to_uninit)
Performs copy-assignment from self to dest. Read more
§

impl<'de, D, R> CommandArg<'de, R> for D
where + D: Deserialize<'de>, + R: Runtime,

§

fn from_command(command: CommandItem<'de, R>) -> Result<D, InvokeError>

Derives an instance of Self from the [CommandItem]. Read more
§

impl<Q, K> Equivalent<K> for Q
where + Q: Eq + ?Sized, + K: Borrow<Q> + ?Sized,

§

fn equivalent(&self, key: &K) -> bool

Checks if this value is equivalent to the given key. Read more
§

impl<Q, K> Equivalent<K> for Q
where + Q: Eq + ?Sized, + K: Borrow<Q> + ?Sized,

§

fn equivalent(&self, key: &K) -> bool

Compare self to key and return true if they are equal.
§

impl<Q, K> Equivalent<K> for Q
where + Q: Eq + ?Sized, + K: Borrow<Q> + ?Sized,

§

fn equivalent(&self, key: &K) -> bool

Checks if this value is equivalent to the given key. Read more
Source§

impl<T> From<T> for T

Source§

fn from(t: T) -> T

Returns the argument unchanged.

+
§

impl<T> FunctionArg for T
where + T: Type,

§

fn to_datatype(type_map: &mut TypeCollection) -> Option<DataType>

Gets the type of an argument as a [DataType]. Read more
§

impl<T> Instrument for T

§

fn instrument(self, span: Span) -> Instrumented<Self>

Instruments this type with the provided [Span], returning an +Instrumented wrapper. Read more
§

fn in_current_span(self) -> Instrumented<Self>

Instruments this type with the current Span, returning an +Instrumented wrapper. Read more
Source§

impl<T, U> Into<U> for T
where + U: From<T>,

Source§

fn into(self) -> U

Calls U::from(self).

+

That is, this conversion is whatever the implementation of +From<T> for U chooses to do.

+
§

impl<T> IpcResponse for T
where + T: Serialize,

§

fn body(self) -> Result<InvokeResponseBody, Error>

Resolve the IPC response body.
§

impl<T> PolicyExt for T
where + T: ?Sized,

§

fn and<P, B, E>(self, other: P) -> And<T, P>
where + T: Policy<B, E>, + P: Policy<B, E>,

Create a new Policy that returns [Action::Follow] only if self and other return +Action::Follow. Read more
§

fn or<P, B, E>(self, other: P) -> Or<T, P>
where + T: Policy<B, E>, + P: Policy<B, E>,

Create a new Policy that returns [Action::Follow] if either self or other returns +Action::Follow. Read more
Source§

impl<T> Same for T

Source§

type Output = T

Should always be Self
§

impl<T> ScopeObject for T
where + T: Send + Sync + Debug + DeserializeOwned + 'static,

§

type Error = Error

The error type.
§

fn deserialize<R>( + _app: &AppHandle<R>, + raw: Value, +) -> Result<T, <T as ScopeObject>::Error>
where + R: Runtime,

Deserialize the raw scope value.
Source§

impl<T> Serialize for T
where + T: Serialize + ?Sized,

Source§

fn erased_serialize(&self, serializer: &mut dyn Serializer) -> Result<(), Error>

Source§

fn do_erased_serialize( + &self, + serializer: &mut dyn Serializer, +) -> Result<(), ErrorImpl>

Source§

impl<T> ToOwned for T
where + T: Clone,

Source§

type Owned = T

The resulting type after obtaining ownership.
Source§

fn to_owned(&self) -> T

Creates owned data from borrowed data, usually by cloning. Read more
Source§

fn clone_into(&self, target: &mut T)

Uses borrowed data to replace owned data, usually by cloning. Read more
Source§

impl<T, U> TryFrom<U> for T
where + U: Into<T>,

Source§

type Error = Infallible

The type returned in the event of a conversion error.
Source§

fn try_from(value: U) -> Result<T, <T as TryFrom<U>>::Error>

Performs the conversion.
Source§

impl<T, U> TryInto<U> for T
where + U: TryFrom<T>,

Source§

type Error = <U as TryFrom<T>>::Error

The type returned in the event of a conversion error.
Source§

fn try_into(self) -> Result<U, <U as TryFrom<T>>::Error>

Performs the conversion.
§

impl<V, T> VZip<V> for T
where + V: MultiLane<T>,

§

fn vzip(self) -> V

§

impl<T> WithSubscriber for T

§

fn with_subscriber<S>(self, subscriber: S) -> WithDispatch<Self>
where + S: Into<Dispatch>,

Attaches the provided Subscriber to this type, returning a +[WithDispatch] wrapper. Read more
§

fn with_current_subscriber(self) -> WithDispatch<Self>

Attaches the current default Subscriber to this type, returning a +[WithDispatch] wrapper. Read more
Source§

impl<T> DeserializeOwned for T
where + T: for<'de> Deserialize<'de>,

§

impl<T> UserEvent for T
where + T: Debug + Clone + Send + 'static,

\ No newline at end of file diff --git a/api/jellytau_lib/repository/types/struct.PlaybackInfo.html b/api/jellytau_lib/repository/types/struct.PlaybackInfo.html new file mode 100644 index 00000000..de30f60b --- /dev/null +++ b/api/jellytau_lib/repository/types/struct.PlaybackInfo.html @@ -0,0 +1,56 @@ +PlaybackInfo in jellytau_lib::repository::types - RustSkip to main content

PlaybackInfo

Struct PlaybackInfo 

Source
pub struct PlaybackInfo {
+    pub media_source_id: String,
+    pub play_session_id: String,
+    pub stream_url: String,
+    pub direct_play: bool,
+    pub needs_transcoding: bool,
+}
Expand description

Playback information

+

Fields§

§media_source_id: String§play_session_id: String§stream_url: String§direct_play: bool§needs_transcoding: bool

Trait Implementations§

Source§

impl Clone for PlaybackInfo

Source§

fn clone(&self) -> PlaybackInfo

Returns a duplicate of the value. Read more
1.0.0 (const: unstable) · Source§

fn clone_from(&mut self, source: &Self)

Performs copy-assignment from source. Read more
Source§

impl Debug for PlaybackInfo

Source§

fn fmt(&self, f: &mut Formatter<'_>) -> Result

Formats the value using the given formatter. Read more
Source§

impl<'de> Deserialize<'de> for PlaybackInfo

Source§

fn deserialize<__D>(__deserializer: __D) -> Result<Self, __D::Error>
where + __D: Deserializer<'de>,

Deserialize this value from the given Serde deserializer. Read more
Source§

impl MeaningfulContent for PlaybackInfo

Source§

impl NamedType for PlaybackInfo

Source§

fn sid() -> SpectaID

Source§

fn named_data_type( + type_map: &mut TypeCollection, + generics: &[DataType], +) -> NamedDataType

this is equivalent to [Type::inline] but returns a [NamedDataType] instead.
Source§

fn definition_named_data_type(type_map: &mut TypeCollection) -> NamedDataType

this is equivalent to [Type::definition] but returns a [NamedDataType] instead.
Source§

impl Serialize for PlaybackInfo

Source§

fn serialize<__S>(&self, __serializer: __S) -> Result<__S::Ok, __S::Error>
where + __S: Serializer,

Serialize this value into the given Serde serializer. Read more
Source§

impl Type for PlaybackInfo

Source§

fn inline(type_map: &mut TypeCollection, generics: Generics<'_>) -> DataType

Returns the definition of a type using the provided generics. Read more
Source§

fn reference(type_map: &mut TypeCollection, generics: &[DataType]) -> Reference

Generates a datatype corresponding to a reference to this type, +as determined by its category. Getting a reference to a type implies that +it should belong in the type map (since it has to be referenced from somewhere), +so the output of definition will be put into the type map.
Source§

impl Flatten for PlaybackInfo

Auto Trait Implementations§

Blanket Implementations§

Source§

impl<T> Any for T
where + T: 'static + ?Sized,

Source§

fn type_id(&self) -> TypeId

Gets the TypeId of self. Read more
Source§

impl<T> Borrow<T> for T
where + T: ?Sized,

Source§

fn borrow(&self) -> &T

Immutably borrows from an owned value. Read more
Source§

impl<T> BorrowMut<T> for T
where + T: ?Sized,

Source§

fn borrow_mut(&mut self) -> &mut T

Mutably borrows from an owned value. Read more
Source§

impl<T> CloneToUninit for T
where + T: Clone,

Source§

unsafe fn clone_to_uninit(&self, dest: *mut u8)

🔬This is a nightly-only experimental API. (clone_to_uninit)
Performs copy-assignment from self to dest. Read more
§

impl<'de, D, R> CommandArg<'de, R> for D
where + D: Deserialize<'de>, + R: Runtime,

§

fn from_command(command: CommandItem<'de, R>) -> Result<D, InvokeError>

Derives an instance of Self from the [CommandItem]. Read more
Source§

impl<T> From<T> for T

Source§

fn from(t: T) -> T

Returns the argument unchanged.

+
§

impl<T> FunctionArg for T
where + T: Type,

§

fn to_datatype(type_map: &mut TypeCollection) -> Option<DataType>

Gets the type of an argument as a [DataType]. Read more
§

impl<T> Instrument for T

§

fn instrument(self, span: Span) -> Instrumented<Self>

Instruments this type with the provided [Span], returning an +Instrumented wrapper. Read more
§

fn in_current_span(self) -> Instrumented<Self>

Instruments this type with the current Span, returning an +Instrumented wrapper. Read more
Source§

impl<T, U> Into<U> for T
where + U: From<T>,

Source§

fn into(self) -> U

Calls U::from(self).

+

That is, this conversion is whatever the implementation of +From<T> for U chooses to do.

+
§

impl<T> IpcResponse for T
where + T: Serialize,

§

fn body(self) -> Result<InvokeResponseBody, Error>

Resolve the IPC response body.
§

impl<T> PolicyExt for T
where + T: ?Sized,

§

fn and<P, B, E>(self, other: P) -> And<T, P>
where + T: Policy<B, E>, + P: Policy<B, E>,

Create a new Policy that returns [Action::Follow] only if self and other return +Action::Follow. Read more
§

fn or<P, B, E>(self, other: P) -> Or<T, P>
where + T: Policy<B, E>, + P: Policy<B, E>,

Create a new Policy that returns [Action::Follow] if either self or other returns +Action::Follow. Read more
Source§

impl<T> Same for T

Source§

type Output = T

Should always be Self
§

impl<T> ScopeObject for T
where + T: Send + Sync + Debug + DeserializeOwned + 'static,

§

type Error = Error

The error type.
§

fn deserialize<R>( + _app: &AppHandle<R>, + raw: Value, +) -> Result<T, <T as ScopeObject>::Error>
where + R: Runtime,

Deserialize the raw scope value.
Source§

impl<T> Serialize for T
where + T: Serialize + ?Sized,

Source§

fn erased_serialize(&self, serializer: &mut dyn Serializer) -> Result<(), Error>

Source§

fn do_erased_serialize( + &self, + serializer: &mut dyn Serializer, +) -> Result<(), ErrorImpl>

Source§

impl<T> ToOwned for T
where + T: Clone,

Source§

type Owned = T

The resulting type after obtaining ownership.
Source§

fn to_owned(&self) -> T

Creates owned data from borrowed data, usually by cloning. Read more
Source§

fn clone_into(&self, target: &mut T)

Uses borrowed data to replace owned data, usually by cloning. Read more
Source§

impl<T, U> TryFrom<U> for T
where + U: Into<T>,

Source§

type Error = Infallible

The type returned in the event of a conversion error.
Source§

fn try_from(value: U) -> Result<T, <T as TryFrom<U>>::Error>

Performs the conversion.
Source§

impl<T, U> TryInto<U> for T
where + U: TryFrom<T>,

Source§

type Error = <U as TryFrom<T>>::Error

The type returned in the event of a conversion error.
Source§

fn try_into(self) -> Result<U, <U as TryFrom<T>>::Error>

Performs the conversion.
§

impl<V, T> VZip<V> for T
where + V: MultiLane<T>,

§

fn vzip(self) -> V

§

impl<T> WithSubscriber for T

§

fn with_subscriber<S>(self, subscriber: S) -> WithDispatch<Self>
where + S: Into<Dispatch>,

Attaches the provided Subscriber to this type, returning a +[WithDispatch] wrapper. Read more
§

fn with_current_subscriber(self) -> WithDispatch<Self>

Attaches the current default Subscriber to this type, returning a +[WithDispatch] wrapper. Read more
Source§

impl<T> DeserializeOwned for T
where + T: for<'de> Deserialize<'de>,

§

impl<T> UserEvent for T
where + T: Debug + Clone + Send + 'static,

\ No newline at end of file diff --git a/api/jellytau_lib/repository/types/struct.PlaylistCreatedResult.html b/api/jellytau_lib/repository/types/struct.PlaylistCreatedResult.html new file mode 100644 index 00000000..a140c52e --- /dev/null +++ b/api/jellytau_lib/repository/types/struct.PlaylistCreatedResult.html @@ -0,0 +1,53 @@ +PlaylistCreatedResult in jellytau_lib::repository::types - RustSkip to main content

PlaylistCreatedResult

Struct PlaylistCreatedResult 

Source
pub struct PlaylistCreatedResult {
+    pub id: String,
+}
Expand description

Result of creating a playlist

+

@req: JA-019 - Get/create/update playlists

+

Fields§

§id: String

Trait Implementations§

Source§

impl Clone for PlaylistCreatedResult

Source§

fn clone(&self) -> PlaylistCreatedResult

Returns a duplicate of the value. Read more
1.0.0 (const: unstable) · Source§

fn clone_from(&mut self, source: &Self)

Performs copy-assignment from source. Read more
Source§

impl Debug for PlaylistCreatedResult

Source§

fn fmt(&self, f: &mut Formatter<'_>) -> Result

Formats the value using the given formatter. Read more
Source§

impl<'de> Deserialize<'de> for PlaylistCreatedResult

Source§

fn deserialize<__D>(__deserializer: __D) -> Result<Self, __D::Error>
where + __D: Deserializer<'de>,

Deserialize this value from the given Serde deserializer. Read more
Source§

impl MeaningfulContent for PlaylistCreatedResult

Source§

impl NamedType for PlaylistCreatedResult

Source§

fn sid() -> SpectaID

Source§

fn named_data_type( + type_map: &mut TypeCollection, + generics: &[DataType], +) -> NamedDataType

this is equivalent to [Type::inline] but returns a [NamedDataType] instead.
Source§

fn definition_named_data_type(type_map: &mut TypeCollection) -> NamedDataType

this is equivalent to [Type::definition] but returns a [NamedDataType] instead.
Source§

impl Serialize for PlaylistCreatedResult

Source§

fn serialize<__S>(&self, __serializer: __S) -> Result<__S::Ok, __S::Error>
where + __S: Serializer,

Serialize this value into the given Serde serializer. Read more
Source§

impl Type for PlaylistCreatedResult

Source§

fn inline(type_map: &mut TypeCollection, generics: Generics<'_>) -> DataType

Returns the definition of a type using the provided generics. Read more
Source§

fn reference(type_map: &mut TypeCollection, generics: &[DataType]) -> Reference

Generates a datatype corresponding to a reference to this type, +as determined by its category. Getting a reference to a type implies that +it should belong in the type map (since it has to be referenced from somewhere), +so the output of definition will be put into the type map.
Source§

impl Flatten for PlaylistCreatedResult

Auto Trait Implementations§

Blanket Implementations§

Source§

impl<T> Any for T
where + T: 'static + ?Sized,

Source§

fn type_id(&self) -> TypeId

Gets the TypeId of self. Read more
Source§

impl<T> Borrow<T> for T
where + T: ?Sized,

Source§

fn borrow(&self) -> &T

Immutably borrows from an owned value. Read more
Source§

impl<T> BorrowMut<T> for T
where + T: ?Sized,

Source§

fn borrow_mut(&mut self) -> &mut T

Mutably borrows from an owned value. Read more
Source§

impl<T> CloneToUninit for T
where + T: Clone,

Source§

unsafe fn clone_to_uninit(&self, dest: *mut u8)

🔬This is a nightly-only experimental API. (clone_to_uninit)
Performs copy-assignment from self to dest. Read more
§

impl<'de, D, R> CommandArg<'de, R> for D
where + D: Deserialize<'de>, + R: Runtime,

§

fn from_command(command: CommandItem<'de, R>) -> Result<D, InvokeError>

Derives an instance of Self from the [CommandItem]. Read more
Source§

impl<T> From<T> for T

Source§

fn from(t: T) -> T

Returns the argument unchanged.

+
§

impl<T> FunctionArg for T
where + T: Type,

§

fn to_datatype(type_map: &mut TypeCollection) -> Option<DataType>

Gets the type of an argument as a [DataType]. Read more
§

impl<T> Instrument for T

§

fn instrument(self, span: Span) -> Instrumented<Self>

Instruments this type with the provided [Span], returning an +Instrumented wrapper. Read more
§

fn in_current_span(self) -> Instrumented<Self>

Instruments this type with the current Span, returning an +Instrumented wrapper. Read more
Source§

impl<T, U> Into<U> for T
where + U: From<T>,

Source§

fn into(self) -> U

Calls U::from(self).

+

That is, this conversion is whatever the implementation of +From<T> for U chooses to do.

+
§

impl<T> IpcResponse for T
where + T: Serialize,

§

fn body(self) -> Result<InvokeResponseBody, Error>

Resolve the IPC response body.
§

impl<T> PolicyExt for T
where + T: ?Sized,

§

fn and<P, B, E>(self, other: P) -> And<T, P>
where + T: Policy<B, E>, + P: Policy<B, E>,

Create a new Policy that returns [Action::Follow] only if self and other return +Action::Follow. Read more
§

fn or<P, B, E>(self, other: P) -> Or<T, P>
where + T: Policy<B, E>, + P: Policy<B, E>,

Create a new Policy that returns [Action::Follow] if either self or other returns +Action::Follow. Read more
Source§

impl<T> Same for T

Source§

type Output = T

Should always be Self
§

impl<T> ScopeObject for T
where + T: Send + Sync + Debug + DeserializeOwned + 'static,

§

type Error = Error

The error type.
§

fn deserialize<R>( + _app: &AppHandle<R>, + raw: Value, +) -> Result<T, <T as ScopeObject>::Error>
where + R: Runtime,

Deserialize the raw scope value.
Source§

impl<T> Serialize for T
where + T: Serialize + ?Sized,

Source§

fn erased_serialize(&self, serializer: &mut dyn Serializer) -> Result<(), Error>

Source§

fn do_erased_serialize( + &self, + serializer: &mut dyn Serializer, +) -> Result<(), ErrorImpl>

Source§

impl<T> ToOwned for T
where + T: Clone,

Source§

type Owned = T

The resulting type after obtaining ownership.
Source§

fn to_owned(&self) -> T

Creates owned data from borrowed data, usually by cloning. Read more
Source§

fn clone_into(&self, target: &mut T)

Uses borrowed data to replace owned data, usually by cloning. Read more
Source§

impl<T, U> TryFrom<U> for T
where + U: Into<T>,

Source§

type Error = Infallible

The type returned in the event of a conversion error.
Source§

fn try_from(value: U) -> Result<T, <T as TryFrom<U>>::Error>

Performs the conversion.
Source§

impl<T, U> TryInto<U> for T
where + U: TryFrom<T>,

Source§

type Error = <U as TryFrom<T>>::Error

The type returned in the event of a conversion error.
Source§

fn try_into(self) -> Result<U, <U as TryFrom<T>>::Error>

Performs the conversion.
§

impl<V, T> VZip<V> for T
where + V: MultiLane<T>,

§

fn vzip(self) -> V

§

impl<T> WithSubscriber for T

§

fn with_subscriber<S>(self, subscriber: S) -> WithDispatch<Self>
where + S: Into<Dispatch>,

Attaches the provided Subscriber to this type, returning a +[WithDispatch] wrapper. Read more
§

fn with_current_subscriber(self) -> WithDispatch<Self>

Attaches the current default Subscriber to this type, returning a +[WithDispatch] wrapper. Read more
Source§

impl<T> DeserializeOwned for T
where + T: for<'de> Deserialize<'de>,

§

impl<T> UserEvent for T
where + T: Debug + Clone + Send + 'static,

\ No newline at end of file diff --git a/api/jellytau_lib/repository/types/struct.PlaylistEntry.html b/api/jellytau_lib/repository/types/struct.PlaylistEntry.html new file mode 100644 index 00000000..a8a71156 --- /dev/null +++ b/api/jellytau_lib/repository/types/struct.PlaylistEntry.html @@ -0,0 +1,57 @@ +PlaylistEntry in jellytau_lib::repository::types - RustSkip to main content

PlaylistEntry

Struct PlaylistEntry 

Source
pub struct PlaylistEntry {
+    pub playlist_item_id: String,
+    pub item: MediaItem,
+}
Expand description

Playlist entry — wraps a MediaItem with the Jellyfin PlaylistItemId +needed for remove/reorder operations (distinct from the media item’s ID)

+

@req: UR-014 - Make and edit playlists of music that sync back to Jellyfin

+

Fields§

§playlist_item_id: String

The playlist-scoped entry ID (Jellyfin’s PlaylistItemId)

+
§item: MediaItem

The underlying media item

+

Trait Implementations§

Source§

impl Clone for PlaylistEntry

Source§

fn clone(&self) -> PlaylistEntry

Returns a duplicate of the value. Read more
1.0.0 (const: unstable) · Source§

fn clone_from(&mut self, source: &Self)

Performs copy-assignment from source. Read more
Source§

impl Debug for PlaylistEntry

Source§

fn fmt(&self, f: &mut Formatter<'_>) -> Result

Formats the value using the given formatter. Read more
Source§

impl<'de> Deserialize<'de> for PlaylistEntry

Source§

fn deserialize<__D>(__deserializer: __D) -> Result<Self, __D::Error>
where + __D: Deserializer<'de>,

Deserialize this value from the given Serde deserializer. Read more
Source§

impl NamedType for PlaylistEntry

Source§

fn sid() -> SpectaID

Source§

fn named_data_type( + type_map: &mut TypeCollection, + generics: &[DataType], +) -> NamedDataType

this is equivalent to [Type::inline] but returns a [NamedDataType] instead.
Source§

fn definition_named_data_type(type_map: &mut TypeCollection) -> NamedDataType

this is equivalent to [Type::definition] but returns a [NamedDataType] instead.
Source§

impl Serialize for PlaylistEntry

Source§

fn serialize<__S>(&self, __serializer: __S) -> Result<__S::Ok, __S::Error>
where + __S: Serializer,

Serialize this value into the given Serde serializer. Read more
Source§

impl Type for PlaylistEntry

Source§

fn inline(type_map: &mut TypeCollection, generics: Generics<'_>) -> DataType

Returns the definition of a type using the provided generics. Read more
Source§

fn reference(type_map: &mut TypeCollection, generics: &[DataType]) -> Reference

Generates a datatype corresponding to a reference to this type, +as determined by its category. Getting a reference to a type implies that +it should belong in the type map (since it has to be referenced from somewhere), +so the output of definition will be put into the type map.
Source§

impl Flatten for PlaylistEntry

Auto Trait Implementations§

Blanket Implementations§

Source§

impl<T> Any for T
where + T: 'static + ?Sized,

Source§

fn type_id(&self) -> TypeId

Gets the TypeId of self. Read more
Source§

impl<T> Borrow<T> for T
where + T: ?Sized,

Source§

fn borrow(&self) -> &T

Immutably borrows from an owned value. Read more
Source§

impl<T> BorrowMut<T> for T
where + T: ?Sized,

Source§

fn borrow_mut(&mut self) -> &mut T

Mutably borrows from an owned value. Read more
Source§

impl<T> CloneToUninit for T
where + T: Clone,

Source§

unsafe fn clone_to_uninit(&self, dest: *mut u8)

🔬This is a nightly-only experimental API. (clone_to_uninit)
Performs copy-assignment from self to dest. Read more
§

impl<'de, D, R> CommandArg<'de, R> for D
where + D: Deserialize<'de>, + R: Runtime,

§

fn from_command(command: CommandItem<'de, R>) -> Result<D, InvokeError>

Derives an instance of Self from the [CommandItem]. Read more
Source§

impl<T> From<T> for T

Source§

fn from(t: T) -> T

Returns the argument unchanged.

+
§

impl<T> FunctionArg for T
where + T: Type,

§

fn to_datatype(type_map: &mut TypeCollection) -> Option<DataType>

Gets the type of an argument as a [DataType]. Read more
§

impl<T> Instrument for T

§

fn instrument(self, span: Span) -> Instrumented<Self>

Instruments this type with the provided [Span], returning an +Instrumented wrapper. Read more
§

fn in_current_span(self) -> Instrumented<Self>

Instruments this type with the current Span, returning an +Instrumented wrapper. Read more
Source§

impl<T, U> Into<U> for T
where + U: From<T>,

Source§

fn into(self) -> U

Calls U::from(self).

+

That is, this conversion is whatever the implementation of +From<T> for U chooses to do.

+
§

impl<T> IpcResponse for T
where + T: Serialize,

§

fn body(self) -> Result<InvokeResponseBody, Error>

Resolve the IPC response body.
§

impl<T> PolicyExt for T
where + T: ?Sized,

§

fn and<P, B, E>(self, other: P) -> And<T, P>
where + T: Policy<B, E>, + P: Policy<B, E>,

Create a new Policy that returns [Action::Follow] only if self and other return +Action::Follow. Read more
§

fn or<P, B, E>(self, other: P) -> Or<T, P>
where + T: Policy<B, E>, + P: Policy<B, E>,

Create a new Policy that returns [Action::Follow] if either self or other returns +Action::Follow. Read more
Source§

impl<T> Same for T

Source§

type Output = T

Should always be Self
§

impl<T> ScopeObject for T
where + T: Send + Sync + Debug + DeserializeOwned + 'static,

§

type Error = Error

The error type.
§

fn deserialize<R>( + _app: &AppHandle<R>, + raw: Value, +) -> Result<T, <T as ScopeObject>::Error>
where + R: Runtime,

Deserialize the raw scope value.
Source§

impl<T> Serialize for T
where + T: Serialize + ?Sized,

Source§

fn erased_serialize(&self, serializer: &mut dyn Serializer) -> Result<(), Error>

Source§

fn do_erased_serialize( + &self, + serializer: &mut dyn Serializer, +) -> Result<(), ErrorImpl>

Source§

impl<T> ToOwned for T
where + T: Clone,

Source§

type Owned = T

The resulting type after obtaining ownership.
Source§

fn to_owned(&self) -> T

Creates owned data from borrowed data, usually by cloning. Read more
Source§

fn clone_into(&self, target: &mut T)

Uses borrowed data to replace owned data, usually by cloning. Read more
Source§

impl<T, U> TryFrom<U> for T
where + U: Into<T>,

Source§

type Error = Infallible

The type returned in the event of a conversion error.
Source§

fn try_from(value: U) -> Result<T, <T as TryFrom<U>>::Error>

Performs the conversion.
Source§

impl<T, U> TryInto<U> for T
where + U: TryFrom<T>,

Source§

type Error = <U as TryFrom<T>>::Error

The type returned in the event of a conversion error.
Source§

fn try_into(self) -> Result<U, <U as TryFrom<T>>::Error>

Performs the conversion.
§

impl<V, T> VZip<V> for T
where + V: MultiLane<T>,

§

fn vzip(self) -> V

§

impl<T> WithSubscriber for T

§

fn with_subscriber<S>(self, subscriber: S) -> WithDispatch<Self>
where + S: Into<Dispatch>,

Attaches the provided Subscriber to this type, returning a +[WithDispatch] wrapper. Read more
§

fn with_current_subscriber(self) -> WithDispatch<Self>

Attaches the current default Subscriber to this type, returning a +[WithDispatch] wrapper. Read more
Source§

impl<T> DeserializeOwned for T
where + T: for<'de> Deserialize<'de>,

§

impl<T> UserEvent for T
where + T: Debug + Clone + Send + 'static,

\ No newline at end of file diff --git a/api/jellytau_lib/repository/types/struct.SearchOptions.html b/api/jellytau_lib/repository/types/struct.SearchOptions.html new file mode 100644 index 00000000..0491fadc --- /dev/null +++ b/api/jellytau_lib/repository/types/struct.SearchOptions.html @@ -0,0 +1,64 @@ +SearchOptions in jellytau_lib::repository::types - RustSkip to main content

SearchOptions

Struct SearchOptions 

Source
pub struct SearchOptions {
+    pub limit: Option<usize>,
+    pub include_item_types: Option<Vec<String>>,
+    pub search_term: Option<String>,
+    pub scope: Option<SearchScope>,
+}
Expand description

Options for search queries

+

Fields§

§limit: Option<usize>§include_item_types: Option<Vec<String>>§search_term: Option<String>§scope: Option<SearchScope>

Opaque scope selected by the UI. When set it wins over +include_item_types, which remains for the non-search get_items +callers that legitimately request a single concrete type.

+

Implementations§

Source§

impl SearchOptions

Source

pub fn resolve_scope(&mut self)

Expand scope into include_item_types in place.

+

Call this once, in the search command, before dispatching to the +cache and server paths — both already honour include_item_types, and +resolving in one place keeps online and offline results identical.

+

TRACES: UR-049 | DR-063

+

Trait Implementations§

Source§

impl Clone for SearchOptions

Source§

fn clone(&self) -> SearchOptions

Returns a duplicate of the value. Read more
1.0.0 (const: unstable) · Source§

fn clone_from(&mut self, source: &Self)

Performs copy-assignment from source. Read more
Source§

impl Debug for SearchOptions

Source§

fn fmt(&self, f: &mut Formatter<'_>) -> Result

Formats the value using the given formatter. Read more
Source§

impl Default for SearchOptions

Source§

fn default() -> SearchOptions

Returns the “default value” for a type. Read more
Source§

impl<'de> Deserialize<'de> for SearchOptions

Source§

fn deserialize<__D>(__deserializer: __D) -> Result<Self, __D::Error>
where + __D: Deserializer<'de>,

Deserialize this value from the given Serde deserializer. Read more
Source§

impl NamedType for SearchOptions

Source§

fn sid() -> SpectaID

Source§

fn named_data_type( + type_map: &mut TypeCollection, + generics: &[DataType], +) -> NamedDataType

this is equivalent to [Type::inline] but returns a [NamedDataType] instead.
Source§

fn definition_named_data_type(type_map: &mut TypeCollection) -> NamedDataType

this is equivalent to [Type::definition] but returns a [NamedDataType] instead.
Source§

impl Serialize for SearchOptions

Source§

fn serialize<__S>(&self, __serializer: __S) -> Result<__S::Ok, __S::Error>
where + __S: Serializer,

Serialize this value into the given Serde serializer. Read more
Source§

impl Type for SearchOptions

Source§

fn inline(type_map: &mut TypeCollection, generics: Generics<'_>) -> DataType

Returns the definition of a type using the provided generics. Read more
Source§

fn reference(type_map: &mut TypeCollection, generics: &[DataType]) -> Reference

Generates a datatype corresponding to a reference to this type, +as determined by its category. Getting a reference to a type implies that +it should belong in the type map (since it has to be referenced from somewhere), +so the output of definition will be put into the type map.
Source§

impl Flatten for SearchOptions

Auto Trait Implementations§

Blanket Implementations§

Source§

impl<T> Any for T
where + T: 'static + ?Sized,

Source§

fn type_id(&self) -> TypeId

Gets the TypeId of self. Read more
Source§

impl<T> Borrow<T> for T
where + T: ?Sized,

Source§

fn borrow(&self) -> &T

Immutably borrows from an owned value. Read more
Source§

impl<T> BorrowMut<T> for T
where + T: ?Sized,

Source§

fn borrow_mut(&mut self) -> &mut T

Mutably borrows from an owned value. Read more
Source§

impl<T> CloneToUninit for T
where + T: Clone,

Source§

unsafe fn clone_to_uninit(&self, dest: *mut u8)

🔬This is a nightly-only experimental API. (clone_to_uninit)
Performs copy-assignment from self to dest. Read more
§

impl<'de, D, R> CommandArg<'de, R> for D
where + D: Deserialize<'de>, + R: Runtime,

§

fn from_command(command: CommandItem<'de, R>) -> Result<D, InvokeError>

Derives an instance of Self from the [CommandItem]. Read more
Source§

impl<T> From<T> for T

Source§

fn from(t: T) -> T

Returns the argument unchanged.

+
§

impl<T> FunctionArg for T
where + T: Type,

§

fn to_datatype(type_map: &mut TypeCollection) -> Option<DataType>

Gets the type of an argument as a [DataType]. Read more
§

impl<T> Instrument for T

§

fn instrument(self, span: Span) -> Instrumented<Self>

Instruments this type with the provided [Span], returning an +Instrumented wrapper. Read more
§

fn in_current_span(self) -> Instrumented<Self>

Instruments this type with the current Span, returning an +Instrumented wrapper. Read more
Source§

impl<T, U> Into<U> for T
where + U: From<T>,

Source§

fn into(self) -> U

Calls U::from(self).

+

That is, this conversion is whatever the implementation of +From<T> for U chooses to do.

+
§

impl<T> IpcResponse for T
where + T: Serialize,

§

fn body(self) -> Result<InvokeResponseBody, Error>

Resolve the IPC response body.
§

impl<T> NoneValue for T
where + T: Default,

§

type NoneType = T

§

fn null_value() -> T

The none-equivalent value.
§

impl<T> PolicyExt for T
where + T: ?Sized,

§

fn and<P, B, E>(self, other: P) -> And<T, P>
where + T: Policy<B, E>, + P: Policy<B, E>,

Create a new Policy that returns [Action::Follow] only if self and other return +Action::Follow. Read more
§

fn or<P, B, E>(self, other: P) -> Or<T, P>
where + T: Policy<B, E>, + P: Policy<B, E>,

Create a new Policy that returns [Action::Follow] if either self or other returns +Action::Follow. Read more
Source§

impl<T> Same for T

Source§

type Output = T

Should always be Self
§

impl<T> ScopeObject for T
where + T: Send + Sync + Debug + DeserializeOwned + 'static,

§

type Error = Error

The error type.
§

fn deserialize<R>( + _app: &AppHandle<R>, + raw: Value, +) -> Result<T, <T as ScopeObject>::Error>
where + R: Runtime,

Deserialize the raw scope value.
Source§

impl<T> Serialize for T
where + T: Serialize + ?Sized,

Source§

fn erased_serialize(&self, serializer: &mut dyn Serializer) -> Result<(), Error>

Source§

fn do_erased_serialize( + &self, + serializer: &mut dyn Serializer, +) -> Result<(), ErrorImpl>

Source§

impl<T> ToOwned for T
where + T: Clone,

Source§

type Owned = T

The resulting type after obtaining ownership.
Source§

fn to_owned(&self) -> T

Creates owned data from borrowed data, usually by cloning. Read more
Source§

fn clone_into(&self, target: &mut T)

Uses borrowed data to replace owned data, usually by cloning. Read more
Source§

impl<T, U> TryFrom<U> for T
where + U: Into<T>,

Source§

type Error = Infallible

The type returned in the event of a conversion error.
Source§

fn try_from(value: U) -> Result<T, <T as TryFrom<U>>::Error>

Performs the conversion.
Source§

impl<T, U> TryInto<U> for T
where + U: TryFrom<T>,

Source§

type Error = <U as TryFrom<T>>::Error

The type returned in the event of a conversion error.
Source§

fn try_into(self) -> Result<U, <U as TryFrom<T>>::Error>

Performs the conversion.
§

impl<V, T> VZip<V> for T
where + V: MultiLane<T>,

§

fn vzip(self) -> V

§

impl<T> WithSubscriber for T

§

fn with_subscriber<S>(self, subscriber: S) -> WithDispatch<Self>
where + S: Into<Dispatch>,

Attaches the provided Subscriber to this type, returning a +[WithDispatch] wrapper. Read more
§

fn with_current_subscriber(self) -> WithDispatch<Self>

Attaches the current default Subscriber to this type, returning a +[WithDispatch] wrapper. Read more
Source§

impl<T> DeserializeOwned for T
where + T: for<'de> Deserialize<'de>,

§

impl<T> UserEvent for T
where + T: Debug + Clone + Send + 'static,

\ No newline at end of file diff --git a/api/jellytau_lib/repository/types/struct.SearchResult.html b/api/jellytau_lib/repository/types/struct.SearchResult.html new file mode 100644 index 00000000..2aeb24aa --- /dev/null +++ b/api/jellytau_lib/repository/types/struct.SearchResult.html @@ -0,0 +1,53 @@ +SearchResult in jellytau_lib::repository::types - RustSkip to main content

SearchResult

Struct SearchResult 

Source
pub struct SearchResult {
+    pub items: Vec<MediaItem>,
+    pub total_record_count: usize,
+}
Expand description

Search result with pagination

+

Fields§

§items: Vec<MediaItem>§total_record_count: usize

Trait Implementations§

Source§

impl Clone for SearchResult

Source§

fn clone(&self) -> SearchResult

Returns a duplicate of the value. Read more
1.0.0 (const: unstable) · Source§

fn clone_from(&mut self, source: &Self)

Performs copy-assignment from source. Read more
Source§

impl Debug for SearchResult

Source§

fn fmt(&self, f: &mut Formatter<'_>) -> Result

Formats the value using the given formatter. Read more
Source§

impl<'de> Deserialize<'de> for SearchResult

Source§

fn deserialize<__D>(__deserializer: __D) -> Result<Self, __D::Error>
where + __D: Deserializer<'de>,

Deserialize this value from the given Serde deserializer. Read more
Source§

impl ExcludeHidden for SearchResult

Source§

fn without_excluded(self) -> Self

Source§

impl MeaningfulContent for SearchResult

Source§

impl NamedType for SearchResult

Source§

fn sid() -> SpectaID

Source§

fn named_data_type( + type_map: &mut TypeCollection, + generics: &[DataType], +) -> NamedDataType

this is equivalent to [Type::inline] but returns a [NamedDataType] instead.
Source§

fn definition_named_data_type(type_map: &mut TypeCollection) -> NamedDataType

this is equivalent to [Type::definition] but returns a [NamedDataType] instead.
Source§

impl Serialize for SearchResult

Source§

fn serialize<__S>(&self, __serializer: __S) -> Result<__S::Ok, __S::Error>
where + __S: Serializer,

Serialize this value into the given Serde serializer. Read more
Source§

impl Type for SearchResult

Source§

fn inline(type_map: &mut TypeCollection, generics: Generics<'_>) -> DataType

Returns the definition of a type using the provided generics. Read more
Source§

fn reference(type_map: &mut TypeCollection, generics: &[DataType]) -> Reference

Generates a datatype corresponding to a reference to this type, +as determined by its category. Getting a reference to a type implies that +it should belong in the type map (since it has to be referenced from somewhere), +so the output of definition will be put into the type map.
Source§

impl Flatten for SearchResult

Auto Trait Implementations§

Blanket Implementations§

Source§

impl<T> Any for T
where + T: 'static + ?Sized,

Source§

fn type_id(&self) -> TypeId

Gets the TypeId of self. Read more
Source§

impl<T> Borrow<T> for T
where + T: ?Sized,

Source§

fn borrow(&self) -> &T

Immutably borrows from an owned value. Read more
Source§

impl<T> BorrowMut<T> for T
where + T: ?Sized,

Source§

fn borrow_mut(&mut self) -> &mut T

Mutably borrows from an owned value. Read more
Source§

impl<T> CloneToUninit for T
where + T: Clone,

Source§

unsafe fn clone_to_uninit(&self, dest: *mut u8)

🔬This is a nightly-only experimental API. (clone_to_uninit)
Performs copy-assignment from self to dest. Read more
§

impl<'de, D, R> CommandArg<'de, R> for D
where + D: Deserialize<'de>, + R: Runtime,

§

fn from_command(command: CommandItem<'de, R>) -> Result<D, InvokeError>

Derives an instance of Self from the [CommandItem]. Read more
Source§

impl<T> From<T> for T

Source§

fn from(t: T) -> T

Returns the argument unchanged.

+
§

impl<T> FunctionArg for T
where + T: Type,

§

fn to_datatype(type_map: &mut TypeCollection) -> Option<DataType>

Gets the type of an argument as a [DataType]. Read more
§

impl<T> Instrument for T

§

fn instrument(self, span: Span) -> Instrumented<Self>

Instruments this type with the provided [Span], returning an +Instrumented wrapper. Read more
§

fn in_current_span(self) -> Instrumented<Self>

Instruments this type with the current Span, returning an +Instrumented wrapper. Read more
Source§

impl<T, U> Into<U> for T
where + U: From<T>,

Source§

fn into(self) -> U

Calls U::from(self).

+

That is, this conversion is whatever the implementation of +From<T> for U chooses to do.

+
§

impl<T> IpcResponse for T
where + T: Serialize,

§

fn body(self) -> Result<InvokeResponseBody, Error>

Resolve the IPC response body.
§

impl<T> PolicyExt for T
where + T: ?Sized,

§

fn and<P, B, E>(self, other: P) -> And<T, P>
where + T: Policy<B, E>, + P: Policy<B, E>,

Create a new Policy that returns [Action::Follow] only if self and other return +Action::Follow. Read more
§

fn or<P, B, E>(self, other: P) -> Or<T, P>
where + T: Policy<B, E>, + P: Policy<B, E>,

Create a new Policy that returns [Action::Follow] if either self or other returns +Action::Follow. Read more
Source§

impl<T> Same for T

Source§

type Output = T

Should always be Self
§

impl<T> ScopeObject for T
where + T: Send + Sync + Debug + DeserializeOwned + 'static,

§

type Error = Error

The error type.
§

fn deserialize<R>( + _app: &AppHandle<R>, + raw: Value, +) -> Result<T, <T as ScopeObject>::Error>
where + R: Runtime,

Deserialize the raw scope value.
Source§

impl<T> Serialize for T
where + T: Serialize + ?Sized,

Source§

fn erased_serialize(&self, serializer: &mut dyn Serializer) -> Result<(), Error>

Source§

fn do_erased_serialize( + &self, + serializer: &mut dyn Serializer, +) -> Result<(), ErrorImpl>

Source§

impl<T> ToOwned for T
where + T: Clone,

Source§

type Owned = T

The resulting type after obtaining ownership.
Source§

fn to_owned(&self) -> T

Creates owned data from borrowed data, usually by cloning. Read more
Source§

fn clone_into(&self, target: &mut T)

Uses borrowed data to replace owned data, usually by cloning. Read more
Source§

impl<T, U> TryFrom<U> for T
where + U: Into<T>,

Source§

type Error = Infallible

The type returned in the event of a conversion error.
Source§

fn try_from(value: U) -> Result<T, <T as TryFrom<U>>::Error>

Performs the conversion.
Source§

impl<T, U> TryInto<U> for T
where + U: TryFrom<T>,

Source§

type Error = <U as TryFrom<T>>::Error

The type returned in the event of a conversion error.
Source§

fn try_into(self) -> Result<U, <U as TryFrom<T>>::Error>

Performs the conversion.
§

impl<V, T> VZip<V> for T
where + V: MultiLane<T>,

§

fn vzip(self) -> V

§

impl<T> WithSubscriber for T

§

fn with_subscriber<S>(self, subscriber: S) -> WithDispatch<Self>
where + S: Into<Dispatch>,

Attaches the provided Subscriber to this type, returning a +[WithDispatch] wrapper. Read more
§

fn with_current_subscriber(self) -> WithDispatch<Self>

Attaches the current default Subscriber to this type, returning a +[WithDispatch] wrapper. Read more
Source§

impl<T> DeserializeOwned for T
where + T: for<'de> Deserialize<'de>,

§

impl<T> UserEvent for T
where + T: Debug + Clone + Send + 'static,

\ No newline at end of file diff --git a/api/jellytau_lib/repository/types/struct.UserData.html b/api/jellytau_lib/repository/types/struct.UserData.html new file mode 100644 index 00000000..aae7d1fe --- /dev/null +++ b/api/jellytau_lib/repository/types/struct.UserData.html @@ -0,0 +1,66 @@ +UserData in jellytau_lib::repository::types - RustSkip to main content

UserData

Struct UserData 

Source
pub struct UserData {
+    pub playback_position_ticks: Option<i64>,
+    pub playback_position_ms: Option<i64>,
+    pub is_played: Option<bool>,
+    pub is_favorite: Option<bool>,
+    pub play_count: Option<i32>,
+    pub last_played_date: Option<String>,
+    pub playback_context_type: Option<String>,
+    pub playback_context_id: Option<String>,
+}
Expand description

User-specific data for an item (playback state, favorites, etc.)

+

Fields§

§playback_position_ticks: Option<i64>

Legacy Jellyfin resume position in ticks. Being replaced by +playback_position_ms; dual-carried while the frontend migrates +(docs/specs/frontend-domain-model.md). New code should read the ms field.

+
§playback_position_ms: Option<i64>

Resume position in milliseconds — the neutral replacement for +playback_position_ticks. Populated from ticks by the mapping; the +frontend never divides ticks itself.

+
§is_played: Option<bool>§is_favorite: Option<bool>§play_count: Option<i32>§last_played_date: Option<String>§playback_context_type: Option<String>§playback_context_id: Option<String>

Trait Implementations§

Source§

impl Clone for UserData

Source§

fn clone(&self) -> UserData

Returns a duplicate of the value. Read more
1.0.0 (const: unstable) · Source§

fn clone_from(&mut self, source: &Self)

Performs copy-assignment from source. Read more
Source§

impl Debug for UserData

Source§

fn fmt(&self, f: &mut Formatter<'_>) -> Result

Formats the value using the given formatter. Read more
Source§

impl Default for UserData

Source§

fn default() -> UserData

Returns the “default value” for a type. Read more
Source§

impl<'de> Deserialize<'de> for UserData

Source§

fn deserialize<__D>(__deserializer: __D) -> Result<Self, __D::Error>
where + __D: Deserializer<'de>,

Deserialize this value from the given Serde deserializer. Read more
Source§

impl From<JellyfinUserData> for UserData

Source§

fn from(jf: JellyfinUserData) -> Self

Converts to this type from the input type.
Source§

impl NamedType for UserData

Source§

fn sid() -> SpectaID

Source§

fn named_data_type( + type_map: &mut TypeCollection, + generics: &[DataType], +) -> NamedDataType

this is equivalent to [Type::inline] but returns a [NamedDataType] instead.
Source§

fn definition_named_data_type(type_map: &mut TypeCollection) -> NamedDataType

this is equivalent to [Type::definition] but returns a [NamedDataType] instead.
Source§

impl Serialize for UserData

Source§

fn serialize<__S>(&self, __serializer: __S) -> Result<__S::Ok, __S::Error>
where + __S: Serializer,

Serialize this value into the given Serde serializer. Read more
Source§

impl Type for UserData

Source§

fn inline(type_map: &mut TypeCollection, generics: Generics<'_>) -> DataType

Returns the definition of a type using the provided generics. Read more
Source§

fn reference(type_map: &mut TypeCollection, generics: &[DataType]) -> Reference

Generates a datatype corresponding to a reference to this type, +as determined by its category. Getting a reference to a type implies that +it should belong in the type map (since it has to be referenced from somewhere), +so the output of definition will be put into the type map.
Source§

impl Flatten for UserData

Auto Trait Implementations§

Blanket Implementations§

Source§

impl<T> Any for T
where + T: 'static + ?Sized,

Source§

fn type_id(&self) -> TypeId

Gets the TypeId of self. Read more
Source§

impl<T> Borrow<T> for T
where + T: ?Sized,

Source§

fn borrow(&self) -> &T

Immutably borrows from an owned value. Read more
Source§

impl<T> BorrowMut<T> for T
where + T: ?Sized,

Source§

fn borrow_mut(&mut self) -> &mut T

Mutably borrows from an owned value. Read more
Source§

impl<T> CloneToUninit for T
where + T: Clone,

Source§

unsafe fn clone_to_uninit(&self, dest: *mut u8)

🔬This is a nightly-only experimental API. (clone_to_uninit)
Performs copy-assignment from self to dest. Read more
§

impl<'de, D, R> CommandArg<'de, R> for D
where + D: Deserialize<'de>, + R: Runtime,

§

fn from_command(command: CommandItem<'de, R>) -> Result<D, InvokeError>

Derives an instance of Self from the [CommandItem]. Read more
Source§

impl<T> From<T> for T

Source§

fn from(t: T) -> T

Returns the argument unchanged.

+
§

impl<T> FunctionArg for T
where + T: Type,

§

fn to_datatype(type_map: &mut TypeCollection) -> Option<DataType>

Gets the type of an argument as a [DataType]. Read more
§

impl<T> Instrument for T

§

fn instrument(self, span: Span) -> Instrumented<Self>

Instruments this type with the provided [Span], returning an +Instrumented wrapper. Read more
§

fn in_current_span(self) -> Instrumented<Self>

Instruments this type with the current Span, returning an +Instrumented wrapper. Read more
Source§

impl<T, U> Into<U> for T
where + U: From<T>,

Source§

fn into(self) -> U

Calls U::from(self).

+

That is, this conversion is whatever the implementation of +From<T> for U chooses to do.

+
§

impl<T> IpcResponse for T
where + T: Serialize,

§

fn body(self) -> Result<InvokeResponseBody, Error>

Resolve the IPC response body.
§

impl<T> NoneValue for T
where + T: Default,

§

type NoneType = T

§

fn null_value() -> T

The none-equivalent value.
§

impl<T> PolicyExt for T
where + T: ?Sized,

§

fn and<P, B, E>(self, other: P) -> And<T, P>
where + T: Policy<B, E>, + P: Policy<B, E>,

Create a new Policy that returns [Action::Follow] only if self and other return +Action::Follow. Read more
§

fn or<P, B, E>(self, other: P) -> Or<T, P>
where + T: Policy<B, E>, + P: Policy<B, E>,

Create a new Policy that returns [Action::Follow] if either self or other returns +Action::Follow. Read more
Source§

impl<T> Same for T

Source§

type Output = T

Should always be Self
§

impl<T> ScopeObject for T
where + T: Send + Sync + Debug + DeserializeOwned + 'static,

§

type Error = Error

The error type.
§

fn deserialize<R>( + _app: &AppHandle<R>, + raw: Value, +) -> Result<T, <T as ScopeObject>::Error>
where + R: Runtime,

Deserialize the raw scope value.
Source§

impl<T> Serialize for T
where + T: Serialize + ?Sized,

Source§

fn erased_serialize(&self, serializer: &mut dyn Serializer) -> Result<(), Error>

Source§

fn do_erased_serialize( + &self, + serializer: &mut dyn Serializer, +) -> Result<(), ErrorImpl>

Source§

impl<T> ToOwned for T
where + T: Clone,

Source§

type Owned = T

The resulting type after obtaining ownership.
Source§

fn to_owned(&self) -> T

Creates owned data from borrowed data, usually by cloning. Read more
Source§

fn clone_into(&self, target: &mut T)

Uses borrowed data to replace owned data, usually by cloning. Read more
Source§

impl<T, U> TryFrom<U> for T
where + U: Into<T>,

Source§

type Error = Infallible

The type returned in the event of a conversion error.
Source§

fn try_from(value: U) -> Result<T, <T as TryFrom<U>>::Error>

Performs the conversion.
Source§

impl<T, U> TryInto<U> for T
where + U: TryFrom<T>,

Source§

type Error = <U as TryFrom<T>>::Error

The type returned in the event of a conversion error.
Source§

fn try_into(self) -> Result<U, <U as TryFrom<T>>::Error>

Performs the conversion.
§

impl<V, T> VZip<V> for T
where + V: MultiLane<T>,

§

fn vzip(self) -> V

§

impl<T> WithSubscriber for T

§

fn with_subscriber<S>(self, subscriber: S) -> WithDispatch<Self>
where + S: Into<Dispatch>,

Attaches the provided Subscriber to this type, returning a +[WithDispatch] wrapper. Read more
§

fn with_current_subscriber(self) -> WithDispatch<Self>

Attaches the current default Subscriber to this type, returning a +[WithDispatch] wrapper. Read more
Source§

impl<T> DeserializeOwned for T
where + T: for<'de> Deserialize<'de>,

§

impl<T> UserEvent for T
where + T: Debug + Clone + Send + 'static,

\ No newline at end of file diff --git a/api/jellytau_lib/repository/types/trait.MeaningfulContent.html b/api/jellytau_lib/repository/types/trait.MeaningfulContent.html new file mode 100644 index 00000000..b59c329c --- /dev/null +++ b/api/jellytau_lib/repository/types/trait.MeaningfulContent.html @@ -0,0 +1,5 @@ +MeaningfulContent in jellytau_lib::repository::types - RustSkip to main content

MeaningfulContent

Trait MeaningfulContent 

Source
pub trait MeaningfulContent {
+    // Required method
+    fn has_content(&self) -> bool;
+}
Expand description

Trait for checking if data has meaningful content

+

Required Methods§

Source

fn has_content(&self) -> bool

Implementations on Foreign Types§

Source§

impl MeaningfulContent for Vec<Genre>

Source§

impl MeaningfulContent for Vec<Library>

Source§

impl MeaningfulContent for Vec<MediaItem>

Source§

impl MeaningfulContent for Vec<PlaylistEntry>

Implementors§

\ No newline at end of file diff --git a/api/jellytau_lib/session_poller/enum.PollingHint.html b/api/jellytau_lib/session_poller/enum.PollingHint.html new file mode 100644 index 00000000..57f5cc48 --- /dev/null +++ b/api/jellytau_lib/session_poller/enum.PollingHint.html @@ -0,0 +1,42 @@ +PollingHint in jellytau_lib::session_poller - RustSkip to main content

PollingHint

Enum PollingHint 

Source
pub enum PollingHint {
+    CastActive,
+    CastDiscovery,
+    Normal,
+}
Expand description

Hint for adjusting poll frequency based on UI state

+

Variants§

§

CastActive

CastButton is active and needs frequent updates (500ms)

+
§

CastDiscovery

CastButton is in discovery mode (15s)

+
§

Normal

No special hint, use mode-based frequency (default)

+

Trait Implementations§

Source§

impl Clone for PollingHint

Source§

fn clone(&self) -> PollingHint

Returns a duplicate of the value. Read more
1.0.0 (const: unstable) · Source§

fn clone_from(&mut self, source: &Self)

Performs copy-assignment from source. Read more
Source§

impl Debug for PollingHint

Source§

fn fmt(&self, f: &mut Formatter<'_>) -> Result

Formats the value using the given formatter. Read more
Source§

impl PartialEq for PollingHint

Source§

fn eq(&self, other: &PollingHint) -> bool

Tests for self and other values to be equal, and is used by ==.
1.0.0 (const: unstable) · Source§

fn ne(&self, other: &Rhs) -> bool

Tests for !=. The default implementation is almost always sufficient, +and should not be overridden without very good reason.
Source§

impl Copy for PollingHint

Source§

impl Eq for PollingHint

Source§

impl StructuralPartialEq for PollingHint

Auto Trait Implementations§

Blanket Implementations§

Source§

impl<T> Any for T
where + T: 'static + ?Sized,

Source§

fn type_id(&self) -> TypeId

Gets the TypeId of self. Read more
Source§

impl<T> Borrow<T> for T
where + T: ?Sized,

Source§

fn borrow(&self) -> &T

Immutably borrows from an owned value. Read more
Source§

impl<T> BorrowMut<T> for T
where + T: ?Sized,

Source§

fn borrow_mut(&mut self) -> &mut T

Mutably borrows from an owned value. Read more
Source§

impl<T> CloneToUninit for T
where + T: Clone,

Source§

unsafe fn clone_to_uninit(&self, dest: *mut u8)

🔬This is a nightly-only experimental API. (clone_to_uninit)
Performs copy-assignment from self to dest. Read more
§

impl<Q, K> Equivalent<K> for Q
where + Q: Eq + ?Sized, + K: Borrow<Q> + ?Sized,

§

fn equivalent(&self, key: &K) -> bool

Checks if this value is equivalent to the given key. Read more
§

impl<Q, K> Equivalent<K> for Q
where + Q: Eq + ?Sized, + K: Borrow<Q> + ?Sized,

§

fn equivalent(&self, key: &K) -> bool

Compare self to key and return true if they are equal.
§

impl<Q, K> Equivalent<K> for Q
where + Q: Eq + ?Sized, + K: Borrow<Q> + ?Sized,

§

fn equivalent(&self, key: &K) -> bool

Checks if this value is equivalent to the given key. Read more
Source§

impl<T> From<T> for T

Source§

fn from(t: T) -> T

Returns the argument unchanged.

+
§

impl<T> Instrument for T

§

fn instrument(self, span: Span) -> Instrumented<Self>

Instruments this type with the provided [Span], returning an +Instrumented wrapper. Read more
§

fn in_current_span(self) -> Instrumented<Self>

Instruments this type with the current Span, returning an +Instrumented wrapper. Read more
Source§

impl<T, U> Into<U> for T
where + U: From<T>,

Source§

fn into(self) -> U

Calls U::from(self).

+

That is, this conversion is whatever the implementation of +From<T> for U chooses to do.

+
§

impl<T> PolicyExt for T
where + T: ?Sized,

§

fn and<P, B, E>(self, other: P) -> And<T, P>
where + T: Policy<B, E>, + P: Policy<B, E>,

Create a new Policy that returns [Action::Follow] only if self and other return +Action::Follow. Read more
§

fn or<P, B, E>(self, other: P) -> Or<T, P>
where + T: Policy<B, E>, + P: Policy<B, E>,

Create a new Policy that returns [Action::Follow] if either self or other returns +Action::Follow. Read more
Source§

impl<T> Same for T

Source§

type Output = T

Should always be Self
Source§

impl<T> ToOwned for T
where + T: Clone,

Source§

type Owned = T

The resulting type after obtaining ownership.
Source§

fn to_owned(&self) -> T

Creates owned data from borrowed data, usually by cloning. Read more
Source§

fn clone_into(&self, target: &mut T)

Uses borrowed data to replace owned data, usually by cloning. Read more
Source§

impl<T, U> TryFrom<U> for T
where + U: Into<T>,

Source§

type Error = Infallible

The type returned in the event of a conversion error.
Source§

fn try_from(value: U) -> Result<T, <T as TryFrom<U>>::Error>

Performs the conversion.
Source§

impl<T, U> TryInto<U> for T
where + U: TryFrom<T>,

Source§

type Error = <U as TryFrom<T>>::Error

The type returned in the event of a conversion error.
Source§

fn try_into(self) -> Result<U, <U as TryFrom<T>>::Error>

Performs the conversion.
§

impl<V, T> VZip<V> for T
where + V: MultiLane<T>,

§

fn vzip(self) -> V

§

impl<T> WithSubscriber for T

§

fn with_subscriber<S>(self, subscriber: S) -> WithDispatch<Self>
where + S: Into<Dispatch>,

Attaches the provided Subscriber to this type, returning a +[WithDispatch] wrapper. Read more
§

fn with_current_subscriber(self) -> WithDispatch<Self>

Attaches the current default Subscriber to this type, returning a +[WithDispatch] wrapper. Read more
§

impl<T> UserEvent for T
where + T: Debug + Clone + Send + 'static,

\ No newline at end of file diff --git a/api/jellytau_lib/session_poller/index.html b/api/jellytau_lib/session_poller/index.html new file mode 100644 index 00000000..b92bbe31 --- /dev/null +++ b/api/jellytau_lib/session_poller/index.html @@ -0,0 +1,5 @@ +jellytau_lib::session_poller - RustSkip to main content

Module session_poller

Module session_poller 

Source
Expand description

Session polling manager for remote playback control.

+

Manages background polling of Jellyfin sessions with dynamic frequency adjustment +based on playback mode and UI state. Eliminates duplicate pollers across browser tabs.

+

TRACES: UR-010 | JA-021

+

Structs§

SessionPollerManager
Manages background polling of Jellyfin sessions

Enums§

PollingHint
Hint for adjusting poll frequency based on UI state
\ No newline at end of file diff --git a/api/jellytau_lib/session_poller/sidebar-items.js b/api/jellytau_lib/session_poller/sidebar-items.js new file mode 100644 index 00000000..c5fd490d --- /dev/null +++ b/api/jellytau_lib/session_poller/sidebar-items.js @@ -0,0 +1 @@ +window.SIDEBAR_ITEMS = {"enum":["PollingHint"],"struct":["SessionPollerManager"]}; \ No newline at end of file diff --git a/api/jellytau_lib/session_poller/struct.SessionPollerManager.html b/api/jellytau_lib/session_poller/struct.SessionPollerManager.html new file mode 100644 index 00000000..6f4ab2f6 --- /dev/null +++ b/api/jellytau_lib/session_poller/struct.SessionPollerManager.html @@ -0,0 +1,59 @@ +SessionPollerManager in jellytau_lib::session_poller - RustSkip to main content

SessionPollerManager

Struct SessionPollerManager 

Source
pub struct SessionPollerManager {
+    jellyfin_client: Arc<Mutex<Option<JellyfinClient>>>,
+    playback_mode_manager: Arc<PlaybackModeManager>,
+    event_emitter: Arc<Mutex<Option<Arc<dyn PlayerEventEmitter>>>>,
+    connectivity_reporter: Arc<Mutex<Option<ConnectivityReporter>>>,
+    is_running: Arc<AtomicBool>,
+    current_hint: Arc<RwLock<PollingHint>>,
+    current_interval_ms: Arc<AtomicU64>,
+    thread_handle: Arc<Mutex<Option<JoinHandle<()>>>>,
+}
Expand description

Manages background polling of Jellyfin sessions

+

Fields§

§jellyfin_client: Arc<Mutex<Option<JellyfinClient>>>§playback_mode_manager: Arc<PlaybackModeManager>§event_emitter: Arc<Mutex<Option<Arc<dyn PlayerEventEmitter>>>>§connectivity_reporter: Arc<Mutex<Option<ConnectivityReporter>>>

Optional connectivity reporter. The session poller is the one piece of +server traffic that runs continuously even when the user is idle (not +browsing the library), so feeding its poll outcomes into the reporter is +what lets the app detect going offline — and, crucially, recover when the +server returns — without any user interaction. Repository traffic alone +can’t do this because it only happens while browsing.

+
§is_running: Arc<AtomicBool>§current_hint: Arc<RwLock<PollingHint>>§current_interval_ms: Arc<AtomicU64>§thread_handle: Arc<Mutex<Option<JoinHandle<()>>>>

Implementations§

Source§

impl SessionPollerManager

Source

pub fn new( + jellyfin_client: Arc<Mutex<Option<JellyfinClient>>>, + playback_mode_manager: Arc<PlaybackModeManager>, +) -> Self

Create a new SessionPollerManager

+
Source

pub fn set_event_emitter(&self, emitter: Arc<dyn PlayerEventEmitter>)

Set event emitter for broadcasting session updates

+
Source

pub fn set_connectivity_reporter(&self, reporter: ConnectivityReporter)

Wire the connectivity reporter so each poll outcome updates reachability. +A successful poll recovers the app to online instantly; sustained poll +failures flip it offline (subject to the reporter’s debounce window).

+
Source

pub fn start(&self)

Start the background polling thread

+
Source

pub fn stop(&self)

Stop the polling thread

+
Source

pub fn set_polling_hint(&self, hint: PollingHint)

Set UI hint for polling frequency adjustment

+
Source

fn push_remote_lockscreen(sessions: &[SessionInfo], session_id: &str)

Push the remote session’s now-playing onto the Android lockscreen.

+

Looks up the active remote session by id and forwards its title/artist/ +album, duration and position to the media notification. Silently does +nothing if the session isn’t found or has no now-playing item (e.g. the +remote stopped) - the next state change will refresh it.

+
Source

fn calculate_interval(mode: &PlaybackMode, hint: PollingHint) -> u64

Calculate polling interval based on mode and hint

+
Source

pub async fn poll_now(&self) -> Result<Vec<SessionInfo>, String>

Manually trigger a poll (for frontend refresh button)

+

Trait Implementations§

Source§

impl Drop for SessionPollerManager

Source§

fn drop(&mut self)

Executes the destructor for this type. Read more
Source§

fn pin_drop(self: Pin<&mut Self>)

🔬This is a nightly-only experimental API. (pin_ergonomics)
Execute the destructor for this type, but different to Drop::drop, it requires self +to be pinned. Read more

Auto Trait Implementations§

Blanket Implementations§

Source§

impl<T> Any for T
where + T: 'static + ?Sized,

Source§

fn type_id(&self) -> TypeId

Gets the TypeId of self. Read more
Source§

impl<T> Borrow<T> for T
where + T: ?Sized,

Source§

fn borrow(&self) -> &T

Immutably borrows from an owned value. Read more
Source§

impl<T> BorrowMut<T> for T
where + T: ?Sized,

Source§

fn borrow_mut(&mut self) -> &mut T

Mutably borrows from an owned value. Read more
Source§

impl<T> From<T> for T

Source§

fn from(t: T) -> T

Returns the argument unchanged.

+
§

impl<T> Instrument for T

§

fn instrument(self, span: Span) -> Instrumented<Self>

Instruments this type with the provided [Span], returning an +Instrumented wrapper. Read more
§

fn in_current_span(self) -> Instrumented<Self>

Instruments this type with the current Span, returning an +Instrumented wrapper. Read more
Source§

impl<T, U> Into<U> for T
where + U: From<T>,

Source§

fn into(self) -> U

Calls U::from(self).

+

That is, this conversion is whatever the implementation of +From<T> for U chooses to do.

+
§

impl<T> PolicyExt for T
where + T: ?Sized,

§

fn and<P, B, E>(self, other: P) -> And<T, P>
where + T: Policy<B, E>, + P: Policy<B, E>,

Create a new Policy that returns [Action::Follow] only if self and other return +Action::Follow. Read more
§

fn or<P, B, E>(self, other: P) -> Or<T, P>
where + T: Policy<B, E>, + P: Policy<B, E>,

Create a new Policy that returns [Action::Follow] if either self or other returns +Action::Follow. Read more
Source§

impl<T> Same for T

Source§

type Output = T

Should always be Self
Source§

impl<T, U> TryFrom<U> for T
where + U: Into<T>,

Source§

type Error = Infallible

The type returned in the event of a conversion error.
Source§

fn try_from(value: U) -> Result<T, <T as TryFrom<U>>::Error>

Performs the conversion.
Source§

impl<T, U> TryInto<U> for T
where + U: TryFrom<T>,

Source§

type Error = <U as TryFrom<T>>::Error

The type returned in the event of a conversion error.
Source§

fn try_into(self) -> Result<U, <U as TryFrom<T>>::Error>

Performs the conversion.
§

impl<V, T> VZip<V> for T
where + V: MultiLane<T>,

§

fn vzip(self) -> V

§

impl<T> WithSubscriber for T

§

fn with_subscriber<S>(self, subscriber: S) -> WithDispatch<Self>
where + S: Into<Dispatch>,

Attaches the provided Subscriber to this type, returning a +[WithDispatch] wrapper. Read more
§

fn with_current_subscriber(self) -> WithDispatch<Self>

Attaches the current default Subscriber to this type, returning a +[WithDispatch] wrapper. Read more
\ No newline at end of file diff --git a/api/jellytau_lib/settings/constant.EQ_BANDS.html b/api/jellytau_lib/settings/constant.EQ_BANDS.html new file mode 100644 index 00000000..4f6d0c49 --- /dev/null +++ b/api/jellytau_lib/settings/constant.EQ_BANDS.html @@ -0,0 +1,7 @@ +EQ_BANDS in jellytau_lib::settings - RustSkip to main content

EQ_BANDS

Constant EQ_BANDS 

Source
pub const EQ_BANDS: [f32; 10];
Expand description

Centre frequencies (Hz) of the fixed 10-band ISO equalizer. The band count +and layout are a property of the audio engine, not the UI — presets and the +MPV filter are defined against these bands. See +docs/architecture/05-platform-backends.md (“The equalizer, and where its +vocabulary lives”).

+

TRACES: UR-027 | DR-030, IR-020

+
\ No newline at end of file diff --git a/api/jellytau_lib/settings/constant.EQ_GAIN_MAX.html b/api/jellytau_lib/settings/constant.EQ_GAIN_MAX.html new file mode 100644 index 00000000..fde3f11f --- /dev/null +++ b/api/jellytau_lib/settings/constant.EQ_GAIN_MAX.html @@ -0,0 +1,2 @@ +EQ_GAIN_MAX in jellytau_lib::settings - RustSkip to main content

EQ_GAIN_MAX

Constant EQ_GAIN_MAX 

Source
pub const EQ_GAIN_MAX: f32 = 12.0;
Expand description

Maximum per-band gain in dB.

+
\ No newline at end of file diff --git a/api/jellytau_lib/settings/constant.EQ_GAIN_MIN.html b/api/jellytau_lib/settings/constant.EQ_GAIN_MIN.html new file mode 100644 index 00000000..462f7745 --- /dev/null +++ b/api/jellytau_lib/settings/constant.EQ_GAIN_MIN.html @@ -0,0 +1,2 @@ +EQ_GAIN_MIN in jellytau_lib::settings - RustSkip to main content

EQ_GAIN_MIN

Constant EQ_GAIN_MIN 

Source
pub const EQ_GAIN_MIN: f32 = -12.0;
Expand description

Minimum per-band gain in dB.

+
\ No newline at end of file diff --git a/api/jellytau_lib/settings/enum.EqPreset.html b/api/jellytau_lib/settings/enum.EqPreset.html new file mode 100644 index 00000000..d0cb9c7b --- /dev/null +++ b/api/jellytau_lib/settings/enum.EqPreset.html @@ -0,0 +1,67 @@ +EqPreset in jellytau_lib::settings - RustSkip to main content

EqPreset

Enum EqPreset 

Source
pub enum EqPreset {
+    Flat,
+    Rock,
+    Pop,
+    Jazz,
+    Classical,
+    BassBoost,
+    TrebleBoost,
+    Vocal,
+}
Expand description

Built-in equalizer presets. A preset is a gain curve defined by the band +layout above (a domain concept), not a mere label — the curve numbers live +in Rust so the frontend never encodes the taxonomy.

+

TRACES: UR-027 | DR-030

+

Variants§

§

Flat

§

Rock

§

Pop

§

Jazz

§

Classical

§

BassBoost

§

TrebleBoost

§

Vocal

Implementations§

Source§

impl EqPreset

Source

pub const ALL: [EqPreset; 8]

All presets, for enumerating the curve table across the IPC boundary.

+
Source

pub fn gains(&self) -> [f32; 10]

The 10-band gain curve (dB) for this preset, one entry per EQ_BANDS. +Curves are conservative (within ±8 dB) so presets stack safely with the +player volume. Bands: 31 62 125 250 500 1k 2k 4k 8k 16k.

+

Trait Implementations§

Source§

impl Clone for EqPreset

Source§

fn clone(&self) -> EqPreset

Returns a duplicate of the value. Read more
1.0.0 (const: unstable) · Source§

fn clone_from(&mut self, source: &Self)

Performs copy-assignment from source. Read more
Source§

impl Debug for EqPreset

Source§

fn fmt(&self, f: &mut Formatter<'_>) -> Result

Formats the value using the given formatter. Read more
Source§

impl<'de> Deserialize<'de> for EqPreset

Source§

fn deserialize<__D>(__deserializer: __D) -> Result<Self, __D::Error>
where + __D: Deserializer<'de>,

Deserialize this value from the given Serde deserializer. Read more
Source§

impl NamedType for EqPreset

Source§

fn sid() -> SpectaID

Source§

fn named_data_type( + type_map: &mut TypeCollection, + generics: &[DataType], +) -> NamedDataType

this is equivalent to [Type::inline] but returns a [NamedDataType] instead.
Source§

fn definition_named_data_type(type_map: &mut TypeCollection) -> NamedDataType

this is equivalent to [Type::definition] but returns a [NamedDataType] instead.
Source§

impl PartialEq for EqPreset

Source§

fn eq(&self, other: &EqPreset) -> bool

Tests for self and other values to be equal, and is used by ==.
1.0.0 (const: unstable) · Source§

fn ne(&self, other: &Rhs) -> bool

Tests for !=. The default implementation is almost always sufficient, +and should not be overridden without very good reason.
Source§

impl Serialize for EqPreset

Source§

fn serialize<__S>(&self, __serializer: __S) -> Result<__S::Ok, __S::Error>
where + __S: Serializer,

Serialize this value into the given Serde serializer. Read more
Source§

impl Type for EqPreset

Source§

fn inline(type_map: &mut TypeCollection, generics: Generics<'_>) -> DataType

Returns the definition of a type using the provided generics. Read more
Source§

fn reference(type_map: &mut TypeCollection, generics: &[DataType]) -> Reference

Generates a datatype corresponding to a reference to this type, +as determined by its category. Getting a reference to a type implies that +it should belong in the type map (since it has to be referenced from somewhere), +so the output of definition will be put into the type map.
Source§

impl Copy for EqPreset

Source§

impl StructuralPartialEq for EqPreset

Auto Trait Implementations§

Blanket Implementations§

Source§

impl<T> Any for T
where + T: 'static + ?Sized,

Source§

fn type_id(&self) -> TypeId

Gets the TypeId of self. Read more
Source§

impl<T> Borrow<T> for T
where + T: ?Sized,

Source§

fn borrow(&self) -> &T

Immutably borrows from an owned value. Read more
Source§

impl<T> BorrowMut<T> for T
where + T: ?Sized,

Source§

fn borrow_mut(&mut self) -> &mut T

Mutably borrows from an owned value. Read more
Source§

impl<T> CloneToUninit for T
where + T: Clone,

Source§

unsafe fn clone_to_uninit(&self, dest: *mut u8)

🔬This is a nightly-only experimental API. (clone_to_uninit)
Performs copy-assignment from self to dest. Read more
§

impl<'de, D, R> CommandArg<'de, R> for D
where + D: Deserialize<'de>, + R: Runtime,

§

fn from_command(command: CommandItem<'de, R>) -> Result<D, InvokeError>

Derives an instance of Self from the [CommandItem]. Read more
Source§

impl<T> From<T> for T

Source§

fn from(t: T) -> T

Returns the argument unchanged.

+
§

impl<T> FunctionArg for T
where + T: Type,

§

fn to_datatype(type_map: &mut TypeCollection) -> Option<DataType>

Gets the type of an argument as a [DataType]. Read more
§

impl<T> Instrument for T

§

fn instrument(self, span: Span) -> Instrumented<Self>

Instruments this type with the provided [Span], returning an +Instrumented wrapper. Read more
§

fn in_current_span(self) -> Instrumented<Self>

Instruments this type with the current Span, returning an +Instrumented wrapper. Read more
Source§

impl<T, U> Into<U> for T
where + U: From<T>,

Source§

fn into(self) -> U

Calls U::from(self).

+

That is, this conversion is whatever the implementation of +From<T> for U chooses to do.

+
§

impl<T> IpcResponse for T
where + T: Serialize,

§

fn body(self) -> Result<InvokeResponseBody, Error>

Resolve the IPC response body.
§

impl<T> PolicyExt for T
where + T: ?Sized,

§

fn and<P, B, E>(self, other: P) -> And<T, P>
where + T: Policy<B, E>, + P: Policy<B, E>,

Create a new Policy that returns [Action::Follow] only if self and other return +Action::Follow. Read more
§

fn or<P, B, E>(self, other: P) -> Or<T, P>
where + T: Policy<B, E>, + P: Policy<B, E>,

Create a new Policy that returns [Action::Follow] if either self or other returns +Action::Follow. Read more
Source§

impl<T> Same for T

Source§

type Output = T

Should always be Self
§

impl<T> ScopeObject for T
where + T: Send + Sync + Debug + DeserializeOwned + 'static,

§

type Error = Error

The error type.
§

fn deserialize<R>( + _app: &AppHandle<R>, + raw: Value, +) -> Result<T, <T as ScopeObject>::Error>
where + R: Runtime,

Deserialize the raw scope value.
Source§

impl<T> Serialize for T
where + T: Serialize + ?Sized,

Source§

fn erased_serialize(&self, serializer: &mut dyn Serializer) -> Result<(), Error>

Source§

fn do_erased_serialize( + &self, + serializer: &mut dyn Serializer, +) -> Result<(), ErrorImpl>

Source§

impl<T> ToOwned for T
where + T: Clone,

Source§

type Owned = T

The resulting type after obtaining ownership.
Source§

fn to_owned(&self) -> T

Creates owned data from borrowed data, usually by cloning. Read more
Source§

fn clone_into(&self, target: &mut T)

Uses borrowed data to replace owned data, usually by cloning. Read more
Source§

impl<T, U> TryFrom<U> for T
where + U: Into<T>,

Source§

type Error = Infallible

The type returned in the event of a conversion error.
Source§

fn try_from(value: U) -> Result<T, <T as TryFrom<U>>::Error>

Performs the conversion.
Source§

impl<T, U> TryInto<U> for T
where + U: TryFrom<T>,

Source§

type Error = <U as TryFrom<T>>::Error

The type returned in the event of a conversion error.
Source§

fn try_into(self) -> Result<U, <U as TryFrom<T>>::Error>

Performs the conversion.
§

impl<V, T> VZip<V> for T
where + V: MultiLane<T>,

§

fn vzip(self) -> V

§

impl<T> WithSubscriber for T

§

fn with_subscriber<S>(self, subscriber: S) -> WithDispatch<Self>
where + S: Into<Dispatch>,

Attaches the provided Subscriber to this type, returning a +[WithDispatch] wrapper. Read more
§

fn with_current_subscriber(self) -> WithDispatch<Self>

Attaches the current default Subscriber to this type, returning a +[WithDispatch] wrapper. Read more
Source§

impl<T> DeserializeOwned for T
where + T: for<'de> Deserialize<'de>,

§

impl<T> UserEvent for T
where + T: Debug + Clone + Send + 'static,

\ No newline at end of file diff --git a/api/jellytau_lib/settings/enum.StreamingQuality.html b/api/jellytau_lib/settings/enum.StreamingQuality.html new file mode 100644 index 00000000..d7dc5e0f --- /dev/null +++ b/api/jellytau_lib/settings/enum.StreamingQuality.html @@ -0,0 +1,99 @@ +StreamingQuality in jellytau_lib::settings - RustSkip to main content

StreamingQuality

Enum StreamingQuality 

Source
pub enum StreamingQuality {
+    Original,
+    Mbps20,
+    Mbps10,
+    Mbps8,
+    Mbps4,
+    Mbps2,
+    Mbps1,
+    Kbps720,
+}
Expand description

A ceiling on how much bandwidth a video stream may consume.

+

A quality step is a bundle of concrete transcode parameters — total stream +ceiling, the audio share of it, and the resolution that ceiling can carry — +not just a label. Those numbers are Jellyfin encoding domain vocabulary, so +they live here and the frontend only ever names a variant; the labels the +picker shows are served over IPC by player_get_streaming_qualities.

+

The ladder is deliberately expressed in bandwidth rather than resolution: it +exists to fit a connection, and the resolution cap is chosen from the +bitrate so the encoder does not spend a small budget on pixels it cannot +afford. See docs/architecture/01-rust-backend.md (“Streaming quality +ladder”).

+

TRACES: UR-074 | DR-162

+

Variants§

§

Original

No client-imposed cap — the server may direct-play the source as-is.

+
§

Mbps20

§

Mbps10

§

Mbps8

§

Mbps4

§

Mbps2

§

Mbps1

§

Kbps720

Implementations§

Source§

impl StreamingQuality

Source

pub const ALL: [StreamingQuality; 8]

The ladder, highest first, for enumerating across the IPC boundary.

+
Source

pub fn max_bitrate(&self) -> Option<u64>

Total bits per second the stream may use (video + audio), or None for +the uncapped Original.

+

This is the number that goes to PlaybackInfo as MaxStreamingBitrate +and into the device profile. Sending it there — not just on the transcode +URL — is what makes the cap real: a stream the server decides to direct +play is served at the source file’s own bitrate, and no URL parameter +afterwards can reduce it.

+
Source

pub fn audio_bitrate(&self) -> u64

Bits per second allotted to the audio track.

+

The value shrinks with the ladder because at the bottom rungs a fixed +384 kbps would be a third of the entire budget.

+
Source

pub fn video_bitrate(&self) -> Option<u64>

Bits per second allotted to the video track: the total minus the audio +share, so the two together honour max_bitrate +rather than overshooting it by the size of the audio track.

+
Source

pub fn max_height(&self) -> Option<u32>

Resolution ceiling that suits the bitrate, or None to leave the source +resolution alone. Scaling down is what keeps a small budget looking like +clean video instead of blocky 1080p.

+
Source

pub fn label(&self) -> &'static str

Human label for the picker. Lives in Rust with the numbers it describes, +so the two cannot drift apart.

+
Source

pub fn detail(&self) -> &'static str

Secondary line for the picker: what the cap means in practice.

+

Trait Implementations§

Source§

impl Clone for StreamingQuality

Source§

fn clone(&self) -> StreamingQuality

Returns a duplicate of the value. Read more
1.0.0 (const: unstable) · Source§

fn clone_from(&mut self, source: &Self)

Performs copy-assignment from source. Read more
Source§

impl Debug for StreamingQuality

Source§

fn fmt(&self, f: &mut Formatter<'_>) -> Result

Formats the value using the given formatter. Read more
Source§

impl Default for StreamingQuality

Source§

fn default() -> StreamingQuality

Returns the “default value” for a type. Read more
Source§

impl<'de> Deserialize<'de> for StreamingQuality

Source§

fn deserialize<__D>(__deserializer: __D) -> Result<Self, __D::Error>
where + __D: Deserializer<'de>,

Deserialize this value from the given Serde deserializer. Read more
Source§

impl NamedType for StreamingQuality

Source§

fn sid() -> SpectaID

Source§

fn named_data_type( + type_map: &mut TypeCollection, + generics: &[DataType], +) -> NamedDataType

this is equivalent to [Type::inline] but returns a [NamedDataType] instead.
Source§

fn definition_named_data_type(type_map: &mut TypeCollection) -> NamedDataType

this is equivalent to [Type::definition] but returns a [NamedDataType] instead.
Source§

impl PartialEq for StreamingQuality

Source§

fn eq(&self, other: &StreamingQuality) -> bool

Tests for self and other values to be equal, and is used by ==.
1.0.0 (const: unstable) · Source§

fn ne(&self, other: &Rhs) -> bool

Tests for !=. The default implementation is almost always sufficient, +and should not be overridden without very good reason.
Source§

impl Serialize for StreamingQuality

Source§

fn serialize<__S>(&self, __serializer: __S) -> Result<__S::Ok, __S::Error>
where + __S: Serializer,

Serialize this value into the given Serde serializer. Read more
Source§

impl Type for StreamingQuality

Source§

fn inline(type_map: &mut TypeCollection, generics: Generics<'_>) -> DataType

Returns the definition of a type using the provided generics. Read more
Source§

fn reference(type_map: &mut TypeCollection, generics: &[DataType]) -> Reference

Generates a datatype corresponding to a reference to this type, +as determined by its category. Getting a reference to a type implies that +it should belong in the type map (since it has to be referenced from somewhere), +so the output of definition will be put into the type map.
Source§

impl Copy for StreamingQuality

Source§

impl Eq for StreamingQuality

Source§

impl StructuralPartialEq for StreamingQuality

Auto Trait Implementations§

Blanket Implementations§

Source§

impl<T> Any for T
where + T: 'static + ?Sized,

Source§

fn type_id(&self) -> TypeId

Gets the TypeId of self. Read more
Source§

impl<T> Borrow<T> for T
where + T: ?Sized,

Source§

fn borrow(&self) -> &T

Immutably borrows from an owned value. Read more
Source§

impl<T> BorrowMut<T> for T
where + T: ?Sized,

Source§

fn borrow_mut(&mut self) -> &mut T

Mutably borrows from an owned value. Read more
Source§

impl<T> CloneToUninit for T
where + T: Clone,

Source§

unsafe fn clone_to_uninit(&self, dest: *mut u8)

🔬This is a nightly-only experimental API. (clone_to_uninit)
Performs copy-assignment from self to dest. Read more
§

impl<'de, D, R> CommandArg<'de, R> for D
where + D: Deserialize<'de>, + R: Runtime,

§

fn from_command(command: CommandItem<'de, R>) -> Result<D, InvokeError>

Derives an instance of Self from the [CommandItem]. Read more
§

impl<Q, K> Equivalent<K> for Q
where + Q: Eq + ?Sized, + K: Borrow<Q> + ?Sized,

§

fn equivalent(&self, key: &K) -> bool

Checks if this value is equivalent to the given key. Read more
§

impl<Q, K> Equivalent<K> for Q
where + Q: Eq + ?Sized, + K: Borrow<Q> + ?Sized,

§

fn equivalent(&self, key: &K) -> bool

Compare self to key and return true if they are equal.
§

impl<Q, K> Equivalent<K> for Q
where + Q: Eq + ?Sized, + K: Borrow<Q> + ?Sized,

§

fn equivalent(&self, key: &K) -> bool

Checks if this value is equivalent to the given key. Read more
Source§

impl<T> From<T> for T

Source§

fn from(t: T) -> T

Returns the argument unchanged.

+
§

impl<T> FunctionArg for T
where + T: Type,

§

fn to_datatype(type_map: &mut TypeCollection) -> Option<DataType>

Gets the type of an argument as a [DataType]. Read more
§

impl<T> Instrument for T

§

fn instrument(self, span: Span) -> Instrumented<Self>

Instruments this type with the provided [Span], returning an +Instrumented wrapper. Read more
§

fn in_current_span(self) -> Instrumented<Self>

Instruments this type with the current Span, returning an +Instrumented wrapper. Read more
Source§

impl<T, U> Into<U> for T
where + U: From<T>,

Source§

fn into(self) -> U

Calls U::from(self).

+

That is, this conversion is whatever the implementation of +From<T> for U chooses to do.

+
§

impl<T> IpcResponse for T
where + T: Serialize,

§

fn body(self) -> Result<InvokeResponseBody, Error>

Resolve the IPC response body.
§

impl<T> NoneValue for T
where + T: Default,

§

type NoneType = T

§

fn null_value() -> T

The none-equivalent value.
§

impl<T> PolicyExt for T
where + T: ?Sized,

§

fn and<P, B, E>(self, other: P) -> And<T, P>
where + T: Policy<B, E>, + P: Policy<B, E>,

Create a new Policy that returns [Action::Follow] only if self and other return +Action::Follow. Read more
§

fn or<P, B, E>(self, other: P) -> Or<T, P>
where + T: Policy<B, E>, + P: Policy<B, E>,

Create a new Policy that returns [Action::Follow] if either self or other returns +Action::Follow. Read more
Source§

impl<T> Same for T

Source§

type Output = T

Should always be Self
§

impl<T> ScopeObject for T
where + T: Send + Sync + Debug + DeserializeOwned + 'static,

§

type Error = Error

The error type.
§

fn deserialize<R>( + _app: &AppHandle<R>, + raw: Value, +) -> Result<T, <T as ScopeObject>::Error>
where + R: Runtime,

Deserialize the raw scope value.
Source§

impl<T> Serialize for T
where + T: Serialize + ?Sized,

Source§

fn erased_serialize(&self, serializer: &mut dyn Serializer) -> Result<(), Error>

Source§

fn do_erased_serialize( + &self, + serializer: &mut dyn Serializer, +) -> Result<(), ErrorImpl>

Source§

impl<T> ToOwned for T
where + T: Clone,

Source§

type Owned = T

The resulting type after obtaining ownership.
Source§

fn to_owned(&self) -> T

Creates owned data from borrowed data, usually by cloning. Read more
Source§

fn clone_into(&self, target: &mut T)

Uses borrowed data to replace owned data, usually by cloning. Read more
Source§

impl<T, U> TryFrom<U> for T
where + U: Into<T>,

Source§

type Error = Infallible

The type returned in the event of a conversion error.
Source§

fn try_from(value: U) -> Result<T, <T as TryFrom<U>>::Error>

Performs the conversion.
Source§

impl<T, U> TryInto<U> for T
where + U: TryFrom<T>,

Source§

type Error = <U as TryFrom<T>>::Error

The type returned in the event of a conversion error.
Source§

fn try_into(self) -> Result<U, <U as TryFrom<T>>::Error>

Performs the conversion.
§

impl<V, T> VZip<V> for T
where + V: MultiLane<T>,

§

fn vzip(self) -> V

§

impl<T> WithSubscriber for T

§

fn with_subscriber<S>(self, subscriber: S) -> WithDispatch<Self>
where + S: Into<Dispatch>,

Attaches the provided Subscriber to this type, returning a +[WithDispatch] wrapper. Read more
§

fn with_current_subscriber(self) -> WithDispatch<Self>

Attaches the current default Subscriber to this type, returning a +[WithDispatch] wrapper. Read more
Source§

impl<T> DeserializeOwned for T
where + T: for<'de> Deserialize<'de>,

§

impl<T> UserEvent for T
where + T: Debug + Clone + Send + 'static,

\ No newline at end of file diff --git a/api/jellytau_lib/settings/enum.VolumeLevel.html b/api/jellytau_lib/settings/enum.VolumeLevel.html new file mode 100644 index 00000000..c917240e --- /dev/null +++ b/api/jellytau_lib/settings/enum.VolumeLevel.html @@ -0,0 +1,60 @@ +VolumeLevel in jellytau_lib::settings - RustSkip to main content

VolumeLevel

Enum VolumeLevel 

Source
pub enum VolumeLevel {
+    Loud,
+    Normal,
+    Quiet,
+}
Expand description

Volume normalization levels matching Spotify’s presets

+

Variants§

§

Loud

Louder output (-11 LUFS)

+
§

Normal

Default level (-14 LUFS)

+
§

Quiet

Quieter output (-23 LUFS)

+

Implementations§

Source§

impl VolumeLevel

Source

pub fn target_lufs(&self) -> f32

Get the target LUFS value for this volume level

+

Trait Implementations§

Source§

impl Clone for VolumeLevel

Source§

fn clone(&self) -> VolumeLevel

Returns a duplicate of the value. Read more
1.0.0 (const: unstable) · Source§

fn clone_from(&mut self, source: &Self)

Performs copy-assignment from source. Read more
Source§

impl Debug for VolumeLevel

Source§

fn fmt(&self, f: &mut Formatter<'_>) -> Result

Formats the value using the given formatter. Read more
Source§

impl Default for VolumeLevel

Source§

fn default() -> VolumeLevel

Returns the “default value” for a type. Read more
Source§

impl<'de> Deserialize<'de> for VolumeLevel

Source§

fn deserialize<__D>(__deserializer: __D) -> Result<Self, __D::Error>
where + __D: Deserializer<'de>,

Deserialize this value from the given Serde deserializer. Read more
Source§

impl NamedType for VolumeLevel

Source§

fn sid() -> SpectaID

Source§

fn named_data_type( + type_map: &mut TypeCollection, + generics: &[DataType], +) -> NamedDataType

this is equivalent to [Type::inline] but returns a [NamedDataType] instead.
Source§

fn definition_named_data_type(type_map: &mut TypeCollection) -> NamedDataType

this is equivalent to [Type::definition] but returns a [NamedDataType] instead.
Source§

impl PartialEq for VolumeLevel

Source§

fn eq(&self, other: &VolumeLevel) -> bool

Tests for self and other values to be equal, and is used by ==.
1.0.0 (const: unstable) · Source§

fn ne(&self, other: &Rhs) -> bool

Tests for !=. The default implementation is almost always sufficient, +and should not be overridden without very good reason.
Source§

impl Serialize for VolumeLevel

Source§

fn serialize<__S>(&self, __serializer: __S) -> Result<__S::Ok, __S::Error>
where + __S: Serializer,

Serialize this value into the given Serde serializer. Read more
Source§

impl Type for VolumeLevel

Source§

fn inline(type_map: &mut TypeCollection, generics: Generics<'_>) -> DataType

Returns the definition of a type using the provided generics. Read more
Source§

fn reference(type_map: &mut TypeCollection, generics: &[DataType]) -> Reference

Generates a datatype corresponding to a reference to this type, +as determined by its category. Getting a reference to a type implies that +it should belong in the type map (since it has to be referenced from somewhere), +so the output of definition will be put into the type map.
Source§

impl Copy for VolumeLevel

Source§

impl StructuralPartialEq for VolumeLevel

Auto Trait Implementations§

Blanket Implementations§

Source§

impl<T> Any for T
where + T: 'static + ?Sized,

Source§

fn type_id(&self) -> TypeId

Gets the TypeId of self. Read more
Source§

impl<T> Borrow<T> for T
where + T: ?Sized,

Source§

fn borrow(&self) -> &T

Immutably borrows from an owned value. Read more
Source§

impl<T> BorrowMut<T> for T
where + T: ?Sized,

Source§

fn borrow_mut(&mut self) -> &mut T

Mutably borrows from an owned value. Read more
Source§

impl<T> CloneToUninit for T
where + T: Clone,

Source§

unsafe fn clone_to_uninit(&self, dest: *mut u8)

🔬This is a nightly-only experimental API. (clone_to_uninit)
Performs copy-assignment from self to dest. Read more
§

impl<'de, D, R> CommandArg<'de, R> for D
where + D: Deserialize<'de>, + R: Runtime,

§

fn from_command(command: CommandItem<'de, R>) -> Result<D, InvokeError>

Derives an instance of Self from the [CommandItem]. Read more
Source§

impl<T> From<T> for T

Source§

fn from(t: T) -> T

Returns the argument unchanged.

+
§

impl<T> FunctionArg for T
where + T: Type,

§

fn to_datatype(type_map: &mut TypeCollection) -> Option<DataType>

Gets the type of an argument as a [DataType]. Read more
§

impl<T> Instrument for T

§

fn instrument(self, span: Span) -> Instrumented<Self>

Instruments this type with the provided [Span], returning an +Instrumented wrapper. Read more
§

fn in_current_span(self) -> Instrumented<Self>

Instruments this type with the current Span, returning an +Instrumented wrapper. Read more
Source§

impl<T, U> Into<U> for T
where + U: From<T>,

Source§

fn into(self) -> U

Calls U::from(self).

+

That is, this conversion is whatever the implementation of +From<T> for U chooses to do.

+
§

impl<T> IpcResponse for T
where + T: Serialize,

§

fn body(self) -> Result<InvokeResponseBody, Error>

Resolve the IPC response body.
§

impl<T> NoneValue for T
where + T: Default,

§

type NoneType = T

§

fn null_value() -> T

The none-equivalent value.
§

impl<T> PolicyExt for T
where + T: ?Sized,

§

fn and<P, B, E>(self, other: P) -> And<T, P>
where + T: Policy<B, E>, + P: Policy<B, E>,

Create a new Policy that returns [Action::Follow] only if self and other return +Action::Follow. Read more
§

fn or<P, B, E>(self, other: P) -> Or<T, P>
where + T: Policy<B, E>, + P: Policy<B, E>,

Create a new Policy that returns [Action::Follow] if either self or other returns +Action::Follow. Read more
Source§

impl<T> Same for T

Source§

type Output = T

Should always be Self
§

impl<T> ScopeObject for T
where + T: Send + Sync + Debug + DeserializeOwned + 'static,

§

type Error = Error

The error type.
§

fn deserialize<R>( + _app: &AppHandle<R>, + raw: Value, +) -> Result<T, <T as ScopeObject>::Error>
where + R: Runtime,

Deserialize the raw scope value.
Source§

impl<T> Serialize for T
where + T: Serialize + ?Sized,

Source§

fn erased_serialize(&self, serializer: &mut dyn Serializer) -> Result<(), Error>

Source§

fn do_erased_serialize( + &self, + serializer: &mut dyn Serializer, +) -> Result<(), ErrorImpl>

Source§

impl<T> ToOwned for T
where + T: Clone,

Source§

type Owned = T

The resulting type after obtaining ownership.
Source§

fn to_owned(&self) -> T

Creates owned data from borrowed data, usually by cloning. Read more
Source§

fn clone_into(&self, target: &mut T)

Uses borrowed data to replace owned data, usually by cloning. Read more
Source§

impl<T, U> TryFrom<U> for T
where + U: Into<T>,

Source§

type Error = Infallible

The type returned in the event of a conversion error.
Source§

fn try_from(value: U) -> Result<T, <T as TryFrom<U>>::Error>

Performs the conversion.
Source§

impl<T, U> TryInto<U> for T
where + U: TryFrom<T>,

Source§

type Error = <U as TryFrom<T>>::Error

The type returned in the event of a conversion error.
Source§

fn try_into(self) -> Result<U, <U as TryFrom<T>>::Error>

Performs the conversion.
§

impl<V, T> VZip<V> for T
where + V: MultiLane<T>,

§

fn vzip(self) -> V

§

impl<T> WithSubscriber for T

§

fn with_subscriber<S>(self, subscriber: S) -> WithDispatch<Self>
where + S: Into<Dispatch>,

Attaches the provided Subscriber to this type, returning a +[WithDispatch] wrapper. Read more
§

fn with_current_subscriber(self) -> WithDispatch<Self>

Attaches the current default Subscriber to this type, returning a +[WithDispatch] wrapper. Read more
Source§

impl<T> DeserializeOwned for T
where + T: for<'de> Deserialize<'de>,

§

impl<T> UserEvent for T
where + T: Debug + Clone + Send + 'static,

\ No newline at end of file diff --git a/api/jellytau_lib/settings/fn.audio_settings_jni_payload.html b/api/jellytau_lib/settings/fn.audio_settings_jni_payload.html new file mode 100644 index 00000000..3898ddc0 --- /dev/null +++ b/api/jellytau_lib/settings/fn.audio_settings_jni_payload.html @@ -0,0 +1,12 @@ +audio_settings_jni_payload in jellytau_lib::settings - RustSkip to main content

audio_settings_jni_payload

Function audio_settings_jni_payload 

Source
pub fn audio_settings_jni_payload(
+    settings: &AudioSettings,
+) -> Result<String, Error>
Expand description

Serialise AudioSettings into the JSON payload handed to the Android player +over JNI.

+

Sanitises first (crossfade clamped, band vector normalised) so a malformed +vector can never reach the Kotlin parser. JSON is used rather than a wide JNI +signature so that adding a field does not change the method signature — the +same approach load() already uses for subtitles.

+

The emitted keys are camelCase (serde) and volumeLevel is lowercase; the +Kotlin side matches on those literals. Both are pinned by tests.

+

TRACES: UR-027, UR-032, UR-033 | DR-030, DR-035, DR-036

+
\ No newline at end of file diff --git a/api/jellytau_lib/settings/fn.default_eq_bands.html b/api/jellytau_lib/settings/fn.default_eq_bands.html new file mode 100644 index 00000000..0e9e4bb3 --- /dev/null +++ b/api/jellytau_lib/settings/fn.default_eq_bands.html @@ -0,0 +1,2 @@ +default_eq_bands in jellytau_lib::settings - RustSkip to main content

default_eq_bands

Function default_eq_bands 

Source
fn default_eq_bands() -> Vec<f32>
Expand description

Flat 10-band curve — the default equalizer state.

+
\ No newline at end of file diff --git a/api/jellytau_lib/settings/index.html b/api/jellytau_lib/settings/index.html new file mode 100644 index 00000000..627f7e2a --- /dev/null +++ b/api/jellytau_lib/settings/index.html @@ -0,0 +1,9 @@ +jellytau_lib::settings - RustSkip to main content

Module settings

Module settings 

Source
Expand description

TRACES: UR-023, UR-027, UR-031, UR-032, UR-033 | DR-030, DR-034, DR-035, DR-036, DR-048, IR-020

+

Structs§

AudioSettings
Audio playback settings
LibrarySettings
Library browsing preferences.
VideoSettings
Video playback settings

Enums§

EqPreset
Built-in equalizer presets. A preset is a gain curve defined by the band +layout above (a domain concept), not a mere label — the curve numbers live +in Rust so the frontend never encodes the taxonomy.
StreamingQuality
A ceiling on how much bandwidth a video stream may consume.
VolumeLevel
Volume normalization levels matching Spotify’s presets

Constants§

EQ_BANDS
Centre frequencies (Hz) of the fixed 10-band ISO equalizer. The band count +and layout are a property of the audio engine, not the UI — presets and the +MPV filter are defined against these bands. See +docs/architecture/05-platform-backends.md (“The equalizer, and where its +vocabulary lives”).
EQ_GAIN_MAX
Maximum per-band gain in dB.
EQ_GAIN_MIN
Minimum per-band gain in dB.

Functions§

audio_settings_jni_payload
Serialise AudioSettings into the JSON payload handed to the Android player +over JNI.
default_eq_bands 🔒
Flat 10-band curve — the default equalizer state.
\ No newline at end of file diff --git a/api/jellytau_lib/settings/sidebar-items.js b/api/jellytau_lib/settings/sidebar-items.js new file mode 100644 index 00000000..5bdbae28 --- /dev/null +++ b/api/jellytau_lib/settings/sidebar-items.js @@ -0,0 +1 @@ +window.SIDEBAR_ITEMS = {"constant":["EQ_BANDS","EQ_GAIN_MAX","EQ_GAIN_MIN"],"enum":["EqPreset","StreamingQuality","VolumeLevel"],"fn":["audio_settings_jni_payload","default_eq_bands"],"struct":["AudioSettings","LibrarySettings","VideoSettings"]}; \ No newline at end of file diff --git a/api/jellytau_lib/settings/struct.AudioSettings.html b/api/jellytau_lib/settings/struct.AudioSettings.html new file mode 100644 index 00000000..eaec846d --- /dev/null +++ b/api/jellytau_lib/settings/struct.AudioSettings.html @@ -0,0 +1,70 @@ +AudioSettings in jellytau_lib::settings - RustSkip to main content

AudioSettings

Struct AudioSettings 

Source
pub struct AudioSettings {
+    pub crossfade_duration: f32,
+    pub gapless_playback: bool,
+    pub normalize_volume: bool,
+    pub volume_level: VolumeLevel,
+    pub equalizer_enabled: bool,
+    pub equalizer_bands: Vec<f32>,
+}
Expand description

Audio playback settings

+

Fields§

§crossfade_duration: f32

Crossfade duration in seconds (0 = disabled, max 12)

+
§gapless_playback: bool

Enable gapless playback between tracks

+
§normalize_volume: bool

Enable volume normalization

+
§volume_level: VolumeLevel

Target volume level for normalization

+
§equalizer_enabled: bool

Enable the graphic equalizer. When false, no EQ filter is applied.

+
§equalizer_bands: Vec<f32>

Per-band gains in dB, one per EQ_BANDS. Normalised to 10 entries and +clamped to [EQ_GAIN_MIN, EQ_GAIN_MAX] via Self::with_equalizer_normalised.

+

Implementations§

Source§

impl AudioSettings

Source

pub fn with_crossfade_clamped(self) -> Self

Clamp crossfade duration to valid range (0-12 seconds)

+
Source

pub fn with_equalizer_normalised(self) -> Self

Normalise the equalizer band vector to exactly EQ_BANDS.len() +entries (pad with 0 dB / truncate) and clamp each gain to the valid +range. Guards against malformed persisted or IPC input.

+

TRACES: UR-027 | DR-030

+

Trait Implementations§

Source§

impl Clone for AudioSettings

Source§

fn clone(&self) -> AudioSettings

Returns a duplicate of the value. Read more
1.0.0 (const: unstable) · Source§

fn clone_from(&mut self, source: &Self)

Performs copy-assignment from source. Read more
Source§

impl Debug for AudioSettings

Source§

fn fmt(&self, f: &mut Formatter<'_>) -> Result

Formats the value using the given formatter. Read more
Source§

impl Default for AudioSettings

Source§

fn default() -> Self

Returns the “default value” for a type. Read more
Source§

impl<'de> Deserialize<'de> for AudioSettings

Source§

fn deserialize<__D>(__deserializer: __D) -> Result<Self, __D::Error>
where + __D: Deserializer<'de>,

Deserialize this value from the given Serde deserializer. Read more
Source§

impl NamedType for AudioSettings

Source§

fn sid() -> SpectaID

Source§

fn named_data_type( + type_map: &mut TypeCollection, + generics: &[DataType], +) -> NamedDataType

this is equivalent to [Type::inline] but returns a [NamedDataType] instead.
Source§

fn definition_named_data_type(type_map: &mut TypeCollection) -> NamedDataType

this is equivalent to [Type::definition] but returns a [NamedDataType] instead.
Source§

impl Serialize for AudioSettings

Source§

fn serialize<__S>(&self, __serializer: __S) -> Result<__S::Ok, __S::Error>
where + __S: Serializer,

Serialize this value into the given Serde serializer. Read more
Source§

impl Type for AudioSettings

Source§

fn inline(type_map: &mut TypeCollection, generics: Generics<'_>) -> DataType

Returns the definition of a type using the provided generics. Read more
Source§

fn reference(type_map: &mut TypeCollection, generics: &[DataType]) -> Reference

Generates a datatype corresponding to a reference to this type, +as determined by its category. Getting a reference to a type implies that +it should belong in the type map (since it has to be referenced from somewhere), +so the output of definition will be put into the type map.
Source§

impl Flatten for AudioSettings

Auto Trait Implementations§

Blanket Implementations§

Source§

impl<T> Any for T
where + T: 'static + ?Sized,

Source§

fn type_id(&self) -> TypeId

Gets the TypeId of self. Read more
Source§

impl<T> Borrow<T> for T
where + T: ?Sized,

Source§

fn borrow(&self) -> &T

Immutably borrows from an owned value. Read more
Source§

impl<T> BorrowMut<T> for T
where + T: ?Sized,

Source§

fn borrow_mut(&mut self) -> &mut T

Mutably borrows from an owned value. Read more
Source§

impl<T> CloneToUninit for T
where + T: Clone,

Source§

unsafe fn clone_to_uninit(&self, dest: *mut u8)

🔬This is a nightly-only experimental API. (clone_to_uninit)
Performs copy-assignment from self to dest. Read more
§

impl<'de, D, R> CommandArg<'de, R> for D
where + D: Deserialize<'de>, + R: Runtime,

§

fn from_command(command: CommandItem<'de, R>) -> Result<D, InvokeError>

Derives an instance of Self from the [CommandItem]. Read more
Source§

impl<T> From<T> for T

Source§

fn from(t: T) -> T

Returns the argument unchanged.

+
§

impl<T> FunctionArg for T
where + T: Type,

§

fn to_datatype(type_map: &mut TypeCollection) -> Option<DataType>

Gets the type of an argument as a [DataType]. Read more
§

impl<T> Instrument for T

§

fn instrument(self, span: Span) -> Instrumented<Self>

Instruments this type with the provided [Span], returning an +Instrumented wrapper. Read more
§

fn in_current_span(self) -> Instrumented<Self>

Instruments this type with the current Span, returning an +Instrumented wrapper. Read more
Source§

impl<T, U> Into<U> for T
where + U: From<T>,

Source§

fn into(self) -> U

Calls U::from(self).

+

That is, this conversion is whatever the implementation of +From<T> for U chooses to do.

+
§

impl<T> IpcResponse for T
where + T: Serialize,

§

fn body(self) -> Result<InvokeResponseBody, Error>

Resolve the IPC response body.
§

impl<T> NoneValue for T
where + T: Default,

§

type NoneType = T

§

fn null_value() -> T

The none-equivalent value.
§

impl<T> PolicyExt for T
where + T: ?Sized,

§

fn and<P, B, E>(self, other: P) -> And<T, P>
where + T: Policy<B, E>, + P: Policy<B, E>,

Create a new Policy that returns [Action::Follow] only if self and other return +Action::Follow. Read more
§

fn or<P, B, E>(self, other: P) -> Or<T, P>
where + T: Policy<B, E>, + P: Policy<B, E>,

Create a new Policy that returns [Action::Follow] if either self or other returns +Action::Follow. Read more
Source§

impl<T> Same for T

Source§

type Output = T

Should always be Self
§

impl<T> ScopeObject for T
where + T: Send + Sync + Debug + DeserializeOwned + 'static,

§

type Error = Error

The error type.
§

fn deserialize<R>( + _app: &AppHandle<R>, + raw: Value, +) -> Result<T, <T as ScopeObject>::Error>
where + R: Runtime,

Deserialize the raw scope value.
Source§

impl<T> Serialize for T
where + T: Serialize + ?Sized,

Source§

fn erased_serialize(&self, serializer: &mut dyn Serializer) -> Result<(), Error>

Source§

fn do_erased_serialize( + &self, + serializer: &mut dyn Serializer, +) -> Result<(), ErrorImpl>

Source§

impl<T> ToOwned for T
where + T: Clone,

Source§

type Owned = T

The resulting type after obtaining ownership.
Source§

fn to_owned(&self) -> T

Creates owned data from borrowed data, usually by cloning. Read more
Source§

fn clone_into(&self, target: &mut T)

Uses borrowed data to replace owned data, usually by cloning. Read more
Source§

impl<T, U> TryFrom<U> for T
where + U: Into<T>,

Source§

type Error = Infallible

The type returned in the event of a conversion error.
Source§

fn try_from(value: U) -> Result<T, <T as TryFrom<U>>::Error>

Performs the conversion.
Source§

impl<T, U> TryInto<U> for T
where + U: TryFrom<T>,

Source§

type Error = <U as TryFrom<T>>::Error

The type returned in the event of a conversion error.
Source§

fn try_into(self) -> Result<U, <U as TryFrom<T>>::Error>

Performs the conversion.
§

impl<V, T> VZip<V> for T
where + V: MultiLane<T>,

§

fn vzip(self) -> V

§

impl<T> WithSubscriber for T

§

fn with_subscriber<S>(self, subscriber: S) -> WithDispatch<Self>
where + S: Into<Dispatch>,

Attaches the provided Subscriber to this type, returning a +[WithDispatch] wrapper. Read more
§

fn with_current_subscriber(self) -> WithDispatch<Self>

Attaches the current default Subscriber to this type, returning a +[WithDispatch] wrapper. Read more
Source§

impl<T> DeserializeOwned for T
where + T: for<'de> Deserialize<'de>,

§

impl<T> UserEvent for T
where + T: Debug + Clone + Send + 'static,

\ No newline at end of file diff --git a/api/jellytau_lib/settings/struct.LibrarySettings.html b/api/jellytau_lib/settings/struct.LibrarySettings.html new file mode 100644 index 00000000..b0aa318b --- /dev/null +++ b/api/jellytau_lib/settings/struct.LibrarySettings.html @@ -0,0 +1,77 @@ +LibrarySettings in jellytau_lib::settings - RustSkip to main content

LibrarySettings

Struct LibrarySettings 

Source
pub struct LibrarySettings {
+    pub excluded_item_ids: Vec<String>,
+}
Expand description

Library browsing preferences.

+

Currently a single list: the folders (or whole libraries) the user has asked +to keep out of browsing. It is a list of ids, never names — names are +unstable, locale-dependent and non-unique, and the hardcoded name filter this +setting replaced broke on exactly that. What the ids then hide is decided in +repository::exclusions; this struct is only how the choice is carried and +persisted.

+

The default is an empty list: nobody inherits another user’s folder layout.

+

TRACES: UR-076 | DR-209

+

Fields§

§excluded_item_ids: Vec<String>

Stable item ids of the folders/libraries hidden from browsing.

+

#[serde(default)] so settings JSON persisted before this field existed +loads as the previous behaviour (nothing hidden).

+

Implementations§

Source§

impl LibrarySettings

Source

pub fn sanitised(self) -> Self

Drop blanks and duplicates from the id list.

+

Applied on the way in from IPC and on the way out of the database, so a +hand-edited or half-written value cannot make the list grow without bound +or carry an empty id (which would match nothing but still be shown as a +selection in the picker).

+

TRACES: UR-076 | DR-209

+

Trait Implementations§

Source§

impl Clone for LibrarySettings

Source§

fn clone(&self) -> LibrarySettings

Returns a duplicate of the value. Read more
1.0.0 (const: unstable) · Source§

fn clone_from(&mut self, source: &Self)

Performs copy-assignment from source. Read more
Source§

impl Debug for LibrarySettings

Source§

fn fmt(&self, f: &mut Formatter<'_>) -> Result

Formats the value using the given formatter. Read more
Source§

impl Default for LibrarySettings

Source§

fn default() -> LibrarySettings

Returns the “default value” for a type. Read more
Source§

impl<'de> Deserialize<'de> for LibrarySettings

Source§

fn deserialize<__D>(__deserializer: __D) -> Result<Self, __D::Error>
where + __D: Deserializer<'de>,

Deserialize this value from the given Serde deserializer. Read more
Source§

impl NamedType for LibrarySettings

Source§

fn sid() -> SpectaID

Source§

fn named_data_type( + type_map: &mut TypeCollection, + generics: &[DataType], +) -> NamedDataType

this is equivalent to [Type::inline] but returns a [NamedDataType] instead.
Source§

fn definition_named_data_type(type_map: &mut TypeCollection) -> NamedDataType

this is equivalent to [Type::definition] but returns a [NamedDataType] instead.
Source§

impl PartialEq for LibrarySettings

Source§

fn eq(&self, other: &LibrarySettings) -> bool

Tests for self and other values to be equal, and is used by ==.
1.0.0 (const: unstable) · Source§

fn ne(&self, other: &Rhs) -> bool

Tests for !=. The default implementation is almost always sufficient, +and should not be overridden without very good reason.
Source§

impl Serialize for LibrarySettings

Source§

fn serialize<__S>(&self, __serializer: __S) -> Result<__S::Ok, __S::Error>
where + __S: Serializer,

Serialize this value into the given Serde serializer. Read more
Source§

impl Type for LibrarySettings

Source§

fn inline(type_map: &mut TypeCollection, generics: Generics<'_>) -> DataType

Returns the definition of a type using the provided generics. Read more
Source§

fn reference(type_map: &mut TypeCollection, generics: &[DataType]) -> Reference

Generates a datatype corresponding to a reference to this type, +as determined by its category. Getting a reference to a type implies that +it should belong in the type map (since it has to be referenced from somewhere), +so the output of definition will be put into the type map.
Source§

impl Eq for LibrarySettings

Source§

impl Flatten for LibrarySettings

Source§

impl StructuralPartialEq for LibrarySettings

Auto Trait Implementations§

Blanket Implementations§

Source§

impl<T> Any for T
where + T: 'static + ?Sized,

Source§

fn type_id(&self) -> TypeId

Gets the TypeId of self. Read more
Source§

impl<T> Borrow<T> for T
where + T: ?Sized,

Source§

fn borrow(&self) -> &T

Immutably borrows from an owned value. Read more
Source§

impl<T> BorrowMut<T> for T
where + T: ?Sized,

Source§

fn borrow_mut(&mut self) -> &mut T

Mutably borrows from an owned value. Read more
Source§

impl<T> CloneToUninit for T
where + T: Clone,

Source§

unsafe fn clone_to_uninit(&self, dest: *mut u8)

🔬This is a nightly-only experimental API. (clone_to_uninit)
Performs copy-assignment from self to dest. Read more
§

impl<'de, D, R> CommandArg<'de, R> for D
where + D: Deserialize<'de>, + R: Runtime,

§

fn from_command(command: CommandItem<'de, R>) -> Result<D, InvokeError>

Derives an instance of Self from the [CommandItem]. Read more
§

impl<Q, K> Equivalent<K> for Q
where + Q: Eq + ?Sized, + K: Borrow<Q> + ?Sized,

§

fn equivalent(&self, key: &K) -> bool

Checks if this value is equivalent to the given key. Read more
§

impl<Q, K> Equivalent<K> for Q
where + Q: Eq + ?Sized, + K: Borrow<Q> + ?Sized,

§

fn equivalent(&self, key: &K) -> bool

Compare self to key and return true if they are equal.
§

impl<Q, K> Equivalent<K> for Q
where + Q: Eq + ?Sized, + K: Borrow<Q> + ?Sized,

§

fn equivalent(&self, key: &K) -> bool

Checks if this value is equivalent to the given key. Read more
Source§

impl<T> From<T> for T

Source§

fn from(t: T) -> T

Returns the argument unchanged.

+
§

impl<T> FunctionArg for T
where + T: Type,

§

fn to_datatype(type_map: &mut TypeCollection) -> Option<DataType>

Gets the type of an argument as a [DataType]. Read more
§

impl<T> Instrument for T

§

fn instrument(self, span: Span) -> Instrumented<Self>

Instruments this type with the provided [Span], returning an +Instrumented wrapper. Read more
§

fn in_current_span(self) -> Instrumented<Self>

Instruments this type with the current Span, returning an +Instrumented wrapper. Read more
Source§

impl<T, U> Into<U> for T
where + U: From<T>,

Source§

fn into(self) -> U

Calls U::from(self).

+

That is, this conversion is whatever the implementation of +From<T> for U chooses to do.

+
§

impl<T> IpcResponse for T
where + T: Serialize,

§

fn body(self) -> Result<InvokeResponseBody, Error>

Resolve the IPC response body.
§

impl<T> NoneValue for T
where + T: Default,

§

type NoneType = T

§

fn null_value() -> T

The none-equivalent value.
§

impl<T> PolicyExt for T
where + T: ?Sized,

§

fn and<P, B, E>(self, other: P) -> And<T, P>
where + T: Policy<B, E>, + P: Policy<B, E>,

Create a new Policy that returns [Action::Follow] only if self and other return +Action::Follow. Read more
§

fn or<P, B, E>(self, other: P) -> Or<T, P>
where + T: Policy<B, E>, + P: Policy<B, E>,

Create a new Policy that returns [Action::Follow] if either self or other returns +Action::Follow. Read more
Source§

impl<T> Same for T

Source§

type Output = T

Should always be Self
§

impl<T> ScopeObject for T
where + T: Send + Sync + Debug + DeserializeOwned + 'static,

§

type Error = Error

The error type.
§

fn deserialize<R>( + _app: &AppHandle<R>, + raw: Value, +) -> Result<T, <T as ScopeObject>::Error>
where + R: Runtime,

Deserialize the raw scope value.
Source§

impl<T> Serialize for T
where + T: Serialize + ?Sized,

Source§

fn erased_serialize(&self, serializer: &mut dyn Serializer) -> Result<(), Error>

Source§

fn do_erased_serialize( + &self, + serializer: &mut dyn Serializer, +) -> Result<(), ErrorImpl>

Source§

impl<T> ToOwned for T
where + T: Clone,

Source§

type Owned = T

The resulting type after obtaining ownership.
Source§

fn to_owned(&self) -> T

Creates owned data from borrowed data, usually by cloning. Read more
Source§

fn clone_into(&self, target: &mut T)

Uses borrowed data to replace owned data, usually by cloning. Read more
Source§

impl<T, U> TryFrom<U> for T
where + U: Into<T>,

Source§

type Error = Infallible

The type returned in the event of a conversion error.
Source§

fn try_from(value: U) -> Result<T, <T as TryFrom<U>>::Error>

Performs the conversion.
Source§

impl<T, U> TryInto<U> for T
where + U: TryFrom<T>,

Source§

type Error = <U as TryFrom<T>>::Error

The type returned in the event of a conversion error.
Source§

fn try_into(self) -> Result<U, <U as TryFrom<T>>::Error>

Performs the conversion.
§

impl<V, T> VZip<V> for T
where + V: MultiLane<T>,

§

fn vzip(self) -> V

§

impl<T> WithSubscriber for T

§

fn with_subscriber<S>(self, subscriber: S) -> WithDispatch<Self>
where + S: Into<Dispatch>,

Attaches the provided Subscriber to this type, returning a +[WithDispatch] wrapper. Read more
§

fn with_current_subscriber(self) -> WithDispatch<Self>

Attaches the current default Subscriber to this type, returning a +[WithDispatch] wrapper. Read more
Source§

impl<T> DeserializeOwned for T
where + T: for<'de> Deserialize<'de>,

§

impl<T> UserEvent for T
where + T: Debug + Clone + Send + 'static,

\ No newline at end of file diff --git a/api/jellytau_lib/settings/struct.VideoSettings.html b/api/jellytau_lib/settings/struct.VideoSettings.html new file mode 100644 index 00000000..8b373b39 --- /dev/null +++ b/api/jellytau_lib/settings/struct.VideoSettings.html @@ -0,0 +1,64 @@ +VideoSettings in jellytau_lib::settings - RustSkip to main content

VideoSettings

Struct VideoSettings 

Source
pub struct VideoSettings {
+    pub auto_play_next_episode: bool,
+    pub auto_play_countdown_seconds: u32,
+    pub auto_play_max_episodes: u32,
+    pub streaming_quality: StreamingQuality,
+}
Expand description

Video playback settings

+

Fields§

§auto_play_next_episode: bool

Enable auto-play of next episode (with countdown)

+
§auto_play_countdown_seconds: u32

Countdown duration in seconds before auto-play (5-30 seconds)

+
§auto_play_max_episodes: u32

Maximum number of episodes to auto-play consecutively (0 = unlimited)

+
§streaming_quality: StreamingQuality

Bandwidth ceiling applied to every video stream.

+

#[serde(default)] so settings JSON persisted before this field existed +loads as the previous behaviour (uncapped).

+

TRACES: UR-074 | DR-162

+

Implementations§

Source§

impl VideoSettings

Source

pub fn with_countdown_clamped(self) -> Self

Clamp countdown duration to valid range (5-30 seconds)

+

Trait Implementations§

Source§

impl Clone for VideoSettings

Source§

fn clone(&self) -> VideoSettings

Returns a duplicate of the value. Read more
1.0.0 (const: unstable) · Source§

fn clone_from(&mut self, source: &Self)

Performs copy-assignment from source. Read more
Source§

impl Debug for VideoSettings

Source§

fn fmt(&self, f: &mut Formatter<'_>) -> Result

Formats the value using the given formatter. Read more
Source§

impl Default for VideoSettings

Source§

fn default() -> Self

Returns the “default value” for a type. Read more
Source§

impl<'de> Deserialize<'de> for VideoSettings

Source§

fn deserialize<__D>(__deserializer: __D) -> Result<Self, __D::Error>
where + __D: Deserializer<'de>,

Deserialize this value from the given Serde deserializer. Read more
Source§

impl NamedType for VideoSettings

Source§

fn sid() -> SpectaID

Source§

fn named_data_type( + type_map: &mut TypeCollection, + generics: &[DataType], +) -> NamedDataType

this is equivalent to [Type::inline] but returns a [NamedDataType] instead.
Source§

fn definition_named_data_type(type_map: &mut TypeCollection) -> NamedDataType

this is equivalent to [Type::definition] but returns a [NamedDataType] instead.
Source§

impl Serialize for VideoSettings

Source§

fn serialize<__S>(&self, __serializer: __S) -> Result<__S::Ok, __S::Error>
where + __S: Serializer,

Serialize this value into the given Serde serializer. Read more
Source§

impl Type for VideoSettings

Source§

fn inline(type_map: &mut TypeCollection, generics: Generics<'_>) -> DataType

Returns the definition of a type using the provided generics. Read more
Source§

fn reference(type_map: &mut TypeCollection, generics: &[DataType]) -> Reference

Generates a datatype corresponding to a reference to this type, +as determined by its category. Getting a reference to a type implies that +it should belong in the type map (since it has to be referenced from somewhere), +so the output of definition will be put into the type map.
Source§

impl Flatten for VideoSettings

Auto Trait Implementations§

Blanket Implementations§

Source§

impl<T> Any for T
where + T: 'static + ?Sized,

Source§

fn type_id(&self) -> TypeId

Gets the TypeId of self. Read more
Source§

impl<T> Borrow<T> for T
where + T: ?Sized,

Source§

fn borrow(&self) -> &T

Immutably borrows from an owned value. Read more
Source§

impl<T> BorrowMut<T> for T
where + T: ?Sized,

Source§

fn borrow_mut(&mut self) -> &mut T

Mutably borrows from an owned value. Read more
Source§

impl<T> CloneToUninit for T
where + T: Clone,

Source§

unsafe fn clone_to_uninit(&self, dest: *mut u8)

🔬This is a nightly-only experimental API. (clone_to_uninit)
Performs copy-assignment from self to dest. Read more
§

impl<'de, D, R> CommandArg<'de, R> for D
where + D: Deserialize<'de>, + R: Runtime,

§

fn from_command(command: CommandItem<'de, R>) -> Result<D, InvokeError>

Derives an instance of Self from the [CommandItem]. Read more
Source§

impl<T> From<T> for T

Source§

fn from(t: T) -> T

Returns the argument unchanged.

+
§

impl<T> FunctionArg for T
where + T: Type,

§

fn to_datatype(type_map: &mut TypeCollection) -> Option<DataType>

Gets the type of an argument as a [DataType]. Read more
§

impl<T> Instrument for T

§

fn instrument(self, span: Span) -> Instrumented<Self>

Instruments this type with the provided [Span], returning an +Instrumented wrapper. Read more
§

fn in_current_span(self) -> Instrumented<Self>

Instruments this type with the current Span, returning an +Instrumented wrapper. Read more
Source§

impl<T, U> Into<U> for T
where + U: From<T>,

Source§

fn into(self) -> U

Calls U::from(self).

+

That is, this conversion is whatever the implementation of +From<T> for U chooses to do.

+
§

impl<T> IpcResponse for T
where + T: Serialize,

§

fn body(self) -> Result<InvokeResponseBody, Error>

Resolve the IPC response body.
§

impl<T> NoneValue for T
where + T: Default,

§

type NoneType = T

§

fn null_value() -> T

The none-equivalent value.
§

impl<T> PolicyExt for T
where + T: ?Sized,

§

fn and<P, B, E>(self, other: P) -> And<T, P>
where + T: Policy<B, E>, + P: Policy<B, E>,

Create a new Policy that returns [Action::Follow] only if self and other return +Action::Follow. Read more
§

fn or<P, B, E>(self, other: P) -> Or<T, P>
where + T: Policy<B, E>, + P: Policy<B, E>,

Create a new Policy that returns [Action::Follow] if either self or other returns +Action::Follow. Read more
Source§

impl<T> Same for T

Source§

type Output = T

Should always be Self
§

impl<T> ScopeObject for T
where + T: Send + Sync + Debug + DeserializeOwned + 'static,

§

type Error = Error

The error type.
§

fn deserialize<R>( + _app: &AppHandle<R>, + raw: Value, +) -> Result<T, <T as ScopeObject>::Error>
where + R: Runtime,

Deserialize the raw scope value.
Source§

impl<T> Serialize for T
where + T: Serialize + ?Sized,

Source§

fn erased_serialize(&self, serializer: &mut dyn Serializer) -> Result<(), Error>

Source§

fn do_erased_serialize( + &self, + serializer: &mut dyn Serializer, +) -> Result<(), ErrorImpl>

Source§

impl<T> ToOwned for T
where + T: Clone,

Source§

type Owned = T

The resulting type after obtaining ownership.
Source§

fn to_owned(&self) -> T

Creates owned data from borrowed data, usually by cloning. Read more
Source§

fn clone_into(&self, target: &mut T)

Uses borrowed data to replace owned data, usually by cloning. Read more
Source§

impl<T, U> TryFrom<U> for T
where + U: Into<T>,

Source§

type Error = Infallible

The type returned in the event of a conversion error.
Source§

fn try_from(value: U) -> Result<T, <T as TryFrom<U>>::Error>

Performs the conversion.
Source§

impl<T, U> TryInto<U> for T
where + U: TryFrom<T>,

Source§

type Error = <U as TryFrom<T>>::Error

The type returned in the event of a conversion error.
Source§

fn try_into(self) -> Result<U, <U as TryFrom<T>>::Error>

Performs the conversion.
§

impl<V, T> VZip<V> for T
where + V: MultiLane<T>,

§

fn vzip(self) -> V

§

impl<T> WithSubscriber for T

§

fn with_subscriber<S>(self, subscriber: S) -> WithDispatch<Self>
where + S: Into<Dispatch>,

Attaches the provided Subscriber to this type, returning a +[WithDispatch] wrapper. Read more
§

fn with_current_subscriber(self) -> WithDispatch<Self>

Attaches the current default Subscriber to this type, returning a +[WithDispatch] wrapper. Read more
Source§

impl<T> DeserializeOwned for T
where + T: for<'de> Deserialize<'de>,

§

impl<T> UserEvent for T
where + T: Debug + Clone + Send + 'static,

\ No newline at end of file diff --git a/api/jellytau_lib/sidebar-items.js b/api/jellytau_lib/sidebar-items.js new file mode 100644 index 00000000..24e73e04 --- /dev/null +++ b/api/jellytau_lib/sidebar-items.js @@ -0,0 +1 @@ +window.SIDEBAR_ITEMS = {"fn":["build_log_plugin","create_player_backend","emit_backend_init_failed","enable_linux_hardware_video_decoding","log_available_vaapi_decoders","run","set_env_if_unset","specta_builder"],"mod":["auth","commands","connectivity","credentials","domain","download","jellyfin","media_server","playback_mode","playback_reporting","player","repository","session_poller","settings","storage","thumbnail","utils"],"struct":["BackendInitError"]}; \ No newline at end of file diff --git a/api/jellytau_lib/storage/db_service/enum.QueryParam.html b/api/jellytau_lib/storage/db_service/enum.QueryParam.html new file mode 100644 index 00000000..9741acec --- /dev/null +++ b/api/jellytau_lib/storage/db_service/enum.QueryParam.html @@ -0,0 +1,35 @@ +QueryParam in jellytau_lib::storage::db_service - RustSkip to main content

QueryParam

Enum QueryParam 

Source
pub enum QueryParam {
+    String(String),
+    Int(i32),
+    Int64(i64),
+    Float(f64),
+    Bool(bool),
+    Null,
+}
Expand description

Query parameter types supported by the database

+

Variants§

§

String(String)

§

Int(i32)

§

Int64(i64)

§

Float(f64)

§

Bool(bool)

§

Null

Trait Implementations§

Source§

impl Clone for QueryParam

Source§

fn clone(&self) -> QueryParam

Returns a duplicate of the value. Read more
1.0.0 (const: unstable) · Source§

fn clone_from(&mut self, source: &Self)

Performs copy-assignment from source. Read more
Source§

impl Debug for QueryParam

Source§

fn fmt(&self, f: &mut Formatter<'_>) -> Result

Formats the value using the given formatter. Read more

Auto Trait Implementations§

Blanket Implementations§

Source§

impl<T> Any for T
where + T: 'static + ?Sized,

Source§

fn type_id(&self) -> TypeId

Gets the TypeId of self. Read more
Source§

impl<T> Borrow<T> for T
where + T: ?Sized,

Source§

fn borrow(&self) -> &T

Immutably borrows from an owned value. Read more
Source§

impl<T> BorrowMut<T> for T
where + T: ?Sized,

Source§

fn borrow_mut(&mut self) -> &mut T

Mutably borrows from an owned value. Read more
Source§

impl<T> CloneToUninit for T
where + T: Clone,

Source§

unsafe fn clone_to_uninit(&self, dest: *mut u8)

🔬This is a nightly-only experimental API. (clone_to_uninit)
Performs copy-assignment from self to dest. Read more
Source§

impl<T> From<T> for T

Source§

fn from(t: T) -> T

Returns the argument unchanged.

+
§

impl<T> Instrument for T

§

fn instrument(self, span: Span) -> Instrumented<Self>

Instruments this type with the provided [Span], returning an +Instrumented wrapper. Read more
§

fn in_current_span(self) -> Instrumented<Self>

Instruments this type with the current Span, returning an +Instrumented wrapper. Read more
Source§

impl<T, U> Into<U> for T
where + U: From<T>,

Source§

fn into(self) -> U

Calls U::from(self).

+

That is, this conversion is whatever the implementation of +From<T> for U chooses to do.

+
§

impl<T> PolicyExt for T
where + T: ?Sized,

§

fn and<P, B, E>(self, other: P) -> And<T, P>
where + T: Policy<B, E>, + P: Policy<B, E>,

Create a new Policy that returns [Action::Follow] only if self and other return +Action::Follow. Read more
§

fn or<P, B, E>(self, other: P) -> Or<T, P>
where + T: Policy<B, E>, + P: Policy<B, E>,

Create a new Policy that returns [Action::Follow] if either self or other returns +Action::Follow. Read more
Source§

impl<T> Same for T

Source§

type Output = T

Should always be Self
Source§

impl<T> ToOwned for T
where + T: Clone,

Source§

type Owned = T

The resulting type after obtaining ownership.
Source§

fn to_owned(&self) -> T

Creates owned data from borrowed data, usually by cloning. Read more
Source§

fn clone_into(&self, target: &mut T)

Uses borrowed data to replace owned data, usually by cloning. Read more
Source§

impl<T, U> TryFrom<U> for T
where + U: Into<T>,

Source§

type Error = Infallible

The type returned in the event of a conversion error.
Source§

fn try_from(value: U) -> Result<T, <T as TryFrom<U>>::Error>

Performs the conversion.
Source§

impl<T, U> TryInto<U> for T
where + U: TryFrom<T>,

Source§

type Error = <U as TryFrom<T>>::Error

The type returned in the event of a conversion error.
Source§

fn try_into(self) -> Result<U, <U as TryFrom<T>>::Error>

Performs the conversion.
§

impl<V, T> VZip<V> for T
where + V: MultiLane<T>,

§

fn vzip(self) -> V

§

impl<T> WithSubscriber for T

§

fn with_subscriber<S>(self, subscriber: S) -> WithDispatch<Self>
where + S: Into<Dispatch>,

Attaches the provided Subscriber to this type, returning a +[WithDispatch] wrapper. Read more
§

fn with_current_subscriber(self) -> WithDispatch<Self>

Attaches the current default Subscriber to this type, returning a +[WithDispatch] wrapper. Read more
§

impl<T> UserEvent for T
where + T: Debug + Clone + Send + 'static,

\ No newline at end of file diff --git a/api/jellytau_lib/storage/db_service/fn.convert_params.html b/api/jellytau_lib/storage/db_service/fn.convert_params.html new file mode 100644 index 00000000..133bb226 --- /dev/null +++ b/api/jellytau_lib/storage/db_service/fn.convert_params.html @@ -0,0 +1,2 @@ +convert_params in jellytau_lib::storage::db_service - RustSkip to main content

convert_params

Function convert_params 

Source
fn convert_params(params: &[QueryParam]) -> Vec<Value>
Expand description

Convert QueryParam to rusqlite::types::Value

+
\ No newline at end of file diff --git a/api/jellytau_lib/storage/db_service/fn.execute_query.html b/api/jellytau_lib/storage/db_service/fn.execute_query.html new file mode 100644 index 00000000..e469908c --- /dev/null +++ b/api/jellytau_lib/storage/db_service/fn.execute_query.html @@ -0,0 +1 @@ +execute_query in jellytau_lib::storage::db_service - RustSkip to main content

execute_query

Function execute_query 

Source
fn execute_query(conn: &Connection, query: Query) -> Result<usize, String>
\ No newline at end of file diff --git a/api/jellytau_lib/storage/db_service/fn.query_many.html b/api/jellytau_lib/storage/db_service/fn.query_many.html new file mode 100644 index 00000000..c69c2dc3 --- /dev/null +++ b/api/jellytau_lib/storage/db_service/fn.query_many.html @@ -0,0 +1,6 @@ +query_many in jellytau_lib::storage::db_service - RustSkip to main content

query_many

Function query_many 

Source
fn query_many<T, F>(
+    conn: &Connection,
+    query: Query,
+    mapper: F,
+) -> Result<Vec<T>, String>
where + F: Fn(&Row<'_>) -> Result<T>,
\ No newline at end of file diff --git a/api/jellytau_lib/storage/db_service/fn.query_one.html b/api/jellytau_lib/storage/db_service/fn.query_one.html new file mode 100644 index 00000000..7dac22e9 --- /dev/null +++ b/api/jellytau_lib/storage/db_service/fn.query_one.html @@ -0,0 +1,6 @@ +query_one in jellytau_lib::storage::db_service - RustSkip to main content

query_one

Function query_one 

Source
fn query_one<T, F>(
+    conn: &Connection,
+    query: Query,
+    mapper: F,
+) -> Result<T, String>
where + F: Fn(&Row<'_>) -> Result<T>,
\ No newline at end of file diff --git a/api/jellytau_lib/storage/db_service/fn.query_optional.html b/api/jellytau_lib/storage/db_service/fn.query_optional.html new file mode 100644 index 00000000..c6c87728 --- /dev/null +++ b/api/jellytau_lib/storage/db_service/fn.query_optional.html @@ -0,0 +1,6 @@ +query_optional in jellytau_lib::storage::db_service - RustSkip to main content

query_optional

Function query_optional 

Source
fn query_optional<T, F>(
+    conn: &Connection,
+    query: Query,
+    mapper: F,
+) -> Result<Option<T>, String>
where + F: Fn(&Row<'_>) -> Result<T>,
\ No newline at end of file diff --git a/api/jellytau_lib/storage/db_service/index.html b/api/jellytau_lib/storage/db_service/index.html new file mode 100644 index 00000000..a99ea467 --- /dev/null +++ b/api/jellytau_lib/storage/db_service/index.html @@ -0,0 +1,10 @@ +jellytau_lib::storage::db_service - RustSkip to main content

Module db_service

Module db_service 

Source
Expand description

Database service abstraction layer

+

This module provides an async database interface that abstracts away +the underlying database implementation. This makes it easy to:

+
    +
  • Switch between sync (rusqlite) and async (tokio-rusqlite) implementations
  • +
  • Prevent blocking the async runtime with synchronous database calls
  • +
  • Test with different database backends
  • +
  • Migrate to other database systems in the future
  • +
+

Structs§

Query
Represents a database query that can be executed
RusqliteService
Rusqlite-based database service implementation
Transaction
Transaction handle for batching multiple operations

Enums§

QueryParam
Query parameter types supported by the database

Traits§

DatabaseService
Database service trait - abstraction over database operations

Functions§

convert_params 🔒
Convert QueryParam to rusqlite::types::Value
execute_query 🔒
query_many 🔒
query_one 🔒
query_optional 🔒

Type Aliases§

DbResult
Database query result type
\ No newline at end of file diff --git a/api/jellytau_lib/storage/db_service/sidebar-items.js b/api/jellytau_lib/storage/db_service/sidebar-items.js new file mode 100644 index 00000000..e5581a59 --- /dev/null +++ b/api/jellytau_lib/storage/db_service/sidebar-items.js @@ -0,0 +1 @@ +window.SIDEBAR_ITEMS = {"enum":["QueryParam"],"fn":["convert_params","execute_query","query_many","query_one","query_optional"],"struct":["Query","RusqliteService","Transaction"],"trait":["DatabaseService"],"type":["DbResult"]}; \ No newline at end of file diff --git a/api/jellytau_lib/storage/db_service/struct.Query.html b/api/jellytau_lib/storage/db_service/struct.Query.html new file mode 100644 index 00000000..9c0ab6a0 --- /dev/null +++ b/api/jellytau_lib/storage/db_service/struct.Query.html @@ -0,0 +1,30 @@ +Query in jellytau_lib::storage::db_service - RustSkip to main content

Query

Struct Query 

Source
pub struct Query {
+    pub sql: String,
+    pub params: Vec<QueryParam>,
+}
Expand description

Represents a database query that can be executed

+

Fields§

§sql: String§params: Vec<QueryParam>

Implementations§

Source§

impl Query

Source

pub fn new(sql: impl Into<String>) -> Self

Source

pub fn with_params(sql: impl Into<String>, params: Vec<QueryParam>) -> Self

Trait Implementations§

Source§

impl Clone for Query

Source§

fn clone(&self) -> Query

Returns a duplicate of the value. Read more
1.0.0 (const: unstable) · Source§

fn clone_from(&mut self, source: &Self)

Performs copy-assignment from source. Read more

Auto Trait Implementations§

§

impl Freeze for Query

§

impl RefUnwindSafe for Query

§

impl Send for Query

§

impl Sync for Query

§

impl Unpin for Query

§

impl UnsafeUnpin for Query

§

impl UnwindSafe for Query

Blanket Implementations§

Source§

impl<T> Any for T
where + T: 'static + ?Sized,

Source§

fn type_id(&self) -> TypeId

Gets the TypeId of self. Read more
Source§

impl<T> Borrow<T> for T
where + T: ?Sized,

Source§

fn borrow(&self) -> &T

Immutably borrows from an owned value. Read more
Source§

impl<T> BorrowMut<T> for T
where + T: ?Sized,

Source§

fn borrow_mut(&mut self) -> &mut T

Mutably borrows from an owned value. Read more
Source§

impl<T> CloneToUninit for T
where + T: Clone,

Source§

unsafe fn clone_to_uninit(&self, dest: *mut u8)

🔬This is a nightly-only experimental API. (clone_to_uninit)
Performs copy-assignment from self to dest. Read more
Source§

impl<T> From<T> for T

Source§

fn from(t: T) -> T

Returns the argument unchanged.

+
§

impl<T> Instrument for T

§

fn instrument(self, span: Span) -> Instrumented<Self>

Instruments this type with the provided [Span], returning an +Instrumented wrapper. Read more
§

fn in_current_span(self) -> Instrumented<Self>

Instruments this type with the current Span, returning an +Instrumented wrapper. Read more
Source§

impl<T, U> Into<U> for T
where + U: From<T>,

Source§

fn into(self) -> U

Calls U::from(self).

+

That is, this conversion is whatever the implementation of +From<T> for U chooses to do.

+
§

impl<T> PolicyExt for T
where + T: ?Sized,

§

fn and<P, B, E>(self, other: P) -> And<T, P>
where + T: Policy<B, E>, + P: Policy<B, E>,

Create a new Policy that returns [Action::Follow] only if self and other return +Action::Follow. Read more
§

fn or<P, B, E>(self, other: P) -> Or<T, P>
where + T: Policy<B, E>, + P: Policy<B, E>,

Create a new Policy that returns [Action::Follow] if either self or other returns +Action::Follow. Read more
Source§

impl<T> Same for T

Source§

type Output = T

Should always be Self
Source§

impl<T> ToOwned for T
where + T: Clone,

Source§

type Owned = T

The resulting type after obtaining ownership.
Source§

fn to_owned(&self) -> T

Creates owned data from borrowed data, usually by cloning. Read more
Source§

fn clone_into(&self, target: &mut T)

Uses borrowed data to replace owned data, usually by cloning. Read more
Source§

impl<T, U> TryFrom<U> for T
where + U: Into<T>,

Source§

type Error = Infallible

The type returned in the event of a conversion error.
Source§

fn try_from(value: U) -> Result<T, <T as TryFrom<U>>::Error>

Performs the conversion.
Source§

impl<T, U> TryInto<U> for T
where + U: TryFrom<T>,

Source§

type Error = <U as TryFrom<T>>::Error

The type returned in the event of a conversion error.
Source§

fn try_into(self) -> Result<U, <U as TryFrom<T>>::Error>

Performs the conversion.
§

impl<V, T> VZip<V> for T
where + V: MultiLane<T>,

§

fn vzip(self) -> V

§

impl<T> WithSubscriber for T

§

fn with_subscriber<S>(self, subscriber: S) -> WithDispatch<Self>
where + S: Into<Dispatch>,

Attaches the provided Subscriber to this type, returning a +[WithDispatch] wrapper. Read more
§

fn with_current_subscriber(self) -> WithDispatch<Self>

Attaches the current default Subscriber to this type, returning a +[WithDispatch] wrapper. Read more
\ No newline at end of file diff --git a/api/jellytau_lib/storage/db_service/struct.RusqliteService.html b/api/jellytau_lib/storage/db_service/struct.RusqliteService.html new file mode 100644 index 00000000..c36ea096 --- /dev/null +++ b/api/jellytau_lib/storage/db_service/struct.RusqliteService.html @@ -0,0 +1,75 @@ +RusqliteService in jellytau_lib::storage::db_service - RustSkip to main content

RusqliteService

Struct RusqliteService 

Source
pub struct RusqliteService {
+    conn: Arc<Mutex<Connection>>,
+}
Expand description

Rusqlite-based database service implementation

+

This implementation wraps synchronous rusqlite operations in tokio::task::spawn_blocking +to prevent blocking the async runtime.

+

Fields§

§conn: Arc<Mutex<Connection>>

Implementations§

Source§

impl RusqliteService

Source

pub fn new(conn: Arc<Mutex<Connection>>) -> Self

Trait Implementations§

Source§

impl DatabaseService for RusqliteService

Source§

fn execute<'life0, 'async_trait>( + &'life0 self, + query: Query, +) -> Pin<Box<dyn Future<Output = Result<usize, String>> + Send + 'async_trait>>
where + Self: 'async_trait, + 'life0: 'async_trait,

Execute a query that doesn’t return results (INSERT, UPDATE, DELETE)
Source§

fn execute_batch<'life0, 'life1, 'async_trait>( + &'life0 self, + sql: &'life1 str, +) -> Pin<Box<dyn Future<Output = Result<(), String>> + Send + 'async_trait>>
where + Self: 'async_trait, + 'life0: 'async_trait, + 'life1: 'async_trait,

Execute a batch of SQL statements (for migrations)
Source§

fn query_one<'life0, 'async_trait, T, F>( + &'life0 self, + query: Query, + mapper: F, +) -> Pin<Box<dyn Future<Output = Result<T, String>> + Send + 'async_trait>>
where + T: Send + 'static + 'async_trait, + F: Fn(&Row<'_>) -> SqliteResult<T> + Send + 'static + 'async_trait, + Self: 'async_trait, + 'life0: 'async_trait,

Query a single row
Source§

fn query_optional<'life0, 'async_trait, T, F>( + &'life0 self, + query: Query, + mapper: F, +) -> Pin<Box<dyn Future<Output = Result<Option<T>, String>> + Send + 'async_trait>>
where + T: Send + 'static + 'async_trait, + F: Fn(&Row<'_>) -> SqliteResult<T> + Send + 'static + 'async_trait, + Self: 'async_trait, + 'life0: 'async_trait,

Query a single optional row
Source§

fn query_many<'life0, 'async_trait, T, F>( + &'life0 self, + query: Query, + mapper: F, +) -> Pin<Box<dyn Future<Output = Result<Vec<T>, String>> + Send + 'async_trait>>
where + T: Send + 'static + 'async_trait, + F: Fn(&Row<'_>) -> SqliteResult<T> + Send + 'static + 'async_trait, + Self: 'async_trait, + 'life0: 'async_trait,

Query multiple rows
Source§

fn transaction<'life0, 'async_trait, F, T>( + &'life0 self, + f: F, +) -> Pin<Box<dyn Future<Output = Result<T, String>> + Send + 'async_trait>>
where + F: FnOnce(&mut Transaction<'_>) -> Result<T, String> + Send + 'static + 'async_trait, + T: Send + 'static + 'async_trait, + Self: 'async_trait, + 'life0: 'async_trait,

Run a transaction with multiple operations
Source§

fn last_insert_rowid<'life0, 'async_trait>( + &'life0 self, +) -> Pin<Box<dyn Future<Output = Result<i64, String>> + Send + 'async_trait>>
where + Self: 'async_trait, + 'life0: 'async_trait,

Get the row ID of the most recent successful INSERT

Auto Trait Implementations§

Blanket Implementations§

Source§

impl<T> Any for T
where + T: 'static + ?Sized,

Source§

fn type_id(&self) -> TypeId

Gets the TypeId of self. Read more
Source§

impl<T> Borrow<T> for T
where + T: ?Sized,

Source§

fn borrow(&self) -> &T

Immutably borrows from an owned value. Read more
Source§

impl<T> BorrowMut<T> for T
where + T: ?Sized,

Source§

fn borrow_mut(&mut self) -> &mut T

Mutably borrows from an owned value. Read more
Source§

impl<T> From<T> for T

Source§

fn from(t: T) -> T

Returns the argument unchanged.

+
§

impl<T> Instrument for T

§

fn instrument(self, span: Span) -> Instrumented<Self>

Instruments this type with the provided [Span], returning an +Instrumented wrapper. Read more
§

fn in_current_span(self) -> Instrumented<Self>

Instruments this type with the current Span, returning an +Instrumented wrapper. Read more
Source§

impl<T, U> Into<U> for T
where + U: From<T>,

Source§

fn into(self) -> U

Calls U::from(self).

+

That is, this conversion is whatever the implementation of +From<T> for U chooses to do.

+
§

impl<T> PolicyExt for T
where + T: ?Sized,

§

fn and<P, B, E>(self, other: P) -> And<T, P>
where + T: Policy<B, E>, + P: Policy<B, E>,

Create a new Policy that returns [Action::Follow] only if self and other return +Action::Follow. Read more
§

fn or<P, B, E>(self, other: P) -> Or<T, P>
where + T: Policy<B, E>, + P: Policy<B, E>,

Create a new Policy that returns [Action::Follow] if either self or other returns +Action::Follow. Read more
Source§

impl<T> Same for T

Source§

type Output = T

Should always be Self
Source§

impl<T, U> TryFrom<U> for T
where + U: Into<T>,

Source§

type Error = Infallible

The type returned in the event of a conversion error.
Source§

fn try_from(value: U) -> Result<T, <T as TryFrom<U>>::Error>

Performs the conversion.
Source§

impl<T, U> TryInto<U> for T
where + U: TryFrom<T>,

Source§

type Error = <U as TryFrom<T>>::Error

The type returned in the event of a conversion error.
Source§

fn try_into(self) -> Result<U, <U as TryFrom<T>>::Error>

Performs the conversion.
§

impl<V, T> VZip<V> for T
where + V: MultiLane<T>,

§

fn vzip(self) -> V

§

impl<T> WithSubscriber for T

§

fn with_subscriber<S>(self, subscriber: S) -> WithDispatch<Self>
where + S: Into<Dispatch>,

Attaches the provided Subscriber to this type, returning a +[WithDispatch] wrapper. Read more
§

fn with_current_subscriber(self) -> WithDispatch<Self>

Attaches the current default Subscriber to this type, returning a +[WithDispatch] wrapper. Read more
\ No newline at end of file diff --git a/api/jellytau_lib/storage/db_service/struct.Transaction.html b/api/jellytau_lib/storage/db_service/struct.Transaction.html new file mode 100644 index 00000000..91129742 --- /dev/null +++ b/api/jellytau_lib/storage/db_service/struct.Transaction.html @@ -0,0 +1,32 @@ +Transaction in jellytau_lib::storage::db_service - RustSkip to main content

Transaction

Struct Transaction 

Source
pub struct Transaction<'a> {
+    conn: &'a Connection,
+}
Expand description

Transaction handle for batching multiple operations

+

Fields§

§conn: &'a Connection

Implementations§

Source§

impl<'a> Transaction<'a>

Source

pub fn new(conn: &'a Connection) -> Self

Source

pub fn execute(&mut self, query: Query) -> Result<usize, String>

Source

pub fn query_many<T, F>( + &self, + query: Query, + mapper: F, +) -> Result<Vec<T>, String>
where + F: Fn(&Row<'_>) -> SqliteResult<T>,

Auto Trait Implementations§

§

impl<'a> Freeze for Transaction<'a>

§

impl<'a> !RefUnwindSafe for Transaction<'a>

§

impl<'a> !Send for Transaction<'a>

§

impl<'a> !Sync for Transaction<'a>

§

impl<'a> Unpin for Transaction<'a>

§

impl<'a> UnsafeUnpin for Transaction<'a>

§

impl<'a> !UnwindSafe for Transaction<'a>

Blanket Implementations§

Source§

impl<T> Any for T
where + T: 'static + ?Sized,

Source§

fn type_id(&self) -> TypeId

Gets the TypeId of self. Read more
Source§

impl<T> Borrow<T> for T
where + T: ?Sized,

Source§

fn borrow(&self) -> &T

Immutably borrows from an owned value. Read more
Source§

impl<T> BorrowMut<T> for T
where + T: ?Sized,

Source§

fn borrow_mut(&mut self) -> &mut T

Mutably borrows from an owned value. Read more
Source§

impl<T> From<T> for T

Source§

fn from(t: T) -> T

Returns the argument unchanged.

+
§

impl<T> Instrument for T

§

fn instrument(self, span: Span) -> Instrumented<Self>

Instruments this type with the provided [Span], returning an +Instrumented wrapper. Read more
§

fn in_current_span(self) -> Instrumented<Self>

Instruments this type with the current Span, returning an +Instrumented wrapper. Read more
Source§

impl<T, U> Into<U> for T
where + U: From<T>,

Source§

fn into(self) -> U

Calls U::from(self).

+

That is, this conversion is whatever the implementation of +From<T> for U chooses to do.

+
§

impl<T> PolicyExt for T
where + T: ?Sized,

§

fn and<P, B, E>(self, other: P) -> And<T, P>
where + T: Policy<B, E>, + P: Policy<B, E>,

Create a new Policy that returns [Action::Follow] only if self and other return +Action::Follow. Read more
§

fn or<P, B, E>(self, other: P) -> Or<T, P>
where + T: Policy<B, E>, + P: Policy<B, E>,

Create a new Policy that returns [Action::Follow] if either self or other returns +Action::Follow. Read more
Source§

impl<T> Same for T

Source§

type Output = T

Should always be Self
Source§

impl<T, U> TryFrom<U> for T
where + U: Into<T>,

Source§

type Error = Infallible

The type returned in the event of a conversion error.
Source§

fn try_from(value: U) -> Result<T, <T as TryFrom<U>>::Error>

Performs the conversion.
Source§

impl<T, U> TryInto<U> for T
where + U: TryFrom<T>,

Source§

type Error = <U as TryFrom<T>>::Error

The type returned in the event of a conversion error.
Source§

fn try_into(self) -> Result<U, <U as TryFrom<T>>::Error>

Performs the conversion.
§

impl<V, T> VZip<V> for T
where + V: MultiLane<T>,

§

fn vzip(self) -> V

§

impl<T> WithSubscriber for T

§

fn with_subscriber<S>(self, subscriber: S) -> WithDispatch<Self>
where + S: Into<Dispatch>,

Attaches the provided Subscriber to this type, returning a +[WithDispatch] wrapper. Read more
§

fn with_current_subscriber(self) -> WithDispatch<Self>

Attaches the current default Subscriber to this type, returning a +[WithDispatch] wrapper. Read more
\ No newline at end of file diff --git a/api/jellytau_lib/storage/db_service/trait.DatabaseService.html b/api/jellytau_lib/storage/db_service/trait.DatabaseService.html new file mode 100644 index 00000000..faa180ff --- /dev/null +++ b/api/jellytau_lib/storage/db_service/trait.DatabaseService.html @@ -0,0 +1,110 @@ +DatabaseService in jellytau_lib::storage::db_service - RustSkip to main content

DatabaseService

Trait DatabaseService 

Source
pub trait DatabaseService: Send + Sync {
+    // Required methods
+    fn execute<'life0, 'async_trait>(
+        &'life0 self,
+        query: Query,
+    ) -> Pin<Box<dyn Future<Output = Result<usize, String>> + Send + 'async_trait>>
+       where Self: 'async_trait,
+             'life0: 'async_trait;
+    fn execute_batch<'life0, 'life1, 'async_trait>(
+        &'life0 self,
+        sql: &'life1 str,
+    ) -> Pin<Box<dyn Future<Output = Result<(), String>> + Send + 'async_trait>>
+       where Self: 'async_trait,
+             'life0: 'async_trait,
+             'life1: 'async_trait;
+    fn query_one<'life0, 'async_trait, T, F>(
+        &'life0 self,
+        query: Query,
+        mapper: F,
+    ) -> Pin<Box<dyn Future<Output = Result<T, String>> + Send + 'async_trait>>
+       where T: Send + 'static + 'async_trait,
+             F: Fn(&Row<'_>) -> SqliteResult<T> + Send + 'static + 'async_trait,
+             Self: 'async_trait,
+             'life0: 'async_trait;
+    fn query_optional<'life0, 'async_trait, T, F>(
+        &'life0 self,
+        query: Query,
+        mapper: F,
+    ) -> Pin<Box<dyn Future<Output = Result<Option<T>, String>> + Send + 'async_trait>>
+       where T: Send + 'static + 'async_trait,
+             F: Fn(&Row<'_>) -> SqliteResult<T> + Send + 'static + 'async_trait,
+             Self: 'async_trait,
+             'life0: 'async_trait;
+    fn query_many<'life0, 'async_trait, T, F>(
+        &'life0 self,
+        query: Query,
+        mapper: F,
+    ) -> Pin<Box<dyn Future<Output = Result<Vec<T>, String>> + Send + 'async_trait>>
+       where T: Send + 'static + 'async_trait,
+             F: Fn(&Row<'_>) -> SqliteResult<T> + Send + 'static + 'async_trait,
+             Self: 'async_trait,
+             'life0: 'async_trait;
+    fn transaction<'life0, 'async_trait, F, T>(
+        &'life0 self,
+        f: F,
+    ) -> Pin<Box<dyn Future<Output = Result<T, String>> + Send + 'async_trait>>
+       where F: FnOnce(&mut Transaction<'_>) -> Result<T, String> + Send + 'static + 'async_trait,
+             T: Send + 'static + 'async_trait,
+             Self: 'async_trait,
+             'life0: 'async_trait;
+    fn last_insert_rowid<'life0, 'async_trait>(
+        &'life0 self,
+    ) -> Pin<Box<dyn Future<Output = Result<i64, String>> + Send + 'async_trait>>
+       where Self: 'async_trait,
+             'life0: 'async_trait;
+}
Expand description

Database service trait - abstraction over database operations

+

Required Methods§

Source

fn execute<'life0, 'async_trait>( + &'life0 self, + query: Query, +) -> Pin<Box<dyn Future<Output = Result<usize, String>> + Send + 'async_trait>>
where + Self: 'async_trait, + 'life0: 'async_trait,

Execute a query that doesn’t return results (INSERT, UPDATE, DELETE)

+
Source

fn execute_batch<'life0, 'life1, 'async_trait>( + &'life0 self, + sql: &'life1 str, +) -> Pin<Box<dyn Future<Output = Result<(), String>> + Send + 'async_trait>>
where + Self: 'async_trait, + 'life0: 'async_trait, + 'life1: 'async_trait,

Execute a batch of SQL statements (for migrations)

+
Source

fn query_one<'life0, 'async_trait, T, F>( + &'life0 self, + query: Query, + mapper: F, +) -> Pin<Box<dyn Future<Output = Result<T, String>> + Send + 'async_trait>>
where + T: Send + 'static + 'async_trait, + F: Fn(&Row<'_>) -> SqliteResult<T> + Send + 'static + 'async_trait, + Self: 'async_trait, + 'life0: 'async_trait,

Query a single row

+
Source

fn query_optional<'life0, 'async_trait, T, F>( + &'life0 self, + query: Query, + mapper: F, +) -> Pin<Box<dyn Future<Output = Result<Option<T>, String>> + Send + 'async_trait>>
where + T: Send + 'static + 'async_trait, + F: Fn(&Row<'_>) -> SqliteResult<T> + Send + 'static + 'async_trait, + Self: 'async_trait, + 'life0: 'async_trait,

Query a single optional row

+
Source

fn query_many<'life0, 'async_trait, T, F>( + &'life0 self, + query: Query, + mapper: F, +) -> Pin<Box<dyn Future<Output = Result<Vec<T>, String>> + Send + 'async_trait>>
where + T: Send + 'static + 'async_trait, + F: Fn(&Row<'_>) -> SqliteResult<T> + Send + 'static + 'async_trait, + Self: 'async_trait, + 'life0: 'async_trait,

Query multiple rows

+
Source

fn transaction<'life0, 'async_trait, F, T>( + &'life0 self, + f: F, +) -> Pin<Box<dyn Future<Output = Result<T, String>> + Send + 'async_trait>>
where + F: FnOnce(&mut Transaction<'_>) -> Result<T, String> + Send + 'static + 'async_trait, + T: Send + 'static + 'async_trait, + Self: 'async_trait, + 'life0: 'async_trait,

Run a transaction with multiple operations

+
Source

fn last_insert_rowid<'life0, 'async_trait>( + &'life0 self, +) -> Pin<Box<dyn Future<Output = Result<i64, String>> + Send + 'async_trait>>
where + Self: 'async_trait, + 'life0: 'async_trait,

Get the row ID of the most recent successful INSERT

+

Dyn Compatibility§

This trait is not dyn compatible.

In older versions of Rust, dyn compatibility was called "object safety", so this trait is not object safe.

Implementors§

\ No newline at end of file diff --git a/api/jellytau_lib/storage/db_service/type.DbResult.html b/api/jellytau_lib/storage/db_service/type.DbResult.html new file mode 100644 index 00000000..47e380a4 --- /dev/null +++ b/api/jellytau_lib/storage/db_service/type.DbResult.html @@ -0,0 +1,7 @@ +DbResult in jellytau_lib::storage::db_service - RustSkip to main content

DbResult

Type Alias DbResult 

Source
pub type DbResult<T> = Result<T, String>;
Expand description

Database query result type

+

Aliased Type§

pub enum DbResult<T> {
+    Ok(T),
+    Err(String),
+}

Variants§

§1.0.0

Ok(T)

Contains the success value

+
§1.0.0

Err(String)

Contains the error value

+
\ No newline at end of file diff --git a/api/jellytau_lib/storage/index.html b/api/jellytau_lib/storage/index.html new file mode 100644 index 00000000..2e5c375c --- /dev/null +++ b/api/jellytau_lib/storage/index.html @@ -0,0 +1,4 @@ +jellytau_lib::storage - RustSkip to main content

Module storage

Module storage 

Source
Expand description

Offline storage module using SQLite

+

Provides local caching of Jellyfin metadata, download management, +and offline mutation queue for sync-back operations.

+

Re-exports§

pub use db_service::DatabaseService;
pub use db_service::RusqliteService;

Modules§

db_service
Database service abstraction layer
models
Database model structs
schema
Database schema and migrations

Structs§

Database
Database connection wrapper with thread-safe access
\ No newline at end of file diff --git a/api/jellytau_lib/storage/models/index.html b/api/jellytau_lib/storage/models/index.html new file mode 100644 index 00000000..1a297b3d --- /dev/null +++ b/api/jellytau_lib/storage/models/index.html @@ -0,0 +1,2 @@ +jellytau_lib::storage::models - RustSkip to main content

Module models

Module models 

Source
Expand description

Database model structs

+

Structs§

PlaylistItem
Playlist item entry
\ No newline at end of file diff --git a/api/jellytau_lib/storage/models/sidebar-items.js b/api/jellytau_lib/storage/models/sidebar-items.js new file mode 100644 index 00000000..cbed3c23 --- /dev/null +++ b/api/jellytau_lib/storage/models/sidebar-items.js @@ -0,0 +1 @@ +window.SIDEBAR_ITEMS = {"struct":["PlaylistItem"]}; \ No newline at end of file diff --git a/api/jellytau_lib/storage/models/struct.PlaylistItem.html b/api/jellytau_lib/storage/models/struct.PlaylistItem.html new file mode 100644 index 00000000..10fbae1b --- /dev/null +++ b/api/jellytau_lib/storage/models/struct.PlaylistItem.html @@ -0,0 +1,49 @@ +PlaylistItem in jellytau_lib::storage::models - RustSkip to main content

PlaylistItem

Struct PlaylistItem 

Source
pub struct PlaylistItem {
+    pub id: Option<i64>,
+    pub playlist_id: String,
+    pub item_id: String,
+    pub sort_order: i32,
+    pub added_at: Option<DateTime<Utc>>,
+}
Expand description

Playlist item entry

+

Fields§

§id: Option<i64>§playlist_id: String§item_id: String§sort_order: i32§added_at: Option<DateTime<Utc>>

Trait Implementations§

Source§

impl Clone for PlaylistItem

Source§

fn clone(&self) -> PlaylistItem

Returns a duplicate of the value. Read more
1.0.0 (const: unstable) · Source§

fn clone_from(&mut self, source: &Self)

Performs copy-assignment from source. Read more
Source§

impl Debug for PlaylistItem

Source§

fn fmt(&self, f: &mut Formatter<'_>) -> Result

Formats the value using the given formatter. Read more
Source§

impl<'de> Deserialize<'de> for PlaylistItem

Source§

fn deserialize<__D>(__deserializer: __D) -> Result<Self, __D::Error>
where + __D: Deserializer<'de>,

Deserialize this value from the given Serde deserializer. Read more
Source§

impl Serialize for PlaylistItem

Source§

fn serialize<__S>(&self, __serializer: __S) -> Result<__S::Ok, __S::Error>
where + __S: Serializer,

Serialize this value into the given Serde serializer. Read more

Auto Trait Implementations§

Blanket Implementations§

Source§

impl<T> Any for T
where + T: 'static + ?Sized,

Source§

fn type_id(&self) -> TypeId

Gets the TypeId of self. Read more
Source§

impl<T> Borrow<T> for T
where + T: ?Sized,

Source§

fn borrow(&self) -> &T

Immutably borrows from an owned value. Read more
Source§

impl<T> BorrowMut<T> for T
where + T: ?Sized,

Source§

fn borrow_mut(&mut self) -> &mut T

Mutably borrows from an owned value. Read more
Source§

impl<T> CloneToUninit for T
where + T: Clone,

Source§

unsafe fn clone_to_uninit(&self, dest: *mut u8)

🔬This is a nightly-only experimental API. (clone_to_uninit)
Performs copy-assignment from self to dest. Read more
§

impl<'de, D, R> CommandArg<'de, R> for D
where + D: Deserialize<'de>, + R: Runtime,

§

fn from_command(command: CommandItem<'de, R>) -> Result<D, InvokeError>

Derives an instance of Self from the [CommandItem]. Read more
Source§

impl<T> From<T> for T

Source§

fn from(t: T) -> T

Returns the argument unchanged.

+
§

impl<T> Instrument for T

§

fn instrument(self, span: Span) -> Instrumented<Self>

Instruments this type with the provided [Span], returning an +Instrumented wrapper. Read more
§

fn in_current_span(self) -> Instrumented<Self>

Instruments this type with the current Span, returning an +Instrumented wrapper. Read more
Source§

impl<T, U> Into<U> for T
where + U: From<T>,

Source§

fn into(self) -> U

Calls U::from(self).

+

That is, this conversion is whatever the implementation of +From<T> for U chooses to do.

+
§

impl<T> IpcResponse for T
where + T: Serialize,

§

fn body(self) -> Result<InvokeResponseBody, Error>

Resolve the IPC response body.
§

impl<T> PolicyExt for T
where + T: ?Sized,

§

fn and<P, B, E>(self, other: P) -> And<T, P>
where + T: Policy<B, E>, + P: Policy<B, E>,

Create a new Policy that returns [Action::Follow] only if self and other return +Action::Follow. Read more
§

fn or<P, B, E>(self, other: P) -> Or<T, P>
where + T: Policy<B, E>, + P: Policy<B, E>,

Create a new Policy that returns [Action::Follow] if either self or other returns +Action::Follow. Read more
Source§

impl<T> Same for T

Source§

type Output = T

Should always be Self
§

impl<T> ScopeObject for T
where + T: Send + Sync + Debug + DeserializeOwned + 'static,

§

type Error = Error

The error type.
§

fn deserialize<R>( + _app: &AppHandle<R>, + raw: Value, +) -> Result<T, <T as ScopeObject>::Error>
where + R: Runtime,

Deserialize the raw scope value.
Source§

impl<T> Serialize for T
where + T: Serialize + ?Sized,

Source§

fn erased_serialize(&self, serializer: &mut dyn Serializer) -> Result<(), Error>

Source§

fn do_erased_serialize( + &self, + serializer: &mut dyn Serializer, +) -> Result<(), ErrorImpl>

Source§

impl<T> ToOwned for T
where + T: Clone,

Source§

type Owned = T

The resulting type after obtaining ownership.
Source§

fn to_owned(&self) -> T

Creates owned data from borrowed data, usually by cloning. Read more
Source§

fn clone_into(&self, target: &mut T)

Uses borrowed data to replace owned data, usually by cloning. Read more
Source§

impl<T, U> TryFrom<U> for T
where + U: Into<T>,

Source§

type Error = Infallible

The type returned in the event of a conversion error.
Source§

fn try_from(value: U) -> Result<T, <T as TryFrom<U>>::Error>

Performs the conversion.
Source§

impl<T, U> TryInto<U> for T
where + U: TryFrom<T>,

Source§

type Error = <U as TryFrom<T>>::Error

The type returned in the event of a conversion error.
Source§

fn try_into(self) -> Result<U, <U as TryFrom<T>>::Error>

Performs the conversion.
§

impl<V, T> VZip<V> for T
where + V: MultiLane<T>,

§

fn vzip(self) -> V

§

impl<T> WithSubscriber for T

§

fn with_subscriber<S>(self, subscriber: S) -> WithDispatch<Self>
where + S: Into<Dispatch>,

Attaches the provided Subscriber to this type, returning a +[WithDispatch] wrapper. Read more
§

fn with_current_subscriber(self) -> WithDispatch<Self>

Attaches the current default Subscriber to this type, returning a +[WithDispatch] wrapper. Read more
Source§

impl<T> DeserializeOwned for T
where + T: for<'de> Deserialize<'de>,

§

impl<T> UserEvent for T
where + T: Debug + Clone + Send + 'static,

\ No newline at end of file diff --git a/api/jellytau_lib/storage/schema/constant.MIGRATIONS.html b/api/jellytau_lib/storage/schema/constant.MIGRATIONS.html new file mode 100644 index 00000000..ab71aeec --- /dev/null +++ b/api/jellytau_lib/storage/schema/constant.MIGRATIONS.html @@ -0,0 +1,3 @@ +MIGRATIONS in jellytau_lib::storage::schema - RustSkip to main content

MIGRATIONS

Constant MIGRATIONS 

Source
pub const MIGRATIONS: &[(&str, &str)];
Expand description

List of migrations to apply in order. +Each migration is a tuple of (name, sql).

+
\ No newline at end of file diff --git a/api/jellytau_lib/storage/schema/constant.MIGRATION_001.html b/api/jellytau_lib/storage/schema/constant.MIGRATION_001.html new file mode 100644 index 00000000..a59af697 --- /dev/null +++ b/api/jellytau_lib/storage/schema/constant.MIGRATION_001.html @@ -0,0 +1,262 @@ +MIGRATION_001 in jellytau_lib::storage::schema - RustSkip to main content

MIGRATION_001

Constant MIGRATION_001 

Source
const MIGRATION_001: &str = r#"
+-- Jellyfin servers the user has connected to
+CREATE TABLE IF NOT EXISTS servers (
+    id TEXT PRIMARY KEY,
+    name TEXT NOT NULL,
+    url TEXT NOT NULL UNIQUE,
+    version TEXT,
+    created_at TEXT DEFAULT CURRENT_TIMESTAMP,
+    last_connected_at TEXT
+);
+
+-- User accounts on Jellyfin servers
+CREATE TABLE IF NOT EXISTS users (
+    id TEXT PRIMARY KEY,
+    server_id TEXT NOT NULL REFERENCES servers(id) ON DELETE CASCADE,
+    username TEXT NOT NULL,
+    access_token TEXT,
+    is_active INTEGER DEFAULT 0,
+    created_at TEXT DEFAULT CURRENT_TIMESTAMP,
+    last_login_at TEXT,
+    UNIQUE(server_id, username)
+);
+
+-- Libraries/views from Jellyfin
+CREATE TABLE IF NOT EXISTS libraries (
+    id TEXT PRIMARY KEY,
+    server_id TEXT NOT NULL REFERENCES servers(id) ON DELETE CASCADE,
+    name TEXT NOT NULL,
+    collection_type TEXT,
+    image_tag TEXT,
+    sort_order INTEGER DEFAULT 0,
+    synced_at TEXT,
+    UNIQUE(server_id, id)
+);
+
+-- Media items (movies, shows, episodes, albums, songs, artists)
+CREATE TABLE IF NOT EXISTS items (
+    id TEXT PRIMARY KEY,
+    server_id TEXT NOT NULL REFERENCES servers(id) ON DELETE CASCADE,
+    library_id TEXT REFERENCES libraries(id) ON DELETE SET NULL,
+    parent_id TEXT REFERENCES items(id) ON DELETE CASCADE,
+
+    -- Core metadata
+    name TEXT NOT NULL,
+    sort_name TEXT,
+    original_title TEXT,
+    item_type TEXT NOT NULL,  -- Movie, Series, Episode, MusicAlbum, Audio, MusicArtist, etc.
+
+    -- Media info
+    overview TEXT,
+    tagline TEXT,
+    genres TEXT,  -- JSON array
+    tags TEXT,    -- JSON array
+    studios TEXT, -- JSON array
+
+    -- For episodes
+    series_id TEXT,
+    series_name TEXT,
+    season_id TEXT,
+    season_name TEXT,
+    index_number INTEGER,        -- Episode number
+    parent_index_number INTEGER, -- Season number
+
+    -- For music
+    album_id TEXT,
+    album_name TEXT,
+    album_artist TEXT,
+    artists TEXT,  -- JSON array
+
+    -- Dates
+    premiere_date TEXT,
+    production_year INTEGER,
+    date_created TEXT,
+
+    -- Runtime (ticks)
+    runtime_ticks INTEGER,
+
+    -- Images
+    primary_image_tag TEXT,
+    backdrop_image_tags TEXT,  -- JSON array
+
+    -- Ratings
+    community_rating REAL,
+    official_rating TEXT,
+
+    -- Sync metadata
+    synced_at TEXT,
+    etag TEXT,
+
+    UNIQUE(server_id, id)
+);
+
+-- Full-text search index for items
+CREATE VIRTUAL TABLE IF NOT EXISTS items_fts USING fts5(
+    name,
+    overview,
+    album_name,
+    album_artist,
+    artists,
+    series_name,
+    content='items',
+    content_rowid='rowid'
+);
+
+-- Triggers to keep FTS index in sync
+CREATE TRIGGER IF NOT EXISTS items_ai AFTER INSERT ON items BEGIN
+    INSERT INTO items_fts(rowid, name, overview, album_name, album_artist, artists, series_name)
+    VALUES (new.rowid, new.name, new.overview, new.album_name, new.album_artist, new.artists, new.series_name);
+END;
+
+CREATE TRIGGER IF NOT EXISTS items_ad AFTER DELETE ON items BEGIN
+    INSERT INTO items_fts(items_fts, rowid, name, overview, album_name, album_artist, artists, series_name)
+    VALUES('delete', old.rowid, old.name, old.overview, old.album_name, old.album_artist, old.artists, old.series_name);
+END;
+
+CREATE TRIGGER IF NOT EXISTS items_au AFTER UPDATE ON items BEGIN
+    INSERT INTO items_fts(items_fts, rowid, name, overview, album_name, album_artist, artists, series_name)
+    VALUES('delete', old.rowid, old.name, old.overview, old.album_name, old.album_artist, old.artists, old.series_name);
+    INSERT INTO items_fts(rowid, name, overview, album_name, album_artist, artists, series_name)
+    VALUES (new.rowid, new.name, new.overview, new.album_name, new.album_artist, new.artists, new.series_name);
+END;
+
+-- Media streams (audio/subtitle tracks)
+CREATE TABLE IF NOT EXISTS media_streams (
+    id INTEGER PRIMARY KEY AUTOINCREMENT,
+    item_id TEXT NOT NULL REFERENCES items(id) ON DELETE CASCADE,
+    stream_index INTEGER NOT NULL,
+    stream_type TEXT NOT NULL,  -- Audio, Subtitle, Video
+    codec TEXT,
+    language TEXT,
+    display_title TEXT,
+    is_default INTEGER DEFAULT 0,
+    is_forced INTEGER DEFAULT 0,
+    is_external INTEGER DEFAULT 0,
+    path TEXT,  -- For external subtitles
+    UNIQUE(item_id, stream_index)
+);
+
+-- User-specific data (watch progress, favorites)
+CREATE TABLE IF NOT EXISTS user_data (
+    id INTEGER PRIMARY KEY AUTOINCREMENT,
+    user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
+    item_id TEXT NOT NULL REFERENCES items(id) ON DELETE CASCADE,
+
+    -- Playback state
+    playback_position_ticks INTEGER DEFAULT 0,
+    play_count INTEGER DEFAULT 0,
+    is_played INTEGER DEFAULT 0,
+    is_favorite INTEGER DEFAULT 0,
+
+    -- Timestamps
+    last_played_at TEXT,
+
+    -- Sync status
+    synced_at TEXT,
+    pending_sync INTEGER DEFAULT 0,  -- 1 if local changes need sync
+
+    UNIQUE(user_id, item_id)
+);
+
+-- Downloaded media files
+CREATE TABLE IF NOT EXISTS downloads (
+    id INTEGER PRIMARY KEY AUTOINCREMENT,
+    item_id TEXT NOT NULL REFERENCES items(id) ON DELETE CASCADE,
+    user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
+
+    -- File info
+    file_path TEXT NOT NULL,
+    file_size INTEGER,
+    mime_type TEXT,
+
+    -- Download state
+    status TEXT DEFAULT 'pending',  -- pending, downloading, completed, failed, paused
+    progress REAL DEFAULT 0,        -- 0.0 to 1.0
+
+    -- Transcoding options used
+    bitrate INTEGER,
+    container TEXT,
+
+    -- Timestamps
+    queued_at TEXT DEFAULT CURRENT_TIMESTAMP,
+    started_at TEXT,
+    completed_at TEXT,
+
+    -- Error tracking
+    error_message TEXT,
+    retry_count INTEGER DEFAULT 0,
+
+    UNIQUE(item_id, user_id)
+);
+
+-- Offline mutation queue (changes to sync back to server)
+CREATE TABLE IF NOT EXISTS sync_queue (
+    id INTEGER PRIMARY KEY AUTOINCREMENT,
+    user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
+
+    -- Operation details
+    operation TEXT NOT NULL,  -- mark_played, mark_favorite, update_progress, etc.
+    item_id TEXT,
+    payload TEXT,  -- JSON data for the operation
+
+    -- Queue state
+    status TEXT DEFAULT 'pending',  -- pending, processing, completed, failed
+    retry_count INTEGER DEFAULT 0,
+
+    -- Timestamps
+    created_at TEXT DEFAULT CURRENT_TIMESTAMP,
+    processed_at TEXT,
+
+    -- Error tracking
+    error_message TEXT
+);
+
+-- Cached thumbnails
+CREATE TABLE IF NOT EXISTS thumbnails (
+    id INTEGER PRIMARY KEY AUTOINCREMENT,
+    item_id TEXT NOT NULL REFERENCES items(id) ON DELETE CASCADE,
+    image_type TEXT NOT NULL,  -- Primary, Backdrop, Thumb, Logo, etc.
+    image_tag TEXT NOT NULL,
+    file_path TEXT NOT NULL,
+    width INTEGER,
+    height INTEGER,
+    cached_at TEXT DEFAULT CURRENT_TIMESTAMP,
+    UNIQUE(item_id, image_type, image_tag)
+);
+
+-- User playlists (local + synced)
+CREATE TABLE IF NOT EXISTS playlists (
+    id TEXT PRIMARY KEY,
+    user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
+    name TEXT NOT NULL,
+    is_local INTEGER DEFAULT 0,  -- 1 for local-only playlists
+    jellyfin_id TEXT,            -- NULL for local-only
+    created_at TEXT DEFAULT CURRENT_TIMESTAMP,
+    updated_at TEXT
+);
+
+-- Playlist items
+CREATE TABLE IF NOT EXISTS playlist_items (
+    id INTEGER PRIMARY KEY AUTOINCREMENT,
+    playlist_id TEXT NOT NULL REFERENCES playlists(id) ON DELETE CASCADE,
+    item_id TEXT NOT NULL REFERENCES items(id) ON DELETE CASCADE,
+    sort_order INTEGER NOT NULL,
+    added_at TEXT DEFAULT CURRENT_TIMESTAMP,
+    UNIQUE(playlist_id, item_id)
+);
+
+-- Indexes for common queries
+CREATE INDEX IF NOT EXISTS idx_items_server ON items(server_id);
+CREATE INDEX IF NOT EXISTS idx_items_library ON items(library_id);
+CREATE INDEX IF NOT EXISTS idx_items_parent ON items(parent_id);
+CREATE INDEX IF NOT EXISTS idx_items_type ON items(item_type);
+CREATE INDEX IF NOT EXISTS idx_items_album ON items(album_id);
+CREATE INDEX IF NOT EXISTS idx_items_series ON items(series_id);
+CREATE INDEX IF NOT EXISTS idx_items_season ON items(season_id);
+CREATE INDEX IF NOT EXISTS idx_user_data_user ON user_data(user_id);
+CREATE INDEX IF NOT EXISTS idx_user_data_item ON user_data(item_id);
+CREATE INDEX IF NOT EXISTS idx_downloads_status ON downloads(status);
+CREATE INDEX IF NOT EXISTS idx_sync_queue_status ON sync_queue(status);
+CREATE INDEX IF NOT EXISTS idx_thumbnails_item ON thumbnails(item_id);
+"#;
Expand description

Initial schema migration

+
\ No newline at end of file diff --git a/api/jellytau_lib/storage/schema/constant.MIGRATION_002.html b/api/jellytau_lib/storage/schema/constant.MIGRATION_002.html new file mode 100644 index 00000000..17bdf835 --- /dev/null +++ b/api/jellytau_lib/storage/schema/constant.MIGRATION_002.html @@ -0,0 +1,25 @@ +MIGRATION_002 in jellytau_lib::storage::schema - RustSkip to main content

MIGRATION_002

Constant MIGRATION_002 

Source
const MIGRATION_002: &str = r#"
+-- Remove access_token column from users table
+-- Tokens are now stored in secure storage (system keyring)
+
+-- SQLite doesn't support DROP COLUMN in older versions, so we recreate the table
+CREATE TABLE IF NOT EXISTS users_new (
+    id TEXT PRIMARY KEY,
+    server_id TEXT NOT NULL REFERENCES servers(id) ON DELETE CASCADE,
+    username TEXT NOT NULL,
+    is_active INTEGER DEFAULT 0,
+    created_at TEXT DEFAULT CURRENT_TIMESTAMP,
+    last_login_at TEXT,
+    UNIQUE(server_id, username)
+);
+
+-- Copy existing data (excluding access_token)
+INSERT OR IGNORE INTO users_new (id, server_id, username, is_active, created_at, last_login_at)
+SELECT id, server_id, username, is_active, created_at, last_login_at FROM users;
+
+-- Drop old table and rename new one
+DROP TABLE IF EXISTS users;
+ALTER TABLE users_new RENAME TO users;
+"#;
Expand description

Migration to remove access_token column from users table +Tokens are now stored in the system keyring (or encrypted file fallback)

+
\ No newline at end of file diff --git a/api/jellytau_lib/storage/schema/constant.MIGRATION_003.html b/api/jellytau_lib/storage/schema/constant.MIGRATION_003.html new file mode 100644 index 00000000..adac75c0 --- /dev/null +++ b/api/jellytau_lib/storage/schema/constant.MIGRATION_003.html @@ -0,0 +1,39 @@ +MIGRATION_003 in jellytau_lib::storage::schema - RustSkip to main content

MIGRATION_003

Constant MIGRATION_003 

Source
const MIGRATION_003: &str = r#"
+-- Recreate user_data table without foreign key constraint on item_id
+-- This allows tracking playback progress for items that haven't been synced locally yet
+
+CREATE TABLE IF NOT EXISTS user_data_new (
+    id INTEGER PRIMARY KEY AUTOINCREMENT,
+    user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
+    item_id TEXT NOT NULL,  -- No foreign key constraint - item may not be synced yet
+
+    -- Playback state
+    playback_position_ticks INTEGER DEFAULT 0,
+    play_count INTEGER DEFAULT 0,
+    is_played INTEGER DEFAULT 0,
+    is_favorite INTEGER DEFAULT 0,
+
+    -- Timestamps
+    last_played_at TEXT,
+
+    -- Sync status
+    synced_at TEXT,
+    pending_sync INTEGER DEFAULT 0,  -- 1 if local changes need sync
+
+    UNIQUE(user_id, item_id)
+);
+
+-- Copy existing data
+INSERT OR IGNORE INTO user_data_new (id, user_id, item_id, playback_position_ticks, play_count, is_played, is_favorite, last_played_at, synced_at, pending_sync)
+SELECT id, user_id, item_id, playback_position_ticks, play_count, is_played, is_favorite, last_played_at, synced_at, pending_sync FROM user_data;
+
+-- Drop old table and rename new one
+DROP TABLE IF EXISTS user_data;
+ALTER TABLE user_data_new RENAME TO user_data;
+
+-- Recreate index
+CREATE INDEX IF NOT EXISTS idx_user_data_user ON user_data(user_id);
+CREATE INDEX IF NOT EXISTS idx_user_data_item ON user_data(item_id);
+"#;
Expand description

Migration to relax foreign key constraints on user_data table +Allows tracking playback progress for items not yet synced to local database

+
\ No newline at end of file diff --git a/api/jellytau_lib/storage/schema/constant.MIGRATION_004.html b/api/jellytau_lib/storage/schema/constant.MIGRATION_004.html new file mode 100644 index 00000000..5a0ea974 --- /dev/null +++ b/api/jellytau_lib/storage/schema/constant.MIGRATION_004.html @@ -0,0 +1,13 @@ +MIGRATION_004 in jellytau_lib::storage::schema - RustSkip to main content

MIGRATION_004

Constant MIGRATION_004 

Source
const MIGRATION_004: &str = r#"
+-- Add priority column for queue ordering
+ALTER TABLE downloads ADD COLUMN priority INTEGER DEFAULT 0;
+
+-- Add bytes_downloaded for resume support
+ALTER TABLE downloads ADD COLUMN bytes_downloaded INTEGER DEFAULT 0;
+
+-- Create index for efficient queue processing (priority DESC, FIFO within same priority)
+CREATE INDEX IF NOT EXISTS idx_downloads_queue
+  ON downloads(status, priority DESC, queued_at ASC)
+  WHERE status IN ('pending', 'downloading');
+"#;
Expand description

Migration to enhance downloads table with priority and bytes_downloaded

+
\ No newline at end of file diff --git a/api/jellytau_lib/storage/schema/constant.MIGRATION_005.html b/api/jellytau_lib/storage/schema/constant.MIGRATION_005.html new file mode 100644 index 00000000..9e4e2696 --- /dev/null +++ b/api/jellytau_lib/storage/schema/constant.MIGRATION_005.html @@ -0,0 +1,62 @@ +MIGRATION_005 in jellytau_lib::storage::schema - RustSkip to main content

MIGRATION_005

Constant MIGRATION_005 

Source
const MIGRATION_005: &str = r#"
+-- Recreate downloads table without foreign key constraint on item_id
+-- This allows downloading items that haven't been synced locally yet
+
+CREATE TABLE IF NOT EXISTS downloads_new (
+    id INTEGER PRIMARY KEY AUTOINCREMENT,
+    item_id TEXT NOT NULL,  -- No foreign key constraint - item may not be synced yet
+    user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
+
+    -- File info
+    file_path TEXT NOT NULL,
+    file_size INTEGER,
+    mime_type TEXT,
+
+    -- Download state
+    status TEXT DEFAULT 'pending',  -- pending, downloading, completed, failed, paused
+    progress REAL DEFAULT 0,        -- 0.0 to 1.0
+
+    -- Transcoding options used
+    bitrate INTEGER,
+    container TEXT,
+
+    -- Timestamps
+    queued_at TEXT DEFAULT CURRENT_TIMESTAMP,
+    started_at TEXT,
+    completed_at TEXT,
+
+    -- Error tracking
+    error_message TEXT,
+    retry_count INTEGER DEFAULT 0,
+
+    -- Priority and progress tracking (from migration 004)
+    priority INTEGER DEFAULT 0,
+    bytes_downloaded INTEGER DEFAULT 0,
+
+    UNIQUE(item_id, user_id)
+);
+
+-- Copy existing data
+INSERT OR IGNORE INTO downloads_new (
+    id, item_id, user_id, file_path, file_size, mime_type, status, progress,
+    bitrate, container, queued_at, started_at, completed_at, error_message,
+    retry_count, priority, bytes_downloaded
+)
+SELECT
+    id, item_id, user_id, file_path, file_size, mime_type, status, progress,
+    bitrate, container, queued_at, started_at, completed_at, error_message,
+    retry_count, priority, bytes_downloaded
+FROM downloads;
+
+-- Drop old table and rename new one
+DROP TABLE IF EXISTS downloads;
+ALTER TABLE downloads_new RENAME TO downloads;
+
+-- Recreate indexes
+CREATE INDEX IF NOT EXISTS idx_downloads_status ON downloads(status);
+CREATE INDEX IF NOT EXISTS idx_downloads_queue
+  ON downloads(status, priority DESC, queued_at ASC)
+  WHERE status IN ('pending', 'downloading');
+"#;
Expand description

Migration to relax foreign key constraint on downloads.item_id +Allows downloading items that haven’t been synced to local database yet

+
\ No newline at end of file diff --git a/api/jellytau_lib/storage/schema/constant.MIGRATION_006.html b/api/jellytau_lib/storage/schema/constant.MIGRATION_006.html new file mode 100644 index 00000000..5bf2b5cb --- /dev/null +++ b/api/jellytau_lib/storage/schema/constant.MIGRATION_006.html @@ -0,0 +1,9 @@ +MIGRATION_006 in jellytau_lib::storage::schema - RustSkip to main content

MIGRATION_006

Constant MIGRATION_006 

Source
const MIGRATION_006: &str = r#"
+-- Add columns to store item metadata directly in downloads
+-- This ensures correct display even when items aren't synced locally
+ALTER TABLE downloads ADD COLUMN item_name TEXT;
+ALTER TABLE downloads ADD COLUMN artist_name TEXT;
+ALTER TABLE downloads ADD COLUMN album_name TEXT;
+"#;
Expand description

Migration to store item metadata directly in downloads table +This eliminates dependency on items table being synced and fixes UUID display issues

+
\ No newline at end of file diff --git a/api/jellytau_lib/storage/schema/constant.MIGRATION_007.html b/api/jellytau_lib/storage/schema/constant.MIGRATION_007.html new file mode 100644 index 00000000..d8959693 --- /dev/null +++ b/api/jellytau_lib/storage/schema/constant.MIGRATION_007.html @@ -0,0 +1,47 @@ +MIGRATION_007 in jellytau_lib::storage::schema - RustSkip to main content

MIGRATION_007

Constant MIGRATION_007 

Source
const MIGRATION_007: &str = r#"
+-- Recreate thumbnails table without foreign key constraint on item_id
+-- and add LRU eviction support columns
+CREATE TABLE IF NOT EXISTS thumbnails_new (
+    id INTEGER PRIMARY KEY AUTOINCREMENT,
+    item_id TEXT NOT NULL,  -- No foreign key constraint - item may not be synced yet
+    image_type TEXT NOT NULL,  -- Primary, Backdrop, Thumb, Logo, etc.
+    image_tag TEXT NOT NULL,
+    file_path TEXT NOT NULL,
+    width INTEGER,
+    height INTEGER,
+    file_size INTEGER DEFAULT 0,  -- Size in bytes for cache limit tracking
+    cached_at TEXT DEFAULT CURRENT_TIMESTAMP,
+    last_accessed TEXT DEFAULT CURRENT_TIMESTAMP,  -- For LRU eviction
+    UNIQUE(item_id, image_type, image_tag)
+);
+
+-- Copy existing data (if any)
+INSERT OR IGNORE INTO thumbnails_new (id, item_id, image_type, image_tag, file_path, width, height, cached_at)
+SELECT id, item_id, image_type, image_tag, file_path, width, height, cached_at FROM thumbnails;
+
+-- Drop old table and rename new one
+DROP TABLE IF EXISTS thumbnails;
+ALTER TABLE thumbnails_new RENAME TO thumbnails;
+
+-- Create indexes for efficient queries
+CREATE INDEX IF NOT EXISTS idx_thumbnails_item ON thumbnails(item_id);
+CREATE INDEX IF NOT EXISTS idx_thumbnails_lru ON thumbnails(last_accessed ASC);
+
+-- Cache settings table for configurable limits
+CREATE TABLE IF NOT EXISTS cache_settings (
+    key TEXT PRIMARY KEY,
+    value TEXT NOT NULL,
+    updated_at TEXT DEFAULT CURRENT_TIMESTAMP
+);
+
+-- Insert default settings
+INSERT OR IGNORE INTO cache_settings (key, value) VALUES ('image_cache_limit_bytes', '1073741824');  -- 1GB default
+INSERT OR IGNORE INTO cache_settings (key, value) VALUES ('image_cache_enabled', 'true');
+"#;
Expand description

Migration to enhance thumbnail caching with LRU eviction support

+
    +
  • Relaxes foreign key constraint on item_id (allows caching for items not yet synced)
  • +
  • Adds last_accessed for LRU eviction
  • +
  • Adds file_size for cache limit tracking
  • +
  • Creates cache_settings table for configurable limits
  • +
+
\ No newline at end of file diff --git a/api/jellytau_lib/storage/schema/constant.MIGRATION_008.html b/api/jellytau_lib/storage/schema/constant.MIGRATION_008.html new file mode 100644 index 00000000..bbcb74b3 --- /dev/null +++ b/api/jellytau_lib/storage/schema/constant.MIGRATION_008.html @@ -0,0 +1,28 @@ +MIGRATION_008 in jellytau_lib::storage::schema - RustSkip to main content

MIGRATION_008

Constant MIGRATION_008 

Source
const MIGRATION_008: &str = r#"
+-- Add video-specific metadata columns to downloads
+ALTER TABLE downloads ADD COLUMN series_name TEXT;
+ALTER TABLE downloads ADD COLUMN season_name TEXT;
+ALTER TABLE downloads ADD COLUMN episode_number INTEGER;
+ALTER TABLE downloads ADD COLUMN season_number INTEGER;
+ALTER TABLE downloads ADD COLUMN quality_preset TEXT DEFAULT 'original';
+ALTER TABLE downloads ADD COLUMN media_type TEXT DEFAULT 'audio';
+
+-- Add pinning support to items table
+-- Pinned items are protected from cache clear operations
+ALTER TABLE items ADD COLUMN is_pinned INTEGER DEFAULT 0;
+
+-- Index for efficiently finding pinned items
+CREATE INDEX IF NOT EXISTS idx_items_pinned ON items(is_pinned) WHERE is_pinned = 1;
+
+-- Index for efficiently querying downloads by series
+CREATE INDEX IF NOT EXISTS idx_downloads_series ON downloads(series_name) WHERE series_name IS NOT NULL;
+
+-- Index for filtering by media type
+CREATE INDEX IF NOT EXISTS idx_downloads_media_type ON downloads(media_type);
+"#;
Expand description

Migration to add video download support and item pinning

+
    +
  • Adds video-specific metadata columns to downloads (series/episode info, quality preset)
  • +
  • Adds media_type to distinguish audio vs video downloads
  • +
  • Adds is_pinned column to items table for protecting metadata from cache clear
  • +
+
\ No newline at end of file diff --git a/api/jellytau_lib/storage/schema/constant.MIGRATION_009.html b/api/jellytau_lib/storage/schema/constant.MIGRATION_009.html new file mode 100644 index 00000000..7a144a14 --- /dev/null +++ b/api/jellytau_lib/storage/schema/constant.MIGRATION_009.html @@ -0,0 +1,41 @@ +MIGRATION_009 in jellytau_lib::storage::schema - RustSkip to main content

MIGRATION_009

Constant MIGRATION_009 

Source
const MIGRATION_009: &str = r#"
+-- People table for caching cast/crew members
+CREATE TABLE IF NOT EXISTS people (
+    id TEXT PRIMARY KEY,
+    server_id TEXT NOT NULL,
+    name TEXT NOT NULL,
+    overview TEXT,
+    primary_image_tag TEXT,
+    premiere_date TEXT,      -- Birth date
+    end_date TEXT,           -- Death date
+    synced_at TEXT DEFAULT CURRENT_TIMESTAMP,
+    UNIQUE(server_id, id)
+);
+
+-- Item-Person association table (many-to-many)
+-- Stores which people appear in which items, along with role info
+CREATE TABLE IF NOT EXISTS item_people (
+    id INTEGER PRIMARY KEY AUTOINCREMENT,
+    item_id TEXT NOT NULL,
+    person_id TEXT NOT NULL,
+    server_id TEXT NOT NULL,
+    person_type TEXT NOT NULL,  -- Actor, Director, Writer, Producer, Composer, etc.
+    role TEXT,                   -- Character name for actors
+    sort_order INTEGER DEFAULT 0,
+    synced_at TEXT DEFAULT CURRENT_TIMESTAMP,
+    UNIQUE(item_id, person_id, person_type)
+);
+
+-- Indexes for efficient queries
+CREATE INDEX IF NOT EXISTS idx_people_server ON people(server_id);
+CREATE INDEX IF NOT EXISTS idx_people_name ON people(name);
+CREATE INDEX IF NOT EXISTS idx_item_people_item ON item_people(item_id);
+CREATE INDEX IF NOT EXISTS idx_item_people_person ON item_people(person_id);
+CREATE INDEX IF NOT EXISTS idx_item_people_type ON item_people(person_type);
+"#;
Expand description

Migration to add people/cast caching support

+
    +
  • Creates people table for caching actor/director/writer/etc info
  • +
  • Creates item_people junction table for many-to-many relationships
  • +
  • Adds indexes for efficient queries
  • +
+
\ No newline at end of file diff --git a/api/jellytau_lib/storage/schema/constant.MIGRATION_010.html b/api/jellytau_lib/storage/schema/constant.MIGRATION_010.html new file mode 100644 index 00000000..07395991 --- /dev/null +++ b/api/jellytau_lib/storage/schema/constant.MIGRATION_010.html @@ -0,0 +1,17 @@ +MIGRATION_010 in jellytau_lib::storage::schema - RustSkip to main content

MIGRATION_010

Constant MIGRATION_010 

Source
const MIGRATION_010: &str = r#"
+-- Add playback context tracking to user_data
+-- Tracks whether user played a container (album/playlist) or single item
+ALTER TABLE user_data ADD COLUMN playback_context_type TEXT;
+ALTER TABLE user_data ADD COLUMN playback_context_id TEXT;
+
+-- Index for efficient recently played queries
+CREATE INDEX IF NOT EXISTS idx_user_data_last_played
+  ON user_data(user_id, last_played_at DESC)
+  WHERE last_played_at IS NOT NULL;
+"#;
Expand description

Migration to add playback context tracking

+
    +
  • Adds playback_context_type column to track if user played a container or single item
  • +
  • Adds playback_context_id column to store the container ID (album/playlist)
  • +
  • Adds index for efficient recently played queries
  • +
+
\ No newline at end of file diff --git a/api/jellytau_lib/storage/schema/constant.MIGRATION_011.html b/api/jellytau_lib/storage/schema/constant.MIGRATION_011.html new file mode 100644 index 00000000..d8a561d9 --- /dev/null +++ b/api/jellytau_lib/storage/schema/constant.MIGRATION_011.html @@ -0,0 +1,29 @@ +MIGRATION_011 in jellytau_lib::storage::schema - RustSkip to main content

MIGRATION_011

Constant MIGRATION_011 

Source
const MIGRATION_011: &str = r#"
+-- User-specific player settings (autoplay and audio settings)
+-- Sleep timer is NOT persisted here (maintained in-memory only)
+CREATE TABLE IF NOT EXISTS user_player_settings (
+    user_id TEXT PRIMARY KEY REFERENCES users(id) ON DELETE CASCADE,
+
+    -- Autoplay settings
+    autoplay_next_episode INTEGER DEFAULT 1,  -- 1 = enabled, 0 = disabled
+    autoplay_countdown_seconds INTEGER DEFAULT 10,  -- 5-30 seconds
+
+    -- Audio settings (crossfade, normalization)
+    crossfade_duration REAL DEFAULT 0.0,  -- 0-12 seconds
+    gapless_playback INTEGER DEFAULT 1,
+    normalize_volume INTEGER DEFAULT 0,
+    volume_level TEXT DEFAULT 'normal',  -- 'loud', 'normal', 'quiet'
+
+    updated_at TEXT DEFAULT CURRENT_TIMESTAMP
+);
+
+-- Index for efficient user settings lookup
+CREATE INDEX IF NOT EXISTS idx_user_player_settings_user ON user_player_settings(user_id);
+"#;
Expand description

Migration to add user-specific player settings

+
    +
  • Creates user_player_settings table for autoplay and audio preferences
  • +
  • Note: Sleep timer state is NOT persisted (cancelled on app close)
  • +
  • Autoplay settings control next episode behavior
  • +
  • Audio settings for crossfade, gapless playback, and volume normalization
  • +
+
\ No newline at end of file diff --git a/api/jellytau_lib/storage/schema/constant.MIGRATION_012.html b/api/jellytau_lib/storage/schema/constant.MIGRATION_012.html new file mode 100644 index 00000000..eb59b3a2 --- /dev/null +++ b/api/jellytau_lib/storage/schema/constant.MIGRATION_012.html @@ -0,0 +1,13 @@ +MIGRATION_012 in jellytau_lib::storage::schema - RustSkip to main content

MIGRATION_012

Constant MIGRATION_012 

Source
const MIGRATION_012: &str = r#"
+-- Add download source tracking
+-- Values: 'user' (explicit download), 'auto' (smart cache/queue precache)
+ALTER TABLE downloads ADD COLUMN download_source TEXT DEFAULT 'user';
+
+-- Index for filtering by source
+CREATE INDEX IF NOT EXISTS idx_downloads_source ON downloads(download_source);
+"#;
Expand description

Migration to track download source (user-initiated vs auto-cached)

+
    +
  • Adds download_source column to distinguish manual downloads from auto-caching
  • +
  • Enables color-coded UI display
  • +
+
\ No newline at end of file diff --git a/api/jellytau_lib/storage/schema/constant.MIGRATION_013.html b/api/jellytau_lib/storage/schema/constant.MIGRATION_013.html new file mode 100644 index 00000000..f0e541cb --- /dev/null +++ b/api/jellytau_lib/storage/schema/constant.MIGRATION_013.html @@ -0,0 +1,10 @@ +MIGRATION_013 in jellytau_lib::storage::schema - RustSkip to main content

MIGRATION_013

Constant MIGRATION_013 

Source
const MIGRATION_013: &str = r#"
+-- Add composite index for offline mode filtering
+-- This speeds up queries that join items with downloads to show only downloaded content
+CREATE INDEX IF NOT EXISTS idx_downloads_item_status ON downloads(item_id, status);
+"#;
Expand description

Migration to add composite index for offline mode filtering

+
    +
  • Adds index on (item_id, status) for efficient JOIN queries in OfflineRepository
  • +
  • Significantly improves performance when filtering items by download status
  • +
+
\ No newline at end of file diff --git a/api/jellytau_lib/storage/schema/constant.MIGRATION_014.html b/api/jellytau_lib/storage/schema/constant.MIGRATION_014.html new file mode 100644 index 00000000..1bf7b465 --- /dev/null +++ b/api/jellytau_lib/storage/schema/constant.MIGRATION_014.html @@ -0,0 +1,28 @@ +MIGRATION_014 in jellytau_lib::storage::schema - RustSkip to main content

MIGRATION_014

Constant MIGRATION_014 

Source
const MIGRATION_014: &str = r#"
+-- Series-specific audio track preferences
+-- When user changes audio track for an episode, remember preference for the series
+CREATE TABLE IF NOT EXISTS series_audio_preferences (
+    user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
+    series_id TEXT NOT NULL,
+    server_id TEXT NOT NULL,
+
+    -- Audio track info for matching across episodes
+    audio_track_display_title TEXT,
+    audio_track_language TEXT,
+    audio_track_index INTEGER,
+
+    updated_at TEXT DEFAULT CURRENT_TIMESTAMP,
+
+    PRIMARY KEY (user_id, series_id, server_id)
+);
+
+-- Index for efficient lookups
+CREATE INDEX IF NOT EXISTS idx_series_audio_prefs_user_series
+  ON series_audio_preferences(user_id, series_id);
+"#;
Expand description

Migration to add series audio track preferences

+
    +
  • Stores user’s preferred audio track per series
  • +
  • Matches tracks by display title and language across episodes
  • +
  • Falls back to default track if preferred track not found
  • +
+
\ No newline at end of file diff --git a/api/jellytau_lib/storage/schema/constant.MIGRATION_015.html b/api/jellytau_lib/storage/schema/constant.MIGRATION_015.html new file mode 100644 index 00000000..3429575b --- /dev/null +++ b/api/jellytau_lib/storage/schema/constant.MIGRATION_015.html @@ -0,0 +1,18 @@ +MIGRATION_015 in jellytau_lib::storage::schema - RustSkip to main content

MIGRATION_015

Constant MIGRATION_015 

Source
const MIGRATION_015: &str = r#"
+-- App-wide settings table for device ID and other app-level configuration
+-- Device ID is a unique identifier for this app installation
+-- Required for Jellyfin server communication and session tracking
+CREATE TABLE IF NOT EXISTS app_settings (
+    key TEXT PRIMARY KEY,
+    value TEXT NOT NULL,
+    updated_at TEXT DEFAULT CURRENT_TIMESTAMP
+);
+
+-- Create index for efficient lookups (though key is already primary key)
+CREATE INDEX IF NOT EXISTS idx_app_settings_key ON app_settings(key);
+"#;
Expand description

Migration to add device ID storage

+
    +
  • Creates app_settings table for app-wide configuration (device ID, etc.)
  • +
  • Device ID is generated once and persisted for Jellyfin server identification
  • +
+
\ No newline at end of file diff --git a/api/jellytau_lib/storage/schema/constant.MIGRATION_016.html b/api/jellytau_lib/storage/schema/constant.MIGRATION_016.html new file mode 100644 index 00000000..735c7584 --- /dev/null +++ b/api/jellytau_lib/storage/schema/constant.MIGRATION_016.html @@ -0,0 +1,9 @@ +MIGRATION_016 in jellytau_lib::storage::schema - RustSkip to main content

MIGRATION_016

Constant MIGRATION_016 

Source
const MIGRATION_016: &str = r#"
+ALTER TABLE user_player_settings
+ADD COLUMN autoplay_max_episodes INTEGER DEFAULT 0;
+"#;
Expand description

Migration to add autoplay episode limit setting

+
    +
  • Adds autoplay_max_episodes column to user_player_settings
  • +
  • 0 = unlimited (default), any positive value limits consecutive auto-plays
  • +
+
\ No newline at end of file diff --git a/api/jellytau_lib/storage/schema/constant.MIGRATION_017.html b/api/jellytau_lib/storage/schema/constant.MIGRATION_017.html new file mode 100644 index 00000000..f9b73b6d --- /dev/null +++ b/api/jellytau_lib/storage/schema/constant.MIGRATION_017.html @@ -0,0 +1,11 @@ +MIGRATION_017 in jellytau_lib::storage::schema - RustSkip to main content

MIGRATION_017

Constant MIGRATION_017 

Source
const MIGRATION_017: &str = r#"
+-- Resolved download source URL and on-disk target directory, captured when the
+-- download is enqueued. Nullable: pre-existing rows and rows enqueued without a
+-- URL simply won't be auto-started by the pump.
+ALTER TABLE downloads ADD COLUMN stream_url TEXT;
+ALTER TABLE downloads ADD COLUMN target_dir TEXT;
+"#;
Expand description

Migration to persist the resolved stream URL and target directory on each +download row. This lets the backend queue pump start a pending download by +itself (replaying the stored URL) once a concurrency slot frees up, instead +of relying on the frontend to re-issue every queued item.

+
\ No newline at end of file diff --git a/api/jellytau_lib/storage/schema/constant.MIGRATION_018.html b/api/jellytau_lib/storage/schema/constant.MIGRATION_018.html new file mode 100644 index 00000000..d4a3f87c --- /dev/null +++ b/api/jellytau_lib/storage/schema/constant.MIGRATION_018.html @@ -0,0 +1,11 @@ +MIGRATION_018 in jellytau_lib::storage::schema - RustSkip to main content

MIGRATION_018

Constant MIGRATION_018 

Source
const MIGRATION_018: &str = r#"
+ALTER TABLE items ADD COLUMN is_folder INTEGER DEFAULT 0;
+
+-- Force re-fetch of all cached items so is_folder is populated from the server.
+UPDATE items SET synced_at = NULL;
+"#;
Expand description

Migration to record whether a cached item is a folder/container vs a playable +leaf. Needed so channel items (which can be either) route to the player or to +a browse list correctly. Existing cached rows predate the column and have an +unknown folder flag, so we force a refresh by clearing their synced_at, +causing the hybrid repository to re-fetch them from the server on next browse.

+
\ No newline at end of file diff --git a/api/jellytau_lib/storage/schema/constant.MIGRATION_019.html b/api/jellytau_lib/storage/schema/constant.MIGRATION_019.html new file mode 100644 index 00000000..32f9d887 --- /dev/null +++ b/api/jellytau_lib/storage/schema/constant.MIGRATION_019.html @@ -0,0 +1,19 @@ +MIGRATION_019 in jellytau_lib::storage::schema - RustSkip to main content

MIGRATION_019

Constant MIGRATION_019 

Source
const MIGRATION_019: &str = r#"
+CREATE TABLE IF NOT EXISTS genres (
+    id TEXT NOT NULL,
+    server_id TEXT NOT NULL,
+    library_id TEXT,
+    name TEXT NOT NULL,
+    album_count INTEGER,
+    synced_at TEXT DEFAULT CURRENT_TIMESTAMP,
+    PRIMARY KEY (server_id, library_id, name)
+);
+
+CREATE INDEX IF NOT EXISTS idx_genres_scope ON genres(server_id, library_id);
+"#;
Expand description

Migration to cache the full server genre catalog. Previously genres were +derived on the fly from cached albums, which meant offline (and the hybrid +cache-first race) only ever saw genres for the handful of locally-cached +albums — collapsing the variety on the music/TV/movie landing pages. This +table stores the complete genre list per library so offline has the real +catalog and cache-first routing returns the right thing.

+
\ No newline at end of file diff --git a/api/jellytau_lib/storage/schema/constant.MIGRATION_020.html b/api/jellytau_lib/storage/schema/constant.MIGRATION_020.html new file mode 100644 index 00000000..85ac8e52 --- /dev/null +++ b/api/jellytau_lib/storage/schema/constant.MIGRATION_020.html @@ -0,0 +1,10 @@ +MIGRATION_020 in jellytau_lib::storage::schema - RustSkip to main content

MIGRATION_020

Constant MIGRATION_020 

Source
const MIGRATION_020: &str = r#"
+CREATE INDEX IF NOT EXISTS idx_items_season ON items(season_id);
+"#;
Expand description

Migration to index items.season_id.

+

Episodes link to their season via season_id (parent_id is NULL in the +cache). The container-rollup queries used by the Downloaded browse and the +disk-usage aggregation join children.season_id = c.id, which without this +index degrades to an unindexable scan — a large synced catalog then makes +the Downloaded page hang (“Loading your downloads…”). parent_id, +album_id, and series_id were already indexed; this closes the gap.

+
\ No newline at end of file diff --git a/api/jellytau_lib/storage/schema/constant.MIGRATION_021.html b/api/jellytau_lib/storage/schema/constant.MIGRATION_021.html new file mode 100644 index 00000000..9c9dd9d8 --- /dev/null +++ b/api/jellytau_lib/storage/schema/constant.MIGRATION_021.html @@ -0,0 +1,21 @@ +MIGRATION_021 in jellytau_lib::storage::schema - RustSkip to main content

MIGRATION_021

Constant MIGRATION_021 

Source
const MIGRATION_021: &str = r#"
+INSERT INTO items_fts(items_fts) VALUES('rebuild');
+"#;
Expand description

Discard and rebuild the FTS index from the items table.

+

Until DR-110, save_to_cache used INSERT OR REPLACE INTO items. REPLACE +deletes the conflicting row and inserts a new one, but SQLite only fires +AFTER DELETE triggers on that implicit delete when recursive_triggers is +enabled — it is not (storage/mod.rs sets only foreign_keys and +journal_mode), so items_ad never ran and the old index row was orphaned. +Worse, items.id is a TEXT PRIMARY KEY, so the replacement row also took a +fresh rowid and items_ai appended a second entry. Every catalog pass +therefore left another duplicate behind, and existing installs carry one +stale entry per item per sync since the database was created.

+

This was invisible in results — the JOIN items_fts fts ON fts.rowid = i.rowid drops rowids that no longer exist — but it degrades MATCH +permanently, and it becomes a correctness problem the moment rowids are +freed and reused: a new item landing on a freed rowid inherits the orphan’s +index entry and matches queries for the deleted item’s title. The DR-110 +deletion sweep frees rowids, so this rebuild must run before it.

+

'rebuild' is the FTS5 command for exactly this: it truncates the index and +repopulates it from the external content table.

+

TRACES: UR-065 | DR-110

+
\ No newline at end of file diff --git a/api/jellytau_lib/storage/schema/constant.MIGRATION_022.html b/api/jellytau_lib/storage/schema/constant.MIGRATION_022.html new file mode 100644 index 00000000..184ed32f --- /dev/null +++ b/api/jellytau_lib/storage/schema/constant.MIGRATION_022.html @@ -0,0 +1,34 @@ +MIGRATION_022 in jellytau_lib::storage::schema - RustSkip to main content

MIGRATION_022

Constant MIGRATION_022 

Source
const MIGRATION_022: &str = r#"
+CREATE VIRTUAL TABLE IF NOT EXISTS people_fts USING fts5(
+    name,
+    overview,
+    content='people',
+    content_rowid='rowid'
+);
+
+CREATE TRIGGER IF NOT EXISTS people_ai AFTER INSERT ON people BEGIN
+    INSERT INTO people_fts(rowid, name, overview)
+    VALUES (new.rowid, new.name, new.overview);
+END;
+
+CREATE TRIGGER IF NOT EXISTS people_ad AFTER DELETE ON people BEGIN
+    INSERT INTO people_fts(people_fts, rowid, name, overview)
+    VALUES('delete', old.rowid, old.name, old.overview);
+END;
+
+CREATE TRIGGER IF NOT EXISTS people_au AFTER UPDATE ON people BEGIN
+    INSERT INTO people_fts(people_fts, rowid, name, overview)
+    VALUES('delete', old.rowid, old.name, old.overview);
+    INSERT INTO people_fts(rowid, name, overview)
+    VALUES (new.rowid, new.name, new.overview);
+END;
+
+-- Backfill for rows cached before this index existed.
+INSERT INTO people_fts(people_fts) VALUES('rebuild');
+"#;
Expand description

Full-text index over people, mirroring items_fts.

+

People live in their own table (migration 009) rather than in items, and +had no FTS index at all — so the People group UR-060 requires could only ever +be filled by the server leg of search. With the local index now answering +first, an actor’s name has to be findable offline too.

+

TRACES: UR-065, UR-060 | DR-111

+
\ No newline at end of file diff --git a/api/jellytau_lib/storage/schema/constant.MIGRATION_023.html b/api/jellytau_lib/storage/schema/constant.MIGRATION_023.html new file mode 100644 index 00000000..87ac2456 --- /dev/null +++ b/api/jellytau_lib/storage/schema/constant.MIGRATION_023.html @@ -0,0 +1,20 @@ +MIGRATION_023 in jellytau_lib::storage::schema - RustSkip to main content

MIGRATION_023

Constant MIGRATION_023 

Source
const MIGRATION_023: &str = r#"
+ALTER TABLE downloads ADD COLUMN expires_at TEXT;
+
+-- Reclaim scans filter on expiry among 'auto' rows; index both so the sweep
+-- stays cheap as the cache tier grows.
+CREATE INDEX IF NOT EXISTS idx_downloads_expiry
+    ON downloads(download_source, expires_at);
+"#;
Expand description

Give temporary downloads a life limit.

+

A cache entry is not a different kind of object from a download — it is a +download with a shorter life. Modelling it as one downloads row with an +expiry (rather than a parallel cache store) means there is a single storage +accounting, a single eviction path, and no way for a cache and a download +library to disagree about what is on disk.

+

expires_at is NULL for permanent rows, which is every row that exists +today: download_source defaults to 'user', and a user’s own download +never expires. Only 'auto' rows get a timestamp, and they are reclaimed by +whichever comes first — the expiry passing, or LRU eviction under space +pressure (DR-126).

+

TRACES: UR-071 | DR-127

+
\ No newline at end of file diff --git a/api/jellytau_lib/storage/schema/index.html b/api/jellytau_lib/storage/schema/index.html new file mode 100644 index 00000000..1dcf7a60 --- /dev/null +++ b/api/jellytau_lib/storage/schema/index.html @@ -0,0 +1,20 @@ +jellytau_lib::storage::schema - RustSkip to main content

Module schema

Module schema 

Source
Expand description

Database schema and migrations

+

TRACES: UR-002 | DR-012 | IR-013

+

Constants§

MIGRATIONS
List of migrations to apply in order. +Each migration is a tuple of (name, sql).
MIGRATION_001 🔒
Initial schema migration
MIGRATION_002 🔒
Migration to remove access_token column from users table +Tokens are now stored in the system keyring (or encrypted file fallback)
MIGRATION_003 🔒
Migration to relax foreign key constraints on user_data table +Allows tracking playback progress for items not yet synced to local database
MIGRATION_004 🔒
Migration to enhance downloads table with priority and bytes_downloaded
MIGRATION_005 🔒
Migration to relax foreign key constraint on downloads.item_id +Allows downloading items that haven’t been synced to local database yet
MIGRATION_006 🔒
Migration to store item metadata directly in downloads table +This eliminates dependency on items table being synced and fixes UUID display issues
MIGRATION_007 🔒
Migration to enhance thumbnail caching with LRU eviction support
MIGRATION_008 🔒
Migration to add video download support and item pinning
MIGRATION_009 🔒
Migration to add people/cast caching support
MIGRATION_010 🔒
Migration to add playback context tracking
MIGRATION_011 🔒
Migration to add user-specific player settings
MIGRATION_012 🔒
Migration to track download source (user-initiated vs auto-cached)
MIGRATION_013 🔒
Migration to add composite index for offline mode filtering
MIGRATION_014 🔒
Migration to add series audio track preferences
MIGRATION_015 🔒
Migration to add device ID storage
MIGRATION_016 🔒
Migration to add autoplay episode limit setting
MIGRATION_017 🔒
Migration to persist the resolved stream URL and target directory on each +download row. This lets the backend queue pump start a pending download by +itself (replaying the stored URL) once a concurrency slot frees up, instead +of relying on the frontend to re-issue every queued item.
MIGRATION_018 🔒
Migration to record whether a cached item is a folder/container vs a playable +leaf. Needed so channel items (which can be either) route to the player or to +a browse list correctly. Existing cached rows predate the column and have an +unknown folder flag, so we force a refresh by clearing their synced_at, +causing the hybrid repository to re-fetch them from the server on next browse.
MIGRATION_019 🔒
Migration to cache the full server genre catalog. Previously genres were +derived on the fly from cached albums, which meant offline (and the hybrid +cache-first race) only ever saw genres for the handful of locally-cached +albums — collapsing the variety on the music/TV/movie landing pages. This +table stores the complete genre list per library so offline has the real +catalog and cache-first routing returns the right thing.
MIGRATION_020 🔒
Migration to index items.season_id.
MIGRATION_021 🔒
Discard and rebuild the FTS index from the items table.
MIGRATION_022 🔒
Full-text index over people, mirroring items_fts.
MIGRATION_023 🔒
Give temporary downloads a life limit.
\ No newline at end of file diff --git a/api/jellytau_lib/storage/schema/sidebar-items.js b/api/jellytau_lib/storage/schema/sidebar-items.js new file mode 100644 index 00000000..9699fe1b --- /dev/null +++ b/api/jellytau_lib/storage/schema/sidebar-items.js @@ -0,0 +1 @@ +window.SIDEBAR_ITEMS = {"constant":["MIGRATIONS","MIGRATION_001","MIGRATION_002","MIGRATION_003","MIGRATION_004","MIGRATION_005","MIGRATION_006","MIGRATION_007","MIGRATION_008","MIGRATION_009","MIGRATION_010","MIGRATION_011","MIGRATION_012","MIGRATION_013","MIGRATION_014","MIGRATION_015","MIGRATION_016","MIGRATION_017","MIGRATION_018","MIGRATION_019","MIGRATION_020","MIGRATION_021","MIGRATION_022","MIGRATION_023"]}; \ No newline at end of file diff --git a/api/jellytau_lib/storage/sidebar-items.js b/api/jellytau_lib/storage/sidebar-items.js new file mode 100644 index 00000000..ae486385 --- /dev/null +++ b/api/jellytau_lib/storage/sidebar-items.js @@ -0,0 +1 @@ +window.SIDEBAR_ITEMS = {"mod":["db_service","models","schema"],"struct":["Database"]}; \ No newline at end of file diff --git a/api/jellytau_lib/storage/struct.Database.html b/api/jellytau_lib/storage/struct.Database.html new file mode 100644 index 00000000..bb5c1500 --- /dev/null +++ b/api/jellytau_lib/storage/struct.Database.html @@ -0,0 +1,35 @@ +Database in jellytau_lib::storage - RustSkip to main content

Database

Struct Database 

Source
pub struct Database {
+    conn: Arc<Mutex<Connection>>,
+    path: PathBuf,
+}
Expand description

Database connection wrapper with thread-safe access

+

Fields§

§conn: Arc<Mutex<Connection>>§path: PathBuf

Implementations§

Source§

impl Database

Source

pub fn open(path: &PathBuf) -> SqliteResult<Self>

Open or create the database at a specific path

+
Source

pub fn migrate(&self) -> SqliteResult<()>

Run all pending migrations

+
Source

pub fn service(&self) -> RusqliteService

Get a database service for async-safe operations

+

This wraps all blocking database operations in spawn_blocking to prevent +freezing the async runtime.

+
Source

pub fn path(&self) -> &PathBuf

Get the database file path

+
Source

pub fn file_size(&self) -> Option<u64>

Get database file size in bytes

+

Auto Trait Implementations§

Blanket Implementations§

Source§

impl<T> Any for T
where + T: 'static + ?Sized,

Source§

fn type_id(&self) -> TypeId

Gets the TypeId of self. Read more
Source§

impl<T> Borrow<T> for T
where + T: ?Sized,

Source§

fn borrow(&self) -> &T

Immutably borrows from an owned value. Read more
Source§

impl<T> BorrowMut<T> for T
where + T: ?Sized,

Source§

fn borrow_mut(&mut self) -> &mut T

Mutably borrows from an owned value. Read more
Source§

impl<T> From<T> for T

Source§

fn from(t: T) -> T

Returns the argument unchanged.

+
§

impl<T> Instrument for T

§

fn instrument(self, span: Span) -> Instrumented<Self>

Instruments this type with the provided [Span], returning an +Instrumented wrapper. Read more
§

fn in_current_span(self) -> Instrumented<Self>

Instruments this type with the current Span, returning an +Instrumented wrapper. Read more
Source§

impl<T, U> Into<U> for T
where + U: From<T>,

Source§

fn into(self) -> U

Calls U::from(self).

+

That is, this conversion is whatever the implementation of +From<T> for U chooses to do.

+
§

impl<T> PolicyExt for T
where + T: ?Sized,

§

fn and<P, B, E>(self, other: P) -> And<T, P>
where + T: Policy<B, E>, + P: Policy<B, E>,

Create a new Policy that returns [Action::Follow] only if self and other return +Action::Follow. Read more
§

fn or<P, B, E>(self, other: P) -> Or<T, P>
where + T: Policy<B, E>, + P: Policy<B, E>,

Create a new Policy that returns [Action::Follow] if either self or other returns +Action::Follow. Read more
Source§

impl<T> Same for T

Source§

type Output = T

Should always be Self
Source§

impl<T, U> TryFrom<U> for T
where + U: Into<T>,

Source§

type Error = Infallible

The type returned in the event of a conversion error.
Source§

fn try_from(value: U) -> Result<T, <T as TryFrom<U>>::Error>

Performs the conversion.
Source§

impl<T, U> TryInto<U> for T
where + U: TryFrom<T>,

Source§

type Error = <U as TryFrom<T>>::Error

The type returned in the event of a conversion error.
Source§

fn try_into(self) -> Result<U, <U as TryFrom<T>>::Error>

Performs the conversion.
§

impl<V, T> VZip<V> for T
where + V: MultiLane<T>,

§

fn vzip(self) -> V

§

impl<T> WithSubscriber for T

§

fn with_subscriber<S>(self, subscriber: S) -> WithDispatch<Self>
where + S: Into<Dispatch>,

Attaches the provided Subscriber to this type, returning a +[WithDispatch] wrapper. Read more
§

fn with_current_subscriber(self) -> WithDispatch<Self>

Attaches the current default Subscriber to this type, returning a +[WithDispatch] wrapper. Read more
\ No newline at end of file diff --git a/api/jellytau_lib/struct.BackendInitError.html b/api/jellytau_lib/struct.BackendInitError.html new file mode 100644 index 00000000..29024d06 --- /dev/null +++ b/api/jellytau_lib/struct.BackendInitError.html @@ -0,0 +1,38 @@ +BackendInitError in jellytau_lib - RustSkip to main content

BackendInitError

Struct BackendInitError 

Source
pub(crate) struct BackendInitError {
+    pub(crate) platform: &'static str,
+    pub(crate) backend: &'static str,
+    pub(crate) message: String,
+}
Expand description

Payload emitted to the frontend when a native player backend fails to +initialize and the app falls back to a no-op backend.

+

Fields§

§platform: &'static str§backend: &'static str§message: String

Trait Implementations§

Source§

impl Clone for BackendInitError

Source§

fn clone(&self) -> BackendInitError

Returns a duplicate of the value. Read more
1.0.0 (const: unstable) · Source§

fn clone_from(&mut self, source: &Self)

Performs copy-assignment from source. Read more
Source§

impl Serialize for BackendInitError

Source§

fn serialize<__S>(&self, __serializer: __S) -> Result<__S::Ok, __S::Error>
where + __S: Serializer,

Serialize this value into the given Serde serializer. Read more

Auto Trait Implementations§

Blanket Implementations§

Source§

impl<T> Any for T
where + T: 'static + ?Sized,

Source§

fn type_id(&self) -> TypeId

Gets the TypeId of self. Read more
Source§

impl<T> Borrow<T> for T
where + T: ?Sized,

Source§

fn borrow(&self) -> &T

Immutably borrows from an owned value. Read more
Source§

impl<T> BorrowMut<T> for T
where + T: ?Sized,

Source§

fn borrow_mut(&mut self) -> &mut T

Mutably borrows from an owned value. Read more
Source§

impl<T> CloneToUninit for T
where + T: Clone,

Source§

unsafe fn clone_to_uninit(&self, dest: *mut u8)

🔬This is a nightly-only experimental API. (clone_to_uninit)
Performs copy-assignment from self to dest. Read more
Source§

impl<T> From<T> for T

Source§

fn from(t: T) -> T

Returns the argument unchanged.

+
§

impl<T> Instrument for T

§

fn instrument(self, span: Span) -> Instrumented<Self>

Instruments this type with the provided [Span], returning an +Instrumented wrapper. Read more
§

fn in_current_span(self) -> Instrumented<Self>

Instruments this type with the current Span, returning an +Instrumented wrapper. Read more
Source§

impl<T, U> Into<U> for T
where + U: From<T>,

Source§

fn into(self) -> U

Calls U::from(self).

+

That is, this conversion is whatever the implementation of +From<T> for U chooses to do.

+
§

impl<T> IpcResponse for T
where + T: Serialize,

§

fn body(self) -> Result<InvokeResponseBody, Error>

Resolve the IPC response body.
§

impl<T> PolicyExt for T
where + T: ?Sized,

§

fn and<P, B, E>(self, other: P) -> And<T, P>
where + T: Policy<B, E>, + P: Policy<B, E>,

Create a new Policy that returns [Action::Follow] only if self and other return +Action::Follow. Read more
§

fn or<P, B, E>(self, other: P) -> Or<T, P>
where + T: Policy<B, E>, + P: Policy<B, E>,

Create a new Policy that returns [Action::Follow] if either self or other returns +Action::Follow. Read more
Source§

impl<T> Same for T

Source§

type Output = T

Should always be Self
Source§

impl<T> Serialize for T
where + T: Serialize + ?Sized,

Source§

fn erased_serialize(&self, serializer: &mut dyn Serializer) -> Result<(), Error>

Source§

fn do_erased_serialize( + &self, + serializer: &mut dyn Serializer, +) -> Result<(), ErrorImpl>

Source§

impl<T> ToOwned for T
where + T: Clone,

Source§

type Owned = T

The resulting type after obtaining ownership.
Source§

fn to_owned(&self) -> T

Creates owned data from borrowed data, usually by cloning. Read more
Source§

fn clone_into(&self, target: &mut T)

Uses borrowed data to replace owned data, usually by cloning. Read more
Source§

impl<T, U> TryFrom<U> for T
where + U: Into<T>,

Source§

type Error = Infallible

The type returned in the event of a conversion error.
Source§

fn try_from(value: U) -> Result<T, <T as TryFrom<U>>::Error>

Performs the conversion.
Source§

impl<T, U> TryInto<U> for T
where + U: TryFrom<T>,

Source§

type Error = <U as TryFrom<T>>::Error

The type returned in the event of a conversion error.
Source§

fn try_into(self) -> Result<U, <U as TryFrom<T>>::Error>

Performs the conversion.
§

impl<V, T> VZip<V> for T
where + V: MultiLane<T>,

§

fn vzip(self) -> V

§

impl<T> WithSubscriber for T

§

fn with_subscriber<S>(self, subscriber: S) -> WithDispatch<Self>
where + S: Into<Dispatch>,

Attaches the provided Subscriber to this type, returning a +[WithDispatch] wrapper. Read more
§

fn with_current_subscriber(self) -> WithDispatch<Self>

Attaches the current default Subscriber to this type, returning a +[WithDispatch] wrapper. Read more
\ No newline at end of file diff --git a/api/jellytau_lib/thumbnail/cache/fn.safe_component.html b/api/jellytau_lib/thumbnail/cache/fn.safe_component.html new file mode 100644 index 00000000..7000a7c0 --- /dev/null +++ b/api/jellytau_lib/thumbnail/cache/fn.safe_component.html @@ -0,0 +1,8 @@ +safe_component in jellytau_lib::thumbnail::cache - RustSkip to main content

safe_component

Function safe_component 

Source
fn safe_component(value: &str) -> String
Expand description

Make one part of a cache filename safe to put in a path.

+

Every part of the name comes from the caller — the item id and image type are +taken verbatim from Jellyfin JSON — so none of them may contribute a path +separator or a ... The rule is the one the image tag has always used +(non-alphanumerics become _), applied to all three parts, so values that +were already safe keep producing exactly the filename they did before.

+

TRACES: | DR-210 | UT-204

+
\ No newline at end of file diff --git a/api/jellytau_lib/thumbnail/cache/index.html b/api/jellytau_lib/thumbnail/cache/index.html new file mode 100644 index 00000000..e65711f1 --- /dev/null +++ b/api/jellytau_lib/thumbnail/cache/index.html @@ -0,0 +1,2 @@ +jellytau_lib::thumbnail::cache - RustSkip to main content

Module cache

Module cache 

Source
Expand description

Thumbnail cache manager with LRU eviction

+

Structs§

CacheConfig
Configuration for the thumbnail cache
ThumbnailCache
Thumbnail cache with LRU eviction

Functions§

safe_component 🔒
Make one part of a cache filename safe to put in a path.
\ No newline at end of file diff --git a/api/jellytau_lib/thumbnail/cache/sidebar-items.js b/api/jellytau_lib/thumbnail/cache/sidebar-items.js new file mode 100644 index 00000000..31c99c47 --- /dev/null +++ b/api/jellytau_lib/thumbnail/cache/sidebar-items.js @@ -0,0 +1 @@ +window.SIDEBAR_ITEMS = {"fn":["safe_component"],"struct":["CacheConfig","ThumbnailCache"]}; \ No newline at end of file diff --git a/api/jellytau_lib/thumbnail/cache/struct.CacheConfig.html b/api/jellytau_lib/thumbnail/cache/struct.CacheConfig.html new file mode 100644 index 00000000..49579071 --- /dev/null +++ b/api/jellytau_lib/thumbnail/cache/struct.CacheConfig.html @@ -0,0 +1,36 @@ +CacheConfig in jellytau_lib::thumbnail::cache - RustSkip to main content

CacheConfig

Struct CacheConfig 

Source
pub struct CacheConfig {
+    pub max_size_bytes: u64,
+    pub cache_subdir: String,
+    pub enabled: bool,
+}
Expand description

Configuration for the thumbnail cache

+

Fields§

§max_size_bytes: u64

Maximum cache size in bytes (0 = unlimited)

+
§cache_subdir: String

Subdirectory name for cached thumbnails

+
§enabled: bool

Whether caching is enabled

+

Trait Implementations§

Source§

impl Clone for CacheConfig

Source§

fn clone(&self) -> CacheConfig

Returns a duplicate of the value. Read more
1.0.0 (const: unstable) · Source§

fn clone_from(&mut self, source: &Self)

Performs copy-assignment from source. Read more
Source§

impl Debug for CacheConfig

Source§

fn fmt(&self, f: &mut Formatter<'_>) -> Result

Formats the value using the given formatter. Read more
Source§

impl Default for CacheConfig

Source§

fn default() -> Self

Returns the “default value” for a type. Read more

Auto Trait Implementations§

Blanket Implementations§

Source§

impl<T> Any for T
where + T: 'static + ?Sized,

Source§

fn type_id(&self) -> TypeId

Gets the TypeId of self. Read more
Source§

impl<T> Borrow<T> for T
where + T: ?Sized,

Source§

fn borrow(&self) -> &T

Immutably borrows from an owned value. Read more
Source§

impl<T> BorrowMut<T> for T
where + T: ?Sized,

Source§

fn borrow_mut(&mut self) -> &mut T

Mutably borrows from an owned value. Read more
Source§

impl<T> CloneToUninit for T
where + T: Clone,

Source§

unsafe fn clone_to_uninit(&self, dest: *mut u8)

🔬This is a nightly-only experimental API. (clone_to_uninit)
Performs copy-assignment from self to dest. Read more
Source§

impl<T> From<T> for T

Source§

fn from(t: T) -> T

Returns the argument unchanged.

+
§

impl<T> Instrument for T

§

fn instrument(self, span: Span) -> Instrumented<Self>

Instruments this type with the provided [Span], returning an +Instrumented wrapper. Read more
§

fn in_current_span(self) -> Instrumented<Self>

Instruments this type with the current Span, returning an +Instrumented wrapper. Read more
Source§

impl<T, U> Into<U> for T
where + U: From<T>,

Source§

fn into(self) -> U

Calls U::from(self).

+

That is, this conversion is whatever the implementation of +From<T> for U chooses to do.

+
§

impl<T> NoneValue for T
where + T: Default,

§

type NoneType = T

§

fn null_value() -> T

The none-equivalent value.
§

impl<T> PolicyExt for T
where + T: ?Sized,

§

fn and<P, B, E>(self, other: P) -> And<T, P>
where + T: Policy<B, E>, + P: Policy<B, E>,

Create a new Policy that returns [Action::Follow] only if self and other return +Action::Follow. Read more
§

fn or<P, B, E>(self, other: P) -> Or<T, P>
where + T: Policy<B, E>, + P: Policy<B, E>,

Create a new Policy that returns [Action::Follow] if either self or other returns +Action::Follow. Read more
Source§

impl<T> Same for T

Source§

type Output = T

Should always be Self
Source§

impl<T> ToOwned for T
where + T: Clone,

Source§

type Owned = T

The resulting type after obtaining ownership.
Source§

fn to_owned(&self) -> T

Creates owned data from borrowed data, usually by cloning. Read more
Source§

fn clone_into(&self, target: &mut T)

Uses borrowed data to replace owned data, usually by cloning. Read more
Source§

impl<T, U> TryFrom<U> for T
where + U: Into<T>,

Source§

type Error = Infallible

The type returned in the event of a conversion error.
Source§

fn try_from(value: U) -> Result<T, <T as TryFrom<U>>::Error>

Performs the conversion.
Source§

impl<T, U> TryInto<U> for T
where + U: TryFrom<T>,

Source§

type Error = <U as TryFrom<T>>::Error

The type returned in the event of a conversion error.
Source§

fn try_into(self) -> Result<U, <U as TryFrom<T>>::Error>

Performs the conversion.
§

impl<V, T> VZip<V> for T
where + V: MultiLane<T>,

§

fn vzip(self) -> V

§

impl<T> WithSubscriber for T

§

fn with_subscriber<S>(self, subscriber: S) -> WithDispatch<Self>
where + S: Into<Dispatch>,

Attaches the provided Subscriber to this type, returning a +[WithDispatch] wrapper. Read more
§

fn with_current_subscriber(self) -> WithDispatch<Self>

Attaches the current default Subscriber to this type, returning a +[WithDispatch] wrapper. Read more
§

impl<T> UserEvent for T
where + T: Debug + Clone + Send + 'static,

\ No newline at end of file diff --git a/api/jellytau_lib/thumbnail/cache/struct.ThumbnailCache.html b/api/jellytau_lib/thumbnail/cache/struct.ThumbnailCache.html new file mode 100644 index 00000000..7aa48296 --- /dev/null +++ b/api/jellytau_lib/thumbnail/cache/struct.ThumbnailCache.html @@ -0,0 +1,88 @@ +ThumbnailCache in jellytau_lib::thumbnail::cache - RustSkip to main content

ThumbnailCache

Struct ThumbnailCache 

Source
pub struct ThumbnailCache {
+    config: Arc<Mutex<CacheConfig>>,
+    cache_dir: PathBuf,
+}
Expand description

Thumbnail cache with LRU eviction

+

Fields§

§config: Arc<Mutex<CacheConfig>>§cache_dir: PathBuf

Implementations§

Source§

impl ThumbnailCache

Source

pub fn new(app_data_dir: PathBuf, config: CacheConfig) -> Self

Create a new thumbnail cache

+
Source

fn resolve_in_cache_dir(&self, filename: &str) -> Result<PathBuf, String>

Resolve a cache filename against the cache directory, refusing anything +that lands outside it.

+

.. is folded away lexically rather than through canonicalize, so a +file that does not exist yet still resolves — the same approach as +media_server::resolve_path. safe_component should already have made an +escape impossible; this is the check at the point of use.

+

TRACES: | DR-210 | UT-204

+
Source

pub fn is_enabled(&self) -> bool

Check if caching is enabled

+
Source

pub async fn get_cached_path( + &self, + db: Arc<RusqliteService>, + item_id: &str, + image_type: &str, + tag: &str, +) -> Option<PathBuf>

Get cached thumbnail path, or None if not cached +Updates last_accessed timestamp for LRU tracking

+
Source

async fn touch( + &self, + db: &Arc<RusqliteService>, + item_id: &str, + image_type: &str, + tag: Option<&str>, +)

Update last_accessed for LRU tracking. When tag is Some, scope to +that exact row; when None, touch every row for the item + type.

+
Source

pub async fn save_thumbnail( + &self, + db: Arc<RusqliteService>, + item_id: &str, + image_type: &str, + tag: &str, + data: &[u8], + width: Option<i32>, + height: Option<i32>, +) -> Result<PathBuf, String>

Save thumbnail to cache

+

TRACES: | DR-210 | UT-204

+
Source

async fn ensure_space( + &self, + db: Arc<RusqliteService>, + needed_bytes: u64, +) -> Result<(), String>

Ensure there’s enough space by evicting LRU items if needed

+
Source

async fn evict_lru( + &self, + db: Arc<RusqliteService>, + to_free: u64, +) -> Result<(), String>

Evict least recently used items to free up space

+
Source

pub async fn get_cache_size(&self, db: Arc<RusqliteService>) -> u64

Get current total cache size in bytes

+
Source

pub async fn get_item_count(&self, db: Arc<RusqliteService>) -> i64

Get count of cached items

+
Source

pub async fn get_limit(&self, db: Arc<RusqliteService>) -> u64

Get the current cache limit in bytes

+
Source

pub async fn set_limit( + &self, + db: Arc<RusqliteService>, + limit_bytes: u64, +) -> Result<(), String>

Set the cache limit in bytes

+
Source

pub async fn clear_cache(&self, db: Arc<RusqliteService>) -> Result<(), String>

Clear all cached thumbnails

+
Source

pub async fn delete_item( + &self, + db: Arc<RusqliteService>, + item_id: &str, +) -> Result<(), String>

Delete cached thumbnail for a specific item

+

Auto Trait Implementations§

Blanket Implementations§

Source§

impl<T> Any for T
where + T: 'static + ?Sized,

Source§

fn type_id(&self) -> TypeId

Gets the TypeId of self. Read more
Source§

impl<T> Borrow<T> for T
where + T: ?Sized,

Source§

fn borrow(&self) -> &T

Immutably borrows from an owned value. Read more
Source§

impl<T> BorrowMut<T> for T
where + T: ?Sized,

Source§

fn borrow_mut(&mut self) -> &mut T

Mutably borrows from an owned value. Read more
Source§

impl<T> From<T> for T

Source§

fn from(t: T) -> T

Returns the argument unchanged.

+
§

impl<T> Instrument for T

§

fn instrument(self, span: Span) -> Instrumented<Self>

Instruments this type with the provided [Span], returning an +Instrumented wrapper. Read more
§

fn in_current_span(self) -> Instrumented<Self>

Instruments this type with the current Span, returning an +Instrumented wrapper. Read more
Source§

impl<T, U> Into<U> for T
where + U: From<T>,

Source§

fn into(self) -> U

Calls U::from(self).

+

That is, this conversion is whatever the implementation of +From<T> for U chooses to do.

+
§

impl<T> PolicyExt for T
where + T: ?Sized,

§

fn and<P, B, E>(self, other: P) -> And<T, P>
where + T: Policy<B, E>, + P: Policy<B, E>,

Create a new Policy that returns [Action::Follow] only if self and other return +Action::Follow. Read more
§

fn or<P, B, E>(self, other: P) -> Or<T, P>
where + T: Policy<B, E>, + P: Policy<B, E>,

Create a new Policy that returns [Action::Follow] if either self or other returns +Action::Follow. Read more
Source§

impl<T> Same for T

Source§

type Output = T

Should always be Self
Source§

impl<T, U> TryFrom<U> for T
where + U: Into<T>,

Source§

type Error = Infallible

The type returned in the event of a conversion error.
Source§

fn try_from(value: U) -> Result<T, <T as TryFrom<U>>::Error>

Performs the conversion.
Source§

impl<T, U> TryInto<U> for T
where + U: TryFrom<T>,

Source§

type Error = <U as TryFrom<T>>::Error

The type returned in the event of a conversion error.
Source§

fn try_into(self) -> Result<U, <U as TryFrom<T>>::Error>

Performs the conversion.
§

impl<V, T> VZip<V> for T
where + V: MultiLane<T>,

§

fn vzip(self) -> V

§

impl<T> WithSubscriber for T

§

fn with_subscriber<S>(self, subscriber: S) -> WithDispatch<Self>
where + S: Into<Dispatch>,

Attaches the provided Subscriber to this type, returning a +[WithDispatch] wrapper. Read more
§

fn with_current_subscriber(self) -> WithDispatch<Self>

Attaches the current default Subscriber to this type, returning a +[WithDispatch] wrapper. Read more
\ No newline at end of file diff --git a/api/jellytau_lib/thumbnail/index.html b/api/jellytau_lib/thumbnail/index.html new file mode 100644 index 00000000..9e6e24f6 --- /dev/null +++ b/api/jellytau_lib/thumbnail/index.html @@ -0,0 +1,9 @@ +jellytau_lib::thumbnail - RustSkip to main content

Module thumbnail

Module thumbnail 

Source
Expand description

Thumbnail caching with LRU eviction

+

This module handles caching image thumbnails from Jellyfin servers with:

+
    +
  • Lazy caching (cache as viewed)
  • +
  • LRU (Least Recently Used) eviction when storage limit reached
  • +
  • Configurable storage limits
  • +
  • SQLite-backed cache metadata
  • +
+

Re-exports§

pub use cache::CacheConfig;
pub use cache::ThumbnailCache;
pub use worker::ThumbnailWorker;

Modules§

cache
Thumbnail cache manager with LRU eviction
worker
Thumbnail download worker

Structs§

ThumbnailCacheStats
Statistics about the thumbnail cache
\ No newline at end of file diff --git a/api/jellytau_lib/thumbnail/sidebar-items.js b/api/jellytau_lib/thumbnail/sidebar-items.js new file mode 100644 index 00000000..b4445765 --- /dev/null +++ b/api/jellytau_lib/thumbnail/sidebar-items.js @@ -0,0 +1 @@ +window.SIDEBAR_ITEMS = {"mod":["cache","worker"],"struct":["ThumbnailCacheStats"]}; \ No newline at end of file diff --git a/api/jellytau_lib/thumbnail/struct.ThumbnailCacheStats.html b/api/jellytau_lib/thumbnail/struct.ThumbnailCacheStats.html new file mode 100644 index 00000000..c772bcf5 --- /dev/null +++ b/api/jellytau_lib/thumbnail/struct.ThumbnailCacheStats.html @@ -0,0 +1,54 @@ +ThumbnailCacheStats in jellytau_lib::thumbnail - RustSkip to main content

ThumbnailCacheStats

Struct ThumbnailCacheStats 

Source
pub struct ThumbnailCacheStats {
+    pub total_size_bytes: u64,
+    pub item_count: i64,
+    pub limit_bytes: u64,
+}
Expand description

Statistics about the thumbnail cache

+

Fields§

§total_size_bytes: u64§item_count: i64§limit_bytes: u64

Trait Implementations§

Source§

impl Clone for ThumbnailCacheStats

Source§

fn clone(&self) -> ThumbnailCacheStats

Returns a duplicate of the value. Read more
1.0.0 (const: unstable) · Source§

fn clone_from(&mut self, source: &Self)

Performs copy-assignment from source. Read more
Source§

impl Debug for ThumbnailCacheStats

Source§

fn fmt(&self, f: &mut Formatter<'_>) -> Result

Formats the value using the given formatter. Read more
Source§

impl<'de> Deserialize<'de> for ThumbnailCacheStats

Source§

fn deserialize<__D>(__deserializer: __D) -> Result<Self, __D::Error>
where + __D: Deserializer<'de>,

Deserialize this value from the given Serde deserializer. Read more
Source§

impl NamedType for ThumbnailCacheStats

Source§

fn sid() -> SpectaID

Source§

fn named_data_type( + type_map: &mut TypeCollection, + generics: &[DataType], +) -> NamedDataType

this is equivalent to [Type::inline] but returns a [NamedDataType] instead.
Source§

fn definition_named_data_type(type_map: &mut TypeCollection) -> NamedDataType

this is equivalent to [Type::definition] but returns a [NamedDataType] instead.
Source§

impl Serialize for ThumbnailCacheStats

Source§

fn serialize<__S>(&self, __serializer: __S) -> Result<__S::Ok, __S::Error>
where + __S: Serializer,

Serialize this value into the given Serde serializer. Read more
Source§

impl Type for ThumbnailCacheStats

Source§

fn inline(type_map: &mut TypeCollection, generics: Generics<'_>) -> DataType

Returns the definition of a type using the provided generics. Read more
Source§

fn reference(type_map: &mut TypeCollection, generics: &[DataType]) -> Reference

Generates a datatype corresponding to a reference to this type, +as determined by its category. Getting a reference to a type implies that +it should belong in the type map (since it has to be referenced from somewhere), +so the output of definition will be put into the type map.
Source§

impl Flatten for ThumbnailCacheStats

Auto Trait Implementations§

Blanket Implementations§

Source§

impl<T> Any for T
where + T: 'static + ?Sized,

Source§

fn type_id(&self) -> TypeId

Gets the TypeId of self. Read more
Source§

impl<T> Borrow<T> for T
where + T: ?Sized,

Source§

fn borrow(&self) -> &T

Immutably borrows from an owned value. Read more
Source§

impl<T> BorrowMut<T> for T
where + T: ?Sized,

Source§

fn borrow_mut(&mut self) -> &mut T

Mutably borrows from an owned value. Read more
Source§

impl<T> CloneToUninit for T
where + T: Clone,

Source§

unsafe fn clone_to_uninit(&self, dest: *mut u8)

🔬This is a nightly-only experimental API. (clone_to_uninit)
Performs copy-assignment from self to dest. Read more
§

impl<'de, D, R> CommandArg<'de, R> for D
where + D: Deserialize<'de>, + R: Runtime,

§

fn from_command(command: CommandItem<'de, R>) -> Result<D, InvokeError>

Derives an instance of Self from the [CommandItem]. Read more
Source§

impl<T> From<T> for T

Source§

fn from(t: T) -> T

Returns the argument unchanged.

+
§

impl<T> FunctionArg for T
where + T: Type,

§

fn to_datatype(type_map: &mut TypeCollection) -> Option<DataType>

Gets the type of an argument as a [DataType]. Read more
§

impl<T> Instrument for T

§

fn instrument(self, span: Span) -> Instrumented<Self>

Instruments this type with the provided [Span], returning an +Instrumented wrapper. Read more
§

fn in_current_span(self) -> Instrumented<Self>

Instruments this type with the current Span, returning an +Instrumented wrapper. Read more
Source§

impl<T, U> Into<U> for T
where + U: From<T>,

Source§

fn into(self) -> U

Calls U::from(self).

+

That is, this conversion is whatever the implementation of +From<T> for U chooses to do.

+
§

impl<T> IpcResponse for T
where + T: Serialize,

§

fn body(self) -> Result<InvokeResponseBody, Error>

Resolve the IPC response body.
§

impl<T> PolicyExt for T
where + T: ?Sized,

§

fn and<P, B, E>(self, other: P) -> And<T, P>
where + T: Policy<B, E>, + P: Policy<B, E>,

Create a new Policy that returns [Action::Follow] only if self and other return +Action::Follow. Read more
§

fn or<P, B, E>(self, other: P) -> Or<T, P>
where + T: Policy<B, E>, + P: Policy<B, E>,

Create a new Policy that returns [Action::Follow] if either self or other returns +Action::Follow. Read more
Source§

impl<T> Same for T

Source§

type Output = T

Should always be Self
§

impl<T> ScopeObject for T
where + T: Send + Sync + Debug + DeserializeOwned + 'static,

§

type Error = Error

The error type.
§

fn deserialize<R>( + _app: &AppHandle<R>, + raw: Value, +) -> Result<T, <T as ScopeObject>::Error>
where + R: Runtime,

Deserialize the raw scope value.
Source§

impl<T> Serialize for T
where + T: Serialize + ?Sized,

Source§

fn erased_serialize(&self, serializer: &mut dyn Serializer) -> Result<(), Error>

Source§

fn do_erased_serialize( + &self, + serializer: &mut dyn Serializer, +) -> Result<(), ErrorImpl>

Source§

impl<T> ToOwned for T
where + T: Clone,

Source§

type Owned = T

The resulting type after obtaining ownership.
Source§

fn to_owned(&self) -> T

Creates owned data from borrowed data, usually by cloning. Read more
Source§

fn clone_into(&self, target: &mut T)

Uses borrowed data to replace owned data, usually by cloning. Read more
Source§

impl<T, U> TryFrom<U> for T
where + U: Into<T>,

Source§

type Error = Infallible

The type returned in the event of a conversion error.
Source§

fn try_from(value: U) -> Result<T, <T as TryFrom<U>>::Error>

Performs the conversion.
Source§

impl<T, U> TryInto<U> for T
where + U: TryFrom<T>,

Source§

type Error = <U as TryFrom<T>>::Error

The type returned in the event of a conversion error.
Source§

fn try_into(self) -> Result<U, <U as TryFrom<T>>::Error>

Performs the conversion.
§

impl<V, T> VZip<V> for T
where + V: MultiLane<T>,

§

fn vzip(self) -> V

§

impl<T> WithSubscriber for T

§

fn with_subscriber<S>(self, subscriber: S) -> WithDispatch<Self>
where + S: Into<Dispatch>,

Attaches the provided Subscriber to this type, returning a +[WithDispatch] wrapper. Read more
§

fn with_current_subscriber(self) -> WithDispatch<Self>

Attaches the current default Subscriber to this type, returning a +[WithDispatch] wrapper. Read more
Source§

impl<T> DeserializeOwned for T
where + T: for<'de> Deserialize<'de>,

§

impl<T> UserEvent for T
where + T: Debug + Clone + Send + 'static,

\ No newline at end of file diff --git a/api/jellytau_lib/thumbnail/worker/enum.ThumbnailDownloadError.html b/api/jellytau_lib/thumbnail/worker/enum.ThumbnailDownloadError.html new file mode 100644 index 00000000..2aacdee3 --- /dev/null +++ b/api/jellytau_lib/thumbnail/worker/enum.ThumbnailDownloadError.html @@ -0,0 +1,34 @@ +ThumbnailDownloadError in jellytau_lib::thumbnail::worker - RustSkip to main content

ThumbnailDownloadError

Enum ThumbnailDownloadError 

Source
pub enum ThumbnailDownloadError {
+    Network(String),
+    Http(u16),
+}
Expand description

Errors that can occur during thumbnail download

+

Variants§

§

Network(String)

§

Http(u16)

Implementations§

Source§

impl ThumbnailDownloadError

Source

pub fn is_retryable(&self) -> bool

Check if this error is retryable

+

Trait Implementations§

Source§

impl Debug for ThumbnailDownloadError

Source§

fn fmt(&self, f: &mut Formatter<'_>) -> Result

Formats the value using the given formatter. Read more
Source§

impl Display for ThumbnailDownloadError

Source§

fn fmt(&self, f: &mut Formatter<'_>) -> Result

Formats the value using the given formatter. Read more
Source§

impl Error for ThumbnailDownloadError

1.30.0 · Source§

fn source(&self) -> Option<&(dyn Error + 'static)>

Returns the lower-level source of this error, if any. Read more
1.0.0 · Source§

fn description(&self) -> &str

👎Deprecated since 1.42.0:

use the Display impl or to_string()

+
1.0.0 · Source§

fn cause(&self) -> Option<&dyn Error>

👎Deprecated since 1.33.0:

replaced by Error::source, which can support downcasting

+
Source§

fn provide<'a>(&'a self, request: &mut Request<'a>)

🔬This is a nightly-only experimental API. (error_generic_member_access)
Provides type-based access to context intended for error reports. Read more

Auto Trait Implementations§

Blanket Implementations§

Source§

impl<T> Any for T
where + T: 'static + ?Sized,

Source§

fn type_id(&self) -> TypeId

Gets the TypeId of self. Read more
Source§

impl<T> Borrow<T> for T
where + T: ?Sized,

Source§

fn borrow(&self) -> &T

Immutably borrows from an owned value. Read more
Source§

impl<T> BorrowMut<T> for T
where + T: ?Sized,

Source§

fn borrow_mut(&mut self) -> &mut T

Mutably borrows from an owned value. Read more
Source§

impl<T> From<T> for T

Source§

fn from(t: T) -> T

Returns the argument unchanged.

+
§

impl<T> Instrument for T

§

fn instrument(self, span: Span) -> Instrumented<Self>

Instruments this type with the provided [Span], returning an +Instrumented wrapper. Read more
§

fn in_current_span(self) -> Instrumented<Self>

Instruments this type with the current Span, returning an +Instrumented wrapper. Read more
Source§

impl<T, U> Into<U> for T
where + U: From<T>,

Source§

fn into(self) -> U

Calls U::from(self).

+

That is, this conversion is whatever the implementation of +From<T> for U chooses to do.

+
§

impl<T> PolicyExt for T
where + T: ?Sized,

§

fn and<P, B, E>(self, other: P) -> And<T, P>
where + T: Policy<B, E>, + P: Policy<B, E>,

Create a new Policy that returns [Action::Follow] only if self and other return +Action::Follow. Read more
§

fn or<P, B, E>(self, other: P) -> Or<T, P>
where + T: Policy<B, E>, + P: Policy<B, E>,

Create a new Policy that returns [Action::Follow] if either self or other returns +Action::Follow. Read more
Source§

impl<T> Same for T

Source§

type Output = T

Should always be Self
Source§

impl<T> ToString for T
where + T: Display + ?Sized,

Source§

fn to_string(&self) -> String

Converts the given value to a String. Read more
§

impl<T> ToStringFallible for T
where + T: Display,

§

fn try_to_string(&self) -> Result<String, TryReserveError>

ToString::to_string, but without panic on OOM.

+
Source§

impl<T, U> TryFrom<U> for T
where + U: Into<T>,

Source§

type Error = Infallible

The type returned in the event of a conversion error.
Source§

fn try_from(value: U) -> Result<T, <T as TryFrom<U>>::Error>

Performs the conversion.
Source§

impl<T, U> TryInto<U> for T
where + U: TryFrom<T>,

Source§

type Error = <U as TryFrom<T>>::Error

The type returned in the event of a conversion error.
Source§

fn try_into(self) -> Result<U, <U as TryFrom<T>>::Error>

Performs the conversion.
§

impl<V, T> VZip<V> for T
where + V: MultiLane<T>,

§

fn vzip(self) -> V

§

impl<T> WithSubscriber for T

§

fn with_subscriber<S>(self, subscriber: S) -> WithDispatch<Self>
where + S: Into<Dispatch>,

Attaches the provided Subscriber to this type, returning a +[WithDispatch] wrapper. Read more
§

fn with_current_subscriber(self) -> WithDispatch<Self>

Attaches the current default Subscriber to this type, returning a +[WithDispatch] wrapper. Read more
\ No newline at end of file diff --git a/api/jellytau_lib/thumbnail/worker/index.html b/api/jellytau_lib/thumbnail/worker/index.html new file mode 100644 index 00000000..65a7c992 --- /dev/null +++ b/api/jellytau_lib/thumbnail/worker/index.html @@ -0,0 +1,2 @@ +jellytau_lib::thumbnail::worker - RustSkip to main content

Module worker

Module worker 

Source
Expand description

Thumbnail download worker

+

Structs§

ThumbnailWorker
Worker for downloading thumbnails from Jellyfin server

Enums§

ThumbnailDownloadError
Errors that can occur during thumbnail download
\ No newline at end of file diff --git a/api/jellytau_lib/thumbnail/worker/sidebar-items.js b/api/jellytau_lib/thumbnail/worker/sidebar-items.js new file mode 100644 index 00000000..f2846fd8 --- /dev/null +++ b/api/jellytau_lib/thumbnail/worker/sidebar-items.js @@ -0,0 +1 @@ +window.SIDEBAR_ITEMS = {"enum":["ThumbnailDownloadError"],"struct":["ThumbnailWorker"]}; \ No newline at end of file diff --git a/api/jellytau_lib/thumbnail/worker/struct.ThumbnailWorker.html b/api/jellytau_lib/thumbnail/worker/struct.ThumbnailWorker.html new file mode 100644 index 00000000..3ac7c309 --- /dev/null +++ b/api/jellytau_lib/thumbnail/worker/struct.ThumbnailWorker.html @@ -0,0 +1,38 @@ +ThumbnailWorker in jellytau_lib::thumbnail::worker - RustSkip to main content

ThumbnailWorker

Struct ThumbnailWorker 

Source
pub struct ThumbnailWorker {
+    client: Client,
+}
Expand description

Worker for downloading thumbnails from Jellyfin server

+

Fields§

§client: Client

Implementations§

Source§

impl ThumbnailWorker

Source

pub fn new() -> Self

Create a new thumbnail worker

+
Source

pub async fn download( + &self, + url: &str, +) -> Result<Vec<u8>, ThumbnailDownloadError>

Download a thumbnail from URL

+
Source

pub async fn download_with_retry( + &self, + url: &str, + max_retries: u32, +) -> Result<Vec<u8>, ThumbnailDownloadError>

Download with retry logic

+

Trait Implementations§

Source§

impl Default for ThumbnailWorker

Source§

fn default() -> Self

Returns the “default value” for a type. Read more

Auto Trait Implementations§

Blanket Implementations§

Source§

impl<T> Any for T
where + T: 'static + ?Sized,

Source§

fn type_id(&self) -> TypeId

Gets the TypeId of self. Read more
Source§

impl<T> Borrow<T> for T
where + T: ?Sized,

Source§

fn borrow(&self) -> &T

Immutably borrows from an owned value. Read more
Source§

impl<T> BorrowMut<T> for T
where + T: ?Sized,

Source§

fn borrow_mut(&mut self) -> &mut T

Mutably borrows from an owned value. Read more
Source§

impl<T> From<T> for T

Source§

fn from(t: T) -> T

Returns the argument unchanged.

+
§

impl<T> Instrument for T

§

fn instrument(self, span: Span) -> Instrumented<Self>

Instruments this type with the provided [Span], returning an +Instrumented wrapper. Read more
§

fn in_current_span(self) -> Instrumented<Self>

Instruments this type with the current Span, returning an +Instrumented wrapper. Read more
Source§

impl<T, U> Into<U> for T
where + U: From<T>,

Source§

fn into(self) -> U

Calls U::from(self).

+

That is, this conversion is whatever the implementation of +From<T> for U chooses to do.

+
§

impl<T> NoneValue for T
where + T: Default,

§

type NoneType = T

§

fn null_value() -> T

The none-equivalent value.
§

impl<T> PolicyExt for T
where + T: ?Sized,

§

fn and<P, B, E>(self, other: P) -> And<T, P>
where + T: Policy<B, E>, + P: Policy<B, E>,

Create a new Policy that returns [Action::Follow] only if self and other return +Action::Follow. Read more
§

fn or<P, B, E>(self, other: P) -> Or<T, P>
where + T: Policy<B, E>, + P: Policy<B, E>,

Create a new Policy that returns [Action::Follow] if either self or other returns +Action::Follow. Read more
Source§

impl<T> Same for T

Source§

type Output = T

Should always be Self
Source§

impl<T, U> TryFrom<U> for T
where + U: Into<T>,

Source§

type Error = Infallible

The type returned in the event of a conversion error.
Source§

fn try_from(value: U) -> Result<T, <T as TryFrom<U>>::Error>

Performs the conversion.
Source§

impl<T, U> TryInto<U> for T
where + U: TryFrom<T>,

Source§

type Error = <U as TryFrom<T>>::Error

The type returned in the event of a conversion error.
Source§

fn try_into(self) -> Result<U, <U as TryFrom<T>>::Error>

Performs the conversion.
§

impl<V, T> VZip<V> for T
where + V: MultiLane<T>,

§

fn vzip(self) -> V

§

impl<T> WithSubscriber for T

§

fn with_subscriber<S>(self, subscriber: S) -> WithDispatch<Self>
where + S: Into<Dispatch>,

Attaches the provided Subscriber to this type, returning a +[WithDispatch] wrapper. Read more
§

fn with_current_subscriber(self) -> WithDispatch<Self>

Attaches the current default Subscriber to this type, returning a +[WithDispatch] wrapper. Read more
\ No newline at end of file diff --git a/api/jellytau_lib/utils/conversions/constant.TICKS_PER_SECOND.html b/api/jellytau_lib/utils/conversions/constant.TICKS_PER_SECOND.html new file mode 100644 index 00000000..434faa83 --- /dev/null +++ b/api/jellytau_lib/utils/conversions/constant.TICKS_PER_SECOND.html @@ -0,0 +1,4 @@ +TICKS_PER_SECOND in jellytau_lib::utils::conversions - RustSkip to main content

TICKS_PER_SECOND

Constant TICKS_PER_SECOND 

Source
pub const TICKS_PER_SECOND: i64 = 10_000_000;
Expand description

Number of Jellyfin ticks per second (10 million)

+

Jellyfin uses “ticks” for time values where 10,000,000 ticks = 1 second. +This follows the .NET TimeSpan.Ticks convention.

+
\ No newline at end of file diff --git a/api/jellytau_lib/utils/conversions/fn.calculate_progress.html b/api/jellytau_lib/utils/conversions/fn.calculate_progress.html new file mode 100644 index 00000000..276705a9 --- /dev/null +++ b/api/jellytau_lib/utils/conversions/fn.calculate_progress.html @@ -0,0 +1,10 @@ +calculate_progress in jellytau_lib::utils::conversions - RustSkip to main content

calculate_progress

Function calculate_progress 

Source
pub fn calculate_progress(position: f64, duration: f64) -> f64
Expand description

Calculate progress percentage from position and duration

+

§Arguments

+
    +
  • position - Current position in seconds
  • +
  • duration - Total duration in seconds
  • +
+

§Returns

+

Progress as percentage (0.0 to 100.0), or 0.0 if duration is invalid

+

§Example

+
let progress = calculate_progress(45.0, 180.0); // 25.0
\ No newline at end of file diff --git a/api/jellytau_lib/utils/conversions/fn.format_time.html b/api/jellytau_lib/utils/conversions/fn.format_time.html new file mode 100644 index 00000000..4e1b69e2 --- /dev/null +++ b/api/jellytau_lib/utils/conversions/fn.format_time.html @@ -0,0 +1,9 @@ +format_time in jellytau_lib::utils::conversions - RustSkip to main content

format_time

Function format_time 

Source
pub fn format_time(seconds: f64) -> String
Expand description

Format time in seconds to MM:SS display string

+

§Arguments

+
    +
  • seconds - Time in seconds
  • +
+

§Returns

+

Formatted string like “3:45” or “12:09”

+

§Example

+
let formatted = format_time(225.0); // "3:45"
\ No newline at end of file diff --git a/api/jellytau_lib/utils/conversions/fn.format_time_long.html b/api/jellytau_lib/utils/conversions/fn.format_time_long.html new file mode 100644 index 00000000..4cebaaa4 --- /dev/null +++ b/api/jellytau_lib/utils/conversions/fn.format_time_long.html @@ -0,0 +1,15 @@ +format_time_long in jellytau_lib::utils::conversions - RustSkip to main content

format_time_long

Function format_time_long 

Source
pub fn format_time_long(seconds: f64) -> String
Expand description

Format time in seconds to HH:MM:SS or MM:SS display string

+

Automatically chooses format based on duration:

+
    +
  • Less than 1 hour: Returns MM:SS format
  • +
  • 1 hour or more: Returns HH:MM:SS format
  • +
+

§Arguments

+
    +
  • seconds - Time in seconds
  • +
+

§Returns

+

Formatted string like “1:23:45” or “3:45”

+

§Example

+
let short = format_time_long(225.0);  // "3:45"
+let long = format_time_long(5025.0);  // "1:23:45"
\ No newline at end of file diff --git a/api/jellytau_lib/utils/conversions/fn.percent_to_volume.html b/api/jellytau_lib/utils/conversions/fn.percent_to_volume.html new file mode 100644 index 00000000..5fe4dc20 --- /dev/null +++ b/api/jellytau_lib/utils/conversions/fn.percent_to_volume.html @@ -0,0 +1,11 @@ +percent_to_volume in jellytau_lib::utils::conversions - RustSkip to main content

percent_to_volume

Function percent_to_volume 

Source
pub fn percent_to_volume(percent: f64) -> f64
Expand description

Convert percentage volume (0-100) to normalized (0.0-1.0)

+

Used when receiving volume from Jellyfin remote sessions or UI controls. +Values outside the 0-100 range are clamped.

+

§Arguments

+
    +
  • percent - Volume as percentage (0 to 100)
  • +
+

§Returns

+

Normalized volume (0.0 to 1.0)

+

§Example

+
let normalized = percent_to_volume(75.0); // 0.75
\ No newline at end of file diff --git a/api/jellytau_lib/utils/conversions/fn.seconds_to_ticks.html b/api/jellytau_lib/utils/conversions/fn.seconds_to_ticks.html new file mode 100644 index 00000000..7056986c --- /dev/null +++ b/api/jellytau_lib/utils/conversions/fn.seconds_to_ticks.html @@ -0,0 +1,9 @@ +seconds_to_ticks in jellytau_lib::utils::conversions - RustSkip to main content

seconds_to_ticks

Function seconds_to_ticks 

Source
pub fn seconds_to_ticks(seconds: f64) -> i64
Expand description

Convert seconds to Jellyfin ticks

+

§Arguments

+
    +
  • seconds - Time in seconds (e.g., 90.5 for 1 minute 30.5 seconds)
  • +
+

§Returns

+

Time in Jellyfin ticks (will be rounded down to nearest tick)

+

§Example

+
let ticks = seconds_to_ticks(1.5); // 15,000,000 ticks
\ No newline at end of file diff --git a/api/jellytau_lib/utils/conversions/fn.ticks_to_seconds.html b/api/jellytau_lib/utils/conversions/fn.ticks_to_seconds.html new file mode 100644 index 00000000..4ff72748 --- /dev/null +++ b/api/jellytau_lib/utils/conversions/fn.ticks_to_seconds.html @@ -0,0 +1,9 @@ +ticks_to_seconds in jellytau_lib::utils::conversions - RustSkip to main content

ticks_to_seconds

Function ticks_to_seconds 

Source
pub fn ticks_to_seconds(ticks: i64) -> f64
Expand description

Convert Jellyfin ticks to seconds

+

§Arguments

+
    +
  • ticks - Time in Jellyfin ticks
  • +
+

§Returns

+

Time in seconds as floating point

+

§Example

+
let seconds = ticks_to_seconds(15_000_000); // 1.5 seconds
\ No newline at end of file diff --git a/api/jellytau_lib/utils/conversions/fn.volume_to_percent.html b/api/jellytau_lib/utils/conversions/fn.volume_to_percent.html new file mode 100644 index 00000000..1292707f --- /dev/null +++ b/api/jellytau_lib/utils/conversions/fn.volume_to_percent.html @@ -0,0 +1,11 @@ +volume_to_percent in jellytau_lib::utils::conversions - RustSkip to main content

volume_to_percent

Function volume_to_percent 

Source
pub fn volume_to_percent(volume: f64) -> f64
Expand description

Convert normalized volume (0.0-1.0) to percentage (0-100)

+

Used when sending volume to Jellyfin remote sessions, MPV, or ExoPlayer. +Values outside the 0.0-1.0 range are clamped.

+

§Arguments

+
    +
  • volume - Normalized volume (0.0 to 1.0)
  • +
+

§Returns

+

Volume as percentage (0 to 100), floored to integer value

+

§Example

+
let percent = volume_to_percent(0.75); // 75.0
\ No newline at end of file diff --git a/api/jellytau_lib/utils/conversions/index.html b/api/jellytau_lib/utils/conversions/index.html new file mode 100644 index 00000000..2f874341 --- /dev/null +++ b/api/jellytau_lib/utils/conversions/index.html @@ -0,0 +1,10 @@ +jellytau_lib::utils::conversions - RustSkip to main content

Module conversions

Module conversions 

Source
Expand description

Unit conversion and formatting utilities

+

This module provides centralized conversion functions for:

+
    +
  • Jellyfin tick-to-seconds conversions
  • +
  • Volume normalization (0-1 vs 0-100 ranges)
  • +
  • Time formatting for UI display
  • +
+

These utilities eliminate magic numbers and duplicate conversion logic +across the codebase.

+

Constants§

TICKS_PER_SECOND
Number of Jellyfin ticks per second (10 million)

Functions§

calculate_progress
Calculate progress percentage from position and duration
format_time
Format time in seconds to MM:SS display string
format_time_long
Format time in seconds to HH:MM:SS or MM:SS display string
percent_to_volume
Convert percentage volume (0-100) to normalized (0.0-1.0)
seconds_to_ticks
Convert seconds to Jellyfin ticks
ticks_to_seconds
Convert Jellyfin ticks to seconds
volume_to_percent
Convert normalized volume (0.0-1.0) to percentage (0-100)
\ No newline at end of file diff --git a/api/jellytau_lib/utils/conversions/sidebar-items.js b/api/jellytau_lib/utils/conversions/sidebar-items.js new file mode 100644 index 00000000..6f59e9e4 --- /dev/null +++ b/api/jellytau_lib/utils/conversions/sidebar-items.js @@ -0,0 +1 @@ +window.SIDEBAR_ITEMS = {"constant":["TICKS_PER_SECOND"],"fn":["calculate_progress","format_time","format_time_long","percent_to_volume","seconds_to_ticks","ticks_to_seconds","volume_to_percent"]}; \ No newline at end of file diff --git a/api/jellytau_lib/utils/diagnostics/constant.REDACTED.html b/api/jellytau_lib/utils/diagnostics/constant.REDACTED.html new file mode 100644 index 00000000..8b46d001 --- /dev/null +++ b/api/jellytau_lib/utils/diagnostics/constant.REDACTED.html @@ -0,0 +1,2 @@ +REDACTED in jellytau_lib::utils::diagnostics - RustSkip to main content

REDACTED

Constant REDACTED 

Source
pub const REDACTED: &str = "[REDACTED]";
Expand description

Replacement for a redacted value.

+
\ No newline at end of file diff --git a/api/jellytau_lib/utils/diagnostics/constant.SECRET_HEADERS.html b/api/jellytau_lib/utils/diagnostics/constant.SECRET_HEADERS.html new file mode 100644 index 00000000..6c3e7438 --- /dev/null +++ b/api/jellytau_lib/utils/diagnostics/constant.SECRET_HEADERS.html @@ -0,0 +1,2 @@ +SECRET_HEADERS in jellytau_lib::utils::diagnostics - RustSkip to main content

SECRET_HEADERS

Constant SECRET_HEADERS 

Source
const SECRET_HEADERS: &[&str];
Expand description

Header names whose value is a credential.

+
\ No newline at end of file diff --git a/api/jellytau_lib/utils/diagnostics/constant.SECRET_JSON_KEYS.html b/api/jellytau_lib/utils/diagnostics/constant.SECRET_JSON_KEYS.html new file mode 100644 index 00000000..dafb589c --- /dev/null +++ b/api/jellytau_lib/utils/diagnostics/constant.SECRET_JSON_KEYS.html @@ -0,0 +1,2 @@ +SECRET_JSON_KEYS in jellytau_lib::utils::diagnostics - RustSkip to main content

SECRET_JSON_KEYS

Constant SECRET_JSON_KEYS 

Source
const SECRET_JSON_KEYS: &[&str];
Expand description

JSON keys whose value is a credential.

+
\ No newline at end of file diff --git a/api/jellytau_lib/utils/diagnostics/constant.SECRET_QUERY_KEYS.html b/api/jellytau_lib/utils/diagnostics/constant.SECRET_QUERY_KEYS.html new file mode 100644 index 00000000..c2604cde --- /dev/null +++ b/api/jellytau_lib/utils/diagnostics/constant.SECRET_QUERY_KEYS.html @@ -0,0 +1,5 @@ +SECRET_QUERY_KEYS in jellytau_lib::utils::diagnostics - RustSkip to main content

SECRET_QUERY_KEYS

Constant SECRET_QUERY_KEYS 

Source
const SECRET_QUERY_KEYS: &[&str];
Expand description

Query-string parameters whose value is a credential.

+

Jellyfin accepts the API key under several spellings depending on the +endpoint and client generation, and this codebase has emitted more than one +of them over time.

+
\ No newline at end of file diff --git a/api/jellytau_lib/utils/diagnostics/fn.install_panic_hook.html b/api/jellytau_lib/utils/diagnostics/fn.install_panic_hook.html new file mode 100644 index 00000000..a34fcbac --- /dev/null +++ b/api/jellytau_lib/utils/diagnostics/fn.install_panic_hook.html @@ -0,0 +1,9 @@ +install_panic_hook in jellytau_lib::utils::diagnostics - RustSkip to main content

install_panic_hook

Function install_panic_hook 

Source
pub fn install_panic_hook()
Expand description

Install a panic hook that records the panic through log::error! before the +default hook runs.

+

§Why it chains rather than replaces

+

utils::lock installs a silencing hook around its own tests, which +deliberately provoke poisoned locks. Replacing the current hook here would +make that test output scream about panics it is intentionally causing — and, +more importantly, replacing whatever hook is present is how you lose the +backtrace the runtime would otherwise print.

+
\ No newline at end of file diff --git a/api/jellytau_lib/utils/diagnostics/fn.panic_payload_string.html b/api/jellytau_lib/utils/diagnostics/fn.panic_payload_string.html new file mode 100644 index 00000000..37973b7c --- /dev/null +++ b/api/jellytau_lib/utils/diagnostics/fn.panic_payload_string.html @@ -0,0 +1,2 @@ +panic_payload_string in jellytau_lib::utils::diagnostics - RustSkip to main content

panic_payload_string

Function panic_payload_string 

Source
fn panic_payload_string(info: &PanicHookInfo<'_>) -> String
Expand description

Extract a printable message from a panic payload.

+
\ No newline at end of file diff --git a/api/jellytau_lib/utils/diagnostics/fn.redact.html b/api/jellytau_lib/utils/diagnostics/fn.redact.html new file mode 100644 index 00000000..6926ce43 --- /dev/null +++ b/api/jellytau_lib/utils/diagnostics/fn.redact.html @@ -0,0 +1,4 @@ +redact in jellytau_lib::utils::diagnostics - RustSkip to main content

redact

Function redact 

Source
pub fn redact(line: &str) -> String
Expand description

Strip credentials from one log line.

+

Idempotent: redacting an already-redacted line changes nothing, which matters +because the exporter may re-process a file the formatter already cleaned.

+
\ No newline at end of file diff --git a/api/jellytau_lib/utils/diagnostics/fn.redact_emby_auth.html b/api/jellytau_lib/utils/diagnostics/fn.redact_emby_auth.html new file mode 100644 index 00000000..cc134fb2 --- /dev/null +++ b/api/jellytau_lib/utils/diagnostics/fn.redact_emby_auth.html @@ -0,0 +1,3 @@ +redact_emby_auth in jellytau_lib::utils::diagnostics - RustSkip to main content

redact_emby_auth

Function redact_emby_auth 

Source
fn redact_emby_auth(line: &str) -> String
Expand description

MediaBrowser Token="abc" -> MediaBrowser Token="[REDACTED]"

+

Jellyfin’s own auth header format, which is not JSON and not a query param.

+
\ No newline at end of file diff --git a/api/jellytau_lib/utils/diagnostics/fn.redact_headers.html b/api/jellytau_lib/utils/diagnostics/fn.redact_headers.html new file mode 100644 index 00000000..9cee6f15 --- /dev/null +++ b/api/jellytau_lib/utils/diagnostics/fn.redact_headers.html @@ -0,0 +1,6 @@ +redact_headers in jellytau_lib::utils::diagnostics - RustSkip to main content

redact_headers

Function redact_headers 

Source
fn redact_headers(line: &str) -> String
Expand description

X-Emby-Token: abc -> X-Emby-Token: [REDACTED]

+

Scans forward from a cursor rather than recursing on the rewritten string. +The obvious recursive version does not terminate: the replacement keeps the +header name, so the next call finds the same header again and recurses +until the stack is gone. A test provoked exactly that.

+
\ No newline at end of file diff --git a/api/jellytau_lib/utils/diagnostics/fn.redact_json_values.html b/api/jellytau_lib/utils/diagnostics/fn.redact_json_values.html new file mode 100644 index 00000000..583dd2a1 --- /dev/null +++ b/api/jellytau_lib/utils/diagnostics/fn.redact_json_values.html @@ -0,0 +1,2 @@ +redact_json_values in jellytau_lib::utils::diagnostics - RustSkip to main content

redact_json_values

Function redact_json_values 

Source
fn redact_json_values(line: &str) -> String
Expand description

"AccessToken":"abc" -> "AccessToken":"[REDACTED]"

+
\ No newline at end of file diff --git a/api/jellytau_lib/utils/diagnostics/fn.redact_query_params.html b/api/jellytau_lib/utils/diagnostics/fn.redact_query_params.html new file mode 100644 index 00000000..803235a7 --- /dev/null +++ b/api/jellytau_lib/utils/diagnostics/fn.redact_query_params.html @@ -0,0 +1,4 @@ +redact_query_params in jellytau_lib::utils::diagnostics - RustSkip to main content

redact_query_params

Function redact_query_params 

Source
fn redact_query_params(line: &str) -> String
Expand description

?api_key=abc&x=1 -> ?api_key=[REDACTED]&x=1

+

The value ends at the first character that cannot be part of one: & +separates parameters, and whitespace/quotes mean the URL itself ended.

+
\ No newline at end of file diff --git a/api/jellytau_lib/utils/diagnostics/fn.redact_server_url.html b/api/jellytau_lib/utils/diagnostics/fn.redact_server_url.html new file mode 100644 index 00000000..f7f5f034 --- /dev/null +++ b/api/jellytau_lib/utils/diagnostics/fn.redact_server_url.html @@ -0,0 +1,5 @@ +redact_server_url in jellytau_lib::utils::diagnostics - RustSkip to main content

redact_server_url

Function redact_server_url 

Source
pub fn redact_server_url(url: &str) -> String
Expand description

Reduce a server URL to scheme and host.

+

The host is diagnostic (is it https? a LAN address? a reverse proxy?); the +path and any query on it are not, and a configured URL has been seen to carry +a token.

+
\ No newline at end of file diff --git a/api/jellytau_lib/utils/diagnostics/index.html b/api/jellytau_lib/utils/diagnostics/index.html new file mode 100644 index 00000000..adbb6859 --- /dev/null +++ b/api/jellytau_lib/utils/diagnostics/index.html @@ -0,0 +1,17 @@ +jellytau_lib::utils::diagnostics - RustSkip to main content

Module diagnostics

Module diagnostics 

Source
Expand description

Credential redaction and crash capture for diagnostic logs.

+

TRACES: UR-078 | DR-218

+

§Why redaction lives here and not at the export

+

A diagnostic bundle is something a user attaches to a public bug report. If a +Jellyfin access token can reach it, this feature is a credential-disclosure +bug with a friendly button on it.

+

So redact runs in the log formatter — the token never reaches disk — +and again over every line the exporter copies, which covers files written by +an older build that lacked the formatter pass. Redacting only at export would +leave the secret sitting in a file on the device, which is exactly the thing +we are trying not to do.

+

§What is deliberately NOT redacted

+

Server host, item ids, filenames and paths inside the app’s own directories +all stay. They are not secrets and they are the entire diagnostic value of a +log: a bundle scrubbed of them is one nobody can debug anything from.

+

Constants§

REDACTED
Replacement for a redacted value.
SECRET_HEADERS 🔒
Header names whose value is a credential.
SECRET_JSON_KEYS 🔒
JSON keys whose value is a credential.
SECRET_QUERY_KEYS 🔒
Query-string parameters whose value is a credential.

Functions§

install_panic_hook
Install a panic hook that records the panic through log::error! before the +default hook runs.
panic_payload_string 🔒
Extract a printable message from a panic payload.
redact
Strip credentials from one log line.
redact_emby_auth 🔒
MediaBrowser Token="abc" -> MediaBrowser Token="[REDACTED]"
redact_headers 🔒
X-Emby-Token: abc -> X-Emby-Token: [REDACTED]
redact_json_values 🔒
"AccessToken":"abc" -> "AccessToken":"[REDACTED]"
redact_query_params 🔒
?api_key=abc&x=1 -> ?api_key=[REDACTED]&x=1
redact_server_url
Reduce a server URL to scheme and host.
\ No newline at end of file diff --git a/api/jellytau_lib/utils/diagnostics/sidebar-items.js b/api/jellytau_lib/utils/diagnostics/sidebar-items.js new file mode 100644 index 00000000..58e19b37 --- /dev/null +++ b/api/jellytau_lib/utils/diagnostics/sidebar-items.js @@ -0,0 +1 @@ +window.SIDEBAR_ITEMS = {"constant":["REDACTED","SECRET_HEADERS","SECRET_JSON_KEYS","SECRET_QUERY_KEYS"],"fn":["install_panic_hook","panic_payload_string","redact","redact_emby_auth","redact_headers","redact_json_values","redact_query_params","redact_server_url"]}; \ No newline at end of file diff --git a/api/jellytau_lib/utils/index.html b/api/jellytau_lib/utils/index.html new file mode 100644 index 00000000..9ab184bb --- /dev/null +++ b/api/jellytau_lib/utils/index.html @@ -0,0 +1 @@ +jellytau_lib::utils - RustSkip to main content

Module utils

Module utils 

Source

Modules§

conversions
Unit conversion and formatting utilities
diagnostics
Credential redaction and crash capture for diagnostic logs.
lock
Poison-tolerant locking helpers.
\ No newline at end of file diff --git a/api/jellytau_lib/utils/lock/index.html b/api/jellytau_lib/utils/lock/index.html new file mode 100644 index 00000000..6239709a --- /dev/null +++ b/api/jellytau_lib/utils/lock/index.html @@ -0,0 +1,16 @@ +jellytau_lib::utils::lock - RustSkip to main content

Module lock

Module lock 

Source
Expand description

Poison-tolerant locking helpers.

+

std::sync::Mutex and RwLock become poisoned if a thread panics while +holding the guard. After that, every .lock().unwrap() / .read().unwrap() +/ .write().unwrap() panics as well — so a single failure can cascade into +an unrecoverable crash. That is a real risk for stateful subsystems like the +player, whose locks are touched from many background threads (the MPV event +loop, sleep/autoplay timers, JNI callbacks, the session poller).

+

These extension traits recover the guard from a poisoned lock instead of +panicking. The data behind a poisoned lock may be in an unexpected state, +but for this application’s state (queues, settings, flags) recovering and +continuing is far preferable to taking down playback entirely.

+

Use lock_safe() / read_safe() / write_safe() in place of +.lock().unwrap() / .read().unwrap() / .write().unwrap().

+

Note: these apply only to std::sync primitives. tokio::sync::Mutex does +not poison, so async code keeps using .lock().await unchanged.

+

Traits§

MutexSafe
Poison-tolerant locking for std::sync::Mutex.
RwLockSafe
Poison-tolerant locking for std::sync::RwLock.
\ No newline at end of file diff --git a/api/jellytau_lib/utils/lock/sidebar-items.js b/api/jellytau_lib/utils/lock/sidebar-items.js new file mode 100644 index 00000000..71f89d09 --- /dev/null +++ b/api/jellytau_lib/utils/lock/sidebar-items.js @@ -0,0 +1 @@ +window.SIDEBAR_ITEMS = {"trait":["MutexSafe","RwLockSafe"]}; \ No newline at end of file diff --git a/api/jellytau_lib/utils/lock/trait.MutexSafe.html b/api/jellytau_lib/utils/lock/trait.MutexSafe.html new file mode 100644 index 00000000..666414aa --- /dev/null +++ b/api/jellytau_lib/utils/lock/trait.MutexSafe.html @@ -0,0 +1,6 @@ +MutexSafe in jellytau_lib::utils::lock - RustSkip to main content

MutexSafe

Trait MutexSafe 

Source
pub trait MutexSafe<T: ?Sized> {
+    // Required method
+    fn lock_safe(&self) -> MutexGuard<'_, T>;
+}
Expand description

Poison-tolerant locking for std::sync::Mutex.

+

Required Methods§

Source

fn lock_safe(&self) -> MutexGuard<'_, T>

Lock the mutex, recovering the inner guard if the lock was poisoned.

+

Implementations on Foreign Types§

Source§

impl<T: ?Sized> MutexSafe<T> for Mutex<T>

Source§

fn lock_safe(&self) -> MutexGuard<'_, T>

Implementors§

\ No newline at end of file diff --git a/api/jellytau_lib/utils/lock/trait.RwLockSafe.html b/api/jellytau_lib/utils/lock/trait.RwLockSafe.html new file mode 100644 index 00000000..4f2f0695 --- /dev/null +++ b/api/jellytau_lib/utils/lock/trait.RwLockSafe.html @@ -0,0 +1,8 @@ +RwLockSafe in jellytau_lib::utils::lock - RustSkip to main content

RwLockSafe

Trait RwLockSafe 

Source
pub trait RwLockSafe<T: ?Sized> {
+    // Required methods
+    fn read_safe(&self) -> RwLockReadGuard<'_, T>;
+    fn write_safe(&self) -> RwLockWriteGuard<'_, T>;
+}
Expand description

Poison-tolerant locking for std::sync::RwLock.

+

Required Methods§

Source

fn read_safe(&self) -> RwLockReadGuard<'_, T>

Acquire a read guard, recovering it if the lock was poisoned.

+
Source

fn write_safe(&self) -> RwLockWriteGuard<'_, T>

Acquire a write guard, recovering it if the lock was poisoned.

+

Implementations on Foreign Types§

Source§

impl<T: ?Sized> RwLockSafe<T> for RwLock<T>

Implementors§

\ No newline at end of file diff --git a/api/jellytau_lib/utils/sidebar-items.js b/api/jellytau_lib/utils/sidebar-items.js new file mode 100644 index 00000000..9bd210ec --- /dev/null +++ b/api/jellytau_lib/utils/sidebar-items.js @@ -0,0 +1 @@ +window.SIDEBAR_ITEMS = {"mod":["conversions","diagnostics","lock"]}; \ No newline at end of file diff --git a/api/search.index/005277f3fc.js b/api/search.index/005277f3fc.js new file mode 100644 index 00000000..02c6d513 --- /dev/null +++ b/api/search.index/005277f3fc.js @@ -0,0 +1 @@ +rn_("BQLBAAAXmRupDBpmcwUCwAAAFHwdF9IYY3IbAsgAABrNb3IhxwAASxbQG6HFAAAUFqHMAABRGwXAwAIkAAAUoh0mgBxmHRNJHKHQAABRHAX9wAFABQAApBIWE/wTrecbEXobUccAAEobSxtMG00bix0BAP1+0A96jyoqZwGn7Sb7LBYPA+Vz7wXgIsgAABxJE/Ol68LBAAAO/8UAABdcU0ql9xoK2PUosMcAABVEfS0LzYxhldaqKzowAAABAAAAAABGABAAAAC8B70Hvge/B8AHwQfCB8MHxAdFCMMJ1Q7WDtcO2A7mE+cTGRQaFEMURBQzFToVdRV2FXcVsRayFgsXaBd+GX8ZgBmBGYMZhBmFGYYZBRoGGgcaCBoTGj8aURpyGnMadBp1GokaihrKGtQaYRuXG5gbrhu2G74b4hvjG+Qb5RvxG0QcexxrHWwdex2UHZwd") \ No newline at end of file diff --git a/api/search.index/00c49cc141.js b/api/search.index/00c49cc141.js new file mode 100644 index 00000000..c1897d41 --- /dev/null +++ b/api/search.index/00c49cc141.js @@ -0,0 +1 @@ +rn_("AUM7MAAAAQAA2QEFAKkLAABFFOsAYxYAACwYdQDcHHUABcDDgGAAABLLGxXAGSXBGcwaBQLAAAASThoRvhlpdAUCwAAAEAEcI78ZtBpydlODA8gAAByuZWZ0GwLGAAAaT250McUAAAwcJxxdHfMCA2Jpc+MoCgAARhoAAFwbAABoHAAAEBC1AsMAAKZWFxZYF3N3qgvzAMABgfjUCwAAXAQBAG8GPwK4AL4ADAM=") \ No newline at end of file diff --git a/api/search.index/012b1a8829.js b/api/search.index/012b1a8829.js new file mode 100644 index 00000000..4f337564 --- /dev/null +++ b/api/search.index/012b1a8829.js @@ -0,0 +1 @@ +rn_("cccAAKYdpx2oHakdqh2zHbUd8wIBZuelEgAAGRcAAPUZAACSGwAAohsAANMcAACTHQAAlx0AAEATEAAFAsAAABuQHaI/FG95YcAAAMMJOhWxFrIWCxdhGyUBxQAAF6AdcNcWzRoVAcYAAKfTG3LFHAUCwAAAoT0cH4IcZW2lAcMAAKvRHGZcEHUBwAAAEtEba+8Y2RlDGkQa/xu9HM0cI4YCwQAAHYlpeUvAHADBAAAdiMAAABb0KwLgAAAXIGRuIcAAADcU+BjBxwAAuxihwQAAoxIBRzswAAABAADsAAMAYxYAACwYdQDcHHUAGwLCAAAOtWR0dwEAwARsxgAAHNY7MAAAAQAAUgAVANMFFQClCwEAkA4CAPIOBAAyEAAAYhANAFUSAADoEgQA2RMAAAYUAAAkFAUALBQIAGEVAAAYFgAAvBYCABoYAQCzGAAA1hkAADUaAQCAGgAAeBsAAOc/AP5AROEHwAAAGabsUhAAAM4SAABYFQAAXxUAAJwZAADFGQAAWRoAALIaAAC5GgAA7xsAADccAABuHQAAdx0AAABRMAAAEAA=") \ No newline at end of file diff --git a/api/search.index/01aaaebd26.js b/api/search.index/01aaaebd26.js new file mode 100644 index 00000000..de7d177c --- /dev/null +++ b/api/search.index/01aaaebd26.js @@ -0,0 +1 @@ +rn_("BYnBAAASnR0UpR0jjQLGAAAdrHBzpQHEAACmdhx31BkTgQLIAAAcoW5yBQLEAAAaoR0Ylx1kcwUCxAAAHbQdF4wdY2UblskAAB17yAAAHW0FAsMAABRsHRV6HWltQ4HABkDPAAAdscwAAB2cBQLAAAAU+hsaeR1lbAUCwAAAFqAcG5UdYnYrhcgAAB1rFQHAAAAXix1ithvzAgFzAWQbAAArAsUAAByfbXQFAsEAABSUHRirHWlsI4cCxwAAHaRkZQGEAtMAAB21xwAAHZNvc1f2gv5AW6oJxgAAHGTHAAAcnsQAABv5zAAAHZYFAsAAAB+KHRQzGmxyVwUA/UxSC8UAABqTygAAHMzMAAAdbsQAABo0zAAAHVrrYQoAAIwSAADmEgAASBcAAGAXAADdGAAA4xgAAMkZAABjGgAAzxoAACEcAACKHQAAAwABEQMANYHAAAARzBxECBwYHRgFAsAAABErFlGjFsgYrhlzHX8dZW4lhMMAABSpHKsamh1DAMIEQ8QAABY2zQAAHKPOAAAczcUAABwKAVUXAACxwgAAnxIFAs0AABaYHRefHWNwBQLAAAAcbBwSNRZveSHDAABlFVMdBQLAAAAZaxwWcBthdysCxwAAG75hdAGCAtAAAB2ywQAAG29pdhUBwwAAFagccEEak4L9AIkKxQAAG73EAAAbbsIAABpC+wNhZXQFAsMAABVsGxVtG3BzocQAANUWBQLBAAAR0hgRNBZmaQkC0AAAHa3CAAAba2l2KwLKAAAdb2NyG4bBAAAZsgUCwAAAHY4dE6UabnIrA8EAABmxzQAAHYZhZWwVAsMAABVqHBA/GnBzsBkFAsMAABZeHRNpHHN1I4ECxwAAHKdjdLcBgv0BiQrHAAAcBcMAABpABQLFAAAVBBwTahtzdfMC/UgZDoSxCgAA9woA/RlBUsEAABgFxQAAHNf1qw4AAAEACQzYABcB") \ No newline at end of file diff --git a/api/search.index/01e0eb7c2c.js b/api/search.index/01e0eb7c2c.js new file mode 100644 index 00000000..fab591b0 --- /dev/null +++ b/api/search.index/01e0eb7c2c.js @@ -0,0 +1 @@ +rn_("ocoAAMEbIc8AAF8drh0FAsUAAKO/G6U3HHBzPwL9GDAFygAAHDnJAAAcBssAABxuBQLBAAAccB0XphphY/ODAmVz") \ No newline at end of file diff --git a/api/search.index/022a78ccc8.js b/api/search.index/022a78ccc8.js new file mode 100644 index 00000000..bc6e9726 --- /dev/null +++ b/api/search.index/022a78ccc8.js @@ -0,0 +1 @@ +rn_("UccAAJIbkxuUG5Ubkx0BBQHIAAAc2mrkPhYAAAQXAACcGgAAwhwAAMQcAAAFMAAlhMQAABcUHGgbuRv7AnN0W/0QQETDAAAcSMMAABrX") \ No newline at end of file diff --git a/api/search.index/022ae8d4aa.js b/api/search.index/022ae8d4aa.js new file mode 100644 index 00000000..84dec95d --- /dev/null +++ b/api/search.index/022ae8d4aa.js @@ -0,0 +1 @@ +rn_("ockAAMEbFYHEAAARMhpXHAUCwAAAEe0bEVsbcnT7A2FvciHCAADFGFsaIc8AAF8drh0FAsUAAKO/G6U3HHBzdwQA/RgxDcoAABw5yQAAHAbLAAAcbsQAABc9AaAKAAA=") \ No newline at end of file diff --git a/api/search.index/022c27213e.js b/api/search.index/022c27213e.js new file mode 100644 index 00000000..6dfe69fc --- /dev/null +++ b/api/search.index/022c27213e.js @@ -0,0 +1 @@ +rn_("BQLBAAChXhCgoA5lbCHHAADvGRUcpZDEAACi9Rk3HCsCxgAAGixldDHDAADeE78bwBs1gcAAABHMHEQIHBgdGPMAAXOCqw4AABsCxAAAF09ycxWBwgAAFBYYyw4FAsAAABmQGxiRGmVzBQLAAAAbfx0SUBpydBuJygAAHXMrAskAAB1oZXQxxwAAeRvaG9AcNQHFAAAW1xx3tRqNG6QcBQLCAAAYZx0afh1ydfuJFYLDAAAUPxyGHBOBg8cAABy01wECwBJixwAAHErhfhUAAEwaAABAIcMAAGkcaxwxxgAAPBYJGl4dIcgAAPcYkRuxxAAAbRXzAP0MAArl3Q4AADoQAAAhGAAAFBwAAEccAABUHQAAUAAA+4bBwwAAtRgxwwAAAhgDGEgbFQHFAAAWyht27haHAwH9EUBFwQAAEsDDAAAZA8YAAByQ/UMIAAD6Bx0AhwJUAgEAzQHWAREBewFnAAEAxgD7Am92JQHAAAATjhZzzAtNHBMAAsUAABmlZG0BehsAAAUDwAAAFkoXEdALGdcaYml1AQD+QOTyB1/RELgQLoBL/QDFAAAXO1klXWJkPJGVHJs7ju+SNgy1ElecSoJ1e2bJAAAbYccAABywOa0aMn1lPVZUUSTctY8S8tAJAAANCQ==") \ No newline at end of file diff --git a/api/search.index/027fad17e9.js b/api/search.index/027fad17e9.js new file mode 100644 index 00000000..c5ceb18f --- /dev/null +++ b/api/search.index/027fad17e9.js @@ -0,0 +1 @@ +rn_("BQLJAAAUkh0Vmh1schUBwgAAG4cddBYaJQHGAAAW2Rx3vxrsGwUCwgAAFr4cG5EdY3KhxgAAvRq3AwL9RBEGzAAAHYPhTRUAALcWAAATK4nHAAAbrQ==") \ No newline at end of file diff --git a/api/search.index/0308b9ba4b.js b/api/search.index/0308b9ba4b.js new file mode 100644 index 00000000..6f3d784c --- /dev/null +++ b/api/search.index/0308b9ba4b.js @@ -0,0 +1 @@ +rn_("FQHGAAAYyxx0kRwhxgAALRzbHAWJwQAAYvQb9Rv2G/cb+BudHWRfHGAcYRxiHGMcpR1hxwAAmRyaHJscnBydHKwd84YCcHP7A2xvcg==") \ No newline at end of file diff --git a/api/search.index/0390e8fb6c.js b/api/search.index/0390e8fb6c.js new file mode 100644 index 00000000..5abb5034 --- /dev/null +++ b/api/search.index/0390e8fb6c.js @@ -0,0 +1 @@ +rn_("RQHAAAASehtjiRqXG5gblB0xwgAA+Rn6Gf0bFQHHAAAWYh1ySxw3AQL9AYEHxAAAGNrFAAAat8kAAByy4zgQAAC7EgAAghYAAJUaAAAQAQ==") \ No newline at end of file diff --git a/api/search.index/03ab55e57a.js b/api/search.index/03ab55e57a.js new file mode 100644 index 00000000..efd89936 --- /dev/null +++ b/api/search.index/03ab55e57a.js @@ -0,0 +1 @@ +rn_("FQHCAAAd2xxyMRplAsEAABF9FRQEGGFjnQ6eDiEXcRs6HFMc+4YrAsQAABiibXQFAsEAACZjGiEcw7IbaGzzAIPypA4AAPML") \ No newline at end of file diff --git a/api/search.index/03e2f5b1df.js b/api/search.index/03e2f5b1df.js new file mode 100644 index 00000000..9ed4d70e --- /dev/null +++ b/api/search.index/03e2f5b1df.js @@ -0,0 +1 @@ +rn_("BYzAAAAcnh0UsxYFAsAAACRIGm0aECcXYWwrAsUAAByObHIFAsAAADJLFcYWzhoStQ5peQUCwgAAHaAdHJAdcnSxyAAADxrzgYMxxQAAzxLQEh4WAUc7MAAAAQAA2QACAAwZbADYGmwAnwH9ECEFwQAAFo7FAAAZ+wUCwQAAIV8azxsZYx1sbSsCwwAAHA9xcyuLwQAAHDbAAAAOugUCwAAAEY4cIRkY0xpzdBuDwwAAFTs1A8AAABdzHBMcHBQeHGV0dfcYkRvZG/MFAXPUKRcAAOSyTDHAAADKC8sLpxhrwgAMzQAAHa8VhMQAABNhFx0WBQLEAAAyihyLHKsdED8WaXkrhsIAABY2NQHAAAASehtjlxuYG5QdMcIAAPkZ+hn9G1MC/QCBBsQAABjayQAAHLLhOBAAAJUaAAARBQLBAAAdsBxY4hvjG+Qb5RucHW52GwLFAAAaDmNwIcAAAMQOuBtVAcMAABCsG3OyDrMOXRJeEl8SJQHEAACgsRZzzhK5GlUCxAAAoSoYoK8WaXPKEssSzBLNErgascMAAOAYpQHGAAAW2Bx3uhrzhAJtdDUBwwAAFlYad8kSGxYcFvsCcnP7A2VvdCHDAABbHKIcBQLBAABDxxLIEsocWB0VKRhhY6HDAACwDgUCwAAAoBsMcq0Org6vDmEXHxjzGbIcZHTnDQD9BI0HxQAAHDnIAAAXYOPMBwAAHRYAAH0WAAAdFwAAYADfAf0JAAfAAAAVPwWFwAAAGYwbEkgWNYLDAAAWYBrJG0EceBwVAcMAABYKHHdVFyHDAAAFGZYbJQHDAAATeBtu6Rv7G/MAwAiIgvYSAABDgwPDAAAXVMQAABjbZXJzKwLgAAAZjGR0BQLDAAAVBBwTahtzdfvBAAUBRDswAAABAADsAAMAYxYAACwYdQDcHHUAE4GD4QAAGZAbmcYAABxmxQAAGhJVAcIAABOtHGSNFSUWKBYpFgQYI4ECxAAAGqZtdQUCwAAAFnAdo5kaaG/zAwFj4bkcAACxHQAAMHWCwAAAwL8W2gsXD9QRohJ9FR8XMhoFAsAAABDMCxalGWRtEwH+AKRAAcUAABc7yAAAGpHJAAAbYccAABosAdAJAACfAf0ECA7IAAAay8IAABvyAQD+wE+2DzbAtp/vwAAAFxpUmtdneRY8K++GYYYqGMRHZjXSLTyhfA3nanmjMLVDb1GbUwl3RlicbCHTngRoGGhUtFqIRxfQMRNnYcI2ekLvYCimYy7fOzAAAAEAABsBWAAaBwAA0wcDAA8IAQAjCAMAKwgBAD4JAADMCQAAEQoFAFoKAQCvCgAAtwsAAMwLAQDYCwEA8AsAABMMAAAdDAAASwwAAFYMfwCTDgAAmg4BAJ0OAQDDDgAA5g4KAJUPAAAuEAEAShADAF4QAQBhEAAAUREAAEcSAABREgAAxxIBAAAUBQBFFQEAURUBAGMVAQASFgMAFxYAAGUWAAB0FgEAqBYAAK0WAADZFgAA9RYAAPwWAQAcFwAAIRcAAGYXAQBvFwEAohgAANwYAAD8GAIAkxkBALQZAAC4GQMA2xkCAPgZAAAUGgAARxoAAGQaAABxGgAAvRoBAFMbAABsGwEAcBsBAJkbAACkGwAApxsAAK0bAADFGwEA4RsAABIcAAAxHAAAOhwAAD0cAQBNHAAAUxwBAFccAABfHAQAhRwAAIccAQCUHAAAqhwAALAcAADKHAEA1RwAAFgdAAClHQAA") \ No newline at end of file diff --git a/api/search.index/04d04cf241.js b/api/search.index/04d04cf241.js new file mode 100644 index 00000000..93c469a9 --- /dev/null +++ b/api/search.index/04d04cf241.js @@ -0,0 +1 @@ +rn_("IcIAAIYVqRnFAcIAAMVUG255FhsCxgAAGtRkdLHCAADkFhUBwAAAHdYcYsUa84KGFQHDAAAWChx3VRchwwAABRmWGyUBwwAAE3gbbukb+xvzAMAIiIL2EgAAg4H9EgADxQAAG0fDAAAXVMQAABjbvwL9DSAG4wAADw3LAAAchA==") \ No newline at end of file diff --git a/api/search.index/0534fd2192.js b/api/search.index/0534fd2192.js new file mode 100644 index 00000000..3c0ae4f5 --- /dev/null +++ b/api/search.index/0534fd2192.js @@ -0,0 +1 @@ +rn_("AUI7MAAAAQAA3AACAJYOAAB1ENsA8wABbTswAAABAADcAAIACAgAAEsI2wChxgAAhBZDAP0AFQnCAAAOpMIAAA6lxAAAHJjGAAAZl+z6BgAAtQcAALwOAADBEgAA1RIAAEgVAABEFgAAChcAAFQXAAB2FwAA2BgAAAMZAAARHAAAghARAAAAAPsCcndRwgAAIhgjGKwZgx2HHQX9wxCQAgAAFc4aGlgdFy0cFFsbBQLAAAAVDBgX0xxldAWKwAAAE30WEocVIcIAAIYVqRkFAsAAAMJ5FhfUGmR0W8AAxMsAAByEUcYAAAoYCxirG2UctB2nCgH9mDMCxwAAHX3CAAAVhcgAABrVxQAAHa/jMAoAACMXAACgGgAAvhwAACIAIcAAAPALqBYFAsEAABZNHBjvG252bwP9DbAGxQAAG1DLAAAclsIAAByYzwAAHXfjAI/AAAAHKwEwAQAA") \ No newline at end of file diff --git a/api/search.index/05e71b2d64.js b/api/search.index/05e71b2d64.js new file mode 100644 index 00000000..1111df9e --- /dev/null +++ b/api/search.index/05e71b2d64.js @@ -0,0 +1 @@ +rn_("BcjAggAAABPHFhhlG6dRHAmDwQAAFizQAAAdhSHAAADNC0sMLwP+AEMiCcwAABxlyAAAG7jMAAAcos4AAB1bAUOiYBIAAPuD") \ No newline at end of file diff --git a/api/search.index/06205eed03.js b/api/search.index/06205eed03.js new file mode 100644 index 00000000..e1856551 --- /dev/null +++ b/api/search.index/06205eed03.js @@ -0,0 +1 @@ +rn_("ocMAAJMPocUAAOsZRf3DA4ACAAAlRxxUHTPXG88cYB0jsxrYGxVIHN8bHxx1HYQdMcQAANkY6hmTHPsCY3WXAQD9EYgBwAAAHB7AAAAZwoO5CwAA") \ No newline at end of file diff --git a/api/search.index/0656fbba1c.js b/api/search.index/0656fbba1c.js new file mode 100644 index 00000000..af5ffc5e --- /dev/null +++ b/api/search.index/0656fbba1c.js @@ -0,0 +1 @@ +rn_("FQHFAAAWxxtykhwVAcMAABavGnIqHPODAmJzFQHEAAAWvBlyWRvBxwAAdxf7AnBx+wJvchUBxgAAEGYcc8cYBQLAAABGwxlPHFAcpB0TiRpzdAUCwAAAEtoLIKYOSRZkbjHAAAAFDJAZkRkBRDswAAABAADsAAMAYxYAACwYdQDcHHUA84GD9wIA/RHBDMYAABK1xQAAGhIBPQEAAA==") \ No newline at end of file diff --git a/api/search.index/066ed90be6.js b/api/search.index/066ed90be6.js new file mode 100644 index 00000000..91b0b306 --- /dev/null +++ b/api/search.index/066ed90be6.js @@ -0,0 +1 @@ +rn_("ocUAAJgZBQLAAAARtRYWgBpycwUCwAAAE1caE1gabHAbAsQAABn4Zm4jAAPDAAAYFcgAABzVYmVu4VYQAAA8FQAAEIcDAP4ACpAMwgAAFyfJAAAcKMMAABjcOzAAAAEAACoCBwC/DgEAmg9sALwSAAD5Et8AfRcAAAwZbADYGmwA") \ No newline at end of file diff --git a/api/search.index/067a0a341a.js b/api/search.index/067a0a341a.js new file mode 100644 index 00000000..b574371c --- /dev/null +++ b/api/search.index/067a0a341a.js @@ -0,0 +1 @@ +rn_("ocQAAE4bFQHCAAASFhZsFwxx0AAAph2nHagdqR2qHbMdtR0FAsAAAFm5HLocuxy8HLEdGWUabHIVAcMAABPfGGRDFQUCxQAAFLgaFLkab3QVhMIAAKGOGUIVNQHAAAAQkBtz/RP+E9Ua+wJkdAFE4/EWAAAkGAAA9hgAAGcbAAAUABsCwwAAFvNsb3cBAv0EkQjKAAAbfuUVDAAAjxYAAHkaAABYHAAAwRwAANEcAAAREAHzAMEIyPczEAAA4gYiA2EA9AEsAOsA") \ No newline at end of file diff --git a/api/search.index/06f4a0ea08.js b/api/search.index/06f4a0ea08.js new file mode 100644 index 00000000..190e1d87 --- /dev/null +++ b/api/search.index/06f4a0ea08.js @@ -0,0 +1 @@ +rn_("ockAAMEbFYHEAAARMhpXHAUCwAAAEe0bEVsbcnT7A2FvciHCAADFGFsaBQLAAAAYPhouXx2uHXJ4BQLFAACjvxulNxxwc3cEAP0YMQ3KAAAcOckAABwGywAAHG7EAAAXPQGgCgAABQLAAACznxIVPBdydcHCAACCGgXAxABKAAAayhwX8xsUkhqHKQD9lvEGwAAACrDIAAAaMTD47LknyQAAHVjMAAAcMwMIubpLwgAAGZL29AUAAOQDAQDkCBYEnAM=") \ No newline at end of file diff --git a/api/search.index/07bcf5eb4a.js b/api/search.index/07bcf5eb4a.js new file mode 100644 index 00000000..8a6eb4fb --- /dev/null +++ b/api/search.index/07bcf5eb4a.js @@ -0,0 +1 @@ +rn_("AQICzAAAHaDLAAAdkHJ04lgVAADFGQAAshoAAFAAscgAAA8aO4fBAAAbqgUCwAAAGFMdFKcZcnQhwQAARxYcHCUBwQAAFmIdcsIOSxz7AnJ0+wNhb3IFA8EAADPPEtASHhYUxxsVxBlhcXMVgcEAABSCHY4WKwLBAAAaVHR6BQLCAAAZNRwapRxtdgFEOzAAAAEAANkAAgAMGWwA2BpsABOBg8AAABgTIcQAAIIbdRyr/QUQA8IAABgSxQAAHHPKAAAbvBWIxQAAH68d4RsrAsAAAA8YZmz3GAD9kWkNwgAAHInFAAAZ+8MAABgUwwAAHB46MAAAAQAAAAAALAAQAAAAPwmwC+4L7wsUDMoOKxAsED8VexWRFhUY9xgGGQoZtRnYGfAZ8RnyGfkZ+hkuGi8aMBqmGkkbghuMG44bkBuRG9kb/RsdHE4cZhx1HIMckhyrHGUdfR2eHaId") \ No newline at end of file diff --git a/api/search.index/07e0e5e373.js b/api/search.index/07e0e5e373.js new file mode 100644 index 00000000..ec934248 --- /dev/null +++ b/api/search.index/07e0e5e373.js @@ -0,0 +1 @@ +rn_("McgAAIocixyrHTUBwAAAEnobY5cbmBuUHTHCAAD5GfoZ/RvzggJpcysCwAAAFT9kdMMA/QMJCsAAAAvawAAAFxrGAAAZ+8UAABpt7McSAABlFgAA9RYAAPwYAAC4GQAAFBoAAGQaAABTGwAAVBwAAJQcAACqHAAAyhwAAFgdAAAQAjAAAAAA") \ No newline at end of file diff --git a/api/search.index/0816aa337f.js b/api/search.index/0816aa337f.js new file mode 100644 index 00000000..d8785c1f --- /dev/null +++ b/api/search.index/0816aa337f.js @@ -0,0 +1 @@ +rn_("McQAANoSqxnOHEHJAADjGOQYyRnKGQUCwAAAGakbEtQSc3TDAP1EAQPLAAAcDsYAABoTxAAAFyg7MAAAAQAAfAAEAEgJdQDBCQEABgoAAJMKAwA7/VAgCMEAABxv1QAAHbA=") \ No newline at end of file diff --git a/api/search.index/0898b99c3b.js b/api/search.index/0898b99c3b.js new file mode 100644 index 00000000..ce50124f --- /dev/null +++ b/api/search.index/0898b99c3b.js @@ -0,0 +1 @@ +rn_("FYTEAAATYRcdFjHDAADJG0EceBwFAsMAABUEHBNqG3N1+wJsdpMA/gBMFAjJAAAbjMcAABoOzgAAHHPEAAAWs+dRFQAAYxUAAHQWAAD8FgAA2xkAAHEaAAC9GgAAhxwAABEREBA=") \ No newline at end of file diff --git a/api/search.index/08a1f89cba.js b/api/search.index/08a1f89cba.js new file mode 100644 index 00000000..2cb9c36a --- /dev/null +++ b/api/search.index/08a1f89cba.js @@ -0,0 +1 @@ +rn_("UcAAAOYT5xMZFBoUrhsFg8AAACRVGmEdoy4QBQLDAAAUuBoUuRpvdFvABITHAAAbRlHJAADMGM0YzhjPGFodpQHEAAAW2Bx3uho7xAAk4AAAEr67/REBAsAAABK/") \ No newline at end of file diff --git a/api/search.index/0919f93e2b.js b/api/search.index/0919f93e2b.js new file mode 100644 index 00000000..b9449a1d --- /dev/null +++ b/api/search.index/0919f93e2b.js @@ -0,0 +1 @@ +rn_("ocwAAHEcAQQBxQAAHA9y42YXAABfHAAAiBwAAKUdAAAUAAECAcUAABuBcuJmFgAA9BsAAJ0dAAAUAPuJIcUAANkWxhuhxgAAyxkVAcQAABbHG3KSHLcKAP0WgB3EAAAZ2MUAABnN0AAAHYj1NxYAAEUDCwEJADsABYnAAAAS7RsUVxwrAsEAABpbaXTBwgAAghplA8EAABa/HBMpHBZDHGRpd+kY6hgXGhgaGRr+G7OD/RCBAsYAAByRE4ECwwAAG85wcQEGAcUAABxCcuToGAAAmRwAAIYdAACfHQAArB0AAAQAAAUCxgAAEa4bpFUcbXRrA8EAABKwbG9ysccAAM4ZJQHBAAAXdBxzYhpHG/sCZnAFicAAABaLGxT1FgUCwAAAEucZYUwV/Bn9Gf4Z/xl5HWRmpQHAAACgKxBz1gz7AmRuocUAAOsZRf3DA4ACAAAlRxxUHTPXG88cYB0jsxrYGxVIHN8bHxx1HYQdMcQAANkY6hmTHPsCY3XzhQJhcAUCwwAAFNgYFYUcZWZBwQAAZRYUGlQclBwzAf0QIQHIAAAZvcoAABqy5iIKAABOEAAA0hIAAAsUAABmFQAAaBYAANsbAAAxEAEAGwLgAAAWbG5yFYTCAABoxxrIGskaVR1WHVcdpwsrAskAAByxaXI1AsQAABFYGxPrG2l0ZBdlF5gdGwLBAAAVNG51K4nJAAAbgBUBwwAAFq8aciocBQLFAAAWQBwTfRtobwEA/V/ZAyUmWemIWDpF8Ih8MSMhUSLP5Q6Kf3/bsIodCPE+QBUJl0G2OajGjVICf60X6Tz/SpujFHfuPdQ/wFhFwh3NLMVeOjAAAAEAAAAAAA8AEAAAAB8HNBA/EG4WbxbDGMQYyxh7GaEaohqsGi4cXBzGHMgc") \ No newline at end of file diff --git a/api/search.index/095b71c9a8.js b/api/search.index/095b71c9a8.js new file mode 100644 index 00000000..6808e1ca --- /dev/null +++ b/api/search.index/095b71c9a8.js @@ -0,0 +1 @@ +rn_("AUU7MAAAAQAAXQACAEEQAQDZEVsACYnQAAAdZsYAABj7BQLAAAAffxwcGhxhdCHBAAD0CUoXCQLWAAAds8YAABnuZHQrAsUAABeAZnV3AYH9cSgAzgAAHIDMAAAcG2sDwAAABxllbHY=") \ No newline at end of file diff --git a/api/search.index/096b826784.js b/api/search.index/096b826784.js new file mode 100644 index 00000000..e4cfd81f --- /dev/null +++ b/api/search.index/096b826784.js @@ -0,0 +1 @@ +rn_("ocUAAJgZBQLAAAARtRYWgBpycwUCwAAAE1caE1gabHAbAsQAABn4Zm4jAAPDAAAYFcgAABzVYmVu4VYQAAA8FQAAEAUCwgAApDUaFygcZXQVkMAAABInFxoHQcQAAJ0SJhe2GbcZ5xoA/sAKkA3DAAAdgsMAABjc4AAAB+Q7MAAAAQAANAIPAAAGAQADCAAAvw4BANMOAQCaD2wAdBAAAK8SAAC8EgAA+RLfAI4WAAAWFwAAfRcAAAwZbACyGQAA2BpsAA==") \ No newline at end of file diff --git a/api/search.index/0a139f0cc8.js b/api/search.index/0a139f0cc8.js new file mode 100644 index 00000000..4eea3788 --- /dev/null +++ b/api/search.index/0a139f0cc8.js @@ -0,0 +1 @@ +rn_("VQHDAAAgrhqKG3M3FTgVmRqaGn0bFQLEAAAnVRphHR2NHW1wMBwhwgAAAg9VEPsCYWMjAMIAYMcAABdryAAAGO3jgwMAAM0FAAA0CAAAvAsAAAEQQcYAAHsaQByCHHAdBQTCAAAi5RLDGzFwFnEWThcWxBsUwxZpb3BxIcgAAFscohwFAsAAABDQGBaBHGJvUwD9CDEKwwAAHG/IAAAXX8UAAB1qwQAAGCnjBwcAACUHAABJGgAAkh0AACAARQLCAACklRoTlxpkac0WuhtnHHwd+wJjbPv9kUQA") \ No newline at end of file diff --git a/api/search.index/0a1bdfec6d.js b/api/search.index/0a1bdfec6d.js new file mode 100644 index 00000000..5853d26b --- /dev/null +++ b/api/search.index/0a1bdfec6d.js @@ -0,0 +1 @@ +rn_("FQHFAAAWxxtykhwVAcMAABavGnIqHPODAmJzFQHEAAAWvBlyWRvBxwAAdxf7AnBxQcIAAAYMSxXGFs4aMcAAAAQPBQ85Fe8B/YFBQcEAABU+wQAADrU=") \ No newline at end of file diff --git a/api/search.index/0a2d96e9df.js b/api/search.index/0a2d96e9df.js new file mode 100644 index 00000000..60799bf8 --- /dev/null +++ b/api/search.index/0a2d96e9df.js @@ -0,0 +1 @@ +rn_("UcIAAIcZiBmJGYoZbR1RxAAAbhZvFqEaohrGHBWExAAAFIgdWRoTAALVAAAdrnR1giEQAAAFAsIAABhtHBOvHGNtlwMA/gAIYAfGAAAaRccAABrC5PcHAADRCwAAgBYAALEcAACPHQAAMBAA") \ No newline at end of file diff --git a/api/search.index/0a7158c888.js b/api/search.index/0a7158c888.js new file mode 100644 index 00000000..565572a2 --- /dev/null +++ b/api/search.index/0a7158c888.js @@ -0,0 +1 @@ +rn_("ocMAAEUWIcMAAEQWERwxyAAAihyLHKsdNQHEAAASehtjlxuYG5Qd8wACaWzlxxIAAPUWAAD8GAAAZBoAAMocAABYHQAAECAA+8EEIA==") \ No newline at end of file diff --git a/api/search.index/0a96637815.js b/api/search.index/0a96637815.js new file mode 100644 index 00000000..f6e52545 --- /dev/null +++ b/api/search.index/0a96637815.js @@ -0,0 +1 @@ +rn_("QcgAAOMY5BjJGcoZBQLAAAAXuxsdIBxsdDMA/REBA8EAAAu4wQAAGQPgAAAagfoHCAAAvgYBAEcHCwGcAfMByAEoABIA") \ No newline at end of file diff --git a/api/search.index/0b66f9ee35.js b/api/search.index/0b66f9ee35.js new file mode 100644 index 00000000..e22e70eb --- /dev/null +++ b/api/search.index/0b66f9ee35.js @@ -0,0 +1 @@ +rn_("BQLAAAAa3hsziBWJFVobbXAjAP0REBHGAAAdkMUAABuDwQAAF1TFAAAbhOTsFgAAQxcAAOsYAAAaGgAAjBoAABEAIA==") \ No newline at end of file diff --git a/api/search.index/0bbcca8cf5.js b/api/search.index/0bbcca8cf5.js new file mode 100644 index 00000000..a46440a2 --- /dev/null +++ b/api/search.index/0bbcca8cf5.js @@ -0,0 +1 @@ +rn_("BQLBAAATqBgZshphbAUCwwAAFNgYFYUcZWZBwQAAZRYUGlQclByzAP0QIQHIAAAZveYiCgAAThAAANISAAALFAAAZhUAAGgWAADbGwAAMRABAPMAAWTl+wcAANoOAACREgAAZxUAAIsVAACnHAAAAAUA") \ No newline at end of file diff --git a/api/search.index/0d106aa40a.js b/api/search.index/0d106aa40a.js new file mode 100644 index 00000000..4a931ccc --- /dev/null +++ b/api/search.index/0d106aa40a.js @@ -0,0 +1 @@ +rn_("BQLAAAATfhcTfxdscGHAAABaClsKRxL4GRIcMRwXBAH+AIaGCckAAByYxgAAHFjYAAAdsMgAABusywAAG97LAAAddOw5CQAAVwoAADwQAAAbFAAADhYAANIWAAAiFwAAlhkAAFAbAACpGwAAPBwAAGcdAAB+HQAAEQAwAAAAAA==") \ No newline at end of file diff --git a/api/search.index/0d46f4b6ba.js b/api/search.index/0d46f4b6ba.js new file mode 100644 index 00000000..a45b091b --- /dev/null +++ b/api/search.index/0d46f4b6ba.js @@ -0,0 +1 @@ +rn_("AUDmsQsAABoWAACeGwAA4BsAABccAACMHQAAlh0AABAwAADBxAAAhRuhxQAAExcHFgP9NaEDxwAAHaPDAAAcGcMAAByqxQAAHWnCAAAYIMEAABBA//gJAABQCNkDAQDoAQEA6QG3AW0ATQALABQAEQDdAEIA") \ No newline at end of file diff --git a/api/search.index/0deef0af69.js b/api/search.index/0deef0af69.js new file mode 100644 index 00000000..667b098b --- /dev/null +++ b/api/search.index/0deef0af69.js @@ -0,0 +1 @@ +rn_("VQLDAAChKhigrxZpc8oSyxLMEs0SuBoFkcAAAOIkFKTPFsHBAACWDzMAA8IAABQqaW5y/DgIAACVAQEA1AQBAE4A5wJvBSUApgCWACIA+wJudQ==") \ No newline at end of file diff --git a/api/search.index/0e0dee73c0.js b/api/search.index/0e0dee73c0.js new file mode 100644 index 00000000..2375b13a --- /dev/null +++ b/api/search.index/0e0dee73c0.js @@ -0,0 +1 @@ +rn_("MccAAKIboxuXHTUBxQAAFokbd3MWwxzHHAUCwQAAp+EZR+MZ5BmAHYEdcnP7AmV0+wJldAUDwwAAGFQcEaUcNmEafBx+HGJlcwUCxgAAIE4cnh1iIxwkHCUcJhxbHaEdc3UrisUAAB2wocQAADcVBYXAAAAVZhUV4hbnKAD9j5ETyQAAGq7EAAAWOccAABjzwwAAFx3FAAAbfMMAABU55yMHAAARFwAAHRoAAE4bAAAGHAAAph0AALMdAAC1HQAAEAEEAA==") \ No newline at end of file diff --git a/api/search.index/0e129ff076.js b/api/search.index/0e129ff076.js new file mode 100644 index 00000000..3069f1d2 --- /dev/null +++ b/api/search.index/0e129ff076.js @@ -0,0 +1 @@ +rn_("McMAADEVMhVuGiUCxAAAFs4bMHwZkBrLGnJzNBBcHGUBwAAAGdIYdEUI4hvjG+Qb5RucHRUBzQAAELccc7EaBQLAAAApYxohHB1FHGxyAUOOYhAAAAFBjtsLAADRxAAAcBUFA8YAABYSHCWwGrYcGH0cZXN0+wJmchc/Af0toQfDAAAS58cAABwdxwAAGPjj7QYAACYHAAAOFwAAoxoAAEQAocIAANISBQLFAAAWQBwTfRtobzOCi8EAABtH+wNhdXk=") \ No newline at end of file diff --git a/api/search.index/0e2b2f16ca.js b/api/search.index/0e2b2f16ca.js new file mode 100644 index 00000000..2800528e --- /dev/null +++ b/api/search.index/0e2b2f16ca.js @@ -0,0 +1 @@ +rn_("ocMAAEIWQcYAAPAZ8RnyGWUdJQLAAAAW4RsaUx1hdJEWfR0FAsEAADSXG5gblB04ihyLHKsdaWwxyAAATxxQHKQdswP9GBACygAAHVLySxcAAB8DBQLAAAAUBBkVPRxhbPMCwALB1KYcAAAHBugFAsAAABCsEhZAFnJzBQLAAAATshinGhhsdnuexQAAFqQ=") \ No newline at end of file diff --git a/api/search.index/0ec21d7a53.js b/api/search.index/0ec21d7a53.js new file mode 100644 index 00000000..9db27a33 --- /dev/null +++ b/api/search.index/0ec21d7a53.js @@ -0,0 +1 @@ +rn_("FQLEAAAVihuj9BhjbK0aEwACzAAAHKxhY4UeDAAAocoAAN8ZBYrBAACiLxalwRjhwwAALBQBQYgHEAAAocUAAJMZFYLEAAAUUxtpGyHGAADgG4wd+wJwczHOAADSG60dsh3XCwD9NeEEzAAAHbTDAAAaPsUAABxnOjAAAAEAAAAAABoAEAAAABcIOggIFAkUChQrFpwWnRaeFp8WExjtGfcZARoCGgMaBBq2GoAbBRxaHIocixygHHodiR2rHTUCwAAAxL8WFhAcZXdtF24XqBkBQegJCAAAKAkAAC0QAABEEAAA8RMAAPEWAAAkGAAA9hgAAGcbAAAUAUFAADHAAADYC9kLkw7XDQDDBOXBAAAJP+AAAAcZgw4HAAA=") \ No newline at end of file diff --git a/api/search.index/0eda3a5286.js b/api/search.index/0eda3a5286.js new file mode 100644 index 00000000..5cf4914f --- /dev/null +++ b/api/search.index/0eda3a5286.js @@ -0,0 +1 @@ +rn_("AQQBxQAAHA9y42YXAABfHAAAiBwAAKUdAAAUAAECAcUAABuBcuJmFgAA9BsAAJ0dAAAUAPuJKwPEAAAZ2NAAAB2IZXR1") \ No newline at end of file diff --git a/api/search.index/0ef07bb638.js b/api/search.index/0ef07bb638.js new file mode 100644 index 00000000..7c2496c8 --- /dev/null +++ b/api/search.index/0ef07bb638.js @@ -0,0 +1 @@ +rn_("ccAAAMQJxQnGCccJzhZqHKgcBQLBAAA0YRp8HH4cddYY1xjoGekZ/Bt2HXgdc3TBwwAAdxcFAsAAABoZHBClHHBysccAANMYNQHDAAAWZR1zphyzHJsd8wQBZNK3GwAA1ucJAP0eOALHAAAcVMkAABwXyQAAHBiC6gsAACHAAACzGtgb+wJuefuD") \ No newline at end of file diff --git a/api/search.index/0f59dc5171.js b/api/search.index/0f59dc5171.js new file mode 100644 index 00000000..b8d3a234 --- /dev/null +++ b/api/search.index/0f59dc5171.js @@ -0,0 +1 @@ +rn_("ocUAAHQXpYPAAAASUBcWdRutEgUCwgAAFcUbFHQbY3MFisAAABgIHBsRHKUCwwAAFMMaFtYbc3cBFxsCxwAAHAdhbPMAwhZA4SQQAABCFwAAEA==") \ No newline at end of file diff --git a/api/search.index/0f7bbb39c0.js b/api/search.index/0f7bbb39c0.js new file mode 100644 index 00000000..9a0dc374 --- /dev/null +++ b/api/search.index/0f7bbb39c0.js @@ -0,0 +1 @@ +rn_("occAAMwbIwD9MAAFyQAAHXHFAAAcQscAABzL7MYLAABDDAAAeBYAABsXAADBGAAA6BgAAMYZAABoGgAAkRwAAJkcAACGHQAAnx0AAKwdAAAxABAABAAA") \ No newline at end of file diff --git a/api/search.index/0fb2f636e1.js b/api/search.index/0fb2f636e1.js new file mode 100644 index 00000000..cff4db0c --- /dev/null +++ b/api/search.index/0fb2f636e1.js @@ -0,0 +1 @@ +rn_("UcMAAG4WbxahGqIaxhwVAcIAAB3bHHIxGsHHAAAOGCHDAAD4EggcBQLDAAATBxkW1hpmaxUBxgAAFR4ccw0YQwADwAAAFX3DAAAYBGFjb/adDgAAAQCDCFAEyQAZAAUCwQAAGx0cGY4bYmz3AQD9KYEOxAAAHATDAAAVi8IAABtq8jMJAAB/D/sDZWly") \ No newline at end of file diff --git a/api/search.index/11a84d7ac3.js b/api/search.index/11a84d7ac3.js new file mode 100644 index 00000000..32d7e4db --- /dev/null +++ b/api/search.index/11a84d7ac3.js @@ -0,0 +1 @@ +rn_("9QLBAAAzXRdfF70ZFAYcc3YYCCWCwAAAFQoZSRC3HKHBAADmE6HEAAB5HKcCAQcWSbiFUsgAABx7xAAAGQnFAAAaxDBlaW1vc3bsrwsAAPcLAAALDwAApBYAAOMWAADOGQAAEhoAAEgaAABtGgAAsRoAAHIbAAAWHAAARhwAAA0QMgAAAAABQeGjGAAA/xgAADP7A25vdg==") \ No newline at end of file diff --git a/api/search.index/121bd2064d.js b/api/search.index/121bd2064d.js new file mode 100644 index 00000000..4b8dbb65 --- /dev/null +++ b/api/search.index/121bd2064d.js @@ -0,0 +1 @@ +rn_("AQGBwQAAHA3tWBcAAN4ZAABNGgAAVhoAAF4aAAC8GgAAhBsAAIkbAADWGwAAChwAABAcAABDHAAAdhwAANccAAAAAAAAAQAS") \ No newline at end of file diff --git a/api/search.index/124b4634f7.js b/api/search.index/124b4634f7.js new file mode 100644 index 00000000..d3849e11 --- /dev/null +++ b/api/search.index/124b4634f7.js @@ -0,0 +1 @@ +rn_("ocQAAE4bcdAAAKYdpx2oHakdqh2zHbUd8wACc3b3MxAAAOIGIgNhAPQBLADrAAWMwAAAF0UbNOoW6xYCHBUBxAAAFnwbchEXZQHCAAAQhR1zIxwkHCUcJhxbHaEdBYnAAAAiRxYcHBlTHQUDwAAAExIYFnMcExMYYW1yFYjGAAAfrx3hGzcCAP0RQQ3GAAAdgsMAABgUxgAAGcTDAAAcHvvKDgAAxwdmApcCAwBIAEQAMQAYABcBIQAFAsAAABGDFhCVGW93K4rDAAAW6RUCxgAAJHsbUh0WCxxzd1oXFQHHAAAWYh1ySxwTAgLFAAAat2FyAYIWAABLwASE5gAAE9/BAAAVPaHDAABFFiHDAABEFhEcMcgAAIocixyrHTUBxAAAEnobY5cbmBuUHfMAAmls4fwYAABkGgAAIPvBBCBByAAAwxlPHFAcpB2hxAAADxcFAsAAABMOFxNpGmxzAQL+wE/EHxX6MZTSaGAVGA/HAAAcU0y/C7L0XUyOKE3EAAAbpz23FOGEFxZ0qInDAAAbXis+d10HbBDy8fXCAAAVPl2CeJ59WXLWWDNTLPI7uu3xCwAAOBYAACkXAABLFwAAexcAAIEXAAANGAAAvxgAAMcYAAALGQAALRoAAGoaAAB2GwAAgRwAAFAAEAAAAABRxwAAnBmdGZ4ZnxluHaHJAADjGPMBAXI7MAAAAQAAdwACAEgJdQDBCQEAa8EABsEAABt3+QMABBtpb3k=") \ No newline at end of file diff --git a/api/search.index/1334874d83.js b/api/search.index/1334874d83.js new file mode 100644 index 00000000..5193b347 --- /dev/null +++ b/api/search.index/1334874d83.js @@ -0,0 +1 @@ +rn_("BQLBAAAUXB0kwRpkHW1u8wUBY/LFFgAAAwUFAsAAABFjGxbJHGV0GwLDAAAawGV0E4QCyAAAHVlzdCsCxwAAG2JucxsCwwAAHZFldCHGAADeG24cFwYA/gBDIgnRAAAcsMMAABypyAAAHDDLAAAcMuQtCAAA0QkAAKsaAACQHAAAmh0AADEAAA==") \ No newline at end of file diff --git a/api/search.index/13592a7599.js b/api/search.index/13592a7599.js new file mode 100644 index 00000000..5167ab45 --- /dev/null +++ b/api/search.index/13592a7599.js @@ -0,0 +1 @@ +rn_("Vf3ECAkCAAAbsRwU5xkXixutcRzDGMQYrBouHMgcBQLAAAAisxrYGzjSG60dsh1scqHEAABOG3HQAACmHacdqB2pHaodsx21HfMDAnN2AaMdAAChwwAA9Bg1AcMAACCuGoobc5kamhp9G0HGAAB7GkAcghxwHfsCaG8FhcAAADLXG88cYB0VdxYhxQAARxxUHXcuAP03yAbFAAAchM0AABwTAip4zMjp8wYAAO8WAACtGgAA3xsAAB8cAACsHAAAuRwAAHUdAACEHQAAsR0AADAAADAA") \ No newline at end of file diff --git a/api/search.index/137d601df6.js b/api/search.index/137d601df6.js new file mode 100644 index 00000000..f77d12e6 --- /dev/null +++ b/api/search.index/137d601df6.js @@ -0,0 +1 @@ +rn_("ocQAAE4bFQLEAACmyxkWzRljcoca0cYAANoWMcMAAIAWgRaxHLHJAAD8GBMBAsIAABFRZHXmnRIAAP8TAAAxFQAADxcAACYXAAC2GQAAbhoAAAARAQAVAcEAABIWFmwXDL8D/ZFBBcYAABx9xQAAG3NxzgAAph2nHagdqR2qHbMdtR0lhMEAABPsGEMQ1xoFksAAACC4DoYSUfsWUBqwGrYcbx0rAsoAAB1/cnS7/RFAAcoAAB1zAQQBwQAAG39r+l4XAAB4AQEAEQEBABMCkwAUANMAAgAFAsEAABZoHRWmG3J0+wJzdFWExAAA0asYUxdhGsYafBx+HMv9AQgGwgAAGjTNAAAckFMAA8AAABdoZW100+4YAADp6wEChccAABw8wwAAGqfl/AkAAOAWAADAGAAAfRkAAI0ZAADWGgAAEQAABQPAAAAYdhek0xsadx1lcHQFicEAABKdHRSlHSOHAsYAAB2scHMxwQAAeRvaG9Ac84MCZXD7AnBzwcQAAP8Y+4clwMQKIAAApuUYE6oYFucYhRIcGhUBwgAAIhIX3BtrPhAjAIbEAAAWSNIzCgAAKQWKwAAAozQXGGUaUckAALkcuhy7HLwcsR0BQOX/BwAAVBIAAPMYAABPGgAAdhoAAKwbAAAwABBRwwAAShtLG0wbTRuLHQFBOzAAAAEAANwAAgBCCAAAuArbAMcBAP0MAQPIAAAXA80AAB2eOzAAAAEAAP8BFwCKAwAAqQsAAKoOAACyDgEAtQ4AAPcOBwAGDwMARBIAAF0SAgDREgAADhQBAD8UAQBFFOsAgxUBAB8WAQA1FgAAYxYAAAIYAQAsGHUA4BgCAEgbAADrGwAA3Bx1ABUBwwAAEGEbc1IQ+/0RCAUFAsAAABtuHbU2F2V0BQLAAACwFA8mqRgyHGVz+wJucCHJAAAYHJUcBQLAAAAS0gskwRnMGml0Ff3EDBEAAAAbeBwUNhwTchYXchsIGQUCxQAAMXAWcRZOFxbEG29wocQAAAwa+wJscgXAwwoIAAAYcRsVtBkZwxtVAcMAAKBRG3NEFecb6BtRHFIcFQHIAAAUOxxjsxn7AmVyJYHBAAARHxcFFG0bocQAAKoSBQLAAAATYBASjh1udQUCwgAAGzocFMkYbnL7AmxvAQD+QB/lA8YAABnHQuCebLwZf86sGgKSMNL3yQAAGkZ76hJZW8EAABvLeLyv9YxsXyGMzBxSlOIxxAAAGcAMG8BkpMYAABdP5ocDAAALBgAAORcAABYYAABpGgAA8hsAAIIdAAABEAAAFQHCAAAX1hxyGBb5/ABUAAEQNQHFAAAW1xx3tRqNG6QcBQPBAAAXoRwYZx0afh1mcnWlAcMAAKZ2HHfUGRsC4wAAEodheTvEACTHAAAcFzsD4AAAGsphb3M1AcIAABWqG3OXCpgK2hwFwMEARgAAGPEbE7MYFQkcscMAALwWxQLAAAAX0BsWfB1ydHIaocEAAKIZ8wICaXPiUBUAAHwaAAB+GwAAAwArAuQAABQUZHQVAcUAABbPG2SxGCcaAv1dMQHGAAAaicQAABiywwAAFmrGAAAaisYAABqL5r8LAAAlDAAAdxcAAAYYAAAOGAAAuxgAALIbAABhMzMw+4wVAcIAACDXGcIac9EbJQHCAAAWYxdyEg+hGQFBOzAAAAEAANwAAgAYCgAAtA3bAEHDAAD2FvcWmhmbGRMAjMYAABuk5QgKAABKEgAAxBIAAKEWAACLGQAADhoAABIAAPsCZ3QBRuEfGgAARRwAALAVAcAAABbHG3KSHCMAAsUAABqvcnPhVhIAACocAAAg+8AEhgWMwAAAJWMaIRwXVBoFAsQAABqhHRiXHWRzBQLDAAAdtB0XjB1jZRsCxAAAG6VydBv9EAEMyQAAHXvDAAAcSsgAAB1tBQLDAAAUbB0Veh1pbUOBwAZAzwAAHbHMAAAdnAUCwAAAFPobGnkdZWwFAsAAABagHBuVHWJ2K4XIAAAdaxUBwAAAF4sdYrYb8wIBcwFkGwAAKwLFAAAcn210BQLBAAAUlB0Yqx1pbCOHAscAAB2kZGUBhALTAAAdtccAAB2Tb3MBgv5AX6oJd51Iz8PGAAAcZDmKsL0d0QAAHZnHAAAcnsQAABv5EwjD/3loQ433xMwAAB2WVFQ2pMtK7ambICnlimA6cpDA/UoBQjswAAABAADcAAIAFwoAANgM2wAVAcMAABPfGGRDFQUCxQAAFLgaFLkab3QVhMIAAKGOGUIVNQHAAAAQkBtz/RP+E9Ua+wJkdAFE4/EWAAAkGAAA9hgAAGcbAAAUABsCwwAAFvNsb3cBAf0EkQjKAAAbfuUVDAAAjxYAAHkaAABYHAAAwRwAANEcAAAREAH7AmlyFQLFAAATiBoW8Bttc7sWJYLBAAC00RlPFQAatQHAAAAWXhp31xLzgZIbicAAABQTBYzAAAAbxxwUHhoFA8AAABgvHBvFHBvGHGx0dQUCwgAAF8QcFC4cc3QTgQLMAAAdeGR02wTJAAAcXmFjc3YVAcEAABQdGnYSFPsCZXRb/RAEFMMAABcqzQAAHMgFwMMASgAAGlgdFy0cFFsbS8ACiskAABvvywAAHJbPAAAddxuKwAAAFwoFAsEAABtdHBSwGGx2Ff7CQEBIAgAAGVwcE6EZGpUcFBwaFYcaERRrA8IAABYnYWxyBQLAAAATGxofiR1hcksDxAAAGobHAAAcLGVsdAUCwQAAGFkcGVocY3AFAsEAABrCHB12HWR0NwGC/kAFQAnHAAAb7sMAABmgywAAHMOhygAAXBqhwwAA1RJrxQAk0QAAHYTQAAAddQUCwgAAG1gcGiscYW0FAsEAAB3BHCbrGBoaZnQBhALPAAAdm8gAABzAYXM7A8gAABtaYW95BQLDAAAyaBZpFtsbG5McZWcFAsMAABMqHBWSHGJzI4MCxAAAG1lvcgEA/V/4Bxv1KgRIYV2/sdNza9y4+CccbwejbmX9hFQC7/HOwMwAABxbyQAAG6/CAAAUEM0AAByUc6uBpRYQggrGDh9gpCt4UwSvO2vjyAkAAAwaAAA1HAAApRwAADEAocMAAJAZUcQAALkcuhy7HLwcsR3zAAJhZOftDgAAShAAAAEUAABFFQAA2RYAAGwbAACtGwAAxhsAADMxAADBwQAAtRg/Af0JoCHEAAAcEskAABwZwAAADrkVgsYAABFkGjgWBQLCAAARyhoSaBdtc7HFAADTGDvAAkHDAAAak/uKNQHDAAAWZR1zphyzHJsd8wIBZOOCEgAAHxQAALcbAACNHAAAIgBLA8MAABYmwwAAEsNtbnUFAsEAAB2KHTWIFYkVWhthcgUCwgAAGUQcFbQcaXMVgsMAABQ/HIYc+4N7/YBIAcMAABozFQHCAACp3RhyRwkBBAHFAAAcD3LjZhcAAF8cAACIHAAApR0AABQAAQIBxQAAG4Fy4mYWAAD0GwAAnR0AABQA+4kbAtAAAB2IdHUFicAAABLtGxRXHCsCwQAAGltpdMHCAACCGmUDwQAAFr8cEykcFkMcZGl36RjqGBcaGBoZGv4bs4X9EIECxgAAHJEBBgHFAAAcQnLk6BgAAJkcAACGHQAAnx0AAKwdAAAEAAAFAsYAABGuG6RVHG10+wJvcrHIAADOGQUCyQAAFJIdFZodbHIVAcIAABuHHXQWGiUBxgAAFtkcd78a7BsFAsIAABa+HBuRHWNyocYAAL0atwMC/UQRBswAAB2D4U0VAAC3FgAAEyuJxwAAG61hwwAATBX8Gf0Z/hn/GXkdQcQAAGUWFBpUHJQcNQLFAAARWBsT6xtpdGQXZReYHSuJyQAAG4DzAv0U0QPSyxgAALBbA88AAByOYWVyFQHIAAAWzhtyXBylgcAAABHMHBwY8wMBc4KrDgAApQHCAAAZrBttEBZ7/QYICMEAABmlAQD+wP//DxA/NZOYXMQCJ5Emp83dVhliLdQmJkmdDPc6iytsjhuLjxuTZ6hFqMcpx96DrcoAABzMEcJBG+dOXf2g/3CuuEYFL7Moz/hC98qDmz2VXUWNSul2iNVjqdoYXTmekv0OfuZbspPMAAAdWnnogJnAOzAAAAEAANwAUAAsAQAAPAEAAJIDbADwBQMA+QYAABgHAAArBwAAtAcAACkIAAD3CQAA/gkAAAoKAABWCgAAYQoAAIEKAQCdCgIAsAoAAOoLAQBADAIARgwAADMQAABJEgAAhxIDAIwSAwCwEgAA5hIAAN4TAAARFAAARxUAAH4VBAAuFgAAMhYAAD8WAQBqFgAAjRYAAKcWAACuFgAAEhcAABUXAAAaFwAASBcAAGAXAADdGAEA4xgBAO8YAADJGQEA2RkAAOMZAQDrGQEANxoAAEMaAQBMGgAAUxoAAGMaAABsGgAAkxoBAJgaAADPGgMARRsBAH8bAAC9GwAAvxsBAMsbAADRGwAA3BsAAP8bAAAaHAAAIRwAACgcAACMHAAAuBwAAL0cAAC/HAAAxRwAAM0cAABfHQAAgB0BAIodAACjHQAArh0AAA==") \ No newline at end of file diff --git a/api/search.index/1454ed7163.js b/api/search.index/1454ed7163.js new file mode 100644 index 00000000..7de0b655 --- /dev/null +++ b/api/search.index/1454ed7163.js @@ -0,0 +1 @@ +rn_("pcHAAEYAABk1HM+FGxxoHBqlHEAIsccAALkZGwLSAAAdj2VzBQLDAAAZNRwapRxtdgUCwQAAE9MWEjIWcHSHAYHABmjIAAAao8YAABjHywAAHABbwAChwAAAG3MxwQAAvg4eGHMcFYTBAAATZBZ4GgWGwAAAFXUcE1QaIwECxQAAGkdyc/IjEAAAWgy/DP0AyB/EAAAWbckAAB2P4AAAGfs=") \ No newline at end of file diff --git a/api/search.index/145bcd88ba.js b/api/search.index/145bcd88ba.js new file mode 100644 index 00000000..6d675f8d --- /dev/null +++ b/api/search.index/145bcd88ba.js @@ -0,0 +1 @@ +rn_("QcAAAOMZ5BmAHYEdFQHBAAAnLRp2G2RFDBOBAsAAAAxGZXIrAs8AABx+bnMFAsAAABGiFhNAGnJzCQLQAAAdrcIAABtraXYrAsoAAB1vY3IbhsEAABmyBQLAAAAdjh0TpRpucisDwQAAGbHNAAAdhmFlbBUCwgAAFWocIBMaPxpwc7AZKwLgAAAWfmR0BQLDAAAWXh0TaRxzdSOBAscAABynY3RfDf0FiQvFAAAcvscAABwFygAAHZErAskAABu6bnS7/RABSMIAABjt") \ No newline at end of file diff --git a/api/search.index/1479a56c48.js b/api/search.index/1479a56c48.js new file mode 100644 index 00000000..3f0454dd --- /dev/null +++ b/api/search.index/1479a56c48.js @@ -0,0 +1 @@ +rn_("ocgAAAoaAQDCwADIAAAc2sEAABt/xQAAG4nvUAwAAD4WAABzFgAABBcAAF4XAADWGAAA6BkAAJwaAAClGwAA/BsAAI8cAACjHAAAwhwAAMccAAB2HQAAeB0AADAFARMQACAAIwACyAAAGeVmc+KjGAAA/xgAAIIaAAAzMHUBwgAAxVQbbjwUPRQ+FHkWehZ7FnwWrwHAHsDQAAAdXcAAABuPxgAAGtQ=") \ No newline at end of file diff --git a/api/search.index/14afe7e309.js b/api/search.index/14afe7e309.js new file mode 100644 index 00000000..abd492b6 --- /dev/null +++ b/api/search.index/14afe7e309.js @@ -0,0 +1 @@ +rn_("FYTFAAATqRm+HBUBwgAAFs8bZLEYE4ECygAAG5ZhZAXAyAIiAAAVhhwWpBwX0BwrwAiEwQAAEjjCAAAUNQUCwAAAEDwXFjEaZXD7Am1uBYbAAAAc4BsSdBChxwAAcRwhxgAARxWuFlHGAADPGtAa0RrSGoodFYLBAAARFxauHBsC4AAAG3BjZAUCwAAAVP4V/xUAFgEWZBwYyBxudXcHAP5AJMgB0AAAHWmCixYAAAUCwAAAEhYXI3MX5htpdBUBwAAAExkYcMUbQcUAACIcWRxeHNQccwLCBkDFAAAZ3uFbEAAAOhQAAAEbwAKIyAAAHFrKAAAby/MA/YFACQE6AQAA") \ No newline at end of file diff --git a/api/search.index/1514d573d9.js b/api/search.index/1514d573d9.js new file mode 100644 index 00000000..57d36cea --- /dev/null +++ b/api/search.index/1514d573d9.js @@ -0,0 +1 @@ +rn_("AUPkhhYAAN8ZAAA5GgAAXBoAAGAaAABBIQBBwAAAFAz5GfoZ/RvzAAFzOzAAAAEAACIABABjCh0A6RMAAI4ZAQCiGQEAW8AEhMEAABjs") \ No newline at end of file diff --git a/api/search.index/15e7900241.js b/api/search.index/15e7900241.js new file mode 100644 index 00000000..d6ab409a --- /dev/null +++ b/api/search.index/15e7900241.js @@ -0,0 +1 @@ +rn_("Jf7AAEBwAgAAIuUSwxsxcBZxFk4XFsQbFMMWdGcVaBVpFWoVaxVsFacctQo9FwUCwAAAEZAdEn8WY3RjAP0SAAbEAAAcbccAABwT4a8WAABqHQAAEA==") \ No newline at end of file diff --git a/api/search.index/1623dd104b.js b/api/search.index/1623dd104b.js new file mode 100644 index 00000000..58453f5c --- /dev/null +++ b/api/search.index/1623dd104b.js @@ -0,0 +1 @@ +rn_("FYHDAAAUFhjLDqHGAAAMF0HDAADZE2EVGhgbGEHBAACfDscOyA7JDuMA/UChBsMAABZ+wwAAFn/IAAAabuU1CQAAogoAANURAADkEgAAVhcAAGkXAAAQIBH7AmV0") \ No newline at end of file diff --git a/api/search.index/163e46064d.js b/api/search.index/163e46064d.js new file mode 100644 index 00000000..9eb141ac --- /dev/null +++ b/api/search.index/163e46064d.js @@ -0,0 +1 @@ +rn_("BcjAggAAABPHFhhlG6dRHAmDwQAAFizQAAAdhSHAAADNC0sMJwOB/gBDIgnMAAAcZcgAABu4zAAAHKLOAAAdWxUBwgAAF+YZcNEW+wJ1dg==") \ No newline at end of file diff --git a/api/search.index/1649b88552.js b/api/search.index/1649b88552.js new file mode 100644 index 00000000..76b5fa7f --- /dev/null +++ b/api/search.index/1649b88552.js @@ -0,0 +1 @@ +rn_("BfzADwAAABBfFhBgFhBhFhBiFgX8wP8DAAAQVRYQVhYQVxYQWBYQWRYQWhYQWxYQXBYQXRYQXhYF/MD+AwAAEEwWEE0WEE4WEE8WEFAWEFEWEFIWEFMWEFQW+/wHAA==") \ No newline at end of file diff --git a/api/search.index/16ee865867.js b/api/search.index/16ee865867.js new file mode 100644 index 00000000..d7288a82 --- /dev/null +++ b/api/search.index/16ee865867.js @@ -0,0 +1 @@ +rn_("QcMAABgMGQwaDIsbMcQAAMkbQRx4HBMAAskAABuMZWbnURUAAGMVAAB0FgAA/BYAANsZAABxGgAAvRoAAIccAAARERAQEwACwwAAHD9kZQGGHAAA+5I=") \ No newline at end of file diff --git a/api/search.index/172ca71682.js b/api/search.index/172ca71682.js new file mode 100644 index 00000000..997cf612 --- /dev/null +++ b/api/search.index/172ca71682.js @@ -0,0 +1 @@ +rn_("BYnBAAASnR0UpR0jjQLGAAAdrHBzpQHEAACmdhx31BkTgQLIAAAcoW5yBQLEAAAaoR0Ylx1kcwUCxAAAHbQdF4wdY2UblskAAB17yAAAHW0FAsMAABRsHRV6HWltQ4HABkDPAAAdscwAAB2cBQLAAAAU+hsaeR1lbAUCwAAAFqAcG5UdYnYrhcgAAB1rFQHAAAAXix1ithvzAgFzAWQbAAArAsUAAByfbXQFAsEAABSUHRirHWlsI4cCxwAAHaRkZQGEAtMAAB21xwAAHZNvc1f2gv5AW6oJxgAAHGTHAAAcnsQAABv5zAAAHZYFAsAAAB+KHRQzGmxyVwUA/UxSC8UAABqTygAAHMzMAAAdbsQAABo0zAAAHVrmjBIAAOYSAABIFwAAYBcAAN0YAADjGAAAyRkAADAAERAFAsAAABErFlGjFsgYrhlzHX8dZW5TAAPFAAAc12l3efO1GgAA2AAXAQ==") \ No newline at end of file diff --git a/api/search.index/1797af3460.js b/api/search.index/1797af3460.js new file mode 100644 index 00000000..d8f02419 --- /dev/null +++ b/api/search.index/1797af3460.js @@ -0,0 +1 @@ +rn_("UccAAEobSxtMG00bix0TAP0SQAjGAAAcgMUAABdcyAAAG5nk1Q4AABMaAAA/GgAAihoAALYbAAAwAACDAv0EgQLDAAAXF8AAABPpxAAAHGriBwwAALAZAABkGwAAMAAVAcEAAB3bHHIxGgkC0AAAHV3AAAAbj2NmBQLCAAAWWxdSlxaYFpkWmhafHHJ1G4PgAAALrWHCAADJFsoWyxbMFj8XQBffA/0McAThAAAWfg==") \ No newline at end of file diff --git a/api/search.index/179e526931.js b/api/search.index/179e526931.js new file mode 100644 index 00000000..7f275973 --- /dev/null +++ b/api/search.index/179e526931.js @@ -0,0 +1 @@ +rn_("NQLCAABSoxbIGK4Zcx1/HRbXHGl3tRqNG6QcBf3AISAEAAAVFBwR3xIiGBe0GBd7HAFA4xEPAACWDwAAlxIAAKsYAAADFPvABIQ=") \ No newline at end of file diff --git a/api/search.index/17b47436be.js b/api/search.index/17b47436be.js new file mode 100644 index 00000000..280ada9e --- /dev/null +++ b/api/search.index/17b47436be.js @@ -0,0 +1 @@ +rn_("BYnBAAASnR0UpR0jjQLGAAAdrHBzpQHEAACmdhx31BkTgQLIAAAcoW5yBQLEAAAaoR0Ylx1kcwUCxAAAHbQdF4wdY2UblskAAB17yAAAHW0FAsMAABRsHRV6HWltQ4HABkDPAAAdscwAAB2cBQLAAAAU+hsaeR1lbAUCwAAAFqAcG5UdYnYrhcgAAB1rFQHAAAAXix1ithvzAgFzAWQbAAArAsUAAByfbXQFAsEAABSUHRirHWlsI4cCxwAAHaRkZQGEAtMAAB21xwAAHZNvc1f2gv5AW6oJxgAAHGTHAAAcnsQAABv5zAAAHZYFAsAAAB+KHRQzGmxyVwUA/UxSC8UAABqTygAAHMzMAAAdbsQAABo0zAAAHVrrYQoAAIwSAADmEgAASBcAAGAXAADdGAAA4xgAAMkZAABjGgAAzxoAACEcAACKHQAAAwABEQMA") \ No newline at end of file diff --git a/api/search.index/182cd60ca3.js b/api/search.index/182cd60ca3.js new file mode 100644 index 00000000..844c85bb --- /dev/null +++ b/api/search.index/182cd60ca3.js @@ -0,0 +1 @@ +rn_("FQHIAAAQmR1zUhoTgwLhAAASNWNzocgAAHYaBQLAAAARPxU1+Rn6Gf0bZHQjAAPAAAAXGsYAABn7Ymlzg7kZAAAxzAAADBwnHF0ds4H9jAEAxAAAFkn7AmRz") \ No newline at end of file diff --git a/api/search.index/183621b5a3.js b/api/search.index/183621b5a3.js new file mode 100644 index 00000000..f114afd8 --- /dev/null +++ b/api/search.index/183621b5a3.js @@ -0,0 +1 @@ +rn_("AQAB4AAADFRy7NULAABYEAAA3xIAAD0VAABMFQAAZBYAAPQWAABZFwAA0hgAAOwYAAD8GQAAjxsAAHkdAAABAAAAADAABQLBAAAZ0xoVBRhxdAEAkMkAABzB6y0JAAAaCgAARhAAAGAQAAA4EgAAtxIAAN8WAADzFgAAIhwAAFkcAABeHAAA1BwAAEAQAgAAAFvAECXEAAAbmcYAABwOBQLAAAARmxYbAxxzdvuN") \ No newline at end of file diff --git a/api/search.index/188672d80d.js b/api/search.index/188672d80d.js new file mode 100644 index 00000000..c548c4b6 --- /dev/null +++ b/api/search.index/188672d80d.js @@ -0,0 +1 @@ +rn_("BQLAAAATfhcTfxdscGHAAABaClsKRxL4GRIcMRwnCAD+gIaGCcMAABLiyQAAHJjGAAAcWNgAAB2wyAAAG6zLAAAb3ssAAB107TkJAABXCgAAPBAAABsUAAAOFgAA0hYAACIXAADyGAAAlhkAAFAbAACpGwAAPBwAAGcdAAB+HQAAEQAwAAAAABuGygAAGOw=") \ No newline at end of file diff --git a/api/search.index/188846429b.js b/api/search.index/188846429b.js new file mode 100644 index 00000000..3fdb5c6a --- /dev/null +++ b/api/search.index/188846429b.js @@ -0,0 +1 @@ +rn_("RQHCAAATgBpuQhpqG2kcaxwBQeXsCwAAlxYAALUWAACHGQAAnxwAAG0dAAADAwAFAswAABaYHRefHWNwIcUAANwSghkhxgAA5xi8GQUCxgAApLAbFjEcY2UrAswAAByIbHKhxQAAVhUbldMAAB2exQAAG3IFAsAAABU5FxKqGG9yBYvAAAASNhwUXxoRug4FAsAAADDKC8sLpxgVCxlwd6HEAAAoFqcCAP0NwAHAAAAPF8cAABxmwgAAGtOCKwgAAFHFAABTFVQVVRXlGOYYNYLFAAAUeBxRFVIVcRr7Amhv5zwA/kBLphnJAAAakMIAABZyygAAHWrMAAAcTskAABw07dsHAAAfEAAAhRIAAIsSAADxEgAAwxYAAAgZAACgGQAAHBoAAFkbAABeGwAArxsAAAAcAACWHAAAQQAAAAAAAPv9EIAD") \ No newline at end of file diff --git a/api/search.index/18de64a2b5.js b/api/search.index/18de64a2b5.js new file mode 100644 index 00000000..5d16535c --- /dev/null +++ b/api/search.index/18de64a2b5.js @@ -0,0 +1 @@ +rn_("FQHDAAAWChx3VRchwwAABRmWGyUBwwAAE3gbbukb+xvzAMAIiIL2EgAAQ4QDwwAAF1TEAAAY22VycxsCyQAAGmBudg==") \ No newline at end of file diff --git a/api/search.index/19697bb405.js b/api/search.index/19697bb405.js new file mode 100644 index 00000000..927522d7 --- /dev/null +++ b/api/search.index/19697bb405.js @@ -0,0 +1 @@ +rn_("BQLAAAAUChcmUxfGGmVzNQHAAAAWEBx3bRduF6gZMcAAANgL2QuTDvsCZHIlhsUAAKR5HBl7HBYcRhwzAAPCAAAQM2NlbwGNAwAAWwPDAAAZqml1eQ==") \ No newline at end of file diff --git a/api/search.index/19714149b9.js b/api/search.index/19714149b9.js new file mode 100644 index 00000000..6a94d490 --- /dev/null +++ b/api/search.index/19714149b9.js @@ -0,0 +1 @@ +rn_("AUDn/hUAAHsaAACaGwAAQBwAAGQcAACCHAAAcB0AAJUdAAAwMAAAIwACwgAAErppc/L1CQAA0QgTgQLgAAAIB2xy") \ No newline at end of file diff --git a/api/search.index/19e02ae9d7.js b/api/search.index/19e02ae9d7.js new file mode 100644 index 00000000..f468ef80 --- /dev/null +++ b/api/search.index/19e02ae9d7.js @@ -0,0 +1 @@ +rn_("AQIBwgAAHK1k5I0VAAAlFgAAKBYAAAQYAACoGgAAABAgFQHDAAAQphpzYhsbAsQAABxTbXD7Am11KwPHAAAao8wAABvpYW9y") \ No newline at end of file diff --git a/api/search.index/1a3d608bc4.js b/api/search.index/1a3d608bc4.js new file mode 100644 index 00000000..0a761ae9 --- /dev/null +++ b/api/search.index/1a3d608bc4.js @@ -0,0 +1 @@ +rn_("BQLCAAAZNRwapRxtdiMAA8QAABx1wgAAGlRlaGkBPwkAAAUDwQAAFEcaE9MWEjIWYXB0FYTAAAAXgh2OGyMGAsMAABwRZXPygRcAAAAFCYnQAAAdZsYAABj7BQLAAAAffxwcGhxhdGHAAAASCPQJ4w7kDuUOShcFhsAAABYIHCRfHa4d+wJscgkC1gAAHbPGAAAZ7mR0KwLFAAAXgGZ1AQD9f+gDCfOzxBrgAAAY0MgAABqjxgAAGMdT5YDrtmZt5yyrzgAAHIBkJLSMi8wAABwbA2xRV7nLAAAcAG06UzFMMIaexlHyIxAAAFoM") \ No newline at end of file diff --git a/api/search.index/1a5884d2a2.js b/api/search.index/1a5884d2a2.js new file mode 100644 index 00000000..2c85ec40 --- /dev/null +++ b/api/search.index/1a5884d2a2.js @@ -0,0 +1 @@ +rn_("QcEAAFAQ8BZrF+0YBQPCAAAUFRgW+BkZ1RxiZW6hxwAA0xuhxQAANBdjAMCAKMEAABvR+58KAACoCmcBQQLqAGoAAQC7Ab4ACAAIACsCwQAAHYlpefOBAnR6QcEAABgMGQwaDIsbe8AQYcIAABgW") \ No newline at end of file diff --git a/api/search.index/1ad408e09e.js b/api/search.index/1ad408e09e.js new file mode 100644 index 00000000..79fd485d --- /dev/null +++ b/api/search.index/1ad408e09e.js @@ -0,0 +1 @@ +rn_("FQHDAAAVrhpj7xYFjMAAABdFGzTqFusWAhwVAcMAABZ8G3IRF6UBwwAAHHIddHkV+4VlAcEAABCFHXMjHCQcJRwmHFsdoR0rAsUAABxdcHIBQuJYFQAAxRkAALIaAABQABuGwQAAG6ohwgAARxYcHFsDyAAAHVNhb3IFA8AAABMSGBZzHBMTGGFtchWIxgAAH68d4Rs3AgD9EUENxgAAHYLDAAAYFMYAABnEwwAAHB77yg4AAMcHZgKXAgMASABEADEAGAAXASEABQLAAAARgxYQlRlvdyHEAABrFukW+4oVAsYAACR7G1IdFgscc3daFxUBxwAAFmIdckscEwICxQAAGrdhcgGCFgAABQLAAAARPRWg/wlmZ2vABITmAAAT36HDAABFFiHDAABEFhEcMcgAAIocixyrHTUBxAAAEnobY5cbmBuUHfMAAmls5ccSAAD1FgAA/BgAAGQaAADKHAAAWB0AABAgAPvBBCBByAAAwxlPHFAcpB0FAsAAABdTHB3pG29yocQAAA8XBcDAAEUAABMOFxWnFhNpGgEA/sBPxB9q1iIAV2hgFRgPJsdD1n9Mvwuy9ApxWMiIxAAAG6cmZVauVhcWdKiJwwAAG15uWhpT6zMfh/BfwgAAFT4k4o3UbnOtjzwIUyzyO7o6MAAAAQAAAAAAGwAQAAAAygXLBcwFlwqYCvEL8gvzC/QL9Qv2CzgWKRdLF3sXfBeBFw0YvxjHGPQY9RgLGS0aahp2G4Ec2hwhxAAARxxUHbn9DAAMAAHgAAAHAyE=") \ No newline at end of file diff --git a/api/search.index/1af9830d57.js b/api/search.index/1af9830d57.js new file mode 100644 index 00000000..383d4330 --- /dev/null +++ b/api/search.index/1af9830d57.js @@ -0,0 +1 @@ +rn_("ocMAAEUWVQLAAAAiRBYRHCD6FjsXcnMUCEYJ7hIYGO4ZMcgAAIocixyrHTUBxAAAEnobY5cbmBuUHTMAA8UAABptaWx15ccSAAD1FgAA/BgAAGQaAADKHAAAWB0AABAgAOvBBiDFAAAcPg==") \ No newline at end of file diff --git a/api/search.index/1bb746ea6b.js b/api/search.index/1bb746ea6b.js new file mode 100644 index 00000000..18080fd1 --- /dev/null +++ b/api/search.index/1bb746ea6b.js @@ -0,0 +1 @@ +rn_("odAAAIAdNQLAAABI4xjkGMkZyhkQgRplc8UOxg4NFgUCwAAAGakbEtQSc3RHCQD+AFHAC8sAABwOxgAAGhPIAAAa08QAABcoxAAAGAU7MAAAAQAAfAAEAEgJdQDBCQEABgoAAJMKAwA=") \ No newline at end of file diff --git a/api/search.index/1cd29c9b8f.js b/api/search.index/1cd29c9b8f.js new file mode 100644 index 00000000..ceac895e --- /dev/null +++ b/api/search.index/1cd29c9b8f.js @@ -0,0 +1 @@ +rn_("UcIAAJcWmBaZFpoWnxwhwgAAqg5EEgFA46gOAABCEgAAxxoAAFUdAAARIvMBAXPi2w4AAB8aAABFHAAAGwD7AmF5scEAABAMTwz9SEEPzwAAHHPEAAAWosUAABVBxQAAF1vHAAAYHg==") \ No newline at end of file diff --git a/api/search.index/1cd33ae72f.js b/api/search.index/1cd33ae72f.js new file mode 100644 index 00000000..39527309 --- /dev/null +++ b/api/search.index/1cd33ae72f.js @@ -0,0 +1 @@ +rn_("McEAANcLExTqGyWCwQAAtNEZTxUAGiHAAAB7G1IdZQHAAAAWXhp31xLYEtkScRdyFz0a84EDZXN1OwPCAAAb72FvcqHDAAAOFCMBAsAAAA8RaXToJwgAAD0SAAA7FQAA2hgAAKUaAABKGwAAbhsAAPobAACLHQAAEwADAAAVAcIAABEqF3TWCwUCwQAAHo8cH70cbHNlAcEAAMDyEnOiC6MLpAuzErQSxBprA8AAABVebm9zBYzAAAAbxxwUHhoFA8AAABgvHBvFHBvGHGx0dQUCwgAAF8QcFC4cc3QTgQLMAAAdeGR02wTJAAAcXmFjc3YVAcEAABQdGnYSFKHDAADwGPsDY2V09wEA/RBFFM0AABzI+OcJAAABABINQQAFA0EA6gAJAg==") \ No newline at end of file diff --git a/api/search.index/1d2ab49438.js b/api/search.index/1d2ab49438.js new file mode 100644 index 00000000..44739198 --- /dev/null +++ b/api/search.index/1d2ab49438.js @@ -0,0 +1 @@ +rn_("BQLAAABztxK4ErkSIhxZHF4c1ByisBtudQUCwQAAEPYTF0QcZ3RDAP0wAQPAAAAWF84AAByBygAAGmzEAAActAGuHAAA+wJrbw==") \ No newline at end of file diff --git a/api/search.index/1dd41f1ed9.js b/api/search.index/1dd41f1ed9.js new file mode 100644 index 00000000..78539f7f --- /dev/null +++ b/api/search.index/1dd41f1ed9.js @@ -0,0 +1 @@ +rn_("BQLGAAAUXhsSlRlxcgUCxwAAFpgdF58dY3AFAsIAABPQGB5hHWFrBZjAAAAU3xYe0xwnAwL9HYgDyAAAGqzhAAAXb8MAABnazgAAHKvOAAAc1IL4FgAA+4k=") \ No newline at end of file diff --git a/api/search.index/1e397fad65.js b/api/search.index/1e397fad65.js new file mode 100644 index 00000000..159aaf8b --- /dev/null +++ b/api/search.index/1e397fad65.js @@ -0,0 +1 @@ +rn_("BQLAAAAbfx0SUBpydBuJygAAHXMrAskAAB1oZXQxxwAAeRvaG9AcNQHFAAAW1xx3tRqNG6QcBQLCAAAYZx0afh1ydfuJFYLDAAAUPxyGHBOBg8cAABy01wEAwBJixwAAHErhfhUAAEwaAABA") \ No newline at end of file diff --git a/api/search.index/1ea3b759b7.js b/api/search.index/1ea3b759b7.js new file mode 100644 index 00000000..8d4110f0 --- /dev/null +++ b/api/search.index/1ea3b759b7.js @@ -0,0 +1 @@ +rn_("ocQAAAoaBYPAAAAUCxcdtRwlhMUAABSpHKsamh0lhMQAABRvHEkakh0VAsMAABOIGhbwG21zuxb7wBAh6/2AwAXDAAAapMwAAByD") \ No newline at end of file diff --git a/api/search.index/1eb262383e.js b/api/search.index/1eb262383e.js new file mode 100644 index 00000000..081492b8 --- /dev/null +++ b/api/search.index/1eb262383e.js @@ -0,0 +1 @@ +rn_("FYLCAAATHxc6FysCxQAAFx5leSUBwQAAoLEWc84SuRoTgQLGAAAZkmFwBQLAAACokBmmpRZsbwUCwAAAqQoaEgEPbnQFAsEAABJZEhMGFG1uBZTAAAAXQBYWpBYFAsAAABzLGxUXFmFo5w8A/R2BA8QAABA685sWAABuApsA") \ No newline at end of file diff --git a/api/search.index/1f23378845.js b/api/search.index/1f23378845.js new file mode 100644 index 00000000..de817514 --- /dev/null +++ b/api/search.index/1f23378845.js @@ -0,0 +1 @@ +rn_("BQLBAAAUrhwYeRljZhWBwAAAFPIb3RmhwgAAoA6lkMUAAKL1GTccMcMAAN4TvxvAG/OBA3BzdfsCY2QXAQD9EFADxQAAHA7LAAAbRskAABmX9YMKAABBBOgD0AI8Bg==") \ No newline at end of file diff --git a/api/search.index/21249d6599.js b/api/search.index/21249d6599.js new file mode 100644 index 00000000..4f244e30 --- /dev/null +++ b/api/search.index/21249d6599.js @@ -0,0 +1 @@ +rn_("BQLAAAAXURoSihVpcjHAAABfG2AbqBsFg8AAABM3FhrIGwUCwgAAEvcZECgcaXkrAuAAABuZZG7zAILyxRIAALsEKwLEAAAZBnFy1wEA/gAEhAvIAAAatsUAABvKOzAAAAEAADMAEAAxCAIA2gkAAPYJAAA0CiEArgsAADcQAABFEgEAYhUAAGsWAADpFgAA7hYAANsYAADAGQAAXxoAAGUaAADPGwAA") \ No newline at end of file diff --git a/api/search.index/21834f9d44.js b/api/search.index/21834f9d44.js new file mode 100644 index 00000000..2bfa22bc --- /dev/null +++ b/api/search.index/21834f9d44.js @@ -0,0 +1 @@ +rn_("occAANMbocUAADQXYwDAgCjBAAAb0fyfCgAAqApnAUEC6gBqAAEAKACTAb4ACAAIADHCAACAG1ociR2hxgAAaRcVAcAAABAzFXO6EvsCbXP7A2VpeQ==") \ No newline at end of file diff --git a/api/search.index/21b5657865.js b/api/search.index/21b5657865.js new file mode 100644 index 00000000..9633b7a7 --- /dev/null +++ b/api/search.index/21b5657865.js @@ -0,0 +1 @@ +rn_("AUDmsQsAABoWAACeGwAA4BsAABccAACMHQAAlh0AABAwAADBxAAAhRuhxQAAExcHFgL9NaEDxwAAHaPDAAAcGcMAAByqxQAAHWnCAAAYIMEAABBA//gJAABQCNkDAQDoAQEA6QG3AW0ATQALABQAEQDdAEIA") \ No newline at end of file diff --git a/api/search.index/21f232bb84.js b/api/search.index/21f232bb84.js new file mode 100644 index 00000000..f1626495 --- /dev/null +++ b/api/search.index/21f232bb84.js @@ -0,0 +1 @@ +rn_("ocQAAAQPCf0AAUzGAAAXJtIAAB1j0wAAHXDJAAAZx0HGAAAIFAkUChS2GrHFAABaEiHHAAAaFhccRQHJAAAYfR1kIxckFyUXoBohzAAAexqCHNcdAP2XgBPFAAAUB84AABvMAb4SAAA=") \ No newline at end of file diff --git a/api/search.index/224476c153.js b/api/search.index/224476c153.js new file mode 100644 index 00000000..1b49ca0a --- /dev/null +++ b/api/search.index/224476c153.js @@ -0,0 +1 @@ +rn_("IcMAALMa2BsFAsAAABZwHaOZGmhvMcMAANcbzxxgHSHFAABHHFQddwEC/QfAAtMAAB2zxAAAHEjl3xsAAB8cAAC5HAAAdR0AAIQdAACxHQAAADAAGwLGAAAZ7mR0") \ No newline at end of file diff --git a/api/search.index/233704d8f1.js b/api/search.index/233704d8f1.js new file mode 100644 index 00000000..82c61003 --- /dev/null +++ b/api/search.index/233704d8f1.js @@ -0,0 +1 @@ +rn_("AQSBwQAAHA3tWBcAAN4ZAABNGgAAVhoAAF4aAAC8GgAAhBsAAIkbAADWGwAAChwAABAcAABDHAAAdhwAANccAAAAAAAAAQAS") \ No newline at end of file diff --git a/api/search.index/245c5a5079.js b/api/search.index/245c5a5079.js new file mode 100644 index 00000000..2a889b53 --- /dev/null +++ b/api/search.index/245c5a5079.js @@ -0,0 +1 @@ +rn_("FQHDAAAWChx3VRchwwAABRmWGyUBwwAAE3gbbukb+xvzAMAIiIL2EgAABQLAAAAQtw4TVBdxcgUCwAAAEQ0UGIEabW9vCP0WwQPFAAAbR8EAABd9wQAAEFHCAAAWJMQAABjb") \ No newline at end of file diff --git a/api/search.index/25316bfd39.js b/api/search.index/25316bfd39.js new file mode 100644 index 00000000..fb3907ca --- /dev/null +++ b/api/search.index/25316bfd39.js @@ -0,0 +1 @@ +rn_("UcIAAIcZiBmJGYoZbR1RxAAAbhZvFqEaohrGHBWExAAAFIgdWRoTAALVAAAdrnR1giEQAAAFAsIAABhtHBOvHGNtlwMA/gAIYAfGAAAaRccAABrC8tELAAC+EQ==") \ No newline at end of file diff --git a/api/search.index/265396c567.js b/api/search.index/265396c567.js new file mode 100644 index 00000000..a06fb142 --- /dev/null +++ b/api/search.index/265396c567.js @@ -0,0 +1 @@ +rn_("IcQAAMEZzBoFAsEAABFZFxDSC2ZrhwIA/VIBRsQAABnCxAAAGm/FAAAbj8YAABvdxwAAGOznuAsAAJ4SAADaEwAArhoAAIobAAB0HAAAcR0AAJAdAAAAMAAA") \ No newline at end of file diff --git a/api/search.index/2719d44bd8.js b/api/search.index/2719d44bd8.js new file mode 100644 index 00000000..0feea703 --- /dev/null +++ b/api/search.index/2719d44bd8.js @@ -0,0 +1 @@ +rn_("McMAANESHxYgFhWBxgAAEg0cvBojBALEAAAaWmh38koVAADyBHUBwQAAokgVcvEJ8gnqFusWdBd1FwIclwEA/TABDsUAABwzwQAADqXBAAAS7eMBCgAAjgoAAOUZAACvHQAAQQA=") \ No newline at end of file diff --git a/api/search.index/2746edb569.js b/api/search.index/2746edb569.js new file mode 100644 index 00000000..2dbcf74d --- /dev/null +++ b/api/search.index/2746edb569.js @@ -0,0 +1 @@ +rn_("BQLAAAATFxgYuBxjZQUCwAAAF4wcKskbQRxhaBcCAP0cQQLDAAAcA8YAABx/wQAAFUDCAAAXFePMDgAAkhYAAEwcAACeHAAAEwBDAAPBAAAbjOAAAB1paW1z6LULAAD3EwAAqRYAAMQWAABiFwAAzBgAAGwcAAChHAAAWh0AABQwAwAAGwLiAAAMQGV5") \ No newline at end of file diff --git a/api/search.index/278242b340.js b/api/search.index/278242b340.js new file mode 100644 index 00000000..361fdc6a --- /dev/null +++ b/api/search.index/278242b340.js @@ -0,0 +1 @@ +rn_("BQLBAAAUoh0TSRxjcsHHAACyGyHGAAB9GdYaYccAAI8WkBZ5Gnoa0RzSHCHDAACWFgcZAUY7MAAAAQAAbwABAI4VbwDXCwD+AI1hAcEAABa0wwAAHJY7MAAAAQAAvgEFAHQDAAAIBN8AFwoAANgM2wCqGAAA") \ No newline at end of file diff --git a/api/search.index/27a225a666.js b/api/search.index/27a225a666.js new file mode 100644 index 00000000..fb9c0981 --- /dev/null +++ b/api/search.index/27a225a666.js @@ -0,0 +1 @@ +rn_("BYnBAAAycRdyFz0aEeobscMAAP4WMcYAANsZ3BmHHNMF/RQQAsUAABnd9zgUAAABAI0EpQHBAQMAkQAbAsgAAByYcHI=") \ No newline at end of file diff --git a/api/search.index/281013e35a.js b/api/search.index/281013e35a.js new file mode 100644 index 00000000..aef5e9de --- /dev/null +++ b/api/search.index/281013e35a.js @@ -0,0 +1 @@ +rn_("UcIAAAEaAhoDGgQaeh0FAsMAAMeyGxSWHHBxEwACwwAAG/FmaQHTCQAA+4YFAsEAABRcHSTBGmQdbW7zBQFj8sUWAAADBQUCwAAAEWMbFskcZXQbAsMAABrAZXQThALIAAAdWXN0KwLHAAAbYm5zGwLDAAAdkWV0IcYAAN4bbhwXBgH+AEMiCdEAABywwwAAHKnIAAAcMMsAABwy5C0IAADRCQAAqxoAAJAcAACaHQAAMQAAa8EEgMAAABYz") \ No newline at end of file diff --git a/api/search.index/28d36029a1.js b/api/search.index/28d36029a1.js new file mode 100644 index 00000000..a339601c --- /dev/null +++ b/api/search.index/28d36029a1.js @@ -0,0 +1 @@ +rn_("BQLAAAAVoRYW8xtsbwcEAP0VsQPEAAAYIcUAABpwxQAAGkrHAAAZi8kAABzKyAAAF13KAAAZxcMAABqSAQUHAAA=") \ No newline at end of file diff --git a/api/search.index/291b42356a.js b/api/search.index/291b42356a.js new file mode 100644 index 00000000..664b559d --- /dev/null +++ b/api/search.index/291b42356a.js @@ -0,0 +1 @@ +rn_("pcDAEkAAALW5GRRQEBO0DhIHVYjAAAAbHBzTB9QH1QfWBxMMocQAAIsWNwEAxAUYxgAAGebDAAAWjeXiBwAAXQoAANEWAACIGgAAZhsAAFkdAAACAAA=") \ No newline at end of file diff --git a/api/search.index/291f9cbfd9.js b/api/search.index/291f9cbfd9.js new file mode 100644 index 00000000..332b3d92 --- /dev/null +++ b/api/search.index/291f9cbfd9.js @@ -0,0 +1 @@ +rn_("BQLBAAAVbBsVbRtwczHEAAC+Dh4YcxwVAcUAABZiHXJLHEUBwwAAFtkcd1YVVxW/Guwb+wJhZKUCxAAAFLoYRi4aLxowGqscY3ORD+cBAP0EoQHAAAASnoLbCQAA") \ No newline at end of file diff --git a/api/search.index/29609eb484.js b/api/search.index/29609eb484.js new file mode 100644 index 00000000..60d33467 --- /dev/null +++ b/api/search.index/29609eb484.js @@ -0,0 +1 @@ +rn_("BQLAAAAb1hxCnRImF7YZtxludBUCxAAAFYobo/QYY2ytGhODAswAABysYWMFAsAAABFTGhLiEmRvocIAAIsWFQHCAAAXdBxzYhojgQLNAAAdYGdv+wJjdF8D/kAAEkzFAAAS4sAAAAb4") \ No newline at end of file diff --git a/api/search.index/2971347ecf.js b/api/search.index/2971347ecf.js new file mode 100644 index 00000000..e3e8d045 --- /dev/null +++ b/api/search.index/2971347ecf.js @@ -0,0 +1 @@ +rn_("BQLAAADW2hYWcxtvdDUBxAAAEn8ba14XjxyjHCsCxgAAG6ZzdCOBAsIAABo0YXQFAsAAABh2Fxp3HWV0BYnBAAASnR0UpR0jiwLGAAAdrHBz84ECcHMVAcIAACISF9wbaz4QIwCGxAAAFkjSMwoAACkVAcMAABBhG3NSEPsCYXQFAsAAABtuHbU2F2V0scEAABQP+wJucKUBwwAApnYcd9QZGwLjAAASh2F5E4ECyAAAHKFuciHDAADXGcIaAUI7MAAAAQAA3AACABgKAAC0DdsAAUbhHxoAAEUcAACwa8AEBuIAABJWBQLEAAAaoR0Ylx1kcwUCwwAAHbQdF4wdY2UbAsQAABulcnQblskAAB17yAAAHW0FAsMAABRsHRV6HWltQ4HABkDPAAAdscwAAB2cBQLAAAAU+hsaeR1lbAUCwAAAFqAcG5UdYnYrhcgAAB1rFQHAAAAXix1ithvzAgFzAWQbAAArAsUAAByfbXQFAsEAABSUHRirHWlsI4cCxwAAHaRkZQGEAtMAAB21xwAAHZNvcwGC/kBfqgl3nUjPw8YAABxkOYqwvR3RAAAdmccAAByexAAAG/kTCMP/eWhDjffEzAAAHZZUVDaky0rtqZsgNJPQeoFykMD9SgFDOzAAAAEAANwAAgAXCgAA2AzbAAUCwgAAEcoaEmgXbXMbAsMAABqTbHMbisUAABY4Bf3AgEgBAAAdRBwfih2zHxQUMxqhwwAAEBYBAP3t9w9ylptKBGk91RRtcRRh/5J+bg7SvVCB2f04xwAAGlRVl2zA2coAABzMC+Lg5jduvhBey8IAABYYW/uQ7ZYEd5RLC20GdmMMbfaDrBwzMCI7+8wAAB1aOjAAAAEAAAAAACwAEAAAADwBGAf+CQoKYQqBCoIKnQqeCkYMSRKMEo0SjhKPEuYSLhYyFj8WQBZqFo0WSBdgF90Y3hjjGOQYyRnKGWMalBrPGtAa0RrSGkUbRhu9GxocIRwoHF8dih2uHQ==") \ No newline at end of file diff --git a/api/search.index/2a2ae297cf.js b/api/search.index/2a2ae297cf.js new file mode 100644 index 00000000..c248dd69 --- /dev/null +++ b/api/search.index/2a2ae297cf.js @@ -0,0 +1 @@ +rn_("ocUAAHQXpYPAAAASUBcWdRutEgUCwgAAFcUbFHQbY3MFisAAABgIHBsRHKUCwwAAFMMaFtYbc3cBFxsCxwAAHAdhbPMDwhZA4SQQAABCFwAAEPMBAW4BHgcAACHEAADUFiAcIwMCwgAAFjNtb+OnCgAATxIAAFEXAADwGwAAYRA7wAgOwwAADpnIAAAZ2g==") \ No newline at end of file diff --git a/api/search.index/2a83716182.js b/api/search.index/2a83716182.js new file mode 100644 index 00000000..fc2a0050 --- /dev/null +++ b/api/search.index/2a83716182.js @@ -0,0 +1 @@ +rn_("BQLAAABG5xvoG1EcUhwTQxBucwUCwAAAHnUcFWwWaHKlgcUAABGmGV8VBQLFAAAYZhwVXxpkcgkCxQAAHaDQAAAdcW9yUcUAAAEaAhoDGgQaeh1RxAAAgxmEGYUZhhlsHQUCwAAAFmMdGrkbdHWXPQH+wEJ0A80AABx0xgAAGL/EAAAWyPPXFgAA9gPLATHAAABfG2AbqBsTAQLFAAAbynN2/zEIAAABAAEApwHUAYkEKwUJAX4ABQDtAeUAnwAGAGoBa8ABS8IAABV81AAAHa7BAAARUTHBAABWCpMavxwFwMAcAAAAEogdEfQWF80cKwLgAAAXIGRuIwKFwAAAGabjXxUAAJwZAABZGgAAbh0AABMAa4vFAAAcEwUCwQAAHqMcH80cbHMhxAAAuhtnHPsCY3IFhcYAABg8HBSnGlWCwwAAF2Ydfhl/GYAZgRlrHSsC4AAAEpBkdAUCwAAAUGUVcxfDGuYbUx0x3BKCGZEdc3WhyAAAXBtbwAChxwAAHXfXAwD9EUURwAAAHLDHAAAalDowAAABAAAAAAAPABAAAAALCgwKWxdjF+cYvBnNGa8atxp8G4EbxxvOGw8cQhxiHQUCwAAAF8scc2cVaBVpFWoVaxVsFaccaG8FAsAAABXEGxZ7GWVsMcEAAHAWcRZOFzUBwQAAFhAcd20XbheoGaHKAAB5HPOFAmNlBQLAAAAoZhtZHbLgGG95ocYAAG8XBQLAAAAhMRZjGyabGskcZXQXAQD9EIAOxQAAGjjMAAAczsMAABrA4UwMAAACFgAAIKUCxQAAFoMbFoQbbXdDF6UBxgAAFtgcd7oaBQLAAAAVFBwR3xJhZnHAAACXEpgSqxisGK0YrhivGPsCZG4FAsAAACO4GVMbGqocbnAFAsAAAKX8FhW8G2hv9x4B/gBFLAvJAAAccDowAAABAAAAAAAaABAAAAAQCqMKpAqlCqYK6hPrEzYUPBZTFwwYqRj5GPoYrxkJGmEahhrGGmgbaRu5GyscMhx8HH4cXh0hwQAA5RLDG/sCbm+hxwAAzBsFAsAAABlxHRe4HHByAULhhAoAAIQWAACB1ycA/kBN8ALCAAAYF8MAABbDxQAAHELutQoAAMYLAABDDAAAeBYAABsXAAA9FwAAwRgAAOgYAADGGQAAaBoAAJEcAACZHAAAhh0AAJ8dAACsHQAAAxAAEAAEAAAFisAAAKIvFqXBGAEDAsIAABpTwwAAHJdiaerOCwAAwhIAAEsWAADEGQAA4RkAAI8aAADQGwAA0xsAAAkcAABxHAAA1hwAABAAEAEBACHCAACMFX8X+wNhb3IFAsAAABPHFhhlG2NrCYPBAAAWLNAAAB2FIcAAAM0LSwwnA4L+AEMiCcwAABxlyAAAG7jMAAAcos4AAB1bG8AAJcEAABcVwgAAGj7hwwAALBQFisAAABvqGxJ+FzHBAADTDtQOFhdL0AQE5wAAEAfAAAAVQDWIxAAAFCAcURdSF/AbJQLCAAAWRxoWdRxycyMQfRxTAAPPAAAdam9xdQErGgAAocUAAJMZBQLAAAAYaxwbtxtjdRMA/QEBFsAAABZ/4AAAG+7KAAAdh8UAABtw4+AHAACzCwAANBcAABYaAAAQEAUCwAAAEWMbFskcZXQbAsMAABrAZXQjBAPDAAAbU8gAAB1ZZXN09DwWAADNA2AB9QFBwgAAVhdXF2kXahcFAsAAABR2FyBvGwQcZXL7hgEEAsMAABjc4AAAFTxvcjswAAABAADgAAIAvBIAAPkS3wAbg88AAB2y+wJpdiHGAADgG4wdBQLBAAAWYhsUfxxjcDHBAABdF18XvRklAcMAABOJHGx7FUkbGwLgAAAdkG9zFQLDAAAUvhsWqBtpcigXfx/9FbAHxQAAGB4FAsAAALj8GBMDHG9yIcoAAMkbQRwFAsAAABaMHBV3G2NuO/2BQAjBAAAWx8sAAB20AYIC0AAAHbLBAAAbb2l2FQHDAAAVqBxwQRqTgf0AiQrFAAAbvcQAABtuwgAAGkIxzQAA0hutHbIdawPAAAASsmZ1dgEA/b/9Xgie24pJxAAAHG0BW0ZJDDYPWTYLItDy3FVl5Clz/H9SpBTSOAK/cM7HAAAbZTZ948PKzQAAHNR+M3I7lFXUm+Asc6dSAMFwty3OMn0aI79TDj2qd7/KAAAarXJn0RxvOjAAAAEAAAAAAFgAEAAAAOQH/wcACAEIAggXCDoIQAlBCUIJQwlECWAKsQuyC8wOzQ4DDyEQIhA5EjoSOxI8ElQSnRL/EwgUCRQKFDcUMRUyFRoWKxaSFpMWlBaVFpwWnRaeFp8WrxawFg8XEBcmFxMY8xj4GLYZtxntGfcZARoCGgMaBBpFGk8abhp2GncakRq2GoAbnhufG6AboRusG+Ab+RsFHBccTBxaHIocixyeHKAcah16HYkdjB2WHZkdqx0=") \ No newline at end of file diff --git a/api/search.index/2b52324a45.js b/api/search.index/2b52324a45.js new file mode 100644 index 00000000..fdc20eb5 --- /dev/null +++ b/api/search.index/2b52324a45.js @@ -0,0 +1 @@ +rn_("FQHCAAAWChx3VRcricEAABZKIcMAAAUZlhsVAcIAAKS2GWKvEiUBwwAAE3gbbukb+xu3AgD9IMEOxAAAHWrEAAAXbMwAAByvgvYSAAA=") \ No newline at end of file diff --git a/api/search.index/2b89e0e099.js b/api/search.index/2b89e0e099.js new file mode 100644 index 00000000..688ad4ed --- /dev/null +++ b/api/search.index/2b89e0e099.js @@ -0,0 +1 @@ +rn_("BQLBAAClRRUolhl+HXByIcUAAAUUbRsFAsEAACYiF2cdFa0WYXABRONKEAAAARQAAGwbAACtGwAAMwAbAscAABvDbHIBReOpFgAAzBgAAKEcAABaHQAAMwAhwwAAABS0GfOBAml2BQLGAAAYUxwVpxtjZiuKxQAAFqghyAAAIRdxG/v9LIAL") \ No newline at end of file diff --git a/api/search.index/2bab1e099b.js b/api/search.index/2bab1e099b.js new file mode 100644 index 00000000..0027091f --- /dev/null +++ b/api/search.index/2bab1e099b.js @@ -0,0 +1 @@ +rn_("JYHGAAASDRxeGrwaoccAAOcbVYLFAAAXZh1+GX8ZgBmBGWsdhwEAyCKCxgAAG3fJAAAcIM0AABzUAZkdAADFAcMAABcJHHIGGAUCwwAAFpgdF58dY3BTggPHAAAb22JlbBsCxQAAGmhscgUCwgAAE9AYHmEdYWtBwQAA4xnkGYAdgR0FAsUAAB2gHRyQHXJ0BwEA/RUAA8AAABHUxwAAHK3BAAAZjMQAABvPgm8XAAAFA8AAAKVjFRTfFh7THGhvdQUCwAAAFWIaHNYcYXkBAP3fmRfIAAAarH+eBrKHLtzfsrk4jZEtisMAABnaXZPQHn/EAAAaWsYAABpnfo+4bEHHAAAZ81kfu91izgAAHKtnnHhl18IAAAutYUV8Wn06MAAAAQAAAAAALAAQAAAAHQfrB+wH7QfuB+8H8AfxB/IH8wf0B/UHMgkmCicKjQoFDLgOhhLXEtgS2RIHFEoVShZ3Fr8WwBbBFsIW4hb4FvkWcRdyF7EYkBmRGTMaPBo9GlIakhrFGlsb") \ No newline at end of file diff --git a/api/search.index/2bff131fcc.js b/api/search.index/2bff131fcc.js new file mode 100644 index 00000000..8b23b995 --- /dev/null +++ b/api/search.index/2bff131fcc.js @@ -0,0 +1 @@ +rn_("YcYAAE0aCxx2HHcc2BzZHCHEAAB7G1IdYwADxAAAF1tlc3fothIAAFYVAAB4FQAAWhcAANQZAAC6GgAAvxoAAIMbAADsGwAAAQARAAA=") \ No newline at end of file diff --git a/api/search.index/2c2f32b91e.js b/api/search.index/2c2f32b91e.js new file mode 100644 index 00000000..672e61fa --- /dev/null +++ b/api/search.index/2c2f32b91e.js @@ -0,0 +1 @@ +rn_("BYPAAAASGRgUOxVFAsIAABXaGBqyHHB0OBA5EJUalhohwAAAxA64G/sCcnSjAP0BiQjJAAAay8UAABY/xgAAHI73zAkAAOsBWwoBADQEKQFVAA==") \ No newline at end of file diff --git a/api/search.index/2c5a805b6d.js b/api/search.index/2c5a805b6d.js new file mode 100644 index 00000000..d31582a8 --- /dev/null +++ b/api/search.index/2c5a805b6d.js @@ -0,0 +1 @@ +rn_("RQLFAAAYfR0Wrx1kbiMXJBclF6AaFQHFAAAWyht27hYjggLBAAASwGFyG4zFAAAXTwUCwAAAEtILJMEZzBppdBUBwgAAIhIX3BtrPhAjAobEAAAWSAFcCgAACQLPAAAcjcIAABnAbXP7AmV5Ff3EDBEAAAAbeBwUNhwTchYXchsIGQUCxQAAMXAWcRZOFxbEG29wocQAAAwa+wJscgUCwAAAFLwbE/MWbW4FwMMKCAAAGHEbFbQZGcMbBQLCAAA8DBwnHF0dqHYaY2kBQ+PxFgAAJBgAAPYYAABnGwAAFABrlcMAABw9ocUAAPwWVQHDAACgURtzRBXnG+gbURxSHBUByAAAFDscY7MZ+wJlchWBxgAAGKMdch3zBYIBPRYAACHJAABrGsAcMcIAAJoOmw5hECHQAACzHJsdVwEA/QlIAsYAABeBwQAAC7zyJwkAAN0PJYHAAAARHxcFFG0bswDABMHCAAAdkDswAAABAAB+AAgAEQgAAPMJAAC7DgAAGg92AHkVAQDSGwAArR0AALIdAAChxAAAqhIFAsAAABNgEBKOHW51BQLCAAAbOhwUyRhucvsCbG8FAsUAAB2gHRyQHXJ0I4ECwQAAGYxlcysDyQAAHAHEAAAZx2FldBUBxQAAEJkdc1IaKwLBAAAZBGNka8AChMQAABu4AQD+QD/3B1rPz4uDQuCebLwZf86sGmheMtINyQAAGkZ76hJZW2xqtoV2wQAAG8s+fkn+sni8r/WMcsbGmP9sXyGMzBxSlOIxJqVSJlUMG8BkpDoctw+ORbBn7U/mhwMAAAsGAAA5FwAAFhgAAGkaAADyGwAAgh0AAAEQAAA=") \ No newline at end of file diff --git a/api/search.index/2c9654b8c1.js b/api/search.index/2c9654b8c1.js new file mode 100644 index 00000000..ad9a0053 --- /dev/null +++ b/api/search.index/2c9654b8c1.js @@ -0,0 +1 @@ +rn_("AUDjqA4AAEISAADHGgAAVR0AABEixf3GUgECAAAVwhkVbxoWjxsTWRcX3RvaE1HBAACXFpgWmRaaFp8cBQLMAAAWmB0Xnx1jcPsCcHIxwgAAqg5EEjUW8QEBBXPi2w4AAB8aAABFHAAAGwBbA8sAABxsYW95IcMAAGUVUx0FAsAAABlrHBZwG2F3SwPHAAAbvsUAABgeYW10AYIC0AAAHbLBAAAbb2l2FQHDAAAVqBxwQRqXAIL9AIkKxQAAG73EAAAbbsIAABpC+wNhZXQFAsMAABVsGxVtG3BzocQAANUWBQLAAAAR0hgRNBZmaSsC4gAADBBkbgUCwAAAEaIWE0AacnMJAtAAAB2twgAAG2tpdisCygAAHW9jchuGwQAAGbIFAsAAAB2OHROlGm5yKwPBAAAZsc0AAB2GYWVsFQLDAAAVahwQPxpwc7AZBQLDAAAWXh0TaRxzdSOBAscAABynY3S3A4L9AYkKxwAAHAUFAsQAABUEHBNqG3N1G4rOAAAcc/c5AP1IWQ/FAAAVQSv/Ex/M54QKAACxCgAAGAwAAIQWAADDGQAAixsAAE8cAACkHQAAgyEAEPEAAwAeH2Vuc+NnAwAA2w4AAB8aAABFHAAAEbA=") \ No newline at end of file diff --git a/api/search.index/2ddcf8988a.js b/api/search.index/2ddcf8988a.js new file mode 100644 index 00000000..591965d3 --- /dev/null +++ b/api/search.index/2ddcf8988a.js @@ -0,0 +1 @@ +rn_("ocQAAAQPBQLCAAAWGxxWnhufG6AboRuWHW5wBQLAAAAQvhkihBXrG2x5BQLAAAARThog0xFYG2NkBQLDAAAnXx2uHR6vHGRzZQPFAAAYox0WaR3EhRthZnAhFiIWGBxwHJUcch37AnBytwEA/RABDc0AABuQ734DAADpBQAAKwoAAA0MAAC5DgAAwxIAABAUAAA1FAAAUxUAAD0WAAC0FgAAZBcAAOUYAADLGQAANBwAAJgdAAABQgAAIAERAAn9AQFMwQAADrTGAAAXJtIAAB1j0wAAHXDJAAAZx0HGAAAIFAkUChS2GgUCwAAAHUcbEW8abnIVgcMAABTyG90ZFQHCAAAU8BZnPhfb/RFBAccAABaOzQAAG8wBReEIFgAAVBsAAEMFwMAAxAAAGMYZEwcWL98bdR2xxAAAWhIrg9IAAB2wIcMAAL8ZtBohxgAAGhYXHLUCwwAApIsWFI0WYnFdCrvBAijgAAAcAUUByAAAGH0dZCMXJBclF6AaFQHIAAAQmR1zUhoTgwLhAAASNWNzBQLAAAARPxU1+Rn6Gf0bZHQjAAPAAAAXGsYAABn7Ymlzg7kZAAATgQLEAAAWSWRo84ECZHP7AmxvIcwAAHsaghwBAP7AfegTMKiRtO/FAAAUByNxI379X10275TIAAAcrsMAABBQLtOfYd9sxjc+nVBptZEkDaR9JUjDAAAcGgux/5JmffHKiY9Y4pXVFkmPX1jn6C4BAAASBwAAuQcAACoIAAC+EgAAPBQAAHkWAADnGQAAiB0AAAEgAjAA") \ No newline at end of file diff --git a/api/search.index/2e40da7041.js b/api/search.index/2e40da7041.js new file mode 100644 index 00000000..1eb3abbb --- /dev/null +++ b/api/search.index/2e40da7041.js @@ -0,0 +1 @@ +rn_("ocgAAOcbxQHDAAAXCRxyBhgjgwLHAAAb22VsBQLAAAAR1BEVzxthctMAwARKxwAAGfPzMgkAAH8PggHzAIIBeRkAAA==") \ No newline at end of file diff --git a/api/search.index/2e4b4d3d97.js b/api/search.index/2e4b4d3d97.js new file mode 100644 index 00000000..3b9a420a --- /dev/null +++ b/api/search.index/2e4b4d3d97.js @@ -0,0 +1 @@ +rn_("BQLBAAAQkRIUwBhueRWExAAAE5cazRZbwIoAwgAAFi0rAsYAABdCaG8BggLQAAAdssEAABtvaXYJAsMAABtu0QAAHXxlbAUCxQAAGWccEL0baXkVAcMAABWoHHBBGsccAP1EuQ7NAAAb+8sAABu6wgAAEp7DAAAUN8IAABpCAVAXAAA=") \ No newline at end of file diff --git a/api/search.index/2ed45421aa.js b/api/search.index/2ed45421aa.js new file mode 100644 index 00000000..018f9f6a --- /dev/null +++ b/api/search.index/2ed45421aa.js @@ -0,0 +1 @@ +rn_("BYnBAAASnR0UpR0jjQLGAAAdrHBzpQHEAACmdhx31BkTgQLIAAAcoW5yBQLEAAAaoR0Ylx1kcwUCxAAAHbQdF4wdY2UblskAAB17yAAAHW0FAsMAABRsHRV6HWltQ4HABkDPAAAdscwAAB2cBQLAAAAU+hsaeR1lbAUCwAAAFqAcG5UdYnYrhcgAAB1rFQHAAAAXix1ithvzAgFzAWQbAAArAsUAAByfbXQFAsEAABSUHRirHWlsI4cCxwAAHaRkZQGEAtMAAB21xwAAHZNvc1f2gv5AW6oJxgAAHGTHAAAcnsQAABv5zAAAHZYFAsAAAB+KHRQzGmxyVwUA/UxSC8UAABqTygAAHMzMAAAdbsQAABo0zAAAHVrmjBIAAOYSAABIFwAAYBcAAN0YAADjGAAAyRkAADAAERArAsEAABYraXk=") \ No newline at end of file diff --git a/api/search.index/2f4092f920.js b/api/search.index/2f4092f920.js new file mode 100644 index 00000000..7c7e0343 --- /dev/null +++ b/api/search.index/2f4092f920.js @@ -0,0 +1 @@ +rn_("BQLAAAAxKxTIFo0dFnQcZXShyQAAyRkFAsAAABmpGxLUEnN0wwL9RAEDywAAHA7GAAAaE8QAABco4QYKAACTCgAAAyHEAADUFiAcIwACwgAAFjNtb+OnCgAATxIAAFEXAADwGwAAYRD7hgUDwwAAFhIcJbAathwYfRxlc3TByQAAoxhbwAIFwgAAF4ChxAAAlw73AAD9EAkGxQAAFu/iMwEAAGIaAABHGwAAEAA=") \ No newline at end of file diff --git a/api/search.index/303f0095f8.js b/api/search.index/303f0095f8.js new file mode 100644 index 00000000..3978b50e --- /dev/null +++ b/api/search.index/303f0095f8.js @@ -0,0 +1 @@ +rn_("AUA7MAAAAQAAbQACAJIDbABTGgAAwckAALUYBQLGAAAgThyeHWIjHCQcJRwmHFsdoR1zdWHCAAAPGhAaERrBG8IbsB0FAskAABSSHRWaHWxyFQHBAAAbhx10FhrzAILmsQsAABoWAACeGwAA4BsAABccAACMHQAAlh0AABAwAAAFAsAAABFAEBB0HW9zwcQAAIUbocUAABMXJQHGAAAW2Rx3vxrsG6HBAAC/CQUCwAAAHIMdEiAYc3QFAsIAABa+HBuRHWNyIcAAAK4aihsFAsAAABOwHRIZHGR1ocUAAL0a8wADaG9z47IHAAA3FQAAmRoAAH0bAAAREBWIxAAAE6MdrBwBAP11uQcqveoE4nomGISlwwAAHKrFAAAdaSNNxI3UA0TGAx9A22e9+3Q4YukkLuQfOEADbE1LP2EFXVMOG2SQutQBLqwykjowAAABAAAAAAAlABAAAADQBdEF0gX4CQwMTwxTEFQQSBIcFB0UHhRNFU4VhxUhFiIWMxa3FrgWuRa6Fv4W/xYAF00XChgLGPQZqxsTHBgcZRxwHIQclRxyHbQdAUY7MAAAAQAA6wABAEUU6wD5/RFBSAABERITFA==") \ No newline at end of file diff --git a/api/search.index/30919374c7.js b/api/search.index/30919374c7.js new file mode 100644 index 00000000..7cf39262 --- /dev/null +++ b/api/search.index/30919374c7.js @@ -0,0 +1 @@ +rn_("JYTFAAAUqRyrGpodJYTEAAAUbxxJGpId84ECbHJRwwAAIhgjGKwZgx2HHfOCAnJ0BYbCAAAgTRcTHFIPGhAaERrBG8IbMcUAAOESNRU2FXv9JQEAwgAAEC0=") \ No newline at end of file diff --git a/api/search.index/30a0569711.js b/api/search.index/30a0569711.js new file mode 100644 index 00000000..04d483ae --- /dev/null +++ b/api/search.index/30a0569711.js @@ -0,0 +1 @@ +rn_("ocUAAJgZBQLAAAARtRYWgBpycwUCwgAAE1caE1gabHDHAYH+AAqADMIAABcnyQAAHCgFAsEAABDVHBEjFmRp+wJzevMAhIIfCAAA") \ No newline at end of file diff --git a/api/search.index/30ebb55f95.js b/api/search.index/30ebb55f95.js new file mode 100644 index 00000000..4e09e360 --- /dev/null +++ b/api/search.index/30ebb55f95.js @@ -0,0 +1 @@ +rn_("0cEAAB4MccIAAKYdpx2oHakdqh2zHbUdAQEBwgAAFWFu5KIKAADVEQAA5BIAAFYXAABpFwAAAgEQ+/wAcAFC45ISAABJFwAALBoAAGgdAABAAAUCwQAAFlAbGE0cYWMhwwAAmBq4HLHDAAAyFKHCAABYECsC7gAAEA9kbgUCwAAAFWgcHI8dYWj3BQD9BGEb4AAAG2vFAAAaVuk7CAAAyRIAABsWAABPFwAA7xkAAG8bAAAEHAAAFRwAAEkcAAC0HAAAIBAAAAAVA8AAAKmZGhcSGhNLFWFkdB0MWwPDAAAXdmRlchUBwQAAMdQaRBx7HG1nGvuH") \ No newline at end of file diff --git a/api/search.index/30f8ecb927.js b/api/search.index/30f8ecb927.js new file mode 100644 index 00000000..5d8e2c58 --- /dev/null +++ b/api/search.index/30f8ecb927.js @@ -0,0 +1 @@ +rn_("AQD9CAESxQAAHL/CAAAcKcIAABVexQAAHEPmRwwAAOkYAAAXGgAAeRsAANobAAD+GwAA0BwAADEgAAAVA8AAABxwHRemGiPGFs4aYWN0rRb7AmRy") \ No newline at end of file diff --git a/api/search.index/31367eb30b.js b/api/search.index/31367eb30b.js new file mode 100644 index 00000000..52eebad4 --- /dev/null +++ b/api/search.index/31367eb30b.js @@ -0,0 +1 @@ +rn_("McIAAIMVhBXrGwWRwAAAKawZhx0RihUFAsAAAHREEEUQ8RPyE/MT9BP1E6RGEGxv8wABY4J1AwAAIcUAABUatRwhxQAA7xkVHKvBgMDBAAAO2tIAAB2CUcAAAOwW7RaMGo0ajhoVkMAAABZIGssc8wACZHIBVQwAABMAAsMAABtzZWgBvhkAAAECwsAAyAAAHNrBAAAbf8UAABuJ71AMAAA+FgAAcxYAAAQXAABeFwAA1hgAAOgZAACcGgAApRsAAPwbAACPHAAAoxwAAMIcAADHHAAAdh0AAHgdAAAwBQETEAAgABUBxgAAE4kcbEkbgwH9AEgDwgAAGXrDAAAaUcUAABqk8+gTAADpBMEAa8ACBsoAAByDoccAAOwWAUDlpw4AAIYWAADfGQAAORoAAFwaAABgGgAABBIQFQHCAAAw6QvHGWMdc3YW+wJtcv8X/gBkskjGAAAZrw==") \ No newline at end of file diff --git a/api/search.index/31bec0fa92.js b/api/search.index/31bec0fa92.js new file mode 100644 index 00000000..cd2ff414 --- /dev/null +++ b/api/search.index/31bec0fa92.js @@ -0,0 +1 @@ +rn_("Ff7BAAxhAAAAErQWEwcZFtYax7IbFJYcqhgBAgHPAAAdanEBKxoAAPuKBf3BBBECAAAYbRwTaR0TrxwTORcVAcAAABQaHHEqCAFSOzAAAAEAAHUAAQDcHHUABQLAAACkNRoXKBxldPvEAAUhwgAA8AuoFgFDOzAAAAEAAHoABQC7DgAAGg92ANIbAACtHQAAsh0AAPOBAmVyBQLAAAAXURoSihVpcoMC/RAQBcUAABkGyAAAGrbKAAAbyDswAAABAAAkAAMA9gkAADQKIQBFEgEAGwLEAAAcbHV2fwX9EckDwgAAGlfCAAAaWOEAABYv") \ No newline at end of file diff --git a/api/search.index/320e4c7b98.js b/api/search.index/320e4c7b98.js new file mode 100644 index 00000000..88000ed1 --- /dev/null +++ b/api/search.index/320e4c7b98.js @@ -0,0 +1 @@ +rn_("AQADxgAAHWbGAAAb0MUAAB18ZXJ043UVAAB+GQAAchoAAGsdAAAjMKHBAACiGfMCAmlz5AEPAAA1EAAAUBUAAHwaAAB+GwAAAAMAockAAAoaYcAAAJASFBQVFBYUFxQYFMX9wVIBAgAAFcIZFW8aFo8bE1kXF90b2hP7wAaE") \ No newline at end of file diff --git a/api/search.index/32929b3b2f.js b/api/search.index/32929b3b2f.js new file mode 100644 index 00000000..8c1775d2 --- /dev/null +++ b/api/search.index/32929b3b2f.js @@ -0,0 +1 @@ +rn_("BQLAAAAbfx0SUBpydBuJygAAHXMrAskAAB1oZXQxxwAAeRvaG9AcNQHFAAAW1xx3tRqNG6QcBQLCAAAYZx0afh1ydfuJFYLDAAAUPxyGHBOBg8cAABy01wECwBJixwAAHErhfhUAAEwaAABAIcMAAGkcaxxRyQAAmhubG5wbnRuVHQUCwAAAECAXEvQWZG7zBIThnBkAAG4dAAAwBQLAAAAxXRdfF70ZNDwWCRpeHWVz+wJldCHBAABfHa4dIcgAAPcYkRsFAsAAABXGErNtFWhvtwcA/gAHgA/LAAAb3MMAABwG5xgIAADdDgAAGQ8AADoQAAAhGAAAFBwAAEccAABUHQAAZQAAAAFEOzAAAAEAAF0AAgBBEAEA2RFbAPuH") \ No newline at end of file diff --git a/api/search.index/331f87f05f.js b/api/search.index/331f87f05f.js new file mode 100644 index 00000000..78239904 --- /dev/null +++ b/api/search.index/331f87f05f.js @@ -0,0 +1 @@ +rn_("AULiWBUAAMUZAACyGgAAUAAbhsEAABuqIcIAAEcWHBxbA8gAAB1TYW9yBQPAAAATEhgWcxwTExhhbXIViMYAAB+vHeEbNwIA/RFBDcYAAB2CwwAAGBTGAAAZxMMAABwe+8oOAADHB2YClwIDAEgARAAxABgAFwEhAA==") \ No newline at end of file diff --git a/api/search.index/33398ea7d0.js b/api/search.index/33398ea7d0.js new file mode 100644 index 00000000..c08cce1b --- /dev/null +++ b/api/search.index/33398ea7d0.js @@ -0,0 +1 @@ +rn_("BQLAAAAVZhoZcBppbQUCwQAAFCsaIpQaRRtmdLHHAAC5GROCAtIAAB2PZXMFAsAAABhMGhM0HG50GwLMAAAcPW51GwLNAAAcPm9ynwr+AAewC8YAABbj0QAAHNXEAAAVOscAABjR0AAAHK0=") \ No newline at end of file diff --git a/api/search.index/336e4ed4cc.js b/api/search.index/336e4ed4cc.js new file mode 100644 index 00000000..042ea4a6 --- /dev/null +++ b/api/search.index/336e4ed4cc.js @@ -0,0 +1 @@ +rn_("ocMAAEIWQcYAAPAZ8RnyGWUdBQLAAAAUBBkVPRxhbPMDwALA1KYcAAAHBujzAAFkOjAAAAEAAAAAAB8AEAAAADcSUhJTEoISgxKEEucS/BMfFCAUIRQ0FW0VbhVvFY0VJRYoFikWFxcEGLoYqBqpGqoasBuxG7cbGRw7HI0cjh0BQzswAAABAACAAAIAQRYAAIIXfwChxQAA6xlF/cMDgAIAACVHHFQdM9cbzxxgHSOzGtgbFUgc3xsfHHUdhB0xxAAA2RjqGZMc+wJjdfMAAmFwg7kLAAD7A2RpdA==") \ No newline at end of file diff --git a/api/search.index/33bc58afd8.js b/api/search.index/33bc58afd8.js new file mode 100644 index 00000000..3db6d55d --- /dev/null +++ b/api/search.index/33bc58afd8.js @@ -0,0 +1 @@ +rn_("IcMAAGUVUx0FAsAAABlrHBZwG2F3BQLDAAAUvhsWqBtpclsDxQAAGB5hbXQBggLQAAAdssEAABtvaXYVAcMAABWoHHBBGpOC/QCJCsUAABu9xAAAG27CAAAaQvsDYWV0") \ No newline at end of file diff --git a/api/search.index/33e0f12a5f.js b/api/search.index/33e0f12a5f.js new file mode 100644 index 00000000..b3b807c9 --- /dev/null +++ b/api/search.index/33e0f12a5f.js @@ -0,0 +1 @@ +rn_("BQLAAAAT3hIYmBpjZRUBxAAAMF8bYBuoG3NiFSuDwQAAFCIHDAD+AEXwB8cAABo3xwAAGdbDAAAbvsUAABZtxAAAFs7DAAAWLsIAABQjyQAAGdrjNggAAN0JAAAoFwAANBoAABMA") \ No newline at end of file diff --git a/api/search.index/342884e43f.js b/api/search.index/342884e43f.js new file mode 100644 index 00000000..6185e0ea --- /dev/null +++ b/api/search.index/342884e43f.js @@ -0,0 +1 @@ +rn_("FfzADwAAABBfFhBgFhBhFhBiFk0WFfzA/wMAABBVFhBWFhBXFhBYFhBZFhBaFhBbFhBcFhBdFhBeFkwWBfzA/gMAABBMFhBNFhBOFhBPFhBQFhBRFhBSFhBTFhBUFnMA/P8D4AAAFk7gAAAWT+AAABZQ4AAAFlHgAAAWUuAAABZT4AAAFlT1FQgAAAEA5QFaDAoA") \ No newline at end of file diff --git a/api/search.index/34941a1a54.js b/api/search.index/34941a1a54.js new file mode 100644 index 00000000..e805fcf2 --- /dev/null +++ b/api/search.index/34941a1a54.js @@ -0,0 +1 @@ +rn_("JYTFAAAUqRyrGpodJYTEAAAUbxxJGpId84ECbHIFhsAAACHwC6gWUiIYIxisGYMdhx2hwgAAlw4BQzswAAABAAB6AAUAuw4AABoPdgDSGwAArR0AALIdAAD7/RABBQ==") \ No newline at end of file diff --git a/api/search.index/34e3637f1f.js b/api/search.index/34e3637f1f.js new file mode 100644 index 00000000..6519ae7f --- /dev/null +++ b/api/search.index/34e3637f1f.js @@ -0,0 +1 @@ +rn_("NQHEAAASfxtrXhePHKMcI4ICxgAAG6ZzdLHBAABWEgGCAtEAAB2ZxgAAG6Vlc1cBAP3AIQLHAAAaVMIAABYY9v4JAACDAAEAxwf2A34FocUAAJMZEwICxQAAHGdmdOScFgAABRwAAIocAACgHAAAqx0AADAQAPOCAml5IwACwAAAGPJscgEWFgAA") \ No newline at end of file diff --git a/api/search.index/34f1b758e3.js b/api/search.index/34f1b758e3.js new file mode 100644 index 00000000..346657ae --- /dev/null +++ b/api/search.index/34f1b758e3.js @@ -0,0 +1 @@ +rn_("BYPAAAASGRgUOxVFAsIAABXaGBqyHHB0OBA5EJUalhohwAAAxA64G/sCcnSjAP0BiQjJAAAay8UAABY/xgAAHI71EhYAAAEANAQpAVUA") \ No newline at end of file diff --git a/api/search.index/3527292415.js b/api/search.index/3527292415.js new file mode 100644 index 00000000..4f2c5589 --- /dev/null +++ b/api/search.index/3527292415.js @@ -0,0 +1 @@ +rn_("IccAAIAcZh1RxwAAShtLG0wbTRuLHTMA/RJADcUAABdcxgAAG9DFAAAdfMgAABuZ6NUOAAB1FQAAfhkAABMaAAA/GgAAchoAAIoaAAC2GwAAax0AADIwAwAAMcEAAOkTohmjGaMA/QSBAsMAABcXxAAAHGrpBwwAAAEPAAA1EAAAUBUAAIYVAACpGQAAsBkAAHwaAABkGwAAfhsAADAAAAMA") \ No newline at end of file diff --git a/api/search.index/35f9a1ab38.js b/api/search.index/35f9a1ab38.js new file mode 100644 index 00000000..1f8c3692 --- /dev/null +++ b/api/search.index/35f9a1ab38.js @@ -0,0 +1 @@ +rn_("IckAAKsZzhzByAAAchoFA8MAAFP8Gf0Z/hn/GXkdF4YdF/4baXByU4IDxgAAGgBiZWxhwwAAPRI+Ej8SQBKlGvobFQLEAAAUOxwYjh1jc7MZ+wJuciHCAABCFLEZ+/0RSAA=") \ No newline at end of file diff --git a/api/search.index/363e4dfe30.js b/api/search.index/363e4dfe30.js new file mode 100644 index 00000000..ff404edc --- /dev/null +++ b/api/search.index/363e4dfe30.js @@ -0,0 +1 @@ +rn_("ccQAAJkSmhKbEpwSHhqbGskcBQLAAAAmwxzHHDeiG6Mblx1ldFHKAAAjHCQcJRwmHKEd84MCZHNRxwAAmhubG5wbnRuVHfsDY2lv") \ No newline at end of file diff --git a/api/search.index/366876ccad.js b/api/search.index/366876ccad.js new file mode 100644 index 00000000..85ce30ad --- /dev/null +++ b/api/search.index/366876ccad.js @@ -0,0 +1 @@ +rn_("tQHAAAAl+xZvHXNADAUCwAAAExcYGLgcY2UFAsAAABeMHCrJG0EcYWgnBAD9HUECwQAAErLDAAAcA8YAABx/wQAAFUDCAAAXFePMDgAAkhYAAEwcAACeHAAAEwAlAcAAAFAFGgYaBxoIGnsdc1IRaR3DAP1AEQLDAAAY7cEAABuM6LULAAD3EwAAqRYAAMQWAABiFwAAzBgAAGwcAAChHAAAWh0AABQwAwAA+wJlebHEAABADAEAA8cAABw8xgAAGPLDAAAap2FmaTowAAABAAAAAAAjABAAAAA+CD8I/An9Cb0L0wuXDpgOwQ4mECcQKBApECoQoxbaFtsW3BbdFt4W4BbhFiIYIxjAGMgYfRmNGawZrhnWGqobcx1/HYMdhx0xwQAA1QtYEFkQOwPAAAAcKWZnaQFH4R8aAABFHAAAsPMChOEKCgAAnQoAAAEbAsAAAA6kYXAhxwAAUxfGGjUBwAAAFhAcd20XbheoGTHAAADYC9kLkw77AmRyJYbFAACkeRwZexwWHEYc+wJjZfsCaXmhygAA3xkFisEAAKIvFqXBGOHDAAAsFAFBiAcQAAChxQAAkxkhxwAA4BuMHTHOAADSG60dsh3XCwD9NeEEzAAAHbTDAAAaPsUAABxn6zoIAAAIFAAAKxYAAJwWAAATGAAA7RkAAPcZAAC2GgAABRwAAIocAACgHAAAqx0AAAIDAAABAPsCc3ahyQAA3RjzAAFy4kcJAACVCwAADxAAAAzgMcAAAFYQVxA8FSMDAsMAABjcb3I7MAAAAQAAKQIGAL8OAQCaD2wAvBIAAPkS3wAMGWwA2BpsABsCwAAAG8psbt9d/kAfPArAAAALuMMAAAu+xAAAEuDNAAAb3A==") \ No newline at end of file diff --git a/api/search.index/36bdb296db.js b/api/search.index/36bdb296db.js new file mode 100644 index 00000000..8ee2b15e --- /dev/null +++ b/api/search.index/36bdb296db.js @@ -0,0 +1 @@ +rn_("BQLAAAAQtAsREBR0eAFB5JAOAAAyEAAABhQAADIUAACAGgAAIAIABQLAAAATShYdrxx0dSHHAABfHa4d+wJkc/sCbG07i8cAABzV") \ No newline at end of file diff --git a/api/search.index/37f2b7ce76.js b/api/search.index/37f2b7ce76.js new file mode 100644 index 00000000..71d9bf5a --- /dev/null +++ b/api/search.index/37f2b7ce76.js @@ -0,0 +1 @@ +rn_("AUE7MAAAAQAA3AACAIwDAAARBtsAocUAAJgZBQLAAAARtRYWgBpycwUCwAAAE1caE1gabHAbAsQAABn4Zm4jAAPDAAAYFcgAABzVYmVu4VYQAAA8FQAAEAUCwgAApDUaFygcZXQVkMAAABInFxoHQcQAAJ0SJhe2GbcZ5xoA/sAKkA3DAAAdgsMAABjc4AAAB+Q7MAAAAQAANAIPAAAGAQADCAAAvw4BANMOAQCaD2wAdBAAAK8SAAC8EgAA+RLfAI4WAAAWFwAAfRcAAAwZbACyGQAA2BpsADMAA+AAAAN7ZWl6AXsDAAA=") \ No newline at end of file diff --git a/api/search.index/37fec4f07e.js b/api/search.index/37fec4f07e.js new file mode 100644 index 00000000..8edf3130 --- /dev/null +++ b/api/search.index/37fec4f07e.js @@ -0,0 +1 @@ +rn_("BQLAAAClRRUolhl+HXByGwLAAAAS7W5yMcMAANESHxYgFiHEAAAFFG0bFYHGAAASDRy8GiMDAsQAABpaaHfyShUAAPIE+wJ0eQUCwQAAJiIXZx0VrRZhcAFE40oQAAABFAAAbBsAAK0bAAAzABsCxwAAG8NscgFF46kWAADMGAAAoRwAAFodAAAzAAWSwQAAIQAUtBkSMxzzgQJpdnUBwQAAokgVcvEJ8gnqFusWdBd1FwIcBQLGAAAYUxwVpxtjZiuKxQAAFqghyAAAIRdxG/c+AP08gQ/BAAAOpeMBCgAAjgoAAOUZAACvHQAAQQA=") \ No newline at end of file diff --git a/api/search.index/37fee1b22e.js b/api/search.index/37fee1b22e.js new file mode 100644 index 00000000..a931ac5f --- /dev/null +++ b/api/search.index/37fee1b22e.js @@ -0,0 +1 @@ +rn_("AULjDhQAAAIYAADgGAAASBsAABEgBQPAAACn7BYmFRq1HBKDFWhvebUDwAAAFQwYF9McFUYbZXR1OwkhwAAAahYoHGUCwQAApucWIusYGhpjbHAawRreGzkcbhxkHWsDwQAAGfdpb3khxwAAZhtZHRMAAscAABgfZ207MAAAAQAAOQIFAHoDAADoBNsAGAoAALQN2wBTEX8ABYPAAAAS1hkVrx0FisAAABN9FhKHFQUCwAAAEq0ZW7kcuhy7HLwcsR1veSHCAACGFakZxQHCAADFVBtueRYbAsYAABrUZHQVAcEAAGTJFsoWyxbMFj8XQBd0DQoBAAHJAAAdombhKxcAAFUcAACB8wABc4JCFgAAscIAAOQWFQHAAAAd1hxixRrzgYb7AmFrFQHDAAAWChx3VRchwwAABRmWGyUBwwAAE3gbbukb+xvzAMAIiIL2EgAAg4H9EgADxQAAG0fDAAAXVMQAABjbvwX9DTAG4wAADw3LAAAchAUCwAAAEoUVtowaaXIhwwAAhBXrGyHBAADTEVgb8wECaXToKwoAAMMSAAA1FAAAUxUAAGQXAADlGAAAyxkAADQcAACYHQAAQAIREAAVAcIAADHUGkQcexxtZxohzgAAHxyEHVMAwQEExgAAFkoBnAoAAGHFAAAKGAsY9BmrG2UctB1lAcEAABODFXTsE+0T7hPvE/ATyBkBReSGFgAA3xkAADkaAABcGgAAYBoAAEEhAO8DxAQu4AAAGKKhwwAAQhZBxgAA8BnxGfIZZR0FAsAAABQEGRU9HGFs0wPAAsHDAAAdfdSmHAAABwbosdAAAFUdNQHDAAAQgRpzxQ7GDg0WG5TKAAAcjQEA/uDmnkPAAAAHBgBaQMMUT1JPBNwKSPqbljlk7oywSl+u2iERh4bckcgAABrValTwOdhNo0w9KnkL95WwO9j79KtHavENyxTxnV/nWXCDi4E3yMyEaTswAAABAAC/ADkAMQEAAAkIAQAoCQQAMAoCAJkKAAAtEAAAQRABAEQQAQDZEVsANxIAAFISAQCCEgIA2hIAAOcSAADsEwkA/BMAAB8UAgA0FQAAbRUCAIUVAACNFQAAJRYAACgWAQBFFgEA8RYBABcXAAAjFwIAQRcAAAQYAAAkGAQAuhgAAPYYAACrGQAAvxkAAMgZAADRGQIA2hkAAEoaAQCgGgAAqBoCALQaAABnGwAAsBsBALcbAADJGwEAGRwAABscAAA7HAAAPxwAAEEcAAB4HAIAfxwBAI0cAAC+HAAAzhwAAGYdAACOHQAA") \ No newline at end of file diff --git a/api/search.index/38532243cf.js b/api/search.index/38532243cf.js new file mode 100644 index 00000000..3da5c86b --- /dev/null +++ b/api/search.index/38532243cf.js @@ -0,0 +1 @@ +rn_("FQHCAAAWChx3VRcricEAABZKIcMAAAUZlhshyAAAaxrAHBUBwgAApLYZYq8SJQHDAAATeBtu6Rv7G8cVAP0xyQ7BAAAXUMUAABt1xAAAHWrEAAAXbMwAAByv7AQIAAC/CQAArRIAAPYSAAD+FQAAexoAAJobAABAHAAAZBwAAIIcAABwHQAAhR0AAJUdAAARETAwAAAA") \ No newline at end of file diff --git a/api/search.index/38d1105793.js b/api/search.index/38d1105793.js new file mode 100644 index 00000000..7ecac20a --- /dev/null +++ b/api/search.index/38d1105793.js @@ -0,0 +1 @@ +rn_("ocMAAA4UFYTGAAAUlxyPGiuDwQAAFCoFkcAAAOIkFKTPFgUCwAAAUBEPlg+XD5gPmQ8jGBe0GGRuJwcA/gBIiwrCAAAWm8IAABjuwAAAFxXEAAAakTowAAABAAAAAAAdABAAAAAnCCgIOAjNCc4JCwyiDqMO8Q4TD9gRPRI+Ej8SQBI7FUcXbBcSGKgYyhjaGKUaShtLG0wbTRtuG/obix0=") \ No newline at end of file diff --git a/api/search.index/396a4312c9.js b/api/search.index/396a4312c9.js new file mode 100644 index 00000000..18f09541 --- /dev/null +++ b/api/search.index/396a4312c9.js @@ -0,0 +1 @@ +rn_("NQHEAAASfxtrXhePHKMcI4ICxgAAG6ZzdLHBAABWEgGCAtEAAB2ZxgAAG6Vlc1cBAP3AIQbHAAAaVMIAABYYxgAAG3P2/gkAAIMAAQDHB/YDfgWhxQAAkxkTAgLFAAAcZ2Z05JwWAAAFHAAAihwAAKAcAACrHQAAMBAAawPgAAAcDWdpeQ==") \ No newline at end of file diff --git a/api/search.index/3970a6fa34.js b/api/search.index/3970a6fa34.js new file mode 100644 index 00000000..48d9d4c6 --- /dev/null +++ b/api/search.index/3970a6fa34.js @@ -0,0 +1 @@ +rn_("FQHEAAAUNhxkCBmhxQAADBrzggJwcQXAwAEoAAAVjx2lbxcWjBUFwMEAoQAAGngaGWoaEsAaJYTFAAAUqRyrGpodJYTEAAAUbxxJGpId84ECbHJRwwAAIhgjGKwZgx2HHfOCAnJ0FQHAAAAlmxrJHHaqGSHEAACpGDIcocYAAAoaMcEAAO0LMRZjGwUCwAAAUwoZ8BnxGfIZZR0QcR1vc2cHAP0WAQfCAAAaU8MAAByX6s4LAADCEgAASxYAAMQZAADhGQAAjxoAANAbAADTGwAACRwAAHEcAADWHAAAEAAQAQEABYbBAAAgTRcTHFIPGhAaERrBG8IbKwLJAAAcjmhvMcUAAOESNRU2Ffc4AP01gU7CAAAQLcUAABo4zAAAHM7quAcAAEwMAABJEAAANRIAAPISAABBFQAAAhYAABkWAABtFgAAXBcAALccAAACATAAAAA=") \ No newline at end of file diff --git a/api/search.index/39b42cf26b.js b/api/search.index/39b42cf26b.js new file mode 100644 index 00000000..b62f40a3 --- /dev/null +++ b/api/search.index/39b42cf26b.js @@ -0,0 +1 @@ +rn_("FYTEAAAUiB1ZGqHCAADTDjHFAABRF1IX8Bs3AgD9gIlOwgAAF37CAAAXf8cAABl71QAAHa7DAAAQQ/VgCgAAowQeAQEAIwo=") \ No newline at end of file diff --git a/api/search.index/39ca75e29f.js b/api/search.index/39ca75e29f.js new file mode 100644 index 00000000..e5f91987 --- /dev/null +++ b/api/search.index/39ca75e29f.js @@ -0,0 +1 @@ +rn_("IcMAAIQV6xshwQAA0xFYG/MAAml06CsKAADDEgAANRQAAFMVAABkFwAA5RgAAMsZAAA0HAAAmB0AAEACERAAtYTCAAAW/Ru5GROCAswAAB2ZZHOTAMASwMMAABBQwgAADrSCEgcAAA==") \ No newline at end of file diff --git a/api/search.index/39ea6647c2.js b/api/search.index/39ea6647c2.js new file mode 100644 index 00000000..a4f0c85b --- /dev/null +++ b/api/search.index/39ea6647c2.js @@ -0,0 +1 @@ +rn_("MccAAKIboxuXHTUBxQAAFokbd3MWwxzHHAUCwQAAp+EZR+MZ5BmAHYEdcnP7AmV0+wJldAUDwwAAGFQcEaUcNmEafBx+HGJlcwUCxgAAIE4cnh1iIxwkHCUcJhxbHaEdc3UrisUAAB2wocQAADcVBYXAAAAVZhUV4hbnKAD9j5ETyQAAGq7EAAAWOccAABjzwwAAFx3FAAAbfMMAABU55yMHAAARFwAAHRoAAE4bAAAGHAAAph0AALMdAAC1HQAAEAEEACsCxAAAHXRjbw==") \ No newline at end of file diff --git a/api/search.index/3a4a01dd39.js b/api/search.index/3a4a01dd39.js new file mode 100644 index 00000000..7c619ac0 --- /dev/null +++ b/api/search.index/3a4a01dd39.js @@ -0,0 +1 @@ +rn_("FQHGAAAYyxx0kRxVAcUAAB+iHXSlFqYWTBctHNscIcgAAKsZzhz7AmxzwcgAAHIaBQLAAACkkBkWABpkcgWJwQAAYvQb9Rv2G/cb+BudHWRfHGAcYRxiHGMcpR1xxwAAmRyaHJscnBydHIYdrB1RwwAA/Bn9Gf4Z/xl5HbOD/QCBA8YAABv+8wIDYmVs4d4TAAC/GwAAAQUCwAAAG7UcYj0SPhI/EkASpRr6G2NzFQLEAAAUOxwYjh1jc7MZ+wJucgUCwAAAdEQQRRDxE/IT8xP0E/UTpEYQbG8FAsAAABMLFyFCFLEZcnT3AwD9FUgJwAAACCKCdQMAAA==") \ No newline at end of file diff --git a/api/search.index/3a83f9bc41.js b/api/search.index/3a83f9bc41.js new file mode 100644 index 00000000..f0ad2977 --- /dev/null +++ b/api/search.index/3a83f9bc41.js @@ -0,0 +1 @@ +rn_("BQLEAAAyihyLHKsdED8WaXkBAALgAAAKYMEAABY2c3bndwMAAEUJAACUDgAAcBAAANgWAABFFwAAwhkAAFoaAAAgAgEACQLEAAAXOs8AAB1mc3R7j8YAABj7") \ No newline at end of file diff --git a/api/search.index/3ac3539af7.js b/api/search.index/3ac3539af7.js new file mode 100644 index 00000000..cd3c7812 --- /dev/null +++ b/api/search.index/3ac3539af7.js @@ -0,0 +1 @@ +rn_("IcoAAKwZhx0JAsEAAA7a0gAAHYJrdAEDwsAAyAAAHNrBAAAbf8UAABuJ71AMAAA+FgAAcxYAAAQXAABeFwAA1hgAAOgZAACcGgAApRsAAPwbAACPHAAAoxwAAMIcAADHHAAAdh0AAHgdAAAwBQETEAAgAPsDaW9y") \ No newline at end of file diff --git a/api/search.index/3b716596fb.js b/api/search.index/3b716596fb.js new file mode 100644 index 00000000..98c48ebf --- /dev/null +++ b/api/search.index/3b716596fb.js @@ -0,0 +1 @@ +rn_("occAAMwbIwT9MAAFyQAAHXHFAAAcQscAABzL7MYLAABDDAAAeBYAABsXAADBGAAA6BgAAMYZAABoGgAAkRwAAJkcAACGHQAAnx0AAKwdAAAxABAABAAA") \ No newline at end of file diff --git a/api/search.index/3bb7391ee9.js b/api/search.index/3bb7391ee9.js new file mode 100644 index 00000000..37b975e9 --- /dev/null +++ b/api/search.index/3bb7391ee9.js @@ -0,0 +1 @@ +rn_("FQHCAAASFhZsFwwVAcMAABPfGGRDFQUCxQAAFLgaFLkab3QVhMIAAKGOGUIVNQHAAAAQkBtz/RP+E9Ua+wJkdAFE4/EWAAAkGAAA9hgAAGcbAAAUABsCwwAAFvNsb3cBAv0EkQjKAAAbfuUVDAAAjxYAAHkaAABYHAAAwRwAANEcAAAREAH7AmZ0AUSiYBIAAKHIAAB7FxWIwQAAH68d4RtjAQPFAAAWdmFlcwHyBgAAIcIAACcMOBoFwMABRAAAEvIYNdoSqxnOHBXVG0UCwAAAFoMbFoQbbXdDF0QX6xgaGjUBwAAAHWMdcpoOmw5hEBUBxAAAMwIYAxhIG3QDF7vABiHDAAAWtqHCAADSEgUCxQAAFkAcE30baG/zg4OXZwD+gEc4TMUAABpGxwAAG47HAAAaeMIAAAuo4ScBAACBAwAAMA==") \ No newline at end of file diff --git a/api/search.index/3bdd79e4f9.js b/api/search.index/3bdd79e4f9.js new file mode 100644 index 00000000..5c897fe6 --- /dev/null +++ b/api/search.index/3bdd79e4f9.js @@ -0,0 +1 @@ +rn_("FQLEAAAVihuj9BhjbK0aEwACzAAAHKxhY4UeDAAAFYLFAAAUUxtpG/MAguIXCAAAARoAAHodAAADAPMAAnN3gw4HAAA=") \ No newline at end of file diff --git a/api/search.index/3bea74271a.js b/api/search.index/3bea74271a.js new file mode 100644 index 00000000..a051d7c5 --- /dev/null +++ b/api/search.index/3bea74271a.js @@ -0,0 +1 @@ +rn_("IcgAAGsawBzzAAFs6L8JAAD+FQAAexoAAJobAABAHAAAZBwAAIIcAABwHQAAlR0AABMDAAAAFQHBAAAY7RlzehkVAcUAABdhHW0wHPMAAXMBvAsAAAXAwQRCAAAR0BgYohwWah37AmFrAUXiWBUAAMUZAACyGgAAUAAjgQPEAAAWa8YAABxdcnN1+wNhY24VAcIAABIWFmwXDBUBwwAAE98YZEMVBQLFAAAUuBoUuRpvdBWEwgAAoY4ZQhU1AcAAABCQG3P9E/4T1Rr7AmR0AUTj8RYAACQYAAD2GAAAZxsAABQAGwLDAAAW82xvdwEC/QSRCMoAABt+5RUMAACPFgAAeRoAAFgcAADBHAAA0RwAABEQAfsCZnQxwQAAmg6bDmEQWwPCAAALqGV1eSHDAABfHa4docIAANUWFQHAAAAXoxxzvRsjgwLIAAAcZ2l5PwHBAYbWAAAdsMEAABK6BQLCAAAQSBASxRJldEHCAADjEgwUOhY7FsHHAAAOGCHDAAD4EggcBQLDAAATBxkW1hpmaxUBxwAAFR4ccw0YBQLBAAAbHRwZjhtibPcBAf0pgQ7EAAAcBMMAABWLwgAAG2ryMwkAAH8Py8ECgssAAByQwgAAGkoFAsAAABaBFxA/CW9zBQLAAAARyhoSaBdtcyHPAACzHJsd+wJ1efcrAP05aQPCAAASVcIAABa26cUHAAAnCQAA9QkAAMYSAADjEgAADBQAADYWAAA6FgAABBkAAN0bAAAwAAABAA==") \ No newline at end of file diff --git a/api/search.index/3c8e9e7de5.js b/api/search.index/3c8e9e7de5.js new file mode 100644 index 00000000..e3c5d59a --- /dev/null +++ b/api/search.index/3c8e9e7de5.js @@ -0,0 +1 @@ +rn_("BQLCAAAUwRIbUxplcgUCxgAAGFQcFn4cYnMbwAwgxAAAGarMAAAbRgWDwAAAFAsXHbUcGwLMAAAcg3B0FQHDAADA8hJzxBo1AcEAABYQHHdtF24XqBmhygAAeRzzggJjZfOBA2lzdOuPwwAAGpE=") \ No newline at end of file diff --git a/api/search.index/3dfc897795.js b/api/search.index/3dfc897795.js new file mode 100644 index 00000000..8da7aa03 --- /dev/null +++ b/api/search.index/3dfc897795.js @@ -0,0 +1 @@ +rn_("AQECwgAAGlPDAAAcl2Jp6s4LAADCEgAASxYAAMQZAADhGQAAjxoAANAbAADTGwAACRwAAHEcAADWHAAAEAAQAQEAFYTCAAAZRxtBF/sCZXM=") \ No newline at end of file diff --git a/api/search.index/3e17052546.js b/api/search.index/3e17052546.js new file mode 100644 index 00000000..984233ed --- /dev/null +++ b/api/search.index/3e17052546.js @@ -0,0 +1 @@ +rn_("NQHEAAASfxtrXhePHKMcI4ICxgAAG6ZzdAFG4R8aAABFHAAAsCsC4gAAElZkbQGCAtEAAB2ZxgAAG6Vlc1cBAP3AIQbHAAAaVMIAABYYxgAAG3P6/gkAAAwAdwABABsAAQCrB/YDBwV3AA==") \ No newline at end of file diff --git a/api/search.index/3e6d42a59f.js b/api/search.index/3e6d42a59f.js new file mode 100644 index 00000000..a1a1ab0a --- /dev/null +++ b/api/search.index/3e6d42a59f.js @@ -0,0 +1 @@ +rn_("QcUAAC4aLxowGqscEwD9EAZQyAAAHNrBAAAbf8UAABuJxgAAHY7vUAwAAD4WAABzFgAABBcAAF4XAADWGAAA6BkAAJwaAAClGwAA/BsAAI8cAACjHAAAwhwAAMccAAB2HQAAeB0AADAFARMQACAAdQHAAAAS0Rtr7xjZGUMaRBr/G70czRwjhQLBAAAdiWl5McYAAOAW4RaNGQUCwAAAE6caQvYW9xaaGZsZbnQxwwAA2hKrGc4cFQHFAAAQTBxzYhcrAsQAABjybHYbmccAABnlxwAAGcghxAAAuhg7HKc3AP5Aa1EBxwAAHDzKAAAdhcYAABukOjAAAAEAAAAAAFEAEAAAAO8FPgg/CPwJ/Qn/CQAKCAoJCg4KDwqaCpsKvQvTC5cOmA7BDpEPkg8mECcQKBApECoQShJLEkwSTRJOEsQS5RL2EwMWKhahFqMW2hbbFtwW3RbeFuAW4RYiGCMYoxikGKUYphjAGMgY/xgAGQEZAhl9GYsZjRmsGa4ZsxkOGg8aEBoRGoIagxqEGoUa1hqqG8EbwhvDG8QbXRxzHX8dgx2HHbAd") \ No newline at end of file diff --git a/api/search.index/3f3c2046da.js b/api/search.index/3f3c2046da.js new file mode 100644 index 00000000..ae77b7e5 --- /dev/null +++ b/api/search.index/3f3c2046da.js @@ -0,0 +1 @@ +rn_("FQHBAAAX5hlw0RYVAcgAABCZHXNSGhODAuEAABI1Y3MlgcAAABF5GdsSMxwbAuAAABgpZWwxxAAA4xI6FjsWBQLAAAAUXB0kwRpkHW1uocUAAHQW+wJobzMBwAKBxwAAGkvq6QkAAJkSAACzFgAAxRYAAJgZAAAVGgAAHhoAAJsaAADIGwAAtRwAAMkcAABzABAAAAAhwgAA4BL7GCHBAABBFFcaAQAB4AAAGpFzOzAAAAEAACUAAwAgBwIAtg4AAGASIQChyAAAdhoFAsAAABE/FTX5GfoZ/RtkdCMAA8AAABcaxgAAGftiaXODuRkAADHMAAAMHCccXR2zgf2MAQDEAAAWSf8e/XgYB8EAAAuo") \ No newline at end of file diff --git a/api/search.index/3f4d1f777c.js b/api/search.index/3f4d1f777c.js new file mode 100644 index 00000000..cf778ff0 --- /dev/null +++ b/api/search.index/3f4d1f777c.js @@ -0,0 +1 @@ +rn_("ocQAAHYaZQHAAAAZSRxmgxmEGYUZhhnxG2wdYwLAAMjEAAAcqPgKDwAAygMkAHoDrgEhAkoAfQEFAsAAABipGxuDHWF0AUA7MAAAAQAAfwABAFMRfwArisMAABpnIcIAAJccqRyPAv4ASUgCxgAAG7jAAAAY0sAAABY0wwAAG69BwgAAWhJbElwSORYBAAHgAAAbjHM7MAAAAQAA5gAHAEIIAAC4CtsAEAwCAJ8OAADHDgIAIxYAACoYAQC/A8AUxsEAABqX") \ No newline at end of file diff --git a/api/search.index/3f7a47d957.js b/api/search.index/3f7a47d957.js new file mode 100644 index 00000000..5ec2b42d --- /dev/null +++ b/api/search.index/3f7a47d957.js @@ -0,0 +1 @@ +rn_("BQLAAAAUoh0TSRxjcvMAgoJDFAAAFQHCAAAd2xxyMRplAsEAABF9FRQEGGFjnQ6eDiEXcRs6HFMc84GGwcUAALIb+4MFg8EAABM8EBAqCjv9AUgIwwAAF0HOAAAcHMHKAAD/GBUBxwAAELccc7EaMcAAAO4L7wumGkMCA8EAABA7wgAAErthaXM7MAAAAQAAdgAGAI4VbwAmFgAA5xYBAGIbAAC7GwEAXB0AAPuTIcYAAH0Z1hphxwAAjxaQFnkaehrRHNIcBQLAAAAUNhAhAxZdHGNuIcMAAJYWBxkVhMIAACMfGLIcHRcTAALAAAAWtGR4ATwXAAABRjswAAABAABvAAEAjhVvAKHCAAA3CfdfAP5AzWUBwwAAHJY7MAAAAQAAwgEGAHQDAAAIBN8ACwgDABcKAADYDNsAqhgAAA==") \ No newline at end of file diff --git a/api/search.index/401ee11d61.js b/api/search.index/401ee11d61.js new file mode 100644 index 00000000..201e0c83 --- /dev/null +++ b/api/search.index/401ee11d61.js @@ -0,0 +1 @@ +rn_("BQLAAAAbfx1S+xZQGrAathxvHXJ0G4nKAAAdcysCyQAAHWhldAUCwAAANXkb2hvQHKXTG3BzwcQAAP8Y+4OhxQAANBc1AcUAABbXHHe1Go0bpBwFAsIAABhnHRp+HXJ1+4kVgsMAABQ/HIYcE4GDxwAAHLQXDwD9VJQHxQAAHBLHAAAcSsEAABvRxwAAHH3tnwoAAEAMAABHFQAAfhUAAK4WAADvGAAA2RkAAEMaAABMGgAAbBoAAP8bAAC9HAAAxRwAAM0cAAACBAABAAAA") \ No newline at end of file diff --git a/api/search.index/403bc0153d.js b/api/search.index/403bc0153d.js new file mode 100644 index 00000000..cf1a3bd1 --- /dev/null +++ b/api/search.index/403bc0153d.js @@ -0,0 +1 @@ +rn_("AQOBwQAAHA3tWBcAAN4ZAABNGgAAVhoAAF4aAAC8GgAAhBsAAIkbAADWGwAAChwAABAcAABDHAAAdhwAANccAAAAAAAAAQAS") \ No newline at end of file diff --git a/api/search.index/404ce9fcbf.js b/api/search.index/404ce9fcbf.js new file mode 100644 index 00000000..4796c734 --- /dev/null +++ b/api/search.index/404ce9fcbf.js @@ -0,0 +1 @@ +rn_("BYnBAAASnR0UpR0jjQLGAAAdrHBzpQHEAACmdhx31BkTgQLIAAAcoW5yBQLEAAAaoR0Ylx1kcwUCxAAAHbQdF4wdY2UblskAAB17yAAAHW0FAsMAABRsHRV6HWltQ4HABkDPAAAdscwAAB2cBQLAAAAU+hsaeR1lbAUCwAAAFqAcG5UdYnYrhcgAAB1rFQHAAAAXix1ithvzAgFzAWQbAAArAsUAAByfbXQFAsEAABSUHRirHWlsI4cCxwAAHaRkZQGEAtMAAB21xwAAHZNvc1f2gv5AW6oJxgAAHGTHAAAcnsQAABv5zAAAHZYFAsAAAB+KHRQzGmxyVwUA/UxSC8UAABqTygAAHMzMAAAdbsQAABo0zAAAHVrmjBIAAOYSAABIFwAAYBcAAN0YAADjGAAAyRkAADAAERAjgQLBAAAWK2l5IcIAAOYZoB3zgowVgcQAABEyGlccBQLAAAAR7RsRWxtydPuJIcIAAMUYWxozAAPEAAAXPWl0dQGgCgAAwcIAAIIaBcDEAEoAABrKHBfzGxSSGgEA/QgBEsUAABy/wgAAHCnCAAAVXsUAABxD5kcMAADpGAAAFxoAAHkbAADaGwAA/hsAANAcAAAxIAAAFQHAAAAjxhbOGnStFvsCZHLnEAH9lPEGyAAAGjHJAAAdWMwAABwzxQAAFzwDCLm6S8IAABmS4tgJAAC9EgAA0xYAABAAwcIAAIUbocQAALwO+/0QgAM=") \ No newline at end of file diff --git a/api/search.index/40fdbde65c.js b/api/search.index/40fdbde65c.js new file mode 100644 index 00000000..1b0d27e8 --- /dev/null +++ b/api/search.index/40fdbde65c.js @@ -0,0 +1 @@ +rn_("FQLEAACmyxkWzRljcocaMcMAAIAWgRaxHPsCaXIhwwAAuA6GEnHFAADWGNcY6BnpGfwbdh14HTWExAAA0asYYRp8HH4ca8AAwc0AAByQWwPAAAAXaGVtdCXAxAogAACm5RgTqhgW5xiFEhwaIcgAAKkYMhwhygAAGByVHAUCwAAAGRccEMoaYXM1AcIAABWqG3OXCpgK2hwFwMEARgAAGPEbE7MYFQkcscMAALwWxQLAAAAX0BsWfB1ydHIaocEAAKIZ8wICaXPiUBUAAHwaAAB+GwAAAwArAuQAABQUZHQVAcUAABbPG2SxGCcaAv1dMQHGAAAaicQAABiywwAAFmrGAAAaisYAABqL5r8LAAAlDAAAdxcAAAYYAAAOGAAAuxgAALIbAABhMzMw+4wlAcIAABZjF3ISD6EZQcMAAPYW9xaaGZsZEwGMxgAAG6TlCAoAAEoSAADEEgAAoRYAAIsZAAAOGgAAEgAA+wJudCHGAABjGiEcFQLFAAATiBoW8Bttc7sWJYLBAAC00RlPFQAatQHAAAAWXhp31xLzgZIbicAAABQTBYzAAAAbxxwUHhoFA8AAABgvHBvFHBvGHGx0dQUCwgAAF8QcFC4cc3QTgQLMAAAdeGR02wTJAAAcXmFjc3YVAcEAABQdGnYSFPsCZXRb/RAEFMMAABcqzQAAHMgFwMMASgAAGlgdFy0cFFsbQ4HAAorJAAAb78sAAByWzwAAHXcFAsEAABtdHBSwGGx2Ff7CQEBIAgAAGVwcE6EZGpUcFBwaFYcaERRrA8IAABYnYWxyBQLAAAATGxofiR1hcksDxAAAGobHAAAcLGVsdAUCwQAAGFkcGVocY3AFAsEAABrCHB12HWR0NwGC/kAFQAnHAAAb7sMAABmgywAAHMOhygAAXBqhwwAA1RJrxQAk0QAAHYTQAAAddQUCwgAAG1gcGiscYW0FAsEAAB3BHCbrGBoaZnQBhALPAAAdm8gAABzAYXM7A8gAABtaYW95BQLDAAAyaBZpFtsbG5McZWcFAsMAABMqHBWSHGJzI4MCxAAAG1lvcgEA/V/4Bxv1KgRIYV2/sdNza9y4+CccbwejbmX9hFQC7/HOwMwAABxbyQAAG6/CAAAUEM0AAByUc6uBpRZ0qynY+R9gpCt4UwSvO2vjyAkAAAwaAAA1HAAApRwAADEAocMAAJAZUcQAALkcuhy7HLwcsR3zAAJhZOftDgAAShAAAAEUAABFFQAA2RYAAGwbAACtGwAAxhsAADMxAABbwAQoyQAAHBmxxwAA0xg1AcMAABZlHXOmHLMcmx3zAgFk4oISAAC3GwAAjRwAACAAQ4EDwwAAFibDAAASw21udQEEAcUAABwPcuNmFwAAXxwAAIgcAAClHQAAFAABAgHFAAAbgXLiZhYAAPQbAACdHQAAFAD7iRsC0AAAHYh0dQEGAcUAABxCcuToGAAAmRwAAIYdAACfHQAArB0AAAQAAAUCxgAAEa4bpFUcbXT7Am9yscgAAM4ZYcMAAEwV/Bn9Gf4Z/xl5HUHEAABlFhQaVByUHDUCxQAAEVgbE+sbaXRkF2UXmB0rickAABuAdwsC/RTRAwJ/rRfpV9WZtKnSyxgAALAbg88AAByOFQHIAAAWzhtyXBylgcAAABHMHBwY8wMBc4KrDgAAa8ADAcsAABus8QD+wGdeAwAEEhUWPD1BS0xNTk9U6PAFAAArBwAA9wkAAOoLAACwEgAA3hMAABEUAACnFgAAvxsAADABAAAQ") \ No newline at end of file diff --git a/api/search.index/4166a1d76b.js b/api/search.index/4166a1d76b.js new file mode 100644 index 00000000..e3369549 --- /dev/null +++ b/api/search.index/4166a1d76b.js @@ -0,0 +1 @@ +rn_("wcoAAP8YFQHHAAAQtxxzsRoxwAAA7gvvC6YaQwEDwQAAEDvCAAASu2FpczswAAABAAB2AAYAjhVvACYWAADnFgEAYhsAALsbAQBcHQAAGwLAAAAbRm5y+5M=") \ No newline at end of file diff --git a/api/search.index/417fdd7934.js b/api/search.index/417fdd7934.js new file mode 100644 index 00000000..43407cd3 --- /dev/null +++ b/api/search.index/417fdd7934.js @@ -0,0 +1 @@ +rn_("VQHAAACgURtzRBXnG+gbURxSHKHFAADrGUX9wwOAAgAAJUccVB0z1xvPHGAdI7Ma2BsVSBzfGx8cdR2EHTHEAADZGOoZkxz7AmN184ECYXDzgQJucKHHAADMGyMC/TAABckAAB1xxQAAHELHAAAcy+zGCwAAQwwAAHgWAAAbFwAAwRgAAOgYAADGGQAAaBoAAJEcAACZHAAAhh0AAJ8dAACsHQAAMQAQAAQAAAGCAhZJuIVS4AAAEEkwcyOBhuAAAAqN+4s=") \ No newline at end of file diff --git a/api/search.index/41a1732d80.js b/api/search.index/41a1732d80.js new file mode 100644 index 00000000..cdc0beb2 --- /dev/null +++ b/api/search.index/41a1732d80.js @@ -0,0 +1 @@ +rn_("FYTFAAATqRm+HAUCyAAAFqQcF9AcbXIjgcAIhMEAABI4wgAAFDVRxQAA/hX/FQAWARZkHBsD0AAAHWnMAAAb4GFucxUBwAAAExkYcMUbIcUAAFkcXhwzgsAGQMIAABcWGwLIAAAcWmNwfwH9gUAJzAAAG5Y=") \ No newline at end of file diff --git a/api/search.index/41b2eca860.js b/api/search.index/41b2eca860.js new file mode 100644 index 00000000..b40b230c --- /dev/null +++ b/api/search.index/41b2eca860.js @@ -0,0 +1 @@ +rn_("FQHCAAAWyht27hYVhMQAABSXHI8aUwADxQAAG7pwdHXiQAkAADkSAAD5GwAAQwAVAcMAABfbHHC9EiHGAACWGX4dlwEA/UGQAswAABt2wgAAHJABBAcAACuDwQAAFCo=") \ No newline at end of file diff --git a/api/search.index/43520d3d0e.js b/api/search.index/43520d3d0e.js new file mode 100644 index 00000000..c216d501 --- /dev/null +++ b/api/search.index/43520d3d0e.js @@ -0,0 +1 @@ +rn_("FYLAAAAVChlJEKHBAADmEwX8wA8AAAAQXxYQYBYQYRYQYhYF/MD/AwAAEFUWEFYWEFcWEFgWEFkWEFoWEFsWEFwWEF0WEF4WBfzA/gMAABBMFhBNFhBOFhBPFhBQFhBRFhBSFhBTFhBUFvv8BwCzAQTEAAAZCTBtb3PorwsAAPcLAAALDwAApBYAAOMWAADOGQAAEhoAAEgaAABtGgAADRACAAABQeGjGAAA/xgAADP7Am5vZQLAAABB4xnkGYAdgR0bIBxncyYKJwqNCgcUdxbiFvuG") \ No newline at end of file diff --git a/api/search.index/4361f6feaf.js b/api/search.index/4361f6feaf.js new file mode 100644 index 00000000..568949a1 --- /dev/null +++ b/api/search.index/4361f6feaf.js @@ -0,0 +1 @@ +rn_("AQACwgAAHInFAAAZp2x07HsVAAAKGQAAtRkAANgZAADwGQAALhoAAEkbAACCGwAAdRwAAIMcAACrHAAAZR0AAKIdAAAAACIAAAAA8wABc+HwGAAA1RsAABA=") \ No newline at end of file diff --git a/api/search.index/4367636084.js b/api/search.index/4367636084.js new file mode 100644 index 00000000..d77304ab --- /dev/null +++ b/api/search.index/4367636084.js @@ -0,0 +1 @@ +rn_("occAAFEcBQLAAAAQIBcS9BZkbvMEhOGcGQAAbh0AADAxxAAAPBYJGl4dCQLQAAAdssEAABtvaXYxwQAAXRdfF70Z+wNlcnOvAf0UBETCAAAWx8cAABtl") \ No newline at end of file diff --git a/api/search.index/436a0c8af9.js b/api/search.index/436a0c8af9.js new file mode 100644 index 00000000..b7b779f8 --- /dev/null +++ b/api/search.index/436a0c8af9.js @@ -0,0 +1 @@ +rn_("AUg7MAAAAQAA2QACAAwZbADYGmwAQwADwwAAFiXFAAAXKWNvc+UjCAAA6A4AAJUPAABREQAApBsAANUcAAA0AAA=") \ No newline at end of file diff --git a/api/search.index/43ae0d6123.js b/api/search.index/43ae0d6123.js new file mode 100644 index 00000000..d7bfd665 --- /dev/null +++ b/api/search.index/43ae0d6123.js @@ -0,0 +1 @@ +rn_("IcMAAMYWzhoFAsQAAB2gHRyQHXJ0KwLBAAAWjml0BQLAAAATHBwUHhx0dfMGAXMBDRgAABUBwgAAE60cZAQYI4ECxAAAGqZtdQUCwAAAMNQRohJ9FRtwHXR1NxoA/TVBB8EAABmMwgAAFkjQAAAcsMQAABvP7dMHAAARCgAAzAsAABMMAACdDgAArRYAACEXAABvFwAAohgAAHEbAAA6HAAATRwAAFMcAACwHAAAMQAQAQAAAA==") \ No newline at end of file diff --git a/api/search.index/442bc328ca.js b/api/search.index/442bc328ca.js new file mode 100644 index 00000000..99d48965 --- /dev/null +++ b/api/search.index/442bc328ca.js @@ -0,0 +1 @@ +rn_("ocwAAHEcIcUAANkWxhuhxgAAyxkVAcQAABbHG3KSHHcBAMIDKMUAABnN9TcWAABFAwsBCQA7AAWJwAAAFosbFPUWpQHAAACgKxBz1gwbAsIAABnnZG4VAcMAABavGnIqHBcTAP0eiQPDAAAVNMoAAByxwAAAFmzCAAASsMUAABvO6h8HAAA0EAAAPxAAAG4WAADDGAAAoRoAAKwaAAAuHAAAXBwAAMYcAADIHAAAAAERAAAA") \ No newline at end of file diff --git a/api/search.index/44792fdc89.js b/api/search.index/44792fdc89.js new file mode 100644 index 00000000..bab453eb --- /dev/null +++ b/api/search.index/44792fdc89.js @@ -0,0 +1 @@ +rn_("BQLBAAAZ0xoVBRhxdAEAkMkAABzB6y0JAAAaCgAARhAAAGAQAAA4EgAAtxIAAN8WAADzFgAAIhwAAFkcAABeHAAA1BwAAEAQAgAAADvAECTGAAAcDiuFwQAAFps=") \ No newline at end of file diff --git a/api/search.index/44c4b78887.js b/api/search.index/44c4b78887.js new file mode 100644 index 00000000..8e93a69d --- /dev/null +++ b/api/search.index/44c4b78887.js @@ -0,0 +1 @@ +rn_("BQLNAAAWmB0Xnx1jcAUCwAAAHGwcEjUWb3khwwAAZRVTHQUCwAAAGWscFnAbYXcrAscAABu+YXQBggLQAAAdssEAABtvaXYVAcMAABWoHHBBGpOC/QCJCsUAABu9xAAAG27CAAAaQvsDYWV0BQLDAAAVbBsVbRtwc6HEAADVFgUCwQAAEdIYETQWZmkJAtAAAB2twgAAG2tpdisCygAAHW9jchuGwQAAGbIFAsAAAB2OHROlGm5yKwPBAAAZsc0AAB2GYWVsFQLDAAAVahwQPxpwc7AZBQLDAAAWXh0TaRxzdSOBAscAABynY3S3AYL9AYkKxwAAHAXDAAAaQAUCxQAAFQQcE2obc3XzAf1IGQ6EsQoAABsCyAAAG3Nncg==") \ No newline at end of file diff --git a/api/search.index/458b2166fc.js b/api/search.index/458b2166fc.js new file mode 100644 index 00000000..4d18ba2e --- /dev/null +++ b/api/search.index/458b2166fc.js @@ -0,0 +1 @@ +rn_("ocYAAG8XIcYAAJsayRwhxAAAqRgyHCHBAAAxFmMbBQLAAAATChkQcR1vc2cDAP0WAQbCAAAaU8MAAByX6s4LAADCEgAASxYAAMQZAADhGQAAjxoAANAbAADTGwAACRwAAHEcAADWHAAAEAAQAQEAFwEA/RCADsUAABo4zAAAHM7DAAAawOlMDAAASRAAADUSAADyEgAAQRUAAAIWAAAZFgAAbRYAAFwXAAC3HAAAIBMAAAA=") \ No newline at end of file diff --git a/api/search.index/46209da0c7.js b/api/search.index/46209da0c7.js new file mode 100644 index 00000000..0835aabc --- /dev/null +++ b/api/search.index/46209da0c7.js @@ -0,0 +1 @@ +rn_("scMAANEZBQLAAAAToBYQQBpkc/sCcnbzAII6MAAAAQAAAAAAEgAQAAAA4wnkCeUJQRLcEk8V+xaCGcEZABobGlAasBrAGswaShy2HG8dkR0=") \ No newline at end of file diff --git a/api/search.index/462cbf4cea.js b/api/search.index/462cbf4cea.js new file mode 100644 index 00000000..a7f985c5 --- /dev/null +++ b/api/search.index/462cbf4cea.js @@ -0,0 +1 @@ +rn_("ocQAABMXFQHDAAAWChx3VRchwwAABRmWGyUBwwAAE3gbbukb+xvzAMAIiIL2EgAAQ4MDwwAAF1TEAAAY22Vyc2MAA+AAABmMZHR18qYOAACjBw==") \ No newline at end of file diff --git a/api/search.index/462dcef4cc.js b/api/search.index/462dcef4cc.js new file mode 100644 index 00000000..16e4f133 --- /dev/null +++ b/api/search.index/462dcef4cc.js @@ -0,0 +1 @@ +rn_("NYLAAAASuQ4pCOsZ7BkVgcMAABQWGMsOVf3AUgFCAAAVwhkVbxoWjxsTWRcX3RsY7Bi4C9oT2xPcE90TBQLBAAChQxQmtRmDHG1zBQLCAAAa1Rul8Bhtc2MAwAiIyQAAHaLkyRYAACsXAAA/FwAApxkAAFUcAAA4EBAjAALMAAAbq2Nz4UIWAABOGgAAEMHIAACjGBWEwwAAE2QWeBqhxgAADBdBwwAA2RNhFRoYGxhBwQAAnw7HDsgOyQ7jAP1AoQbDAAAWfsMAABZ/yAAAGm7m5QcAADUJAACiCgAA1REAAOQSAABWFwAAaRcAAAECARBVA8IAAKR5HBl7HBbEGmVpduQW5RbmFhYcRhwVAcAAAB3WHGLFGvuGKwLgAAAOnGx01zsA/ZOFRsIAABA2ywAAG6oBEQcAAA==") \ No newline at end of file diff --git a/api/search.index/4674d5e560.js b/api/search.index/4674d5e560.js new file mode 100644 index 00000000..b2cc8848 --- /dev/null +++ b/api/search.index/4674d5e560.js @@ -0,0 +1 @@ +rn_("ocQAAM8WIccAAO8ZFRwFAsAAABt/HRJQGnJ0G4nKAAAdcysCyQAAHWhldDHHAAB5G9ob0Bw1AcUAABbXHHe1Go0bpBwFAsIAABhnHRp+HXJ1+4kVgsMAABQ/HIYcE4GDxwAAHLTXAQPAEmLHAAAcSuF+FQAATBoAAEAVgsEAABQDGT0Q+wJvdqOB/RABBcAAABxNxwAAHLAbAscAABxwY3gBSDswAAABAAB1AAEA3Bx1APMCAWSGJBQAAPMAk4MABAAA") \ No newline at end of file diff --git a/api/search.index/46b440aa10.js b/api/search.index/46b440aa10.js new file mode 100644 index 00000000..c56e44d0 --- /dev/null +++ b/api/search.index/46b440aa10.js @@ -0,0 +1 @@ +rn_("BQLAAAATGxofiR1hcksDxAAAGobHAAAcLGVsdAUCwQAAGFkcGVocY3AFAsEAABrCHB12HWR0PwH+QAVACccAABvuwwAAGaDLAAAcwxWExAAAE2EXHRYrA8cAABoOxAAAFrNpbXZbwATAywAAG6g=") \ No newline at end of file diff --git a/api/search.index/46cb0cb60e.js b/api/search.index/46cb0cb60e.js new file mode 100644 index 00000000..5ffd5aec --- /dev/null +++ b/api/search.index/46cb0cb60e.js @@ -0,0 +1 @@ +rn_("cccAAKYdpx2oHakdqh2zHbUd8wIBZuelEgAAGRcAAPUZAACSGwAAohsAANMcAACTHQAAlx0AAEATEAAFAsAAABuQHaI/FG95JQHFAAAXoB1w1xbNGhUBxgAAp9MbcsUcpQHDAACr0RxmXBB1AcAAABLRG2vvGNkZQxpEGv8bvRzNHCOGAsEAAB2JaXlLwBwAwQAAHYjAAAAW9CsC4AAAFyBkbqHCAACjEucbAP5ARMEHwAAAGabhAAAWsevOEgAAWBUAAF8VAACcGQAAxRkAAFkaAACyGgAAuRoAAO8bAAA3HAAAbh0AAHcdAAAFEwAAAQA=") \ No newline at end of file diff --git a/api/search.index/46fe400a36.js b/api/search.index/46fe400a36.js new file mode 100644 index 00000000..d62885e2 --- /dev/null +++ b/api/search.index/46fe400a36.js @@ -0,0 +1 @@ +rn_("BYzAAAASHhcgOQihCkHHAADjGeQZgB2BHaHHAADhGaHCAAAOCkcHAP5ARJALxgAAFtHHAAAYqMQAABQrwwAAGh3n+QUAAO0LAAASFAAAMRYAAF0XAABfFwAAvRkAAGMbAABAAAAA") \ No newline at end of file diff --git a/api/search.index/47342f0a10.js b/api/search.index/47342f0a10.js new file mode 100644 index 00000000..241102b2 --- /dev/null +++ b/api/search.index/47342f0a10.js @@ -0,0 +1 @@ +rn_("IcUAANwSghkhxgAA5xi8GQUCxgAApLAbFjEcY2UrAswAAByIbHKhxQAAVhUbldMAAB2exQAAG3IFAsAAABU5FxKqGG9yBYvAAAASNhwUXxoRug4FAsAAADDKC8sLpxgVCxlwd6HEAAAoFqcCAP0NwAHAAAAPF8cAABxmwgAAGtOCKwgAAFHFAABTFVQVVRXlGOYYNYLFAAAUeBxRFVIVcRr7Amhv5zwB/kBLphnJAAAakMIAABZyygAAHWrMAAAcTskAABw07dsHAAAfEAAAhRIAAIsSAADxEgAAwxYAAAgZAACgGQAAHBoAAFkbAABeGwAArxsAAAAcAACWHAAAQQAAAAAAACHDAADcEoIZEwD9EQEQwAAAHLDAAAASkMcAABqUOjAAAAEAAAAAAA8AEAAAAAsKDApbF2MX5xi8Gc0Zrxq3GnwbgRvHG84bDxxCHGId8wABdAGRGgAAFQHEAAAQYRtzUhAFAsAAALEUD7Y2F25wwcMAAIcSIcMAANcZwhrzAP0A1AD0GAcAABYPXwCNBSHFAADnGLwZ+wJleQEA/kBLph/JAAAakEIe7uyiOJuAyohs2yRtRsIAABZyygAAHWoAAPlf8iAbEn3hay1osqdbG1HISMoAABvGFaabibnMAAAcTskAABw0OzAAAAEAADoAEwAgBwIA2wcEALYOAAAfEAEAYBIhAIUSAACLEgAA8RIAAIoVAAAHFgAAwxYAAAgZAACgGQAAHBoAAFkbAABeGwAArxsAAAAcAACWHAAA") \ No newline at end of file diff --git a/api/search.index/47c5dab1ce.js b/api/search.index/47c5dab1ce.js new file mode 100644 index 00000000..d3fc8885 --- /dev/null +++ b/api/search.index/47c5dab1ce.js @@ -0,0 +1 @@ +rn_("Vf3ECAkCAAAbsRwU5xkXixutcRzDGMQYrBouHMgcUccAAJIbkxuUG5Ubkx0BBQHIAAAc2mrkPhYAAAQXAACcGgAAwhwAAMQcAAAFMAAlhMQAABcUHGgbuRv7AnN0W/0QQETDAAAcSMMAABrXBQLAAAAisxrYGzjSG60dsh1scqHEAABOG3HQAACmHacdqB2pHaodsx21HfMDAnN2AaMdAAChwwAA9Bg1AcMAACCuGoobc5kamhp9G0HGAAB7GkAcghxwHfsCaG8FhcAAADLXG88cYB0VdxYhxQAARxxUHXc+Af03yAbFAAAchM0AABwT6fMGAADvFgAArRoAAN8bAAAfHAAArBwAALkcAAB1HQAAhB0AALEdAAAwAAAwAKHIAAB2GgUCwAAAET8VNfkZ+hn9G2R0IwADwAAAFxrGAAAZ+2Jpc4O5GQAAMcwAAAwcJxxdHbv9jAEAxAAAFknzAIYBfQMAAA==") \ No newline at end of file diff --git a/api/search.index/481b981f39.js b/api/search.index/481b981f39.js new file mode 100644 index 00000000..fbcd7e4d --- /dev/null +++ b/api/search.index/481b981f39.js @@ -0,0 +1 @@ +rn_("BQPAAAAx1QtYEFkQML0Llw6YDhEpHGZnaSHHAABTF8YaNQHAAAAWEBx3bRduF6gZMcAAANgL2QuTDvsCZHIlhsUAAKR5HBl7HBYcRhz7AmNl+wJpeQWKwQAAoi8WpcEY4cMAACwUAUGIBxAAACHHAADgG4wdMc4AANIbrR2yHdcFAf0V4QDMAAAdtMMAABo+8zoIAADZD9oBAUDhlQsAAA8QAADOMcAAAFYQVxA8FSMEAsMAABjcb3I7MAAAAQAAKQIGAL8OAQCaD2wAvBIAAPkS3wAMGWwA2BpsAN8D/kAPCAjAAAALuM0AABvc") \ No newline at end of file diff --git a/api/search.index/482b333a23.js b/api/search.index/482b333a23.js new file mode 100644 index 00000000..d6735447 --- /dev/null +++ b/api/search.index/482b333a23.js @@ -0,0 +1 @@ +rn_("Bf3AEAAHAAAWEhzF/xglsBq2HBh9HDUCwQAAUqMWyBiuGXMdfx0W1xxpd7UajRukHKHJAAA1GgFOOzAAAAEAAHUAAQDcHHUAq/0BQILDAAAXfcEAAA+VNYTAAAChjhkjDLESQhUbAsIAABjYbXLXAQD9MEFAxAAAHIU7MAAAAQAAgwAFAA0GAAA0FgAAQRYAAIIXfwApHAAABQLAAAAV2hwUNRxhbCHCAAAYF7QYAUE7MAAAAQAAgAACAEEWAACCF38AZcDACkAAABS6GBnIGUYuGi8aMBqrHJoKmwqRD5IPTRJOEocDAP1pIQTEAAAcFMUAABc+wAAAEt/GAAAcewFtHAAAAQAB4AAAG5Bz5REPAACWDwAAlxIAAP0TAACrGAAA1RoAAAMRQBWEwwAAE2QWeBobAssAABuqaHD/BMAG3M0AABvGwgAAC7c=") \ No newline at end of file diff --git a/api/search.index/4861d531a4.js b/api/search.index/4861d531a4.js new file mode 100644 index 00000000..a5c6a940 --- /dev/null +++ b/api/search.index/4861d531a4.js @@ -0,0 +1 @@ +rn_("IccAAO8ZFRwFAsAAABt/HRJQGnJ0G4nKAAAdcysCyQAAHWhldDHHAAB5G9ob0Bw1AcUAABbXHHe1Go0bpBwFAsIAABhnHRp+HXJ1+4kVgsMAABQ/HIYcE4GDxwAAHLTXAQPAEmLHAAAcSuF+FQAATBoAAEAVgsEAABQDGT0Q+wJvdqv9EAEFwAAAHE3HAAAcsCsCygAAHA5vdA==") \ No newline at end of file diff --git a/api/search.index/48d9940e32.js b/api/search.index/48d9940e32.js new file mode 100644 index 00000000..c57316aa --- /dev/null +++ b/api/search.index/48d9940e32.js @@ -0,0 +1 @@ +rn_("BQLCAAATlhYWfRlma7HCAAC5CwEBAdEAAB1kcgEkDAAA+wJydVUCxgAAGIwcGbgcY3OSFpMWlBaVFp4cBZLBAAARhRwUeRn7Aml2+wNhb3I=") \ No newline at end of file diff --git a/api/search.index/4977b1f5be.js b/api/search.index/4977b1f5be.js new file mode 100644 index 00000000..287b619d --- /dev/null +++ b/api/search.index/4977b1f5be.js @@ -0,0 +1 @@ +rn_("BcDDgGAAABLLGxXAGSXBGcwaoccAAMwbIwP9MAAFyQAAHXHFAAAcQscAABzL7MYLAABDDAAAeBYAABsXAADBGAAA6BgAAMYZAABoGgAAkRwAAJkcAACGHQAAnx0AAKwdAAAxABAABAAAFZDAAAAhvgnvEnwDBQLAAAASThoRvhlpdAUCwAAAEAEcI78ZtBpydlOCA8gAAByuZWZ08wABZPLLBwAAqAgbAsYAABpPbnQVwMIISgAAF2wcM4gaZhtZHRfLGDZRF1IX8Bu7FjHFAAAMHCccXR0nNwD9a8ED1AAAHXTBAAAKg8MAAA8DxAAAGPvjKAoAAEYaAABcGwAAaBwAABAQ") \ No newline at end of file diff --git a/api/search.index/49a722d9ab.js b/api/search.index/49a722d9ab.js new file mode 100644 index 00000000..f574fcfa --- /dev/null +++ b/api/search.index/49a722d9ab.js @@ -0,0 +1 @@ +rn_("ocUAAOEZBYnAAAAS7RsUVxwrAsEAABpbaXTBwgAAghplA8AAABa/HBMpHBZDHGRpd+kY6hgXGhgaGRr+GysC4AAAFq1kcrOD/RCBAsYAAByRBQLDAAAUbBohIhdnHWlsGwLEAAAXa2NkuwTDAAAbzmFwcXM=") \ No newline at end of file diff --git a/api/search.index/49b8725a0e.js b/api/search.index/49b8725a0e.js new file mode 100644 index 00000000..e5af8948 --- /dev/null +++ b/api/search.index/49b8725a0e.js @@ -0,0 +1 @@ +rn_("IcQAAAUUbRuhzAAAcRxBxQAA4xnkGYAdgR0hxgAAtRmDHAEDAcUAABwPcuNmFwAAXxwAAIgcAAClHQAAFAChwQAAQxT7wABKFYHGAAASDRy8GiMBAsQAABpaaHfyShUAAPIEAQEBxQAAG4Fy4mYWAAD0GwAAnR0AABQA+wJydPsDYW9yIcQAANkWxhsFAsEAABrVG6XwGG1z+wJlbAUDwAAAql8bFNgZE7QcYWNyFQLDAAAnVRphHR2NHW1wMByhxQAAyxn7Am9yFQHEAAAWxxtykhwhwgAAAg9VEPdqAP03gF3JAAAdosUAABnN0AAAHYjpNxYAAMkWAAArFwAAPxcAAHwZAACnGQAAhxoAAJAaAADLGgAAVRwAAAOBAAAB") \ No newline at end of file diff --git a/api/search.index/49c3446e1b.js b/api/search.index/49c3446e1b.js new file mode 100644 index 00000000..be0c1e7d --- /dev/null +++ b/api/search.index/49c3446e1b.js @@ -0,0 +1 @@ +rn_("BQLAAAATGRYXTRdpcgECAsAAABoyxQAAHA9hcuYTCgAAURIAAGYXAABXHAAAXxwAAIgcAAClHQAAIBBAACHJAADIGHMdcccAANYY1xjoGekZ/Bt2HXgdwcMAAHcXsccAANMYNQHDAAAWZR1zphyzHJsd8wQBZNK3GwAA1rcEAv0cOALKAAAcGckAABwXyQAAHBiC6gsAAOcBAP4ABCYKxAAAGkvKAAAcFuSGAwAAAgYAANcLAAATFAAA6hsAAAgAAA==") \ No newline at end of file diff --git a/api/search.index/49f62f48ce.js b/api/search.index/49f62f48ce.js new file mode 100644 index 00000000..09c758ea --- /dev/null +++ b/api/search.index/49f62f48ce.js @@ -0,0 +1 @@ +rn_("FYLAAAASdx1KCAUCwAAAcmcVaBVpFWoVaxVsFaccE1ESa3BrA8YAABzLaG9yBQLAAAAVxBsWexllbAUCwAAAq1EcMHAWcRZOF2xyNQHBAAAWEBx3bRduF6gZocoAAHkc84UCY2UFAsAAAChmG1kdsuAYb3mhxgAAbxcFAsAAACExFmMbJpsayRxldBcBAP0QgA7FAAAaOMwAABzOwwAAGsDhTAwAAAIWAAAgpQLFAAAWgxsWhBttd0MXpQHGAAAW2Bx3uhoFAsAAABUUHBHfEmFmccAAAJcSmBKrGKwYrRiuGK8Y+wJkbgUCwAAAI7gZUxsaqhxucAUCwAAApfwWFbwbaG/3HgH+AEUsC8kAABxwOjAAAAEAAAAAABoAEAAAABAKowqkCqUKpgrqE+sTNhQ8FlMXDBipGPkY+hivGQkaYRqGGsYaaBtpG7kbKxwyHHwcfhxeHSHBAADlEsMby/0kYADBAAAOnMUAABcKocUAAE8SocYAAMwbBQLCAAAWPhwXbRpjZPsCYXIFAsAAABTdGxrOHGxwBQLDAAATuBwYUBtvciHDAADxDmwXXwH9AYAHyAAAHXHDAAAcKwFC4YQKAACEFgAAgQEA/sB98EN23KkM4cMAAA6VwgAAGBdldik4HwbVR6DTwwAAFvBkHFMfKXe6vhgXNGWe/nICoOI3bcMAABbDxQAAHELFAAAcKy4jqgvGxgAAFUQ6MAAAAQAAAAAAGgAQAAAAWQq1CsYLxwvIC8kLQwxEDHgWGxc9Fz4XwRjCGOgYxhloGo4ckRyZHJocmxycHJ0chh2fHawd") \ No newline at end of file diff --git a/api/search.index/4a09e215f2.js b/api/search.index/4a09e215f2.js new file mode 100644 index 00000000..51eeb2ea --- /dev/null +++ b/api/search.index/4a09e215f2.js @@ -0,0 +1 @@ +rn_("JQHEAACgsRZzzhK5GlUCxAAAoSoYoK8WaXPKEssSzBLNErgascMAAOAYpQHGAAAW2Bx3uhrzhAJtdDUBwwAAFlYad8kSGxYcFvsCcnP7A2VvdA==") \ No newline at end of file diff --git a/api/search.index/4a9731a00a.js b/api/search.index/4a9731a00a.js new file mode 100644 index 00000000..2d92a887 --- /dev/null +++ b/api/search.index/4a9731a00a.js @@ -0,0 +1 @@ +rn_("FQHCAABQBRoGGgcaCBp7HXNSEaHFAACTGRMBAsUAABxnZnTknBYAAAUcAACKHAAAoBwAAKsdAAAwEABrA8EAABvKYWV0") \ No newline at end of file diff --git a/api/search.index/4b20e10ba7.js b/api/search.index/4b20e10ba7.js new file mode 100644 index 00000000..b677fe92 --- /dev/null +++ b/api/search.index/4b20e10ba7.js @@ -0,0 +1 @@ +rn_("FYTHAAAUlxyPGiHEAAAYF7QYowL+AEiBCMIAABabwgAAGO7EAAAakfILDAAACAOhyAAAHBgVAcEAABCQG3PVGiOGAsQAABtnY2n7A2Vycw==") \ No newline at end of file diff --git a/api/search.index/4ba83d078a.js b/api/search.index/4ba83d078a.js new file mode 100644 index 00000000..4d62c7fd --- /dev/null +++ b/api/search.index/4ba83d078a.js @@ -0,0 +1 @@ +rn_("AUM7MAAAAQAAgAACABYKAABWDH8AIcAAAIMWBRghwQAAlQ7iEqHEAABOG3HQAACmHacdqB2pHaodsx21HfMCAnN2AaMdAAAxwAAAcQP/AzYQFYTAAAATZBZ4Gn8HwoDN4AAAGa0=") \ No newline at end of file diff --git a/api/search.index/4c3ef2365c.js b/api/search.index/4c3ef2365c.js new file mode 100644 index 00000000..bf980ba1 --- /dev/null +++ b/api/search.index/4c3ef2365c.js @@ -0,0 +1 @@ +rn_("JQHAAAAY7RlzehmJHAFHOzAAAAEAAIAAAgBBFgAAghd/AAFDOzAAAAEAAH8AAQBTEX8A8wACaXQ7MAAAAQAAgAACAPcGAAAyB38AOwPCAAAdcWVncw==") \ No newline at end of file diff --git a/api/search.index/4c6c58c935.js b/api/search.index/4c6c58c935.js new file mode 100644 index 00000000..ec55348c --- /dev/null +++ b/api/search.index/4c6c58c935.js @@ -0,0 +1 @@ +rn_("NYLBAAAXxRrPEtASHhYVA8IAABOjHRa5GxSsGm9zdKwc+wJkdFHDAABTEFQQHBQdFB4UocgAAOcbxQHDAAAXCRxyBhgjgwLHAAAb22VsBQLAAAAR1BEVzxthctMAwARKxwAAGfPzMgkAAH8PggEzAAPEAAAdY2VtdOPUCQAAiBUAAHkZAABaGwAAMQAFkcAAAOIkFKTPFsHBAACWDzMBA8IAABQqaW5y/DgIAACVAQEA1AQBAE4A5wJvBSUApgCWACIAGwLBAAAdgm95UcsAAM8a0BrRGtIaih0FAsAAABPeEhiYGmNlFQHEAAAwXxtgG6gbc2IVK4PBAAAUIgcMAP4ARfAHxwAAGjfHAAAZ1sMAABu+xQAAFm3EAAAWzsMAABYuwgAAFCPJAAAZ2uM2CAAA3QkAACgXAAA0GgAAEwAhwwAA8BIHHPsCYWgXBAD+AEHAFcIAABFSwwAAEvHKAAAazswAABvZwgAAGMnhLwcAALUYAAAjBZTAAAAcyBkaOhwVAsIAABgdHBWQGmR0oxobAsgAABuOYmxVAcEAACD6FjsXcxQIRgnuEhgY7hkTAP4ANGJBwwAAF2vEAAAW2MYAABc6xQAAFirDAAASi8IAAA8YyQAAGmQBpBoAAAFB+A4WAAABAMMAUAB0AroBFwIXACOBAsMAABpsaWwTgYPAAAAaR78+/Qw8D+AAABpYRssMtg4=") \ No newline at end of file diff --git a/api/search.index/4c72786c80.js b/api/search.index/4c72786c80.js new file mode 100644 index 00000000..8af9819f --- /dev/null +++ b/api/search.index/4c72786c80.js @@ -0,0 +1 @@ +rn_("BQLEAAAUtxs3/Bt2HXgdY3MrAsUAABgYaG8xxAAAwxjEGC4cBQLAAABmnBqdGp4anxrCHMQcV5IbkxuUG5Ubkx1ldGHUAACmHacdqB2pHaodtR3zggNvc3T7AmR0occAAEUXBf3DAYAFAAAdthwaIRwetxwVfBkBQOOoDgAAQhIAAMcaAABVHQAAESLzAAFz4tsOAAAfGgAARRwAABsAIcMAAFUaYR0FA8MAABiRGxZeHSNpHGscZHN1KwLHAAAcp2N0fwf9TIkIwAAACTQ=") \ No newline at end of file diff --git a/api/search.index/4c8097cd9d.js b/api/search.index/4c8097cd9d.js new file mode 100644 index 00000000..949581af --- /dev/null +++ b/api/search.index/4c8097cd9d.js @@ -0,0 +1 @@ +rn_("ocYAAIQWQwD9ABUJwgAADqTCAAAOpcQAAByYxgAAGZfs+gYAALUHAAC8DgAAwRIAANUSAABIFQAARBYAAAoXAABUFwAAdhcAANgYAAADGQAAERwAAIIQEQAAAAA=") \ No newline at end of file diff --git a/api/search.index/4c8eb1beee.js b/api/search.index/4c8eb1beee.js new file mode 100644 index 00000000..1fb2ef54 --- /dev/null +++ b/api/search.index/4c8eb1beee.js @@ -0,0 +1 @@ +rn_("BQLAAAAXURoSihVpcoMA/RAQBcUAABkGyAAAGrbKAAAbyDswAAABAAAkAAMA9gkAADQKIQBFEgEAKwLAAAAWFml0") \ No newline at end of file diff --git a/api/search.index/4cd2040816.js b/api/search.index/4cd2040816.js new file mode 100644 index 00000000..335c8ee6 --- /dev/null +++ b/api/search.index/4cd2040816.js @@ -0,0 +1 @@ +rn_("BQLDAAAUoh0TSRxjcqHFAADMG/uJBQLAAACz4w6j5g5sciUBwgAAE3gbbukb+xslhMUAABSpHKsamh0lhMQAABRvHEkakh3zgQJsclHDAAAiGCMYrBmDHYcd84ECcnTRxQAA9xOhwwAAkxkFAsEAABY+HBdtGmNk+wNkaXb7/RFBAQ==") \ No newline at end of file diff --git a/api/search.index/4cee7aa8a8.js b/api/search.index/4cee7aa8a8.js new file mode 100644 index 00000000..78ec9b84 --- /dev/null +++ b/api/search.index/4cee7aa8a8.js @@ -0,0 +1 @@ +rn_("AQEDwAAAHLDAAAASkMcAABqUZWl3OjAAAAEAAAAAAA8AEAAAAAsKDApbF2MX5xi8Gc0Zrxq3GnwbgRvHG84bDxxCHGId") \ No newline at end of file diff --git a/api/search.index/4dcce71e79.js b/api/search.index/4dcce71e79.js new file mode 100644 index 00000000..e598cc64 --- /dev/null +++ b/api/search.index/4dcce71e79.js @@ -0,0 +1 @@ +rn_("ocwAAHEcIcYAALUZgxwBAwHFAAAcD3LjZhcAAF8cAACIHAAApR0AABQAocEAAEMU+8AASgECAcUAABuBcuJmFgAA9BsAAJ0dAAAUAPuJIcQAANkWxhsFAsEAABrVG6XwGG1z+wJlbAUCwAAAFNgZE7QcY3IVAsMAACdVGmEdHY0dbXAwHKHFAADLGfsCb3IVAcQAABbHG3KSHCHCAAACD1UQ9yoA/TeAHckAAB2ixQAAGc3QAAAdiOk3FgAAyRYAACsXAAA/FwAAfBkAAKcZAACHGgAAkBoAAMsaAABVHAAAA4EAAAEFicAAABLtGxRXHCsCwQAAGltpdMHCAACCGmUDwQAAFr8cEykcFkMcZGl36RjqGBcaGBoZGv4bs4P9EIECxgAAHJEFAsAAABhsGhVrF2NkM4EDwwAAG85hcHEBBgHFAAAcQnLk6BgAAJkcAACGHQAAnx0AAKwdAAAEAAAFAsYAABGuG6RVHG10awPBAAASsGxvcrHHAADOGSUBwQAAF3Qcc2IaRxv7AmZwBYnAAAAWixsU9RYFwMAcgAAAEucZYUwV/Bn9Gf4Z/xl5HRD2ExdEHKUBwAAAoCsQc9YM+wJkbqHFAADrGUX9wwOAAgAAJUccVB0z1xvPHGAdI7Ma2BsVSBzfGx8cdR2EHTHEAADZGOoZkxz7AmN184UCYXAFAsMAABTYGBWFHGVmQcEAAGUWFBpUHJQcMwH9ECEByAAAGb3KAAAasuYiCgAAThAAANISAAALFAAAZhUAAGgWAADbGwAAMRABACvIBCTgAAAWF+AAABZs4AAAG8sVhMIAAGjHGsgayRpVHVYdVx2nCysCyQAAHLFpcjUCxAAAEVgbE+sbaXRkF2UXmB0bAsEAABU0bnUrmckAABuAywAAG6sVAcMAABavGnIqHAUCxQAAFkAcE30baG8BAP1/2RMlJlnpiFg6RfCIFxIewl0iz+UOinxcnx9pzgAAHIEdCPE+QBaFIDJUOajGjVICf60X6Tz/SpujFHfuPdRCOu3fKTsLfiJEyAAAGO06MAAAAQAAAAAAGQAQAAAAgwPNBc4FHwc0CDUIvAs0ED8QQhZDFm4WbxbDGMQYyxh7GU4aoRqiGqwaLhxcHK4cxhzIHA==") \ No newline at end of file diff --git a/api/search.index/4e45ced048.js b/api/search.index/4e45ced048.js new file mode 100644 index 00000000..8a7730b7 --- /dev/null +++ b/api/search.index/4e45ced048.js @@ -0,0 +1 @@ +rn_("BQLAAAARohYTQBpycwkC0AAAHa3CAAAba2l2KwLKAAAdb2NyG4bBAAAZsgUCwAAAHY4dE6UabnIrA8EAABmxzQAAHYZhZWwVAsMAABVqHBA/GnBzsBkFAsMAABZeHRNpHHN1I4ECxwAAHKdjdL8D/QGJCscAABwF") \ No newline at end of file diff --git a/api/search.index/4f17965b3b.js b/api/search.index/4f17965b3b.js new file mode 100644 index 00000000..b093718c --- /dev/null +++ b/api/search.index/4f17965b3b.js @@ -0,0 +1 @@ +rn_("AYICFkm4hVLgAAAQSTBzI4KG4AAACo1BwQAAWhJbElwSORaXAAD9gCEJwgAAC77BAAAJ4ckAABzB6y0JAAAaCgAARhAAAGAQAAA4EgAAtxIAAN8WAADzFgAAIhwAAFkcAABeHAAA1BwAAEAQAgAAAA==") \ No newline at end of file diff --git a/api/search.index/4f1dbaa28c.js b/api/search.index/4f1dbaa28c.js new file mode 100644 index 00000000..bde2ad11 --- /dev/null +++ b/api/search.index/4f1dbaa28c.js @@ -0,0 +1 @@ +rn_("FQHAAAAT5Rl3tAsbAsAAABQQdHgBQeSQDgAAMhAAAAYUAAAyFAAAgBoAACACAAUCwAAAE0oWHa8cdHUhxwAAXx2uHfsCZHP7AmxtO4/AAAAcbccAABzV") \ No newline at end of file diff --git a/api/search.index/4f2aa3df5b.js b/api/search.index/4f2aa3df5b.js new file mode 100644 index 00000000..8669ad03 --- /dev/null +++ b/api/search.index/4f2aa3df5b.js @@ -0,0 +1 @@ +rn_("cccAAKYdpx2oHakdqh2zHbUd8wIBZuelEgAAGRcAAPUZAACSGwAAohsAANMcAACTHQAAlx0AAEATEAAFAsAAABuQHaI/FG95JQHFAAAXoB1w1xbNGhUBxgAAp9MbcsUcpQHDAACr0RxmXBB1AcAAABLRG2vvGNkZQxpEGv8bvRzNHCOGAsEAAB2JaXlLwBwAwQAAHYjAAAAW9CsC4AAAFyBkbqHCAACjEucbAP5ARMEHwAAAGabhAAAWsevOEgAAWBUAAF8VAACcGQAAxRkAAFkaAACyGgAAuRoAAO8bAAA3HAAAbh0AAHcdAAAFEwAAAQArAsQAABbSY20=") \ No newline at end of file diff --git a/api/search.index/4f375af7c8.js b/api/search.index/4f375af7c8.js new file mode 100644 index 00000000..5ca22725 --- /dev/null +++ b/api/search.index/4f375af7c8.js @@ -0,0 +1 @@ +rn_("IcAAAPkGtAcxwAAA6QvHGWMdIcAAAK4KKhclA8EAALE2FyISF9wbQEEVGRZtFlwXYWtzIQg+EAWJwQAAMnEXchc9GhHqG7HDAAD+FjHGAADbGdwZhxzTAv0UEALFAAAZ3fc4FAAAAQCNBKUBwQEDAJEA+wJtb2cFAP4ARJBJxAAAFkjGAAAXTc4AABx88zMKAAApABoM") \ No newline at end of file diff --git a/api/search.index/4f5a48eeae.js b/api/search.index/4f5a48eeae.js new file mode 100644 index 00000000..14898fb8 --- /dev/null +++ b/api/search.index/4f5a48eeae.js @@ -0,0 +1 @@ +rn_("McQAAMMYxBguHFHHAACSG5MblBuVG5MdBQLAAAAUaBtlnBqdGp4anxrCHMQcc3Q7A8MAABrXZXR5YdQAAKYdpx2oHakdqh21HfsDb3N0") \ No newline at end of file diff --git a/api/search.index/4fa84c5478.js b/api/search.index/4fa84c5478.js new file mode 100644 index 00000000..222400b2 --- /dev/null +++ b/api/search.index/4fa84c5478.js @@ -0,0 +1 @@ +rn_("BQLAAACx/hYYrxtkdqHSAACAHSsCxwAAG9VsbqHFAACEFhUBxgAAE4kcbEkbIwICwgAAGXpscwHoEwAAK4bAAAAOpysC4AAAHK9zdKHFAACYGQUCwAAAEbUWFoAacnMFAsIAABNXGhNYGmxwxwGB/gAKgAzCAAAXJ8kAABwoBQLBAAAQ1RwRIxZkafsCc3rzAISCHwgAAKvAAoXgAAASWcMAABuD9wAA/RFBEdEAAB1kxAAAHA7yJAwAAP8H") \ No newline at end of file diff --git a/api/search.index/4fe04cf3b2.js b/api/search.index/4fe04cf3b2.js new file mode 100644 index 00000000..48647f38 --- /dev/null +++ b/api/search.index/4fe04cf3b2.js @@ -0,0 +1 @@ +rn_("BQLAAAATFxgYuBxjZQUCwAAAF4wcKskbQRxhaBcCAP0cQQLDAAAcA8YAABx/wQAAFUDCAAAXFePMDgAAkhYAAEwcAACeHAAAEwAjAALBAAAbjGlz6LULAAD3EwAAqRYAAMQWAABiFwAAzBgAAGwcAAChHAAAWh0AABQwAwAA") \ No newline at end of file diff --git a/api/search.index/50c02d4d4d.js b/api/search.index/50c02d4d4d.js new file mode 100644 index 00000000..b8b94869 --- /dev/null +++ b/api/search.index/50c02d4d4d.js @@ -0,0 +1 @@ +rn_("FQHCAABkyRbKFssWzBY/F0AXdA0KocMAAEIWQcYAAPAZ8RnyGWUdBQLAAAAUBBkVPRxhbPMDwALA1KYcAAAHBujzAAJkaTowAAABAAAAAAAfABAAAAA3ElISUxKCEoMShBLnEvwTHxQgFCEUNBVtFW4VbxWNFSUWKBYpFhcXBBi6GKgaqRqqGrAbsRu3GxkcOxyNHI4d") \ No newline at end of file diff --git a/api/search.index/510312a6c9.js b/api/search.index/510312a6c9.js new file mode 100644 index 00000000..be97434f --- /dev/null +++ b/api/search.index/510312a6c9.js @@ -0,0 +1 @@ +rn_("FQLEAACmyxkWzRljcocaMcMAAIAWgRaxHPsCaXIhwwAAuA6GEnHFAADWGNcY6BnpGfwbdh14HTWExAAA0asYYRp8HH4ca8AAwc0AAByQWwPAAAAXaGVtdCXAxAogAACm5RgTqhgW5xiFEhwaIcgAAKkYMhwhygAAGByVHAUCwAAAGRccEMoaYXMFwMEARgAAGPEbE7MYFQkcscMAALwWxQLAAAAX0BsWfB1ydHIaocEAAKIZ8wICaXPiUBUAAHwaAAB+GwAAAwArAuQAABQUZHQVAcUAABbPG2SxGCcaA/1dMQHGAAAaicQAABiywwAAFmrGAAAaisYAABqL5r8LAAAlDAAAdxcAAAYYAAAOGAAAuxgAALIbAABhMzMwJQHCAAAWYxdyEg+hGUHDAAD2FvcWmhmbGRMBjMYAABuk5QgKAABKEgAAxBIAAKEWAACLGQAADhoAABIAAPsCbnQhxgAAYxohHBUCxQAAE4gaFvAbbXO7FiWCwQAAtNEZTxUAGrUBwAAAFl4ad9cS84GSG4nAAAAUEwWMwAAAG8ccFB4aBQPAAAAYLxwbxRwbxhxsdHUFAsIAABfEHBQuHHN0E4ECzAAAHXhkdNsEyQAAHF5hY3N2FQHBAAAUHRp2EhT7AmV0W/0QBBTDAAAXKs0AABzIBcDDAEoAABpYHRctHBRbG0OBwAKKyQAAG+/LAAAcls8AAB13BQLBAAAbXRwUsBhsdhX+wkBASAIAABlcHBOhGRqVHBQcGhWHGhEUawPCAAAWJ2FscgUCwAAAExsaH4kdYXJLA8QAABqGxwAAHCxlbHQFAsEAABhZHBlaHGNwBQLBAAAawhwddh1kdDcBgv5ABUAJxwAAG+7DAAAZoMsAABzDocoAAFwaocMAANUSa8UAJNEAAB2E0AAAHXUFAsIAABtYHBorHGFtBQLBAAAdwRwm6xgaGmZ0AYQCzwAAHZvIAAAcwGFzOwPIAAAbWmFveQUCwwAAMmgWaRbbGxuTHGVnBQLDAAATKhwVkhxicyODAsQAABtZb3IBAP1f+Acb9SoESGFdv7HTc2vcuPgnHG8Ho25l/YRUAu/xzsDMAAAcW8kAABuvwgAAFBDNAAAclHOrgaUWdKsp2PkfYKQreFMErztr48gJAAAMGgAANRwAAKUcAAAxAKHDAACQGVHEAAC5HLocuxy8HLEd8wACYWTn7Q4AAEoQAAABFAAARRUAANkWAABsGwAArRsAAMYbAAAzMQAAW8AEKMkAABwZsccAANMYNQHDAAAWZR1zphyzHJsd8wIBZOKCEgAAtxsAAI0cAAAgACOBAsMAABYmbXUBBAHFAAAcD3LjZhcAAF8cAACIHAAApR0AABQAAQIBxQAAG4Fy4mYWAAD0GwAAnR0AABQA+4kbAtAAAB2IdHUFicAAABLtGxRXHCsCwQAAGltpdMHCAACCGmUDwQAAFr8cEykcFkMcZGl36RjqGBcaGBoZGv4bs4X9EIECxgAAHJEBBgHFAAAcQnLk6BgAAJkcAACGHQAAnx0AAKwdAAAEAAAFAsYAABGuG6RVHG10+wJvcrHIAADOGQUCyQAAFJIdFZodbHIVAcIAABuHHXQWGiUBxgAAFtkcd78a7BsFAsIAABa+HBuRHWNyocYAAL0atwMC/UQRBswAAB2D4U0VAAC3FgAAEyuJxwAAG61hwwAATBX8Gf0Z/hn/GXkdQcQAAGUWFBpUHJQcNQLFAAARWBsT6xtpdGQXZReYHSuJyQAAG4DzA/0U0QPSyxgAALAVAcgAABbOG3JcHCsCywAAG6xibPEA/sBnXgMAAhseH0VGSlJTVFVWW+fwBQAA9wkAAOoLAACwEgAA3hMAABEUAACnFgAAvxsAADAQAAE=") \ No newline at end of file diff --git a/api/search.index/5127481cac.js b/api/search.index/5127481cac.js new file mode 100644 index 00000000..e6a383c7 --- /dev/null +++ b/api/search.index/5127481cac.js @@ -0,0 +1 @@ +rn_("BQLAAAAdwRwm6xgaGmZ0KwLKAAAbSGxuBQLAAAAT3hIYmBpjZRUBxAAAMF8bYBuoG3NiFSuDwQAAFCIFAsAAABVtFhqPHXBzMcQAANsZ3BmHHBcZAP5ARfAHxwAAGjfHAAAZ1sMAABu+xAAAFs7DAAAWLsIAABQjyQAAGdrjNggAAN0JAAAoFwAANBoAABMANQLAAAAVbRwYExxidK8WsBZqHSUBwQAAJxUatRx0mQpLGqHBAAAqGOMA/QghAuEAAA7m5OIJAADKEgAAFBgAALgaAABIHAAAAwAAAYMCzwAAHZvIAAAcwGFzIcIAAPASBxw7wAKEwwAAHGj3AAD9kUBAyAAAG1oBLQEAAA==") \ No newline at end of file diff --git a/api/search.index/513cc2ac35.js b/api/search.index/513cc2ac35.js new file mode 100644 index 00000000..c3c1e9b6 --- /dev/null +++ b/api/search.index/513cc2ac35.js @@ -0,0 +1 @@ +rn_("BYnBAAASnR0UpR0jjQLGAAAdrHBzpQHEAACmdhx31BkTgQLIAAAcoW5yBQLEAAAaoR0Ylx1kcwUCxAAAHbQdF4wdY2UblskAAB17yAAAHW0FAsMAABRsHRV6HWltQ4HABkDPAAAdscwAAB2cBQLAAAAU+hsaeR1lbAUCwAAAFqAcG5UdYnYrhcgAAB1rFQHAAAAXix1ithvzAgFzAWQbAAArAsUAAByfbXQFAsEAABSUHRirHWlsI4cCxwAAHaRkZQGEAtMAAB21xwAAHZNvc1f2gv5AW6oJxgAAHGTHAAAcnsQAABv5zAAAHZYFAsAAAB+KHRQzGmxyVwUA/UxSC8UAABqTygAAHMzMAAAdbsQAABo0zAAAHVrmjBIAAOYSAABIFwAAYBcAAN0YAADjGAAAyRkAADAAERAjgQLBAAAWK2l5ocUAAPgWAQMByQAAHaJm4SsXAABVHAAAgbHHAACMGvsCaGkFAsAAABSNHRSkGWZyMcEAABkUGhSuG/MC/QBQDDswAAABAAAfAAoAKAwVALUSAAClFgEATBcAALkYAADmGQAA8xsAAC0cAADbHAAAoB0AABUBwQAAFRwXcFQa+44=") \ No newline at end of file diff --git a/api/search.index/513e195894.js b/api/search.index/513e195894.js new file mode 100644 index 00000000..7c3b83da --- /dev/null +++ b/api/search.index/513e195894.js @@ -0,0 +1 @@ +rn_("BcjAggAAABPHFhhlG6dRHAmDwQAAFizQAAAdhSHAAADNC0sMJwOB/gBDIgnMAAAcZcgAABu4zAAAHKLOAAAdWxUBwQAAF+YZcNEWMcQAAOMSOhY7FgUCwAAAFFwdJMEaZB1tbqHFAAB0FvsCaG8zAcACgccAABpL6ukJAACZEgAAsxYAAMUWAACYGQAAFRoAAB4aAACbGgAAyBsAALUcAADJHAAAcwAQAAAA+wJtdCWEwgAAFG8cSRqSHaHBAAD0GPsCYWMhwgAAJww4GjHGAADaEqsZzhxFAsAAABaDGxaEG213QxdEF+sYGhoVAcQAADMCGAMYSBt0Axf7AmNy+wNlaW41gsIAABK5DikI6xnsGaUBwAAAGJAdcvcFBYbAAAAVdRwTVBpBwQAAEgjjDuQO5Q5TAAPFAAAaR2Vyc/IjEAAAWgyhxgAA5xbzAP4ARxIM+XEDAACOADcMOgpRAB0BWwA1APYA") \ No newline at end of file diff --git a/api/search.index/51cb009146.js b/api/search.index/51cb009146.js new file mode 100644 index 00000000..955c6c99 --- /dev/null +++ b/api/search.index/51cb009146.js @@ -0,0 +1 @@ +rn_("BYbAAABxrQ6uDq8OYRcfGPMZshwc5RkVAcAAABcIGXm4BwWFwAAAEb4SEb8SpQLCAAAUuhhGLhovGjAaqxxjc5EPNQHBAAAQgRpzxQ7GDg0WdwIA/RCRA84AABxMxAAAEr3lzwUAADcJAACTDwAAqhIAAAMcAAA2HAAAAREABQLAAAAb8hsbzRplc6HFAAB0FyWBwgAAEXkZ2xIzHKWDwAAAElAXFnUbrRIFAsIAABXFGxR0G2NzAQEBwwAAFY1j5g8IAAA+CQAAABQAAJMZAAC0GQAApxsAAIUcAAAQAQAABYrAAAAYCBwbERylAsMAABTDGhbWG3N3ARcbAscAABwHYWwFAsAAABN+FxN/F2xwEwMCywAAHXRzdtKpGwAAk/MA/V0AGuI0CQAAJBAAAEIXAAABADHAAADQCxsMHAz7wASH") \ No newline at end of file diff --git a/api/search.index/5206370543.js b/api/search.index/5206370543.js new file mode 100644 index 00000000..918a1e1f --- /dev/null +++ b/api/search.index/5206370543.js @@ -0,0 +1 @@ +rn_("FQLCAAATHxelURtlZzoXBQLFAAA0oRqiGsYcUocZiBmJGYoZbR10dRvAAwLDAAAXPcMAABtGQcIAABkX9Rn2GdMcBQLIAAASiR0g/xu9HGl5UwIDxQAAHMVpcXWCNxwAAKHIAADBG/uDFYHEAAARMhpXHAUDwAAAEFsbEHQbMUESGxpKHGVzdSsCwAAAG+1ydPsDYW9yBQLHAAAVIhwU5htjcxuDyQAAHDk1AcgAABaHHGPGGCwcLxwF0MAABgAAWQUaBhoHGggaex0hxRhbGhGyGSHOAABfHa4docUAAKUbdQHEAAAQAhZz6hPrEzYUDBj5GPoYhhoF/cIEAAsAAB2PHRpNHBBrGzJJFywaaB0F/cIVQAAAAB6tHR20HReMHRQ+GvsCaXbPA/0UACfKAAAdb8QAABceBQLFAACjvxulNxxwcwEA/Zi5Ty+W9IqFC9jnLCzOAAAcaA2IaZYcbqL6GQhgojS7T8kAABwGyQAAHBrLAAAcbsQAAB13PbEulGFWLYHe8V2s9Yw6OjAAAAEAAAAAAB4AEAAAAPYHOwk8CT0JGwocCh0KHgofCiAKIQqgCukLZRVHFnMXfhfHGcMaXxtgG3QbexuoG+Yb7hscHK8cUh1THWMd") \ No newline at end of file diff --git a/api/search.index/53cff3eb1d.js b/api/search.index/53cff3eb1d.js new file mode 100644 index 00000000..0e760057 --- /dev/null +++ b/api/search.index/53cff3eb1d.js @@ -0,0 +1 @@ +rn_("FYHGAAAYox1yHfMFggE9FgAAIckAAGsawBwxwgAAmg6bDmEQIdAAALMcmx3TAP0JCALGAAAXgfInCQAA3Q9TAcAAwcIAAB2Q4fMJAAB5FQAAAQ==") \ No newline at end of file diff --git a/api/search.index/53f4750105.js b/api/search.index/53f4750105.js new file mode 100644 index 00000000..19e1f4b7 --- /dev/null +++ b/api/search.index/53f4750105.js @@ -0,0 +1 @@ +rn_("AQD9BMAJwwAAFY3GAAAb2cIAAByOzAAAHWPFAAAaSO4PCAAAPgkAAFoKAACaDgAAYRAAAEcSAAAAFAAAkxkAALQZAAD4GQAApxsAABIcAAAxHAAAhRwAAMscAAAQEQABAAAAAA==") \ No newline at end of file diff --git a/api/search.index/5413b9dd09.js b/api/search.index/5413b9dd09.js new file mode 100644 index 00000000..c9a1bfea --- /dev/null +++ b/api/search.index/5413b9dd09.js @@ -0,0 +1 @@ +rn_("BQPEAAAWYhoXZxoXaBpiaXAFg8EAABWBGhLIGTHEAADnFugWuxv7Am1upQLCAAAUwxoW1htzdwEXGwLDAAAaRmN0nwL9AdgIxwAAHAfAAAAUDcYAABZ4") \ No newline at end of file diff --git a/api/search.index/5508c8442c.js b/api/search.index/5508c8442c.js new file mode 100644 index 00000000..28b7eb78 --- /dev/null +++ b/api/search.index/5508c8442c.js @@ -0,0 +1 @@ +rn_("BQLAAAAVDBgX0xxldAUCwAAAEvcZECgcaXkFisAAABN9FhKHFSHCAACGFakZxQHCAADFVBtueRYbAsYAABrUZHSrwATE4wAADw3LAAAchAUCwAAAVQoYCxirG2UctB0fhB1wcrHQAABVHVc1AP2aMwPHAAAdfcIAABWFyAAAGtXFAAAdr+MwCgAAIxcAAKAaAAC+HAAAIgA=") \ No newline at end of file diff --git a/api/search.index/550d9fc3a3.js b/api/search.index/550d9fc3a3.js new file mode 100644 index 00000000..df9dfd78 --- /dev/null +++ b/api/search.index/550d9fc3a3.js @@ -0,0 +1 @@ +rn_("BYnAAAAeRhwdFhwrAscAABj6ZXQFAsEAABvFGhM3EGFzJQHGAAAWTRp3thJ4FQWMwgAAEXMQEu8SMcUAAO4SGBjuGbHCAADkFhUBwAAAHdYcYsUa84aGpx4A/gB1hgnNAAAcFcgAABj5yAAAF33LAAAaxuOOAwAAzxIAAB4WAAClGQAAMQA=") \ No newline at end of file diff --git a/api/search.index/55b618af8d.js b/api/search.index/55b618af8d.js new file mode 100644 index 00000000..0d95d967 --- /dev/null +++ b/api/search.index/55b618af8d.js @@ -0,0 +1 @@ +rn_("JQLEAAAWzhswfBmQGssacnM0EFwcZQHAAAAZ0hh0RQjiG+Mb5BvlG5wdFQHNAAAQtxxzsRoFwMAEIQAAHF4bKWMaIRwdRRwBQ45iEAAAAUGO2wsAAAUDwAAAHnUcF0saFWwWaG9ypYHFAAARphlfFQUCxQAAGGYcFV8aZHIJAsUAAB2g0AAAHXFvclHFAAABGgIaAxoEGnodMcQAAOMSOhY7FlHEAACDGYQZhRmGGWwdBQLAAAAUXB0kwRpkHW1uocUAAHQW+wJobwUCwAAAFmMdGrkbdHVX9wD+wEN2A80AABx0xgAAGL/EAAAWyO3pCQAAmRIAALMWAADFFgAA1xYAAJgZAAAVGgAAHhoAAJsaAADNGgAAyBsAAJgcAAC1HAAAyRwAAHMAAQAAAAChxQAAdBelg8AAABJQFxZ1G60SBQLCAAAVxRsUdBtjcwWKwAAAGAgcGxEcpQLDAAAUwxoW1htzdwEXGwLHAAAcB2Fs8wPCFkDhJBAAAEIXAAAQAUHjCBYAAM8WAAATFwAAVBsAAEET8wACZW4BHgcAANHEAABwFQUDxgAAFhIcJbAathwYfRxlc3T7AmZyAQD9P7EHELbz/pobFu1N5MMAABLnxwAAHB1WKEODbccAABj4doHU2C7CAAAbRmhtQHEbC4wJu0wjatkHo0CsQQlvPooHNHHj7QYAACYHAAAOFwAAoxoAAEQA") \ No newline at end of file diff --git a/api/search.index/568c13ce28.js b/api/search.index/568c13ce28.js new file mode 100644 index 00000000..ac003e4f --- /dev/null +++ b/api/search.index/568c13ce28.js @@ -0,0 +1 @@ +rn_("BQPAAAAWEhwlsBq2HBh9HGVzdAFOOzAAAAEAAHUAAQDcHHUAK4nDAAAXfTWEwAAAoY4ZIwyxEkIVGwLCAAAY2G1y0wD9MAFAxAAAHIU7MAAAAQAAggAEADQWAABBFgAAghd/ACkcAAClAsEAABS6GEYuGi8aMBqrHGNzkQ81AcAAABCQG3P9E/4T1Ro/AcAEzMQAABw1wgAAC7c=") \ No newline at end of file diff --git a/api/search.index/56997b0edb.js b/api/search.index/56997b0edb.js new file mode 100644 index 00000000..a8f053cf --- /dev/null +++ b/api/search.index/56997b0edb.js @@ -0,0 +1 @@ +rn_("McMAANESHxYgFhWBxgAAEg0cvBojBALEAAAaWmh38koVAADyBKHEAABIFdMC/TAABsUAABwz4wEKAACOCgAA5RkAAK8dAABBACuJxAAAG3A=") \ No newline at end of file diff --git a/api/search.index/56e0e1b166.js b/api/search.index/56e0e1b166.js new file mode 100644 index 00000000..f9504bc9 --- /dev/null +++ b/api/search.index/56e0e1b166.js @@ -0,0 +1 @@ +rn_("FQLAAAAW4RsaUx1hdJEWBQLBAAA0lxuYG5QdOIocixyrHWlsMcgAAE8cUBykHbOG/RgQAsoAAB1SocgAABoYW47AAAASrA==") \ No newline at end of file diff --git a/api/search.index/571b4a802c.js b/api/search.index/571b4a802c.js new file mode 100644 index 00000000..c1383f4c --- /dev/null +++ b/api/search.index/571b4a802c.js @@ -0,0 +1 @@ +rn_("AQMBxgAAHAly4QYYAAAOGAAAMwUCwwAAFpgdF58dY3BTgv0SSADHAAAb28cAABwywcQAAIUbQwQDxwAAHaPFAAAdaWFmcPwhFgAAAQDoAQEA6QG3AW0ATQALACUA3QBCADv9AEkDxQAAGebFAAAaaMIAABx0") \ No newline at end of file diff --git a/api/search.index/572a1b9c66.js b/api/search.index/572a1b9c66.js new file mode 100644 index 00000000..77e8b668 --- /dev/null +++ b/api/search.index/572a1b9c66.js @@ -0,0 +1 @@ +rn_("BcHAAQIAAGacFp0WnhafFgUcoBwy2Q4eEHUbW5obmxucG50blR1RyQAAfhl/GYAZgRlrHVufwgAAG93VAAAdjcEAABRB") \ No newline at end of file diff --git a/api/search.index/572bb9a5ab.js b/api/search.index/572bb9a5ab.js new file mode 100644 index 00000000..201c2edf --- /dev/null +++ b/api/search.index/572bb9a5ab.js @@ -0,0 +1 @@ +rn_("AULjDhQAAAIYAADgGAAASBsAABEgZQLCAACm5xYi6xgaGmNscBrBGt4bORxuHGQdIcgAAGYbWR0BAwHJAAAdombhKxcAAFUcAACBsccAAIwaIcMAAIQV6xshwQAA0xFYG/MBAml06CsKAADDEgAANRQAAFMVAABkFwAA5RgAAMsZAAA0HAAAmB0AAEACERAAZQHBAAATgxV07BPtE+4T7xPwE8gZ+wJueDUBxAAAEIEac8UOxg4NFvv9kUFB") \ No newline at end of file diff --git a/api/search.index/57714b4cc1.js b/api/search.index/57714b4cc1.js new file mode 100644 index 00000000..441ef57d --- /dev/null +++ b/api/search.index/57714b4cc1.js @@ -0,0 +1 @@ +rn_("QcMAABgMGQwaDIsbQcUAAC4aLxowGqscEwD9EAZUyAAAHNrBAAAbf8YAABntxQAAG4nGAAAdju9QDAAAPhYAAHMWAAAEFwAAXhcAANYYAADoGQAAnBoAAKUbAAD8GwAAjxwAAKMcAADCHAAAxxwAAHYdAAB4HQAAMAUBExAAIAB1AcAAABLRG2vvGNkZQxpEGv8bvRzNHCOFAsEAAB2JaXkxxgAA4BbhFo0ZBQLAAAATpxpC9hb3FpoZmxludDHDAADaEqsZzhwVAcUAABBMHHNiFysCxAAAGPJsdhuZxwAAGeXHAAAZyDHEAADJG0EceBwTAALJAAAbjGVm51EVAABjFQAAdBYAAPwWAADbGQAAcRoAAL0aAACHHAAAEREQEBMAAsMAABw/ZGUBhhwAACHEAAC6GDscp+8A/kBvUQXHAAAcPMoAAB2FxgAAG6Q6MAAAAQAAAAAAUwAQAAAA7wU+CD8I/An9Cf8JAAoICgkKDgoPCpoKmwq9C9MLlw6YDsEOkQ+SDyYQJxAoECkQKhBKEksSTBJNEk4SxBLlEvYTAxYqFqEWoxbaFtsW3BbdFt4W4BbhFiIYIxijGKQYpRimGMAYyBj/GAAZARkCGXoZfRmLGY0ZrBmuGbMZDhoPGhAaERqCGoMahBqFGtYaqhvBG8IbwxvEG10ciRxzHX8dgx2HHbAd") \ No newline at end of file diff --git a/api/search.index/57d599b4a9.js b/api/search.index/57d599b4a9.js new file mode 100644 index 00000000..531da5dc --- /dev/null +++ b/api/search.index/57d599b4a9.js @@ -0,0 +1 @@ +rn_("BYnAAAAS7RsUVxwrAsEAABpbaXTBwgAAghplA8EAABa/HBMpHBZDHGRpd+kY6hgXGhgaGRr+G7OF/RCBAsYAAByR") \ No newline at end of file diff --git a/api/search.index/57e934d6b0.js b/api/search.index/57e934d6b0.js new file mode 100644 index 00000000..63d7ce67 --- /dev/null +++ b/api/search.index/57e934d6b0.js @@ -0,0 +1 @@ +rn_("CQLPAAAddcMAABndaXChygAAXBoVAcQAABNTGmLWHBsCwQAAGmVwdaHDAADVEjHEAABbF+cYvBkFisAAADKIFYkVWhtG4xjkGMkZyhkFAsAAABL3GRAoHGl5KwLPAAAdhGVyAUE7MAAAAQAAegAFALsOAAAaD3YA0hsAAK0dAACyHQAAawPDAAAZ+GFjdAWJwAAAHKscGd4bBQLAAAATVxoTWBpscDWBwAAAwr8WLhAvEJkb/2/FDP7IAAAayg==") \ No newline at end of file diff --git a/api/search.index/57fc59b4e7.js b/api/search.index/57fc59b4e7.js new file mode 100644 index 00000000..006e6639 --- /dev/null +++ b/api/search.index/57fc59b4e7.js @@ -0,0 +1 @@ +rn_("ocgAAFEbBQLGAAA0oRqiGsYcUocZiBmJGYoZbR10dQUCyAAAEokdEP8baXkTgwPFAAAcxcEAABcZaXF1BQLBAAAQdBsxQRIbGkocc3X7AmFyBQLIAAAVIhwU5htjczHIAADGGCwcLxwFAsAAAFkFGgYaBxoIGnsdEbIZbXqhxQAApRtlAcQAABACFnPqE+sTNhT5GPoYhhoFAsIAABBrGyJJF2gdc3UF/MIAVAAAHq0dHbQdF4wd+wJpduvAAuDKAAAdb9cPAP2QGUzOAAAcaAHuGwAA") \ No newline at end of file diff --git a/api/search.index/588cc73fc7.js b/api/search.index/588cc73fc7.js new file mode 100644 index 00000000..3b4f7b5f --- /dev/null +++ b/api/search.index/588cc73fc7.js @@ -0,0 +1 @@ +rn_("BQLAAAATGxofiR1hcksDxAAAGobHAAAcLGVsdAUCwQAAGFkcGVocY3AFAsEAABrCHB12HWR0NwgA/kAHYgnBAAASrMcAABvuwgAAFEHCAAAUQsMAABmgywAAHMMB5gkAABWExAAAE2EXHRYrA8cAABoOxAAAFrNpbXZbwATAywAAG6jzAILzOxAAAIUCTQk=") \ No newline at end of file diff --git a/api/search.index/59803e469f.js b/api/search.index/59803e469f.js new file mode 100644 index 00000000..07a24492 --- /dev/null +++ b/api/search.index/59803e469f.js @@ -0,0 +1 @@ +rn_("FQHCAAAWYh1ySxwbicQAABmnQwADywAAG7zCAAAciWVsdDowAAABAAAAAAAWABAAAACwC+4L7wt7FRUYBhkKGbUZ2BnwGfEZ8hkuGi8aMBqmGkkbght1HIMcqxxlHaIdYcMAAKIOow7YEUcXqBjKGBsDyAAAGrLPAAAdXGFsbQUCwAAAoZMPEQAPaW77gwUCwAAAQhscfxyAHGYdo+sZaWwrAsEAABanbG8FAsQAABRpGhUCHGF38wABbgEOHAAABQLAAAAX7xYUPxBmdFvCCATBAAAOwwEBAeAAAApgc+ZFCQAAlA4AAHAQAADYFgAARRcAAMIZAABaGgAAACAQAAUCwQAAFNgYFYUcZWYVAccAABBMHHNiF0MAwA5AwwAAHDvKAAAdhccAAB2O8yoWAACJAxEC+wJnaRUBwAAAF3cbY5kd8wMBcwFSGgAAO8AEhMEAABK7QcsAAHkaehrRHNIcBQLAAAASThoRvhlpdBUBwwAAFPAWZz4XAUXhCBYAAFQbAABDBQLAAAAYxhkTBxZucwUCwAAAEAEcI78ZtBpydtcDAP0woQTIAAAcruS5BwAAPBQAAHkWAADnGQAAiB0AACIwAHHAAABlFrgZFBpTG1QclByqHKvEBiDHAAAcrOAAABweRf3DA4ACAAAlRxxUHTPXG88cYB0jsxrYGxVIHN8bHxx1HYQdFQHDAAAQtxxzsRoxxAAA2RjqGZMc2/0EAA3AAAAQOwEA/X3pBwNPgU/RxQAAG2LCAAAMBXGxXT8HRp/zBIjMAAAbu3KwRNQjQKPt0hXIAAAZvTETST/mIUQWXYYMFLvxBCmyyKoHwgAADAY7MAAAAQAAdQEcACsBAAAvAQAAPgHbABsCAACEAwAAyAUBAP4FAQAiCgMAuQsCAKUOAACwDgEAThABAFwQAQCjEgEA0hIBAAsUAABmFQAAjhVvACYWAABoFgEA5xYBAPAYAQCMGQAAYhsAALsbAQDVGwAA2xsAAFwdAAA=") \ No newline at end of file diff --git a/api/search.index/5a0b05d867.js b/api/search.index/5a0b05d867.js new file mode 100644 index 00000000..daabb9ba --- /dev/null +++ b/api/search.index/5a0b05d867.js @@ -0,0 +1 @@ +rn_("UcIAAIcZiBmJGYoZbR1RxAAAbhZvFqEaohrGHBWExAAAFIgdWRoTAALVAAAdrnR1giEQAAAFAsIAABhtHBOvHGNtlwME/gAIYAfGAAAaRccAABrC8tELAAC+EYvABMHBAAAVfMsAABjsxAAADsE=") \ No newline at end of file diff --git a/api/search.index/5a337b3006.js b/api/search.index/5a337b3006.js new file mode 100644 index 00000000..ca44978d --- /dev/null +++ b/api/search.index/5a337b3006.js @@ -0,0 +1 @@ +rn_("AUDmsQsAABoWAACeGwAA4BsAABccAACMHQAAlh0AABAwAADBxAAAhRuhxQAAExcHFgH9NaEDxwAAHaPDAAAcGcMAAByqxQAAHWnCAAAYIMEAABBA//gJAABQCNkDAQDoAQEA6QG3AW0ATQALABQAEQDdAEIA") \ No newline at end of file diff --git a/api/search.index/5a5d71bd40.js b/api/search.index/5a5d71bd40.js new file mode 100644 index 00000000..885a834d --- /dev/null +++ b/api/search.index/5a5d71bd40.js @@ -0,0 +1 @@ +rn_("McEAANcLExTqGyWCwQAAtNEZTxUAGiHAAAB7G1IdZQHAAAAWXhp31xLYEtkScRdyFz0a+wNlc3UrAskAABvZbHSb/SVAAcMAAByuxwAAGXnCAAAb76HDAAAOFCMBAsAAAA8RaXToJwgAAD0SAAA7FQAA2hgAAKUaAABKGwAAbhsAAPobAACLHQAAEwADAAAVAcIAABEqF3TWCwUCwQAAHo8cH70cbHNlAcEAAMDyEnOiC6MLpAuzErQSxBprBMAAABVewAAADxlub3N2BYzAAAAbxxwUHhoFA8AAABgvHBvFHBvGHGx0dQUCwgAAF8QcFC4cc3QTgQLMAAAdeGR02wTJAAAcXmFjc3YVAcEAABQdGnYSFKHDAADwGD8B/gAFQgLDAAAUDMYAABin5wMA/RFFVMAAABzMzQAAHMjBAAAXOfxECAAAowEBABINQQDhAAEAIwJBAOoACAABAg==") \ No newline at end of file diff --git a/api/search.index/5aa37c7a12.js b/api/search.index/5aa37c7a12.js new file mode 100644 index 00000000..6922b3b4 --- /dev/null +++ b/api/search.index/5aa37c7a12.js @@ -0,0 +1 @@ +rn_("BQLAAAAXURoSihVpcjHAAABfG2AbqBuHAQD+AASEC8UAABkGyAAAGrbKAAAbyMUAABvKOzAAAAEAADMAEAAxCAIA2gkAAPYJAAA0CiEArgsAADcQAABFEgEAYhUAAGsWAADpFgAA7hYAANsYAADAGQAAXxoAAGUaAADPGwAAAUSiYBIAAKHGAADSElMAwQMAxQAAGkYBgQMAAAUCwwAAGTUcGqUcbXYFAsEAABPTFhIyFnB0jwHABmjIAAAao8YAABjHywAAHAAFA8EAABZiGhdnGhdoGmJpcCsC4AAAC6hydF8G/R0JBMUAABnIxAAAHI7AAAAWFg==") \ No newline at end of file diff --git a/api/search.index/5b02eb2510.js b/api/search.index/5b02eb2510.js new file mode 100644 index 00000000..5d051cb5 --- /dev/null +++ b/api/search.index/5b02eb2510.js @@ -0,0 +1 @@ +rn_("McQAAMkbQRx4HBMAAskAABuMZWbnURUAAGMVAAB0FgAA/BYAANsZAABxGgAAvRoAAIccAAARERAQEwECwwAAHD9kZQGGHAAA") \ No newline at end of file diff --git a/api/search.index/5b3726c12c.js b/api/search.index/5b3726c12c.js new file mode 100644 index 00000000..39d9d253 --- /dev/null +++ b/api/search.index/5b3726c12c.js @@ -0,0 +1 @@ +rn_("JQHGAAAXzRtmGxfGGaUCwwAAFoMbFoQbbXdDFxsCygAAHY1scwUDwgAAKBQalByhRRUXfhtiZWzPAf0QSQHNAAAcTMQAABKx") \ No newline at end of file diff --git a/api/search.index/5bcdeb7958.js b/api/search.index/5bcdeb7958.js new file mode 100644 index 00000000..92751245 --- /dev/null +++ b/api/search.index/5bcdeb7958.js @@ -0,0 +1 @@ +rn_("xQHDAAAXfB1zfBoTAwLGAAAcCWly4gYYAAAOGAAAuxgAADMwFcDDAkgAABaYHRefHRaIHMsYU4L9EkgAxwAAG9vHAAAcMg==") \ No newline at end of file diff --git a/api/search.index/5c621d4c87.js b/api/search.index/5c621d4c87.js new file mode 100644 index 00000000..b09ef337 --- /dev/null +++ b/api/search.index/5c621d4c87.js @@ -0,0 +1 @@ +rn_("AQABwQAACgdi7IIDAADsBQAAzg4AAEIUAAAkFgAALBYAABkYAAAFGQAAsRkAANMaAACWGwAAWxwAAKIcAAACQAAAAAAA") \ No newline at end of file diff --git a/api/search.index/5c7f453061.js b/api/search.index/5c7f453061.js new file mode 100644 index 00000000..41c8d83a --- /dev/null +++ b/api/search.index/5c7f453061.js @@ -0,0 +1 @@ +rn_("BQLDAAAZ/RsdmR1kc7HKAAA5GqHUAACAHaHBAAAuEPsCZW3BxwAADhgFAsAAABZHFxeqGWR2IcMAAPgSCBwFAsMAABMHGRbWGmZrFQHGAAAVHhxzDRhDAAPAAAAVfcMAABgEYWNv9p0OAAABAIMIUATJABkABQLBAAAbHRwZjhtibAEA/kBL6C9a+XxT/8sAABrXKmWwP0JSv4aUfw3sXJdrez7pA7II5Fz4L8YAABvuUi6R+SXEAAAcBMMAABWLwgAAG2osiCZpRy8fgHGF8jMJAAB/Dw==") \ No newline at end of file diff --git a/api/search.index/5c87afc0a3.js b/api/search.index/5c87afc0a3.js new file mode 100644 index 00000000..0be67fe5 --- /dev/null +++ b/api/search.index/5c87afc0a3.js @@ -0,0 +1 @@ +rn_("FQHCAAAx1BpEHHscbWcaBQLAAAATShYdrxx0dSHHAABfHa4d+wJkcxOBAsAAABQGbG2h0wAAgB0jAIXAAAASWQEjFAAABQLCAAAS9xkQKBxpeTHAAAAuEC8QmRtbwAQGzAAAHKsFisAAABW/HCYtGnYbIc4AAB8chB13EwD+gEaMGcIAABwpwQAAGNjCAAAVXsUAABxDOjAAAAEAAAAAAB4AEAAAAJwKRQxHDEgMSQxKDKcOxRKGFocWiBaJFooWgBfpGOoY3xngGRcaGBoZGjkaOho7GlwaXRpgGnkb2hv+G9Ac") \ No newline at end of file diff --git a/api/search.index/5d1bb4d527.js b/api/search.index/5d1bb4d527.js new file mode 100644 index 00000000..5749a86b --- /dev/null +++ b/api/search.index/5d1bb4d527.js @@ -0,0 +1 @@ +rn_("scMAAOAYpQHGAAAW2Bx3uhrzgwJtdAUCwAAAFHsVpFYXY3L7AmVzNQHCAAAWVhp3yRIbFhwWKwLJAAAaymV2AUk7MAAAAQAA2QACAAwZbADYGmwAcwD9CAAbwQAAC+zFAAAXWOOqCwAA1xsAAM8cAABgHQAAIAA=") \ No newline at end of file diff --git a/api/search.index/5da829e7a0.js b/api/search.index/5da829e7a0.js new file mode 100644 index 00000000..20788fd5 --- /dev/null +++ b/api/search.index/5da829e7a0.js @@ -0,0 +1 @@ +rn_("UcsAALkcuhy7HLwcsR0VgcEAABQWGMsOCQLUAAAdjcAAABmtY29RywAAmhubG5wbnRuVHTHCAADZDh4QdRsFAsMAAGKcFp0WnhafFgUcoBwYjxxpefvBAQJRwgAAhxmIGYkZihltHVHEAABuFm8WoRqiGsYcFYTEAAAUiB1ZGhMAAtUAAB2udHWCIRAAAFHAAADsFu0WjBqNGo4aBQLCAAAYbRwTrxxjbRWQwAAAFkgayxw3DwD+AArgD8YAABpFxwAAGsLBAAAb3eX3BwAA0QsAAFUMAACAFgAAsRwAAI8dAAAwAQBRyQAAfhl/GYAZgRlrHbcLAP2RQUnDAAAbc8EAABRBAb4ZAAA=") \ No newline at end of file diff --git a/api/search.index/5e11494b8e.js b/api/search.index/5e11494b8e.js new file mode 100644 index 00000000..221d532e --- /dev/null +++ b/api/search.index/5e11494b8e.js @@ -0,0 +1 @@ +rn_("BQLBAAAU2BgVhRxlZhUBxwAAEEwcc2IXQwDADkDDAAAcO8oAAB2FxwAAHY7m/wkAACoWAACzGQAADxoAAMEbAADEGwAAsB0AABACEABrA8AAABU9Zmdp") \ No newline at end of file diff --git a/api/search.index/5e6676de21.js b/api/search.index/5e6676de21.js new file mode 100644 index 00000000..01e7c782 --- /dev/null +++ b/api/search.index/5e6676de21.js @@ -0,0 +1 @@ +rn_("ocgAAOwWAUbkhhYAAN8ZAAA5GgAAXBoAAGAaAABBIQAXAQD9ECgGwgAAGa3CAAAZ1uIAAAk76EUWAAC/GQAA0RkAANoZAAC0GgAAyRsAAD8cAABBHAAAeBwAABAgAAAgKwLCAAAUN2F0") \ No newline at end of file diff --git a/api/search.index/5ebdc97bb3.js b/api/search.index/5ebdc97bb3.js new file mode 100644 index 00000000..51a5eece --- /dev/null +++ b/api/search.index/5ebdc97bb3.js @@ -0,0 +1 @@ +rn_("IcMAANoZPxwlAcMAABWYHHLBEnYXBYnBAAAycRdyFz0aEeobscMAAP4WMcYAANsZ3BmHHNME/RQQAsUAABnd9zgUAAABAI0EpQHBAQMAkQArAsUAABLEcHQhxwAAVRphHQUCwAAAEaocFcoYZXLDAP0NkADDAAAcMckAABvczAAAHY0BMBwAAAUCwQAANc8S0BIeFroPGnJ03wPBhCxG/kAKNg==") \ No newline at end of file diff --git a/api/search.index/5ed381285a.js b/api/search.index/5ed381285a.js new file mode 100644 index 00000000..abb5666d --- /dev/null +++ b/api/search.index/5ed381285a.js @@ -0,0 +1 @@ +rn_("FQHAAAAQFggwCwcVAcAAABAVCDAKBzMACOAAAAcMwQAACfvgAAAHDcEAABx0wQAAGfvGAAAdcTEyNDc4ZWhpglEbAAA=") \ No newline at end of file diff --git a/api/search.index/5ed3a88012.js b/api/search.index/5ed3a88012.js new file mode 100644 index 00000000..f01b302a --- /dev/null +++ b/api/search.index/5ed3a88012.js @@ -0,0 +1 @@ +rn_("BQLNAAAWmB0Xnx1jcAUCwAAAHGwcEjUWb3khwwAAZRVTHQUCwAAAGWscFnAbYXcrAscAABu+YXQBggLQAAAdssEAABtvaXYVAcMAABWoHHBBGpOC/QCJCsUAABu9xAAAG27CAAAaQvsDYWV0BQLDAAAVbBsVbRtwc6HEAADVFgUCwQAAEdIYETQWZmkJAtAAAB2twgAAG2tpdisCygAAHW9jchuGwQAAGbIFAsAAAB2OHROlGm5yKwPBAAAZsc0AAB2GYWVsFQLDAAAVahwQPxpwc7AZBQLDAAAWXh0TaRxzdSOBAscAABynY3S3AYL9AYkKxwAAHAXDAAAaQAUCxQAAFQQcE2obc3XzAP1IGQ6EsQoAAA==") \ No newline at end of file diff --git a/api/search.index/5efac6e741.js b/api/search.index/5efac6e741.js new file mode 100644 index 00000000..3f8cb28e --- /dev/null +++ b/api/search.index/5efac6e741.js @@ -0,0 +1 @@ +rn_("JQLEAAAWzhswfBmQGssacnM0EFwcZQHAAAAZ0hh0RQjiG+Mb5BvlG5wdFQHNAAAQtxxzsRoFAsAAACljGiEcHUUcbHIBQ45iEAAAAUGO2wsAANHEAABwFQUDxgAAFhIcJbAathwYfRxlc3T7AmZyFz8A/S2hB8MAABLnxwAAHB3HAAAY+OPtBgAAJgcAAA4XAACjGgAARAA=") \ No newline at end of file diff --git a/api/search.index/5ff2504936.js b/api/search.index/5ff2504936.js new file mode 100644 index 00000000..c0a5eae0 --- /dev/null +++ b/api/search.index/5ff2504936.js @@ -0,0 +1 @@ +rn_("FQHDAAAWYh1ySxzzAAF04e4LAACmGgAAEEMAA8EAABA7wgAAErthaXM7MAAAAQAAdgAGAI4VbwAmFgAA5xYBAGIbAAC7GwEAXB0AAGHGAABNGgscdhx3HNgc2RwhxAAAextSHfMDAnN36LYSAABWFQAAeBUAAFoXAADUGQAAuhoAAL8aAACDGwAA7BsAAAEAEQAAOwPjAAASh2FkeQ==") \ No newline at end of file diff --git a/api/search.index/6005e73eaf.js b/api/search.index/6005e73eaf.js new file mode 100644 index 00000000..cb67b898 --- /dev/null +++ b/api/search.index/6005e73eaf.js @@ -0,0 +1 @@ +rn_("RQHCAAAUTxp09hb3FpoZmxkVAcEAACctGnYbZEUME4ECwAAADEZlcgUCwAAAEk4aEb4ZaXQFAsAAABABHCO/GbQacnZTggPIAAAcrmVmdCMABMAAAAu+wQAAGqfOAAAcfmFkbnb0ywcAAKgIbQIbBvvAAMQ=") \ No newline at end of file diff --git a/api/search.index/607534895c.js b/api/search.index/607534895c.js new file mode 100644 index 00000000..8b1b142c --- /dev/null +++ b/api/search.index/607534895c.js @@ -0,0 +1 @@ +rn_("IccAAEsW0BuhxQAAFBYhxwAAgBxmHQUDwAAApBIWE/wTEXobYW90UccAAEobSxtMG00bix13DAD9NtANyAAAHEnBAAAO/8UAABdcxQAAHXzIAAAbmTswAAABAAAtABIAvAcIANUOAwAzFQAAdRUCAH4ZAwCDGQMABRoDABMaAAA/GgAAchoDAIkaAQCXGwEAthsAAL4bAADxGwAAax0BAHsdAACUHQAA") \ No newline at end of file diff --git a/api/search.index/60e72c040a.js b/api/search.index/60e72c040a.js new file mode 100644 index 00000000..f1b613b1 --- /dev/null +++ b/api/search.index/60e72c040a.js @@ -0,0 +1 @@ +rn_("BQLAAAATORUS3BhpbiUBwQAAEVEabbYKZhoxxwAAohujG5cdNQHFAAAWiRt3cxbDHMccBQLBAACn4RlH4xnkGYAdgR1yc/sCZXT7AmV0BQLAAAAi1BYgHBV8G2RlccAAAMQJxQnGCccJzhZqHKgcBQPBAAAYVBwRpRw2YRp8HH4cYmVz+4MbAsMAABVBbHQhwgAANxqMHAUCxgAAIE4cnh1iIxwkHCUcJhxbHaEdc3UrisUAAB2wocQAADcVBQPAAAAVZhUV4hYT3RJhaWoBAP2vsRvJAAAarhqDo+jpc24nQcBEzUse8sMAABbIxAAAFjnHAAAY88MAABcdBh8lclsMPcvhZnLqL8ktDZYEzt87xWQORBJTySw75yMHAAARFwAAHRoAAE4bAAAGHAAAph0AALMdAAC1HQAAEAEEAA==") \ No newline at end of file diff --git a/api/search.index/63b64c0f09.js b/api/search.index/63b64c0f09.js new file mode 100644 index 00000000..3727dedf --- /dev/null +++ b/api/search.index/63b64c0f09.js @@ -0,0 +1 @@ +rn_("AQQBxQAAHEJy5OgYAACZHAAAhh0AAJ8dAACsHQAABAAAI4ECxQAAG8NjZwUCxgAAEa4bpFUcbXQBQeNKEAAAARQAAGwbAACtGwAAMwDzAoIBsBIAAPsDbG9y") \ No newline at end of file diff --git a/api/search.index/64a84ab05c.js b/api/search.index/64a84ab05c.js new file mode 100644 index 00000000..75d8a2f6 --- /dev/null +++ b/api/search.index/64a84ab05c.js @@ -0,0 +1 @@ +rn_("AQICwgAAGlPDAAAcl2Jp6s4LAADCEgAASxYAAMQZAADhGQAAjxoAANAbAADTGwAACRwAAHEcAADWHAAAEAAQAQEAKwLDAAAaOGxu") \ No newline at end of file diff --git a/api/search.index/64b98ee290.js b/api/search.index/64b98ee290.js new file mode 100644 index 00000000..690fde28 --- /dev/null +++ b/api/search.index/64b98ee290.js @@ -0,0 +1 @@ +rn_("JQHBAAAWpBpy0RiSGSsCygAAHINjbRsCxgAAGa9pdqHJAADBGxWBxAAAETIaVxwFAsAAABHtGxFbG3J0+wNhb3IhwgAAxRhbGgUCwAAAGD4aLl8drh1yeAUCxQAAo78bpTcccHN3CAD9GDEPygAAHDnJAAAcBssAABxuxAAAHXfEAAAXPQGgCgAABYrAAAAlgRtiHaMKGhUBxgAAGMscdJEcNQHGAAAfoh10TBctHNscBYnBAABi9Bv1G/Yb9xv4G50dZF8cYBxhHGIcYxylHWHHAACZHJocmxycHJ0crB3zhgJwc88B/RFIAcMAABcLzAAAHLUlhMQAABSpHKsamh2xwgAAnxL7AmRlJYTEAAAUbxxJGpIdFQLDAAATiBoW8Bttc7sWe/1ACAnEAAAXPAUCwAAAJ64Zfx0ZaRtudiuKyAAAHVjRwwAA6BIVAcMAALB1FXM1EDMBA+YAAAobaW5z4gYIAABGCAAAcBUAAANABQLAAACyPBTAghpkZwUCwAAAEQ0UGIEabW8Tgf0UwADAAAAOt8EAABBRwgAAFiT7A2FucwXAwwBKAAAayhwX8xsUkhorAsIAABqkcnQBAP0IARLFAAAcv8IAABwpwgAAFV7FAAAcQ+ZHDAAA6RgAABcaAAB5GwAA2hsAAP4bAADQHAAAMSAAACHDAADGFs4aBQLAAAAQohIbcB10dVMAA8YAABqmYWN04REKAACtFgAAEDvBBCDGAAAZrSHHAABLF2oaNcLDAkAAACUiHNQcJHMX5hsW3hlbEDoUOxQbiccAABoxBQLBAAAZaBsephxkdAEA/sBnvBspt1q/J8AAAAqwAW58Mdsm13x430PMpDljTP1djIJC/00vdF46GCqHzAAAHDMZuLKoEibqJ5i4c8MP8U4tS6PiMhCv2i4BygAAHEbFAAAS7ez0BQAAGwcAAOYHAADYCQAAwg4AAL0SAABmFgAA0xYAAG8aAADtGwAA9BsAAEscAACdHQAAAUEAEABAAA==") \ No newline at end of file diff --git a/api/search.index/6576455c8d.js b/api/search.index/6576455c8d.js new file mode 100644 index 00000000..170b587e --- /dev/null +++ b/api/search.index/6576455c8d.js @@ -0,0 +1 @@ +rn_("wcMAALUYocUAAPgWMcIAAAIYAxhIGwEDAckAAB2iZuErFwAAVRwAAIGxxwAAjBr7A2hpeWWCwAAAFQoZSRBBFRkWbRZcF7ccFQHDAAAWyht27hYjgQLKAAAc23BzBcDACCQAABSNHRaQHCQJGaQZAUw7MAAAAQAAdQABANwcdQBRwQAA5hPnExkUGhSuGyHCAAASF9wbBQLAAAATAxmjeRxydgUCwAAAsDYXEcASbHIF/MAPAAAAEF8WEGAWEGEWEGIWBfzA/wMAABBVFhBWFhBXFhBYFhBZFhBaFhBbFhBcFhBdFhBeFgX8wP4DAAAQTBYQTRYQThYQTxYQUBYQURYQUhYQUxYQVBb7/AcAAQAOFkm4hVJzBEq+U0dZABoOyAAAHHs2q3Dh7SU6dWhcZGMsOWY+AuS+7DcsYMBFMeMxW3U63vBLtlP7sxFSxQAAGsR7rEY26jBhZWlrbW5vcnN0dXZ5OzAAAAEAAE8AIwAhCAAAQwgAAK8LAAD3Cw0AKAwVAAsPAQA9EAEAWhAAALUSAADhEgAANRUBAKQWAgDjFgMAAxcAAEwXAAC5GAAA2RgAAM4ZAgDmGQAA6hkAABIaAAAxGgAASBoAAG0aAACxGgAAZRsAAHIbAADMGwEA8xsAABYcAAAtHAAARhwAAJMcAADbHAAAoB0AAA==") \ No newline at end of file diff --git a/api/search.index/65e2704fd4.js b/api/search.index/65e2704fd4.js new file mode 100644 index 00000000..c4f54f3e --- /dev/null +++ b/api/search.index/65e2704fd4.js @@ -0,0 +1 @@ +rn_("McAAAO4Y1xnCGgEDhccAABw8wwAAGqfl/AkAAOAWAADAGAAAfRkAAI0ZAADWGgAAEQAA8wACcnP1EhcAAG0ETAAGAAsA") \ No newline at end of file diff --git a/api/search.index/65ff6a8b45.js b/api/search.index/65ff6a8b45.js new file mode 100644 index 00000000..29ec5a7f --- /dev/null +++ b/api/search.index/65ff6a8b45.js @@ -0,0 +1 @@ +rn_("wcMAALUYNQHBAAAWEBx3bRduF6gZocoAAHkc84ICY2UTggLCAAAaxGlzocUAAPgWYcIAAAIYAxjgGOEY4hhIGyHIAABmG1kdAQMByQAAHaJm4SsXAABVHAAAgbHHAACMGvv9gEFAocYAAG8XBQPAAAAUChkhMRZjGyabGskcY2V0FwEA/RCADsUAABo4zAAAHM7DAAAawOlMDAAASRAAADUSAADyEgAAQRUAAAIWAAAZFgAAbRYAAFwXAAC3HAAAIBMAAAAVAcMAABbKG3buFiOBAsoAABzbcHOlAsUAABaDGxaEG213QxcFwMAIJAAAFI0dFpAcJAkZpBkBSzswAAABAAB1AAEA3Bx1ABuDyAAAHHBRwAAA5hPnExkUGhSuG6UBxQAAFtgcd7oa+wJhcyHCAAASF9wbBQPAAAAVFBwR3xIXexxhZnRxwAAAlxKYEqsYrBitGK4Yrxj7AmRuBcHAACwAACO4GVMbGqocEwMZo3kcocUAAPwWFQHHAAAQmR1zUho7A8QAABu8YWhvBQLAAACwNhcRwBJscgEA8AAAAAAAAAEAAAAAACr6fQIWSbiFUnMESr5TDYxa/ZsVv0/XRUSVQAXzNqtw4e3AAAAQM0Ln4FcPGpH8FPA+AuS+7FrHh4CaNyxgwEVzKXeMlEvrXiCDU/uzEVJaG6sB73usRjbqOjAAAAEAAAAAAHMAEAAAACEIQwgQCqMKpAqlCqYKogujC6QLrwv3C/gL+Qv6C/sL/Av9C/4L/wsADAEMAgwDDAQMKAwpDCoMKwwsDC0MLgwvDDAMMQwyDDMMNAw1DDYMNww4DDkMOgw7DDwMPQwLDwwPPRA+EFoQsxK0ErUS4RLqE+sTNhQ1FTYVPBakFqUWphbjFuQW5RbmFgMXTBdTFwwYqRi5GNMY1BjVGNkY+Rj6GK8ZzhnPGdAZ5hnqGQkaEhoxGkgaYRptGoYasRrEGsYaZRtoG2kbchu5G8wbzRvzGxYcKxwtHDIcRhx8HH4ckxzbHF4doB0=") \ No newline at end of file diff --git a/api/search.index/6626699025.js b/api/search.index/6626699025.js new file mode 100644 index 00000000..f1cfaf0f --- /dev/null +++ b/api/search.index/6626699025.js @@ -0,0 +1 @@ +rn_("ocUAAIQWFQHGAAATiRxsSRsjAgLCAAAZemxzAegTAAArhsAAAA6nKwLgAAAcr3N0ocUAAJgZBQLAAAARtRYWgBpycwUCwgAAE1caE1gabHDHAYH+AAqADMIAABcnyQAAHCgFAsEAABDVHBEjFmRp+wJzevMAhIIfCAAA+wJsdDMAA9EAAB1kYWVyASQMAAA=") \ No newline at end of file diff --git a/api/search.index/681ec03594.js b/api/search.index/681ec03594.js new file mode 100644 index 00000000..51f61e14 --- /dev/null +++ b/api/search.index/681ec03594.js @@ -0,0 +1 @@ +rn_("BQLAAAAbfx0SUBpydBuJygAAHXMrAskAAB1oZXQxxwAAeRvaG9AcNQHFAAAW1xx3tRqNG6QcBQLCAAAYZx0afh1ydfuJFYLDAAAUPxyGHBOBg8cAABy01wECwBJixwAAHErhfhUAAEwaAABAIcMAAGkcaxwFAsEAADFdF18XvRk0PBYJGl4dZXMhyAAA9xiRG7HEAABtFfcAAP4AAwANwwAAHAbmGAgAAN0OAAA6EAAAIRgAABQcAABHHAAAVB0AAGUAAAD7hg==") \ No newline at end of file diff --git a/api/search.index/68788d27f6.js b/api/search.index/68788d27f6.js new file mode 100644 index 00000000..bd05e7da --- /dev/null +++ b/api/search.index/68788d27f6.js @@ -0,0 +1 @@ +rn_("AULkhhYAAN8ZAAA5GgAAXBoAAGAaAABBIQAFAsAAAB9NHB+HHGVsBQLAAAAfhRwT/xNheRcDAMAj0NAAAByGygAAGnDKAAAaceYGBAAAyQcAANkOAAAeEAAA1RYAAHUbAAB3GwAAEQAQAA==") \ No newline at end of file diff --git a/api/search.index/68e5790e2e.js b/api/search.index/68e5790e2e.js new file mode 100644 index 00000000..9b8cc8eb --- /dev/null +++ b/api/search.index/68e5790e2e.js @@ -0,0 +1 @@ +rn_("AUU7MAAAAQAAgAACAEEWAACCF38AFYHGAAAYox1yHfMDggE9FgAABQLBAAAgkRKLFRTAGG55FYTEAAATlxrNFp8R/WSkDcIAABYtygAAHGzFAAAaS8IAABgFxgAAGm4=") \ No newline at end of file diff --git a/api/search.index/697e306c99.js b/api/search.index/697e306c99.js new file mode 100644 index 00000000..6f7ea728 --- /dev/null +++ b/api/search.index/697e306c99.js @@ -0,0 +1 @@ +rn_("BQPAAABCGxx/HIAcZh2j6xkltRK5GGlscwUCwAAAFCoWEzsVbnIFAsAAABGOHCEZGNMac3S7/RFIAcEAABanygAAHAA=") \ No newline at end of file diff --git a/api/search.index/6980601db7.js b/api/search.index/6980601db7.js new file mode 100644 index 00000000..2741e303 --- /dev/null +++ b/api/search.index/6980601db7.js @@ -0,0 +1 @@ +rn_("ocQAAM8WQcIAAJoKmwpNEk4SccAAAAcIGBe0GKcabxyXHKkcAUDn/hUAAHsaAACaGwAAQBwAAGQcAACCHAAAcB0AAJUdAAAwMAAAIwACwgAAErppc/L1CQAA0QghxwAA7xkVHAUCwAAAG38dElAacnQbicoAAB1zKwLJAAAdaGV0MccAAHkb2hvQHDUBxQAAFtccd7UajRukHAUCwgAAGGcdGn4dcnX7iRWCwwAAFD8chhwTgYPHAAActNcBA8ASYscAABxK4X4VAABMGgAAQBWCwQAAFAMZPRD7Am92o4H9EAEFwAAAHE3HAAAcsNvEBiHgAAAXHMcAABxwAUg7MAAAAQAAdQABANwcdQDzAgFkhiQUAADzAJeDAAQAAA==") \ No newline at end of file diff --git a/api/search.index/69bd0769c7.js b/api/search.index/69bd0769c7.js new file mode 100644 index 00000000..6460df9c --- /dev/null +++ b/api/search.index/69bd0769c7.js @@ -0,0 +1 @@ +rn_("BQLEAAARyhoSaBdtcwUCwAAAHUQcsx8UaG+hwwAAEBZzAP0NAAHMAAAdd/MyFgAAYgSxAEHCAAAIFAkUChS2GgUCwwAAVIMZhBmFGYYZbB1VARoCGgMaBBp6HWltBQLBAAAVxRgVSBdyc1HNAADiG+Mb5BvlG5wdUdAAALkcuhy7HLwcsR1vA/0NkALFAAAW0sgAAB2RBQLGAAAUkh0Vmh1sclUCwQAAEWwWFsYbZXNuFm8WoRqiGsYc+wJjbvv9EQFA") \ No newline at end of file diff --git a/api/search.index/6a625688e6.js b/api/search.index/6a625688e6.js new file mode 100644 index 00000000..fd281bec --- /dev/null +++ b/api/search.index/6a625688e6.js @@ -0,0 +1 @@ +rn_("ocUAAHQXJYHCAAAReRnbEjMcBQLCAAAS9xkQKBxpeSsC4AAAG5lkbvMCgvLFEgAAuwRFg8AAABJQFxZ1GwQIBQitEq4SBQLBAAAigBtaHBBsGml5BQLCAAAVxRsUdBtjcyUCwAAAotMOFEMQaXkDD0Ua0cMAAM4ONQHDAAAQ+BhzNRo2GngbAQABwwAAFY1j5g8IAAA+CQAAABQAAJMZAAC0GQAApxsAAIUcAAAQAQAA5wEAwAZMwwAAEjf5SBAAAOUFGwBOAHYAAQD6AeICEgAJidAAAB1mxgAAGPsFAsAAAB9/HBwaHGF0BQLAAAAXCBwg9AlKF2xyCQLWAAAds8YAABnuZHQrAsUAABeAZnV/Af1xaADOAAAcgMwAABwbygAAHBGlAsMAABTDGhbWG3N3ARcbAscAABwHYWxRxQAABRoGGgcaCBp7HQUCwAAAE34XE38XbHATAgLLAAAddHN20qkbAACT+wJicvfzAP1dSR7GAAAb1cQAAB1x4jQJAAAkEAAAQhcAAAEA") \ No newline at end of file diff --git a/api/search.index/6a6638865b.js b/api/search.index/6a6638865b.js new file mode 100644 index 00000000..c5f34c84 --- /dev/null +++ b/api/search.index/6a6638865b.js @@ -0,0 +1 @@ +rn_("BQLBAAAdwRwm6xgaGmZ0BYnAAAA02xncGYccG48dJQHCAAAnFRq1HHSZCksaAYMCzwAAHZvIAAAcwGFzGwLDAAAcaG50/wD9kUBAyAAAG1o=") \ No newline at end of file diff --git a/api/search.index/6a9048cd0f.js b/api/search.index/6a9048cd0f.js new file mode 100644 index 00000000..1011a5fc --- /dev/null +++ b/api/search.index/6a9048cd0f.js @@ -0,0 +1 @@ +rn_("scIAAJAKZYHFAAASDRxYF94ZXhq8GtYbChwFwMICoAAAFjQcFisaFmYaFQLEAAAVihuj9BhjbK0a1wIA/QUBDcwAABysyQAAFvU7MAAAAQAAJAACAM0CHwAeDAQA") \ No newline at end of file diff --git a/api/search.index/6a9a00f3be.js b/api/search.index/6a9a00f3be.js new file mode 100644 index 00000000..da471b0f --- /dev/null +++ b/api/search.index/6a9a00f3be.js @@ -0,0 +1 @@ +rn_("AQIBxgAAGPJm4tMLAAAmEAAA2hYAAARAAUXjkhsAAKIbAACTHQAAlx0AADEA+4YVhMcAABSXHI8aIwMCwwAAGLRpcgETDwAAxf3CUgECAAAVwhkVbxoWjxsTWRcX3RvaE/MAA2lqcvP1BQAAoAhNBAUCxAAAFj4cF20aY2QFicEAABKdHRSlHSONAsYAAB2scHOlAcQAAKZ2HHfUGROBAsgAAByhbnIFAsQAABqhHRiXHWRzBQLEAAAdtB0XjB1jZRuWyQAAHXvIAAAdbQUCwwAAFGwdFXodaW1DgcAGQM8AAB2xzAAAHZwFAsAAABT6Gxp5HWVsBQLAAAAWoBwblR1idiuFyAAAHWsVAcAAABeLHWK2G/MCAXMBZBsAACsCxQAAHJ9tdAUCwQAAFJQdGKsdaWwjhwLHAAAdpGRlAYQC0wAAHbXHAAAdk29zV/aC/kBbqgnGAAAcZMcAAByexAAAG/nMAAAdlgUCwAAAH4odFDMabHJXBQD9TFILxQAAGpPKAAAczMwAAB1uxAAAGjTMAAAdWuaMEgAA5hIAAEgXAABgFwAA3RgAAOMYAADJGQAAMAARECOBAsEAABYraXmhxQAA+BYBAwHJAAAdombhKxcAAFUcAACBsccAAIwa+wJoaQUCwAAAFI0dFKQZZnIxwQAAGRQaFK4b8wL9AFAMOzAAAAEAAB8ACgAoDBUAtRIAAKUWAQBMFwAAuRgAAOYZAADzGwAALRwAANscAACgHQAA84GMAQUCyAAAHNrFAAAbiWp35T4WAABzFgAABBcAAJwaAADCHAAAxxwAAABTICWExAAAFxQcaBu5GwUCwAAAEu0ZMk8X7xkVHGl2BQLBAACn4RlH4xnkGYAdgR1ycyHEAAC1GYMcvwP9FCAHxwAAHY+/Af0QQU7DAAAcSMsAABpZwwAAGtc=") \ No newline at end of file diff --git a/api/search.index/6b139f716a.js b/api/search.index/6b139f716a.js new file mode 100644 index 00000000..a0677968 --- /dev/null +++ b/api/search.index/6b139f716a.js @@ -0,0 +1 @@ +rn_("cccAANYY1xjoGekZ/Bt2HXgdsccAANMYNQHDAAAWZR1zphyzHJsd8wQBZNK3GwAA1jcCA/0cMALKAAAcGckAABwXyQAAHBiC6gsAAKUBxgAAHHIddHkV+wJhcA==") \ No newline at end of file diff --git a/api/search.index/6b14f4a6b5.js b/api/search.index/6b14f4a6b5.js new file mode 100644 index 00000000..33edb9ac --- /dev/null +++ b/api/search.index/6b14f4a6b5.js @@ -0,0 +1 @@ +rn_("BQLAAABjyRbKFssWzBY/F0AXwgYPaXkVAcYAABOJHGxJGwWJwAAAMnEXchc9GhHqG/MAAXSC5wkAALHDAAD+FjHGAADbGdwZhxzTAP0UEAbFAAAZ3cQAABqk9zgUAAABAI0EpQHBAQMAkQAlAcIAABFRGm22CmYaxwMA/SBIJ8UAABa0wgAAGXrJAAAb8vyIAwAAAQCEBqABWQDiB2MBewFYALMBwQA8AQ==") \ No newline at end of file diff --git a/api/search.index/6b7ecb64af.js b/api/search.index/6b7ecb64af.js new file mode 100644 index 00000000..49edeb3e --- /dev/null +++ b/api/search.index/6b7ecb64af.js @@ -0,0 +1 @@ +rn_("ocMAAEIWBQLAAABF8BnxGfIZZR0cNhxleQWMwAAAF0UbNOoW6xYCHBUBxAAAFnwbchEXZQHCAAAQhR1zIxwkHCUcJhxbHaEdBYnAAAAiRxYcHBlTHQUDwAAAExIYFnMcExMYYW1yFYjGAAAfrx3hGzcCAP0RQQ3GAAAdgsMAABgUxgAAGcTDAAAcHvvKDgAAxwdmApcCAwBIAEQAMQAYABcBIQAFAsAAABGDFhCVGW93K4rDAAAW6RUCxgAAJHsbUh0WCxxzd1oXFQHHAAAWYh1ySxwTAgLFAAAat2FyAYIWAABLwASE5gAAE9/BAAAVPaHDAABFFiHDAABEFhEcMcgAAIocixyrHTUBxAAAEnobY5cbmBuUHfMAAmls4fwYAABkGgAAIPvBBCBByAAAwxlPHFAcpB2hxAAADxcFAsAAABMOFxNpGmxzAQP+wE/EHxX6MZTSaGAVGA/HAAAcU0y/C7L0XUyOKE3EAAAbpz23FOGEFxZ0qInDAAAbXis+d10HbBDy8fXCAAAVPl2CeJ59WXLWWDNTLPI7uu3xCwAAOBYAACkXAABLFwAAexcAAIEXAAANGAAAvxgAAMcYAAALGQAALRoAAGoaAAB2GwAAgRwAAFAAEAAAAADBzQAAhRsBQDswAAABAACBAAMAFgoAAFYMfwDcGAAA+wJkeAUCwAAAEwQZGwEcbnQbAsQAABw9YWxVBwD9PAgGAMwAABwCAswAABwDBRwd9RkKAACNEgcABgDoAA==") \ No newline at end of file diff --git a/api/search.index/6d20265cf5.js b/api/search.index/6d20265cf5.js new file mode 100644 index 00000000..d8762a9e --- /dev/null +++ b/api/search.index/6d20265cf5.js @@ -0,0 +1 @@ +rn_("YcYAAE0aCxx2HHcc2BzZHCHEAAB7G1Id8wMCc3fothIAAFYVAAB4FQAAWhcAANQZAAC6GgAAvxoAAIMbAADsGwAAAQARAAA=") \ No newline at end of file diff --git a/api/search.index/6d3c4ffeb1.js b/api/search.index/6d3c4ffeb1.js new file mode 100644 index 00000000..5ca05322 --- /dev/null +++ b/api/search.index/6d3c4ffeb1.js @@ -0,0 +1 @@ +rn_("ocYAALMSocoAAMEbIc8AAF8drh0FAsUAAKO/G6U3HHBzPwL9GDAFygAAHDnJAAAcBssAABxuBQLBAAAccB0XphphY/OCAmVzocMAAKULBQPAAAAUxBIdxBobZRphaXlhxQAAlA5wEHEQchBFF0YXAQCBwQAAHA3tWBcAAN4ZAABNGgAAVhoAAF4aAAC8GgAAhBsAAIkbAADWGwAAChwAABAcAABDHAAAdhwAANccAAAAAAAAAQASAQACwAAAGjLFAAAcD2Fy5xMKAACvCgAAURIAAGYXAABXHAAAXxwAAIgcAAClHQAAIAEEAPsCZHL7/gAkGgg=") \ No newline at end of file diff --git a/api/search.index/6dcd62114f.js b/api/search.index/6dcd62114f.js new file mode 100644 index 00000000..d1ed0371 --- /dev/null +++ b/api/search.index/6dcd62114f.js @@ -0,0 +1 @@ +rn_("BQLAAAAVghsTwg5ldCHGAAAtGnYbBQLBAAAbWBwaKxxhbWWEwAAAEr0LnxKgEqES1BY+GiAcGwLDAAAcv3JzscQAAKgaYcIAANQLMBAxEKAW3xiXGfcFAP0VwRLGAAAYuc8AAB2w9zYBAACZCIEGAQCfBnsAggE=") \ No newline at end of file diff --git a/api/search.index/6e785e26b7.js b/api/search.index/6e785e26b7.js new file mode 100644 index 00000000..3a83ba82 --- /dev/null +++ b/api/search.index/6e785e26b7.js @@ -0,0 +1 @@ +rn_("YcYAAE0aCxx2HHcc2BzZHCHEAAB7G1Id8wICc3fothIAAFYVAAB4FQAAWhcAANQZAAC6GgAAvxoAAIMbAADsGwAAAQARAAA=") \ No newline at end of file diff --git a/api/search.index/6edf3da97f.js b/api/search.index/6edf3da97f.js new file mode 100644 index 00000000..e13da845 --- /dev/null +++ b/api/search.index/6edf3da97f.js @@ -0,0 +1 @@ +rn_("BQLAAAAVoRYW8xtsbwcEAf0VsQPEAAAYIcUAABpwxQAAGkrHAAAZi8kAABzKyAAAF13KAAAZxcMAABqSAQUHAAA=") \ No newline at end of file diff --git a/api/search.index/6f07519909.js b/api/search.index/6f07519909.js new file mode 100644 index 00000000..c54cc147 --- /dev/null +++ b/api/search.index/6f07519909.js @@ -0,0 +1 @@ +rn_("BcDDgGAAABLLGxXAGSXBGcwaBQLAAAASThoRvhlpdAUCwAAAEAEcI78ZtBpydlODA8gAAByuZWZ0GwLGAAAaT250McUAAAwcJxxdHfMAA2Jpc+MoCgAARhoAAFwbAABoHAAAEBBBwwAAxxLIEsocWB1b/UUAAcQAABgpwAAAEFE=") \ No newline at end of file diff --git a/api/search.index/6f7c7d7d4d.js b/api/search.index/6f7c7d7d4d.js new file mode 100644 index 00000000..790854a4 --- /dev/null +++ b/api/search.index/6f7c7d7d4d.js @@ -0,0 +1 @@ +rn_("dQLAAAAX0BsWfB1ydHUVdhV3FXIacxp0GnUaocEAAKIZ8wICaXPjNRAAAFAVAAB8GgAAfhsAAAAwKwLkAAAUFGR0") \ No newline at end of file diff --git a/api/search.index/700fd0c9f2.js b/api/search.index/700fd0c9f2.js new file mode 100644 index 00000000..08dbf546 --- /dev/null +++ b/api/search.index/700fd0c9f2.js @@ -0,0 +1 @@ +rn_("BQLAAAAZZhol3hnWG2lyBQPAAAASFhczcxfDGuYbFHwVaXR1IcMAADcajBwFAsAAABKXGh1JHG10BYPAAAASGRgUOxVFAsIAABXaGBqyHHB0OBA5EJUalhohwAAAxA64G/sCcnSjAP0BiQDJAAAay8UAABY/4hIWAABHGgAAxRsAABAABQLBAABztxK4ErkSIhxZHF4c1ByisBtudWcbAP5AR1ARwwAAGqPFAAAcMcMAABwA6SwHAABbEAAA8BIAADoUAADNFgAAARcAALobAAAHHAAAZxwAAHwdAAAgAQEAAAFB+A4WAAABAMMAUAB0AroBFwIXACOBAsMAABpsaWz7gw==") \ No newline at end of file diff --git a/api/search.index/710ca2fe88.js b/api/search.index/710ca2fe88.js new file mode 100644 index 00000000..52e9a724 --- /dev/null +++ b/api/search.index/710ca2fe88.js @@ -0,0 +1 @@ +rn_("FQHCAAAWpBpy0RgbAsYAABmvaXYhxgAAgRtiHRUBxwAAH6IddEwXBQLBAAAnrhl/HRlpG252scQAADwUpYHAAAARohIRChsCxgAAGa1kdiHHAABLF2oaBQLBAAAZaBsephxkdPcXAP5ARqQLxQAAHXfKAAAcRucbBwAA5gcAAMIOAABmFgAA7RsAAPQbAABLHAAAnR0AABQBBAA=") \ No newline at end of file diff --git a/api/search.index/714f6b1f62.js b/api/search.index/714f6b1f62.js new file mode 100644 index 00000000..cf947103 --- /dev/null +++ b/api/search.index/714f6b1f62.js @@ -0,0 +1 @@ +rn_("ocUAAJgZBQLAAAARtRYWgBpycwUCwAAAE1caE1gabHAbAsQAABn4Zm4jAAPDAAAYFcgAABzVYmVu4VYQAAA8FQAAEIcDAf4ACpAMwgAAFyfJAAAcKMMAABjcOzAAAAEAACoCBwC/DgEAmg9sALwSAAD5Et8AfRcAAAwZbADYGmwAoccAANMbocUAADQXYwDAgCjBAAAb0fufCgAAqApnAUEC6gBqAAEAuwG+AAgACABLA8EAABUzwQAAHYllaXkFAsEAABDVHBEjFmRpzwHQBMTMAAAcrcIAABxv") \ No newline at end of file diff --git a/api/search.index/718ebe1388.js b/api/search.index/718ebe1388.js new file mode 100644 index 00000000..742fdac2 --- /dev/null +++ b/api/search.index/718ebe1388.js @@ -0,0 +1 @@ +rn_("UcAAAFQS8xhPGnYadxrzAgFz4fcOAAA/FAAAcSUCwwAAFs4bMHwZkBrLGnJzNBBcHAUCwAAAGpAdGLIcbG3zAANvcnk7MAAAAQAApgABAB4CpgA=") \ No newline at end of file diff --git a/api/search.index/71fd372da1.js b/api/search.index/71fd372da1.js new file mode 100644 index 00000000..c13f172c --- /dev/null +++ b/api/search.index/71fd372da1.js @@ -0,0 +1 @@ +rn_("0cEAAB4MAQEBwgAAFWFu5KIKAADVEQAA5BIAAFYXAABpFwAAAgEQ+wJjZQFC45ISAABJFwAALBoAAGgdAABAAAUCwQAAFlAbGE0cYWMhwwAAmBq4HLHDAAAyFKHCAABYECsC7gAAEA9kbgUCwAAAFWgcHI8dYWj3BQD9BGEb4AAAG2vFAAAaVuk7CAAAyRIAABsWAABPFwAA7xkAAG8bAAAEHAAAFRwAAEkcAAC0HAAAIBAAAAAVA8AAAKmZGhcSGhNLFWFkdB0MWwPDAAAXdmRlchUBwQAAMdQaRBx7HG1nGvuH") \ No newline at end of file diff --git a/api/search.index/720775bd56.js b/api/search.index/720775bd56.js new file mode 100644 index 00000000..c344c3dd --- /dev/null +++ b/api/search.index/720775bd56.js @@ -0,0 +1 @@ +rn_("ocsAAJUaNQHAAAAaaBxyDBwnHF0doccAAHsX+wJkZUHKAAD2FvcWmhmbGaHDAABCFkHGAADwGfEZ8hllHQWMwAAAF0UbNOoW6xYCHBUBxAAAFnwbchEXZQHCAAAQhR1zIxwkHCUcJhxbHaEdBYnAAAAiRxYcHBlTHQUDwAAAExIYFnMcExMYYW1yFYjGAAAfrx3hGzcCAP0RQQ3GAAAdgsMAABgUxgAAGcTDAAAcHvvKDgAAxwdmApcCAwBIAEQAMQAYABcBIQAFAsAAABGDFhCVGW93K4rDAAAW6RUCxgAAJHsbUh0WCxxzd1oXFQHHAAAWYh1ySxwTAgLFAAAat2FyAYIWAABLwASE5gAAE9/BAAAVPaHDAABFFiHDAABEFhEcMcgAAIocixyrHTUBxAAAEnobY5cbmBuUHfMAAmls4fwYAABkGgAAIPvBBCBByAAAwxlPHFAcpB2hxAAADxcFAsAAABMOFxNpGmxzAQP+wE/EHxX6MZTSaGAVGA/HAAAcU0y/C7L0XUyOKE3EAAAbpz23FOGEFxZ0qInDAAAbXis+d10HbBDy8fXCAAAVPl2CeJ59WXLWWDNTLPI7uu3xCwAAOBYAACkXAABLFwAAexcAAIEXAAANGAAAvxgAAMcYAAALGQAALRoAAGoaAAB2GwAAgRwAAFAAEAAAAADBzQAAhRsBQDswAAABAACBAAMAFgoAAFYMfwDcGAAA+wJkeAUCwAAAFAQZFT0cYWzxAP0UCAYAAQQbHPUZCgAAjRIHAAYA6AABQTswAAABAADcAAIAlg4AAHUQ2wArlMcAABw1pQHBAAARORV0ChoViMEAAB+vHeEb5Q8A/kBELAvFAAAWdgABAgQiIybJAAAcpTswAAABAADmAAsAOQEAAPIGAAAICAAAQAgBAEsI2wAnDAAAug4AAEAQAACVGQAAOBoAAA4cAAA=") \ No newline at end of file diff --git a/api/search.index/72448fc8e4.js b/api/search.index/72448fc8e4.js new file mode 100644 index 00000000..5d865680 --- /dev/null +++ b/api/search.index/72448fc8e4.js @@ -0,0 +1 @@ +rn_("BQLDAAAXgRwYWxxkbRUBwgAAdKYdpx2oHakdqh2zHbUddpQLIcIAAAkZpBlzAP0hQALgAAAdhTswAAABAAA4ABwAbAMEAJAKAgDsCwAAoA4BAJISBAClEgQA7RIAAJcWAwCiFgAAtRYAABkXAAAnFwAASRcAACkYAACHGQMA9RkBACwaAACSGwMAohsBACMcAwCfHAAA0xwAAFsdAABoHQAAbR0AAJMdAACXHQAAoR0AAA==") \ No newline at end of file diff --git a/api/search.index/74bafbb1ea.js b/api/search.index/74bafbb1ea.js new file mode 100644 index 00000000..f81e2ccb --- /dev/null +++ b/api/search.index/74bafbb1ea.js @@ -0,0 +1 @@ +rn_("BQLAAAAVDBgX0xxldAWKwAAAE30WEocVIcIAAIYVqRkFAsAAAMJ5FhfUGmR0W8AAxMsAAByEUcYAAAoYCxirG2UctB2nCgD9mDMCxwAAHX3CAAAVhcgAABrVxQAAHa/jMAoAACMXAACgGgAAvhwAACIA") \ No newline at end of file diff --git a/api/search.index/74f2d28a80.js b/api/search.index/74f2d28a80.js new file mode 100644 index 00000000..b00f2435 --- /dev/null +++ b/api/search.index/74f2d28a80.js @@ -0,0 +1 @@ +rn_("BQLAAAAedRwVbBZocqWBxQAAEaYZXxUFAsUAABhmHBVfGmRyCQLFAAAdoNAAAB1xb3JRxQAAARoCGgMaBBp6HVHEAACDGYQZhRmGGWwdBQLAAAAWYx0auRt0dZc9AP7AQnQDzQAAHHTGAAAYv8QAABbI89cWAAD2A8sBGwLCAAAbRmVt") \ No newline at end of file diff --git a/api/search.index/7583b7693e.js b/api/search.index/7583b7693e.js new file mode 100644 index 00000000..bc2d743e --- /dev/null +++ b/api/search.index/7583b7693e.js @@ -0,0 +1 @@ +rn_("VQHDAAAQrBtzsg6zDl0SXhJfEiUBxAAAoLEWc84SuRpVAsQAAKEqGKCvFmlzyhLLEswSzRK4GrHDAADgGKUBxgAAFtgcd7oa84QCbXQ1AcMAABZWGnfJEhsWHBb7AnJz+wNlb3QhwwAAWxyiHBUBwwAAFgocd1UXIcMAAAUZlhslAcMAABN4G27pG/sbswDACMjEAAAXbIL2EgAABYPAAADEoxgU2xgFA8AAAEPHEsgSyhxYHRUpGBFREGFjcjHAAABWEFcQPBUjAgLDAAAY3G9yOzAAAAEAACoCBwC/DgEAmg9sALwSAAD5Et8AfRcAAAwZbADYGmwABQLAAAAQtw4TVBdxcgUCwAAAEQ0UGIEabW/vDf0WwQPFAAAbR8IAABYkocMAALAOBQLAAACgGwxyrQ6uDq8OYRcfGPMZshxkdMX9x1IBAgAAFcIZFW8aFo8bE1kXF90b2hPnNQD9RK0HxQAAHDnAAAAbysgAABdg48wHAAAdFgAAfRYAAB0XAABgAA==") \ No newline at end of file diff --git a/api/search.index/762617c93e.js b/api/search.index/762617c93e.js new file mode 100644 index 00000000..10d23ff7 --- /dev/null +++ b/api/search.index/762617c93e.js @@ -0,0 +1 @@ +rn_("AULiWBUAAMUZAACyGgAAUAAbhsEAABuqIcIAAEcWHBxbA8gAAB1TYW9yBQPAAAATEhgWcxwTExhhbXIViMUAAB+vHeEbKwLAAAAPGGZsNwIA/RFBDcYAAB2CwwAAGBTGAAAZxMMAABwe+8oOAADHB2YClwIDAEgARAAxABgAFwEhAA==") \ No newline at end of file diff --git a/api/search.index/764ceab814.js b/api/search.index/764ceab814.js new file mode 100644 index 00000000..6293e28a --- /dev/null +++ b/api/search.index/764ceab814.js @@ -0,0 +1 @@ +rn_("IcAAAEEUVxohxAAA1BYgHCMBAsIAABYzbW/jpwoAAE8SAABRFwAA8BsAAGEQocQAAJcO+wJlbPMAAmZzAQcKAAA=") \ No newline at end of file diff --git a/api/search.index/767beaf046.js b/api/search.index/767beaf046.js new file mode 100644 index 00000000..01468b3b --- /dev/null +++ b/api/search.index/767beaf046.js @@ -0,0 +1 @@ +rn_("BQLAAAAVoRYW8xtsbwcEAP0VsQPEAAAYIcUAABpwxQAAGkrHAAAZi8kAABzKyAAAF13KAAAZxcMAABqS8wUHAABLDwoA") \ No newline at end of file diff --git a/api/search.index/76bb74d417.js b/api/search.index/76bb74d417.js new file mode 100644 index 00000000..94d950a9 --- /dev/null +++ b/api/search.index/76bb74d417.js @@ -0,0 +1 @@ +rn_("FQHCAAAd2xxyMRplAsEAABF9FRQEGGFjnQ6eDiEXcRs6HFMc+4ZDAMAAwsQAABiiyAAAGXvk/gcAAMQJAADOFgAAahwAAKgcAAADAAA=") \ No newline at end of file diff --git a/api/search.index/7720de2b22.js b/api/search.index/7720de2b22.js new file mode 100644 index 00000000..a06f2e59 --- /dev/null +++ b/api/search.index/7720de2b22.js @@ -0,0 +1 @@ +rn_("BQLBAABBaxbpFl8azxsZYx1sbSHEAACvGg8cBQLBAAAR0xal3RhycwUCwAAAE0caYqIOow7YEUcXqBjKGGNznwP+QABmA8gAABqyzwAAHVzBAAAWMg==") \ No newline at end of file diff --git a/api/search.index/7779d97dd6.js b/api/search.index/7779d97dd6.js new file mode 100644 index 00000000..c8f3eada --- /dev/null +++ b/api/search.index/7779d97dd6.js @@ -0,0 +1 @@ +rn_("tQLBAACkixYUjRZicV0KBQLDAAAWZRwY0xxlcyHIAAD7Fm8dOwPCAAAPGWlyc/sCcnShwgAAoA4hxwAA7xkVHKWQxQAAovUZNxwxwwAA3hO/G8AbBQLAAAAbfx0SUBpydBuJygAAHXMrAskAAB1oZXQxxwAAeRvaG9AcNQHFAAAW1xx3tRqNG6QcBQLCAAAYZx0afh1ydfuJFYLDAAAUPxyGHBOBg8cAABy01wEDwBJixwAAHErhfhUAAEwaAABAJQPBAAAUAxkXkBwzAhgDGEgbZW90PRADF/sCb3YVAcEAABOOFnNNHAUDwAAAFkoXEdALGdcaYml19w4A/RGBD8cAABywAd0SAABRwAAArhqKG3QccR2QHQXAwGAQAAAW+hYX7hgY1xlLwASCygAAG/vJAAAZ9BsCwQAACjNleRWCwwAAGpgcGxQTggLEAAAb72FzccIAADcSbRVuFW8VsBuxGxkcFQLBAAC0qBoXuRhjcM8JBQLAAABSUxVUFVUV5RjmGELBGt4bbhxkHWVyAUDltxIAAN8WAAAiHAAAWRwAAF4cAADUHAAAIAAAE4ECxgAAHA5ncqvADIRDYfb+r8QAABxNFQHHAAAQtxxzsRorg8EAAB1cGwLEAAAXQW1wBQLAAAAUrBwRdB1hchuGwQAAHbDfB/4AAo4MxgAAFwMFAsEAAB3yGxM+HGltBQLBAAAQ9hMXRBxndEMA/TABA8AAABYXzgAAHIHKAAAabMQAABy0Aa4cAAAFAsAAABhsF7PTGGFzAQKCwwAAHHjkURUAAGMVAAB0FgAAcRoAAL0aAAAREBATggLBAAASxmduUc8AALkcuhy7HLwcsR2lAcQAADUMHCccXR1iXBsFA8IAAKSwGxYxHBQAHGNlcfsCY3TzAP2RTQ/kOwEAALIHAAA3FQAAmRoAAH0bAAABEQA=") \ No newline at end of file diff --git a/api/search.index/78c0fa646d.js b/api/search.index/78c0fa646d.js new file mode 100644 index 00000000..9a53f76e --- /dev/null +++ b/api/search.index/78c0fa646d.js @@ -0,0 +1 @@ +rn_("NQHBAAAWEBx3bRduF6gZocoAAHkc84UCY2UFAsAAAChmG1kdsuAYb3mhxgAAbxcFAsAAACExFmMbJpsayRxldBcBAP0QgA7FAAAaOMwAABzOwwAAGsDjTAwAADUSAADyEgAAAhYAACEwpQLFAAAWgxsWhBttd0MXpQHGAAAW2Bx3uhoFAsAAABUUHBHfEmFmccAAAJcSmBKrGKwYrRiuGK8Y+wJkbgUCwAAAI7gZUxsaqhxucKHFAAD8FhUBxwAAEJkdc1IaOwPEAAAbvGFob/ceAP4ARSwLyQAAHHA6MAAAAQAAAAAAIwAQAAAAEAqjCqQKpQqmCqILowukC7MStBLqE+sTNhQ8FlMXDBipGNMY1BjVGPkY+hivGQkaYRqGGsQaxhpoG2kbuRsrHDIcfBx+HF4d") \ No newline at end of file diff --git a/api/search.index/79390932a2.js b/api/search.index/79390932a2.js new file mode 100644 index 00000000..d4e04a2f --- /dev/null +++ b/api/search.index/79390932a2.js @@ -0,0 +1 @@ +rn_("McMAAAwcJxxdHRUBwQAAqd0YckcJNQHEAAASfxtrXhePHKMcI4ICxgAAG6ZzdLHBAABWEgGCAtEAAB2ZxgAAG6Vlc1cBAP3AIQLHAAAaVMIAABYY9v4JAACDAAEAxwf2A34FocUAAJMZEwICxQAAHGdmdOScFgAABRwAAIocAACgHAAAqx0AADAQAPOBAml524/DAAALrg==") \ No newline at end of file diff --git a/api/search.index/798fc78a5d.js b/api/search.index/798fc78a5d.js new file mode 100644 index 00000000..ecd0c491 --- /dev/null +++ b/api/search.index/798fc78a5d.js @@ -0,0 +1 @@ +rn_("JYTFAAAUqRyrGpodJYTEAAAUbxxJGpId84ECbHJRwwAAIhgjGKwZgx2HHfOCAnJ0BYbCAAAgTRcTHFIPGhAaERrBG8IbMcUAAOESNRU2Fbv9NQEEwQAAC+3CAAAQLcoAABpq") \ No newline at end of file diff --git a/api/search.index/7ac9e5c047.js b/api/search.index/7ac9e5c047.js new file mode 100644 index 00000000..c2cec849 --- /dev/null +++ b/api/search.index/7ac9e5c047.js @@ -0,0 +1 @@ +rn_("UcAAAOwW7RaMGo0ajhoVkMAAABZIGssc8wQCZHIBVQwAACHCAACUGkUbJQHCAAAwXxtgG6gbczcQYhWhxAAAcRwlAsMAABZHGhZ1HHJzIxB9HPsDaHB1FQHAAAAV2BZopBobAskAABrFZHWfAf0hEAbDAAAaK8wAABwwa4vBAAAUIg==") \ No newline at end of file diff --git a/api/search.index/7b25a8663f.js b/api/search.index/7b25a8663f.js new file mode 100644 index 00000000..e0e9e258 --- /dev/null +++ b/api/search.index/7b25a8663f.js @@ -0,0 +1 @@ +rn_("BQLAAAASThoRvhlpdBUBwwAAFPAWZz4XAUXhCBYAAFQbAABDBQLAAAAYxhkTBxZucwUCwAAAEAEcI78ZtBpydtcDAP0woQTIAAAcruS5BwAAPBQAAHkWAADnGQAAiB0AACIwAA==") \ No newline at end of file diff --git a/api/search.index/7b6859e53a.js b/api/search.index/7b6859e53a.js new file mode 100644 index 00000000..f82e958f --- /dev/null +++ b/api/search.index/7b6859e53a.js @@ -0,0 +1 @@ +rn_("ockAADUaocUAAPgWAQMByQAAHaJm4SsXAABVHAAAgbHHAACMGvsCaGkFAsAAABSNHRSkGWZyAUw7MAAAAQAAdQABANwcdQAxwQAAGRQaFK4b8wH9AHAMOzAAAAEAAB8ACgAoDBUAtRIAAKUWAQBMFwAAuRgAAOYZAADzGwAALRwAANscAACgHQAAMcEAAIAbWhyJHa8C/RFggsMAABd9ygAAGeXBAAAPlQ==") \ No newline at end of file diff --git a/api/search.index/7d00f9d225.js b/api/search.index/7d00f9d225.js new file mode 100644 index 00000000..6b5219a6 --- /dev/null +++ b/api/search.index/7d00f9d225.js @@ -0,0 +1 @@ +rn_("McMAANESHxYgFhWBxgAAEg0cvBojBALEAAAaWmh38koVAADyBKHEAABIFdMC/TAADsUAABwzwQAAEu3jAQoAAI4KAADlGQAArx0AAEEAS43EAAAbcMYAABqU") \ No newline at end of file diff --git a/api/search.index/7e6c8f4e56.js b/api/search.index/7e6c8f4e56.js new file mode 100644 index 00000000..139195b9 --- /dev/null +++ b/api/search.index/7e6c8f4e56.js @@ -0,0 +1 @@ +rn_("AUI7MAAAAQAA2QEFAKkLAABFFOsAYxYAACwYdQDcHHUAscQAANEZ8wGCOjAAAAEAAAAAABIAEAAAAOMJ5AnlCUES3BJPFfsWghnBGQAaGxpQGrAawBrMGkocthxvHZEdAUD79gcAAG8N4gAsAVADsQAHAGsANgA2AQEAJYHGAAASDRxeGrwaocYAAGMVUwDCIgDEAAAaWuodBwAA6wcAALgOAACGEgAA1xIAAEoVAABKFgAAcRcAADwaAACSGgAAWxsAAAoAIAEQAN8B/RABSsEAABty") \ No newline at end of file diff --git a/api/search.index/7eb67ad625.js b/api/search.index/7eb67ad625.js new file mode 100644 index 00000000..ced1c967 --- /dev/null +++ b/api/search.index/7eb67ad625.js @@ -0,0 +1 @@ +rn_("BQLBAAAUXB0kwRpkHW1u8wUBY/LFFgAAAwUFAsAAABFjGxbJHGV0GwLDAAAawGV0E4QCyAAAHVlzdCsCxwAAG2JucxsDywAAG+rDAAAdkWVsdCHGAADeG24cFwYA/gBDIgnRAAAcsMMAABypyAAAHDDLAAAcMuQtCAAA0QkAAKsaAACQHAAAmh0AADEAAA==") \ No newline at end of file diff --git a/api/search.index/7f30aae14d.js b/api/search.index/7f30aae14d.js new file mode 100644 index 00000000..d9acde58 --- /dev/null +++ b/api/search.index/7f30aae14d.js @@ -0,0 +1 @@ +rn_("FQHDAACkthlirxKxygAAqBoFwMOAYAAAEssbFcAZJcEZzBoFAsAAABJOGhG+GWl0BQLAAAAQARwjvxm0GnJ2U4MDyAAAHK5lZnQbAsYAABpPbnQxxQAADBwnHF0d8wEDYmlz4ygKAABGGgAAXBsAAGgcAAAQEAUCwAAAH2MdGHgaZXAFAsAAAKZcGxjZG2NkMcMAADEVMhVuGgX9wQGABQAAHbYcGiEcHrccFXwZ+wJ1eVcPAP0aSQfPAAAdgsMAABUzxAAAFjcB/AcAAA==") \ No newline at end of file diff --git a/api/search.index/7fabdb9cee.js b/api/search.index/7fabdb9cee.js new file mode 100644 index 00000000..43d127b5 --- /dev/null +++ b/api/search.index/7fabdb9cee.js @@ -0,0 +1 @@ +rn_("BcDCgmAAABSOHRLLGxXAGSXBGcwaQcUAAC4aLxowGqscFwIA/RAGVMgAABzawQAAG3/GAAAZ7cUAABuJOzAAAAEAACkAFABQDAMAqA4BAEISAQA+FgAAcxYAAAQXBQBeFwAA1hgBAOgZAQCcGgMAxxoCAKUbAQD8GwAAjxwAAKMcAADCHAIAxxwAAFUdAgB2HQAAeB0AAA==") \ No newline at end of file diff --git a/api/search.index/7fc4c77eda.js b/api/search.index/7fc4c77eda.js new file mode 100644 index 00000000..696113fb --- /dev/null +++ b/api/search.index/7fc4c77eda.js @@ -0,0 +1 @@ +rn_("BQLAAAATFxgYuBxjZQUCwAAAFn8cpGkXbnIFAsAAABeMHCrJG0EcYWinBAD9HUECwQAAErLDAAAcA8EAABVAwgAAFxXjzA4AAJIWAABMHAAAnhwAABMA") \ No newline at end of file diff --git a/api/search.index/alias/62150c532c.js b/api/search.index/alias/62150c532c.js new file mode 100644 index 00000000..f829e463 --- /dev/null +++ b/api/search.index/alias/62150c532c.js @@ -0,0 +1 @@ +rd_("") \ No newline at end of file diff --git a/api/search.index/crateNames/a1da5d2e76.js b/api/search.index/crateNames/a1da5d2e76.js new file mode 100644 index 00000000..f968d086 --- /dev/null +++ b/api/search.index/crateNames/a1da5d2e76.js @@ -0,0 +1 @@ +rd_("hjellytauljellytau_lib") \ No newline at end of file diff --git a/api/search.index/desc/10ca61e09f.js b/api/search.index/desc/10ca61e09f.js new file mode 100644 index 00000000..11845d82 --- /dev/null +++ b/api/search.index/desc/10ca61e09f.js @@ -0,0 +1 @@ +rd_("oFile I/O failedAjContains the success valueCnStable Jellyfin item id \xe2\x80\x94 what gets stored when the user \xe2\x80\xa6AaUnique identifierCjDeserializes from API\xe2\x80\x99s \xe2\x80\x9cId\xe2\x80\x9d field (PascalCase), \xe2\x80\xa6CkAll presets, for enumerating the curve table across the \xe2\x80\xa6CmThe ladder, highest first, for enumerating across the IPC \xe2\x80\xa6AkAdd at the end of the queueAhContains the error valuelTimer is offAfAdd items to the queueBfMake a GET request to the Jellyfin APIAiCreate a new auth managerAmCreate a new session verifierBaCreate a new connectivity monitorCeCreate a new credential store, detecting the best \xe2\x80\xa6AmCreate a new download managerB`Create a new Jellyfin API clientCcCreate a new HTTP client with default configurationBbCreate a new playback mode managerAnCreates a new PlaybackReporterAkCreates a new SyncProcessorCgCreates a new EventThrottler with 30 second default \xe2\x80\xa6CiCreate a new TauriEventEmitter with the given app handle.AhCreate a new MPV backendCnBuild a library, deriving everything that follows from its \xe2\x80\xa6BaCreate a new SessionPollerManagerAlCreate a new thumbnail cacheAmCreate a new thumbnail workerCnStream URL for the <audio> element to play.lSubtitle URLoNo media loadedBdNo active session - browsing libraryAhLouder output (-11 LUFS)BbAdd immediately after current itemClNo match on the name at all. The backend returned it for \xe2\x80\xa6AbNo active network.DfThe byte range a response should carry. end is inclusive.BmStop playback (no next item or timer expired)BcStop after a specific time durationA`User informationCmWiFi (may still be metered \xe2\x80\x94 check unmetered).BnAuthentication and session-lifecycle commands.BjEmit a player status event to the frontendAoReturns the argument unchanged.000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000BaCalls U::from(self).000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000AfCurrently loaded movieAhCurrently loaded episodeAiThe underlying media itemCkProvider-neutral classification \xe2\x80\x94 the replacement for \xe2\x80\xa6CgProvider-neutral stream classification \xe2\x80\x94 replaces \xe2\x80\xa6BmLoad a media item for playback TRACES: UR-005B`Poison-tolerant locking helpers.A`Sleep timer modeCcNew mode: \xe2\x80\x9clocal\xe2\x80\x9d, \xe2\x80\x9cremote\xe2\x80\x9d, or \xe2\x80\x9cidle\xe2\x80\x9d.ChDisplay name of the folder (or of the library, for a \xe2\x80\xa6CcName (alias for title - for frontend compatibility)ClDeserializes from API\xe2\x80\x99s \xe2\x80\x9cName\xe2\x80\x9d field (PascalCase), \xe2\x80\xa6AbSkip to next trackAeMove to the next itemBnOpen or create the database at a specific pathBeAbsolute path to the written archive.AjGet the database file pathCgQuick ping to check if a server is reachable (no retry)AdPlay/resume playbackBgStart or resume playback TRACES: UR-005BgMake a POST request to the Jellyfin APIClDeserializes from API\xe2\x80\x99s \xe2\x80\x9cRole\xe2\x80\x9d field (PascalCase), \xe2\x80\xa6DiSeek to a position in seconds, on the player\xe2\x80\x99s own \xe2\x80\xa6BlSeek to a position in seconds TRACES: UR-005BcVideo seek strategy decision logic.CjDeliver operation. Must not block the caller.AjStop periodic verificationBhStop signalling for in-flight downloads.AhStops the sync processormStop playbackBmStop playback and unload media TRACES: UR-005AgStop the polling threadBhTauri commands for sync queue operationsAmPlaying from a specific album0ChAudio playback session (music, audiobooks, podcasts) \xe2\x80\xa6BaPlayback error - don\xe2\x80\x99t autoplayAaAn error occurredBaAn error occurred during playbackeGenreAlHTML5 video element fallbackAiDownloaded/cached locallyCbMovie playback (single video, auto-dismiss on end)CgA kind we do not model explicitly. Reached only for \xe2\x80\xa6CmAny stream kind we do not model explicitly (e.g. embedded \xe2\x80\xa6CmSome other transport (VPN over unknown carrier, Bluetooth \xe2\x80\xa6CjStop making sound. The user did not ask for background \xe2\x80\xa6C`Represents a database query that can be executedAiQuieter output (-23 LUFS)AdAlbum name for audioBmSmart caching engine for predictive downloadsBiThumbnail cache manager with LRU evictionCnForget a download\xe2\x80\x99s flag. Called when its task finishes, \xe2\x80\xa6AoClears all tracked report timesCjClear the queue entirely, returning it to the empty state.AoPresent on failed.CnThe 10-band gain curve (dB) for this preset, one entry per \xe2\x80\xa6B`Stream index in the media sourceAfAll items in the queueAjGet all items in the queue1mDisplay titleCnHuman label for the picker. Lives in Rust with the numbers \xe2\x80\xa6CnActive level, lowercase: \xe2\x80\x9cerror\xe2\x80\x9d | \xe2\x80\x9cwarn\xe2\x80\x9d | \xe2\x80\x9cinfo\xe2\x80\xa6BeAuthenticate by username and passwordBoCanonical, provider-neutral media domain model.nCheck if mutedAfWhether audio is mutednPause playbackAmPause playback TRACES: UR-005CiQueue manipulation commands (add / remove / move / skip).AcGet queue referenceCnForget everything \xe2\x80\x94 a different file is loading, and the \xe2\x80\xa6CnForget the budget \xe2\x80\x94 a new item is playing, so nothing is \xe2\x80\xa6CnCheck the token and resolve the path, or return the status \xe2\x80\xa6DkOpaque scope selected by the UI. When set it wins over \xe2\x80\xa6Cfitem id \xe2\x86\x92 bytes on disk (leaf\xe2\x80\x99s own size, or a \xe2\x80\xa6BcStart periodic session verificationDjBind to an ephemeral loopback port and start serving root \xe2\x80\xa6AiStarts the sync processorBcStart the background polling threadDbstarted | finished | failedAaGet current stateAhGet current player stateCmNew state: \xe2\x80\x9cplaying\xe2\x80\x9d, \xe2\x80\x9cpaused\xe2\x80\x9d, \xe2\x80\x9cstopped\xe2\x80\x9d, \xe2\x80\x9c\xe2\x80\xa6mDisplay titleFcUpdate last_accessed for LRU tracking. When tag is Some, \xe2\x80\xa6CaCustom queue (search results, manual queue, etc.)CjCustom queue (search results, manual queue, etc.) Will \xe2\x80\xa6AjDownload failed with errorCfNative backend (ExoPlayer on Android, libmpv on Linux)BiUse native seeking (HLS or direct stream)BmNative backend handled it (Android ExoPlayer)CnThe native backend was reloaded here; nothing left for the \xe2\x80\xa6CcNo special hint, use mode-based frequency (default)AhDefault level (-14 LUFS)oDownload pausedoMedia is pausedCgPerson (cast/crew member) - for movies, series, and \xe2\x80\xa6CnThe name starts with the query \xe2\x80\x94 \xe2\x80\x9cparks\xe2\x80\x9d in \xe2\x80\x9cParks \xe2\x80\xa6AhDownload has been queuedAnStreaming from Jellyfin serverClScrub within the current item, to this absolute position \xe2\x80\xa6CeTV show playback (supports next episode auto-advance)ChOne of: \xe2\x80\x9cplay\xe2\x80\x9d, \xe2\x80\x9cpause\xe2\x80\x9d, \xe2\x80\x9cstop\xe2\x80\x9d, \xe2\x80\x9cseek\xe2\x80\x9d.BnOptional now-playing metadata. Used by the \xe2\x80\xa6AhArtist name(s) for audioA`Cancel the timerAiHTTP client for downloadsClTRACES: UR-009 | JA-001, JA-002, JA-003, JA-004, JA-007, \xe2\x80\xa6CjMake a DELETE request to the Jellyfin API (used by the \xe2\x80\xa6ChSecondary line for the picker: what the cap means in \xe2\x80\xa6AnDevice identification commandsCkCanonical, provider-neutral domain model \xe2\x80\x94 the single \xe2\x80\xa6ClAttach remote-volume control (and, on Android, start the \xe2\x80\xa6ChDownload events for progress tracking and status updatesCjPlayer events for frontend communication via Tauri events.CiRows that couldn\xe2\x80\x99t be resolved (item metadata / URL \xe2\x80\xa6BfLogout (call Jellyfin logout endpoint)AfDatabase model structsBhTRACES: UR-002, UR-007 | DR-013 | IR-010BdPerson/cast metadata cache commands.ClTRACES: UR-003, UR-004, UR-005, UR-010, UR-020, UR-021 | \xe2\x80\xa6D`Rows that reached the server and are now completed.CmRecord a live reading. Non-positive durations are treated \xe2\x80\xa6BdStrip credentials from one log line.ChRemote Jellyfin session control commands (casting to \xe2\x80\xa6AmRemove an item from the queueAcCurrent repeat mode0CkReports a playback operation (dual sync: local DB + server)CkSend a playback report. Fire-and-forget by contract, so \xe2\x80\xa6AnDatabase schema and migrationsA`Search for itemsBfSearch every library the user can see.BbAsk an in-flight download to stop.AcSource of the mediaAjTarget position in secondsBbSleep-timer and autoplay commands.AnGet current volume (0.0 - 1.0)AbGet current volumeAjNew volume level (0.0-1.0)CmDownload worker for HTTP streaming with progress tracking \xe2\x80\xa6AiThumbnail download workerClMove to the next/previous queue entry \xe2\x80\x94 a track, or an \xe2\x80\xa6EcA channel container the user drills into (Jellyfin Channel\xe2\x80\xa6BlOperation succeeded using the system keyringAhKeyring operation failedAjLibrary (media collection)AnMedia is being loaded/bufferedA`Media is playingAiSeeking to a new positionAnActive session for restorationAdDownload has startedCmThe download was asked to stop (paused or cancelled). Not \xe2\x80\xa6BbCould not determine the transport.CkArtists as array of strings (fallback when artist_items \xe2\x80\xa6CjBackend being used (native = ExoPlayer/libmpv, html5 = \xe2\x80\xa6CkTauri commands for the offline \xe2\x80\x9cbrowse & queue\xe2\x80\x9d \xe2\x80\xa6CkOptional context for the queue (album, playlist, or custom)CjGet the current queue context (album, playlist, or custom)CkContext for the queue (album, playlist, or custom) Used \xe2\x80\xa6AmGet the current session stateAdGet the current itemCbReturn volume control to the local device speaker.ClDismiss/clear current session (user action) Transitions: \xe2\x80\xa6BmWhether autoplay is enabled for next episodesAjWhether caching is enabledCjExecute a query that doesn\xe2\x80\x99t return results (INSERT, \xe2\x80\xa6BaHandles of every live repository.CgHistory of played indices (for going back with shuffle)CmLibrary browsing preferences \xe2\x80\x94 currently, which folders \xe2\x80\xa6BiDirectory holding the rotating log files.mError messageAjRun all pending migrationsCfNetwork transport classification for the WiFi-only \xe2\x80\xa6BhNew stream URL starting at seek positionBhNew stream URL with selected audio trackCgNew stream URL, already transcoded to the requested \xe2\x80\xa6BfTauri commands for offline data accessAjBuffering progress (0-100)CjPinning commands - protect an item\xe2\x80\x99s cached metadata \xe2\x80\xa6CgWhere to seek after loading, if the source does not \xe2\x80\xa6C`Get a database service for async-safe operationsAmMedia session state commands.AeCurrent session stateAjWhether shuffle is enabled0AhSkip to a specific indexCjNumber of tracks skipped (no jellyfin ID or other reasons)BcOffline storage module using SQLiteBnTauri commands for database/storage operationsAdConfirmation messageBlA playable URL for an absolute on-disk path.ClThe signed-in user these requests are made as. Needed by \xe2\x80\xa6BlThe signed-in user this repository acts for.CkMobile data \xe2\x80\x94 never acceptable when wifi-only is enabled.CcDatabase connection wrapper with thread-safe accessAjDatabase query result typeCdCentre frequencies (Hz) of the fixed 10-band ISO \xe2\x80\xa6CnStop after N more episodes complete (TV episodes only, not \xe2\x80\xa6DaBuilt-in equalizer presets. A preset is a gain curve \xe2\x80\xa6CbWired ethernet, typical on Android TV and desktop.CiLegacy rows only \xe2\x80\x94 live favourite toggles drain via \xe2\x80\xa6CkTrack played to completion (natural end) - trigger autoplayAdCredential not foundClNo client-imposed cap \xe2\x80\x94 the server may direct-play the \xe2\x80\xa6B`Playing from a specific playlist0AhDownload progress updateDhA queued mutation, resolved from its stored operation + \xe2\x80\xa6BaReplacement for a redacted value.CgWhat a request path resolved to, before any file is \xe2\x80\xa6CmThe slice of the repository the drain needs \xe2\x80\x94 narrow so \xe2\x80\xa6CnUser-specific data for an item (playback state, favorites, \xe2\x80\xa6AnUser info returned to frontendCiUser pressed next/previous - already handled, don\xe2\x80\x99t \xe2\x80\xa6BhUser stopped playback - don\xe2\x80\x99t autoplayCbAlbum ID (Jellyfin ID) for remote transfer contextCcWhether to begin playing immediately after loading.BjThe base a media URL is built on, e.g. \xe2\x80\xa6CkRows that failed and will be retried on the next reconnect.BjDownload manager for offline media supportBfTauri commands for download operationsCgDownload a file with retry logic and progress tracking.AmDownload a thumbnail from URLmGet duration.AiTotal duration in seconds0AmGet total duration in seconds11AcDuration in secondsCfTotal duration \xe2\x80\x94 live, or the last one observed. \xe2\x80\xa6AgGet a single item by IDCiFetch one item with every field the detail and player \xe2\x80\xa6CgA single item, cache-first \xe2\x80\x94 and, on a cache hit, \xe2\x80\xa6AnMake authenticated GET requestAiGet current playback modeBhWhether there\xe2\x80\x99s a next track availableBhCheck if there\xe2\x80\x99s a next item availableDhNeutral image identifier \xe2\x80\x94 replaces primary_image_tag \xe2\x80\xa6CkNeutral image identifier the frontend resolves to a URL \xe2\x80\xa60BjLanguage code (e.g., \xe2\x80\x9ceng\xe2\x80\x9d, \xe2\x80\x9cspa\xe2\x80\x9d)BdID of the current media item, if anyCiJellyfin item id, used as the media_id when reporting \xe2\x80\xa6CdLast position/duration seen while a file was loaded.CdPin an item\xe2\x80\x99s metadata (protects from cache clear)ClTauri commands for playlist management Uses handle-based \xe2\x80\xa6CeManually trigger a poll (for frontend refresh button)AmConfirmed position after seekAoCurrent position to resume fromAmPosition playback resumed at.AhPosition to resume from.AdGet current positionBoGet the current playback position if available.AkCurrent position in seconds0BhGet current playback position in seconds1CmTarget position in seconds (only meaningful for \xe2\x80\x9cseek\xe2\x80\x9d\xe2\x80\xa6CjResume position in seconds (0 = start from the beginning).DhCurrent position \xe2\x80\x94 the live time-pos, or the last one \xe2\x80\xa6AfSkip to previous trackAiMove to the previous itemDiRegister download_id as in-flight and hand back its stop \xe2\x80\xa6Cmdownload id \xe2\x86\x92 its stop flag, for downloads currently in \xe2\x80\xa6CnGet a cheap, cloneable reporter so the repository can feed \xe2\x80\xa6B`Playback reporter implementationCbRows whose stream URL was resolved and are now \xe2\x80\xa6B`TRACES: UR-010 | JA-021 | DR-037BnAll active controllable sessions from JellyfinBaSet playback mode (internal use).ClTRACES: UR-023, UR-027, UR-031, UR-032, UR-033 | DR-030, \xe2\x80\xa6BkAudio and video playback settings commands.DeDecide which span to send for a Range header (or its \xe2\x80\xa6BmEvent throttler for position update reportingAgBuffering state changedCfBytes per response. Large enough that a film needs \xe2\x80\xa6AbDownload cancelledAoDownload completed successfullyClEvery request this app makes identifies the same device, \xe2\x80\xa6BbDirect URL (e.g., channel plugins)ChTracks why playback ended to determine autoplay behaviorBdError classification for retry logicAiEscaped the allowed root.jImage typeCmA single actor returned by the JRay plugin\xe2\x80\x99s \xe2\x80\x9ccontext \xe2\x80\xa6BjRepresents a media item that can be playedjMedia itemCaThe kind of a media item \xe2\x80\x94 provider-neutral \xe2\x80\xa6CjPoison-tolerant locking for std::sync::Mutex.BeError types for repository operationsCnThe query appears mid-word \xe2\x80\x94 \xe2\x80\x9cparks\xe2\x80\x9d in \xe2\x80\x9cSparks of \xe2\x80\xa6DgSome later word starts with the query \xe2\x80\x94 \xe2\x80\x9crecreation\xe2\x80\x9d \xe2\x80\xa6DhRows that exhausted MAX_SYNC_ATTEMPTS and were given up on.C`Evict least recently used items to free up spaceCgPushing favourite toggles made while the server was \xe2\x80\xa6AoGet database file size in bytesB`Get items in a library or parentBdGet the current cache limit in bytesAnGet an access token for a userBlTrue = playing/paused, False = stopped/endedBdTrue = playing/paused, False = ended0AhCheck if timer is activeCjWhether this item is a folder/container (vs a playable \xe2\x80\xa6BgCheck if the player is currently pausedCmCached title of the item the operation is about, when the \xe2\x80\xa6CmItem type (e.g. \xe2\x80\x9cEpisode\xe2\x80\x9d, \xe2\x80\x9cMovie\xe2\x80\x9d, \xe2\x80\x9cAudio\xe2\x80\x9d). \xe2\x80\xa6BgItem type (Audio, Movie, Episode, etc.)FjLegacy Jellyfin item-type string ("Audio", "MusicAlbum", \xe2\x80\xa6AnMutable access to queue items.CjRank of a media kind when match quality ties \xe2\x80\x94 lower \xe2\x80\xa6AoLast/current track being playedCnLock the mutex, recovering the inner guard if the lock was \xe2\x80\xa6CjCollect every log file in the log directory, newest first.CnMIME type (e.g., \xe2\x80\x9ctext/vtt\xe2\x80\x9d, \xe2\x80\x9capplication/x-subrip\xe2\x80\x9d\xe2\x80\xa6BfMove an item from one index to anotherCgLoad and play a single item (also sets the queue to \xe2\x80\xa6AoMake authenticated POST requestAbQuery a single rowCgAcquire a read guard, recovering it if the lock was \xe2\x80\xa6CfRows still waiting afterwards (what the badge counts).CmResolve app state and drain. Shared by the reconnect hook \xe2\x80\xa6BlDrop blanks and duplicates from the id list.DiSeries ID for TV episodes. Needed alongside item_type so \xe2\x80\xa6BdSeries ID for fetching next episodesClSeries ID (for TV show episodes) - used for series audio \xe2\x80\xa6BmServer ID - used for series audio preferencesAlSet the cache limit in bytesCbReplace the queue without starting local playback.CgSet the queue with new items (resets context to Custom)CkSubtitle tracks to sideload, with URLs the frontend has \xe2\x80\xa6AiAvailable subtitle tracksBcThumbnail caching with LRU evictionCkThe server could not be reached at all \xe2\x80\x94 retry later, \xe2\x80\xa6CdWhether the active network is unmetered (Android \xe2\x80\xa6AbOnly cache on WiFiClOne track of an album, as the album-download path queues \xe2\x80\xa6CiArtist item with ID and name (for clickable artist links)AeAuthentication resultBfCached media item returned to frontendCgCastButton is active and needs frequent updates (500ms)AlEncryption/decryption failedB`Stop at the end of current trackCnOne row of the series episode listing used when queueing a \xe2\x80\xa6CcEnhanced HTTP client with retry logic and error \xe2\x80\xa6AiHTTP client configurationCnName of the file holding the user\xe2\x80\x99s chosen level, in the \xe2\x80\xa6CmList of migrations to apply in order. Each migration is a \xe2\x80\xa6BbMPV-based player backend for LinuxBoQuery parameter types supported by the databaseAiRepeat mode for the queueCkPoison-tolerant locking for std::sync::RwLock.BiServer information returned from JellyfinB`Server info returned to frontendCmWhat a lockscreen skip button means for the playback that \xe2\x80\xa6AdSmart caching engineCkThe kind of a media stream within an item (audio track, \xe2\x80\xa6B`Configuration for sync processorBoAlbum name (alias - for frontend compatibility)B`Login with username and passwordBeRemoves a specific item from trackingBbDatabase service abstraction layerCnUser-chosen browsing exclusions (UR-076 / DR-209). Library \xe2\x80\xa6CiHow many log files went in, excluding the environment \xe2\x80\xa6AaGet configurationjGet genresAbGet person detailsCbA single Person item (actor, director, \xe2\x80\xa6) by id.AoGet current connectivity statusAkCheck if caching is enabledDfTrue when the candidate is a library rather than a folder \xe2\x80\xa6BhCheck if the player is currently playingAkCheck if shuffle is enabled0CiWhether this outcome means \xe2\x80\x9cthe user stopped it\xe2\x80\x9d, \xe2\x80\xa6CaNumber of downloaded leaf items (not containers).DiThe Jellyfin item types this scope requests, or None for \xe2\x80\xa6DfResolution ceiling that suits the bitrate, or None to \xe2\x80\xa6mType of mediaChSet the queue and start playing from the specified indexAcQuery multiple rowsCjTauri commands for repository access Uses handle-based \xe2\x80\xa6AoSave an access token for a userEeSession id when mode == "remote", otherwise None.AfSet volume (0.0 - 1.0)BeSet volume (0.0 - 1.0) TRACES: UR-016DgTelling a finished stream apart from a truncated one.DhDraining the offline mutation queue (sync_queue) to the \xe2\x80\xa6BgThumbnail cache and image-URL commands.AmTrack that an item was playedAjUnpin an item\xe2\x80\x99s metadataChAcquire a write guard, recovering it if the lock was \xe2\x80\xa6BbPosition to add items to the queueAfAuthentication managerAkSmart caching configurationBeConfiguration for the thumbnail cacheBnA playable leaf inside a channel (Jellyfin \xe2\x80\xa6CmWhat a drain did, for logging and for the frontend\xe2\x80\x99s \xe2\x80\x9c\xe2\x80\xa6AlMaximum per-band gain in dB.AlMinimum per-band gain in dB.DkEnvelope returned by the JRay jray?t= endpoint. Extra keys \xe2\x80\xa6CkCarry on. Music, and video the user explicitly asked to \xe2\x80\xa6CmA live TV channel \xe2\x80\x94 playable, but a live stream with no \xe2\x80\xa6BoMedia has finished loading and is ready to playCmA running server. Dropping this does not stop the thread; \xe2\x80\xa6BoTRACES: UR-002, UR-003, UR-004, UR-011 | DR-003AhMedia source informationChMedia stream information (audio, video, subtitle tracks)BkKind of network transport currently active.CnNull player backend (for testing or when no real player is \xe2\x80\xa6B`Error type for player operationsCkPlayer state machine (6 states: Idle, Loading, Playing, \xe2\x80\xa6CcHint for adjusting poll frequency based on UI stateClWhy a push failed, and whether the row should be charged \xe2\x80\xa6AjResponse for queue queriesCjAn opaque search scope the frontend selects; Rust owns \xe2\x80\xa6BaSession information from JellyfinCcTransaction handle for batching multiple operationsChVolume normalization levels matching Spotify\xe2\x80\x99s presetsCmNumber of albums tagged with this genre, when the backend \xe2\x80\xa6AlURL or path to artwork imageAjBuild authorization headerChLogout (clear session and call Jellyfin logout endpoint)AkClear all cached thumbnailsCjClear the queue entirely (used when playback genuinely \xe2\x80\xa6CbTauri commands for unit conversions and formattingBhUnit conversion and formatting utilitiesB`Secure credential storage moduleBkDelete cached thumbnail for a specific itemCeDiagnostics: log level control and the exportable \xe2\x80\xa6CkCredential redaction and crash capture for diagnostic logs.AoTrack duration in milliseconds.ClDuration in milliseconds \xe2\x80\x94 the neutral replacement for \xe2\x80\xa6BnFormat time in seconds to MM:SS display stringAoExtracts item_id from operationAcGet current sessionAlGet a specific session by IDBoWhether we\xe2\x80\x99re currently checking connectivityBeGet the Jellyfin item ID if availableDjMark an item played \xe2\x80\x94 the inverse of clear_watch_history\xe2\x80\xa6DiPOST /Users/{userId}/PlayedItems/{itemId} \xe2\x80\x94 the mirror \xe2\x80\xa6BmMarks an item as synced in the local databaseCmTotal bits per second the stream may use (video + audio), \xe2\x80\xa6AfMaximum retry attemptsBhParse a stored/user-supplied level name.CkPerson type from Jellyfin API (Actor, Director, Writer, \xe2\x80\xa6CePlaylist ID (Jellyfin ID) for remote transfer contextCmResume position in milliseconds. Stored as Jellyfin ticks \xe2\x80\xa6BjCurrent playback position in milliseconds.B`Whether the error is recoverableoGet repeat modeAkGet the current repeat modeBeRelevance ranking for search results.DdSort key for a season number. Specials sort after every \xe2\x80\xa6BoPosition offset to track (for display purposes)AeSet the queue contextAcSet current sessionCjSmart-cache statistics/config and album recommendation \xe2\x80\xa6CnLegacy Jellyfin stream type string (\xe2\x80\x9cAudio\xe2\x80\x9d/\xe2\x80\x9cVideo\xe2\x80\x9d\xe2\x80\xa6BoGet the target LUFS value for this volume levelDcConvert a Jellyfin RunTimeTicks value to milliseconds.BjRun a transaction with multiple operationsCjVideo codec (e.g., \xe2\x80\x9ch264\xe2\x80\x9d, \xe2\x80\x9chevc\xe2\x80\x9d) for video media0AeVideo width in pixelsBgCached person info returned to frontendAlInformation about a downloadAiDownload task for workersDhIds (normalised \xe2\x80\x94 see normalise_id) of items the user \xe2\x80\xa6BmThe subset of the repository the drain needs.mImage optionsCiAn LMS multi-room sync group, as returned by JellyLMS \xe2\x80\xa6ChThe inverse, queued by the watched toggle. Pushes as \xe2\x80\xa6ChHow well a query matched an item\xe2\x80\x99s name \xe2\x80\x94 better \xe2\x80\xa6CeCurrent network transport plus whether it is metered.ClThe last playback time actually observed while media was \xe2\x80\xa6AdPlayback informationCePlayback mode - local device, remote session, or idleBaResponse for player state queriesAcPlaylist item entryCfQueue changed (items added, removed, reordered, or \xe2\x80\xa6CmContext for the current queue - where did the queue items \xe2\x80\xa6BhQueue manager for playlist functionalityCdReload stream from new position (transcoded non-HLS)CaHTML5 needs to reload stream with new audio trackBlHTML5 must reload its element with this URL.CdThe production sink: hands each operation to the \xe2\x80\xa6CkThe stream ended well short of the item\xe2\x80\x99s runtime \xe2\x80\x94 \xe2\x80\xa6AmSearch result with paginationAdPlayer state changedB`Storage statistics for downloadsAoBackend type for video playbackBlGet the number of currently active downloadsBiArtist items with IDs for clickable linksC`Whether to auto-advance when countdown reaches 0ChThe position the stream\xe2\x80\x99s own zero corresponds to, \xe2\x80\xa6BgSubdirectory name for cached thumbnailsBlServer-reachability / connectivity commands.CfReports the outcome of every server request to the \xe2\x80\xa6AeGuess a content type.BaGet the current media item if anyAaCycle repeat modeAjCycle through repeat modesCjDefault value for supports_remote_control when missing \xe2\x80\xa6BaDelete an access token for a userCjEnsure there\xe2\x80\x99s enough space by evicting LRU items if \xe2\x80\xa6ChGet the root list of plugin \xe2\x80\x9cChannels\xe2\x80\x9d (Jellyfin \xe2\x80\xa6AgGet all active sessionsCjGet the next N upcoming items (for preloading) Returns \xe2\x80\xa6BlWhether there\xe2\x80\x99s a previous track availableBlCheck if there\xe2\x80\x99s a previous item availableCeWhether an index pass is due, given when one last \xe2\x80\xa6CnTrue for kinds that are containers/collections rather than \xe2\x80\xa6CnCurrent reachability as seen by this reporter (shared with \xe2\x80\xa6B`Check if this error is retryable0CiTotal items persisted to the offline cache across all \xe2\x80\xa6CjEntries removed because the server no longer has them. \xe2\x80\xa6CjLast time we checked server reachability (ISO 8601 string)ClLibrary this candidate lives in, so the picker can group \xe2\x80\xa6CnMaximum number of episodes to auto-play consecutively (0 = \xe2\x80\xa6CdA loopback HTTP server for locally downloaded media.CdMedia item from either local queue or remote sessionAdNext episode to playCjJellyfin writes the same GUID both dashed and undashed \xe2\x80\xa6CeThe partial-download sidecar for target.BcNumber of tracks queued for preloadCbRead every favourite change this user has pending.CkResolve a percent-encoded request path to a file inside \xe2\x80\xa6BjPer-series preferred audio track commands.AhSeek on a remote sessionCgCheck if a request should be retried based on the errorBjInitialize the database and run migrationsCgRead the persisted level, if the user has ever set one.AiAttempt a single downloadAfVideo height in pixelsBeTarget volume level for normalizationBmActive session info (for session restoration)CaAdvance to next track in queue (for audio/movies)AgAudio playback settingsBhCached library info returned to frontendBeCastButton is in discovery mode (15s)ChCountdown tick (emitted every second during autoplay \xe2\x80\xa6AdDownload error typesBiEvents emitted during download operationsBhDownload statistics computed server-sideCaOperation succeeded using encrypted file fallbackChDrop the user\xe2\x80\x99s hidden items from a repository result.AhInitial schema migrationClMigration to remove access_token column from users table \xe2\x80\xa6CkMigration to relax foreign key constraints on user_data \xe2\x80\xa6CjMigration to enhance downloads table with priority and \xe2\x80\xa6C`Migration to relax foreign key constraint on \xe2\x80\xa6CjMigration to store item metadata directly in downloads \xe2\x80\xa6ClMigration to enhance thumbnail caching with LRU eviction \xe2\x80\xa6ChMigration to add video download support and item pinningBlMigration to add people/cast caching supportBjMigration to add playback context trackingBnMigration to add user-specific player settingsCiMigration to track download source (user-initiated vs \xe2\x80\xa6CkMigration to add composite index for offline mode filteringBoMigration to add series audio track preferencesBbMigration to add device ID storageBoMigration to add autoplay episode limit settingCkMigration to persist the resolved stream URL and target \xe2\x80\xa6CbMigration to record whether a cached item is a \xe2\x80\xa6CeMigration to cache the full server genre catalog. \xe2\x80\xa6C`Migration to index items.season_id.DdDiscard and rebuild the FTS index from the items table.DkFull-text index over people, mirroring items_fts.BfGive temporary downloads a life limit.CcPlayback has ended naturally (reached end of media)CkPlayer backend trait - implemented by platform-specific \xe2\x80\xa6CjPlaylist entry \xe2\x80\x94 wraps a MediaItem with the Jellyfin \xe2\x80\xa6AnResponse for preload operationCgBudget for consecutive resume attempts that make no \xe2\x80\xa6AjOptions for search queriesAkRepresents a subtitle trackCfSync queue processor that handles retry logic with \xe2\x80\xa6BdSync queue item returned to frontendAgVideo playback settingsnVolume changedDeRecord an attempt at position, returning its 1-based \xe2\x80\xa6BlBits per second allotted to the audio track.ChCalculate progress percentage from position and durationAbCurrent item indexAjGet the current item index1ClGet or create the device ID. Device ID is a UUID v4 that \xe2\x80\xa6CjSet the device ID (primarily for testing or recovery). \xe2\x80\xa6AoQueue a media item for downloadChThe directory every download has to stay inside: the \xe2\x80\xa6CnOptional emitter used to notify the frontend when the mode \xe2\x80\xa6AeGet the event emitterCbExecute a batch of SQL statements (for migrations)B`Jellyfin \xe2\x86\x92 domain translation.CkGet all downloads for a user, optionally filtered by statusCkEverything the viewer has favourited, across every library.CjFavourites held locally \xe2\x80\x94 the ones mirrored from the \xe2\x80\xa6BhTRACES: UR-067 | DR-115, JA-033 | UT-100AgTRACES: UR-067 | DR-115CaGet image URL (synchronous - just constructs URL)ChMake a GET request and deserialize JSON with a short \xe2\x80\xa6AaGet all librariesCkGet user data for an item (playback position, favorite, \xe2\x80\xa6CkGet image as base64 data URL, caching if not already cachedChThe last observed duration, if one was ever established.CnThe last observed position, with no live reading to prefer \xe2\x80\xa6CnLoad and play an item without modifying the queue Use this \xe2\x80\xa6CnPut a row back in the queue untouched apart from its error \xe2\x80\xa6AeMark item as favoriteBnMarks the item as reported at the current timeDnClassify how query matches name, case-insensitively.CjVolume from either local player or remote session (0-1 \xe2\x80\xa6CiNormalize and validate server URL. Enforces HTTPS \xe2\x80\x94 \xe2\x80\xa6CjCache-first query: try cache, fall back to server on miss.CcPlayback-mode transfer commands (local \xe2\x86\x94 remote).DkISO-8601 release/air date (Jellyfin PremiereDate). Used to \xe2\x80\xa6AmProcesses the sync queue onceCmPlatform hook for OS-level remote volume routing (swapped \xe2\x80\xa6DhExpand scope into include_item_types in place.CmWhere to resume writing a partial download, given how the \xe2\x80\xa6CkLegacy Jellyfin duration in ticks (100 ns units). Being \xe2\x80\xa6CiSave browsed items to cache for faster subsequent loadingCdFull-text search over cached people (cast and crew).DkSeek to an absolute position on the item\xe2\x80\x99s own timeline.CmCore transition: update status and emit events only on an \xe2\x80\xa6CkChecks if enough time has elapsed since the last report \xe2\x80\xa6CcShuffled order of indices (used when shuffle is on)BfStorage limit in bytes (0 = unlimited)CkToString::to_string, but without panic on OOM.0000AdUpdate configurationCjWhether we\xe2\x80\x99re using keyring (true) or encrypted file \xe2\x80\xa6CjBits per second allotted to the video track: the total \xe2\x80\xa6CaCreates a new EventThrottler with custom intervalCkBackend-originated control command targeting the active \xe2\x80\xa6AoResult of a successful downloadCfDownload worker that handles individual download tasksCjEvent throttler to prevent spam from frequent position \xe2\x80\xa6ChSwap the video stream for an audio-only one and keep \xe2\x80\xa6DeClears INDEX_IN_PROGRESS however the pass leaves \xe2\x80\x94 \xe2\x80\xa6BjJellyfin API client for playback reportingBeConfiguration for Jellyfin API clientCmLive stream information returned from opening a Live TV / \xe2\x80\xa6CbUser selected a different track - don\xe2\x80\x99t autoplayCjPlayback position updated (emitted periodically during \xe2\x80\xa6BiHeader names whose value is a credential.AdSecurity status infoCdMedia session changed (activity context changed: \xe2\x80\xa6CkSleep timer mode - determines when playback should stop \xe2\x80\xa6BaThumbnail cache with LRU evictionAjGet current audio settings0BaClassify an error for retry logicBlConvert QueryParam to rusqlite::types::ValueDgDevice-profile policy: turning what a device reports about \xe2\x80\xa6BcQueue an entire album for download.CbDownload raw bytes from a URL using the shared \xe2\x80\xa60CkQueue a video item (movie or episode) for download with \xe2\x80\xa6CjEncryption key for file fallback (derived from machine ID)CgRestrict the listing to favourited items. Backs the \xe2\x80\xa6BeGet current total cache size in bytesAiGet count of cached itemsBgHelper to get stream URL from MediaItemDcWhether item falls under one of excluded.CnIs this episode genuinely part-watched (not a false start, \xe2\x80\xa6AjCheck if an item is pinnedCiWhether a media type has a picture that backgrounding \xe2\x80\xa6CgRFC-3339 timestamp of the last successful sync, if any.CnMark server as reachable (called after successful API call \xe2\x80\xa6BdGet the maximum concurrent downloadsAlMaximum concurrent downloadsBkMaximum cache size in bytes (0 = unlimited)CiNormalized server URL with protocol and no trailing slashAdSearch offline itemsAaPause a download.AkQuery a single optional rowAoQueues operation for later syncDeRead the last-sync timestamp straight from app_settings.DjX-Emby-Token: abc -> X-Emby-Token: [REDACTED]CeFeed a request outcome into the connectivity monitor.ClReport that a real server request succeeded (or that the \xe2\x80\xa6CaOne full-catalog indexing pass, shared by the \xe2\x80\xa6ChMake one part of a cache filename safe to put in a path.AgSave thumbnail to cacheCdSession polling manager for remote playback control.BkSet the Tauri app handle for event emissionChSet the Tauri app handle for event emission. Must be \xe2\x80\xa6CbSet the end reason for the next playback end eventChConfigure the media repository used for next-episode \xe2\x80\xa6AeUpdate the server URLDkConstruct the tauri-specta command builder. Shared by run()\xe2\x80\xa6CnStart downloading a file immediately This command actually \xe2\x80\xa6CnPosition (seconds) to resume the starting track from. Used \xe2\x80\xa6CgAsk the server to tear down a transcode this device \xe2\x80\xa6CiSync queue processor with retry logic and exponential \xe2\x80\xa6AhSyncs to Jellyfin serverClDownload and save a thumbnail to cache Returns the local \xe2\x80\xa6nToggle shuffleAcToggle shuffle modeBlVerify current session by fetching user infoBeError types for credential operationsAjCredential storage managerCfDatabase service trait - abstraction over database \xe2\x80\xa6BgWrapper for thread-safe database accessCcWhere logs live and how verbose they currently are.CfDownload manager coordinating downloads across workersBjRequest to get an image URL (with caching)AjOptions for querying itemsClHLS or direct stream with HTML5 - frontend handles seek, \xe2\x80\xa6AmLibrary browsing preferences.BnLocal file - always use native seek on backendCjRepository trait for media access (online, offline, or \xe2\x80\xa6CmLightweight media item for merged playback state Converts \xe2\x80\xa6CkA local favourite change still waiting to reach the server.BcRequest to play a single video itemAlPlayback context informationDkAlso where update_progress lands: replaying a mid-playback \xe2\x80\xa6BnRusqlite-based database service implementationBgJellyfin files specials under season 0.AkBackground session verifierCdRemote sessions updated (for cast/remote control UI)AaSleep timer stateAeSmartCache statisticsCfWorker for downloading thumbnails from Jellyfin serverAgAdd items to a playlistCkWhether downloads may run right now given the wifi-only \xe2\x80\xa60CnInitialize the auth manager (call on app startup) Restores \xe2\x80\xa6DjBuild the full MPV af (audio filter) value from the audio \xe2\x80\xa6AaCancel a downloadEfFold .. out of candidate and require what is left to sit \xe2\x80\xa6BcCreate a new playlist on the serverBbCurrent episode that just finishedAkDelete a completed downloadAaDelete a playlistCdQueue all episodes of a specific season for downloadBkQueue all episodes of a series for downloadDiPer-band gains in dB, one per EQ_BANDS. Normalised to 10 \xe2\x80\xa6ChEvict least recently used items to make space (async \xe2\x80\xa6CnThe favourites scope this library\xe2\x80\x99s contents fall under, \xe2\x80\xa6BkBuild the X-Emby-Authorization header valueClGet cached thumbnail path, or None if not cached Updates \xe2\x80\xa6BaGet device name based on platform0DiQuery the JRay plugin for the actors on screen at time t \xe2\x80\xa6DfQuery the JRay plugin for actors on screen at time t. \xe2\x80\xa6CkCheck if a media item is an episode (has Jellyfin ID to \xe2\x80\xa6FcTrue while webview-rendered media (HTML5 <video>/<audio>) \xe2\x80\xa6AoCheck if currently transferringCnGet a reference to the Jellyfin client (for remote session \xe2\x80\xa6BmA playable URL for a downloaded file on disk.BjTurn a stored row into something pushable.BiRead the end reason WITHOUT consuming it.BhPlay items on a remote session (casting)CmSet the queue and start playing from the specified index, \xe2\x80\xa6AeCreate a new playlistAaDelete a playlistAaRename a playlistChRecord a position alone, e.g. straight after a seek, \xe2\x80\xa61AiResume a paused download.BaWhere a viewer is in a TV series.BjSet the active audio track by stream index0CkReplace the sink playback reports go to. Tests capture; \xe2\x80\xa6CdSet sleep timer mode (in-memory only, not persisted)AlStop monitoring connectivityCjDelete all sync operations for a user (used during logout)BdGet and clear the current end reasonAbToggle play/pause.AgUnmark item as favoriteBbUn-favourite an item: the same \xe2\x80\xa6BiUpdates local database with playback infoC`Rewrite an audio-only stream URL to start at \xe2\x80\xa6AoStorage info for a single albumClAutoplay decision result - determines what happens after \xe2\x80\xa6CbAutoplay settings (controls next episode behavior)ChPayload emitted to the frontend when a native player \xe2\x80\xa6CgWhat the player should do when the app is backgrounded.BcItem-person association for cachingBhResult of a credential storage operationCkHybrid repository combining online and offline data sourcesCkPer-user state Jellyfin attaches to an item (favourite, \xe2\x80\xa6CkMedia session type tracking the high-level playback contextCmOnline repository - fetches data from Jellyfin server via \xe2\x80\xa6CmQueue context for remote transfer - what type of queue is \xe2\x80\xa6B`Request to play a queue of itemsAfPlayback progress infoCmMain playback reporter that handles dual sync (local DB + \xe2\x80\xa6CcCentral player controller that coordinates playbackBfJSON keys whose value is a credential.DdA ceiling on how much bandwidth a video stream may consume.CbNumber of Jellyfin ticks per second (100ns units).C`Number of Jellyfin ticks per second (10 million)DaJellyfin item types whose download is a video stream \xe2\x80\xa6E`Ask the frontend webview <audio> element to load and play \xe2\x80\xa6AmCurrently active download IDsClQueue one download row per track and link every track to \xe2\x80\xa6AcGet current sessionCbSet current session (for restoration from storage)CbCached thumbnails are handed to the webview as \xe2\x80\xa6BjError message from last connectivity checkCaPath to the encrypted credentials file (fallback)CcFlat 10-band curve \xe2\x80\x94 the default equalizer state.CgPush every queued mutation for this user, oldest first.ClThe live reading if there is one, else the last observed \xe2\x80\xa6ChTotal media duration (seconds). Threaded through the \xe2\x80\xa6CmEnqueue a download with its resolved stream URL, then let \xe2\x80\xa6CiTimestamp of the first network failure in the current \xe2\x80\xa6CjFormat time in seconds to HH:MM:SS or MM:SS display stringBfEnable gapless playback between tracksDmEvery track of an album, asked of the server rather than \xe2\x80\xa6AmGet latest items in a libraryBnGet resume items (continue watching/listening)CnContinue Watching: the items this user has started and not \xe2\x80\xa6ChGet subtitle URL (synchronous - just constructs URL) \xe2\x80\xa6CgWhether the webview element last reported itself as \xe2\x80\xa6CmDid this end-of-stream happen far enough short of the item\xe2\x80\xa6CaCheck if we\xe2\x80\x99re using the secure keyring backendBbOriginal Jellyfin ID for sync-backCgLibraries that failed to sync (e.g. server hiccup); \xe2\x80\xa6CcHelper function to map database row to DownloadInfoBdMark server as unreachable directly.CmMirror the server\xe2\x80\x99s per-user state for an item into the \xe2\x80\xa6CiNormalise a volume arriving over IPC to the 0.0..=1.0 \xe2\x80\xa6AkEnable volume normalizationCmOpen a live stream (Live TV channel or live channel item) \xe2\x80\xa6BiPlay a single media item (audio or video)CjThe playlist-scoped entry ID (Jellyfin\xe2\x80\x99s PlaylistItemId)ClThe live reading if there is one, else the last observed \xe2\x80\xa6FlMediaBrowser Token="abc" -> MediaBrowser Token="[REDACTED]"DiRe-open the current stream at position after the network \xe2\x80\xa6BaConvert seconds to Jellyfin ticksCjSet the current queue item without loading it into the \xe2\x80\xa6BlSet UI hint for polling frequency adjustmentBcSet the transferring flag directly.BoStart the MPV event loop in a background threadBaStart the offline recovery probe.CkStart a TV show session Transitions: Any \xe2\x86\x92 TvShow(active)AnGet a single cached item by IDCgGet storage directory path (parent directory of the \xe2\x80\xa6AoGet database file size in bytesBjGet all pending sync operations for a userCbMark a sync operation as failed with error messageBaConvert Jellyfin ticks to secondsBhTotal bytes currently held by log files.CgEnd TV show session (series complete or user dismissed)BlA single item fetched by id is never hidden.AmRequest to add items to queueCnHLS or direct stream with native backend - backend handles \xe2\x80\xa6CgHow long an index stays fresh before a re-index is due.CjProgress of a background index pass, for the staleness \xe2\x80\xa6CmWhere an export landed, so the UI can tell the user where \xe2\x80\xa6CjOn-disk usage of downloaded content, for the Downloads \xe2\x80\xa6BiEnhanced response with pre-computed statsCkTranscoded non-HLS with HTML5 - reload stream, frontend \xe2\x80\xa6CkGuards against two passes running at once. Replaces the \xe2\x80\xa6CgHow many times a row may fail before it stops being \xe2\x80\xa6CaTrait for checking if data has meaningful contentBfContext information for track playbackCgRequest to play tracks by ID (backend fetches metadata)AhPlayback operation typesClEvents emitted by the player backend to the frontend via \xe2\x80\xa6AiRepository handle managerCiTauri event name carrying the merged (cache + server) \xe2\x80\xa6CdQuery-string parameters whose value is a credential.CiHow far a lockscreen skip-back jumps while background \xe2\x80\xa6CiThe bandwidth ceiling every video stream this process \xe2\x80\xa6CmPayload for the deferred, merged search results pushed to \xe2\x80\xa6AiSleep timer state changedCkTime-based sleep timer expired: playback must stop. The \xe2\x80\xa6BmSmartCache wrapper for Tauri state managementCaTauri-based implementation of PlayerEventEmitter.BbResponse for video seek operationsCmSeek strategy for video playback, derived from a stream\xe2\x80\x99\xe2\x80\xa6CnThe queue is holding: WiFi-only is enabled and the current \xe2\x80\xa6DkThe position on the item\xe2\x80\x99s own timeline, whatever is \xe2\x80\xa6AeGet autoplay settingsBdDecide what backgrounding should do.CiWhat playback should do when the app stops being visible.BkBuild the X-Emby-Authorization header valueBeConnect to server and get server infoAmCountdown duration in secondsCjCountdown duration in seconds before auto-playing next \xe2\x80\xa6CfHelper function to create MediaItem from video requestCjDecrement episode counter, returns true if should stop \xe2\x80\xa6DkPeaking-EQ filter entries (unwrapped), one ffmpeg equalizer\xe2\x80\xa6CmEnable the graphic equalizer. When false, no EQ filter is \xe2\x80\xa6CbThe excluded set as currently applied, normalised.ChStable item ids of the folders/libraries hidden from \xe2\x80\xa6CjSnapshot of the excluded set, taken once per list so a \xe2\x80\xa6AoGet playback info for streamingAaGet resume moviesClContinue Watching, narrowed to movies \xe2\x80\x94 the home screen\xe2\x80\xa6BmGet similar/related items for a movie or showCiCheck if this is an active session (any type except Idle)CcGet the row ID of the most recent successful INSERTCnRemove a single LMS zone from its sync group (decouple one \xe2\x80\xa6CnRemove a single LMS player from whatever sync group it\xe2\x80\x99s \xe2\x80\xa6ChPlaying state from either local player or remote sessionCbWhether the video requires server-side transcoding0ClThe duration last reported by webview-rendered media, if \xe2\x80\xa6B`Get all offline items for a userCeHandle playback ended event - decides what to do nextCiConvert percentage volume (0-100) to normalized (0.0-1.0)BlSet the playback mode (internal/testing use)CiGet a reference to the playback reporter (for backend \xe2\x80\xa6AkPlay a queue of media itemsCgSmart video seeking that decides between native and \xe2\x80\xa6AnPrimary image tag for artwork.CiDeserializes from API\xe2\x80\x99s \xe2\x80\x9cPrimaryImageTag\xe2\x80\x9d field \xe2\x80\xa6ChLegacy Jellyfin primary image tag. Being replaced by \xe2\x80\xa6DgSelf::parallel_race, plus a callback fired on the fast \xe2\x80\xa6BgReduce a server URL to scheme and host.AmRegister a download as activeBhRemaining seconds (for time-based timer)AnRemaining seconds in countdownCgReport an item that just finished as stopped at its \xe2\x80\xa6DhTell Jellyfin playback stopped at position, unless that \xe2\x80\xa6ClCreate a new repository instance Returns a handle (UUID) \xe2\x80\xa6AaSearch for items.C`Sanitize filename by removing invalid characters0ChSearch only the local SQLite cache (downloaded content).CdManually trigger a session poll (for refresh button)DjWire the event emitter so set_mode notifies the frontend. \xe2\x80\xa6BgSet the event emitter for notificationsCbSet event emitter for broadcasting session updatesClReport the device\xe2\x80\x99s current network transport (Android \xe2\x80\xa6AeGet sleep timer stateCnOrder episodes as the series is watched: season ascending, \xe2\x80\xa6BhGet cached items with optional filteringAfGet users for a serverAiSave an item to the cacheClSave a user account (token stored in secure storage, not \xe2\x80\xa6CcThe ceiling currently applied to new video streams.C`Bandwidth ceiling applied to every video stream.CmThe SubtitleProfile entries to advertise, as \xe2\x80\xa6CmWalk every library on the server and persist all items to \xe2\x80\xa6CjTransfer playback from remote session back to local deviceCbWhether frontend should render HTML5 video elementCiConvert normalized volume (0.0-1.0) to percentage (0-100)CkAttach a connectivity reporter so server outcomes drive \xe2\x80\xa6C`Wrapper for AuthManager to manage in Tauri stateDbHow often the scheduler wakes to check staleness. Far \xe2\x80\xa6CmItem types worth caching for offline browsing: containers \xe2\x80\xa6AcConnectivity statusCjSomething the user may choose to hide: a library, or a \xe2\x80\xa6CnSQL fragment: the set of item ids that are \xe2\x80\x9con the device\xe2\x80\xa6CaMetadata for the lockscreen / media notification.DgManaged state. None when the server could not bind \xe2\x80\x94 \xe2\x80\xa6ClReference loudness the peak table is anchored at (Normal \xe2\x80\xa6EeReference peak (dynaudnorm p, linear amplitude) for the \xe2\x80\xa6CmJellyfin\xe2\x80\x99s sentinel for \xe2\x80\x9cnegotiate no subtitle stream \xe2\x80\xa6CfShared, mutable view of the current network transport.BiWhere the player\xe2\x80\x99s playback reports go.CaTrait for emitting player events to the frontend.AoPlayer state wrapper for Tauri.BgThread-safe wrapper for event emitters.DgThe PlaySessionId of the video transcode most recently \xe2\x80\xa6BdCalculates exponential backoff delayBeTrack recently played items per albumBcNumber of tracks already downloadedCiApply a deliberate reachability probe result (offline \xe2\x80\xa6ChSimple timeout wrapper for cache queries (100ms timeout)CaCalculate polling interval based on mode and hintChCalculate progress percentage from position and durationCfCheck if storage limit allows download (async version)CnCheck if a new download can be started based on concurrent \xe2\x80\xa6AbCancel sleep timerCjDeliberately probe the server\xe2\x80\x99s reachability (manual \xe2\x80\xa6CdCrossfade duration in seconds (0 = disabled, max 12)CfSum of all downloaded leaf sizes \xe2\x80\x94 the device total.ClWrite a redacted diagnostics archive and return where it \xe2\x80\xa6CjSend a transport intent to the webview element that is \xe2\x80\xa6BdEmit queue changed event to frontendCfGet playlist items with PlaylistItemId (needed for \xe2\x80\xa6CgInstall a panic hook that records the panic through \xe2\x80\xa6EcClassify a Jellyfin Type string into a neutral MediaKind.CmThe channel cap for this device, reading the platform\xe2\x80\x99s \xe2\x80\xa6BfMove a playlist item to a new positionCbNormalize a username before it goes to the server.DgContainer id \xe2\x86\x92 true when it is only partially downloaded \xe2\x80\xa6C`Tauri commands for playback reporting operationsBcGet the current media session stateC`Play tracks by ID - backend fetches all metadataAgAdd items to a playlistBfGet playlist items with PlaylistItemId7CjGet a reference to the position throttler (for backend \xe2\x80\xa6CcMake authenticated POST request and return responseBhTRACES: UR-018, UR-055 | DR-173 | UT-170Gf"AccessToken":"abc" -> "AccessToken":"[REDACTED]"BaReport an HTML5 state change \xe2\x80\xa6AmDestroy a repository instanceCeMake a request with automatic retry on network errorsCdSearch only the live Jellyfin server (full library).DkThe audio codec the server would serve for item_id \xe2\x80\x94 the \xe2\x80\xa6CiThe codec of the audio track the server will actually \xe2\x80\xa6AnSet volume on a remote sessionCfSet audio settings (crossfade, gapless, normalization)ChApply audio settings (crossfade, gapless, normalization)BdSet the maximum concurrent downloadsCjSet the active subtitle track by stream index (None to \xe2\x80\xa60CeCheck if album affinity threshold reached for cachingCkStart background timer thread for sleep timer countdown \xe2\x80\xa6AiGet a cached person by IDCjTransfer playback from local device to remote Jellyfin \xe2\x80\xa6CeUpdate audio session with new track (during playback)DoTrue when audio is rendered by a webview <audio> element \xe2\x80\xa6CkNarrow a detected audio-codec list to what the renderer \xe2\x80\xa6CgRequest to add a track by ID - backend fetches metadataAjAlbum affinity status infoAiAlbum recommendation infoCkTranscoded non-HLS with native backend - reload stream, \xe2\x80\xa6CmHow a background-audio handoff must start playback, given \xe2\x80\xa6CaKebab-case, per the project\xe2\x80\x99s event convention.CfConnectivity monitor for tracking server reachability.BbRequest payload for download_item.BgManages media session state transitionsCjWrapper for the current network transport, used by the \xe2\x80\xa6CgThe authoritative playback mode changed in the Rust \xe2\x80\xa6ClManages playback mode transfers between local and remote \xe2\x80\xa6BdRemote session status for UI updatesCnPlatform hook for attaching/detaching the OS remote-volume \xe2\x80\xa6CnQuery keys through which a stream URL can carry a subtitle \xe2\x80\xa6BdStatistics about the thumbnail cacheCdRe-authenticate with password (when session expired)AfVerify current sessionCkThe album\xe2\x80\x99s tracks as the local catalog cache knows them.CnReport the last-synced timestamp so the UI can show a hint \xe2\x80\xa6ClClaim a resume attempt for the current stream, returning \xe2\x80\xa6BlForget what webview-rendered media reported.CmErase the viewer\xe2\x80\x99s watch history for an item: clear its \xe2\x80\xa6DkDELETE /Users/{userId}/PlayedItems/{itemId} \xe2\x80\x94 Jellyfin\xe2\x80\x99\xe2\x80\xa6CmSanitize a queued download\xe2\x80\x99s path and confine it to the \xe2\x80\xa6CnThe Jellyfin id of whatever is currently queued, if it has \xe2\x80\xa6CdDetect which audio system is available on the systemAiDownload with retry logicChA short, non-identifying description of the environment.BcCalculate exponential backoff delayDdThe scope a library of this Jellyfin CollectionType \xe2\x80\xa6BnFormat time in seconds to MM:SS display stringBaGet items by person (filmography)CmA person\xe2\x80\x99s filmography \xe2\x80\x94 every item they are credited \xe2\x80\xa6DgA video item played through the native audio path \xe2\x80\x94 i.e. \xe2\x80\xa60BhWhether the Jellyfin server is reachableAmList current LMS sync groups.0CiMark movie session as inactive (playback ended) Movie \xe2\x80\xa6BaReport an HTML5 state change \xe2\x80\xa6CcRemove catalog entries the server no longer has \xe2\x80\xa6CdRemove catalog entries the server no longer has. \xe2\x80\xa6CeStart as many pending downloads as there are free \xe2\x80\xa6DaSort search results by relevance to query, in place.E`?api_key=abc&x=1 -> ?api_key=[REDACTED]&x=1BkSend a playback command to a remote sessionAhSeek on a remote sessionAgGet a single item by IDCjDecide whether a lockscreen skip advances the queue or \xe2\x80\xa6AoToggle mute on a remote sessionClConfigure the Jellyfin API client for automatic playback \xe2\x80\xa6CmStart an audio session with a queue Transitions: Idle \xe2\x86\x92 \xe2\x80\xa6ChStart a movie session Transitions: Any \xe2\x86\x92 Movie(active)CiDelete a user account and their token from secure storageAeGet all saved serversBeMark item as played in local databaseBjMark pending sync as completed for an itemAjSave a person to the cacheCkSave a server connection Uses INSERT \xe2\x80\xa6 ON CONFLICT to \xe2\x80\xa6DlSet the watched flag locally for an item and everything \xe2\x80\xa6BbMark a sync operation as completedBcQueue a mutation for sync to serverEaHow long a temporary (download_source = 'auto') download \xe2\x80\xa6AnGet thumbnail cache statisticsBjSet thumbnail cache storage limit in bytesC`Unregister a download when it completes or failsClHow many immediate children of a library the picker will \xe2\x80\xa6BoShared reachability state and transition logic.AoTRACES: UR-055 | DR-082, DR-083AkDownload manager statisticsBcRequest payload for download_video.CjThe codec claimed when a device reports nothing we can \xe2\x80\xa6CjA playlist item from Jellyfin \xe2\x80\x94 wraps a regular item \xe2\x80\xa6Dkapp_settings key holding the persisted library preferences \xe2\x80\xa6CfWhat playback facilities this platform\xe2\x80\x99s backend \xe2\x80\xa6BiRequest body for reporting playback startClHow far a lockscreen skip-forward jumps while background \xe2\x80\xa6BoManages background polling of Jellyfin sessionsBlTauri state wrapper for SessionPollerManagerBfShow next episode popup with countdown0BfVideo settings state wrapper for TauriDkAudio codecs the webview\xe2\x80\x99s <video> element can decode.ClIds whose favourite state differs between what we showed \xe2\x80\xa6AoDelete all downloads for a userBjCurrent log level and where the files are.CfExtract Jellyfin item IDs from queue items Returns \xe2\x80\xa6CjGet a clone of the active downloads set (for internal use)B`Get audio stream URL for a trackDjDownloaded-only browse: items under parent_id that are on \xe2\x80\xa6CnBrowse downloaded content only \xe2\x80\x94 the dedicated Downloads \xe2\x80\xa6CgImmediate children of a container with the user\xe2\x80\x99s \xe2\x80\xa6CeGet Live TV channels (broadcast / IPTV) for browsing.AdGet next up episodesCn\xe2\x80\x9cNext Up\xe2\x80\x9d: the episode that follows the ones this user \xe2\x80\xa6ClGet a video stream URL (initial play, resume, transcoded \xe2\x80\xa6CjGet video stream URL. This method is online-only since \xe2\x80\xa6ChCheck if playback is currently active within the sessionBkThe library preferences currently in force.ChReplace the library preferences: apply them to every \xe2\x80\xa6AiMark a download as failedCgMerge cache and server search results into a single \xe2\x80\xa6BeCheck if an item is available offlineCaExtract a printable message from a panic payload.CfThe episode a viewer should land on when they open \xe2\x80\xa6AcMark item as playedCcResume position in milliseconds \xe2\x80\x94 the neutral \xe2\x80\xa6BfGet number of queue items to pre-cacheBhNumber of tracks to pre-cache from queueC`Remove items from a playlist using entry IDs \xe2\x80\xa6CgGet items in a container (library, folder, album, etc.)CiReset autoplay episode counter (called on manual play \xe2\x80\xa6CiResolve a cache filename against the cache directory, \xe2\x80\xa6AdResume audio sessionCiCache the full server genre catalog for a library, so \xe2\x80\xa6CmSend a full GeneralCommand to a remote session. Uses POST \xe2\x80\xa6BkSend a playback command to a remote sessionAkSave a library to the cacheAmSearch cached items using FTSBdMark a sync operation as in progressCjPush the queue now, on the user\xe2\x80\x99s say-so, instead of \xe2\x80\xa6CnGet cached thumbnail path, returns None if not cached Also \xe2\x80\xa6CkRequest to add multiple tracks by IDs - backend fetches \xe2\x80\xa6ClVolume level (0-100) the remote volume slider starts at. \xe2\x80\xa6DfPayload for FAVORITES_CHANGED_EVENT \xe2\x80\x94 the ids whose \xe2\x80\xa6Cjapp_settings key holding the RFC-3339 timestamp of the \xe2\x80\xa6CjAbove this fraction watched, an episode is effectively \xe2\x80\xa6ClBelow this fraction watched, a position is a false start \xe2\x80\xa6CnRequest to play a track from an album (backend fetches all \xe2\x80\xa6AmResult of creating a playlistCfJellyfin playlist items response \xe2\x80\x94 items include \xe2\x80\xa6Dkapp_settings key holding the persisted streaming bandwidth \xe2\x80\xa6BcAudio track preference for a seriesCeResponse for a mid-playback streaming-quality change.CmJellyfin ticks per second (10 million). A tick is 100 ns. \xe2\x80\xa6BnWrapper for thread-safe thumbnail cache accessCaDecide the base and the seek for a handoff at \xe2\x80\xa6BoClear all stale pending/failed/paused downloadsCmOptional connectivity reporter. The session poller is the \xe2\x80\xa6CiCreate the appropriate player backend for the current \xe2\x80\xa6CaSet the log level, for this session and the next.CbStart the Android playback service and hand it \xe2\x80\xa6ChLeave a background-audio handoff, returning the base \xe2\x80\xa6BkEvery episode of a series, in series order.CnWhether downloads are currently permitted by the WiFi-only \xe2\x80\xa6ChGet albums the user has played, but not recently (\xe2\x80\x9c\xe2\x80\xa6AiGet SmartCache statisticsDkFuse LMS zones into a sync group. master_mac keeps playing \xe2\x80\xa6DkFuse LMS zones: create a sync group with master_mac as the \xe2\x80\xa6BdLoad and decrypt the credential map.CnRecord that playback is being stopped by an expiring sleep \xe2\x80\xa6AeReport playback startCjThe built-in equalizer presets and their per-band gain \xe2\x80\xa6DnTry to recover playback after a recoverable player error, \xe2\x80\xa6CkRemove items from a playlist (uses PlaylistItemId entry \xe2\x80\xa6ChReclaim temporary downloads whose life limit has passed.AoReport an HTML5 position tick.BaReport playback start to Jellyfin6AhGet genres for a libraryAbGet person detailsCnSet autoplay settings (in-memory only, persistence handled \xe2\x80\xa6CaReplace the excluded set. Ids are normalised, \xe2\x80\xa6CmConfigure the playback reporter for dual sync (local DB + \xe2\x80\xa6CjApply a bandwidth ceiling to every subsequently-opened \xe2\x80\xa6BfCheck if should pre-cache queue items.BeStart the background catalog indexer.CdSpawn the background worker for one download. On \xe2\x80\xa6BkDrain on every offline\xe2\x86\x92online transition.BgDelete a server and all associated dataBaGet cached libraries for a serverCgTrue when video can be rendered by a native surface \xe2\x80\xa6AkClear all cached thumbnailsBlDelete cached thumbnails for a specific itemBcJellyfin playlist creation responseBoWrapper for thread-safe credential store accessCeWrapper for DownloadManager to be used as Tauri stateCmWhether offline library queries may include catalog items \xe2\x80\xa6CcArgument struct for set_network_state.BkRequest body for reporting playback stoppedCdWrapper for SessionVerifier to manage in Tauri stateBoErrors that can occur during thumbnail downloadAoEnable album affinity detectionDdDecide whether we must transcode regardless of what the \xe2\x80\xa6CiMark audio session as inactive (playback ended, queue \xe2\x80\xa6C`Connect to a Jellyfin server and get server infoBdStop background session verificationCnMaximum number of episodes to auto-play consecutively (0 = \xe2\x80\xa6CaEnable auto-play of next episode (with countdown)CcBuild a safe FTS5 prefix query from raw user input.CbBuild the Jellyfin endpoint for a Next Up listing.ChHelper to compute download statistics from a list of \xe2\x80\xa6BiDelete all downloads for a specific albumCkRemove every completed download at or under a container \xe2\x80\xa6DjEnter a background-audio handoff at position (the video\xe2\x80\x99\xe2\x80\xa6ChGenerate a shuffle order, optionally starting from a \xe2\x80\xa6D`Get all tracked albums with their play counts Returns Vec<\xe2\x80\xa6AlGet SmartCache configurationCcBuild the URL a video download is fetched from. \xe2\x80\xa6AgTRACES: UR-071 | DR-123DaWhether offline get_items currently includes the \xe2\x80\xa6CkThe loudness-normalization filter entry (unwrapped), or \xe2\x80\xa6CaInitialize playback reporter (called after login)CmAdd a track to queue by ID - backend fetches metadata and \xe2\x80\xa6CcDismiss the current media session (returns to Idle)AmGet current sleep timer stateCiReport an HTML5 position tick (seconds). The adapter \xe2\x80\xa6ChSet the active audio track on a native backend directly.AdSet sleep timer modeClPush the remote session\xe2\x80\x99s now-playing onto the Android \xe2\x80\xa6AhEnable queue pre-cachingCaPlay items on a remote Jellyfin session (casting)CcReport that a real server request failed with a \xe2\x80\xa6CaSet the queue with new items and explicit contextBbCheck if we should show miniplayerBkDrain on every offline\xe2\x86\x92online transition.CjDelete completed sync operations older than specified daysBoGet count of pending sync operations for a userCfClamp countdown duration to valid range (5-30 seconds)CfClamp crossfade duration to valid range (0-12 seconds)BmConnectivity change event emitted to frontendCnChannel count assumed when the platform cannot tell us \xe2\x80\x94 \xe2\x80\xa6CnTauri event announcing that favourite state changed behind \xe2\x80\xa6BlRequest body for reporting playback progressBhTauri state wrapper for PlaybackReporterBeStart background session verificationBjPick the source for an audio-only handoff.CmFetch a folder\xe2\x80\x99s items from the live server and persist \xe2\x80\xa6BcGet the current connectivity statusCkQueue and start a download in a single atomic operation \xe2\x80\xa6CjPush pending favourite changes to the server and clear \xe2\x80\xa6BiEmit server reconnected event to frontendCfEnqueue a batch of already-queued video downloads, \xe2\x80\xa6ClOn-disk bytes for downloaded content, for the disk-usage \xe2\x80\xa6CkOn-disk usage of downloaded content, for the disk-usage \xe2\x80\xa6CnDissolve an entire LMS sync group, identified by its master\xe2\x80\xa60AlMark a download as completedCeHandle video playback ended from HTML5 video element.CkLegacy Jellyfin resume position in ticks. Being replaced byAgReport playback stoppedBmDisable Jellyfin automatic playback reportingBdGet current SmartCache configurationCiReport this platform\xe2\x80\x99s playback capabilities to the \xe2\x80\xa6ChThe on-disk path for a downloaded item, for playback \xe2\x80\xa6CjPlay a track from an album - backend fetches all album \xe2\x80\xa6CfPreload upcoming tracks from the queue This queues \xe2\x80\xa6AoUpdate SmartCache configurationBcReport playback stopped to Jellyfin8BjGet the root list of plugin \xe2\x80\x9cChannels\xe2\x80\x9dChResolve the current episode, fetching everything the \xe2\x80\xa6CjResolve the stream URL for every download row that was \xe2\x80\xa6CjCache the library (view) list from the server into the \xe2\x80\xa6CkControl whether offline library queries reveal the full \xe2\x80\xa6B`Get the active user for a serverCcGet people for an item (with person details joined)ChSet a user as active (and deactivate all other users \xe2\x80\xa6CmToggle favorite status for an item in local database This \xe2\x80\xa6CjWhether asking the server to serve this subtitle codec \xe2\x80\xa6BeAdvance to next episode in TV sessionBmResponse for audio track switching operationsClLimit concurrent image downloads to avoid saturating the \xe2\x80\xa6CkSeconds added per attempt before retrying a stream that \xe2\x80\xa6CnBelow this many seconds we treat the position as \xe2\x80\x9cat the \xe2\x80\xa6AgWrapper for Tauri stateCmEmitted after a drain so open views can re-read the queue \xe2\x80\xa6CeSession verification result event emitted to frontendCnTake ownership of a transcode this process did not build a \xe2\x80\xa6CkThreshold for album affinity (tracks played before caching)CgClaim a transcode identity for a stream about to be \xe2\x80\xa6CeBuild the Jellyfin endpoint for a favourites listing.CaBuild the Jellyfin endpoint for a folder listing.BiCheck if an item has a completed downloadDiDecide the MaxAudioChannels to advertise, given what the \xe2\x80\xa6BaConvert Jellyfin ticks to secondsCnTrue when the current item is a TV episode being played in \xe2\x80\xa6CgLog a backend-initialization failure and notify the \xe2\x80\xa6BjEmit connectivity change event to frontendBjEmit sleep timer changed event to frontendClQueue a watch position that could not be reported to the \xe2\x80\xa6CjFormat time in seconds to HH:MM:SS or MM:SS display stringChLibraries that contain at least one downloaded item. \xe2\x80\xa6ClLibraries that contain downloaded content (offline-only, \xe2\x80\xa6CiFavourites held locally, without touching the server. \xe2\x80\xa6CmIncrement autoplay episode counter. Returns true if limit \xe2\x80\xa6CnOne scheduler tick: check the preconditions, then index if \xe2\x80\xa6CcWrite the preferences to app_settings.CiStart a freshly-built queue on the active remote session.AhReport playback progressCiAdd multiple tracks to queue by IDs - backend fetches \xe2\x80\xa6ClExit background-audio mode: stop the native audio player \xe2\x80\xa6ClHandle playback ended event - triggers autoplay decision \xe2\x80\xa6BmPlay next episode (user confirmed from popup)DdWhere to re-open the current stream after a recoverable \xe2\x80\xa6BdReport playback progress to Jellyfin6CkEverything the viewer has favourited, across libraries, \xe2\x80\xa6AiGet image URL for an itemmGet librariesAhMark an item as favoriteCmResolve the on-disk file backing a completed download, if \xe2\x80\xa6CjRestore the persisted preferences at startup, into the \xe2\x80\xa6CdSet the connectivity monitor (for marking server \xe2\x80\xa6BdCheck if we should show video playerAoStart autoplay countdown threadBmGet user\xe2\x80\x99s access token from secure storageBeSave item-person associations (batch)CmMark TV show session as inactive (episode ended, awaiting \xe2\x80\xa6BmUpdate remaining seconds for time-based timerE`Whether the webview <video> element can decode this audio \xe2\x80\xa6ClValidate and clamp countdown seconds to reasonable range \xe2\x80\xa6CjDelay before the first staleness check, to let sign-in \xe2\x80\xa6CmSubtitle formats we can render ourselves, delivered as an \xe2\x80\xa6ClThe user asked to disconnect from the remote session and \xe2\x80\xa6CfHelper to convert CachedItem from storage to MediaItemB`Cancel active autoplay countdownBjCheck if the server is currently reachableCiConvert percentage volume (0-100) to normalized (0.0-1.0)CiGet album affinity status for all tracked albums This \xe2\x80\xa6BoGet album recommendations based on play historyCiFavourites straight from the server, persisted to the \xe2\x80\xa6AiGet recently played audioDjWrite the ceiling to app_settings. Failure is logged, not \xe2\x80\xa6AmGet the current playback modeBlDestroy playback reporter (called on logout)AbCancel sleep timerChConfigure Jellyfin API client for automatic playback \xe2\x80\xa6CmChange the bandwidth ceiling of the video that is playing \xe2\x80\xa6DiSet (or clear, with None) the active subtitle track on a \xe2\x80\xa6CkSwitch audio track - handles both HTML5 (stream reload) \xe2\x80\xa6AnSet volume on a remote sessionCmReport that the HTML5 element finished loading and knows \xe2\x80\xa6BmSend a throttled progress report to Jellyfin.CnQuery the optional JRay plugin for the actors on screen at \xe2\x80\xa6ClRestore the persisted bandwidth ceiling at startup, into \xe2\x80\xa6BlSet polling frequency hint based on UI stateCmRecord the base offset of a background-audio handoff (the \xe2\x80\xa6CgWire the connectivity reporter so each poll outcome \xe2\x80\xa6DdClassify a Jellyfin media-stream Type string into a \xe2\x80\xa6CmUpdate the stream URL of the current item (for transcoded \xe2\x80\xa6AoUpdate SmartCache configurationDgNormalise the equalizer band vector to exactly EQ_BANDS\xe2\x80\xa6BmWrapper for ConnectivityMonitor managed stateChMedia session manager wrapper for Tauri state managementChWrapper for PlaybackModeManager to manage in Tauri stateDhSerialise AudioSettings into the JSON payload handed to \xe2\x80\xa6CdThe position to report when a stream ends naturally.AoGet download manager statisticsBdGet storage statistics for downloadsCjTrue while the native audio player owns playback via a \xe2\x80\xa6CiReport that the HTML5 finished loading and knows its \xe2\x80\xa6AoToggle mute on a remote sessionAjUnmark an item as favoriteCfResolve the download URL for a video, applying the \xe2\x80\xa6CfSend volume command to remote session Commands: \xe2\x80\x9c\xe2\x80\xa6DfSet whether offline get_items includes non-downloaded \xe2\x80\xa6CnGet the active session (user + server + token) for session \xe2\x80\xa6CiWhether this stream can reach the app as a sidecar it \xe2\x80\xa6C`Read and clear the background-audio base offset.CnPush now-playing metadata to the Android lockscreen. No-op \xe2\x80\xa6CkRequest payload for download_item_and_start (bundled to \xe2\x80\xa6ClConsecutive resume attempts allowed at the same position \xe2\x80\xa6CnProduction hook: forwards to the Android JNI bridge; no-op \xe2\x80\xa6ChCountdown duration in seconds before auto-play (5-30 \xe2\x80\xa6CjBuild the Jellyfin endpoint for a \xe2\x80\x9crecently added\xe2\x80\x9d \xe2\x80\xa6CmMark the server as reachable (called after successful API \xe2\x80\xa6CaSet the server URL and trigger an immediate checkCnFetch next episode for a series by looking up the season\xe2\x80\x99\xe2\x80\xa6CfRe-point queued streaming items at completed local \xe2\x80\xa6AmGet latest items in a libraryBoGet resume items (continue watching/listening).BaGet subtitle URL for a media itemCdOpen a live stream for a Live TV channel / live itemChGet security status (keyring vs encrypted file fallback)CnUpper bound we are willing to claim. Jellyfin profiles top \xe2\x80\xa6CiPosition change that counts as \xe2\x80\x9cthis is a different \xe2\x80\xa6DdPerform the auto-advance for a ShowNextEpisodePopup \xe2\x80\xa6AlStop monitoring connectivityDhProbe (via gst-inspect-1.0, which ships with GStreamer) \xe2\x80\xa6BhTRACES: UR-040 | DR-052 | UT-061, IT-013AeGet autoplay settingsDcWould the player\xe2\x80\x99s own load-error retry restart this \xe2\x80\xa6BmSet autoplay settings and persist to databaseAmGet playback info for an itemAaGet resume moviesBjGet similar/related items for a media itemChCache playlist items from server into local database \xe2\x80\xa6BdSet the maximum concurrent downloadsCkMark the server as unreachable (called after failed API \xe2\x80\xa6CcStart monitoring connectivity with adaptive pollingCeDetermine the video seek strategy based on stream \xe2\x80\xa6CbGet total download size for a user (async version)CfCheck if currently transferring between playback modesCeEnter background-audio mode: hand playback of the \xe2\x80\xa6DkCore of resume_queued_downloads, factored out for testing: \xe2\x80\xa6BaGet playback progress for an itemCgSet the transferring flag on the playback mode manager.DiThe SubtitleStreamIndex to negotiate with: always \xe2\x80\x9cnone\xe2\x80\xa6CmThe bandwidth ceilings the quality picker may offer, each \xe2\x80\xa6BoErase the viewer\xe2\x80\x99s watch history for an item.BkGet items by person (actor, director, etc.)ClEvery episode of a series, across all seasons, in series \xe2\x80\xa6CmSet the base offset (seconds) added to positions reported \xe2\x80\xa6BoGet count of pending sync operations for a userClRewrite a stream URL so it asks for no subtitle, whoever \xe2\x80\xa6CnHow far short of the item\xe2\x80\x99s runtime a stream may end and \xe2\x80\xa6CiGet remote session status (for polling position/duration)CjTransfer playback from remote session back to local deviceB`Get audio stream URL for a trackClDownloaded-only browse: items under a container that are \xe2\x80\xa6CdGet Live TV channels (broadcast / IPTV) for browsingAeGet next up episodes.AgGet a video stream URL.CkUpdate playback progress with context in local database \xe2\x80\xa6BhThe folders the user may choose to hide.ClTransfer playback from local device to a remote Jellyfin \xe2\x80\xa6B`Cancel active autoplay countdownCiGet albums the user hasn\xe2\x80\x99t listened to recently (\xe2\x80\x9c\xe2\x80\xa6AeReport playback startCcRequeue video downloads that were fetched as audio.CnUpdate playback progress in local database This stores the \xe2\x80\xa6CkThe absolute position to re-open the current stream at, \xe2\x80\xa6BjGet video download URL with quality presetCgConvert a live playback position (seconds) into the \xe2\x80\xa6CkAdvance to the next episode while playing audio-only in \xe2\x80\xa6CfOn-disk usage of downloaded content (device total, \xe2\x80\xa6AgReport playback stoppedCjGet an audio-only stream URL for a video item \xe2\x80\xa6CaGet an audio-only stream URL for a video item \xe2\x80\xa6CmDownloaded-only browse: libraries that contain downloaded \xe2\x80\xa6AhReport playback progressBoGet user\xe2\x80\x99s preferred audio track for a seriesClWhether a subtitle in this format can reach the app as a \xe2\x80\xa6ClWhether the current network permits downloads, given the \xe2\x80\xa6CmConfigure GStreamer (the media backend behind WebKitGTK\xe2\x80\x99\xe2\x80\xa6AiGet recently played audioC`Save user\xe2\x80\x99s preferred audio track for a seriesEdGet an audio-only stream URL for a video item, for the \xe2\x80\xa6ChThe episode a viewer should land on when they open a \xe2\x80\xa6;") \ No newline at end of file diff --git a/api/search.index/entry/3cb798bbe5.js b/api/search.index/entry/3cb798bbe5.js new file mode 100644 index 00000000..d0bc847f --- /dev/null +++ b/api/search.index/entry/3cb798bbe5.js @@ -0,0 +1 @@ +rd_("An[5654,15,4855,4855,5973,0,0,0]An[5654,15,4568,4568,2991,0,0,0]An[5654,15,2049,2049,4831,0,0,0]Ba[5654,13,3059,3059,4149,3768,0,0]Ba[5654,13,3789,3789,5987,3768,0,0]Ba[5654,13,4779,4779,3020,3768,0,0]Ba[5654,13,4779,4779,4798,3768,0,0]Ba[5654,13,2002,2002,3762,3768,0,0]Ba[5654,13,2002,2002,4190,3768,0,0]Ba[5654,13,5155,5155,5448,3768,0,0]Ba[5654,13,2703,2703,4818,3768,0,0]Ba[5654,13,2703,2703,5451,3768,0,0]Ba[5654,13,5110,5110,3750,3768,0,0]Ba[5654,13,5660,5660,3022,3768,0,0]B`[5654,13,5660,5660,902,3768,0,0]Ba[5654,13,5816,5816,5455,3768,0,0]Ba[5654,13,2038,2038,3748,3768,0,0]Ba[5654,13,1534,1534,6629,3768,0,0]Ba[5654,13,1534,1534,4187,3768,0,0]Ba[5654,13,2727,2727,6371,3768,0,0]Ba[5654,13,2016,2016,4181,3768,0,0]Ba[5654,13,2016,2016,4790,3768,0,0]Ba[5654,13,6636,6636,6362,3768,0,0]Ba[5654,13,2003,2003,5462,3768,0,0]Ba[5654,13,2003,2003,5744,3768,0,0]Ba[5654,13,2003,2003,3759,3768,0,0]Ba[5654,13,2003,2003,3764,3768,0,0]Ba[5654,13,2003,2003,4813,3768,0,0]Ba[5654,13,5178,5178,5889,3768,0,0]Ba[5654,13,2049,2049,4150,3768,0,0]Ba[5654,13,2049,2049,2089,3768,0,0]Ba[5654,13,2049,2049,4831,3768,0,0]Ba[5654,13,5956,5956,4827,3768,0,0]Ba[5654,13,3153,3153,4836,3768,0,0]Ba[5654,13,3153,3153,2996,3768,0,0]Ba[5654,13,3153,3153,6384,3768,0,0]Ba[5654,13,3153,3153,5983,3768,0,0]Al[5654,14,911,911,4185,0,0,0]Ak[5654,14,911,911,908,0,0,0]Al[5654,14,911,911,6381,0,0,0]Al[5654,14,911,911,5444,0,0,0]An[5654,14,1548,1548,5747,0,0,0]An[5654,14,3059,3059,4149,0,0,0]An[5654,14,2506,2506,5986,0,0,0]An[5654,14,2506,2506,5988,0,0,0]An[5654,14,2739,2739,4186,0,0,0]An[5654,14,2739,2739,3030,0,0,0]An[5654,14,2739,2739,5696,0,0,0]An[5654,14,2739,2739,4153,0,0,0]An[5654,14,2501,2501,5436,0,0,0]An[5654,14,4779,4779,3771,0,0,0]An[5654,14,2690,2690,6739,0,0,0]An[5654,14,2708,2708,4821,0,0,0]An[5654,14,3058,3058,5438,0,0,0]An[5654,14,2349,2349,4832,0,0,0]An[5654,14,2349,2349,5880,0,0,0]An[5654,14,2003,2003,3759,0,0,0]An[5654,14,2711,2711,4154,0,0,0]An[5654,14,2049,2049,2559,0,0,0]An[5654,14,2049,2049,4150,0,0,0]An[5654,14,2049,2049,2089,0,0,0]An[5654,14,2049,2049,3760,0,0,0]An[5654,14,2049,2049,4814,0,0,0]An[5654,14,2049,2049,1797,0,0,0]An[5654,14,2049,2049,7120,0,0,0]An[5654,14,2499,2499,7178,0,0,0]An[5654,14,2499,2499,5443,0,0,0]An[5654,14,2499,2499,6841,0,0,0]An[5654,14,2496,2496,5457,0,0,0]Ba[5654,13,5655,5655,6361,3770,0,0]Ao[5654,13,911,911,4791,3770,0,0]Ao[5654,13,911,911,4185,3770,0,0]An[5654,13,911,911,908,3770,0,0]Ao[5654,13,911,911,4152,3770,0,0]Ao[5654,13,911,911,2577,3770,0,0]Ao[5654,13,911,911,6381,3770,0,0]Ao[5654,13,911,911,7383,3770,0,0]Ao[5654,13,911,911,5444,3770,0,0]Ba[5654,13,6569,6569,5999,3770,0,0]Ba[5654,13,6569,6569,7291,3770,0,0]Ba[5654,13,1548,1548,5747,3770,0,0]Ao[5654,13,912,912,6734,3770,0,0]Ao[5654,13,912,912,7185,3770,0,0]Ba[5654,13,5504,5504,7384,3770,0,0]Ba[5654,13,3059,3059,7180,3770,0,0]Ba[5654,13,3059,3059,6845,3770,0,0]Ba[5654,13,3059,3059,7182,3770,0,0]Ba[5654,13,3059,3059,5704,3770,0,0]Ba[5654,13,3059,3059,6597,3770,0,0]Ba[5654,13,3059,3059,7523,3770,0,0]Ba[5654,13,3059,3059,6840,3770,0,0]Ba[5654,13,3059,3059,7037,3770,0,0]Ba[5654,13,3059,3059,4149,3770,0,0]Ba[5654,13,3059,3059,5478,3770,0,0]Ba[5654,13,3059,3059,6355,3770,0,0]Ba[5654,13,3059,3059,7036,3770,0,0]Ba[5654,13,5179,5179,6004,3770,0,0]Ba[5654,13,5179,5179,6834,3770,0,0]Ba[5654,13,5179,5179,6833,3770,0,0]Ba[5654,13,5817,5817,7386,3770,0,0]Ba[5654,13,5817,5817,6849,3770,0,0]Ba[5654,13,6788,6788,7236,3770,0,0]Ba[5654,13,2506,2506,6623,3770,0,0]Ba[5654,13,2506,2506,6751,3770,0,0]Ba[5654,13,2506,2506,7385,3770,0,0]Ba[5654,13,2506,2506,7050,3770,0,0]Ba[5654,13,2506,2506,6836,3770,0,0]Ba[5654,13,2506,2506,5456,3770,0,0]Ba[5654,13,2506,2506,5986,3770,0,0]Ba[5654,13,2506,2506,4829,3770,0,0]Ba[5654,13,2506,2506,5479,3770,0,0]Ba[5654,13,2506,2506,5988,3770,0,0]Ba[5654,13,2506,2506,6375,3770,0,0]Ba[5654,13,2506,2506,6376,3770,0,0]Ba[5654,13,2506,2506,7119,3770,0,0]Ba[5654,13,2506,2506,6606,3770,0,0]Ba[5654,13,2506,2506,6605,3770,0,0]Ba[5654,13,2506,2506,6627,3770,0,0]Ba[5654,13,2506,2506,7283,3770,0,0]Ba[5654,13,2506,2506,7125,3770,0,0]Ba[5654,13,2506,2506,7041,3770,0,0]Ba[5654,13,2506,2506,5740,3770,0,0]Ba[5654,13,2012,2012,6589,3770,0,0]Ba[5654,13,2012,2012,6832,3770,0,0]Ba[5654,13,2012,2012,7112,3770,0,0]Ba[5654,13,4752,4752,6614,3770,0,0]Ba[5654,13,4752,4752,7288,3770,0,0]Ba[5654,13,4752,4752,6619,3770,0,0]Ba[5654,13,4752,4752,7114,3770,0,0]Ba[5654,13,2739,2739,5977,3770,0,0]Ba[5654,13,2739,2739,7179,3770,0,0]Ba[5654,13,2739,2739,7127,3770,0,0]Ba[5654,13,2739,2739,4186,3770,0,0]Ba[5654,13,2739,2739,3030,3770,0,0]Ba[5654,13,2739,2739,5693,3770,0,0]Ba[5654,13,2739,2739,5884,3770,0,0]Ba[5654,13,2739,2739,6377,3770,0,0]Ba[5654,13,2739,2739,5696,3770,0,0]Ba[5654,13,2739,2739,4153,3770,0,0]Ba[5654,13,2501,2501,5436,3770,0,0]Ba[5654,13,2501,2501,6363,3770,0,0]Ba[5654,13,5680,5680,7123,3770,0,0]Ba[5654,13,4782,4782,5980,3770,0,0]Ba[5654,13,5083,5083,6595,3770,0,0]Ba[5654,13,5083,5083,5978,3770,0,0]Ba[5654,13,2601,2601,5873,3770,0,0]Ba[5654,13,2601,2601,6592,3770,0,0]Ba[5654,13,2601,2601,6593,3770,0,0]Ba[5654,13,2601,2601,6594,3770,0,0]Ba[5654,13,2601,2601,6752,3770,0,0]Ba[5654,13,3789,3789,5987,3770,0,0]Ba[5654,13,4779,4779,4828,3770,0,0]Ba[5654,13,4779,4779,3020,3770,0,0]Ba[5654,13,4779,4779,4798,3770,0,0]Ba[5654,13,4779,4779,3771,3770,0,0]Ba[5654,13,2690,2690,6739,3770,0,0]Ba[5654,13,2708,2708,4821,3770,0,0]Ba[5654,13,3149,3149,7045,3770,0,0]Ba[5654,13,5503,5503,6838,3770,0,0]Ba[5654,13,5503,5503,6737,3770,0,0]Ba[5654,13,5503,5503,7232,3770,0,0]Ba[5654,13,5503,5503,7034,3770,0,0]Ba[5654,13,4855,4855,5974,3770,0,0]Ba[5654,13,4855,4855,6364,3770,0,0]Ba[5654,13,4855,4855,5973,3770,0,0]Ba[5654,13,2002,2002,3762,3770,0,0]Ba[5654,13,2002,2002,4190,3770,0,0]Ba[5654,13,5155,5155,5448,3770,0,0]Ba[5654,13,3058,3058,5979,3770,0,0]Ba[5654,13,3058,3058,5438,3770,0,0]Ba[5654,13,3058,3058,5439,3770,0,0]Ba[5654,13,1837,1837,4793,3770,0,0]Ba[5654,13,1837,1837,4188,3770,0,0]Ba[5654,13,2364,2364,5703,3770,0,0]Ba[5654,13,2703,2703,4818,3770,0,0]Ba[5654,13,2703,2703,5451,3770,0,0]Ba[5654,13,2703,2703,6747,3770,0,0]Ba[5654,13,2546,2546,5868,3770,0,0]Ba[5654,13,2546,2546,5866,3770,0,0]Ba[5654,13,2546,2546,5701,3770,0,0]Ba[5654,13,2349,2349,5875,3770,0,0]Ba[5654,13,2349,2349,5445,3770,0,0]Ba[5654,13,2349,2349,4832,3770,0,0]Ba[5654,13,2349,2349,5880,3770,0,0]Ba[5654,13,2349,2349,3769,3770,0,0]Ba[5654,13,5110,5110,4168,3770,0,0]Ba[5654,13,5110,5110,3750,3770,0,0]Ba[5654,13,5110,5110,4166,3770,0,0]Ba[5654,13,2048,2048,5876,3770,0,0]Ba[5654,13,2048,2048,7042,3770,0,0]Ba[5654,13,2048,2048,7184,3770,0,0]Ba[5654,13,2048,2048,7235,3770,0,0]Ba[5654,13,5660,5660,6743,3770,0,0]Ba[5654,13,5660,5660,4810,3770,0,0]Ba[5654,13,5660,5660,3022,3770,0,0]B`[5654,13,5660,5660,902,3770,0,0]Ba[5654,13,5816,5816,7525,3770,0,0]Ba[5654,13,5816,5816,6848,3770,0,0]Ba[5654,13,5816,5816,5455,3770,0,0]Ba[5654,13,3147,3147,6379,3770,0,0]Ba[5654,13,3147,3147,5990,3770,0,0]Ba[5654,13,3147,3147,6609,3770,0,0]Ba[5654,13,5967,5967,4193,3770,0,0]Ba[5654,13,5967,5967,5746,3770,0,0]Ba[5654,13,3158,3158,5870,3770,0,0]Ba[5654,13,2505,2505,5471,3770,0,0]Ba[5654,13,2505,2505,6380,3770,0,0]Ba[5654,13,2505,2505,6742,3770,0,0]Ba[5654,13,2038,2038,3748,3770,0,0]Ba[5654,13,2038,2038,4826,3770,0,0]Ba[5654,13,1534,1534,6629,3770,0,0]Ba[5654,13,1534,1534,4187,3770,0,0]Ba[5654,13,2598,2598,4820,3770,0,0]Ba[5654,13,2598,2598,4823,3770,0,0]Ba[5654,13,2366,2366,6625,3770,0,0]Ba[5654,13,2366,2366,6612,3770,0,0]Ba[5654,13,2727,2727,6844,3770,0,0]Ba[5654,13,2727,2727,6371,3770,0,0]Ba[5654,13,2016,2016,4181,3770,0,0]Ba[5654,13,2016,2016,5464,3770,0,0]Ba[5654,13,2016,2016,4790,3770,0,0]Ba[5654,13,6636,6636,6362,3770,0,0]Ba[5654,13,2003,2003,5462,3770,0,0]Ba[5654,13,2003,2003,5744,3770,0,0]Ba[5654,13,2003,2003,3759,3770,0,0]Ba[5654,13,2003,2003,3764,3770,0,0]Ba[5654,13,2003,2003,4813,3770,0,0]Ba[5654,13,3052,3052,6358,3770,0,0]Ba[5654,13,3052,3052,6360,3770,0,0]Ba[5654,13,5178,5178,5889,3770,0,0]Ba[5654,13,5178,5178,6003,3770,0,0]Ba[5654,13,4772,4772,5452,3770,0,0]Ba[5654,13,4772,4772,5741,3770,0,0]Ba[5654,13,5132,5132,4176,3770,0,0]Ba[5654,13,5132,5132,5707,3770,0,0]Ba[5654,13,2711,2711,6603,3770,0,0]Ba[5654,13,2711,2711,4154,3770,0,0]Ba[5654,13,2049,2049,3774,3770,0,0]Ba[5654,13,2049,2049,2559,3770,0,0]Ba[5654,13,2049,2049,3029,3770,0,0]Ba[5654,13,2049,2049,4150,3770,0,0]Ba[5654,13,2049,2049,2089,3770,0,0]Ba[5654,13,2049,2049,3760,3770,0,0]Ba[5654,13,2049,2049,4815,3770,0,0]Ba[5654,13,2049,2049,4814,3770,0,0]Ba[5654,13,2049,2049,5475,3770,0,0]Ba[5654,13,2049,2049,6596,3770,0,0]Ba[5654,13,2049,2049,5981,3770,0,0]Ba[5654,13,2049,2049,4831,3770,0,0]Ba[5654,13,2049,2049,5742,3770,0,0]Ba[5654,13,2049,2049,5453,3770,0,0]Ba[5654,13,2049,2049,5877,3770,0,0]Ba[5654,13,2049,2049,1797,3770,0,0]Ba[5654,13,2049,2049,3755,3770,0,0]Ba[5654,13,2049,2049,5442,3770,0,0]Ba[5654,13,2049,2049,5739,3770,0,0]Ba[5654,13,2049,2049,7120,3770,0,0]Ba[5654,13,2499,2499,6373,3770,0,0]Ba[5654,13,2499,2499,3757,3770,0,0]Ba[5654,13,2499,2499,4804,3770,0,0]Ba[5654,13,2499,2499,5708,3770,0,0]Ba[5654,13,2499,2499,7178,3770,0,0]Ba[5654,13,2499,2499,7121,3770,0,0]Ba[5654,13,2499,2499,7039,3770,0,0]Ba[5654,13,2499,2499,5443,3770,0,0]Ba[5654,13,2499,2499,6368,3770,0,0]Ba[5654,13,2499,2499,3754,3770,0,0]Ba[5654,13,2499,2499,6842,3770,0,0]Ba[5654,13,2499,2499,6841,3770,0,0]Ba[5654,13,2376,2376,6367,3770,0,0]Ba[5654,13,5956,5956,7044,3770,0,0]Ba[5654,13,5956,5956,4827,3770,0,0]Ba[5654,13,3153,3153,4836,3770,0,0]Ba[5654,13,3153,3153,2996,3770,0,0]Ba[5654,13,3153,3153,5695,3770,0,0]Ba[5654,13,3153,3153,6384,3770,0,0]Ba[5654,13,3153,3153,5748,3770,0,0]Ba[5654,13,3153,3153,5983,3770,0,0]Ba[5654,13,2738,2738,2989,3770,0,0]Ba[5654,13,4568,4568,4834,3770,0,0]Ba[5654,13,4568,4568,5995,3770,0,0]Ba[5654,13,4568,4568,1817,3770,0,0]Ba[5654,13,4568,4568,4179,3770,0,0]Ba[5654,13,2496,2496,5457,3770,0,0]Ba[5654,13,4133,4133,6852,3770,0,0]Ba[5654,13,1839,1839,5891,3770,0,0]Ba[5654,13,1839,1839,4794,3770,0,0]Ba[5654,13,2547,2547,6006,3770,0,0]Ba[5654,13,2547,2547,7186,3770,0,0]An[5654,19,3153,3153,2996,0,0,0]An[5654,19,3153,3153,6384,0,0,0]An[5654,15,2016,2016,4181,0,0,0]An[5654,15,2049,2049,4831,0,0,0]An[5654,15,2016,2016,4790,0,0,0]An[5654,15,4568,4568,2991,0,0,0]An[5654,15,4568,4568,4179,0,0,0]An[5654,15,2499,2499,3754,0,0,0]5An[5654,15,5178,5178,5889,0,0,0]6An[5654,15,3153,3153,2996,0,0,0]An[5654,13,2016,2016,5464,0,0,0]Ba[5654,13,5655,5655,6361,3770,0,0]Ao[5654,13,911,911,4791,3770,0,0]Ao[5654,13,911,911,4185,3770,0,0]An[5654,13,911,911,908,3770,0,0]Ao[5654,13,911,911,4152,3770,0,0]Ao[5654,13,911,911,2577,3770,0,0]Ao[5654,13,911,911,6381,3770,0,0]Ao[5654,13,911,911,7383,3770,0,0]Ao[5654,13,911,911,5444,3770,0,0]Ba[5654,13,6569,6569,5999,3770,0,0]Ba[5654,13,6569,6569,7291,3770,0,0]Ba[5654,13,1548,1548,5747,3770,0,0]Ao[5654,13,912,912,6734,3770,0,0]Ao[5654,13,912,912,7185,3770,0,0]Ba[5654,13,5504,5504,7384,3770,0,0]Ba[5654,13,3059,3059,7180,3770,0,0]Ba[5654,13,3059,3059,6845,3770,0,0]Ba[5654,13,3059,3059,7182,3770,0,0]Ba[5654,13,3059,3059,5704,3770,0,0]Ba[5654,13,3059,3059,6597,3770,0,0]Ba[5654,13,3059,3059,7523,3770,0,0]Ba[5654,13,3059,3059,6840,3770,0,0]Ba[5654,13,3059,3059,7037,3770,0,0]Ba[5654,13,3059,3059,4149,3770,0,0]Ba[5654,13,3059,3059,5478,3770,0,0]Ba[5654,13,3059,3059,6355,3770,0,0]Ba[5654,13,3059,3059,7036,3770,0,0]Ba[5654,13,5179,5179,6004,3770,0,0]Ba[5654,13,5179,5179,6834,3770,0,0]Ba[5654,13,5179,5179,6833,3770,0,0]Ba[5654,13,5817,5817,7386,3770,0,0]Ba[5654,13,5817,5817,6849,3770,0,0]Ba[5654,13,6788,6788,7236,3770,0,0]Ba[5654,13,2506,2506,6623,3770,0,0]Ba[5654,13,2506,2506,6751,3770,0,0]Ba[5654,13,2506,2506,7385,3770,0,0]Ba[5654,13,2506,2506,7050,3770,0,0]Ba[5654,13,2506,2506,6836,3770,0,0]Ba[5654,13,2506,2506,5456,3770,0,0]Ba[5654,13,2506,2506,5986,3770,0,0]Ba[5654,13,2506,2506,4829,3770,0,0]Ba[5654,13,2506,2506,5479,3770,0,0]Ba[5654,13,2506,2506,5988,3770,0,0]Ba[5654,13,2506,2506,6375,3770,0,0]Ba[5654,13,2506,2506,6376,3770,0,0]Ba[5654,13,2506,2506,7119,3770,0,0]Ba[5654,13,2506,2506,6606,3770,0,0]Ba[5654,13,2506,2506,6605,3770,0,0]Ba[5654,13,2506,2506,6627,3770,0,0]Ba[5654,13,2506,2506,7283,3770,0,0]Ba[5654,13,2506,2506,7125,3770,0,0]Ba[5654,13,2506,2506,7041,3770,0,0]Ba[5654,13,2506,2506,5740,3770,0,0]Ba[5654,13,2012,2012,6589,3770,0,0]Ba[5654,13,2012,2012,6832,3770,0,0]Ba[5654,13,2012,2012,7112,3770,0,0]Ba[5654,13,4752,4752,6614,3770,0,0]Ba[5654,13,4752,4752,7288,3770,0,0]Ba[5654,13,4752,4752,6619,3770,0,0]Ba[5654,13,4752,4752,7114,3770,0,0]Ba[5654,13,2739,2739,5977,3770,0,0]Ba[5654,13,2739,2739,7179,3770,0,0]Ba[5654,13,2739,2739,7127,3770,0,0]Ba[5654,13,2739,2739,4186,3770,0,0]Ba[5654,13,2739,2739,3030,3770,0,0]Ba[5654,13,2739,2739,5693,3770,0,0]Ba[5654,13,2739,2739,5884,3770,0,0]Ba[5654,13,2739,2739,6377,3770,0,0]Ba[5654,13,2739,2739,5696,3770,0,0]Ba[5654,13,2739,2739,4153,3770,0,0]Ba[5654,13,2501,2501,5436,3770,0,0]Ba[5654,13,2501,2501,6363,3770,0,0]Ba[5654,13,5680,5680,7123,3770,0,0]Ba[5654,13,4782,4782,5980,3770,0,0]Ba[5654,13,5083,5083,6595,3770,0,0]Ba[5654,13,5083,5083,5978,3770,0,0]Ba[5654,13,2601,2601,5873,3770,0,0]Ba[5654,13,2601,2601,6592,3770,0,0]Ba[5654,13,2601,2601,6593,3770,0,0]Ba[5654,13,2601,2601,6594,3770,0,0]Ba[5654,13,2601,2601,6752,3770,0,0]Ba[5654,13,3789,3789,5987,3770,0,0]Ba[5654,13,4779,4779,4828,3770,0,0]Ba[5654,13,4779,4779,3020,3770,0,0]Ba[5654,13,4779,4779,4798,3770,0,0]Ba[5654,13,4779,4779,3771,3770,0,0]Ba[5654,13,2690,2690,6739,3770,0,0]Ba[5654,13,2708,2708,4821,3770,0,0]Ba[5654,13,3149,3149,7045,3770,0,0]Ba[5654,13,5503,5503,6838,3770,0,0]Ba[5654,13,5503,5503,6737,3770,0,0]Ba[5654,13,5503,5503,7232,3770,0,0]Ba[5654,13,5503,5503,7034,3770,0,0]Ba[5654,13,4855,4855,5974,3770,0,0]Ba[5654,13,4855,4855,6364,3770,0,0]Ba[5654,13,4855,4855,5973,3770,0,0]Ba[5654,13,2002,2002,3762,3770,0,0]Ba[5654,13,2002,2002,4190,3770,0,0]Ba[5654,13,5155,5155,5448,3770,0,0]Ba[5654,13,3058,3058,5979,3770,0,0]Ba[5654,13,3058,3058,5438,3770,0,0]Ba[5654,13,3058,3058,5439,3770,0,0]Ba[5654,13,1837,1837,4793,3770,0,0]Ba[5654,13,1837,1837,4188,3770,0,0]Ba[5654,13,2364,2364,5703,3770,0,0]Ba[5654,13,2703,2703,4818,3770,0,0]Ba[5654,13,2703,2703,5451,3770,0,0]Ba[5654,13,2703,2703,6747,3770,0,0]Ba[5654,13,2546,2546,5868,3770,0,0]Ba[5654,13,2546,2546,5866,3770,0,0]Ba[5654,13,2546,2546,5701,3770,0,0]Ba[5654,13,2349,2349,5875,3770,0,0]Ba[5654,13,2349,2349,5445,3770,0,0]Ba[5654,13,2349,2349,4832,3770,0,0]Ba[5654,13,2349,2349,5880,3770,0,0]Ba[5654,13,2349,2349,3769,3770,0,0]Ba[5654,13,5110,5110,4168,3770,0,0]Ba[5654,13,5110,5110,3750,3770,0,0]Ba[5654,13,5110,5110,4166,3770,0,0]Ba[5654,13,2048,2048,5876,3770,0,0]Ba[5654,13,2048,2048,7042,3770,0,0]Ba[5654,13,2048,2048,7184,3770,0,0]Ba[5654,13,2048,2048,7235,3770,0,0]Ba[5654,13,5660,5660,6743,3770,0,0]Ba[5654,13,5660,5660,4810,3770,0,0]Ba[5654,13,5660,5660,3022,3770,0,0]B`[5654,13,5660,5660,902,3770,0,0]Ba[5654,13,5816,5816,7525,3770,0,0]Ba[5654,13,5816,5816,6848,3770,0,0]Ba[5654,13,5816,5816,5455,3770,0,0]Ba[5654,13,3147,3147,6379,3770,0,0]Ba[5654,13,3147,3147,5990,3770,0,0]Ba[5654,13,3147,3147,6609,3770,0,0]Ba[5654,13,5967,5967,4193,3770,0,0]Ba[5654,13,5967,5967,5746,3770,0,0]Ba[5654,13,3158,3158,5870,3770,0,0]Ba[5654,13,2505,2505,5471,3770,0,0]Ba[5654,13,2505,2505,6380,3770,0,0]Ba[5654,13,2505,2505,6742,3770,0,0]Ba[5654,13,2038,2038,3748,3770,0,0]Ba[5654,13,2038,2038,4826,3770,0,0]Ba[5654,13,1534,1534,6629,3770,0,0]Ba[5654,13,1534,1534,4187,3770,0,0]Ba[5654,13,2598,2598,4820,3770,0,0]Ba[5654,13,2598,2598,4823,3770,0,0]Ba[5654,13,2366,2366,6625,3770,0,0]Ba[5654,13,2366,2366,6612,3770,0,0]Ba[5654,13,2727,2727,6844,3770,0,0]Ba[5654,13,2727,2727,6371,3770,0,0]Ba[5654,13,2016,2016,4181,3770,0,0]Ba[5654,13,2016,2016,5464,3770,0,0]Ba[5654,13,2016,2016,4790,3770,0,0]Ba[5654,13,6636,6636,6362,3770,0,0]Ba[5654,13,2003,2003,5462,3770,0,0]Ba[5654,13,2003,2003,5744,3770,0,0]Ba[5654,13,2003,2003,3759,3770,0,0]Ba[5654,13,2003,2003,3764,3770,0,0]Ba[5654,13,2003,2003,4813,3770,0,0]Ba[5654,13,3052,3052,6358,3770,0,0]Ba[5654,13,3052,3052,6360,3770,0,0]Ba[5654,13,5178,5178,5889,3770,0,0]Ba[5654,13,5178,5178,6003,3770,0,0]Ba[5654,13,4772,4772,5452,3770,0,0]Ba[5654,13,4772,4772,5741,3770,0,0]Ba[5654,13,5132,5132,4176,3770,0,0]Ba[5654,13,5132,5132,5707,3770,0,0]Ba[5654,13,2711,2711,6603,3770,0,0]Ba[5654,13,2711,2711,4154,3770,0,0]Ba[5654,13,2049,2049,3774,3770,0,0]Ba[5654,13,2049,2049,2559,3770,0,0]Ba[5654,13,2049,2049,3029,3770,0,0]Ba[5654,13,2049,2049,4150,3770,0,0]Ba[5654,13,2049,2049,2089,3770,0,0]Ba[5654,13,2049,2049,3760,3770,0,0]Ba[5654,13,2049,2049,4815,3770,0,0]Ba[5654,13,2049,2049,4814,3770,0,0]Ba[5654,13,2049,2049,5475,3770,0,0]Ba[5654,13,2049,2049,6596,3770,0,0]Ba[5654,13,2049,2049,5981,3770,0,0]Ba[5654,13,2049,2049,4831,3770,0,0]Ba[5654,13,2049,2049,5742,3770,0,0]Ba[5654,13,2049,2049,5453,3770,0,0]Ba[5654,13,2049,2049,5877,3770,0,0]Ba[5654,13,2049,2049,1797,3770,0,0]Ba[5654,13,2049,2049,3755,3770,0,0]Ba[5654,13,2049,2049,5442,3770,0,0]Ba[5654,13,2049,2049,5739,3770,0,0]Ba[5654,13,2049,2049,7120,3770,0,0]Ba[5654,13,2499,2499,6373,3770,0,0]Ba[5654,13,2499,2499,3757,3770,0,0]Ba[5654,13,2499,2499,4804,3770,0,0]Ba[5654,13,2499,2499,5708,3770,0,0]Ba[5654,13,2499,2499,7178,3770,0,0]Ba[5654,13,2499,2499,7121,3770,0,0]Ba[5654,13,2499,2499,7039,3770,0,0]Ba[5654,13,2499,2499,5443,3770,0,0]Ba[5654,13,2499,2499,6368,3770,0,0]Ba[5654,13,2499,2499,3754,3770,0,0]Ba[5654,13,2499,2499,6842,3770,0,0]Ba[5654,13,2499,2499,6841,3770,0,0]Ba[5654,13,2376,2376,6367,3770,0,0]Ba[5654,13,5956,5956,7044,3770,0,0]Ba[5654,13,5956,5956,4827,3770,0,0]Ba[5654,13,3153,3153,4836,3770,0,0]Ba[5654,13,3153,3153,2996,3770,0,0]Ba[5654,13,3153,3153,5695,3770,0,0]Ba[5654,13,3153,3153,6384,3770,0,0]Ba[5654,13,3153,3153,5748,3770,0,0]Ba[5654,13,3153,3153,5983,3770,0,0]Ba[5654,13,2738,2738,2989,3770,0,0]Ba[5654,13,4568,4568,4834,3770,0,0]Ba[5654,13,4568,4568,5995,3770,0,0]Ba[5654,13,4568,4568,1817,3770,0,0]Ba[5654,13,4568,4568,4179,3770,0,0]Ba[5654,13,2496,2496,5457,3770,0,0]Ba[5654,13,4133,4133,6852,3770,0,0]Ba[5654,13,1839,1839,5891,3770,0,0]Ba[5654,13,1839,1839,4794,3770,0,0]Ba[5654,13,2547,2547,6006,3770,0,0]Ba[5654,13,2547,2547,7186,3770,0,0]B`[5654,13,5155,5155,5448,311,0,0]Am[5654,14,5660,5660,902,0,0,0]Ao[5654,13,911,911,4185,1785,0,0]An[5654,13,911,911,908,1785,0,0]Ao[5654,13,911,911,4152,1785,0,0]Ao[5654,13,911,911,2577,1785,0,0]Ao[5654,13,911,911,6381,1785,0,0]Ao[5654,13,911,911,7383,1785,0,0]Ao[5654,13,911,911,5444,1785,0,0]Ba[5654,13,6569,6569,7291,1785,0,0]Ba[5654,13,1548,1548,5747,1785,0,0]Ba[5654,13,3059,3059,7182,1785,0,0]Ba[5654,13,3059,3059,5704,1785,0,0]Ba[5654,13,3059,3059,6597,1785,0,0]Ba[5654,13,3059,3059,7523,1785,0,0]Ba[5654,13,3059,3059,6840,1785,0,0]Ba[5654,13,3059,3059,7037,1785,0,0]Ba[5654,13,3059,3059,4149,1785,0,0]Ba[5654,13,3059,3059,5478,1785,0,0]Ba[5654,13,3059,3059,6355,1785,0,0]Ba[5654,13,3059,3059,7036,1785,0,0]Ba[5654,13,5179,5179,6004,1785,0,0]Ba[5654,13,5179,5179,6834,1785,0,0]Ba[5654,13,5179,5179,6833,1785,0,0]Ba[5654,13,2506,2506,5456,1785,0,0]Ba[5654,13,2506,2506,5986,1785,0,0]Ba[5654,13,2506,2506,4829,1785,0,0]Ba[5654,13,2506,2506,5479,1785,0,0]Ba[5654,13,2506,2506,5988,1785,0,0]Ba[5654,13,2506,2506,6375,1785,0,0]Ba[5654,13,2506,2506,6376,1785,0,0]Ba[5654,13,2506,2506,7119,1785,0,0]Ba[5654,13,2506,2506,6606,1785,0,0]Ba[5654,13,2506,2506,6605,1785,0,0]Ba[5654,13,2506,2506,6627,1785,0,0]Ba[5654,13,2506,2506,7283,1785,0,0]Ba[5654,13,2506,2506,7125,1785,0,0]Ba[5654,13,2506,2506,7041,1785,0,0]Ba[5654,13,2506,2506,5740,1785,0,0]Ba[5654,13,2012,2012,6589,1785,0,0]Ba[5654,13,2012,2012,6832,1785,0,0]Ba[5654,13,2012,2012,7112,1785,0,0]Ba[5654,13,4752,4752,6619,1785,0,0]Ba[5654,13,4752,4752,7114,1785,0,0]Ba[5654,13,2739,2739,4186,1785,0,0]Ba[5654,13,2739,2739,3030,1785,0,0]Ba[5654,13,2739,2739,5693,1785,0,0]Ba[5654,13,2739,2739,5884,1785,0,0]Ba[5654,13,2739,2739,6377,1785,0,0]Ba[5654,13,2739,2739,5696,1785,0,0]Ba[5654,13,2739,2739,4153,1785,0,0]Ba[5654,13,2501,2501,5436,1785,0,0]Ba[5654,13,2501,2501,6363,1785,0,0]Ba[5654,13,5680,5680,7123,1785,0,0]Ba[5654,13,4782,4782,5980,1785,0,0]Ba[5654,13,5083,5083,6595,1785,0,0]Ba[5654,13,5083,5083,5978,1785,0,0]Ba[5654,13,2601,2601,6592,1785,0,0]Ba[5654,13,2601,2601,6593,1785,0,0]Ba[5654,13,2601,2601,6594,1785,0,0]Ba[5654,13,2601,2601,6752,1785,0,0]Ba[5654,13,3789,3789,5987,1785,0,0]Ba[5654,13,4779,4779,3020,1785,0,0]Ba[5654,13,4779,4779,4798,1785,0,0]Ba[5654,13,4779,4779,3771,1785,0,0]Ba[5654,13,2690,2690,6739,1785,0,0]Ba[5654,13,2708,2708,4821,1785,0,0]Ba[5654,13,5503,5503,6737,1785,0,0]Ba[5654,13,5503,5503,7232,1785,0,0]Ba[5654,13,4855,4855,6364,1785,0,0]Db[5654,13,4855,4855,5973,1785,0,0,\"impl-Debug-for-CredentialError\"]Dd[5654,13,4855,4855,5973,2552,0,0,\"impl-Display-for-CredentialError\"]Ba[5654,13,2002,2002,3762,1785,0,0]Ba[5654,13,2002,2002,4190,1785,0,0]Ba[5654,13,5155,5155,5448,1785,0,0]Ba[5654,13,3058,3058,5438,1785,0,0]Ba[5654,13,3058,3058,5439,1785,0,0]Ba[5654,13,1837,1837,4793,1785,0,0]Ba[5654,13,2364,2364,5703,1785,0,0]Ba[5654,13,2703,2703,4818,1785,0,0]Ba[5654,13,2703,2703,5451,1785,0,0]Ba[5654,13,2546,2546,5866,1785,0,0]Db[5654,13,2546,2546,5701,2552,0,0,\"impl-Display-for-DownloadError\"]D`[5654,13,2546,2546,5701,1785,0,0,\"impl-Debug-for-DownloadError\"]Ba[5654,13,2349,2349,5445,1785,0,0]Ba[5654,13,2349,2349,4832,1785,0,0]Ba[5654,13,2349,2349,5880,1785,0,0]Ba[5654,13,2349,2349,3769,1785,0,0]Ba[5654,13,5110,5110,4168,1785,0,0]Ba[5654,13,5110,5110,3750,1785,0,0]Ba[5654,13,2048,2048,5876,1785,0,0]Ba[5654,13,2048,2048,7042,1785,0,0]Ba[5654,13,2048,2048,7184,1785,0,0]Ba[5654,13,2048,2048,7235,1785,0,0]Ba[5654,13,5660,5660,3022,1785,0,0]B`[5654,13,5660,5660,902,1785,0,0]Ba[5654,13,5816,5816,5455,1785,0,0]Ba[5654,13,3147,3147,5990,1785,0,0]Ba[5654,13,3147,3147,6609,1785,0,0]Ba[5654,13,2505,2505,6742,1785,0,0]Ba[5654,13,2038,2038,3748,1785,0,0]Ba[5654,13,2038,2038,4826,1785,0,0]Ba[5654,13,1534,1534,6629,1785,0,0]Ba[5654,13,1534,1534,4187,1785,0,0]D`[5654,13,2598,2598,4823,2552,0,0,\"impl-Display-for-PlayerError\"]Cn[5654,13,2598,2598,4823,1785,0,0,\"impl-Debug-for-PlayerError\"]Ba[5654,13,2366,2366,6612,1785,0,0]Ba[5654,13,2727,2727,6371,1785,0,0]Ba[5654,13,2016,2016,4181,1785,0,0]Ba[5654,13,2016,2016,5464,1785,0,0]Ba[5654,13,2016,2016,4790,1785,0,0]Ba[5654,13,6636,6636,6362,1785,0,0]Ba[5654,13,2003,2003,5462,1785,0,0]Ba[5654,13,2003,2003,5744,1785,0,0]Ba[5654,13,2003,2003,3759,1785,0,0]Ba[5654,13,2003,2003,3764,1785,0,0]Ba[5654,13,2003,2003,4813,1785,0,0]Ba[5654,13,3052,3052,6358,1785,0,0]Ba[5654,13,3052,3052,6360,1785,0,0]Ba[5654,13,5178,5178,5889,1785,0,0]Ba[5654,13,5178,5178,6003,1785,0,0]Ba[5654,13,4772,4772,5452,1785,0,0]Ba[5654,13,4772,4772,5741,1785,0,0]Ba[5654,13,2711,2711,4154,1785,0,0]Cl[5654,13,2049,2049,3774,1785,0,0,\"impl-Debug-for-RepoError\"]Cn[5654,13,2049,2049,3774,2552,0,0,\"impl-Display-for-RepoError\"]Ba[5654,13,2049,2049,2559,1785,0,0]Ba[5654,13,2049,2049,3029,1785,0,0]Ba[5654,13,2049,2049,4150,1785,0,0]Ba[5654,13,2049,2049,2089,1785,0,0]Ba[5654,13,2049,2049,3760,1785,0,0]Ba[5654,13,2049,2049,4815,1785,0,0]Ba[5654,13,2049,2049,4814,1785,0,0]Ba[5654,13,2049,2049,5475,1785,0,0]Ba[5654,13,2049,2049,6596,1785,0,0]Ba[5654,13,2049,2049,5981,1785,0,0]Ba[5654,13,2049,2049,4831,1785,0,0]Ba[5654,13,2049,2049,5742,1785,0,0]Ba[5654,13,2049,2049,5453,1785,0,0]Ba[5654,13,2049,2049,5877,1785,0,0]Ba[5654,13,2049,2049,1797,1785,0,0]Ba[5654,13,2049,2049,3755,1785,0,0]Ba[5654,13,2049,2049,5442,1785,0,0]Ba[5654,13,2049,2049,5739,1785,0,0]Ba[5654,13,2049,2049,7120,1785,0,0]Ba[5654,13,2499,2499,3757,1785,0,0]Ba[5654,13,2499,2499,4804,1785,0,0]Ba[5654,13,2499,2499,5708,1785,0,0]Ba[5654,13,2499,2499,7178,1785,0,0]Ba[5654,13,2499,2499,7121,1785,0,0]Ba[5654,13,2499,2499,7039,1785,0,0]Ba[5654,13,2499,2499,5443,1785,0,0]Ba[5654,13,2499,2499,6368,1785,0,0]Ba[5654,13,2499,2499,3754,1785,0,0]Ba[5654,13,2499,2499,6842,1785,0,0]Ba[5654,13,2499,2499,6841,1785,0,0]Ba[5654,13,5956,5956,4827,1785,0,0]Ba[5654,13,3153,3153,4836,1785,0,0]Ba[5654,13,3153,3153,2996,1785,0,0]Ba[5654,13,3153,3153,5695,1785,0,0]Ba[5654,13,3153,3153,6384,1785,0,0]Ba[5654,13,3153,3153,5748,1785,0,0]Ba[5654,13,3153,3153,5983,1785,0,0]Ba[5654,13,4568,4568,4179,1785,0,0]Ba[5654,13,2496,2496,5457,1785,0,0]Ba[5654,13,4133,4133,6852,1785,0,0]Ba[5654,13,1839,1839,4794,1785,0,0]Di[5654,13,2547,2547,7186,1785,0,0,\"impl-Debug-for-ThumbnailDownloadError\"]Dk[5654,13,2547,2547,7186,2552,0,0,\"impl-Display-for-ThumbnailDownloadError\"]An[5654,13,4752,4752,6614,0,0,0]An[5654,13,2703,2703,6747,0,0,0]An[5654,13,2349,2349,5875,0,0,0]Am[5654,13,5660,5660,902,0,0,0]An[5654,14,5132,5132,4176,0,0,0]Al[5654,13,911,911,4791,0,0,0]An[5654,13,6569,6569,5999,0,0,0]6An[5654,13,5503,5503,6838,0,0,0]An[5654,13,5503,5503,7034,0,0,0]An[5654,13,4855,4855,5974,0,0,0]An[5654,13,3058,3058,5979,0,0,0]An[5654,13,1837,1837,4188,0,0,0]:An[5654,13,2546,2546,5868,0,0,0]:An[5654,13,5110,5110,4166,0,0,0]An[5654,13,5816,5816,6848,0,0,0]An[5654,13,3147,3147,6379,0,0,0]An[5654,13,5967,5967,5746,0,0,0]An[5654,13,3158,3158,5870,0,0,0]An[5654,13,2505,2505,6380,0,0,0]An[5654,13,2598,2598,4820,0,0,0]An[5654,13,2366,2366,6625,0,0,0]An[5654,13,2727,2727,6844,0,0,0]An[5654,13,2016,2016,5464,0,0,0]An[5654,13,5132,5132,4176,0,0,0]An[5654,13,2711,2711,6603,0,0,0]An[5654,13,2049,2049,2559,0,0,0]An[5654,13,2499,2499,6373,0,0,0]An[5654,13,2376,2376,6367,0,0,0]An[5654,13,5956,5956,7044,0,0,0]An[5654,13,4568,4568,4834,0,0,0]An[5654,13,4568,4568,5995,0,0,0]An[5654,13,4568,4568,1817,0,0,0]An[5654,13,1839,1839,5891,0,0,0]An[5654,13,2547,2547,6006,0,0,0]Ag[5654,7,5655,0,0,0,0,0]An[5654,13,2703,2703,6747,0,0,0]Ao[5654,13,911,911,4185,3766,0,0]An[5654,13,911,911,908,3766,0,0]Ao[5654,13,911,911,4152,3766,0,0]Ao[5654,13,911,911,2577,3766,0,0]Ao[5654,13,911,911,6381,3766,0,0]Ao[5654,13,911,911,7383,3766,0,0]Ao[5654,13,911,911,5444,3766,0,0]Ba[5654,13,1548,1548,5747,3766,0,0]Ba[5654,13,3059,3059,7182,3766,0,0]Ba[5654,13,3059,3059,5704,3766,0,0]Ba[5654,13,3059,3059,6597,3766,0,0]Ba[5654,13,3059,3059,7523,3766,0,0]Ba[5654,13,3059,3059,6840,3766,0,0]Ba[5654,13,3059,3059,7037,3766,0,0]Ba[5654,13,3059,3059,5478,3766,0,0]Ba[5654,13,3059,3059,6355,3766,0,0]Ba[5654,13,3059,3059,7036,3766,0,0]Ba[5654,13,5179,5179,6004,3766,0,0]Ba[5654,13,5179,5179,6834,3766,0,0]Ba[5654,13,5179,5179,6833,3766,0,0]Ba[5654,13,5817,5817,6849,3766,0,0]Ba[5654,13,2506,2506,5456,3766,0,0]Ba[5654,13,2506,2506,5986,3766,0,0]Ba[5654,13,2506,2506,4829,3766,0,0]Ba[5654,13,2506,2506,5479,3766,0,0]Ba[5654,13,2506,2506,5988,3766,0,0]Ba[5654,13,2506,2506,6375,3766,0,0]Ba[5654,13,2506,2506,6376,3766,0,0]Ba[5654,13,2506,2506,7119,3766,0,0]Ba[5654,13,2506,2506,6606,3766,0,0]Ba[5654,13,2506,2506,6605,3766,0,0]Ba[5654,13,2506,2506,6627,3766,0,0]Ba[5654,13,2506,2506,7283,3766,0,0]Ba[5654,13,2506,2506,7125,3766,0,0]Ba[5654,13,2506,2506,7041,3766,0,0]Ba[5654,13,2506,2506,5740,3766,0,0]Ba[5654,13,2012,2012,6589,3766,0,0]Ba[5654,13,2012,2012,6832,3766,0,0]Ba[5654,13,2012,2012,7112,3766,0,0]Ba[5654,13,4752,4752,6619,3766,0,0]Ba[5654,13,4752,4752,7114,3766,0,0]Ba[5654,13,2739,2739,4186,3766,0,0]Ba[5654,13,2739,2739,3030,3766,0,0]Ba[5654,13,2739,2739,5693,3766,0,0]Ba[5654,13,2739,2739,5884,3766,0,0]Ba[5654,13,2739,2739,6377,3766,0,0]Ba[5654,13,2739,2739,5696,3766,0,0]Ba[5654,13,2739,2739,4153,3766,0,0]Ba[5654,13,2501,2501,5436,3766,0,0]Ba[5654,13,2501,2501,6363,3766,0,0]Ba[5654,13,5680,5680,7123,3766,0,0]Ba[5654,13,4782,4782,5980,3766,0,0]Ba[5654,13,5083,5083,6595,3766,0,0]Ba[5654,13,5083,5083,5978,3766,0,0]Ba[5654,13,2601,2601,6592,3766,0,0]Ba[5654,13,2601,2601,6593,3766,0,0]Ba[5654,13,2601,2601,6594,3766,0,0]Ba[5654,13,2601,2601,6752,3766,0,0]Ba[5654,13,4779,4779,4798,3766,0,0]Ba[5654,13,2690,2690,6739,3766,0,0]Ba[5654,13,2708,2708,4821,3766,0,0]Ba[5654,13,5503,5503,6737,3766,0,0]Ba[5654,13,5503,5503,7232,3766,0,0]Ba[5654,13,2002,2002,3762,3766,0,0]Ba[5654,13,2002,2002,4190,3766,0,0]Ba[5654,13,3058,3058,5438,3766,0,0]Ba[5654,13,1837,1837,4793,3766,0,0]Ba[5654,13,2703,2703,4818,3766,0,0]Ba[5654,13,2703,2703,5451,3766,0,0]Ba[5654,13,2349,2349,5445,3766,0,0]Ba[5654,13,2349,2349,4832,3766,0,0]Ba[5654,13,2349,2349,5880,3766,0,0]Ba[5654,13,2349,2349,3769,3766,0,0]Ba[5654,13,5816,5816,5455,3766,0,0]Ba[5654,13,2038,2038,3748,3766,0,0]Ba[5654,13,2038,2038,4826,3766,0,0]Ba[5654,13,2366,2366,6612,3766,0,0]Ba[5654,13,2727,2727,6371,3766,0,0]Ba[5654,13,2016,2016,4181,3766,0,0]Ba[5654,13,2016,2016,5464,3766,0,0]Ba[5654,13,6636,6636,6362,3766,0,0]Ba[5654,13,2003,2003,5462,3766,0,0]Ba[5654,13,2003,2003,5744,3766,0,0]Ba[5654,13,2003,2003,3759,3766,0,0]Ba[5654,13,2003,2003,3764,3766,0,0]Ba[5654,13,2003,2003,4813,3766,0,0]Ba[5654,13,3052,3052,6358,3766,0,0]Ba[5654,13,3052,3052,6360,3766,0,0]Ba[5654,13,5178,5178,5889,3766,0,0]Ba[5654,13,5178,5178,6003,3766,0,0]Ba[5654,13,2049,2049,3774,3766,0,0]Ba[5654,13,2049,2049,2559,3766,0,0]Ba[5654,13,2049,2049,3029,3766,0,0]Ba[5654,13,2049,2049,4150,3766,0,0]Ba[5654,13,2049,2049,2089,3766,0,0]Ba[5654,13,2049,2049,3760,3766,0,0]Ba[5654,13,2049,2049,4815,3766,0,0]Ba[5654,13,2049,2049,4814,3766,0,0]Ba[5654,13,2049,2049,5475,3766,0,0]Ba[5654,13,2049,2049,6596,3766,0,0]Ba[5654,13,2049,2049,5981,3766,0,0]Ba[5654,13,2049,2049,4831,3766,0,0]Ba[5654,13,2049,2049,5742,3766,0,0]Ba[5654,13,2049,2049,5453,3766,0,0]Ba[5654,13,2049,2049,5877,3766,0,0]Ba[5654,13,2049,2049,1797,3766,0,0]Ba[5654,13,2049,2049,3755,3766,0,0]Ba[5654,13,2049,2049,5442,3766,0,0]Ba[5654,13,2049,2049,5739,3766,0,0]Ba[5654,13,2049,2049,7120,3766,0,0]Ba[5654,13,2499,2499,3757,3766,0,0]Ba[5654,13,3153,3153,4836,3766,0,0]Ba[5654,13,3153,3153,2996,3766,0,0]Ba[5654,13,3153,3153,5695,3766,0,0]Ba[5654,13,3153,3153,6384,3766,0,0]Ba[5654,13,3153,3153,5748,3766,0,0]Ba[5654,13,3153,3153,5983,3766,0,0]Ba[5654,13,4133,4133,6852,3766,0,0]An[5654,14,4568,4568,1817,0,0,0]An[5654,14,4782,4782,5980,0,0,0]An[5654,14,2049,2049,5442,0,0,0]An[5654,14,2739,2739,4186,0,0,0]An[5654,14,3058,3058,5439,0,0,0]Ak[5654,14,6610,0,6389,0,0,0]An[5654,14,2003,2003,5744,0,0,0]Ak[5654,14,4811,0,3745,0,0,0]An[5654,15,4568,4568,4179,0,0,0]An[5654,15,3153,3153,2996,0,0,0]An[5654,15,2546,2546,5701,0,0,0]An[5654,15,2547,2547,7186,0,0,0]An[5654,15,5816,5816,5455,0,0,0]An[5654,15,2038,2038,4826,0,0,0]An[5654,15,2727,2727,6371,0,0,0]5An[5654,15,2049,2049,3755,0,0,0]An[5654,15,3153,3153,4836,0,0,0]An[5654,15,2016,2016,4790,0,0,0]An[5654,15,5155,5155,5448,0,0,0]An[5654,15,2703,2703,4818,0,0,0];:Ag[5654,5,5660,0,0,0,0,0]An[5654,15,3052,3052,6358,0,0,0]An[5654,15,5178,5178,5889,0,0,0]Af[5654,5,911,0,0,0,0,0]4Ag[5654,2,5655,0,0,0,0,0]Ag[5654,2,3055,0,0,0,0,0]Ba[5654,13,5655,5655,6361,4803,0,0]Ao[5654,13,911,911,4185,4803,0,0]An[5654,13,911,911,908,4803,0,0]Ao[5654,13,911,911,4152,4803,0,0]Ao[5654,13,911,911,2577,4803,0,0]Ba[5654,13,6569,6569,7291,4803,0,0]Ba[5654,13,1548,1548,5747,4803,0,0]Ba[5654,13,3059,3059,7182,4803,0,0]Ba[5654,13,3059,3059,5704,4803,0,0]Ba[5654,13,3059,3059,6597,4803,0,0]Ba[5654,13,3059,3059,5478,4803,0,0]Ba[5654,13,3059,3059,6355,4803,0,0]Ba[5654,13,3059,3059,7036,4803,0,0]Ba[5654,13,5179,5179,6004,4803,0,0]Ba[5654,13,5179,5179,6834,4803,0,0]Ba[5654,13,5179,5179,6833,4803,0,0]Ba[5654,13,5817,5817,6849,4803,0,0]Ba[5654,13,2506,2506,5456,4803,0,0]Ba[5654,13,2506,2506,5986,4803,0,0]Ba[5654,13,2506,2506,4829,4803,0,0]Ba[5654,13,2506,2506,5479,4803,0,0]Ba[5654,13,2506,2506,6627,4803,0,0]Ba[5654,13,2506,2506,7283,4803,0,0]Ba[5654,13,2506,2506,7125,4803,0,0]Ba[5654,13,2506,2506,7041,4803,0,0]Ba[5654,13,2506,2506,5740,4803,0,0]Ba[5654,13,4752,4752,6619,4803,0,0]Ba[5654,13,4752,4752,7114,4803,0,0]Ba[5654,13,2739,2739,4186,4803,0,0]Ba[5654,13,2739,2739,3030,4803,0,0]Ba[5654,13,2739,2739,5693,4803,0,0]Ba[5654,13,2739,2739,5884,4803,0,0]Ba[5654,13,2739,2739,6377,4803,0,0]Ba[5654,13,2739,2739,5696,4803,0,0]Ba[5654,13,2739,2739,4153,4803,0,0]Ba[5654,13,2501,2501,5436,4803,0,0]Ba[5654,13,2501,2501,6363,4803,0,0]Ba[5654,13,5680,5680,7123,4803,0,0]Ba[5654,13,5083,5083,6595,4803,0,0]Ba[5654,13,5083,5083,5978,4803,0,0]Ba[5654,13,2601,2601,6592,4803,0,0]Ba[5654,13,2601,2601,6593,4803,0,0]Ba[5654,13,2601,2601,6594,4803,0,0]Ba[5654,13,2601,2601,6752,4803,0,0]Ba[5654,13,4779,4779,4798,4803,0,0]Ba[5654,13,2690,2690,6739,4803,0,0]Ba[5654,13,2708,2708,4821,4803,0,0]Ba[5654,13,5503,5503,6737,4803,0,0]Ba[5654,13,5503,5503,7232,4803,0,0]Ba[5654,13,2002,2002,3762,4803,0,0]Ba[5654,13,2002,2002,4190,4803,0,0]Ba[5654,13,3058,3058,5438,4803,0,0]Ba[5654,13,1837,1837,4793,4803,0,0]Ba[5654,13,2364,2364,5703,4803,0,0]Ba[5654,13,2703,2703,4818,4803,0,0]Ba[5654,13,2703,2703,5451,4803,0,0]Ba[5654,13,2349,2349,5445,4803,0,0]Ba[5654,13,2349,2349,4832,4803,0,0]Ba[5654,13,2349,2349,5880,4803,0,0]Ba[5654,13,2349,2349,3769,4803,0,0]Ba[5654,13,2048,2048,7042,4803,0,0]Ba[5654,13,2048,2048,7184,4803,0,0]Ba[5654,13,2048,2048,7235,4803,0,0]Ba[5654,13,5816,5816,5455,4803,0,0]Ba[5654,13,2038,2038,3748,4803,0,0]Ba[5654,13,2038,2038,4826,4803,0,0]Ba[5654,13,2366,2366,6612,4803,0,0]Ba[5654,13,2727,2727,6371,4803,0,0]Ba[5654,13,2016,2016,4181,4803,0,0]Ba[5654,13,2016,2016,5464,4803,0,0]Ba[5654,13,6636,6636,6362,4803,0,0]Ba[5654,13,2003,2003,5462,4803,0,0]Ba[5654,13,2003,2003,5744,4803,0,0]Ba[5654,13,2003,2003,3759,4803,0,0]Ba[5654,13,2003,2003,3764,4803,0,0]Ba[5654,13,2003,2003,4813,4803,0,0]Ba[5654,13,3052,3052,6358,4803,0,0]Ba[5654,13,3052,3052,6360,4803,0,0]Ba[5654,13,5178,5178,5889,4803,0,0]Ba[5654,13,5178,5178,6003,4803,0,0]Ba[5654,13,2049,2049,3774,4803,0,0]Ba[5654,13,2049,2049,2559,4803,0,0]Ba[5654,13,2049,2049,3029,4803,0,0]Ba[5654,13,2049,2049,4150,4803,0,0]Ba[5654,13,2049,2049,2089,4803,0,0]Ba[5654,13,2049,2049,3760,4803,0,0]Ba[5654,13,2049,2049,4815,4803,0,0]Ba[5654,13,2049,2049,4814,4803,0,0]Ba[5654,13,2049,2049,5475,4803,0,0]Ba[5654,13,2049,2049,6596,4803,0,0]Ba[5654,13,2049,2049,5981,4803,0,0]Ba[5654,13,2049,2049,4831,4803,0,0]Ba[5654,13,2049,2049,5742,4803,0,0]Ba[5654,13,2049,2049,5453,4803,0,0]Ba[5654,13,2049,2049,5877,4803,0,0]Ba[5654,13,2049,2049,1797,4803,0,0]Ba[5654,13,2049,2049,3755,4803,0,0]Ba[5654,13,2049,2049,5442,4803,0,0]Ba[5654,13,2049,2049,5739,4803,0,0]Ba[5654,13,2049,2049,7120,4803,0,0]Ba[5654,13,2499,2499,3757,4803,0,0]Ba[5654,13,3153,3153,4836,4803,0,0]Ba[5654,13,3153,3153,2996,4803,0,0]Ba[5654,13,3153,3153,5695,4803,0,0]Ba[5654,13,3153,3153,6384,4803,0,0]Ba[5654,13,3153,3153,5748,4803,0,0]Ba[5654,13,3153,3153,5983,4803,0,0]Ba[5654,13,2496,2496,5457,4803,0,0]Ba[5654,13,4133,4133,6852,4803,0,0]An[5654,14,2738,2738,2989,0,0,0]An[5654,14,4568,4568,4834,0,0,0]An[5654,14,4568,4568,5995,0,0,0]B`[5654,13,2601,2601,5873,883,0,0]B`[5654,13,5132,5132,4176,883,0,0]B`[5654,13,5956,5956,7044,883,0,0]An[5654,12,2366,2366,6750,0,0,0]Ba[5654,13,2366,2366,6625,6750,0,0]B`[5654,13,5655,5655,6361,885,0,0]An[5654,13,911,911,4791,885,0,0]An[5654,13,911,911,4185,885,0,0]Am[5654,13,911,911,908,885,0,0]An[5654,13,911,911,4152,885,0,0]An[5654,13,911,911,2577,885,0,0]An[5654,13,911,911,6381,885,0,0]An[5654,13,911,911,7383,885,0,0]An[5654,13,911,911,5444,885,0,0]B`[5654,13,6569,6569,5999,885,0,0]B`[5654,13,6569,6569,7291,885,0,0]B`[5654,13,1548,1548,5747,885,0,0]An[5654,13,912,912,6734,885,0,0]An[5654,13,912,912,7185,885,0,0]B`[5654,13,5504,5504,7384,885,0,0]B`[5654,13,3059,3059,7180,885,0,0]B`[5654,13,3059,3059,6845,885,0,0]B`[5654,13,3059,3059,7182,885,0,0]B`[5654,13,3059,3059,5704,885,0,0]B`[5654,13,3059,3059,6597,885,0,0]B`[5654,13,3059,3059,7523,885,0,0]B`[5654,13,3059,3059,6840,885,0,0]B`[5654,13,3059,3059,7037,885,0,0]B`[5654,13,3059,3059,4149,885,0,0]0B`[5654,13,3059,3059,5478,885,0,0]B`[5654,13,3059,3059,6355,885,0,0]B`[5654,13,3059,3059,7036,885,0,0]B`[5654,13,5179,5179,6004,885,0,0]B`[5654,13,5179,5179,6834,885,0,0]B`[5654,13,5179,5179,6833,885,0,0]B`[5654,13,5817,5817,7386,885,0,0]B`[5654,13,5817,5817,6849,885,0,0]B`[5654,13,6788,6788,7236,885,0,0]B`[5654,13,2506,2506,6623,885,0,0]B`[5654,13,2506,2506,6751,885,0,0]B`[5654,13,2506,2506,7385,885,0,0]B`[5654,13,2506,2506,7050,885,0,0]B`[5654,13,2506,2506,6836,885,0,0]B`[5654,13,2506,2506,5456,885,0,0]B`[5654,13,2506,2506,5986,885,0,0]Eg[5654,13,2506,2506,5986,885,0,0,\"impl-From%3C%26NowPlayingItem%3E-for-MergedMediaItem\"]Eb[5654,13,2506,2506,5986,885,0,0,\"impl-From%3C%26MediaItem%3E-for-MergedMediaItem\"]B`[5654,13,2506,2506,4829,885,0,0]B`[5654,13,2506,2506,5479,885,0,0]B`[5654,13,2506,2506,5988,885,0,0]B`[5654,13,2506,2506,6375,885,0,0]B`[5654,13,2506,2506,6376,885,0,0]B`[5654,13,2506,2506,7119,885,0,0]B`[5654,13,2506,2506,6606,885,0,0]B`[5654,13,2506,2506,6605,885,0,0]B`[5654,13,2506,2506,6627,885,0,0]B`[5654,13,2506,2506,7283,885,0,0]B`[5654,13,2506,2506,7125,885,0,0]B`[5654,13,2506,2506,7041,885,0,0]B`[5654,13,2506,2506,5740,885,0,0]B`[5654,13,2012,2012,6589,885,0,0]B`[5654,13,2012,2012,6832,885,0,0]B`[5654,13,2012,2012,7112,885,0,0]B`[5654,13,4752,4752,6614,885,0,0]B`[5654,13,4752,4752,7288,885,0,0]B`[5654,13,4752,4752,6619,885,0,0]B`[5654,13,4752,4752,7114,885,0,0]B`[5654,13,2739,2739,5977,885,0,0]B`[5654,13,2739,2739,7179,885,0,0]B`[5654,13,2739,2739,7127,885,0,0]B`[5654,13,2739,2739,4186,885,0,0]B`[5654,13,2739,2739,3030,885,0,0]B`[5654,13,2739,2739,5693,885,0,0]B`[5654,13,2739,2739,5884,885,0,0]B`[5654,13,2739,2739,6377,885,0,0]B`[5654,13,2739,2739,5696,885,0,0]B`[5654,13,2739,2739,4153,885,0,0]B`[5654,13,2501,2501,5436,885,0,0]B`[5654,13,2501,2501,6363,885,0,0]B`[5654,13,5680,5680,7123,885,0,0]B`[5654,13,4782,4782,5980,885,0,0]B`[5654,13,5083,5083,6595,885,0,0]B`[5654,13,5083,5083,5978,885,0,0]B`[5654,13,2601,2601,5873,885,0,0]B`[5654,13,2601,2601,6592,885,0,0]B`[5654,13,2601,2601,6593,885,0,0]B`[5654,13,2601,2601,6594,885,0,0]B`[5654,13,2601,2601,6752,885,0,0]B`[5654,13,3789,3789,5987,885,0,0]B`[5654,13,4779,4779,4828,885,0,0]B`[5654,13,4779,4779,3020,885,0,0]B`[5654,13,4779,4779,4798,885,0,0]B`[5654,13,4779,4779,3771,885,0,0]B`[5654,13,2690,2690,6739,885,0,0]B`[5654,13,2708,2708,4821,885,0,0]B`[5654,13,3149,3149,7045,885,0,0]B`[5654,13,5503,5503,6838,885,0,0]B`[5654,13,5503,5503,6737,885,0,0]B`[5654,13,5503,5503,7232,885,0,0]B`[5654,13,5503,5503,7034,885,0,0]B`[5654,13,4855,4855,5974,885,0,0]B`[5654,13,4855,4855,6364,885,0,0]B`[5654,13,4855,4855,5973,885,0,0]B`[5654,13,2002,2002,3762,885,0,0]B`[5654,13,2002,2002,4190,885,0,0]B`[5654,13,5155,5155,5448,885,0,0]B`[5654,13,3058,3058,5979,885,0,0]B`[5654,13,3058,3058,5438,885,0,0]B`[5654,13,3058,3058,5439,885,0,0]B`[5654,13,1837,1837,4793,885,0,0]B`[5654,13,1837,1837,4188,885,0,0]B`[5654,13,2364,2364,5703,885,0,0]B`[5654,13,2703,2703,4818,885,0,0]B`[5654,13,2703,2703,5451,885,0,0]B`[5654,13,2703,2703,6747,885,0,0]B`[5654,13,2546,2546,5868,885,0,0]B`[5654,13,2546,2546,5866,885,0,0]B`[5654,13,2546,2546,5701,885,0,0]B`[5654,13,2349,2349,5875,885,0,0]B`[5654,13,2349,2349,5445,885,0,0]B`[5654,13,2349,2349,4832,885,0,0]B`[5654,13,2349,2349,5880,885,0,0]B`[5654,13,2349,2349,3769,885,0,0]B`[5654,13,5110,5110,4168,885,0,0]B`[5654,13,5110,5110,3750,885,0,0]B`[5654,13,5110,5110,4166,885,0,0]B`[5654,13,2048,2048,5876,885,0,0]B`[5654,13,2048,2048,7042,885,0,0]B`[5654,13,2048,2048,7184,885,0,0]B`[5654,13,2048,2048,7235,885,0,0]B`[5654,13,5660,5660,6743,885,0,0]B`[5654,13,5660,5660,4810,885,0,0]B`[5654,13,5660,5660,3022,885,0,0]Ao[5654,13,5660,5660,902,885,0,0]B`[5654,13,5816,5816,7525,885,0,0]B`[5654,13,5816,5816,6848,885,0,0]B`[5654,13,5816,5816,5455,885,0,0]B`[5654,13,3147,3147,6379,885,0,0]B`[5654,13,3147,3147,5990,885,0,0]B`[5654,13,3147,3147,6609,885,0,0]B`[5654,13,5967,5967,4193,885,0,0]B`[5654,13,5967,5967,5746,885,0,0]B`[5654,13,3158,3158,5870,885,0,0]B`[5654,13,2505,2505,5471,885,0,0]B`[5654,13,2505,2505,6380,885,0,0]B`[5654,13,2505,2505,6742,885,0,0]B`[5654,13,2038,2038,3748,885,0,0]B`[5654,13,2038,2038,4826,885,0,0]B`[5654,13,1534,1534,6629,885,0,0]B`[5654,13,1534,1534,4187,885,0,0]B`[5654,13,2598,2598,4820,885,0,0]B`[5654,13,2598,2598,4823,885,0,0]B`[5654,13,2366,2366,6625,885,0,0]B`[5654,13,2366,2366,6612,885,0,0]B`[5654,13,2727,2727,6844,885,0,0]B`[5654,13,2727,2727,6371,885,0,0]B`[5654,13,2016,2016,4181,885,0,0]B`[5654,13,2016,2016,5464,885,0,0]B`[5654,13,2016,2016,4790,885,0,0]B`[5654,13,6636,6636,6362,885,0,0]B`[5654,13,2003,2003,5462,885,0,0]B`[5654,13,2003,2003,5744,885,0,0]B`[5654,13,2003,2003,3759,885,0,0]B`[5654,13,2003,2003,3764,885,0,0]B`[5654,13,2003,2003,4813,885,0,0]B`[5654,13,3052,3052,6358,885,0,0]B`[5654,13,3052,3052,6360,885,0,0]B`[5654,13,5178,5178,5889,885,0,0]B`[5654,13,5178,5178,6003,885,0,0]B`[5654,13,4772,4772,5452,885,0,0]B`[5654,13,4772,4772,5741,885,0,0]B`[5654,13,5132,5132,4176,885,0,0]B`[5654,13,5132,5132,5707,885,0,0]B`[5654,13,2711,2711,6603,885,0,0]B`[5654,13,2711,2711,4154,885,0,0]B`[5654,13,2049,2049,3774,885,0,0]B`[5654,13,2049,2049,2559,885,0,0]B`[5654,13,2049,2049,3029,885,0,0]0B`[5654,13,2049,2049,4150,885,0,0]B`[5654,13,2049,2049,2089,885,0,0]B`[5654,13,2049,2049,3760,885,0,0]B`[5654,13,2049,2049,4815,885,0,0]B`[5654,13,2049,2049,4814,885,0,0]B`[5654,13,2049,2049,5475,885,0,0]B`[5654,13,2049,2049,6596,885,0,0]B`[5654,13,2049,2049,5981,885,0,0]B`[5654,13,2049,2049,4831,885,0,0]B`[5654,13,2049,2049,5742,885,0,0]B`[5654,13,2049,2049,5453,885,0,0]B`[5654,13,2049,2049,5877,885,0,0]B`[5654,13,2049,2049,1797,885,0,0]B`[5654,13,2049,2049,3755,885,0,0]B`[5654,13,2049,2049,5442,885,0,0]B`[5654,13,2049,2049,5739,885,0,0]B`[5654,13,2049,2049,7120,885,0,0]B`[5654,13,2499,2499,6373,885,0,0]B`[5654,13,2499,2499,3757,885,0,0]B`[5654,13,2499,2499,4804,885,0,0]B`[5654,13,2499,2499,5708,885,0,0]B`[5654,13,2499,2499,7178,885,0,0]B`[5654,13,2499,2499,7121,885,0,0]B`[5654,13,2499,2499,7039,885,0,0]B`[5654,13,2499,2499,5443,885,0,0]B`[5654,13,2499,2499,6368,885,0,0]B`[5654,13,2499,2499,3754,885,0,0]B`[5654,13,2499,2499,6842,885,0,0]B`[5654,13,2499,2499,6841,885,0,0]B`[5654,13,2376,2376,6367,885,0,0]B`[5654,13,5956,5956,7044,885,0,0]B`[5654,13,5956,5956,4827,885,0,0]B`[5654,13,3153,3153,4836,885,0,0]B`[5654,13,3153,3153,2996,885,0,0]B`[5654,13,3153,3153,5695,885,0,0]B`[5654,13,3153,3153,6384,885,0,0]B`[5654,13,3153,3153,5748,885,0,0]B`[5654,13,3153,3153,5983,885,0,0]B`[5654,13,2738,2738,2989,885,0,0]B`[5654,13,4568,4568,4834,885,0,0]B`[5654,13,4568,4568,5995,885,0,0]B`[5654,13,4568,4568,1817,885,0,0]B`[5654,13,4568,4568,4179,885,0,0]B`[5654,13,2496,2496,5457,885,0,0]B`[5654,13,4133,4133,6852,885,0,0]B`[5654,13,1839,1839,5891,885,0,0]B`[5654,13,1839,1839,4794,885,0,0]B`[5654,13,2547,2547,6006,885,0,0]B`[5654,13,2547,2547,7186,885,0,0]B`[5654,13,5655,5655,6361,891,0,0]An[5654,13,911,911,4791,891,0,0]An[5654,13,911,911,4185,891,0,0]Am[5654,13,911,911,908,891,0,0]An[5654,13,911,911,4152,891,0,0]An[5654,13,911,911,2577,891,0,0]An[5654,13,911,911,6381,891,0,0]An[5654,13,911,911,7383,891,0,0]An[5654,13,911,911,5444,891,0,0]B`[5654,13,6569,6569,5999,891,0,0]B`[5654,13,6569,6569,7291,891,0,0]B`[5654,13,1548,1548,5747,891,0,0]An[5654,13,912,912,6734,891,0,0]An[5654,13,912,912,7185,891,0,0]B`[5654,13,5504,5504,7384,891,0,0]B`[5654,13,3059,3059,7180,891,0,0]B`[5654,13,3059,3059,6845,891,0,0]B`[5654,13,3059,3059,7182,891,0,0]B`[5654,13,3059,3059,5704,891,0,0]B`[5654,13,3059,3059,6597,891,0,0]B`[5654,13,3059,3059,7523,891,0,0]B`[5654,13,3059,3059,6840,891,0,0]B`[5654,13,3059,3059,7037,891,0,0]B`[5654,13,3059,3059,4149,891,0,0]B`[5654,13,3059,3059,5478,891,0,0]B`[5654,13,3059,3059,6355,891,0,0]B`[5654,13,3059,3059,7036,891,0,0]B`[5654,13,5179,5179,6004,891,0,0]B`[5654,13,5179,5179,6834,891,0,0]B`[5654,13,5179,5179,6833,891,0,0]B`[5654,13,5817,5817,7386,891,0,0]B`[5654,13,5817,5817,6849,891,0,0]B`[5654,13,6788,6788,7236,891,0,0]B`[5654,13,2506,2506,6623,891,0,0]B`[5654,13,2506,2506,6751,891,0,0]B`[5654,13,2506,2506,7385,891,0,0]B`[5654,13,2506,2506,7050,891,0,0]B`[5654,13,2506,2506,6836,891,0,0]B`[5654,13,2506,2506,5456,891,0,0]B`[5654,13,2506,2506,5986,891,0,0]B`[5654,13,2506,2506,4829,891,0,0]B`[5654,13,2506,2506,5479,891,0,0]B`[5654,13,2506,2506,5988,891,0,0]B`[5654,13,2506,2506,6375,891,0,0]B`[5654,13,2506,2506,6376,891,0,0]B`[5654,13,2506,2506,7119,891,0,0]B`[5654,13,2506,2506,6606,891,0,0]B`[5654,13,2506,2506,6605,891,0,0]B`[5654,13,2506,2506,6627,891,0,0]B`[5654,13,2506,2506,7283,891,0,0]B`[5654,13,2506,2506,7125,891,0,0]B`[5654,13,2506,2506,7041,891,0,0]B`[5654,13,2506,2506,5740,891,0,0]B`[5654,13,2012,2012,6589,891,0,0]B`[5654,13,2012,2012,6832,891,0,0]B`[5654,13,2012,2012,7112,891,0,0]B`[5654,13,4752,4752,6614,891,0,0]B`[5654,13,4752,4752,7288,891,0,0]B`[5654,13,4752,4752,6619,891,0,0]B`[5654,13,4752,4752,7114,891,0,0]B`[5654,13,2739,2739,5977,891,0,0]B`[5654,13,2739,2739,7179,891,0,0]B`[5654,13,2739,2739,7127,891,0,0]B`[5654,13,2739,2739,4186,891,0,0]B`[5654,13,2739,2739,3030,891,0,0]B`[5654,13,2739,2739,5693,891,0,0]B`[5654,13,2739,2739,5884,891,0,0]B`[5654,13,2739,2739,6377,891,0,0]B`[5654,13,2739,2739,5696,891,0,0]B`[5654,13,2739,2739,4153,891,0,0]B`[5654,13,2501,2501,5436,891,0,0]B`[5654,13,2501,2501,6363,891,0,0]B`[5654,13,5680,5680,7123,891,0,0]B`[5654,13,4782,4782,5980,891,0,0]B`[5654,13,5083,5083,6595,891,0,0]B`[5654,13,5083,5083,5978,891,0,0]B`[5654,13,2601,2601,5873,891,0,0]B`[5654,13,2601,2601,6592,891,0,0]B`[5654,13,2601,2601,6593,891,0,0]B`[5654,13,2601,2601,6594,891,0,0]B`[5654,13,2601,2601,6752,891,0,0]B`[5654,13,3789,3789,5987,891,0,0]B`[5654,13,4779,4779,4828,891,0,0]B`[5654,13,4779,4779,3020,891,0,0]B`[5654,13,4779,4779,4798,891,0,0]B`[5654,13,4779,4779,3771,891,0,0]B`[5654,13,2690,2690,6739,891,0,0]B`[5654,13,2708,2708,4821,891,0,0]B`[5654,13,3149,3149,7045,891,0,0]B`[5654,13,5503,5503,6838,891,0,0]B`[5654,13,5503,5503,6737,891,0,0]B`[5654,13,5503,5503,7232,891,0,0]B`[5654,13,5503,5503,7034,891,0,0]B`[5654,13,4855,4855,5974,891,0,0]B`[5654,13,4855,4855,6364,891,0,0]B`[5654,13,4855,4855,5973,891,0,0]B`[5654,13,2002,2002,3762,891,0,0]B`[5654,13,2002,2002,4190,891,0,0]B`[5654,13,5155,5155,5448,891,0,0]B`[5654,13,3058,3058,5979,891,0,0]B`[5654,13,3058,3058,5438,891,0,0]B`[5654,13,3058,3058,5439,891,0,0]B`[5654,13,1837,1837,4793,891,0,0]B`[5654,13,1837,1837,4188,891,0,0]B`[5654,13,2364,2364,5703,891,0,0]B`[5654,13,2703,2703,4818,891,0,0]B`[5654,13,2703,2703,5451,891,0,0]B`[5654,13,2703,2703,6747,891,0,0]B`[5654,13,2546,2546,5868,891,0,0]B`[5654,13,2546,2546,5866,891,0,0]B`[5654,13,2546,2546,5701,891,0,0]B`[5654,13,2349,2349,5875,891,0,0]B`[5654,13,2349,2349,5445,891,0,0]B`[5654,13,2349,2349,4832,891,0,0]B`[5654,13,2349,2349,5880,891,0,0]B`[5654,13,2349,2349,3769,891,0,0]B`[5654,13,5110,5110,4168,891,0,0]B`[5654,13,5110,5110,3750,891,0,0]B`[5654,13,5110,5110,4166,891,0,0]B`[5654,13,2048,2048,5876,891,0,0]B`[5654,13,2048,2048,7042,891,0,0]B`[5654,13,2048,2048,7184,891,0,0]B`[5654,13,2048,2048,7235,891,0,0]B`[5654,13,5660,5660,6743,891,0,0]B`[5654,13,5660,5660,4810,891,0,0]B`[5654,13,5660,5660,3022,891,0,0]Ao[5654,13,5660,5660,902,891,0,0]B`[5654,13,5816,5816,7525,891,0,0]B`[5654,13,5816,5816,6848,891,0,0]B`[5654,13,5816,5816,5455,891,0,0]B`[5654,13,3147,3147,6379,891,0,0]B`[5654,13,3147,3147,5990,891,0,0]B`[5654,13,3147,3147,6609,891,0,0]B`[5654,13,5967,5967,4193,891,0,0]B`[5654,13,5967,5967,5746,891,0,0]B`[5654,13,3158,3158,5870,891,0,0]B`[5654,13,2505,2505,5471,891,0,0]B`[5654,13,2505,2505,6380,891,0,0]B`[5654,13,2505,2505,6742,891,0,0]B`[5654,13,2038,2038,3748,891,0,0]B`[5654,13,2038,2038,4826,891,0,0]B`[5654,13,1534,1534,6629,891,0,0]B`[5654,13,1534,1534,4187,891,0,0]B`[5654,13,2598,2598,4820,891,0,0]B`[5654,13,2598,2598,4823,891,0,0]B`[5654,13,2366,2366,6625,891,0,0]B`[5654,13,2366,2366,6612,891,0,0]B`[5654,13,2727,2727,6844,891,0,0]B`[5654,13,2727,2727,6371,891,0,0]B`[5654,13,2016,2016,4181,891,0,0]B`[5654,13,2016,2016,5464,891,0,0]B`[5654,13,2016,2016,4790,891,0,0]B`[5654,13,6636,6636,6362,891,0,0]B`[5654,13,2003,2003,5462,891,0,0]B`[5654,13,2003,2003,5744,891,0,0]B`[5654,13,2003,2003,3759,891,0,0]B`[5654,13,2003,2003,3764,891,0,0]B`[5654,13,2003,2003,4813,891,0,0]B`[5654,13,3052,3052,6358,891,0,0]B`[5654,13,3052,3052,6360,891,0,0]B`[5654,13,5178,5178,5889,891,0,0]B`[5654,13,5178,5178,6003,891,0,0]B`[5654,13,4772,4772,5452,891,0,0]B`[5654,13,4772,4772,5741,891,0,0]B`[5654,13,5132,5132,4176,891,0,0]B`[5654,13,5132,5132,5707,891,0,0]B`[5654,13,2711,2711,6603,891,0,0]B`[5654,13,2711,2711,4154,891,0,0]B`[5654,13,2049,2049,3774,891,0,0]B`[5654,13,2049,2049,2559,891,0,0]B`[5654,13,2049,2049,3029,891,0,0]B`[5654,13,2049,2049,4150,891,0,0]B`[5654,13,2049,2049,2089,891,0,0]B`[5654,13,2049,2049,3760,891,0,0]B`[5654,13,2049,2049,4815,891,0,0]B`[5654,13,2049,2049,4814,891,0,0]B`[5654,13,2049,2049,5475,891,0,0]B`[5654,13,2049,2049,6596,891,0,0]B`[5654,13,2049,2049,5981,891,0,0]B`[5654,13,2049,2049,4831,891,0,0]B`[5654,13,2049,2049,5742,891,0,0]B`[5654,13,2049,2049,5453,891,0,0]B`[5654,13,2049,2049,5877,891,0,0]B`[5654,13,2049,2049,1797,891,0,0]B`[5654,13,2049,2049,3755,891,0,0]B`[5654,13,2049,2049,5442,891,0,0]B`[5654,13,2049,2049,5739,891,0,0]B`[5654,13,2049,2049,7120,891,0,0]B`[5654,13,2499,2499,6373,891,0,0]B`[5654,13,2499,2499,3757,891,0,0]B`[5654,13,2499,2499,4804,891,0,0]B`[5654,13,2499,2499,5708,891,0,0]B`[5654,13,2499,2499,7178,891,0,0]B`[5654,13,2499,2499,7121,891,0,0]B`[5654,13,2499,2499,7039,891,0,0]B`[5654,13,2499,2499,5443,891,0,0]B`[5654,13,2499,2499,6368,891,0,0]B`[5654,13,2499,2499,3754,891,0,0]B`[5654,13,2499,2499,6842,891,0,0]B`[5654,13,2499,2499,6841,891,0,0]B`[5654,13,2376,2376,6367,891,0,0]B`[5654,13,5956,5956,7044,891,0,0]B`[5654,13,5956,5956,4827,891,0,0]B`[5654,13,3153,3153,4836,891,0,0]B`[5654,13,3153,3153,2996,891,0,0]B`[5654,13,3153,3153,5695,891,0,0]B`[5654,13,3153,3153,6384,891,0,0]B`[5654,13,3153,3153,5748,891,0,0]B`[5654,13,3153,3153,5983,891,0,0]B`[5654,13,2738,2738,2989,891,0,0]B`[5654,13,4568,4568,4834,891,0,0]B`[5654,13,4568,4568,5995,891,0,0]B`[5654,13,4568,4568,1817,891,0,0]B`[5654,13,4568,4568,4179,891,0,0]B`[5654,13,2496,2496,5457,891,0,0]B`[5654,13,4133,4133,6852,891,0,0]B`[5654,13,1839,1839,5891,891,0,0]B`[5654,13,1839,1839,4794,891,0,0]B`[5654,13,2547,2547,6006,891,0,0]B`[5654,13,2547,2547,7186,891,0,0]Ak[5654,14,6369,0,1807,0,0,0]Ak[5654,14,6369,0,2113,0,0,0]An[5654,14,2049,2049,5739,0,0,0]An[5654,14,2499,2499,7039,0,0,0]An[5654,14,2049,2049,3760,0,0,0]An[5654,14,2049,2049,4815,0,0,0]An[5654,12,2598,2598,5738,0,0,0]Ba[5654,13,2598,2598,4820,5738,0,0]Ba[5654,13,5132,5132,4176,5738,0,0]Ag[5654,2,2053,0,0,0,0,0]Ag[3095,7,3096,0,0,0,0,0]Ak[5654,14,6610,0,6620,0,0,0]Ak[5654,14,6610,0,6846,0,0,0]An[5654,14,5178,5178,6003,0,0,0]Al[5654,14,911,911,4185,0,0,0]Ak[5654,14,911,911,908,0,0,0]Al[5654,14,911,911,5444,0,0,0]An[5654,14,3059,3059,4149,0,0,0]An[5654,14,2739,2739,4186,0,0,0]An[5654,14,2739,2739,5696,0,0,0]An[5654,14,2739,2739,4153,0,0,0]An[5654,14,2501,2501,5436,0,0,0]An[5654,14,2690,2690,6739,0,0,0]An[5654,14,2708,2708,4821,0,0,0]An[5654,14,2349,2349,5880,0,0,0]An[5654,14,2003,2003,3759,0,0,0]An[5654,14,2711,2711,4154,0,0,0]An[5654,14,2049,2049,2559,0,0,0]An[5654,14,2049,2049,4150,0,0,0]An[5654,14,2049,2049,2089,0,0,0]An[5654,14,2049,2049,3760,0,0,0]An[5654,14,2049,2049,4814,0,0,0]An[5654,14,2049,2049,1797,0,0,0]An[5654,14,2499,2499,3757,0,0,0]An[5654,14,2499,2499,5443,0,0,0]An[5654,14,2499,2499,6841,0,0,0]An[5654,13,2505,2505,6380,0,0,0]An[5654,13,2016,2016,5464,0,0,0]An[5654,13,2738,2738,2989,0,0,0]An[5654,14,5083,5083,6595,0,0,0]1An[5654,14,2738,2738,2989,0,0,0]An[5654,13,5110,5110,4166,0,0,0]5An[5654,12,2598,2598,5738,0,0,0]Ba[5654,13,2598,2598,4820,5738,0,0]Ba[5654,13,5132,5132,4176,5738,0,0]An[5654,14,5660,5660,4810,0,0,0]An[5654,13,2349,2349,5875,0,0,0]An[5654,12,4779,4779,3027,0,0,0]An[5654,14,2501,2501,6363,0,0,0]An[5654,14,2049,2049,2089,0,0,0]=765Ag[5654,2,2505,0,0,0,0,0]An[5654,12,2505,2505,6748,0,0,0]Ba[5654,13,2505,2505,5471,6748,0,0]An[5654,14,2049,2049,4814,0,0,0]An[5654,14,2499,2499,6841,0,0,0]An[5654,13,6569,6569,5999,0,0,0]Ag[5654,2,3058,0,0,0,0,0]An[5654,13,5967,5967,5746,0,0,0]An[5654,13,2505,2505,6380,0,0,0]An[5654,12,2598,2598,5738,0,0,0]Ba[5654,13,2598,2598,4820,5738,0,0]Ba[5654,13,5132,5132,4176,5738,0,0]An[5654,13,5956,5956,7044,0,0,0]Ag[5654,2,3055,0,0,0,0,0]Al[5654,14,911,911,4152,0,0,0]Al[5654,14,911,911,7383,0,0,0]Ak[5654,14,7290,0,3033,0,0,0]B`[5654,13,5655,5655,6361,909,0,0]An[5654,13,911,911,4791,909,0,0]An[5654,13,911,911,4185,909,0,0]Am[5654,13,911,911,908,909,0,0]An[5654,13,911,911,4152,909,0,0]An[5654,13,911,911,2577,909,0,0]An[5654,13,911,911,6381,909,0,0]An[5654,13,911,911,7383,909,0,0]An[5654,13,911,911,5444,909,0,0]B`[5654,13,6569,6569,5999,909,0,0]B`[5654,13,6569,6569,7291,909,0,0]B`[5654,13,1548,1548,5747,909,0,0]An[5654,13,912,912,6734,909,0,0]An[5654,13,912,912,7185,909,0,0]B`[5654,13,5504,5504,7384,909,0,0]B`[5654,13,3059,3059,7180,909,0,0]B`[5654,13,3059,3059,6845,909,0,0]B`[5654,13,3059,3059,7182,909,0,0]B`[5654,13,3059,3059,5704,909,0,0]B`[5654,13,3059,3059,6597,909,0,0]B`[5654,13,3059,3059,7523,909,0,0]B`[5654,13,3059,3059,6840,909,0,0]B`[5654,13,3059,3059,7037,909,0,0]B`[5654,13,3059,3059,4149,909,0,0]B`[5654,13,3059,3059,5478,909,0,0]B`[5654,13,3059,3059,6355,909,0,0]B`[5654,13,3059,3059,7036,909,0,0]B`[5654,13,5179,5179,6004,909,0,0]B`[5654,13,5179,5179,6834,909,0,0]B`[5654,13,5179,5179,6833,909,0,0]B`[5654,13,5817,5817,7386,909,0,0]B`[5654,13,5817,5817,6849,909,0,0]B`[5654,13,6788,6788,7236,909,0,0]B`[5654,13,2506,2506,6623,909,0,0]B`[5654,13,2506,2506,6751,909,0,0]B`[5654,13,2506,2506,7385,909,0,0]B`[5654,13,2506,2506,7050,909,0,0]B`[5654,13,2506,2506,6836,909,0,0]B`[5654,13,2506,2506,5456,909,0,0]B`[5654,13,2506,2506,5986,909,0,0]B`[5654,13,2506,2506,4829,909,0,0]B`[5654,13,2506,2506,5479,909,0,0]B`[5654,13,2506,2506,5988,909,0,0]B`[5654,13,2506,2506,6375,909,0,0]B`[5654,13,2506,2506,6376,909,0,0]B`[5654,13,2506,2506,7119,909,0,0]B`[5654,13,2506,2506,6606,909,0,0]B`[5654,13,2506,2506,6605,909,0,0]B`[5654,13,2506,2506,6627,909,0,0]B`[5654,13,2506,2506,7283,909,0,0]B`[5654,13,2506,2506,7125,909,0,0]B`[5654,13,2506,2506,7041,909,0,0]B`[5654,13,2506,2506,5740,909,0,0]B`[5654,13,2012,2012,6589,909,0,0]B`[5654,13,2012,2012,6832,909,0,0]B`[5654,13,2012,2012,7112,909,0,0]B`[5654,13,4752,4752,6614,909,0,0]B`[5654,13,4752,4752,7288,909,0,0]B`[5654,13,4752,4752,6619,909,0,0]B`[5654,13,4752,4752,7114,909,0,0]B`[5654,13,2739,2739,5977,909,0,0]B`[5654,13,2739,2739,7179,909,0,0]B`[5654,13,2739,2739,7127,909,0,0]B`[5654,13,2739,2739,4186,909,0,0]B`[5654,13,2739,2739,3030,909,0,0]B`[5654,13,2739,2739,5693,909,0,0]B`[5654,13,2739,2739,5884,909,0,0]B`[5654,13,2739,2739,6377,909,0,0]B`[5654,13,2739,2739,5696,909,0,0]B`[5654,13,2739,2739,4153,909,0,0]B`[5654,13,2501,2501,5436,909,0,0]B`[5654,13,2501,2501,6363,909,0,0]B`[5654,13,5680,5680,7123,909,0,0]B`[5654,13,4782,4782,5980,909,0,0]B`[5654,13,5083,5083,6595,909,0,0]B`[5654,13,5083,5083,5978,909,0,0]B`[5654,13,2601,2601,5873,909,0,0]B`[5654,13,2601,2601,6592,909,0,0]B`[5654,13,2601,2601,6593,909,0,0]B`[5654,13,2601,2601,6594,909,0,0]B`[5654,13,2601,2601,6752,909,0,0]B`[5654,13,3789,3789,5987,909,0,0]B`[5654,13,4779,4779,4828,909,0,0]B`[5654,13,4779,4779,3020,909,0,0]B`[5654,13,4779,4779,4798,909,0,0]B`[5654,13,4779,4779,3771,909,0,0]B`[5654,13,2690,2690,6739,909,0,0]B`[5654,13,2708,2708,4821,909,0,0]B`[5654,13,3149,3149,7045,909,0,0]B`[5654,13,5503,5503,6838,909,0,0]B`[5654,13,5503,5503,6737,909,0,0]B`[5654,13,5503,5503,7232,909,0,0]B`[5654,13,5503,5503,7034,909,0,0]B`[5654,13,4855,4855,5974,909,0,0]B`[5654,13,4855,4855,6364,909,0,0]B`[5654,13,4855,4855,5973,909,0,0]B`[5654,13,2002,2002,3762,909,0,0]B`[5654,13,2002,2002,4190,909,0,0]B`[5654,13,5155,5155,5448,909,0,0]B`[5654,13,3058,3058,5979,909,0,0]B`[5654,13,3058,3058,5438,909,0,0]B`[5654,13,3058,3058,5439,909,0,0]B`[5654,13,1837,1837,4793,909,0,0]B`[5654,13,1837,1837,4188,909,0,0]B`[5654,13,2364,2364,5703,909,0,0]B`[5654,13,2703,2703,4818,909,0,0]B`[5654,13,2703,2703,5451,909,0,0]B`[5654,13,2703,2703,6747,909,0,0]B`[5654,13,2546,2546,5868,909,0,0]B`[5654,13,2546,2546,5866,909,0,0]B`[5654,13,2546,2546,5701,909,0,0]B`[5654,13,2349,2349,5875,909,0,0]B`[5654,13,2349,2349,5445,909,0,0]B`[5654,13,2349,2349,4832,909,0,0]B`[5654,13,2349,2349,5880,909,0,0]B`[5654,13,2349,2349,3769,909,0,0]B`[5654,13,5110,5110,4168,909,0,0]B`[5654,13,5110,5110,3750,909,0,0]B`[5654,13,5110,5110,4166,909,0,0]B`[5654,13,2048,2048,5876,909,0,0]B`[5654,13,2048,2048,7042,909,0,0]B`[5654,13,2048,2048,7184,909,0,0]B`[5654,13,2048,2048,7235,909,0,0]B`[5654,13,5660,5660,6743,909,0,0]B`[5654,13,5660,5660,4810,909,0,0]B`[5654,13,5660,5660,3022,909,0,0]Ao[5654,13,5660,5660,902,909,0,0]B`[5654,13,5816,5816,7525,909,0,0]B`[5654,13,5816,5816,6848,909,0,0]B`[5654,13,5816,5816,5455,909,0,0]B`[5654,13,3147,3147,6379,909,0,0]B`[5654,13,3147,3147,5990,909,0,0]B`[5654,13,3147,3147,6609,909,0,0]B`[5654,13,5967,5967,4193,909,0,0]B`[5654,13,5967,5967,5746,909,0,0]B`[5654,13,3158,3158,5870,909,0,0]B`[5654,13,2505,2505,5471,909,0,0]B`[5654,13,2505,2505,6380,909,0,0]B`[5654,13,2505,2505,6742,909,0,0]B`[5654,13,2038,2038,3748,909,0,0]B`[5654,13,2038,2038,4826,909,0,0]B`[5654,13,1534,1534,6629,909,0,0]B`[5654,13,1534,1534,4187,909,0,0]B`[5654,13,2598,2598,4820,909,0,0]B`[5654,13,2598,2598,4823,909,0,0]B`[5654,13,2366,2366,6625,909,0,0]B`[5654,13,2366,2366,6612,909,0,0]B`[5654,13,2727,2727,6844,909,0,0]B`[5654,13,2727,2727,6371,909,0,0]B`[5654,13,2016,2016,4181,909,0,0]B`[5654,13,2016,2016,5464,909,0,0]B`[5654,13,2016,2016,4790,909,0,0]B`[5654,13,6636,6636,6362,909,0,0]B`[5654,13,2003,2003,5462,909,0,0]B`[5654,13,2003,2003,5744,909,0,0]B`[5654,13,2003,2003,3759,909,0,0]B`[5654,13,2003,2003,3764,909,0,0]B`[5654,13,2003,2003,4813,909,0,0]B`[5654,13,3052,3052,6358,909,0,0]B`[5654,13,3052,3052,6360,909,0,0]B`[5654,13,5178,5178,5889,909,0,0]B`[5654,13,5178,5178,6003,909,0,0]B`[5654,13,4772,4772,5452,909,0,0]B`[5654,13,4772,4772,5741,909,0,0]B`[5654,13,5132,5132,4176,909,0,0]B`[5654,13,5132,5132,5707,909,0,0]B`[5654,13,2711,2711,6603,909,0,0]B`[5654,13,2711,2711,4154,909,0,0]B`[5654,13,2049,2049,3774,909,0,0]B`[5654,13,2049,2049,2559,909,0,0]B`[5654,13,2049,2049,3029,909,0,0]B`[5654,13,2049,2049,4150,909,0,0]B`[5654,13,2049,2049,2089,909,0,0]B`[5654,13,2049,2049,3760,909,0,0]B`[5654,13,2049,2049,4815,909,0,0]B`[5654,13,2049,2049,4814,909,0,0]B`[5654,13,2049,2049,5475,909,0,0]B`[5654,13,2049,2049,6596,909,0,0]B`[5654,13,2049,2049,5981,909,0,0]B`[5654,13,2049,2049,4831,909,0,0]B`[5654,13,2049,2049,5742,909,0,0]B`[5654,13,2049,2049,5453,909,0,0]B`[5654,13,2049,2049,5877,909,0,0]B`[5654,13,2049,2049,1797,909,0,0]B`[5654,13,2049,2049,3755,909,0,0]B`[5654,13,2049,2049,5442,909,0,0]B`[5654,13,2049,2049,5739,909,0,0]B`[5654,13,2049,2049,7120,909,0,0]B`[5654,13,2499,2499,6373,909,0,0]B`[5654,13,2499,2499,3757,909,0,0]B`[5654,13,2499,2499,4804,909,0,0]B`[5654,13,2499,2499,5708,909,0,0]B`[5654,13,2499,2499,7178,909,0,0]B`[5654,13,2499,2499,7121,909,0,0]B`[5654,13,2499,2499,7039,909,0,0]B`[5654,13,2499,2499,5443,909,0,0]B`[5654,13,2499,2499,6368,909,0,0]B`[5654,13,2499,2499,3754,909,0,0]B`[5654,13,2499,2499,6842,909,0,0]B`[5654,13,2499,2499,6841,909,0,0]B`[5654,13,2376,2376,6367,909,0,0]B`[5654,13,5956,5956,7044,909,0,0]B`[5654,13,5956,5956,4827,909,0,0]B`[5654,13,3153,3153,4836,909,0,0]B`[5654,13,3153,3153,2996,909,0,0]B`[5654,13,3153,3153,5695,909,0,0]B`[5654,13,3153,3153,6384,909,0,0]B`[5654,13,3153,3153,5748,909,0,0]B`[5654,13,3153,3153,5983,909,0,0]B`[5654,13,2738,2738,2989,909,0,0]B`[5654,13,4568,4568,4834,909,0,0]B`[5654,13,4568,4568,5995,909,0,0]B`[5654,13,4568,4568,1817,909,0,0]B`[5654,13,4568,4568,4179,909,0,0]B`[5654,13,2496,2496,5457,909,0,0]B`[5654,13,4133,4133,6852,909,0,0]B`[5654,13,1839,1839,5891,909,0,0]B`[5654,13,1839,1839,4794,909,0,0]B`[5654,13,2547,2547,6006,909,0,0]B`[5654,13,2547,2547,7186,909,0,0]An[5654,15,2506,2506,6375,0,0,0]An[5654,15,2002,2002,3762,0,0,0]An[5654,15,2003,2003,5462,0,0,0]An[5654,15,5660,5660,3022,0,0,0]An[5654,15,2002,2002,4190,0,0,0]An[5654,15,2727,2727,6371,0,0,0]An[5654,15,2003,2003,3764,0,0,0]An[5654,15,2038,2038,3748,0,0,0]An[5654,15,2038,2038,4826,0,0,0]An[5654,15,2366,2366,6612,0,0,0]An[5654,15,4568,4568,4179,0,0,0]Ag[5654,5,2049,0,0,0,0,0]An[5654,15,2506,2506,5479,0,0,0]2An[5654,15,5816,5816,5455,0,0,0]An[5654,15,2003,2003,4813,0,0,0]An[5654,15,3153,3153,6384,0,0,0]000>:An[5654,15,2049,2049,4831,0,0,0]?An[5654,12,2598,2598,5738,0,0,0]Ba[5654,13,2598,2598,4820,5738,0,0]An[5654,14,2598,2598,4820,0,0,0]Ak[5654,14,6610,0,5749,0,0,0]Ba[5654,13,5132,5132,4176,5738,0,0]An[5654,14,5132,5132,5707,0,0,0]Ag[5654,2,3058,0,0,0,0,0]Ag[5654,2,4133,0,0,0,0,0]An[5654,15,1534,1534,4187,0,0,0]An[5654,15,2002,2002,3762,0,0,0]0An[5654,15,3153,3153,6384,0,0,0]An[5654,15,4855,4855,6364,0,0,0]An[5654,15,4855,4855,5973,0,0,0]Ag[5654,5,2049,0,0,0,0,0]An[5654,15,2038,2038,4826,0,0,0]An[5654,15,2546,2546,5701,0,0,0]An[5654,15,5110,5110,3750,0,0,0]An[5654,15,2049,2049,3774,0,0,0]An[5654,15,2547,2547,7186,0,0,0]14An[5654,15,2049,2049,3755,0,0,0]5Af[5654,5,911,0,0,0,0,0]An[5654,15,2364,2364,5703,0,0,0]6An[5654,15,3147,3147,6609,0,0,0]An[5654,15,2703,2703,4818,0,0,0]An[5654,14,5967,5967,5746,0,0,0]An[5654,14,2739,2739,4153,0,0,0]An[5654,14,2708,2708,4821,0,0,0]An[5654,14,2349,2349,5880,0,0,0]An[5654,14,2003,2003,3759,0,0,0]An[5654,14,2711,2711,4154,0,0,0]An[5654,14,2049,2049,3760,0,0,0]An[5654,14,2499,2499,5443,0,0,0]An[5654,14,5655,5655,6361,0,0,0]An[5654,14,2506,2506,5456,0,0,0]An[5654,14,2505,2505,6380,0,0,0]Ag[5654,2,2505,0,0,0,0,0]An[5654,14,2049,2049,4814,0,0,0]An[5654,14,2499,2499,6841,0,0,0]Ag[5654,2,3055,0,0,0,0,0]Ba[5654,13,5155,5155,5448,4157,0,0]An[5654,14,2506,2506,6376,0,0,0]An[5654,14,2506,2506,6606,0,0,0]Ak[5654,14,6607,0,1820,0,0,0]An[5654,13,2016,2016,5464,0,0,0]An[5654,14,2016,2016,5464,0,0,0]An[5654,13,2727,2727,6844,0,0,0]An[5654,14,2727,2727,6844,0,0,0]3An[5654,13,4855,4855,5974,0,0,0]Ba[5654,13,4779,4779,4798,2551,0,0]Ba[5654,13,5503,5503,6737,2551,0,0]Ba[5654,13,4855,4855,5974,2551,0,0]Ba[5654,13,2002,2002,3762,2551,0,0]Ba[5654,13,2002,2002,4190,2551,0,0]Ba[5654,13,1837,1837,4793,2551,0,0]Ba[5654,13,2703,2703,5451,2551,0,0]Ba[5654,13,2703,2703,6747,2551,0,0]Ba[5654,13,5110,5110,4168,2551,0,0]Ba[5654,13,5967,5967,4193,2551,0,0]Ba[5654,13,3158,3158,5870,2551,0,0]Ba[5654,13,2505,2505,6380,2551,0,0]Ba[5654,13,2038,2038,4826,2551,0,0]Ba[5654,13,2598,2598,4820,2551,0,0]Ba[5654,13,2727,2727,6844,2551,0,0]Ba[5654,13,2016,2016,4181,2551,0,0]Ba[5654,13,2016,2016,5464,2551,0,0]Ba[5654,13,2003,2003,5462,2551,0,0]Ba[5654,13,3052,3052,6360,2551,0,0]Ba[5654,13,5178,5178,6003,2551,0,0]Ba[5654,13,4772,4772,5452,2551,0,0]Ba[5654,13,4772,4772,5741,2551,0,0]Ba[5654,13,2049,2049,3029,2551,0,0]Ba[5654,13,2049,2049,3760,2551,0,0]Ba[5654,13,2049,2049,5981,2551,0,0]Ba[5654,13,2049,2049,5742,2551,0,0]Ba[5654,13,2049,2049,5442,2551,0,0]Ba[5654,13,3153,3153,4836,2551,0,0]Ba[5654,13,3153,3153,5695,2551,0,0]Ba[5654,13,3153,3153,6384,2551,0,0]Ba[5654,13,3153,3153,5748,2551,0,0]Ba[5654,13,3153,3153,5983,2551,0,0]Ba[5654,13,1839,1839,4794,2551,0,0]Ba[5654,13,2547,2547,6006,2551,0,0]An[5654,13,4752,4752,6614,0,0,0]An[5654,12,5816,5816,6850,0,0,0]Ba[5654,13,5816,5816,7525,6850,0,0]An[5654,13,2727,2727,6844,0,0,0]An[5654,14,3052,3052,6360,0,0,0]An[5654,14,1839,1839,4794,0,0,0]An[5654,13,4855,4855,5974,0,0,0]An[5654,12,4568,4568,5976,0,0,0]An[5654,13,4568,4568,4834,0,0,0]Ba[5654,13,4568,4568,5995,5976,0,0]9An[5654,14,2016,2016,5464,0,0,0]An[5654,14,2499,2499,3757,0,0,0]An[5654,14,1548,1548,5747,0,0,0]An[5654,14,3059,3059,7523,0,0,0]An[5654,14,3059,3059,6840,0,0,0]An[5654,14,3059,3059,7037,0,0,0]An[5654,14,2739,2739,6377,0,0,0]An[5654,14,2501,2501,6363,0,0,0]An[5654,14,4782,4782,5980,0,0,0]An[5654,14,3789,3789,5987,0,0,0]An[5654,14,4779,4779,3771,0,0,0]Ak[5654,14,3019,0,5735,0,0,0]Ak[5654,14,3019,0,5991,0,0,0]Ak[5654,14,3019,0,4171,0,0,0]Ak[5654,14,3019,0,5446,0,0,0]Ak[5654,14,3019,0,2998,0,0,0]An[5654,14,3058,3058,5438,0,0,0]Ak[5654,14,5702,0,2092,0,0,0]Ak[5654,14,5702,0,2578,0,0,0]Ak[5654,14,5702,0,3015,0,0,0]Ak[5654,14,5702,0,3742,0,0,0]Ak[5654,14,5702,0,2064,0,0,0]Ak[5654,14,5702,0,2084,0,0,0]Ak[5654,14,5702,0,3739,0,0,0]An[5654,14,2048,2048,7042,0,0,0]An[5654,14,2048,2048,7184,0,0,0]An[5654,14,2048,2048,7235,0,0,0]Ak[5654,14,6607,0,1820,0,0,0]Ak[5654,14,6607,0,3016,0,0,0]Ak[5654,14,6607,0,2580,0,0,0]Ak[5654,14,6607,0,4172,0,0,0]An[5654,14,2496,2496,5457,0,0,0]Ag[5654,2,3055,0,0,0,0,0]An[5654,14,5083,5083,5978,0,0,0]An[5654,14,5655,5655,6361,0,0,0]Ak[5654,14,7290,0,5449,0,0,0]An[5654,14,2598,2598,4823,0,0,0]Ak[5654,14,6610,0,1788,0,0,0]Ak[5654,14,3773,0,2562,0,0,0]Ak[5654,14,3773,0,5861,0,0,0]Ak[5654,14,3773,0,3002,0,0,0]Ak[5654,14,3773,0,2108,0,0,0]Ak[5654,14,3773,0,2987,0,0,0]An[5654,13,2738,2738,2989,0,0,0]Ag[5654,2,3058,0,0,0,0,0]Ak[5654,14,6626,0,5465,0,0,0]Ak[5654,14,7282,0,5466,0,0,0]Ak[5654,14,7124,0,5467,0,0,0]?An[5654,14,2376,2376,6367,0,0,0]Ag[5654,2,4749,0,0,0,0,0]An[5654,14,1548,1548,5747,0,0,0]An[5654,14,4779,4779,3771,0,0,0]Ak[5654,14,6610,0,3736,0,0,0]Ag[5654,2,3059,0,0,0,0,0]An[5654,13,2499,2499,3754,0,0,0]Ak[5654,14,3753,0,4191,0,0,0]An[5654,14,2049,2049,5442,0,0,0]An[5654,14,2505,2505,6380,0,0,0]An[5654,14,2506,2506,6836,0,0,0]?Ag[5654,2,2506,0,0,0,0,0]Ak[5654,14,6610,0,5885,0,0,0]Ag[5654,2,2505,0,0,0,0,0]An[5654,14,2506,2506,5456,0,0,0]An[5654,14,2506,2506,4829,0,0,0]An[5654,14,2506,2506,6376,0,0,0]An[5654,14,2506,2506,7119,0,0,0]An[5654,14,2506,2506,6606,0,0,0]Ak[5654,14,6610,0,5458,0,0,0]An[5654,14,2016,2016,5464,0,0,0]An[5654,13,2016,2016,5464,0,0,0]An[5654,14,2506,2506,5740,0,0,0]An[5654,14,2049,2049,5981,0,0,0]Ag[5654,2,5655,0,0,0,0,0]Ag[5654,2,3055,0,0,0,0,0]Ak[5654,14,7282,0,2074,0,0,0]An[5654,14,5110,5110,4168,0,0,0]An[5654,14,2499,2499,3757,0,0,0]B`[5654,13,5655,5655,6361,301,0,0]An[5654,13,911,911,4791,301,0,0]An[5654,13,911,911,4185,301,0,0]Am[5654,13,911,911,908,301,0,0]An[5654,13,911,911,4152,301,0,0]An[5654,13,911,911,2577,301,0,0]An[5654,13,911,911,6381,301,0,0]An[5654,13,911,911,7383,301,0,0]An[5654,13,911,911,5444,301,0,0]B`[5654,13,6569,6569,5999,301,0,0]B`[5654,13,6569,6569,7291,301,0,0]B`[5654,13,1548,1548,5747,301,0,0]An[5654,13,912,912,6734,301,0,0]An[5654,13,912,912,7185,301,0,0]B`[5654,13,5504,5504,7384,301,0,0]B`[5654,13,3059,3059,7180,301,0,0]B`[5654,13,3059,3059,6845,301,0,0]B`[5654,13,3059,3059,7182,301,0,0]B`[5654,13,3059,3059,5704,301,0,0]B`[5654,13,3059,3059,6597,301,0,0]B`[5654,13,3059,3059,7523,301,0,0]B`[5654,13,3059,3059,6840,301,0,0]B`[5654,13,3059,3059,7037,301,0,0]B`[5654,13,3059,3059,4149,301,0,0]B`[5654,13,3059,3059,5478,301,0,0]B`[5654,13,3059,3059,6355,301,0,0]B`[5654,13,3059,3059,7036,301,0,0]B`[5654,13,5179,5179,6004,301,0,0]B`[5654,13,5179,5179,6834,301,0,0]B`[5654,13,5179,5179,6833,301,0,0]B`[5654,13,5817,5817,7386,301,0,0]B`[5654,13,5817,5817,6849,301,0,0]B`[5654,13,6788,6788,7236,301,0,0]B`[5654,13,2506,2506,6623,301,0,0]B`[5654,13,2506,2506,6751,301,0,0]B`[5654,13,2506,2506,7385,301,0,0]B`[5654,13,2506,2506,7050,301,0,0]B`[5654,13,2506,2506,6836,301,0,0]B`[5654,13,2506,2506,5456,301,0,0]B`[5654,13,2506,2506,5986,301,0,0]B`[5654,13,2506,2506,4829,301,0,0]B`[5654,13,2506,2506,5479,301,0,0]B`[5654,13,2506,2506,5988,301,0,0]B`[5654,13,2506,2506,6375,301,0,0]B`[5654,13,2506,2506,6376,301,0,0]B`[5654,13,2506,2506,7119,301,0,0]B`[5654,13,2506,2506,6606,301,0,0]B`[5654,13,2506,2506,6605,301,0,0]B`[5654,13,2506,2506,6627,301,0,0]B`[5654,13,2506,2506,7283,301,0,0]B`[5654,13,2506,2506,7125,301,0,0]B`[5654,13,2506,2506,7041,301,0,0]B`[5654,13,2506,2506,5740,301,0,0]B`[5654,13,2012,2012,6589,301,0,0]B`[5654,13,2012,2012,6832,301,0,0]B`[5654,13,2012,2012,7112,301,0,0]B`[5654,13,4752,4752,6614,301,0,0]B`[5654,13,4752,4752,7288,301,0,0]B`[5654,13,4752,4752,6619,301,0,0]B`[5654,13,4752,4752,7114,301,0,0]B`[5654,13,2739,2739,5977,301,0,0]B`[5654,13,2739,2739,7179,301,0,0]B`[5654,13,2739,2739,7127,301,0,0]B`[5654,13,2739,2739,4186,301,0,0]B`[5654,13,2739,2739,3030,301,0,0]B`[5654,13,2739,2739,5693,301,0,0]B`[5654,13,2739,2739,5884,301,0,0]B`[5654,13,2739,2739,6377,301,0,0]B`[5654,13,2739,2739,5696,301,0,0]B`[5654,13,2739,2739,4153,301,0,0]B`[5654,13,2501,2501,5436,301,0,0]B`[5654,13,2501,2501,6363,301,0,0]B`[5654,13,5680,5680,7123,301,0,0]B`[5654,13,4782,4782,5980,301,0,0]B`[5654,13,5083,5083,6595,301,0,0]B`[5654,13,5083,5083,5978,301,0,0]B`[5654,13,2601,2601,5873,301,0,0]B`[5654,13,2601,2601,6592,301,0,0]B`[5654,13,2601,2601,6593,301,0,0]B`[5654,13,2601,2601,6594,301,0,0]B`[5654,13,2601,2601,6752,301,0,0]B`[5654,13,3789,3789,5987,301,0,0]B`[5654,13,4779,4779,4828,301,0,0]B`[5654,13,4779,4779,3020,301,0,0]B`[5654,13,4779,4779,4798,301,0,0]B`[5654,13,4779,4779,3771,301,0,0]B`[5654,13,2690,2690,6739,301,0,0]B`[5654,13,2708,2708,4821,301,0,0]B`[5654,13,3149,3149,7045,301,0,0]B`[5654,13,5503,5503,6838,301,0,0]B`[5654,13,5503,5503,6737,301,0,0]B`[5654,13,5503,5503,7232,301,0,0]B`[5654,13,5503,5503,7034,301,0,0]B`[5654,13,4855,4855,5974,301,0,0]B`[5654,13,4855,4855,6364,301,0,0]B`[5654,13,4855,4855,5973,301,0,0]B`[5654,13,2002,2002,3762,301,0,0]B`[5654,13,2002,2002,4190,301,0,0]B`[5654,13,5155,5155,5448,301,0,0]B`[5654,13,3058,3058,5979,301,0,0]B`[5654,13,3058,3058,5438,301,0,0]B`[5654,13,3058,3058,5439,301,0,0]B`[5654,13,1837,1837,4793,301,0,0]B`[5654,13,1837,1837,4188,301,0,0]B`[5654,13,2364,2364,5703,301,0,0]B`[5654,13,2703,2703,4818,301,0,0]B`[5654,13,2703,2703,5451,301,0,0]B`[5654,13,2703,2703,6747,301,0,0]B`[5654,13,2546,2546,5868,301,0,0]B`[5654,13,2546,2546,5866,301,0,0]B`[5654,13,2546,2546,5701,301,0,0]B`[5654,13,2349,2349,5875,301,0,0]B`[5654,13,2349,2349,5445,301,0,0]B`[5654,13,2349,2349,4832,301,0,0]B`[5654,13,2349,2349,5880,301,0,0]B`[5654,13,2349,2349,3769,301,0,0]B`[5654,13,5110,5110,4168,301,0,0]B`[5654,13,5110,5110,3750,301,0,0]B`[5654,13,5110,5110,4166,301,0,0]B`[5654,13,2048,2048,5876,301,0,0]B`[5654,13,2048,2048,7042,301,0,0]B`[5654,13,2048,2048,7184,301,0,0]B`[5654,13,2048,2048,7235,301,0,0]B`[5654,13,5660,5660,6743,301,0,0]B`[5654,13,5660,5660,4810,301,0,0]B`[5654,13,5660,5660,3022,301,0,0]Ao[5654,13,5660,5660,902,301,0,0]B`[5654,13,5816,5816,7525,301,0,0]B`[5654,13,5816,5816,6848,301,0,0]B`[5654,13,5816,5816,5455,301,0,0]B`[5654,13,3147,3147,6379,301,0,0]B`[5654,13,3147,3147,5990,301,0,0]B`[5654,13,3147,3147,6609,301,0,0]B`[5654,13,5967,5967,4193,301,0,0]B`[5654,13,5967,5967,5746,301,0,0]B`[5654,13,3158,3158,5870,301,0,0]B`[5654,13,2505,2505,5471,301,0,0]B`[5654,13,2505,2505,6380,301,0,0]B`[5654,13,2505,2505,6742,301,0,0]B`[5654,13,2038,2038,3748,301,0,0]B`[5654,13,2038,2038,4826,301,0,0]B`[5654,13,1534,1534,6629,301,0,0]B`[5654,13,1534,1534,4187,301,0,0]B`[5654,13,2598,2598,4820,301,0,0]B`[5654,13,2598,2598,4823,301,0,0]B`[5654,13,2366,2366,6625,301,0,0]B`[5654,13,2366,2366,6612,301,0,0]B`[5654,13,2727,2727,6844,301,0,0]B`[5654,13,2727,2727,6371,301,0,0]B`[5654,13,2016,2016,4181,301,0,0]B`[5654,13,2016,2016,5464,301,0,0]B`[5654,13,2016,2016,4790,301,0,0]B`[5654,13,6636,6636,6362,301,0,0]B`[5654,13,2003,2003,5462,301,0,0]B`[5654,13,2003,2003,5744,301,0,0]B`[5654,13,2003,2003,3759,301,0,0]B`[5654,13,2003,2003,3764,301,0,0]B`[5654,13,2003,2003,4813,301,0,0]B`[5654,13,3052,3052,6358,301,0,0]B`[5654,13,3052,3052,6360,301,0,0]B`[5654,13,5178,5178,5889,301,0,0]B`[5654,13,5178,5178,6003,301,0,0]B`[5654,13,4772,4772,5452,301,0,0]B`[5654,13,4772,4772,5741,301,0,0]B`[5654,13,5132,5132,4176,301,0,0]B`[5654,13,5132,5132,5707,301,0,0]B`[5654,13,2711,2711,6603,301,0,0]B`[5654,13,2711,2711,4154,301,0,0]B`[5654,13,2049,2049,3774,301,0,0]B`[5654,13,2049,2049,2559,301,0,0]B`[5654,13,2049,2049,3029,301,0,0]B`[5654,13,2049,2049,4150,301,0,0]B`[5654,13,2049,2049,2089,301,0,0]B`[5654,13,2049,2049,3760,301,0,0]B`[5654,13,2049,2049,4815,301,0,0]B`[5654,13,2049,2049,4814,301,0,0]B`[5654,13,2049,2049,5475,301,0,0]B`[5654,13,2049,2049,6596,301,0,0]B`[5654,13,2049,2049,5981,301,0,0]B`[5654,13,2049,2049,4831,301,0,0]B`[5654,13,2049,2049,5742,301,0,0]B`[5654,13,2049,2049,5453,301,0,0]B`[5654,13,2049,2049,5877,301,0,0]B`[5654,13,2049,2049,1797,301,0,0]B`[5654,13,2049,2049,3755,301,0,0]B`[5654,13,2049,2049,5442,301,0,0]B`[5654,13,2049,2049,5739,301,0,0]B`[5654,13,2049,2049,7120,301,0,0]B`[5654,13,2499,2499,6373,301,0,0]B`[5654,13,2499,2499,3757,301,0,0]B`[5654,13,2499,2499,4804,301,0,0]B`[5654,13,2499,2499,5708,301,0,0]B`[5654,13,2499,2499,7178,301,0,0]B`[5654,13,2499,2499,7121,301,0,0]B`[5654,13,2499,2499,7039,301,0,0]B`[5654,13,2499,2499,5443,301,0,0]B`[5654,13,2499,2499,6368,301,0,0]B`[5654,13,2499,2499,3754,301,0,0]B`[5654,13,2499,2499,6842,301,0,0]B`[5654,13,2499,2499,6841,301,0,0]B`[5654,13,2376,2376,6367,301,0,0]B`[5654,13,5956,5956,7044,301,0,0]B`[5654,13,5956,5956,4827,301,0,0]B`[5654,13,3153,3153,4836,301,0,0]B`[5654,13,3153,3153,2996,301,0,0]B`[5654,13,3153,3153,5695,301,0,0]B`[5654,13,3153,3153,6384,301,0,0]B`[5654,13,3153,3153,5748,301,0,0]B`[5654,13,3153,3153,5983,301,0,0]B`[5654,13,2738,2738,2989,301,0,0]B`[5654,13,4568,4568,4834,301,0,0]B`[5654,13,4568,4568,5995,301,0,0]B`[5654,13,4568,4568,1817,301,0,0]B`[5654,13,4568,4568,4179,301,0,0]B`[5654,13,2496,2496,5457,301,0,0]B`[5654,13,4133,4133,6852,301,0,0]B`[5654,13,1839,1839,5891,301,0,0]B`[5654,13,1839,1839,4794,301,0,0]B`[5654,13,2547,2547,6006,301,0,0]B`[5654,13,2547,2547,7186,301,0,0]An[5654,13,5660,5660,4810,0,0,0]Al[5654,14,911,911,2577,0,0,0]An[5654,14,1548,1548,5747,0,0,0]An[5654,14,3059,3059,7523,0,0,0]An[5654,14,3059,3059,6840,0,0,0]An[5654,14,3059,3059,7037,0,0,0]An[5654,14,2739,2739,5693,0,0,0]An[5654,14,3058,3058,5438,0,0,0]An[5654,14,2349,2349,4832,0,0,0]An[5654,14,3147,3147,6379,0,0,0]An[5654,14,2711,2711,6603,0,0,0]An[5654,13,2499,2499,6373,0,0,0]An[5654,14,2499,2499,6373,0,0,0]An[5654,13,2376,2376,6367,0,0,0]Al[5654,14,911,911,4185,0,0,0]Al[5654,14,911,911,6381,0,0,0]An[5654,14,2739,2739,4186,0,0,0]Ah[5654,18,2349,0,0,0,0,0]Ah[5654,18,5110,0,0,0,0,0]An[5654,15,2049,2049,3755,0,0,0]An[5654,15,2703,2703,4818,0,0,0]An[5654,15,2049,2049,3774,0,0,0]Ag[5654,5,2738,0,0,0,0,0]Ag[5654,8,4568,0,0,0,0,0]Ah[5654,18,3153,0,0,0,0,0]An[5654,15,5178,5178,5889,0,0,0]Ag[5654,6,3153,0,0,0,0,0]6An[5654,15,4779,4779,3020,0,0,0]An[5654,15,2038,2038,3748,0,0,0]An[5654,15,4855,4855,5973,0,0,0]8An[5654,15,3153,3153,6384,0,0,0]An[5654,15,2506,2506,6375,0,0,0]An[5654,15,2506,2506,6605,0,0,0]An[5654,15,2002,2002,3762,0,0,0]An[5654,15,2003,2003,5462,0,0,0]An[5654,15,2364,2364,5703,0,0,0]An[5654,15,3147,3147,6609,0,0,0]Ag[5654,6,4779,0,0,0,0,0]Ah[5654,18,5084,0,0,0,0,0]Ag[5654,6,5660,0,0,0,0,0]An[5654,15,2002,2002,4190,0,0,0]Ah[5654,10,4779,0,0,0,0,0]Ag[5654,5,2049,0,0,0,0,0]Ag[5654,5,2739,0,0,0,0,0]??An[5654,15,6569,6569,7291,0,0,0]An[5654,14,2496,2496,5457,0,0,0]An[5654,14,3059,3059,6355,0,0,0]An[5654,14,5179,5179,6834,0,0,0]An[5654,14,5179,5179,6833,0,0,0]An[5654,14,2506,2506,5986,0,0,0]An[5654,14,2506,2506,7119,0,0,0]Ak[5654,14,6374,0,1774,0,0,0]An[5654,14,2739,2739,4153,0,0,0]An[5654,14,2708,2708,4821,0,0,0]An[5654,14,2349,2349,5880,0,0,0]An[5654,14,2003,2003,3759,0,0,0]Ak[5654,14,5460,0,1775,0,0,0]An[5654,14,2711,2711,4154,0,0,0]An[5654,14,2049,2049,3760,0,0,0]An[5654,14,2499,2499,5443,0,0,0]An[5654,14,4772,4772,5741,0,0,0]Ak[5654,14,6610,0,6389,0,0,0]Ag[5654,2,2505,0,0,0,0,0]An[5654,13,5660,5660,4810,0,0,0]An[5654,14,2349,2349,3769,0,0,0]Ag[5654,2,5655,0,0,0,0,0]An[5654,14,4779,4779,4798,0,0,0]1Ag[5654,2,3055,0,0,0,0,0]An[5654,13,2546,2546,5868,0,0,0]An[5654,13,2547,2547,6006,0,0,0]An[5654,14,5817,5817,6849,0,0,0]An[5654,14,2506,2506,5456,0,0,0]An[5654,14,2506,2506,5986,0,0,0]An[5654,13,2505,2505,6380,0,0,0]Ak[5654,14,4824,0,2569,0,0,0]Ak[5654,14,4824,0,2085,0,0,0]An[5654,12,2598,2598,5738,0,0,0]Ba[5654,13,2598,2598,4820,5738,0,0]An[5654,14,2598,2598,4820,0,0,0]Ak[5654,14,6610,0,5881,0,0,0]Ak[5654,14,6610,0,4808,0,0,0]An[5654,14,2003,2003,3759,0,0,0]An[5654,14,4772,4772,5452,0,0,0]Ba[5654,13,5132,5132,4176,5738,0,0]An[5654,14,2501,2501,5436,0,0,0]Aj[5654,14,5887,0,905,0,0,0]An[5654,12,4749,4749,5985,0,0,0]Ba[5654,13,2711,2711,6603,5985,0,0]Ba[5654,13,2499,2499,6373,5985,0,0]Ba[5654,13,2376,2376,6367,5985,0,0]An[5654,13,2499,2499,6373,0,0,0]An[5654,13,5816,5816,6848,0,0,0]An[5654,14,2506,2506,4829,0,0,0]Ak[5654,14,6610,0,5458,0,0,0]An[5654,13,2016,2016,5464,0,0,0]An[5654,14,2506,2506,5986,0,0,0]>An[5654,14,2049,2049,3760,0,0,0]An[5654,14,2349,2349,3769,0,0,0]An[5654,14,4752,4752,7114,0,0,0]Ag[5654,2,5655,0,0,0,0,0]Ad[3095,3,0,0,0,0,0,0]An[5654,14,2003,2003,5744,0,0,0]An[5654,14,2049,2049,4815,0,0,0]An[5654,14,2499,2499,6842,0,0,0]Ak[5654,14,6610,0,5476,0,0,0]Ak[5654,14,6610,0,6389,0,0,0]An[5654,14,5132,5132,4176,0,0,0]An[5654,14,2739,2739,4153,0,0,0]An[5654,14,2501,2501,5436,0,0,0]An[5654,14,2711,2711,4154,0,0,0]=An[5654,14,2499,2499,5443,0,0,0]Ag[5654,7,2715,0,0,0,0,0]An[5654,14,5655,5655,6361,0,0,0]Ag[5654,2,3055,0,0,0,0,0]An[5654,13,5956,5956,7044,0,0,0]An[5654,14,5817,5817,6849,0,0,0]An[5654,14,2506,2506,5456,0,0,0]An[5654,14,2012,2012,6589,0,0,0]An[5654,14,2012,2012,6832,0,0,0]An[5654,14,2012,2012,7112,0,0,0]Ak[5654,14,6626,0,2073,0,0,0]Ak[5654,14,7282,0,5466,0,0,0]Ak[5654,14,7124,0,2075,0,0,0]Ak[5654,14,7124,0,5467,0,0,0]An[5654,13,2505,2505,6380,0,0,0]An[5654,13,2038,2038,4826,0,0,0]Ak[5654,14,4824,0,2569,0,0,0]Ak[5654,14,4824,0,2085,0,0,0]An[5654,12,2598,2598,5738,0,0,0]Ba[5654,13,2598,2598,4820,5738,0,0]An[5654,14,2598,2598,4820,0,0,0]") \ No newline at end of file diff --git a/api/search.index/entry/64f22a6898.js b/api/search.index/entry/64f22a6898.js new file mode 100644 index 00000000..ce98bb41 --- /dev/null +++ b/api/search.index/entry/64f22a6898.js @@ -0,0 +1 @@ +rd_("An[5654,14,2739,2739,4153,0,0,0]An[5654,14,2711,2711,4154,0,0,0]An[5654,14,2049,2049,3760,0,0,0]An[5654,14,2499,2499,5443,0,0,0]An[5654,13,4772,4772,5452,0,0,0]Ag[5654,7,4779,0,0,0,0,0]An[5654,12,4749,4749,5985,0,0,0]Ba[5654,13,2711,2711,6603,5985,0,0]Ba[5654,13,2499,2499,6373,5985,0,0]Ba[5654,13,2376,2376,6367,5985,0,0]Ag[5654,7,3059,0,0,0,0,0]An[5654,13,4855,4855,5974,0,0,0]Ag[5654,2,4749,0,0,0,0,0]An[5654,13,2505,2505,6380,0,0,0]An[5654,13,2598,2598,5738,0,0,0]11An[5654,14,5179,5179,6834,0,0,0]An[5654,13,5503,5503,6838,0,0,0]Ag[5654,7,1548,0,0,0,0,0]44;:98An[5654,13,3147,3147,6379,0,0,0]Ag[5654,7,4772,0,0,0,0,0]Ag[5654,5,3059,0,0,0,0,0]Aj[5654,4,2505,3052,0,0,0,0]Ag[5654,6,3052,0,0,0,0,0]1Ag[5654,5,3052,0,0,0,0,0]Ag[5654,5,5655,0,0,0,0,0]Ag[5654,6,6636,0,0,0,0,0]Ag[5654,5,2501,0,0,0,0,0]Ag[5654,6,4855,0,0,0,0,0]Aj[5654,4,4749,2376,0,0,0,0]Ag[5654,5,2376,0,0,0,0,0]Ag[5654,5,2499,0,0,0,0,0]Aj[5654,4,2505,2727,0,0,0,0]Ag[5654,6,2727,0,0,0,0,0]Aj[5654,4,4749,2499,0,0,0,0]3Ag[5654,6,2506,0,0,0,0,0]Ag[5654,5,2506,0,0,0,0,0]Ag[5654,5,2739,0,0,0,0,0]Aj[5654,4,6787,3147,0,0,0,0]Ag[5654,5,3147,0,0,0,0,0]Ag[5654,5,2505,0,0,0,0,0]Af[5654,5,911,0,0,0,0,0]Ah[5654,18,5084,0,0,0,0,0]Ag[5654,6,3153,0,0,0,0,0]Ah[5654,18,5816,0,0,0,0,0]Ah[5654,18,4852,0,0,0,0,0]Ah[5654,18,2601,0,0,0,0,0]An[5654,15,2366,2366,6612,0,0,0]An[5654,14,5967,5967,5746,0,0,0]An[5654,14,3058,3058,5979,0,0,0]Ag[5654,7,3059,0,0,0,0,0]Af[5654,7,912,0,0,0,0,0]0Ag[5654,7,5655,0,0,0,0,0]An[5654,14,3058,3058,5438,0,0,0]Ak[5654,14,5702,0,3015,0,0,0]An[5654,14,2546,2546,5866,0,0,0]An[5654,14,2739,2739,4153,0,0,0]An[5654,14,2711,2711,4154,0,0,0]An[5654,14,2049,2049,3760,0,0,0]An[5654,14,2499,2499,5443,0,0,0]An[5654,14,5503,5503,6737,0,0,0]An[5654,14,2505,2505,6380,0,0,0]An[5654,14,4855,4855,5974,0,0,0]Ag[5654,7,3153,0,0,0,0,0]An[5654,13,4855,4855,5974,0,0,0]Ag[5654,7,4779,0,0,0,0,0]An[5654,13,4772,4772,5452,0,0,0]An[5654,14,2506,2506,5988,0,0,0]Ag[5654,7,3059,0,0,0,0,0]Ba[5654,13,5655,5655,6361,3777,0,0]Ao[5654,13,911,911,4185,3777,0,0]An[5654,13,911,911,908,3777,0,0]Ao[5654,13,911,911,4152,3777,0,0]Ao[5654,13,911,911,2577,3777,0,0]Ba[5654,13,6569,6569,7291,3777,0,0]Ba[5654,13,1548,1548,5747,3777,0,0]Ba[5654,13,3059,3059,7182,3777,0,0]Ba[5654,13,3059,3059,5704,3777,0,0]Ba[5654,13,3059,3059,6597,3777,0,0]Ba[5654,13,3059,3059,5478,3777,0,0]Ba[5654,13,3059,3059,6355,3777,0,0]Ba[5654,13,3059,3059,7036,3777,0,0]Ba[5654,13,5179,5179,6004,3777,0,0]Ba[5654,13,5179,5179,6834,3777,0,0]Ba[5654,13,5179,5179,6833,3777,0,0]Ba[5654,13,5817,5817,6849,3777,0,0]Ba[5654,13,2506,2506,5456,3777,0,0]Ba[5654,13,2506,2506,5986,3777,0,0]Ba[5654,13,2506,2506,4829,3777,0,0]Ba[5654,13,2506,2506,5479,3777,0,0]Ba[5654,13,2506,2506,6627,3777,0,0]Ba[5654,13,2506,2506,7283,3777,0,0]Ba[5654,13,2506,2506,7125,3777,0,0]Ba[5654,13,2506,2506,7041,3777,0,0]Ba[5654,13,2506,2506,5740,3777,0,0]Ba[5654,13,4752,4752,6619,3777,0,0]Ba[5654,13,4752,4752,7114,3777,0,0]Ba[5654,13,2739,2739,4186,3777,0,0]Ba[5654,13,2739,2739,3030,3777,0,0]Ba[5654,13,2739,2739,5693,3777,0,0]Ba[5654,13,2739,2739,5884,3777,0,0]Ba[5654,13,2739,2739,6377,3777,0,0]Ba[5654,13,2739,2739,5696,3777,0,0]Ba[5654,13,2739,2739,4153,3777,0,0]Ba[5654,13,2501,2501,5436,3777,0,0]Ba[5654,13,2501,2501,6363,3777,0,0]Ba[5654,13,5680,5680,7123,3777,0,0]Ba[5654,13,5083,5083,6595,3777,0,0]Ba[5654,13,5083,5083,5978,3777,0,0]Ba[5654,13,2601,2601,6592,3777,0,0]Ba[5654,13,2601,2601,6593,3777,0,0]Ba[5654,13,2601,2601,6594,3777,0,0]Ba[5654,13,2601,2601,6752,3777,0,0]Ba[5654,13,4779,4779,4798,3777,0,0]Ba[5654,13,2690,2690,6739,3777,0,0]Ba[5654,13,2708,2708,4821,3777,0,0]Ba[5654,13,5503,5503,6737,3777,0,0]Ba[5654,13,5503,5503,7232,3777,0,0]Ba[5654,13,2002,2002,3762,3777,0,0]Ba[5654,13,2002,2002,4190,3777,0,0]Ba[5654,13,3058,3058,5438,3777,0,0]Ba[5654,13,1837,1837,4793,3777,0,0]Ba[5654,13,2364,2364,5703,3777,0,0]Ba[5654,13,2703,2703,4818,3777,0,0]Ba[5654,13,2703,2703,5451,3777,0,0]Ba[5654,13,2349,2349,5445,3777,0,0]Ba[5654,13,2349,2349,4832,3777,0,0]Ba[5654,13,2349,2349,5880,3777,0,0]Ba[5654,13,2349,2349,3769,3777,0,0]Ba[5654,13,2048,2048,7042,3777,0,0]Ba[5654,13,2048,2048,7184,3777,0,0]Ba[5654,13,2048,2048,7235,3777,0,0]Ba[5654,13,5816,5816,5455,3777,0,0]Ba[5654,13,2038,2038,3748,3777,0,0]Ba[5654,13,2038,2038,4826,3777,0,0]Ba[5654,13,2366,2366,6612,3777,0,0]Ba[5654,13,2727,2727,6371,3777,0,0]Ba[5654,13,2016,2016,4181,3777,0,0]Ba[5654,13,2016,2016,5464,3777,0,0]Ba[5654,13,6636,6636,6362,3777,0,0]Ba[5654,13,2003,2003,5462,3777,0,0]Ba[5654,13,2003,2003,5744,3777,0,0]Ba[5654,13,2003,2003,3759,3777,0,0]Ba[5654,13,2003,2003,3764,3777,0,0]Ba[5654,13,2003,2003,4813,3777,0,0]Ba[5654,13,3052,3052,6358,3777,0,0]Ba[5654,13,3052,3052,6360,3777,0,0]Ba[5654,13,5178,5178,5889,3777,0,0]Ba[5654,13,5178,5178,6003,3777,0,0]Ba[5654,13,2049,2049,3774,3777,0,0]Ba[5654,13,2049,2049,2559,3777,0,0]Ba[5654,13,2049,2049,3029,3777,0,0]Ba[5654,13,2049,2049,4150,3777,0,0]Ba[5654,13,2049,2049,2089,3777,0,0]Ba[5654,13,2049,2049,3760,3777,0,0]Ba[5654,13,2049,2049,4815,3777,0,0]Ba[5654,13,2049,2049,4814,3777,0,0]Ba[5654,13,2049,2049,5475,3777,0,0]Ba[5654,13,2049,2049,6596,3777,0,0]Ba[5654,13,2049,2049,5981,3777,0,0]Ba[5654,13,2049,2049,4831,3777,0,0]Ba[5654,13,2049,2049,5742,3777,0,0]Ba[5654,13,2049,2049,5453,3777,0,0]Ba[5654,13,2049,2049,5877,3777,0,0]Ba[5654,13,2049,2049,1797,3777,0,0]Ba[5654,13,2049,2049,3755,3777,0,0]Ba[5654,13,2049,2049,5442,3777,0,0]Ba[5654,13,2049,2049,5739,3777,0,0]Ba[5654,13,2049,2049,7120,3777,0,0]Ba[5654,13,2499,2499,3757,3777,0,0]Ba[5654,13,3153,3153,4836,3777,0,0]Ba[5654,13,3153,3153,2996,3777,0,0]Ba[5654,13,3153,3153,5695,3777,0,0]Ba[5654,13,3153,3153,6384,3777,0,0]Ba[5654,13,3153,3153,5748,3777,0,0]Ba[5654,13,3153,3153,5983,3777,0,0]Ba[5654,13,2496,2496,5457,3777,0,0]Ba[5654,13,4133,4133,6852,3777,0,0]An[5654,14,5503,5503,7034,0,0,0]Ag[5654,7,4852,0,0,0,0,0]An[5654,14,3153,3153,5695,0,0,0]An[5654,13,2376,2376,6367,0,0,0]An[5654,13,4855,4855,5974,0,0,0]An[5654,12,4749,4749,5985,0,0,0]Ba[5654,13,2711,2711,6603,5985,0,0]Ba[5654,13,2499,2499,6373,5985,0,0]Ba[5654,13,2376,2376,6367,5985,0,0]Ag[5654,7,2506,0,0,0,0,0]43214321An[5654,13,2505,2505,6380,0,0,0]Ag[5654,7,4772,0,0,0,0,0]7Ak[5654,14,4811,0,2095,0,0,0]Ak[5654,14,4811,0,1800,0,0,0]An[5654,14,2049,2049,3029,0,0,0]An[5654,14,2499,2499,6368,0,0,0]An[5654,14,3158,3158,5870,0,0,0]An[5654,14,2601,2601,6592,0,0,0]An[5654,14,2601,2601,6593,0,0,0]Ag[5654,7,3059,0,0,0,0,0]An[5654,13,5503,5503,6838,0,0,0]An[5654,13,2711,2711,6603,0,0,0]4Ag[5654,7,4752,0,0,0,0,0]An[5654,13,5110,5110,4166,0,0,0]Ag[5654,7,2739,0,0,0,0,0]Ag[5654,7,2711,0,0,0,0,0]An[5654,13,2711,2711,6603,0,0,0]An[5654,13,2376,2376,6367,0,0,0]Ag[5654,7,4749,0,0,0,0,0]Ag[5654,7,5901,0,0,0,0,0]An[5654,13,2349,2349,5875,0,0,0]=An[5654,13,2598,2598,5738,0,0,0]Ba[5654,13,2598,2598,4820,5738,0,0]Ba[5654,13,5132,5132,4176,5738,0,0]An[5654,13,3058,3058,5979,0,0,0]An[5654,13,2505,2505,6380,0,0,0]4An[5654,13,1837,1837,4188,0,0,0]1Ag[5654,7,2501,0,0,0,0,0]An[5654,14,2349,2349,4832,0,0,0]An[5654,13,4855,4855,5974,0,0,0]An[5654,14,2049,2049,5475,0,0,0]An[5654,14,2499,2499,5708,0,0,0]An[5654,14,2499,2499,7121,0,0,0]An[5654,13,5816,5816,6848,0,0,0]An[5654,13,2727,2727,6844,0,0,0]An[5654,14,2506,2506,7041,0,0,0]Ag[5654,7,5901,0,0,0,0,0]Ag[5654,5,2012,0,0,0,0,0]Ag[5654,5,5179,0,0,0,0,0]0An[5654,15,1534,1534,6629,0,0,0]Ag[5654,5,2506,0,0,0,0,0]Ah[5654,18,2601,0,0,0,0,0]Ag[5654,5,5503,0,0,0,0,0]Ah[5654,18,5110,0,0,0,0,0]Ag[5654,5,3059,0,0,0,0,0]Ag[5654,5,2499,0,0,0,0,0]0Aj[5654,4,2505,2727,0,0,0,0]Ag[5654,5,2727,0,0,0,0,0]3An[5654,15,2366,2366,6612,0,0,0]Ag[5654,5,5816,0,0,0,0,0]Ag[5654,5,5817,0,0,0,0,0]Ah[5654,10,5816,0,0,0,0,0]Ah[5654,18,5901,0,0,0,0,0]Ag[5654,5,4133,0,0,0,0,0]An[5654,14,2349,2349,4832,0,0,0]Af[5654,7,912,0,0,0,0,0]0An[5654,14,2711,2711,4154,0,0,0]An[5654,14,2049,2049,3760,0,0,0]An[5654,14,2499,2499,5443,0,0,0]An[5654,14,5967,5967,4193,0,0,0]Ag[5654,7,3059,0,0,0,0,0]Ag[5654,7,2601,0,0,0,0,0]An[5654,13,2505,2505,6380,0,0,0]0An[5654,12,4749,4749,5985,0,0,0]Ba[5654,13,2711,2711,6603,5985,0,0]Ba[5654,13,2499,2499,6373,5985,0,0]Ba[5654,13,2376,2376,6367,5985,0,0]6An[5654,14,5956,5956,7044,0,0,0]5An[5654,13,4855,4855,5974,0,0,0]Ag[5654,7,5132,0,0,0,0,0]Ba[5654,13,5655,5655,6361,3777,0,0]Ao[5654,13,911,911,4185,3777,0,0]An[5654,13,911,911,908,3777,0,0]Ao[5654,13,911,911,4152,3777,0,0]Ao[5654,13,911,911,2577,3777,0,0]Ba[5654,13,6569,6569,7291,3777,0,0]Ba[5654,13,1548,1548,5747,3777,0,0]Ba[5654,13,3059,3059,7182,3777,0,0]Ba[5654,13,3059,3059,5704,3777,0,0]Ba[5654,13,3059,3059,6597,3777,0,0]Ba[5654,13,3059,3059,5478,3777,0,0]Ba[5654,13,3059,3059,6355,3777,0,0]Ba[5654,13,3059,3059,7036,3777,0,0]Ba[5654,13,5179,5179,6004,3777,0,0]Ba[5654,13,5179,5179,6834,3777,0,0]Ba[5654,13,5179,5179,6833,3777,0,0]Ba[5654,13,5817,5817,6849,3777,0,0]Ba[5654,13,2506,2506,5456,3777,0,0]Ba[5654,13,2506,2506,5986,3777,0,0]Ba[5654,13,2506,2506,4829,3777,0,0]Ba[5654,13,2506,2506,5479,3777,0,0]Ba[5654,13,2506,2506,6627,3777,0,0]Ba[5654,13,2506,2506,7283,3777,0,0]Ba[5654,13,2506,2506,7125,3777,0,0]Ba[5654,13,2506,2506,7041,3777,0,0]Ba[5654,13,2506,2506,5740,3777,0,0]Ba[5654,13,4752,4752,6619,3777,0,0]Ba[5654,13,4752,4752,7114,3777,0,0]Ba[5654,13,2739,2739,4186,3777,0,0]Ba[5654,13,2739,2739,3030,3777,0,0]Ba[5654,13,2739,2739,5693,3777,0,0]Ba[5654,13,2739,2739,5884,3777,0,0]Ba[5654,13,2739,2739,6377,3777,0,0]Ba[5654,13,2739,2739,5696,3777,0,0]Ba[5654,13,2739,2739,4153,3777,0,0]Ba[5654,13,2501,2501,5436,3777,0,0]Ba[5654,13,2501,2501,6363,3777,0,0]Ba[5654,13,5680,5680,7123,3777,0,0]Ba[5654,13,5083,5083,6595,3777,0,0]Ba[5654,13,5083,5083,5978,3777,0,0]Ba[5654,13,2601,2601,6592,3777,0,0]Ba[5654,13,2601,2601,6593,3777,0,0]Ba[5654,13,2601,2601,6594,3777,0,0]Ba[5654,13,2601,2601,6752,3777,0,0]Ba[5654,13,4779,4779,4798,3777,0,0]Ba[5654,13,2690,2690,6739,3777,0,0]Ba[5654,13,2708,2708,4821,3777,0,0]Ba[5654,13,5503,5503,6737,3777,0,0]Ba[5654,13,5503,5503,7232,3777,0,0]Ba[5654,13,2002,2002,3762,3777,0,0]Ba[5654,13,2002,2002,4190,3777,0,0]Ba[5654,13,3058,3058,5438,3777,0,0]Ba[5654,13,1837,1837,4793,3777,0,0]Ba[5654,13,2364,2364,5703,3777,0,0]Ba[5654,13,2703,2703,4818,3777,0,0]Ba[5654,13,2703,2703,5451,3777,0,0]Ba[5654,13,2349,2349,5445,3777,0,0]Ba[5654,13,2349,2349,4832,3777,0,0]Ba[5654,13,2349,2349,5880,3777,0,0]Ba[5654,13,2349,2349,3769,3777,0,0]Ba[5654,13,2048,2048,7042,3777,0,0]Ba[5654,13,2048,2048,7184,3777,0,0]Ba[5654,13,2048,2048,7235,3777,0,0]Ba[5654,13,5816,5816,5455,3777,0,0]Ba[5654,13,2038,2038,3748,3777,0,0]Ba[5654,13,2038,2038,4826,3777,0,0]Ba[5654,13,2366,2366,6612,3777,0,0]Ba[5654,13,2727,2727,6371,3777,0,0]Ba[5654,13,2016,2016,4181,3777,0,0]Ba[5654,13,2016,2016,5464,3777,0,0]Ba[5654,13,6636,6636,6362,3777,0,0]Ba[5654,13,2003,2003,5462,3777,0,0]Ba[5654,13,2003,2003,5744,3777,0,0]Ba[5654,13,2003,2003,3759,3777,0,0]Ba[5654,13,2003,2003,3764,3777,0,0]Ba[5654,13,2003,2003,4813,3777,0,0]Ba[5654,13,3052,3052,6358,3777,0,0]Ba[5654,13,3052,3052,6360,3777,0,0]Ba[5654,13,5178,5178,5889,3777,0,0]Ba[5654,13,5178,5178,6003,3777,0,0]Ba[5654,13,2049,2049,3774,3777,0,0]Ba[5654,13,2049,2049,2559,3777,0,0]Ba[5654,13,2049,2049,3029,3777,0,0]Ba[5654,13,2049,2049,4150,3777,0,0]Ba[5654,13,2049,2049,2089,3777,0,0]Ba[5654,13,2049,2049,3760,3777,0,0]Ba[5654,13,2049,2049,4815,3777,0,0]Ba[5654,13,2049,2049,4814,3777,0,0]Ba[5654,13,2049,2049,5475,3777,0,0]Ba[5654,13,2049,2049,6596,3777,0,0]Ba[5654,13,2049,2049,5981,3777,0,0]Ba[5654,13,2049,2049,4831,3777,0,0]Ba[5654,13,2049,2049,5742,3777,0,0]Ba[5654,13,2049,2049,5453,3777,0,0]Ba[5654,13,2049,2049,5877,3777,0,0]Ba[5654,13,2049,2049,1797,3777,0,0]Ba[5654,13,2049,2049,3755,3777,0,0]Ba[5654,13,2049,2049,5442,3777,0,0]Ba[5654,13,2049,2049,5739,3777,0,0]Ba[5654,13,2049,2049,7120,3777,0,0]Ba[5654,13,2499,2499,3757,3777,0,0]Ba[5654,13,3153,3153,4836,3777,0,0]Ba[5654,13,3153,3153,2996,3777,0,0]Ba[5654,13,3153,3153,5695,3777,0,0]Ba[5654,13,3153,3153,6384,3777,0,0]Ba[5654,13,3153,3153,5748,3777,0,0]Ba[5654,13,3153,3153,5983,3777,0,0]Ba[5654,13,2496,2496,5457,3777,0,0]Ba[5654,13,4133,4133,6852,3777,0,0]An[5654,13,2547,2547,6006,0,0,0]Ag[5654,7,5083,0,0,0,0,0]An[5654,13,2546,2546,5868,0,0,0]An[5654,13,2049,2049,4831,0,0,0]Ag[5654,7,4851,0,0,0,0,0]An[5654,12,4749,4749,5985,0,0,0]Ba[5654,13,2711,2711,6603,5985,0,0]Ba[5654,13,2499,2499,6373,5985,0,0]Ba[5654,13,2376,2376,6367,5985,0,0]An[5654,13,2505,2505,6380,0,0,0]Ag[5654,7,4772,0,0,0,0,0]An[5654,14,5503,5503,6737,0,0,0]Ag[5654,7,2514,0,0,0,0,0]An[5654,13,2349,2349,5875,0,0,0]An[5654,13,2727,2727,6844,0,0,0]An[5654,14,2739,2739,4153,0,0,0]An[5654,14,2711,2711,4154,0,0,0]An[5654,14,2049,2049,3760,0,0,0]An[5654,14,2499,2499,5443,0,0,0]An[5654,14,2049,2049,3029,0,0,0]Ag[5654,7,2012,0,0,0,0,0]Ag[5654,7,2506,0,0,0,0,0]Ag[5654,7,2536,0,0,0,0,0]An[5654,13,2711,2711,6603,0,0,0]An[5654,13,2376,2376,6367,0,0,0]Ag[5654,7,3059,0,0,0,0,0]Aj[5654,4,2360,5155,0,0,0,0]Ag[5654,7,5155,0,0,0,0,0]Ag[5654,7,5084,0,0,0,0,0]Ag[5654,7,2514,0,0,0,0,0]0Ag[5654,7,4752,0,0,0,0,0]Ag[5654,7,1534,0,0,0,0,0]An[5654,13,2349,2349,5875,0,0,0]An[5654,13,2505,2505,6380,0,0,0]An[5654,13,2727,2727,6844,0,0,0]0Ag[5654,7,2739,0,0,0,0,0]000Ag[5654,7,2501,0,0,0,0,0]11Ag[5654,7,1548,0,0,0,0,0]0An[5654,14,1837,1837,4793,0,0,0]Ag[5654,7,4782,0,0,0,0,0]0An[5654,13,3058,3058,5979,0,0,0]Ah[5654,18,2690,0,0,0,0,0]Ah[5654,18,4855,0,0,0,0,0]Ag[5654,5,5503,0,0,0,0,0]An[5654,19,2711,2711,6603,0,0,0]Ag[5654,5,3059,0,0,0,0,0]0Ah[5654,18,5901,0,0,0,0,0]Ag[5654,5,2499,0,0,0,0,0]6Ag[5654,5,2506,0,0,0,0,0]Ag[5654,5,2048,0,0,0,0,0]Ah[5654,18,1534,0,0,0,0,0]Ag[5654,5,5956,0,0,0,0,0]Ag[5654,5,3149,0,0,0,0,0]An[5654,15,2366,2366,6612,0,0,0]An[5654,15,3052,3052,6358,0,0,0]68An[5654,14,5680,5680,7123,0,0,0]Ag[5654,7,4752,0,0,0,0,0]Al[5654,14,911,911,4791,0,0,0]Ag[5654,7,3059,0,0,0,0,0]Ag[5654,7,5083,0,0,0,0,0]An[5654,13,5816,5816,6848,0,0,0]An[5654,13,3058,3058,5979,0,0,0]An[5654,12,4749,4749,5985,0,0,0]Ba[5654,13,2711,2711,6603,5985,0,0]Ba[5654,13,2499,2499,6373,5985,0,0]Ba[5654,13,2376,2376,6367,5985,0,0]An[5654,13,4855,4855,5974,0,0,0]An[5654,13,2711,2711,6603,0,0,0]An[5654,13,2376,2376,6367,0,0,0]065436543An[5654,13,2499,2499,6373,0,0,0]1An[5654,13,2727,2727,6371,0,0,0]Ag[5654,7,2690,0,0,0,0,0]0=3Ag[5654,7,2708,0,0,0,0,0]Ag[5654,7,5084,0,0,0,0,0]Ag[5654,7,6338,0,0,0,0,0]An[5654,14,2349,2349,4832,0,0,0]Ag[5654,7,6788,0,0,0,0,0]An[5654,14,2049,2049,3029,0,0,0]Ag[5654,7,2012,0,0,0,0,0]An[5654,13,1837,1837,4188,0,0,0]An[5654,14,1837,1837,4793,0,0,0]An[5654,12,4749,4749,5985,0,0,0]Ba[5654,13,2711,2711,6603,5985,0,0]Ba[5654,13,2499,2499,6373,5985,0,0]Ba[5654,13,2376,2376,6367,5985,0,0]Ag[5654,7,4752,0,0,0,0,0]An[5654,13,2505,2505,6380,0,0,0]An[5654,13,1839,1839,5891,0,0,0]An[5654,13,2727,2727,6844,0,0,0]An[5654,13,2711,2711,6603,0,0,0]An[5654,13,2349,2349,5875,0,0,0]0Ag[5654,7,2739,0,0,0,0,0]0An[5654,14,2049,2049,4814,0,0,0]An[5654,14,2499,2499,6841,0,0,0]10Ag[5654,7,1548,0,0,0,0,0]Ag[5654,7,4779,0,0,0,0,0]Ag[5654,7,4782,0,0,0,0,0]An[5654,14,5179,5179,6833,0,0,0]Ag[5654,5,2012,0,0,0,0,0]Ah[5654,18,5816,0,0,0,0,0]Ag[5654,5,4752,0,0,0,0,0]Ah[5654,18,2601,0,0,0,0,0]Ah[5654,18,6338,0,0,0,0,0]0Ag[5654,5,2506,0,0,0,0,0]Ag[5654,5,2049,0,0,0,0,0]Ag[5654,5,2499,0,0,0,0,0]Ah[5654,18,3154,0,0,0,0,0]Ag[5654,5,5680,0,0,0,0,0]Ag[5654,6,2506,0,0,0,0,0]Ah[5654,18,5776,0,0,0,0,0]Ag[5654,5,2739,0,0,0,0,0]An[5654,14,2505,2505,6380,0,0,0]Ag[5654,7,2506,0,0,0,0,0]Ag[5654,7,3059,0,0,0,0,0]An[5654,14,5956,5956,7044,0,0,0]Ag[5654,7,5655,0,0,0,0,0]An[5654,13,4855,4855,5974,0,0,0]Ag[5654,7,5083,0,0,0,0,0]An[5654,13,5816,5816,6848,0,0,0]An[5654,13,2505,2505,6380,0,0,0]Ag[5654,7,6338,0,0,0,0,0]7An[5654,12,4749,4749,5985,0,0,0]Ba[5654,13,2711,2711,6603,5985,0,0]Ba[5654,13,2499,2499,6373,5985,0,0]Ba[5654,13,2376,2376,6367,5985,0,0]Ag[5654,7,5179,0,0,0,0,0]Ag[5654,7,2514,0,0,0,0,0]An[5654,13,2349,2349,5875,0,0,0];8An[5654,14,2049,2049,3029,0,0,0]>Ag[5654,7,6788,0,0,0,0,0]Ag[5654,7,3154,0,0,0,0,0]Ag[5654,7,2536,0,0,0,0,0]Ag[5654,7,2506,0,0,0,0,0]Ag[5654,7,3110,0,0,0,0,0]An[5654,13,1837,1837,4188,0,0,0]?7=<;:Ag[5654,7,4752,0,0,0,0,0]0An[5654,13,4855,4855,5974,0,0,0]An[5654,13,2505,2505,6380,0,0,0]Ag[5654,7,4662,0,0,0,0,0]1Ag[5654,7,2499,0,0,0,0,0]5Ag[5654,7,2601,0,0,0,0,0]Ag[5654,7,3059,0,0,0,0,0]Ag[5654,7,3789,0,0,0,0,0]Ag[5654,7,2739,0,0,0,0,0]0An[5654,14,2506,2506,7041,0,0,0]Ag[5654,7,4782,0,0,0,0,0]0Ag[5654,5,2499,0,0,0,0,0]Ag[5654,5,2739,0,0,0,0,0]Ag[5654,5,3059,0,0,0,0,0]Ag[5654,9,2711,0,0,0,0,0]1Ah[5654,18,5503,0,0,0,0,0]Ag[5654,5,2048,0,0,0,0,0]Af[5654,5,912,0,0,0,0,0]Ag[5654,6,2547,0,0,0,0,0]An[5654,14,1837,1837,4793,0,0,0]Ag[5654,7,5901,0,0,0,0,0]An[5654,13,2727,2727,6844,0,0,0]Af[5654,7,912,0,0,0,0,0]0An[5654,14,3153,3153,5748,0,0,0]0An[5654,14,2505,2505,6380,0,0,0]Ag[5654,7,2711,0,0,0,0,0]Ag[5654,7,2499,0,0,0,0,0]Ag[5654,7,3059,0,0,0,0,0]00An[5654,13,2505,2505,6380,0,0,0]An[5654,13,2016,2016,5464,0,0,0]An[5654,13,1837,1837,4188,0,0,0]Ag[5654,7,5179,0,0,0,0,0]An[5654,12,4749,4749,5985,0,0,0]Ba[5654,13,2711,2711,6603,5985,0,0]Ba[5654,13,2499,2499,6373,5985,0,0]Ba[5654,13,2376,2376,6367,5985,0,0]:Ag[5654,7,5132,0,0,0,0,0]Ag[5654,7,6788,0,0,0,0,0]Ag[5654,7,2012,0,0,0,0,0]Ag[5654,7,2724,0,0,0,0,0]Ag[5654,7,2536,0,0,0,0,0]0Ag[5654,7,2506,0,0,0,0,0]1An[5654,13,5956,5956,7044,0,0,0]An[5654,14,1837,1837,4793,0,0,0]Ag[5654,7,2514,0,0,0,0,0]An[5654,13,5503,5503,7034,0,0,0]An[5654,13,2016,2016,5464,0,0,0]An[5654,13,2727,2727,6844,0,0,0]Ag[5654,7,4779,0,0,0,0,0]An[5654,14,2049,2049,4814,0,0,0]An[5654,14,2499,2499,6841,0,0,0]An[5654,14,2349,2349,4832,0,0,0]Ag[5654,7,1548,0,0,0,0,0]0An[5654,13,4855,4855,5974,0,0,0]An[5654,13,3153,3153,5748,0,0,0]An[5654,13,3153,3153,5695,0,0,0]Ag[5654,5,5503,0,0,0,0,0]Ah[5654,18,5901,0,0,0,0,0]Ah[5654,18,4752,0,0,0,0,0]Ag[5654,5,2048,0,0,0,0,0]Ag[5654,5,6788,0,0,0,0,0]Ah[5654,18,5503,0,0,0,0,0]An[5654,14,2349,2349,5880,0,0,0]Af[5654,7,912,0,0,0,0,0]An[5654,14,2505,2505,6380,0,0,0]Ag[5654,7,2506,0,0,0,0,0]An[5654,13,2376,2376,6367,0,0,0]Ag[5654,7,5504,0,0,0,0,0]Ag[5654,7,3059,0,0,0,0,0]Ag[5654,7,3789,0,0,0,0,0]An[5654,13,5503,5503,7034,0,0,0]2An[5654,13,2711,2711,6603,0,0,0]5Ag[5654,7,2514,0,0,0,0,0]An[5654,13,2349,2349,5875,0,0,0]5An[5654,13,2505,2505,6380,0,0,0]An[5654,14,2049,2049,3029,0,0,0]An[5654,14,2499,2499,6368,0,0,0]Ag[5654,7,6788,0,0,0,0,0]<<<<<<<4An[5654,12,4749,4749,5985,0,0,0]Ba[5654,13,2711,2711,6603,5985,0,0]Ba[5654,13,2499,2499,6373,5985,0,0]Ba[5654,13,2376,2376,6367,5985,0,0]Ag[5654,7,4752,0,0,0,0,0]Ag[5654,7,6338,0,0,0,0,0]Ag[5654,7,2601,0,0,0,0,0]=0Ag[5654,7,2739,0,0,0,0,0]Ag[5654,7,2501,0,0,0,0,0]11Ag[5654,7,5901,0,0,0,0,0]An[5654,14,2349,2349,4832,0,0,0]An[5654,13,5816,5816,6848,0,0,0]An[5654,13,2727,2727,6844,0,0,0]Ag[5654,6,2506,0,0,0,0,0]Ag[5654,9,4782,0,0,0,0,0]Ah[5654,18,2505,0,0,0,0,0]Ah[5654,18,5816,0,0,0,0,0]Aj[5654,4,3055,4752,0,0,0,0]Ag[5654,5,4752,0,0,0,0,0]Ah[5654,18,4779,0,0,0,0,0]Ag[5654,6,6569,0,0,0,0,0]Ah[5654,18,6569,0,0,0,0,0]Ag[5654,7,2499,0,0,0,0,0]An[5654,14,1837,1837,4793,0,0,0]111Ag[5654,7,2506,0,0,0,0,0]?Ag[5654,7,4851,0,0,0,0,0]An[5654,13,2505,2505,6380,0,0,0]Ag[5654,7,5655,0,0,0,0,0]An[5654,13,5503,5503,7034,0,0,0]2Ag[5654,7,4779,0,0,0,0,0]4An[5654,13,2711,2711,6603,0,0,0]An[5654,13,2376,2376,6367,0,0,0]05Ag[5654,7,2601,0,0,0,0,0]Ag[5654,7,2690,0,0,0,0,0]9Ag[5654,7,6788,0,0,0,0,0]Ag[5654,7,2012,0,0,0,0,0];Ag[5654,7,2536,0,0,0,0,0]01An[5654,13,2499,2499,6373,0,0,0];An[5654,13,2349,2349,5875,0,0,0]An[5654,12,4749,4749,5985,0,0,0]Ba[5654,13,2711,2711,6603,5985,0,0]Ba[5654,13,2499,2499,6373,5985,0,0]Ba[5654,13,2376,2376,6367,5985,0,0]Ag[5654,7,4752,0,0,0,0,0]000Ag[5654,7,2506,0,0,0,0,0];Al[5654,13,911,911,4791,0,0,0]An[5654,13,2727,2727,6844,0,0,0]An[5654,13,2505,2505,6380,0,0,0]Ag[5654,7,2739,0,0,0,0,0]Ag[5654,7,2501,0,0,0,0,0]An[5654,13,5816,5816,6848,0,0,0]4An[5654,13,5178,5178,6003,0,0,0]Ag[5654,7,5901,0,0,0,0,0]An[5654,13,3052,3052,6360,0,0,0]Ah[5654,18,2601,0,0,0,0,0]Ah[5654,18,5901,0,0,0,0,0]An[5654,15,2366,2366,6612,0,0,0]An[5654,14,5680,5680,7123,0,0,0]An[5654,13,2711,2711,6603,0,0,0];Ag[5654,7,5504,0,0,0,0,0]Ag[5654,7,4851,0,0,0,0,0]Ag[5654,7,5179,0,0,0,0,0]0An[5654,13,2376,2376,6367,0,0,0]An[5654,12,4749,4749,5985,0,0,0]Ba[5654,13,2711,2711,6603,5985,0,0]Ba[5654,13,2499,2499,6373,5985,0,0]Ba[5654,13,2376,2376,6367,5985,0,0]Ag[5654,7,3154,0,0,0,0,0]Ag[5654,7,5817,0,0,0,0,0]Ag[5654,7,6788,0,0,0,0,0]Ag[5654,7,2536,0,0,0,0,0]Ag[5654,7,2506,0,0,0,0,0]4440040Ag[5654,7,2514,0,0,0,0,0]An[5654,13,2505,2505,6380,0,0,0]0Ag[5654,7,4752,0,0,0,0,0]7Ag[5654,7,3149,0,0,0,0,0]2An[5654,13,5956,5956,7044,0,0,0]Aj[5654,4,2360,5776,0,0,0,0]Ag[5654,7,5776,0,0,0,0,0]An[5654,13,2016,2016,5464,0,0,0]Ag[5654,7,5179,0,0,0,0,0]An[5654,13,3153,3153,5695,0,0,0]Af[5654,5,911,0,0,0,0,0]Ag[5654,5,5504,0,0,0,0,0]Ag[5654,5,2506,0,0,0,0,0]Ag[5654,5,5817,0,0,0,0,0]Ag[5654,7,3153,0,0,0,0,0]Ag[5654,7,2505,0,0,0,0,0]Ao[5654,13,911,911,4185,3766,0,0]An[5654,13,911,911,908,3766,0,0]Ao[5654,13,911,911,4152,3766,0,0]Ao[5654,13,911,911,2577,3766,0,0]Ao[5654,13,911,911,6381,3766,0,0]Ao[5654,13,911,911,7383,3766,0,0]Ao[5654,13,911,911,5444,3766,0,0]Ba[5654,13,1548,1548,5747,3766,0,0]Ba[5654,13,3059,3059,7182,3766,0,0]Ba[5654,13,3059,3059,5704,3766,0,0]Ba[5654,13,3059,3059,6597,3766,0,0]Ba[5654,13,3059,3059,7523,3766,0,0]Ba[5654,13,3059,3059,6840,3766,0,0]Ba[5654,13,3059,3059,7037,3766,0,0]Ba[5654,13,3059,3059,5478,3766,0,0]Ba[5654,13,3059,3059,6355,3766,0,0]Ba[5654,13,3059,3059,7036,3766,0,0]Ba[5654,13,5179,5179,6004,3766,0,0]Ba[5654,13,5179,5179,6834,3766,0,0]Ba[5654,13,5179,5179,6833,3766,0,0]Ba[5654,13,5817,5817,6849,3766,0,0]Ba[5654,13,2506,2506,5456,3766,0,0]Ba[5654,13,2506,2506,5986,3766,0,0]Ba[5654,13,2506,2506,4829,3766,0,0]Ba[5654,13,2506,2506,5479,3766,0,0]Ba[5654,13,2506,2506,5988,3766,0,0]Ba[5654,13,2506,2506,6375,3766,0,0]Ba[5654,13,2506,2506,6376,3766,0,0]Ba[5654,13,2506,2506,7119,3766,0,0]Ba[5654,13,2506,2506,6606,3766,0,0]Ba[5654,13,2506,2506,6605,3766,0,0]Ba[5654,13,2506,2506,6627,3766,0,0]Ba[5654,13,2506,2506,7283,3766,0,0]Ba[5654,13,2506,2506,7125,3766,0,0]Ba[5654,13,2506,2506,7041,3766,0,0]Ba[5654,13,2506,2506,5740,3766,0,0]Ba[5654,13,2012,2012,6589,3766,0,0]Ba[5654,13,2012,2012,6832,3766,0,0]Ba[5654,13,2012,2012,7112,3766,0,0]Ba[5654,13,4752,4752,6619,3766,0,0]Ba[5654,13,4752,4752,7114,3766,0,0]Ba[5654,13,2739,2739,4186,3766,0,0]Ba[5654,13,2739,2739,3030,3766,0,0]Ba[5654,13,2739,2739,5693,3766,0,0]Ba[5654,13,2739,2739,5884,3766,0,0]Ba[5654,13,2739,2739,6377,3766,0,0]Ba[5654,13,2739,2739,5696,3766,0,0]Ba[5654,13,2739,2739,4153,3766,0,0]Ba[5654,13,2501,2501,5436,3766,0,0]Ba[5654,13,2501,2501,6363,3766,0,0]Ba[5654,13,5680,5680,7123,3766,0,0]Ba[5654,13,4782,4782,5980,3766,0,0]Ba[5654,13,5083,5083,6595,3766,0,0]Ba[5654,13,5083,5083,5978,3766,0,0]Ba[5654,13,2601,2601,6592,3766,0,0]Ba[5654,13,2601,2601,6593,3766,0,0]Ba[5654,13,2601,2601,6594,3766,0,0]Ba[5654,13,2601,2601,6752,3766,0,0]Ba[5654,13,4779,4779,4798,3766,0,0]Ba[5654,13,2690,2690,6739,3766,0,0]Ba[5654,13,2708,2708,4821,3766,0,0]Ba[5654,13,5503,5503,6737,3766,0,0]Ba[5654,13,5503,5503,7232,3766,0,0]Ba[5654,13,2002,2002,3762,3766,0,0]Ba[5654,13,2002,2002,4190,3766,0,0]Ba[5654,13,3058,3058,5438,3766,0,0]Ba[5654,13,1837,1837,4793,3766,0,0]Ba[5654,13,2703,2703,4818,3766,0,0]Ba[5654,13,2703,2703,5451,3766,0,0]Ba[5654,13,2349,2349,5445,3766,0,0]Ba[5654,13,2349,2349,4832,3766,0,0]Ba[5654,13,2349,2349,5880,3766,0,0]Ba[5654,13,2349,2349,3769,3766,0,0]Ba[5654,13,5816,5816,5455,3766,0,0]Ba[5654,13,2038,2038,3748,3766,0,0]Ba[5654,13,2038,2038,4826,3766,0,0]Ba[5654,13,2366,2366,6612,3766,0,0]Ba[5654,13,2727,2727,6371,3766,0,0]Ba[5654,13,2016,2016,4181,3766,0,0]Ba[5654,13,2016,2016,5464,3766,0,0]Ba[5654,13,6636,6636,6362,3766,0,0]Ba[5654,13,2003,2003,5462,3766,0,0]Ba[5654,13,2003,2003,5744,3766,0,0]Ba[5654,13,2003,2003,3759,3766,0,0]Ba[5654,13,2003,2003,3764,3766,0,0]Ba[5654,13,2003,2003,4813,3766,0,0]Ba[5654,13,3052,3052,6358,3766,0,0]Ba[5654,13,3052,3052,6360,3766,0,0]Ba[5654,13,5178,5178,5889,3766,0,0]Ba[5654,13,5178,5178,6003,3766,0,0]Ba[5654,13,2049,2049,3774,3766,0,0]Ba[5654,13,2049,2049,2559,3766,0,0]Ba[5654,13,2049,2049,3029,3766,0,0]Ba[5654,13,2049,2049,4150,3766,0,0]Ba[5654,13,2049,2049,2089,3766,0,0]Ba[5654,13,2049,2049,3760,3766,0,0]Ba[5654,13,2049,2049,4815,3766,0,0]Ba[5654,13,2049,2049,4814,3766,0,0]Ba[5654,13,2049,2049,5475,3766,0,0]Ba[5654,13,2049,2049,6596,3766,0,0]Ba[5654,13,2049,2049,5981,3766,0,0]Ba[5654,13,2049,2049,4831,3766,0,0]Ba[5654,13,2049,2049,5742,3766,0,0]Ba[5654,13,2049,2049,5453,3766,0,0]Ba[5654,13,2049,2049,5877,3766,0,0]Ba[5654,13,2049,2049,1797,3766,0,0]Ba[5654,13,2049,2049,3755,3766,0,0]Ba[5654,13,2049,2049,5442,3766,0,0]Ba[5654,13,2049,2049,5739,3766,0,0]Ba[5654,13,2049,2049,7120,3766,0,0]Ba[5654,13,2499,2499,3757,3766,0,0]Ba[5654,13,3153,3153,4836,3766,0,0]Ba[5654,13,3153,3153,2996,3766,0,0]Ba[5654,13,3153,3153,5695,3766,0,0]Ba[5654,13,3153,3153,6384,3766,0,0]Ba[5654,13,3153,3153,5748,3766,0,0]Ba[5654,13,3153,3153,5983,3766,0,0]Ba[5654,13,4133,4133,6852,3766,0,0]Ag[5654,7,3059,0,0,0,0,0]0An[5654,13,2505,2505,6380,0,0,0]An[5654,14,2711,2711,4154,0,0,0]An[5654,14,2049,2049,3760,0,0,0]An[5654,14,2499,2499,5443,0,0,0]Ag[5654,7,2536,0,0,0,0,0]Ag[5654,7,2514,0,0,0,0,0]Ag[5654,7,4752,0,0,0,0,0]Ag[5654,7,4749,0,0,0,0,0]An[5654,13,5816,5816,6848,0,0,0]Ag[5654,7,2711,0,0,0,0,0]Ag[5654,7,2739,0,0,0,0,0]An[5654,14,2049,2049,4815,0,0,0];Ag[5654,7,2505,0,0,0,0,0]Ag[5654,5,3059,0,0,0,0,0]Ah[5654,18,4772,0,0,0,0,0]Ag[5654,5,5816,0,0,0,0,0]An[5654,14,3153,3153,5748,0,0,0]Ag[5654,7,2499,0,0,0,0,0]Ag[5654,7,5504,0,0,0,0,0]0An[5654,13,2505,2505,6380,0,0,0]Ag[5654,7,2506,0,0,0,0,0]>>>>:Ah[5654,18,5901,0,0,0,0,0]723Ag[5654,7,5655,0,0,0,0,0]2Ag[5654,7,2536,0,0,0,0,0]Ag[5654,7,4772,0,0,0,0,0]1Ag[5654,7,4752,0,0,0,0,0]00An[5654,13,2711,2711,6603,0,0,0]Ag[5654,7,3059,0,0,0,0,0]99Aj[5654,4,2505,1534,0,0,0,0]Ag[5654,7,1534,0,0,0,0,0]An[5654,13,1837,1837,4188,0,0,0]Ag[5654,7,5817,0,0,0,0,0];Ag[5654,7,2601,0,0,0,0,0]Ag[5654,7,2739,0,0,0,0,0]2Ag[5654,7,5901,0,0,0,0,0]Ag[5654,7,3154,0,0,0,0,0]:::Ag[5654,7,2505,0,0,0,0,0]32Ah[5654,18,4772,0,0,0,0,0]66<<<<<4Ag[5654,7,2690,0,0,0,0,0]7?==65An[5654,13,2505,2505,6380,0,0,0]>Ag[5654,7,5816,0,0,0,0,0]1??An[5654,12,4749,4749,5985,0,0,0]Ba[5654,13,2711,2711,6603,5985,0,0]Ba[5654,13,2499,2499,6373,5985,0,0]Cf[5654,13,2376,2376,6367,0,0,0,\"impl-HybridRepository\"]Dm[5654,13,2376,2376,6367,5985,0,0,\"impl-MediaRepository-for-HybridRepository\"]Ag[5654,7,4752,0,0,0,0,0]0Ag[5654,7,5680,0,0,0,0,0]=Ag[5654,7,3059,0,0,0,0,0]Ag[5654,7,5655,0,0,0,0,0]32An[5654,13,2499,2499,6373,0,0,0]44") \ No newline at end of file diff --git a/api/search.index/entry/db02f54839.js b/api/search.index/entry/db02f54839.js new file mode 100644 index 00000000..315ead13 --- /dev/null +++ b/api/search.index/entry/db02f54839.js @@ -0,0 +1 @@ +rd_("Ak[5654,14,6610,0,5881,0,0,0]Ak[5654,14,6610,0,5864,0,0,0]Ak[5654,14,6610,0,6389,0,0,0]Ak[5654,14,6356,0,5472,0,0,0]An[5654,14,4772,4772,5452,0,0,0]Ba[5654,13,5132,5132,4176,5738,0,0]An[5654,13,2505,2505,6380,0,0,0]An[5654,13,2016,2016,5464,0,0,0]An[5654,14,3059,3059,6840,0,0,0]An[5654,14,3059,3059,7037,0,0,0]An[5654,14,3058,3058,5438,0,0,0]0Ak[5654,14,5702,0,3015,0,0,0]Ag[5654,7,1539,0,0,0,0,0]0An[5654,13,5503,5503,6838,0,0,0]An[5654,14,5503,5503,6838,0,0,0]An[5654,14,2505,2505,5471,0,0,0]Ag[5654,2,6787,0,0,0,0,0]An[5654,14,2601,2601,6752,0,0,0]Ag[5654,2,3055,0,0,0,0,0]Ak[5654,14,6610,0,6000,0,0,0]An[5654,13,5816,5816,6848,0,0,0]Ag[5654,2,5655,0,0,0,0,0]Ag[5654,2,2506,0,0,0,0,0]Ag[5654,7,5660,0,0,0,0,0]7Ba[5654,13,5655,5655,6361,2583,0,0]Ao[5654,13,911,911,4185,2583,0,0]An[5654,13,911,911,908,2583,0,0]Ao[5654,13,911,911,4152,2583,0,0]Ao[5654,13,911,911,2577,2583,0,0]Ba[5654,13,6569,6569,7291,2583,0,0]Ba[5654,13,1548,1548,5747,2583,0,0]Ba[5654,13,3059,3059,7182,2583,0,0]Ba[5654,13,3059,3059,5704,2583,0,0]Ba[5654,13,3059,3059,6597,2583,0,0]Ba[5654,13,3059,3059,4149,2583,0,0]Ba[5654,13,3059,3059,5478,2583,0,0]Ba[5654,13,3059,3059,6355,2583,0,0]Ba[5654,13,3059,3059,7036,2583,0,0]Ba[5654,13,5179,5179,6004,2583,0,0]Ba[5654,13,5179,5179,6834,2583,0,0]Ba[5654,13,5179,5179,6833,2583,0,0]Ba[5654,13,2506,2506,5986,2583,0,0]Ba[5654,13,4752,4752,6619,2583,0,0]Ba[5654,13,4752,4752,7114,2583,0,0]Ba[5654,13,2739,2739,4186,2583,0,0]Ba[5654,13,2739,2739,3030,2583,0,0]Ba[5654,13,2739,2739,5693,2583,0,0]Ba[5654,13,2739,2739,5884,2583,0,0]Ba[5654,13,2739,2739,6377,2583,0,0]Ba[5654,13,2739,2739,5696,2583,0,0]Ba[5654,13,2739,2739,4153,2583,0,0]Ba[5654,13,2501,2501,5436,2583,0,0]Ba[5654,13,2501,2501,6363,2583,0,0]Ba[5654,13,5680,5680,7123,2583,0,0]Ba[5654,13,5083,5083,6595,2583,0,0]Ba[5654,13,5083,5083,5978,2583,0,0]Ba[5654,13,2601,2601,6592,2583,0,0]Ba[5654,13,2601,2601,6593,2583,0,0]Ba[5654,13,2601,2601,6594,2583,0,0]Ba[5654,13,2601,2601,6752,2583,0,0]Ba[5654,13,3789,3789,5987,2583,0,0]Ba[5654,13,4779,4779,3020,2583,0,0]Ba[5654,13,4779,4779,4798,2583,0,0]Ba[5654,13,4779,4779,3771,2583,0,0]Ba[5654,13,2690,2690,6739,2583,0,0]Ba[5654,13,5503,5503,6737,2583,0,0]Ba[5654,13,5503,5503,7232,2583,0,0]Ba[5654,13,5503,5503,7034,2583,0,0]Ba[5654,13,2002,2002,3762,2583,0,0]Ba[5654,13,2002,2002,4190,2583,0,0]Ba[5654,13,5155,5155,5448,2583,0,0]Ba[5654,13,3058,3058,5438,2583,0,0]Ba[5654,13,3058,3058,5439,2583,0,0]Ba[5654,13,1837,1837,4793,2583,0,0]Ba[5654,13,1837,1837,4188,2583,0,0]Ba[5654,13,2364,2364,5703,2583,0,0]Ba[5654,13,2703,2703,4818,2583,0,0]Ba[5654,13,2703,2703,5451,2583,0,0]Ba[5654,13,2703,2703,6747,2583,0,0]Ba[5654,13,2349,2349,5875,2583,0,0]Ba[5654,13,2349,2349,5445,2583,0,0]Ba[5654,13,2349,2349,4832,2583,0,0]Ba[5654,13,2349,2349,5880,2583,0,0]Ba[5654,13,2349,2349,3769,2583,0,0]Ba[5654,13,5110,5110,4168,2583,0,0]Ba[5654,13,5110,5110,3750,2583,0,0]Ba[5654,13,5110,5110,4166,2583,0,0]Ba[5654,13,2048,2048,5876,2583,0,0]Ba[5654,13,5816,5816,5455,2583,0,0]Ba[5654,13,3147,3147,6379,2583,0,0]Ba[5654,13,3147,3147,5990,2583,0,0]Ba[5654,13,3147,3147,6609,2583,0,0]Ba[5654,13,3158,3158,5870,2583,0,0]Ba[5654,13,2505,2505,6742,2583,0,0]Ba[5654,13,2038,2038,3748,2583,0,0]Ba[5654,13,2038,2038,4826,2583,0,0]Ba[5654,13,1534,1534,6629,2583,0,0]Ba[5654,13,1534,1534,4187,2583,0,0]Ba[5654,13,2598,2598,4823,2583,0,0]Ba[5654,13,2366,2366,6612,2583,0,0]Ba[5654,13,2727,2727,6371,2583,0,0]Ba[5654,13,2016,2016,4181,2583,0,0]Ba[5654,13,2016,2016,5464,2583,0,0]Ba[5654,13,2016,2016,4790,2583,0,0]Ba[5654,13,6636,6636,6362,2583,0,0]Ba[5654,13,2003,2003,5462,2583,0,0]Ba[5654,13,2003,2003,5744,2583,0,0]Ba[5654,13,2003,2003,3759,2583,0,0]Ba[5654,13,2003,2003,3764,2583,0,0]Ba[5654,13,2003,2003,4813,2583,0,0]Ba[5654,13,3052,3052,6358,2583,0,0]Ba[5654,13,3052,3052,6360,2583,0,0]Ba[5654,13,5178,5178,5889,2583,0,0]Ba[5654,13,5178,5178,6003,2583,0,0]Ba[5654,13,4772,4772,5452,2583,0,0]Ba[5654,13,2049,2049,3774,2583,0,0]Ba[5654,13,2049,2049,2559,2583,0,0]Ba[5654,13,2049,2049,3029,2583,0,0]Ba[5654,13,2049,2049,4150,2583,0,0]Ba[5654,13,2049,2049,2089,2583,0,0]Ba[5654,13,2049,2049,3760,2583,0,0]Ba[5654,13,2049,2049,4815,2583,0,0]Ba[5654,13,2049,2049,4814,2583,0,0]Ba[5654,13,2049,2049,5475,2583,0,0]Ba[5654,13,2049,2049,6596,2583,0,0]Ba[5654,13,2049,2049,5981,2583,0,0]Ba[5654,13,2049,2049,4831,2583,0,0]Ba[5654,13,2049,2049,5742,2583,0,0]Ba[5654,13,2049,2049,5453,2583,0,0]Ba[5654,13,2049,2049,5877,2583,0,0]Ba[5654,13,2049,2049,1797,2583,0,0]Ba[5654,13,2049,2049,3755,2583,0,0]Ba[5654,13,2049,2049,5442,2583,0,0]Ba[5654,13,2049,2049,5739,2583,0,0]Ba[5654,13,2049,2049,7120,2583,0,0]Ba[5654,13,2499,2499,3757,2583,0,0]Ba[5654,13,2499,2499,4804,2583,0,0]Ba[5654,13,2499,2499,6368,2583,0,0]Ba[5654,13,2499,2499,6842,2583,0,0]Ba[5654,13,2499,2499,6841,2583,0,0]Ba[5654,13,5956,5956,4827,2583,0,0]Ba[5654,13,3153,3153,4836,2583,0,0]Ba[5654,13,3153,3153,2996,2583,0,0]Ba[5654,13,3153,3153,5695,2583,0,0]Ba[5654,13,3153,3153,6384,2583,0,0]Ba[5654,13,3153,3153,5748,2583,0,0]Ba[5654,13,3153,3153,5983,2583,0,0]Ba[5654,13,4568,4568,1817,2583,0,0]Ba[5654,13,4568,4568,4179,2583,0,0]Ba[5654,13,2496,2496,5457,2583,0,0]Ba[5654,13,4133,4133,6852,2583,0,0]Ba[5654,13,1839,1839,4794,2583,0,0]An[5654,14,2506,2506,7119,0,0,0]An[5654,14,2012,2012,6832,0,0,0]Ba[5654,13,5655,5655,6361,2584,0,0]Ao[5654,13,911,911,4791,2584,0,0]Ao[5654,13,911,911,4185,2584,0,0]An[5654,13,911,911,908,2584,0,0]Ao[5654,13,911,911,4152,2584,0,0]Ao[5654,13,911,911,2577,2584,0,0]Ao[5654,13,911,911,6381,2584,0,0]Ao[5654,13,911,911,7383,2584,0,0]Ao[5654,13,911,911,5444,2584,0,0]Ba[5654,13,6569,6569,5999,2584,0,0]Ba[5654,13,6569,6569,7291,2584,0,0]Ba[5654,13,1548,1548,5747,2584,0,0]Ao[5654,13,912,912,6734,2584,0,0]Ao[5654,13,912,912,7185,2584,0,0]Ba[5654,13,5504,5504,7384,2584,0,0]Ba[5654,13,3059,3059,7180,2584,0,0]Ba[5654,13,3059,3059,6845,2584,0,0]Ba[5654,13,3059,3059,7182,2584,0,0]Ba[5654,13,3059,3059,5704,2584,0,0]Ba[5654,13,3059,3059,6597,2584,0,0]Ba[5654,13,3059,3059,7523,2584,0,0]Ba[5654,13,3059,3059,6840,2584,0,0]Ba[5654,13,3059,3059,7037,2584,0,0]Ba[5654,13,3059,3059,4149,2584,0,0]Ba[5654,13,3059,3059,5478,2584,0,0]Ba[5654,13,3059,3059,6355,2584,0,0]Ba[5654,13,3059,3059,7036,2584,0,0]Ba[5654,13,5179,5179,6004,2584,0,0]Ba[5654,13,5179,5179,6834,2584,0,0]Ba[5654,13,5179,5179,6833,2584,0,0]Ba[5654,13,5817,5817,7386,2584,0,0]Ba[5654,13,5817,5817,6849,2584,0,0]Ba[5654,13,6788,6788,7236,2584,0,0]Ba[5654,13,2506,2506,6623,2584,0,0]Ba[5654,13,2506,2506,6751,2584,0,0]Ba[5654,13,2506,2506,7385,2584,0,0]Ba[5654,13,2506,2506,7050,2584,0,0]Ba[5654,13,2506,2506,6836,2584,0,0]Ba[5654,13,2506,2506,5456,2584,0,0]Ba[5654,13,2506,2506,5986,2584,0,0]Ba[5654,13,2506,2506,4829,2584,0,0]Ba[5654,13,2506,2506,5479,2584,0,0]Ba[5654,13,2506,2506,5988,2584,0,0]Ba[5654,13,2506,2506,6375,2584,0,0]Ba[5654,13,2506,2506,6376,2584,0,0]Ba[5654,13,2506,2506,7119,2584,0,0]Ba[5654,13,2506,2506,6606,2584,0,0]Ba[5654,13,2506,2506,6605,2584,0,0]Ba[5654,13,2506,2506,6627,2584,0,0]Ba[5654,13,2506,2506,7283,2584,0,0]Ba[5654,13,2506,2506,7125,2584,0,0]Ba[5654,13,2506,2506,7041,2584,0,0]Ba[5654,13,2506,2506,5740,2584,0,0]Ba[5654,13,2012,2012,6589,2584,0,0]Ba[5654,13,2012,2012,6832,2584,0,0]Ba[5654,13,2012,2012,7112,2584,0,0]Ba[5654,13,4752,4752,6614,2584,0,0]Ba[5654,13,4752,4752,7288,2584,0,0]Ba[5654,13,4752,4752,6619,2584,0,0]Ba[5654,13,4752,4752,7114,2584,0,0]Ba[5654,13,2739,2739,5977,2584,0,0]Ba[5654,13,2739,2739,7179,2584,0,0]Ba[5654,13,2739,2739,7127,2584,0,0]Ba[5654,13,2739,2739,4186,2584,0,0]Ba[5654,13,2739,2739,3030,2584,0,0]Ba[5654,13,2739,2739,5693,2584,0,0]Ba[5654,13,2739,2739,5884,2584,0,0]Ba[5654,13,2739,2739,6377,2584,0,0]Ba[5654,13,2739,2739,5696,2584,0,0]Ba[5654,13,2739,2739,4153,2584,0,0]Ba[5654,13,2501,2501,5436,2584,0,0]Ba[5654,13,2501,2501,6363,2584,0,0]Ba[5654,13,5680,5680,7123,2584,0,0]Ba[5654,13,4782,4782,5980,2584,0,0]Ba[5654,13,5083,5083,6595,2584,0,0]Ba[5654,13,5083,5083,5978,2584,0,0]Ba[5654,13,2601,2601,5873,2584,0,0]Ba[5654,13,2601,2601,6592,2584,0,0]Ba[5654,13,2601,2601,6593,2584,0,0]Ba[5654,13,2601,2601,6594,2584,0,0]Ba[5654,13,2601,2601,6752,2584,0,0]Ba[5654,13,3789,3789,5987,2584,0,0]Ba[5654,13,4779,4779,4828,2584,0,0]Ba[5654,13,4779,4779,3020,2584,0,0]Ba[5654,13,4779,4779,4798,2584,0,0]Ba[5654,13,4779,4779,3771,2584,0,0]Ba[5654,13,2690,2690,6739,2584,0,0]Ba[5654,13,2708,2708,4821,2584,0,0]Ba[5654,13,3149,3149,7045,2584,0,0]Ba[5654,13,5503,5503,6838,2584,0,0]Ba[5654,13,5503,5503,6737,2584,0,0]Ba[5654,13,5503,5503,7232,2584,0,0]Ba[5654,13,5503,5503,7034,2584,0,0]Ba[5654,13,4855,4855,5974,2584,0,0]Ba[5654,13,4855,4855,6364,2584,0,0]Ba[5654,13,4855,4855,5973,2584,0,0]Ba[5654,13,2002,2002,3762,2584,0,0]Ba[5654,13,2002,2002,4190,2584,0,0]Ba[5654,13,5155,5155,5448,2584,0,0]Ba[5654,13,3058,3058,5979,2584,0,0]Ba[5654,13,3058,3058,5438,2584,0,0]Ba[5654,13,3058,3058,5439,2584,0,0]Ba[5654,13,1837,1837,4793,2584,0,0]Ba[5654,13,1837,1837,4188,2584,0,0]Ba[5654,13,2364,2364,5703,2584,0,0]Ba[5654,13,2703,2703,4818,2584,0,0]Ba[5654,13,2703,2703,5451,2584,0,0]Ba[5654,13,2703,2703,6747,2584,0,0]Ba[5654,13,2546,2546,5868,2584,0,0]Ba[5654,13,2546,2546,5866,2584,0,0]Ba[5654,13,2546,2546,5701,2584,0,0]Ba[5654,13,2349,2349,5875,2584,0,0]Ba[5654,13,2349,2349,5445,2584,0,0]Ba[5654,13,2349,2349,4832,2584,0,0]Ba[5654,13,2349,2349,5880,2584,0,0]Ba[5654,13,2349,2349,3769,2584,0,0]Ba[5654,13,5110,5110,4168,2584,0,0]Ba[5654,13,5110,5110,3750,2584,0,0]Ba[5654,13,5110,5110,4166,2584,0,0]Ba[5654,13,2048,2048,5876,2584,0,0]Ba[5654,13,2048,2048,7042,2584,0,0]Ba[5654,13,2048,2048,7184,2584,0,0]Ba[5654,13,2048,2048,7235,2584,0,0]Ba[5654,13,5660,5660,6743,2584,0,0]Ba[5654,13,5660,5660,4810,2584,0,0]Ba[5654,13,5660,5660,3022,2584,0,0]B`[5654,13,5660,5660,902,2584,0,0]Ba[5654,13,5816,5816,7525,2584,0,0]Ba[5654,13,5816,5816,6848,2584,0,0]Ba[5654,13,5816,5816,5455,2584,0,0]Ba[5654,13,3147,3147,6379,2584,0,0]Ba[5654,13,3147,3147,5990,2584,0,0]Ba[5654,13,3147,3147,6609,2584,0,0]Ba[5654,13,5967,5967,4193,2584,0,0]Ba[5654,13,5967,5967,5746,2584,0,0]Ba[5654,13,3158,3158,5870,2584,0,0]Ba[5654,13,2505,2505,5471,2584,0,0]Ba[5654,13,2505,2505,6380,2584,0,0]Ba[5654,13,2505,2505,6742,2584,0,0]Ba[5654,13,2038,2038,3748,2584,0,0]Ba[5654,13,2038,2038,4826,2584,0,0]Ba[5654,13,1534,1534,6629,2584,0,0]Ba[5654,13,1534,1534,4187,2584,0,0]Ba[5654,13,2598,2598,4820,2584,0,0]Ba[5654,13,2598,2598,4823,2584,0,0]Ba[5654,13,2366,2366,6625,2584,0,0]Ba[5654,13,2366,2366,6612,2584,0,0]Ba[5654,13,2727,2727,6844,2584,0,0]Ba[5654,13,2727,2727,6371,2584,0,0]Ba[5654,13,2016,2016,4181,2584,0,0]Ba[5654,13,2016,2016,5464,2584,0,0]Ba[5654,13,2016,2016,4790,2584,0,0]Ba[5654,13,6636,6636,6362,2584,0,0]Ba[5654,13,2003,2003,5462,2584,0,0]Ba[5654,13,2003,2003,5744,2584,0,0]Ba[5654,13,2003,2003,3759,2584,0,0]Ba[5654,13,2003,2003,3764,2584,0,0]Ba[5654,13,2003,2003,4813,2584,0,0]Ba[5654,13,3052,3052,6358,2584,0,0]Ba[5654,13,3052,3052,6360,2584,0,0]Ba[5654,13,5178,5178,5889,2584,0,0]Ba[5654,13,5178,5178,6003,2584,0,0]Ba[5654,13,4772,4772,5452,2584,0,0]Ba[5654,13,4772,4772,5741,2584,0,0]Ba[5654,13,5132,5132,4176,2584,0,0]Ba[5654,13,5132,5132,5707,2584,0,0]Ba[5654,13,2711,2711,6603,2584,0,0]Ba[5654,13,2711,2711,4154,2584,0,0]Ba[5654,13,2049,2049,3774,2584,0,0]Ba[5654,13,2049,2049,2559,2584,0,0]Ba[5654,13,2049,2049,3029,2584,0,0]Ba[5654,13,2049,2049,4150,2584,0,0]Ba[5654,13,2049,2049,2089,2584,0,0]Ba[5654,13,2049,2049,3760,2584,0,0]Ba[5654,13,2049,2049,4815,2584,0,0]Ba[5654,13,2049,2049,4814,2584,0,0]Ba[5654,13,2049,2049,5475,2584,0,0]Ba[5654,13,2049,2049,6596,2584,0,0]Ba[5654,13,2049,2049,5981,2584,0,0]Ba[5654,13,2049,2049,4831,2584,0,0]Ba[5654,13,2049,2049,5742,2584,0,0]Ba[5654,13,2049,2049,5453,2584,0,0]Ba[5654,13,2049,2049,5877,2584,0,0]Ba[5654,13,2049,2049,1797,2584,0,0]Ba[5654,13,2049,2049,3755,2584,0,0]Ba[5654,13,2049,2049,5442,2584,0,0]Ba[5654,13,2049,2049,5739,2584,0,0]Ba[5654,13,2049,2049,7120,2584,0,0]Ba[5654,13,2499,2499,6373,2584,0,0]Ba[5654,13,2499,2499,3757,2584,0,0]Ba[5654,13,2499,2499,4804,2584,0,0]Ba[5654,13,2499,2499,5708,2584,0,0]Ba[5654,13,2499,2499,7178,2584,0,0]Ba[5654,13,2499,2499,7121,2584,0,0]Ba[5654,13,2499,2499,7039,2584,0,0]Ba[5654,13,2499,2499,5443,2584,0,0]Ba[5654,13,2499,2499,6368,2584,0,0]Ba[5654,13,2499,2499,3754,2584,0,0]Ba[5654,13,2499,2499,6842,2584,0,0]Ba[5654,13,2499,2499,6841,2584,0,0]Ba[5654,13,2376,2376,6367,2584,0,0]Ba[5654,13,5956,5956,7044,2584,0,0]Ba[5654,13,5956,5956,4827,2584,0,0]Ba[5654,13,3153,3153,4836,2584,0,0]Ba[5654,13,3153,3153,2996,2584,0,0]Ba[5654,13,3153,3153,5695,2584,0,0]Ba[5654,13,3153,3153,6384,2584,0,0]Ba[5654,13,3153,3153,5748,2584,0,0]Ba[5654,13,3153,3153,5983,2584,0,0]Ba[5654,13,2738,2738,2989,2584,0,0]Ba[5654,13,4568,4568,4834,2584,0,0]Ba[5654,13,4568,4568,5995,2584,0,0]Ba[5654,13,4568,4568,1817,2584,0,0]Ba[5654,13,4568,4568,4179,2584,0,0]Ba[5654,13,2496,2496,5457,2584,0,0]Ba[5654,13,4133,4133,6852,2584,0,0]Ba[5654,13,1839,1839,5891,2584,0,0]Ba[5654,13,1839,1839,4794,2584,0,0]Ba[5654,13,2547,2547,6006,2584,0,0]Ba[5654,13,2547,2547,7186,2584,0,0]Ba[5654,13,5655,5655,6361,2585,0,0]Ao[5654,13,911,911,4791,2585,0,0]Ao[5654,13,911,911,4185,2585,0,0]An[5654,13,911,911,908,2585,0,0]Ao[5654,13,911,911,4152,2585,0,0]Ao[5654,13,911,911,2577,2585,0,0]Ao[5654,13,911,911,6381,2585,0,0]Ao[5654,13,911,911,7383,2585,0,0]Ao[5654,13,911,911,5444,2585,0,0]Ba[5654,13,6569,6569,5999,2585,0,0]Ba[5654,13,6569,6569,7291,2585,0,0]Ba[5654,13,1548,1548,5747,2585,0,0]Ao[5654,13,912,912,6734,2585,0,0]Ao[5654,13,912,912,7185,2585,0,0]Ba[5654,13,5504,5504,7384,2585,0,0]Ba[5654,13,3059,3059,7180,2585,0,0]Ba[5654,13,3059,3059,6845,2585,0,0]Ba[5654,13,3059,3059,7182,2585,0,0]Ba[5654,13,3059,3059,5704,2585,0,0]Ba[5654,13,3059,3059,6597,2585,0,0]Ba[5654,13,3059,3059,7523,2585,0,0]Ba[5654,13,3059,3059,6840,2585,0,0]Ba[5654,13,3059,3059,7037,2585,0,0]Ba[5654,13,3059,3059,4149,2585,0,0]Ba[5654,13,3059,3059,5478,2585,0,0]Ba[5654,13,3059,3059,6355,2585,0,0]Ba[5654,13,3059,3059,7036,2585,0,0]Ba[5654,13,5179,5179,6004,2585,0,0]Ba[5654,13,5179,5179,6834,2585,0,0]Ba[5654,13,5179,5179,6833,2585,0,0]Ba[5654,13,5817,5817,7386,2585,0,0]Ba[5654,13,5817,5817,6849,2585,0,0]Ba[5654,13,6788,6788,7236,2585,0,0]Ba[5654,13,2506,2506,6623,2585,0,0]Ba[5654,13,2506,2506,6751,2585,0,0]Ba[5654,13,2506,2506,7385,2585,0,0]Ba[5654,13,2506,2506,7050,2585,0,0]Ba[5654,13,2506,2506,6836,2585,0,0]Ba[5654,13,2506,2506,5456,2585,0,0]Ba[5654,13,2506,2506,5986,2585,0,0]Ba[5654,13,2506,2506,4829,2585,0,0]Ba[5654,13,2506,2506,5479,2585,0,0]Ba[5654,13,2506,2506,5988,2585,0,0]Ba[5654,13,2506,2506,6375,2585,0,0]Ba[5654,13,2506,2506,6376,2585,0,0]Ba[5654,13,2506,2506,7119,2585,0,0]Ba[5654,13,2506,2506,6606,2585,0,0]Ba[5654,13,2506,2506,6605,2585,0,0]Ba[5654,13,2506,2506,6627,2585,0,0]Ba[5654,13,2506,2506,7283,2585,0,0]Ba[5654,13,2506,2506,7125,2585,0,0]Ba[5654,13,2506,2506,7041,2585,0,0]Ba[5654,13,2506,2506,5740,2585,0,0]Ba[5654,13,2012,2012,6589,2585,0,0]Ba[5654,13,2012,2012,6832,2585,0,0]Ba[5654,13,2012,2012,7112,2585,0,0]Ba[5654,13,4752,4752,6614,2585,0,0]Ba[5654,13,4752,4752,7288,2585,0,0]Ba[5654,13,4752,4752,6619,2585,0,0]Ba[5654,13,4752,4752,7114,2585,0,0]Ba[5654,13,2739,2739,5977,2585,0,0]Ba[5654,13,2739,2739,7179,2585,0,0]Ba[5654,13,2739,2739,7127,2585,0,0]Ba[5654,13,2739,2739,4186,2585,0,0]Ba[5654,13,2739,2739,3030,2585,0,0]Ba[5654,13,2739,2739,5693,2585,0,0]Ba[5654,13,2739,2739,5884,2585,0,0]Ba[5654,13,2739,2739,6377,2585,0,0]Ba[5654,13,2739,2739,5696,2585,0,0]Ba[5654,13,2739,2739,4153,2585,0,0]Ba[5654,13,2501,2501,5436,2585,0,0]Ba[5654,13,2501,2501,6363,2585,0,0]Ba[5654,13,5680,5680,7123,2585,0,0]Ba[5654,13,4782,4782,5980,2585,0,0]Ba[5654,13,5083,5083,6595,2585,0,0]Ba[5654,13,5083,5083,5978,2585,0,0]Ba[5654,13,2601,2601,5873,2585,0,0]Ba[5654,13,2601,2601,6592,2585,0,0]Ba[5654,13,2601,2601,6593,2585,0,0]Ba[5654,13,2601,2601,6594,2585,0,0]Ba[5654,13,2601,2601,6752,2585,0,0]Ba[5654,13,3789,3789,5987,2585,0,0]Ba[5654,13,4779,4779,4828,2585,0,0]Ba[5654,13,4779,4779,3020,2585,0,0]Ba[5654,13,4779,4779,4798,2585,0,0]Ba[5654,13,4779,4779,3771,2585,0,0]Ba[5654,13,2690,2690,6739,2585,0,0]Ba[5654,13,2708,2708,4821,2585,0,0]Ba[5654,13,3149,3149,7045,2585,0,0]Ba[5654,13,5503,5503,6838,2585,0,0]Ba[5654,13,5503,5503,6737,2585,0,0]Ba[5654,13,5503,5503,7232,2585,0,0]Ba[5654,13,5503,5503,7034,2585,0,0]Ba[5654,13,4855,4855,5974,2585,0,0]Ba[5654,13,4855,4855,6364,2585,0,0]Ba[5654,13,4855,4855,5973,2585,0,0]Ba[5654,13,2002,2002,3762,2585,0,0]Ba[5654,13,2002,2002,4190,2585,0,0]Ba[5654,13,5155,5155,5448,2585,0,0]Ba[5654,13,3058,3058,5979,2585,0,0]Ba[5654,13,3058,3058,5438,2585,0,0]Ba[5654,13,3058,3058,5439,2585,0,0]Ba[5654,13,1837,1837,4793,2585,0,0]Ba[5654,13,1837,1837,4188,2585,0,0]Ba[5654,13,2364,2364,5703,2585,0,0]Ba[5654,13,2703,2703,4818,2585,0,0]Ba[5654,13,2703,2703,5451,2585,0,0]Ba[5654,13,2703,2703,6747,2585,0,0]Ba[5654,13,2546,2546,5868,2585,0,0]Ba[5654,13,2546,2546,5866,2585,0,0]Ba[5654,13,2546,2546,5701,2585,0,0]Ba[5654,13,2349,2349,5875,2585,0,0]Ba[5654,13,2349,2349,5445,2585,0,0]Ba[5654,13,2349,2349,4832,2585,0,0]Ba[5654,13,2349,2349,5880,2585,0,0]Ba[5654,13,2349,2349,3769,2585,0,0]Ba[5654,13,5110,5110,4168,2585,0,0]Ba[5654,13,5110,5110,3750,2585,0,0]Ba[5654,13,5110,5110,4166,2585,0,0]Ba[5654,13,2048,2048,5876,2585,0,0]Ba[5654,13,2048,2048,7042,2585,0,0]Ba[5654,13,2048,2048,7184,2585,0,0]Ba[5654,13,2048,2048,7235,2585,0,0]Ba[5654,13,5660,5660,6743,2585,0,0]Ba[5654,13,5660,5660,4810,2585,0,0]Ba[5654,13,5660,5660,3022,2585,0,0]B`[5654,13,5660,5660,902,2585,0,0]Ba[5654,13,5816,5816,7525,2585,0,0]Ba[5654,13,5816,5816,6848,2585,0,0]Ba[5654,13,5816,5816,5455,2585,0,0]Ba[5654,13,3147,3147,6379,2585,0,0]Ba[5654,13,3147,3147,5990,2585,0,0]Ba[5654,13,3147,3147,6609,2585,0,0]Ba[5654,13,5967,5967,4193,2585,0,0]Ba[5654,13,5967,5967,5746,2585,0,0]Ba[5654,13,3158,3158,5870,2585,0,0]Ba[5654,13,2505,2505,5471,2585,0,0]Ba[5654,13,2505,2505,6380,2585,0,0]Ba[5654,13,2505,2505,6742,2585,0,0]Ba[5654,13,2038,2038,3748,2585,0,0]Ba[5654,13,2038,2038,4826,2585,0,0]Ba[5654,13,1534,1534,6629,2585,0,0]Ba[5654,13,1534,1534,4187,2585,0,0]Ba[5654,13,2598,2598,4820,2585,0,0]Ba[5654,13,2598,2598,4823,2585,0,0]Ba[5654,13,2366,2366,6625,2585,0,0]Ba[5654,13,2366,2366,6612,2585,0,0]Ba[5654,13,2727,2727,6844,2585,0,0]Ba[5654,13,2727,2727,6371,2585,0,0]Ba[5654,13,2016,2016,4181,2585,0,0]Ba[5654,13,2016,2016,5464,2585,0,0]Ba[5654,13,2016,2016,4790,2585,0,0]Ba[5654,13,6636,6636,6362,2585,0,0]Ba[5654,13,2003,2003,5462,2585,0,0]Ba[5654,13,2003,2003,5744,2585,0,0]Ba[5654,13,2003,2003,3759,2585,0,0]Ba[5654,13,2003,2003,3764,2585,0,0]Ba[5654,13,2003,2003,4813,2585,0,0]Ba[5654,13,3052,3052,6358,2585,0,0]Ba[5654,13,3052,3052,6360,2585,0,0]Ba[5654,13,5178,5178,5889,2585,0,0]Ba[5654,13,5178,5178,6003,2585,0,0]Ba[5654,13,4772,4772,5452,2585,0,0]Ba[5654,13,4772,4772,5741,2585,0,0]Ba[5654,13,5132,5132,4176,2585,0,0]Ba[5654,13,5132,5132,5707,2585,0,0]Ba[5654,13,2711,2711,6603,2585,0,0]Ba[5654,13,2711,2711,4154,2585,0,0]Ba[5654,13,2049,2049,3774,2585,0,0]Ba[5654,13,2049,2049,2559,2585,0,0]Ba[5654,13,2049,2049,3029,2585,0,0]Ba[5654,13,2049,2049,4150,2585,0,0]Ba[5654,13,2049,2049,2089,2585,0,0]Ba[5654,13,2049,2049,3760,2585,0,0]Ba[5654,13,2049,2049,4815,2585,0,0]Ba[5654,13,2049,2049,4814,2585,0,0]Ba[5654,13,2049,2049,5475,2585,0,0]Ba[5654,13,2049,2049,6596,2585,0,0]Ba[5654,13,2049,2049,5981,2585,0,0]Ba[5654,13,2049,2049,4831,2585,0,0]Ba[5654,13,2049,2049,5742,2585,0,0]Ba[5654,13,2049,2049,5453,2585,0,0]Ba[5654,13,2049,2049,5877,2585,0,0]Ba[5654,13,2049,2049,1797,2585,0,0]Ba[5654,13,2049,2049,3755,2585,0,0]Ba[5654,13,2049,2049,5442,2585,0,0]Ba[5654,13,2049,2049,5739,2585,0,0]Ba[5654,13,2049,2049,7120,2585,0,0]Ba[5654,13,2499,2499,6373,2585,0,0]Ba[5654,13,2499,2499,3757,2585,0,0]Ba[5654,13,2499,2499,4804,2585,0,0]Ba[5654,13,2499,2499,5708,2585,0,0]Ba[5654,13,2499,2499,7178,2585,0,0]Ba[5654,13,2499,2499,7121,2585,0,0]Ba[5654,13,2499,2499,7039,2585,0,0]Ba[5654,13,2499,2499,5443,2585,0,0]Ba[5654,13,2499,2499,6368,2585,0,0]Ba[5654,13,2499,2499,3754,2585,0,0]Ba[5654,13,2499,2499,6842,2585,0,0]Ba[5654,13,2499,2499,6841,2585,0,0]Ba[5654,13,2376,2376,6367,2585,0,0]Ba[5654,13,5956,5956,7044,2585,0,0]Ba[5654,13,5956,5956,4827,2585,0,0]Ba[5654,13,3153,3153,4836,2585,0,0]Ba[5654,13,3153,3153,2996,2585,0,0]Ba[5654,13,3153,3153,5695,2585,0,0]Ba[5654,13,3153,3153,6384,2585,0,0]Ba[5654,13,3153,3153,5748,2585,0,0]Ba[5654,13,3153,3153,5983,2585,0,0]Ba[5654,13,2738,2738,2989,2585,0,0]Ba[5654,13,4568,4568,4834,2585,0,0]Ba[5654,13,4568,4568,5995,2585,0,0]Ba[5654,13,4568,4568,1817,2585,0,0]Ba[5654,13,4568,4568,4179,2585,0,0]Ba[5654,13,2496,2496,5457,2585,0,0]Ba[5654,13,4133,4133,6852,2585,0,0]Ba[5654,13,1839,1839,5891,2585,0,0]Ba[5654,13,1839,1839,4794,2585,0,0]Ba[5654,13,2547,2547,6006,2585,0,0]Ba[5654,13,2547,2547,7186,2585,0,0]Al[5654,14,911,911,2577,0,0,0]An[5654,14,2739,2739,3030,0,0,0]An[5654,14,2739,2739,5693,0,0,0]2An[5654,15,3153,3153,2996,0,0,0]An[5654,15,2366,2366,6612,0,0,0]Ah[5654,18,5660,0,0,0,0,0]An[5654,15,2364,2364,5703,0,0,0]30Ah[5654,18,2499,0,0,0,0,0]An[5654,15,2003,2003,4813,0,0,0]Aj[5654,4,2505,2038,0,0,0,0]Ag[5654,6,2038,0,0,0,0,0]Ag[5654,6,5110,0,0,0,0,0]An[5654,15,5660,5660,3022,0,0,0]Ag[5654,6,2499,0,0,0,0,0]Ag[5654,6,2049,0,0,0,0,0]Aj[5654,4,4749,2499,0,0,0,0]Ag[5654,5,2499,0,0,0,0,0]Aj[5654,4,2505,2003,0,0,0,0]Ag[5654,5,2003,0,0,0,0,0]Ag[5654,5,2049,0,0,0,0,0]Aj[5654,4,2360,2002,0,0,0,0]Ag[5654,6,2002,0,0,0,0,0]4Ag[5654,6,2003,0,0,0,0,0]Ah[5654,10,1486,0,0,0,0,0]Ag[5654,5,2349,0,0,0,0,0]Ag[5654,5,4779,0,0,0,0,0];An[5654,15,5155,5155,5448,0,0,0]0An[5654,14,4779,4779,4798,0,0,0]An[5654,14,1839,1839,5891,0,0,0]An[5654,14,2049,2049,4814,0,0,0]An[5654,14,2499,2499,6841,0,0,0]An[5654,14,6569,6569,5999,0,0,0]An[5654,14,2349,2349,4832,0,0,0]An[5654,14,2048,2048,5876,0,0,0]An[5654,14,3059,3059,6597,0,0,0]An[5654,13,1839,1839,5891,0,0,0]Ag[5654,2,3055,0,0,0,0,0]An[5654,14,3059,3059,6840,0,0,0]An[5654,14,3059,3059,7037,0,0,0]An[5654,14,3058,3058,5438,0,0,0]Ak[5654,14,5702,0,3742,0,0,0]Ak[5654,14,4811,0,1800,0,0,0]2An[5654,13,2738,2738,2989,0,0,0]An[5654,12,4749,4749,5985,0,0,0]Ba[5654,13,2711,2711,6603,5985,0,0]Ba[5654,13,2499,2499,6373,5985,0,0]Ba[5654,13,2376,2376,6367,5985,0,0];An[5654,13,4855,4855,5974,0,0,0]An[5654,14,2739,2739,5696,0,0,0]An[5654,14,2049,2049,2559,0,0,0]An[5654,14,2739,2739,3030,0,0,0]An[5654,14,2349,2349,4832,0,0,0]Ak[5654,14,6369,0,1780,0,0,0]Ak[5654,14,6369,0,1807,0,0,0]Ak[5654,14,6369,0,2113,0,0,0]An[5654,13,5178,5178,6003,0,0,0]An[5654,14,2711,2711,4154,0,0,0]An[5654,14,2049,2049,3760,0,0,0]An[5654,14,2499,2499,5443,0,0,0]An[5654,14,2049,2049,4815,0,0,0]An[5654,14,2499,2499,6842,0,0,0]An[5654,14,2349,2349,3769,0,0,0]An[5654,14,2048,2048,7042,0,0,0]An[5654,14,2048,2048,7235,0,0,0]Ak[5654,14,6607,0,3016,0,0,0]An[5654,13,2038,2038,4826,0,0,0]An[5654,14,2739,2739,6377,0,0,0]An[5654,14,2049,2049,3029,0,0,0]An[5654,14,2499,2499,6368,0,0,0]Ag[5654,7,6338,0,0,0,0,0]0An[5654,14,1548,1548,5747,0,0,0]An[5654,14,3059,3059,7523,0,0,0]An[5654,14,3059,3059,6840,0,0,0]An[5654,14,3059,3059,7037,0,0,0]An[5654,14,3058,3058,5438,0,0,0]An[5654,14,2506,2506,5988,0,0,0]An[5654,14,2739,2739,4153,0,0,0]An[5654,14,2708,2708,4821,0,0,0]An[5654,14,2349,2349,5880,0,0,0]An[5654,14,2003,2003,3759,0,0,0]An[5654,14,2711,2711,4154,0,0,0]An[5654,14,2049,2049,3760,0,0,0]An[5654,14,2499,2499,5443,0,0,0]An[5654,13,2016,2016,5464,0,0,0]Ag[5654,7,5155,0,0,0,0,0]Ak[5654,14,6369,0,1780,0,0,0]An[5654,12,1486,1486,3765,0,0,0]Ag[5654,7,5083,0,0,0,0,0]An[5654,14,4782,4782,5980,0,0,0]An[5654,14,2049,2049,5442,0,0,0]An[5654,14,3059,3059,6840,0,0,0]An[5654,14,3059,3059,7037,0,0,0]An[5654,14,3058,3058,5438,0,0,0]An[5654,14,2003,2003,5744,0,0,0]:An[5654,14,1548,1548,5747,0,0,0]An[5654,14,4779,4779,3771,0,0,0]An[5654,14,2739,2739,4153,0,0,0]An[5654,14,2711,2711,4154,0,0,0]An[5654,14,2049,2049,3760,0,0,0]An[5654,14,2499,2499,5443,0,0,0]An[5654,14,2501,2501,6363,0,0,0]An[5654,13,2505,2505,6380,0,0,0]An[5654,13,2499,2499,6373,0,0,0]Ag[5654,7,4568,0,0,0,0,0]An[5654,12,4568,4568,5976,0,0,0]Ba[5654,13,4568,4568,5995,5976,0,0]=An[5654,12,1486,1486,4182,0,0,0]An[5654,14,2049,2049,5981,0,0,0]An[5654,13,911,911,4185,907,0,0]Am[5654,13,911,911,908,907,0,0]An[5654,13,911,911,4152,907,0,0]An[5654,13,911,911,2577,907,0,0]An[5654,13,911,911,6381,907,0,0]An[5654,13,911,911,7383,907,0,0]An[5654,13,911,911,5444,907,0,0]B`[5654,13,1548,1548,5747,907,0,0]B`[5654,13,3059,3059,7182,907,0,0]B`[5654,13,3059,3059,5704,907,0,0]B`[5654,13,3059,3059,6597,907,0,0]B`[5654,13,3059,3059,7523,907,0,0]B`[5654,13,3059,3059,6840,907,0,0]B`[5654,13,3059,3059,7037,907,0,0]B`[5654,13,3059,3059,5478,907,0,0]B`[5654,13,3059,3059,6355,907,0,0]B`[5654,13,3059,3059,7036,907,0,0]B`[5654,13,5179,5179,6004,907,0,0]B`[5654,13,5179,5179,6834,907,0,0]B`[5654,13,5179,5179,6833,907,0,0]B`[5654,13,5817,5817,6849,907,0,0]B`[5654,13,2506,2506,5456,907,0,0]B`[5654,13,2506,2506,5986,907,0,0]B`[5654,13,2506,2506,4829,907,0,0]B`[5654,13,2506,2506,5479,907,0,0]B`[5654,13,2506,2506,5988,907,0,0]B`[5654,13,2506,2506,6375,907,0,0]B`[5654,13,2506,2506,6376,907,0,0]B`[5654,13,2506,2506,7119,907,0,0]B`[5654,13,2506,2506,6606,907,0,0]B`[5654,13,2506,2506,6605,907,0,0]B`[5654,13,2506,2506,6627,907,0,0]B`[5654,13,2506,2506,7283,907,0,0]B`[5654,13,2506,2506,7125,907,0,0]B`[5654,13,2506,2506,7041,907,0,0]B`[5654,13,2506,2506,5740,907,0,0]B`[5654,13,2012,2012,6589,907,0,0]B`[5654,13,2012,2012,6832,907,0,0]B`[5654,13,2012,2012,7112,907,0,0]B`[5654,13,4752,4752,6619,907,0,0]B`[5654,13,4752,4752,7114,907,0,0]B`[5654,13,2739,2739,4186,907,0,0]B`[5654,13,2739,2739,3030,907,0,0]B`[5654,13,2739,2739,5693,907,0,0]B`[5654,13,2739,2739,5884,907,0,0]B`[5654,13,2739,2739,6377,907,0,0]B`[5654,13,2739,2739,5696,907,0,0]B`[5654,13,2739,2739,4153,907,0,0]B`[5654,13,2501,2501,5436,907,0,0]B`[5654,13,2501,2501,6363,907,0,0]B`[5654,13,5680,5680,7123,907,0,0]B`[5654,13,4782,4782,5980,907,0,0]B`[5654,13,5083,5083,6595,907,0,0]B`[5654,13,5083,5083,5978,907,0,0]B`[5654,13,2601,2601,6592,907,0,0]B`[5654,13,2601,2601,6593,907,0,0]B`[5654,13,2601,2601,6594,907,0,0]B`[5654,13,2601,2601,6752,907,0,0]B`[5654,13,4779,4779,4798,907,0,0]B`[5654,13,2690,2690,6739,907,0,0]B`[5654,13,2708,2708,4821,907,0,0]B`[5654,13,5503,5503,6737,907,0,0]B`[5654,13,5503,5503,7232,907,0,0]B`[5654,13,2002,2002,3762,907,0,0]B`[5654,13,2002,2002,4190,907,0,0]B`[5654,13,3058,3058,5438,907,0,0]B`[5654,13,1837,1837,4793,907,0,0]B`[5654,13,2703,2703,4818,907,0,0]B`[5654,13,2703,2703,5451,907,0,0]B`[5654,13,2349,2349,5445,907,0,0]B`[5654,13,2349,2349,4832,907,0,0]B`[5654,13,2349,2349,5880,907,0,0]B`[5654,13,2349,2349,3769,907,0,0]B`[5654,13,5816,5816,5455,907,0,0]B`[5654,13,2038,2038,3748,907,0,0]B`[5654,13,2038,2038,4826,907,0,0]B`[5654,13,2366,2366,6612,907,0,0]B`[5654,13,2727,2727,6371,907,0,0]B`[5654,13,2016,2016,4181,907,0,0]B`[5654,13,2016,2016,5464,907,0,0]B`[5654,13,6636,6636,6362,907,0,0]B`[5654,13,2003,2003,5462,907,0,0]B`[5654,13,2003,2003,5744,907,0,0]B`[5654,13,2003,2003,3759,907,0,0]B`[5654,13,2003,2003,3764,907,0,0]B`[5654,13,2003,2003,4813,907,0,0]B`[5654,13,3052,3052,6358,907,0,0]B`[5654,13,3052,3052,6360,907,0,0]B`[5654,13,5178,5178,5889,907,0,0]B`[5654,13,5178,5178,6003,907,0,0]B`[5654,13,2049,2049,3774,907,0,0]B`[5654,13,2049,2049,2559,907,0,0]B`[5654,13,2049,2049,3029,907,0,0]B`[5654,13,2049,2049,4150,907,0,0]B`[5654,13,2049,2049,2089,907,0,0]B`[5654,13,2049,2049,3760,907,0,0]B`[5654,13,2049,2049,4815,907,0,0]B`[5654,13,2049,2049,4814,907,0,0]B`[5654,13,2049,2049,5475,907,0,0]B`[5654,13,2049,2049,6596,907,0,0]B`[5654,13,2049,2049,5981,907,0,0]B`[5654,13,2049,2049,4831,907,0,0]B`[5654,13,2049,2049,5742,907,0,0]B`[5654,13,2049,2049,5453,907,0,0]B`[5654,13,2049,2049,5877,907,0,0]B`[5654,13,2049,2049,1797,907,0,0]B`[5654,13,2049,2049,3755,907,0,0]B`[5654,13,2049,2049,5442,907,0,0]B`[5654,13,2049,2049,5739,907,0,0]B`[5654,13,2049,2049,7120,907,0,0]B`[5654,13,2499,2499,3757,907,0,0]B`[5654,13,3153,3153,4836,907,0,0]B`[5654,13,3153,3153,2996,907,0,0]B`[5654,13,3153,3153,5695,907,0,0]B`[5654,13,3153,3153,6384,907,0,0]B`[5654,13,3153,3153,5748,907,0,0]B`[5654,13,3153,3153,5983,907,0,0]B`[5654,13,4133,4133,6852,907,0,0]An[5654,14,4779,4779,4798,0,0,0]Ak[5654,14,5887,0,2994,0,0,0]Ag[5654,7,3789,0,0,0,0,0]Ag[5654,7,4779,0,0,0,0,0]An[5654,13,3153,3153,5983,0,0,0]An[5654,14,2739,2739,4153,0,0,0]An[5654,14,2711,2711,4154,0,0,0]An[5654,14,2049,2049,3760,0,0,0]An[5654,14,2499,2499,5443,0,0,0]Ba[5654,13,5655,5655,6361,3776,0,0]Ao[5654,13,911,911,4185,3776,0,0]An[5654,13,911,911,908,3776,0,0]Ao[5654,13,911,911,4152,3776,0,0]Ao[5654,13,911,911,2577,3776,0,0]Ba[5654,13,6569,6569,7291,3776,0,0]Ba[5654,13,1548,1548,5747,3776,0,0]Ba[5654,13,3059,3059,7182,3776,0,0]Ba[5654,13,3059,3059,5704,3776,0,0]Ba[5654,13,3059,3059,6597,3776,0,0]Ba[5654,13,3059,3059,5478,3776,0,0]Ba[5654,13,3059,3059,6355,3776,0,0]Ba[5654,13,3059,3059,7036,3776,0,0]Ba[5654,13,5179,5179,6004,3776,0,0]Ba[5654,13,5179,5179,6834,3776,0,0]Ba[5654,13,5179,5179,6833,3776,0,0]Ba[5654,13,5817,5817,6849,3776,0,0]Ba[5654,13,2506,2506,5456,3776,0,0]Ba[5654,13,2506,2506,5986,3776,0,0]Ba[5654,13,2506,2506,4829,3776,0,0]Ba[5654,13,2506,2506,5479,3776,0,0]Ba[5654,13,2506,2506,6627,3776,0,0]Ba[5654,13,2506,2506,7283,3776,0,0]Ba[5654,13,2506,2506,7125,3776,0,0]Ba[5654,13,2506,2506,7041,3776,0,0]Ba[5654,13,2506,2506,5740,3776,0,0]Ba[5654,13,4752,4752,6619,3776,0,0]Ba[5654,13,4752,4752,7114,3776,0,0]Ba[5654,13,2739,2739,4186,3776,0,0]Ba[5654,13,2739,2739,3030,3776,0,0]Ba[5654,13,2739,2739,5693,3776,0,0]Ba[5654,13,2739,2739,5884,3776,0,0]Ba[5654,13,2739,2739,6377,3776,0,0]Ba[5654,13,2739,2739,5696,3776,0,0]Ba[5654,13,2739,2739,4153,3776,0,0]Ba[5654,13,2501,2501,5436,3776,0,0]Ba[5654,13,2501,2501,6363,3776,0,0]Ba[5654,13,5680,5680,7123,3776,0,0]Ba[5654,13,5083,5083,6595,3776,0,0]Ba[5654,13,5083,5083,5978,3776,0,0]Ba[5654,13,2601,2601,6592,3776,0,0]Ba[5654,13,2601,2601,6593,3776,0,0]Ba[5654,13,2601,2601,6594,3776,0,0]Ba[5654,13,2601,2601,6752,3776,0,0]Ba[5654,13,4779,4779,4798,3776,0,0]Ba[5654,13,2690,2690,6739,3776,0,0]Ba[5654,13,2708,2708,4821,3776,0,0]Ba[5654,13,5503,5503,6737,3776,0,0]Ba[5654,13,5503,5503,7232,3776,0,0]Ba[5654,13,2002,2002,3762,3776,0,0]Ba[5654,13,2002,2002,4190,3776,0,0]Ba[5654,13,3058,3058,5438,3776,0,0]Ba[5654,13,1837,1837,4793,3776,0,0]Ba[5654,13,2364,2364,5703,3776,0,0]Ba[5654,13,2703,2703,4818,3776,0,0]Ba[5654,13,2703,2703,5451,3776,0,0]Ba[5654,13,2349,2349,5445,3776,0,0]Ba[5654,13,2349,2349,4832,3776,0,0]Ba[5654,13,2349,2349,5880,3776,0,0]Ba[5654,13,2349,2349,3769,3776,0,0]Ba[5654,13,2048,2048,7042,3776,0,0]Ba[5654,13,2048,2048,7184,3776,0,0]Ba[5654,13,2048,2048,7235,3776,0,0]Ba[5654,13,5816,5816,5455,3776,0,0]Ba[5654,13,2038,2038,3748,3776,0,0]Ba[5654,13,2038,2038,4826,3776,0,0]Ba[5654,13,2366,2366,6612,3776,0,0]Ba[5654,13,2727,2727,6371,3776,0,0]Ba[5654,13,2016,2016,4181,3776,0,0]Ba[5654,13,2016,2016,5464,3776,0,0]Ba[5654,13,6636,6636,6362,3776,0,0]Ba[5654,13,2003,2003,5462,3776,0,0]Ba[5654,13,2003,2003,5744,3776,0,0]Ba[5654,13,2003,2003,3759,3776,0,0]Ba[5654,13,2003,2003,3764,3776,0,0]Ba[5654,13,2003,2003,4813,3776,0,0]Ba[5654,13,3052,3052,6358,3776,0,0]Ba[5654,13,3052,3052,6360,3776,0,0]Ba[5654,13,5178,5178,5889,3776,0,0]Ba[5654,13,5178,5178,6003,3776,0,0]Ba[5654,13,2049,2049,3774,3776,0,0]Ba[5654,13,2049,2049,2559,3776,0,0]Ba[5654,13,2049,2049,3029,3776,0,0]Ba[5654,13,2049,2049,4150,3776,0,0]Ba[5654,13,2049,2049,2089,3776,0,0]Ba[5654,13,2049,2049,3760,3776,0,0]Ba[5654,13,2049,2049,4815,3776,0,0]Ba[5654,13,2049,2049,4814,3776,0,0]Ba[5654,13,2049,2049,5475,3776,0,0]Ba[5654,13,2049,2049,6596,3776,0,0]Ba[5654,13,2049,2049,5981,3776,0,0]Ba[5654,13,2049,2049,4831,3776,0,0]Ba[5654,13,2049,2049,5742,3776,0,0]Ba[5654,13,2049,2049,5453,3776,0,0]Ba[5654,13,2049,2049,5877,3776,0,0]Ba[5654,13,2049,2049,1797,3776,0,0]Ba[5654,13,2049,2049,3755,3776,0,0]Ba[5654,13,2049,2049,5442,3776,0,0]Ba[5654,13,2049,2049,5739,3776,0,0]Ba[5654,13,2049,2049,7120,3776,0,0]Ba[5654,13,2499,2499,3757,3776,0,0]Ba[5654,13,3153,3153,4836,3776,0,0]Ba[5654,13,3153,3153,2996,3776,0,0]Ba[5654,13,3153,3153,5695,3776,0,0]Ba[5654,13,3153,3153,6384,3776,0,0]Ba[5654,13,3153,3153,5748,3776,0,0]Ba[5654,13,3153,3153,5983,3776,0,0]Ba[5654,13,2496,2496,5457,3776,0,0]Ba[5654,13,4133,4133,6852,3776,0,0]An[5654,14,2506,2506,5988,0,0,0]An[5654,14,2739,2739,4153,0,0,0]An[5654,14,5680,5680,7123,0,0,0]Ak[5654,14,6369,0,2113,0,0,0]An[5654,14,2003,2003,3759,0,0,0]An[5654,14,2711,2711,4154,0,0,0]An[5654,14,2049,2049,3760,0,0,0]An[5654,14,2499,2499,5443,0,0,0]Ak[5654,14,911,911,908,0,0,0]Al[5654,14,911,911,4152,0,0,0]Al[5654,14,911,911,2577,0,0,0]Al[5654,14,911,911,7383,0,0,0]Al[5654,14,911,911,5444,0,0,0]Ag[5654,7,4782,0,0,0,0,0]An[5654,14,5503,5503,6838,0,0,0]An[5654,13,4772,4772,5452,0,0,0]0An[5654,14,4772,4772,5741,0,0,0]An[5654,13,2505,2505,6380,0,0,0]Ag[5654,7,4779,0,0,0,0,0]<;:9An[5654,13,3158,3158,5870,0,0,0]Ag[5654,7,5155,0,0,0,0,0]An[5654,14,2049,2049,3760,0,0,0]An[5654,14,2499,2499,5443,0,0,0]10An[5654,14,2506,2506,5456,0,0,0]:Al[5654,13,911,911,4791,0,0,0]An[5654,13,2376,2376,6367,0,0,0]Ag[5654,2,5655,0,0,0,0,0]Ag[5654,2,3055,0,0,0,0,0]Ag[5654,7,2506,0,0,0,0,0]Ak[5654,14,6604,0,3009,0,0,0]Ak[5654,14,6374,0,3008,0,0,0]Ak[5654,14,5460,0,3010,0,0,0]An[5654,14,2501,2501,5436,0,0,0]An[5654,14,2711,2711,4154,0,0,0]<;An[5654,13,5967,5967,5746,0,0,0]An[5654,12,3789,3789,5441,0,0,0]An[5654,14,5816,5816,6848,0,0,0]An[5654,14,2505,2505,6380,0,0,0]An[5654,13,2049,2049,5742,0,0,0]Ag[5654,7,2546,0,0,0,0,0]An[5654,14,2739,2739,4153,0,0,0]An[5654,14,2708,2708,4821,0,0,0]8An[5654,14,2049,2049,3760,0,0,0]An[5654,13,2711,2711,6603,0,0,0]0An[5654,14,3059,3059,7037,0,0,0]An[5654,14,3058,3058,5438,0,0,0]An[5654,13,2505,2505,6380,0,0,0]An[5654,13,5503,5503,7034,0,0,0]An[5654,13,3158,3158,5870,0,0,0]An[5654,14,2016,2016,5464,0,0,0]An[5654,14,5179,5179,6004,0,0,0]An[5654,14,1837,1837,4793,0,0,0]>An[5654,14,5956,5956,7044,0,0,0]An[5654,14,5179,5179,6834,0,0,0]Ba[5654,13,4855,4855,5973,6387,0,0]Ba[5654,13,2546,2546,5701,6387,0,0]Ba[5654,13,2598,2598,4823,6387,0,0]Ba[5654,13,2049,2049,3774,6387,0,0]Ba[5654,13,2547,2547,7186,6387,0,0]An[5654,13,1837,1837,4188,0,0,0]An[5654,14,2739,2739,5884,0,0,0]An[5654,14,4855,4855,5974,0,0,0]An[5654,13,3153,3153,6384,0,0,0]>An[5654,15,5110,5110,3750,0,0,0]An[5654,15,2049,2049,3774,0,0,0]An[5654,15,2366,2366,6612,0,0,0]Ag[5654,5,2546,0,0,0,0,0]Aj[5654,4,3058,2546,0,0,0,0]1Aj[5654,4,6787,3158,0,0,0,0]Ag[5654,5,3158,0,0,0,0,0]An[5654,15,6636,6636,6362,0,0,0]Ag[5654,5,2601,0,0,0,0,0]Aj[5654,4,3094,2349,0,0,0,0]Ag[5654,5,2349,0,0,0,0,0]Ag[5654,5,2048,0,0,0,0,0]Ag[5654,5,2049,0,0,0,0,0]An[5654,15,2038,2038,3748,0,0,0]43;Ah[5654,18,5084,0,0,0,0,0]Ag[5654,5,2739,0,0,0,0,0]=Aj[5654,4,2505,5178,0,0,0,0]Ag[5654,6,5178,0,0,0,0,0]Aj[5654,4,4133,1839,0,0,0,0]Ag[5654,5,1839,0,0,0,0,0]An[5654,13,2505,2505,6380,0,0,0]An[5654,13,2598,2598,5738,0,0,0]Ba[5654,13,2598,2598,4820,5738,0,0]An[5654,14,2598,2598,4820,0,0,0]Ba[5654,13,5132,5132,4176,5738,0,0]An[5654,14,5132,5132,4176,0,0,0]An[5654,13,5110,5110,4166,0,0,0]Ag[5654,7,4568,0,0,0,0,0]Ag[5654,2,4749,0,0,0,0,0]Ag[5654,7,3059,0,0,0,0,0]An[5654,13,2499,2499,6373,0,0,0]An[5654,13,2376,2376,6367,0,0,0]2An[5654,14,4855,4855,5974,0,0,0]An[5654,14,3059,3059,7037,0,0,0]An[5654,14,3058,3058,5438,0,0,0]An[5654,14,2049,2049,5981,0,0,0]An[5654,13,1839,1839,5891,0,0,0]06Ag[5654,7,5132,0,0,0,0,0]Ag[5654,7,4662,0,0,0,0,0]Ag[5654,7,6338,0,0,0,0,0]Ag[5654,7,2715,0,0,0,0,0]Ag[5654,7,6636,0,0,0,0,0]An[5654,14,5132,5132,4176,0,0,0]An[5654,14,2601,2601,6594,0,0,0]An[5654,14,2049,2049,5877,0,0,0]Ag[5654,7,4779,0,0,0,0,0]An[5654,13,5503,5503,6838,0,0,0]An[5654,14,3059,3059,7036,0,0,0]An[5654,13,3058,3058,5979,0,0,0]An[5654,14,3058,3058,5979,0,0,0]An[5654,14,1839,1839,4794,0,0,0]Al[5654,14,911,911,4185,0,0,0]Ag[5654,7,2708,0,0,0,0,0]Ag[5654,7,3059,0,0,0,0,0]Ag[5654,7,2012,0,0,0,0,0]Ak[5654,14,3019,0,5735,0,0,0]Ak[5654,14,3019,0,5991,0,0,0]An[5654,14,2349,2349,3769,0,0,0]An[5654,14,2048,2048,7042,0,0,0]An[5654,14,2048,2048,7184,0,0,0]An[5654,14,2048,2048,7235,0,0,0]Ak[5654,14,6607,0,1820,0,0,0]Ak[5654,14,6607,0,3016,0,0,0]Ak[5654,14,6607,0,2580,0,0,0]An[5654,14,3059,3059,7037,0,0,0]An[5654,14,3058,3058,5438,0,0,0]Ag[5654,7,4568,0,0,0,0,0]An[5654,12,4568,4568,5976,0,0,0]Ba[5654,13,4568,4568,5995,5976,0,0]An[5654,13,3147,3147,6379,0,0,0]Ag[5654,7,2601,0,0,0,0,0]Ag[5654,7,5084,0,0,0,0,0]An[5654,13,2499,2499,6373,0,0,0]An[5654,13,5503,5503,7034,0,0,0]3An[5654,14,2349,2349,5880,0,0,0]An[5654,14,2499,2499,5443,0,0,0]Ag[5654,7,1839,0,0,0,0,0]An[5654,13,1839,1839,5891,0,0,0]Ag[5654,2,5655,0,0,0,0,0]An[5654,13,6569,6569,5999,0,0,0]An[5654,13,5503,5503,6838,0,0,0]An[5654,13,2505,2505,6380,0,0,0]01Ag[5654,7,5655,0,0,0,0,0]Ag[5654,7,3059,0,0,0,0,0]An[5654,14,2506,2506,6606,0,0,0]Aa[\"{AMF`A@Dd}\",[]]Aa[\"{AMGbA@Dd}\",[]]A`[\"{CGIdBGh}\",[]]Aa[\"{BLN`A@Dd}\",[]]Aa[\"{BEInA@Dd}\",[]]Aa[\"{CEGdA@Dd}\",[]]Aa[\"{BJLhA@Dd}\",[]]Aa[\"{CANjA@Dd}\",[]]A`[\"{B@J`BEl}\",[]]Aa[\"{B@GbA@Dd}\",[]]Aa[\"{BJGhA@Dd}\",[]]Aa[\"{B@GdA@Dd}\",[]];Aa[\"{BJHfA@Dd}\",[]]Bk[\"{{{NCl{BNKb}}A@Gn}{{A@Ff{LAlA@Gn}}}}\",[]]Aa[\"{CAKbANCf}\",[]]C`[\"{{{ANCf{CG@h}}}{{A@Ff{{BGh{BEL`}}A@Gn}}}}\",[]]A`[\"{CEHbDCl}\",[]]A`[\"{BJJ`DCl}\",[]]Aa[\"{CCGjA@Gn}\",[]]Aa[\"{CEF`A@Gn}\",[]]Aa[\"{CGI`A@Gn}\",[]]A`[\"{A@CbDCl}\",[]]A`[\"{BJKdDCl}\",[]]A`[\"{A@CfDCl}\",[]]A`[\"{BJKfDCl}\",[]]Aj[\"{{{ANCf{CAMh}}}DCl}\",[]]Bd[\"{{{ANCf{BEKd}}}{{A@Dd{DCl}}}}\",[]]A`[\"{ADAbDCl}\",[]]A`[\"{A@DjDCl}\",[]]Aj[\"{{{ANCf{BLMd}}}DCl}\",[]]Aj[\"{{{ANCf{BEJh}}}DCl}\",[]]A`[\"{BEJhDCl}\",[]]A`[\"{BMObDCl}\",[]]Aa[\"{BMM`A@Dd}\",[]]A`[\"{CANjDCl}\",[]]A`[\"{BJL`DCl}\",[]]A`[\"{BJIhDCl}\",[]]Aj[\"{{{ANCf{B@J`}}}DCl}\",[]]Bh[\"{{{ANCf{CAMh}}}{{A@Ff{LAlBEJn}}}}\",[]]C`[\"{{{ANCf{EIjBJK`}}}{{A@Dd{{ANCf{AMEn}}}}}}\",[]]Aa[\"{CEG`A@Dd}\",[]]Aa[\"{CFOjA@Dd}\",[]]A`[\"{BJGlEIb}\",[]]A`[\"{BJGlDCl}\",[]]A`[\"{AGHnDCl}\",[]]Ai[\"{EId{{BEl{B@Fn}}}}\",[]]Cb[\"{{}{{ANCf{{NBf{{ACOd{EId{BEl{B@Fn}}}}}}}}}}\",[]]Ak[\"{{{ANCf{CEFl}}}CFOd}\",[]]Aa[\"{CEFlCFOd}\",[]]A`[\"{BJKnBEl}\",[]]Aa[\"{CDL`A@@h}\",[]]A`[\"{BNN`BGh}\",[]]An[\"{{{ANCf{CEH`}}BJIn}LAl}\",[]]Bn[\"{{{A@Dd{{ANCf{FLn}}}}FMh}{{A@Dd{G@l}}}}\",[]]Ac[\"{ANCfc{}}\",[\"T\"]]0000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000Aa[\"{CGInA@Gn}\",[]]Aa[\"{CEF`A@Gn}\",[]]B`[\"{c{{A@Ff{e}}}{}{}}\",[\"U\",\"T\"]]000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000Ak[\"{{}{{A@Ff{c}}}{}}\",[\"U\"]]000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000Aa[\"{ADBbA@Gn}\",[]]Aa[\"{AGJlA@Gn}\",[]]Aa[\"{BLGjA@Gn}\",[]]A`[\"{ADBbH@`}\",[]]n[\"{{}FMh}\",[]]o[\"{{}ANCf}\",[]]A`[\"{BEGlEIb}\",[]]Aa[\"{BN@fADA`}\",[]]Aa[\"{BEIlA@Dd}\",[]]Aa[\"{CEGbA@Dd}\",[]]Aa[\"{BNMnA@Gn}\",[]]Aa[\"{BEL`A@Dd}\",[]]Aa[\"{BMNhA@Gn}\",[]]A`[\"{CCHjBGh}\",[]]Cf[\"{{{ANCf{BN@f}}{BEl{BNMf}}FMh}{{A@Ff{LAlA@Gn}}}}\",[]]Aa[\"{CEG`A@Gn}\",[]]Aa[\"{CFOjA@Gn}\",[]]Aa[\"{BJGlA@Gn}\",[]]Aa[\"{AMClA@Gn}\",[]]A`[\"{NA`ADA`}\",[]]Aa[\"{BJGlA@Dd}\",[]]Bd[\"{{{ANCf{AGEj}}}{{A@Dd{FMh}}}}\",[]]Db[\"{{{ANCf{BNLb}}{ANCf{FLn}}{A@Dd{BNKj}}}{{BG`{{BEn{A@Bh}}}}}}\",[]]Db[\"{{{ANCf{CCIf}}{ANCf{FLn}}{A@Dd{BNKj}}}{{BG`{{BEn{A@Bh}}}}}}\",[]]Db[\"{{{ANCf{CALj}}{ANCf{FLn}}{A@Dd{BNKj}}}{{BG`{{BEn{A@Bh}}}}}}\",[]]Db[\"{{{ANCf{CAKn}}{ANCf{FLn}}{A@Dd{BNKj}}}{{BG`{{BEn{A@Bh}}}}}}\",[]]Be[\"{{{ANCf{BN@f}}{BEl{BNMf}}}FMh}\",[]]Cd[\"{{{ANCf{BNJl}}{ANCf{FLn}}}{{A@Ff{A@GnBNJj}}}}\",[]]Aa[\"{BLH`A@Dd}\",[]]Aa[\"{ACOnA@Dd}\",[]]A`[\"{AGJlH@`}\",[]]Aa[\"{BEL`A@Dd}\",[]]o[\"{MNhH@`}\",[]]o[\"{NAnH@`}\",[]]A`[\"{A@HbH@`}\",[]]Aj[\"{{{ANCf{BNNf}}}H@`}\",[]]A`[\"{B@GdH@`}\",[]]A`[\"{AMF`H@`}\",[]]A`[\"{BJHfH@`}\",[]]A`[\"{BEInH@`}\",[]]A`[\"{CEGdH@`}\",[]]Aa[\"{AMGbA@Dd}\",[]]A`[\"{CG@dH@`}\",[]]A`[\"{CHHfH@`}\",[]]A`[\"{AGI`H@`}\",[]]Aj[\"{{{ANCf{BEKd}}}H@`}\",[]]A`[\"{CAMbH@`}\",[]]Aa[\"{AGJjA@Dd}\",[]]Aa[\"{CAL`A@Dd}\",[]]Aj[\"{{{ANCf{AMF`}}}H@`}\",[]]0Aa[\"{BLNfA@Dd}\",[]]Aa[\"{CJLfA@Dd}\",[]]Aa[\"{CEG`A@Dd}\",[]]Aa[\"{CFOjA@Dd}\",[]]Aa[\"{BJGlA@Dd}\",[]]Aa[\"{BNLhA@Dd}\",[]]Aa[\"{B@GbA@Gn}\",[]]Aa[\"{BEJjA@Gn}\",[]]Aa[\"{BMO`A@Dd}\",[]]Aa[\"{AMEnA@Dd}\",[]]Aa[\"{B@GdA@Gn}\",[]]Aa[\"{AMF`A@Gn}\",[]]Aa[\"{BJHfA@Gn}\",[]]Cb[\"{{{ANCf{EIjBJK`}}}{{ANCf{EIj{OLl{AMEn}}}}}}\",[]]A`[\"{AMFdBDn}\",[]]A`[\"{MNhA@Dd}\",[]]Bh[\"{{{ANCf{AMFj}}}{{B@Jb{c}}}NCf}\",[\"T\"]]Bc[\"{{{ANCf{G@f}}}{{BGh{ADA`}}}}\",[]]Aa[\"{BNKhA@Dd}\",[]]Aa[\"{BJHdA@Dd}\",[]]Aa[\"{CEG`A@Dd}\",[]]Aa[\"{CFOjA@Dd}\",[]]Aa[\"{BJGlA@Dd}\",[]]Aa[\"{BLN`A@Gn}\",[]]Be[\"{{{ANCf{EIjBJK`}}A@@hA@@h}H@`}\",[]]Aa[\"{BLNfA@Gn}\",[]]Aa[\"{AMGfA@Gn}\",[]]Aa[\"{B@GbA@Dd}\",[]]Aa[\"{B@GdA@Dd}\",[]]Aa[\"{AMF`A@Dd}\",[]]Aa[\"{BJHfA@Dd}\",[]]Aa[\"{CAKfA@Gn}\",[]]Bl[\"{{{ANCf{CAMh}}AMEn}{{A@Ff{LAlBEJn}}}}\",[]]Dc[\"{{{ANCf{CALj}}{ANCf{FLn}}{ANCf{c}}}{{A@Ff{LAlAMGl}}}AMH`}\",[\"T\"]]Eh[\"{{{ANCf{B@Gl}}NCbe}{{A@Ff{cA@Gn}}}{}{{f{{ANCf{BGd}}}{{A@Df{{A@Fh{c}}}}}}}}\",[\"T\",\"F\"]]Fa[\"{{{ANCf{BNK`}}NCbe}{{BG`{{BEn{A@Bh}}}}}G@j{{f{{ANCf{BGd}}}{{A@Df{{A@Fh{c}}}}}}G@j}}\",[\"T\",\"F\"]]Fa[\"{{{ANCf{BNMf}}NCbe}{{BG`{{BEn{A@Bh}}}}}G@j{{f{{ANCf{BGd}}}{{A@Df{{A@Fh{c}}}}}}G@j}}\",[\"T\",\"F\"]]Aa[\"{BJGlA@Gn}\",[]]Bh[\"{{{ANCf{B@Jl}}}{{BNMh{c}}}NCf}\",[\"T\"]]Aa[\"{BNKjA@Dd}\",[]]Ca[\"{{{ANCf{EIjBN@h}}{ANCf{{OLl{AGEd}}}}}AMGh}\",[]]000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000A`[\"{BEGlEIb}\",[]]A`[\"{AGFdFMf}\",[]]Bh[\"{{{ANCf{AMBj}}}{{A@Ff{LAlA@Gn}}}}\",[]]Bi[\"{{{ANCf{AMBj}}}{{A@Ff{BEGlA@Gn}}}}\",[]]Aa[\"{BNKnBNKn}\",[]]Aa[\"{B@GbA@Dd}\",[]]Aa[\"{B@GdA@Dd}\",[]]Aa[\"{AMF`A@Dd}\",[]]Aa[\"{BJHfA@Dd}\",[]]Be[\"{{{ANCf{CAKb}}c}A@FfB@Jn}\",[\"__S\"]]Be[\"{{{ANCf{B@Kb}}c}A@FfB@Jn}\",[\"__S\"]]Bd[\"{{{ANCf{GAh}}c}A@FfB@Jn}\",[\"__S\"]]Be[\"{{{ANCf{B@G`}}c}A@FfB@Jn}\",[\"__S\"]]Be[\"{{{ANCf{ADBb}}c}A@FfB@Jn}\",[\"__S\"]]Be[\"{{{ANCf{CHOf}}c}A@FfB@Jn}\",[\"__S\"]]Be[\"{{{ANCf{BLNf}}c}A@FfB@Jn}\",[\"__S\"]]Be[\"{{{ANCf{CHAl}}c}A@FfB@Jn}\",[\"__S\"]]Be[\"{{{ANCf{BLI`}}c}A@FfB@Jn}\",[\"__S\"]]Be[\"{{{ANCf{CCHj}}c}A@FfB@Jn}\",[\"__S\"]]Be[\"{{{ANCf{BJLl}}c}A@FfB@Jn}\",[\"__S\"]]Be[\"{{{ANCf{CAJf}}c}A@FfB@Jn}\",[\"__S\"]]Be[\"{{{ANCf{CFOh}}c}A@FfB@Jn}\",[\"__S\"]]Be[\"{{{ANCf{BNNh}}c}A@FfB@Jn}\",[\"__S\"]]Be[\"{{{ANCf{CEFd}}c}A@FfB@Jn}\",[\"__S\"]]Be[\"{{{ANCf{CEFb}}c}A@FfB@Jn}\",[\"__S\"]]Be[\"{{{ANCf{CEHb}}c}A@FfB@Jn}\",[\"__S\"]]Be[\"{{{ANCf{BJJ`}}c}A@FfB@Jn}\",[\"__S\"]]Be[\"{{{ANCf{BNLd}}c}A@FfB@Jn}\",[\"__S\"]]Be[\"{{{ANCf{BEKj}}c}A@FfB@Jn}\",[\"__S\"]]Be[\"{{{ANCf{BJLn}}c}A@FfB@Jn}\",[\"__S\"]]Be[\"{{{ANCf{CCLf}}c}A@FfB@Jn}\",[\"__S\"]]Be[\"{{{ANCf{CHNf}}c}A@FfB@Jn}\",[\"__S\"]]Be[\"{{{ANCf{CGJj}}c}A@FfB@Jn}\",[\"__S\"]]Be[\"{{{ANCf{CG@b}}c}A@FfB@Jn}\",[\"__S\"]]Be[\"{{{ANCf{BLMh}}c}A@FfB@Jn}\",[\"__S\"]]Be[\"{{{ANCf{CCKf}}c}A@FfB@Jn}\",[\"__S\"]]Be[\"{{{ANCf{CGId}}c}A@FfB@Jn}\",[\"__S\"]]Be[\"{{{ANCf{B@Kd}}c}A@FfB@Jn}\",[\"__S\"]]Be[\"{{{ANCf{AGJl}}c}A@FfB@Jn}\",[\"__S\"]]Be[\"{{{ANCf{BLGj}}c}A@FfB@Jn}\",[\"__S\"]]Be[\"{{{ANCf{BMOh}}c}A@FfB@Jn}\",[\"__S\"]]Be[\"{{{ANCf{CAMb}}c}A@FfB@Jn}\",[\"__S\"]]Be[\"{{{ANCf{BLH`}}c}A@FfB@Jn}\",[\"__S\"]]Be[\"{{{ANCf{B@Gb}}c}A@FfB@Jn}\",[\"__S\"]]Be[\"{{{ANCf{BJGh}}c}A@FfB@Jn}\",[\"__S\"]]Be[\"{{{ANCf{CAKf}}c}A@FfB@Jn}\",[\"__S\"]]Be[\"{{{ANCf{CGJf}}c}A@FfB@Jn}\",[\"__S\"]]Be[\"{{{ANCf{CCHf}}c}A@FfB@Jn}\",[\"__S\"]]Be[\"{{{ANCf{BNKd}}c}A@FfB@Jn}\",[\"__S\"]]Be[\"{{{ANCf{CCH`}}c}A@FfB@Jn}\",[\"__S\"]]Be[\"{{{ANCf{CCHb}}c}A@FfB@Jn}\",[\"__S\"]]Be[\"{{{ANCf{CCHd}}c}A@FfB@Jn}\",[\"__S\"]]Be[\"{{{ANCf{CDL`}}c}A@FfB@Jn}\",[\"__S\"]]Be[\"{{{ANCf{BEGl}}c}A@FfB@Jn}\",[\"__S\"]]Be[\"{{{ANCf{CDJf}}c}A@FfB@Jn}\",[\"__S\"]]Be[\"{{{ANCf{BEJj}}c}A@FfB@Jn}\",[\"__S\"]]Be[\"{{{ANCf{CDJb}}c}A@FfB@Jn}\",[\"__S\"]]Be[\"{{{ANCf{CHH`}}c}A@FfB@Jn}\",[\"__S\"]]Be[\"{{{ANCf{AMFd}}c}A@FfB@Jn}\",[\"__S\"]]Be[\"{{{ANCf{B@Kl}}c}A@FfB@Jn}\",[\"__S\"]]Be[\"{{{ANCf{BJGl}}c}A@FfB@Jn}\",[\"__S\"]]Be[\"{{{ANCf{BEGb}}c}A@FfB@Jn}\",[\"__S\"]]Be[\"{{{ANCf{BLHn}}c}A@FfB@Jn}\",[\"__S\"]]Be[\"{{{ANCf{BEJd}}c}A@FfB@Jn}\",[\"__S\"]]Be[\"{{{ANCf{BJIf}}c}A@FfB@Jn}\",[\"__S\"]]Be[\"{{{ANCf{BJHj}}c}A@FfB@Jn}\",[\"__S\"]]Be[\"{{{ANCf{BEL`}}c}A@FfB@Jn}\",[\"__S\"]]Be[\"{{{ANCf{BMO`}}c}A@FfB@Jn}\",[\"__S\"]]Be[\"{{{ANCf{AMGb}}c}A@FfB@Jn}\",[\"__S\"]]Be[\"{{{ANCf{CG@d}}c}A@FfB@Jn}\",[\"__S\"]]Be[\"{{{ANCf{CHB`}}c}A@FfB@Jn}\",[\"__S\"]]Be[\"{{{ANCf{CHHf}}c}A@FfB@Jn}\",[\"__S\"]]Be[\"{{{ANCf{BJIn}}c}A@FfB@Jn}\",[\"__S\"]]Be[\"{{{ANCf{AMDh}}c}A@FfB@Jn}\",[\"__S\"]]Be[\"{{{ANCf{BEKd}}c}A@FfB@Jn}\",[\"__S\"]]Be[\"{{{ANCf{CCJh}}c}A@FfB@Jn}\",[\"__S\"]]Be[\"{{{ANCf{CALf}}c}A@FfB@Jn}\",[\"__S\"]]Be[\"{{{ANCf{B@Jj}}c}A@FfB@Jn}\",[\"__S\"]]Be[\"{{{ANCf{BJK`}}c}A@FfB@Jn}\",[\"__S\"]]Be[\"{{{ANCf{CAKd}}c}A@FfB@Jn}\",[\"__S\"]]Be[\"{{{ANCf{BJJl}}c}A@FfB@Jn}\",[\"__S\"]]Be[\"{{{ANCf{BLN`}}c}A@FfB@Jn}\",[\"__S\"]]Be[\"{{{ANCf{AMEn}}c}A@FfB@Jn}\",[\"__S\"]]Be[\"{{{ANCf{AMFh}}c}A@FfB@Jn}\",[\"__S\"]]Be[\"{{{ANCf{BEIj}}c}A@FfB@Jn}\",[\"__S\"]]Be[\"{{{ANCf{CAJl}}c}A@FfB@Jn}\",[\"__S\"]]Be[\"{{{ANCf{CAK`}}c}A@FfB@Jn}\",[\"__S\"]]Be[\"{{{ANCf{BN@b}}c}A@FfB@Jn}\",[\"__S\"]]Be[\"{{{ANCf{BNNf}}c}A@FfB@Jn}\",[\"__S\"]]Be[\"{{{ANCf{AMGl}}c}A@FfB@Jn}\",[\"__S\"]]Be[\"{{{ANCf{ACOn}}c}A@FfB@Jn}\",[\"__S\"]]Be[\"{{{ANCf{AGJj}}c}A@FfB@Jn}\",[\"__S\"]]Be[\"{{{ANCf{B@Fl}}c}A@FfB@Jn}\",[\"__S\"]]Be[\"{{{ANCf{A@Eb}}c}A@FfB@Jn}\",[\"__S\"]]Be[\"{{{ANCf{AMF`}}c}A@FfB@Jn}\",[\"__S\"]]Be[\"{{{ANCf{BEIn}}c}A@FfB@Jn}\",[\"__S\"]]Be[\"{{{ANCf{BEIl}}c}A@FfB@Jn}\",[\"__S\"]]Be[\"{{{ANCf{BJLf}}c}A@FfB@Jn}\",[\"__S\"]]Be[\"{{{ANCf{CCHh}}c}A@FfB@Jn}\",[\"__S\"]]Be[\"{{{ANCf{BNKj}}c}A@FfB@Jn}\",[\"__S\"]]Be[\"{{{ANCf{BEKn}}c}A@FfB@Jn}\",[\"__S\"]]Be[\"{{{ANCf{BLMl}}c}A@FfB@Jn}\",[\"__S\"]]Be[\"{{{ANCf{BJIj}}c}A@FfB@Jn}\",[\"__S\"]]Be[\"{{{ANCf{BMNj}}c}A@FfB@Jn}\",[\"__S\"]]Bd[\"{{{ANCf{N@j}}c}A@FfB@Jn}\",[\"__S\"]]Be[\"{{{ANCf{AMEf}}c}A@FfB@Jn}\",[\"__S\"]]Be[\"{{{ANCf{BJHd}}c}A@FfB@Jn}\",[\"__S\"]]Be[\"{{{ANCf{BLMf}}c}A@FfB@Jn}\",[\"__S\"]]Be[\"{{{ANCf{CGJ`}}c}A@FfB@Jn}\",[\"__S\"]]Be[\"{{{ANCf{AMEj}}c}A@FfB@Jn}\",[\"__S\"]]Be[\"{{{ANCf{BELh}}c}A@FfB@Jn}\",[\"__S\"]]Be[\"{{{ANCf{AGFh}}c}A@FfB@Jn}\",[\"__S\"]]Be[\"{{{ANCf{BLGn}}c}A@FfB@Jn}\",[\"__S\"]]Be[\"{{{ANCf{CAN`}}c}A@FfB@Jn}\",[\"__S\"]]Be[\"{{{ANCf{BLNh}}c}A@FfB@Jn}\",[\"__S\"]]Be[\"{{{ANCf{BNKn}}c}A@FfB@Jn}\",[\"__S\"]]Be[\"{{{ANCf{BJJb}}c}A@FfB@Jn}\",[\"__S\"]]Be[\"{{{ANCf{CEHh}}c}A@FfB@Jn}\",[\"__S\"]]Aa[\"{BNLhA@Dd}\",[]]Aa[\"{B@GbA@Dd}\",[]]Aa[\"{CGJfA@Gn}\",[]]Aa[\"{A@HbA@Gn}\",[]]Aa[\"{AMEnA@Dd}\",[]]Aa[\"{B@GdA@Dd}\",[]]Aa[\"{AMF`A@Dd}\",[]]Aa[\"{BJHfA@Dd}\",[]]A`[\"{GAhA@Gn}\",[]]Aa[\"{B@G`A@Gn}\",[]]Aa[\"{ADBbA@Gn}\",[]]Aa[\"{CIJnA@Gn}\",[]]Aa[\"{BJHhA@Gn}\",[]]Ba[\"{c{{A@Ff{BNLh}}}BJGj}\",[\"__D\"]]Ba[\"{c{{A@Ff{CALn}}}BJGj}\",[\"__D\"]]Cg[\"{{{ANCf{{AMBj{c}}}}NDf}{{A@Ff{e}}}ADAl{}}\",[\"R\",\"T\"]]0Ba[\"{c{{A@Ff{CAM`}}}BJGj}\",[\"__D\"]]1Ba[\"{c{{A@Ff{CGIn}}}BJGj}\",[\"__D\"]]2Ba[\"{c{{A@Ff{CCIl}}}BJGj}\",[\"__D\"]]Ba[\"{c{{A@Ff{CCIj}}}BJGj}\",[\"__D\"]]44Ba[\"{c{{A@Ff{CCGj}}}BJGj}\",[\"__D\"]]5Ba[\"{c{{A@Ff{CEF`}}}BJGj}\",[\"__D\"]]6Ba[\"{c{{A@Ff{CGI`}}}BJGj}\",[\"__D\"]]Ba[\"{c{{A@Ff{CCKf}}}BJGj}\",[\"__D\"]]88Ba[\"{c{{A@Ff{CGId}}}BJGj}\",[\"__D\"]]9Ba[\"{c{{A@Ff{B@Kd}}}BJGj}\",[\"__D\"]]:Ba[\"{c{{A@Ff{AGJl}}}BJGj}\",[\"__D\"]];Ba[\"{c{{A@Ff{BLGj}}}BJGj}\",[\"__D\"]]Ba[\"{c{{A@Ff{BMOh}}}BJGj}\",[\"__D\"]]=Ba[\"{c{{A@Ff{CAMb}}}BJGj}\",[\"__D\"]]>Ba[\"{c{{A@Ff{BLH`}}}BJGj}\",[\"__D\"]]??Ba[\"{c{{A@Ff{B@Gb}}}BJGj}\",[\"__D\"]]Cg[\"{{{ANCf{{AMBj{c}}}}NDf}{{A@Ff{e}}}ADAl{}}\",[\"R\",\"T\"]]Ba[\"{c{{A@Ff{BJGh}}}BJGj}\",[\"__D\"]]1Ba[\"{c{{A@Ff{CAKf}}}BJGj}\",[\"__D\"]]2Ba[\"{c{{A@Ff{CGJf}}}BJGj}\",[\"__D\"]]Ba[\"{c{{A@Ff{BNKh}}}BJGj}\",[\"__D\"]]4Ba[\"{c{{A@Ff{CCHf}}}BJGj}\",[\"__D\"]]55Ba[\"{c{{A@Ff{BNKd}}}BJGj}\",[\"__D\"]]6Ba[\"{c{{A@Ff{CCH`}}}BJGj}\",[\"__D\"]]7Ba[\"{c{{A@Ff{CCHb}}}BJGj}\",[\"__D\"]]Ba[\"{c{{A@Ff{CCHd}}}BJGj}\",[\"__D\"]]99Ba[\"{c{{A@Ff{CDL`}}}BJGj}\",[\"__D\"]]:Ba[\"{c{{A@Ff{BEGl}}}BJGj}\",[\"__D\"]];Ba[\"{c{{A@Ff{CDJf}}}BJGj}\",[\"__D\"]]Ba[\"{c{{A@Ff{AMFd}}}BJGj}\",[\"__D\"]]Ba[\"{c{{A@Ff{B@Kl}}}BJGj}\",[\"__D\"]]Cg[\"{{{ANCf{{AMBj{c}}}}NDf}{{A@Ff{e}}}ADAl{}}\",[\"R\",\"T\"]]0Ba[\"{c{{A@Ff{BJGl}}}BJGj}\",[\"__D\"]]Ba[\"{c{{A@Ff{BEGb}}}BJGj}\",[\"__D\"]]2Ba[\"{c{{A@Ff{BLHn}}}BJGj}\",[\"__D\"]]3Ba[\"{c{{A@Ff{BEJd}}}BJGj}\",[\"__D\"]]4Ba[\"{c{{A@Ff{BJIf}}}BJGj}\",[\"__D\"]]5Ba[\"{c{{A@Ff{BJHj}}}BJGj}\",[\"__D\"]]66Ba[\"{c{{A@Ff{BEL`}}}BJGj}\",[\"__D\"]]Ba[\"{c{{A@Ff{BMO`}}}BJGj}\",[\"__D\"]]8Ba[\"{c{{A@Ff{AMGb}}}BJGj}\",[\"__D\"]]99Ba[\"{c{{A@Ff{BJIn}}}BJGj}\",[\"__D\"]]:Ba[\"{c{{A@Ff{AMDh}}}BJGj}\",[\"__D\"]];Ba[\"{c{{A@Ff{BEKd}}}BJGj}\",[\"__D\"]]Ba[\"{c{{A@Ff{CCJh}}}BJGj}\",[\"__D\"]]=Ba[\"{c{{A@Ff{CALf}}}BJGj}\",[\"__D\"]]>Ba[\"{c{{A@Ff{B@Jj}}}BJGj}\",[\"__D\"]]??Ba[\"{c{{A@Ff{BJK`}}}BJGj}\",[\"__D\"]]Cg[\"{{{ANCf{{AMBj{c}}}}NDf}{{A@Ff{e}}}ADAl{}}\",[\"R\",\"T\"]]Ba[\"{c{{A@Ff{CAKd}}}BJGj}\",[\"__D\"]]Ba[\"{c{{A@Ff{BJJl}}}BJGj}\",[\"__D\"]]22Ba[\"{c{{A@Ff{BLN`}}}BJGj}\",[\"__D\"]]3Ba[\"{c{{A@Ff{AMEn}}}BJGj}\",[\"__D\"]]4Ba[\"{c{{A@Ff{AMFh}}}BJGj}\",[\"__D\"]]Ba[\"{c{{A@Ff{BEIj}}}BJGj}\",[\"__D\"]]6Ba[\"{c{{A@Ff{CAK`}}}BJGj}\",[\"__D\"]]7Ba[\"{c{{A@Ff{BN@b}}}BJGj}\",[\"__D\"]]88Ba[\"{c{{A@Ff{BNNf}}}BJGj}\",[\"__D\"]]9Ba[\"{c{{A@Ff{AMGl}}}BJGj}\",[\"__D\"]]:Ba[\"{c{{A@Ff{ACOn}}}BJGj}\",[\"__D\"]];Ba[\"{c{{A@Ff{AGJj}}}BJGj}\",[\"__D\"]]Ba[\"{c{{A@Ff{AMF`}}}BJGj}\",[\"__D\"]]??Ba[\"{c{{A@Ff{BEIn}}}BJGj}\",[\"__D\"]]Cg[\"{{{ANCf{{AMBj{c}}}}NDf}{{A@Ff{e}}}ADAl{}}\",[\"R\",\"T\"]]Ba[\"{c{{A@Ff{BEIl}}}BJGj}\",[\"__D\"]]1Ba[\"{c{{A@Ff{BJLf}}}BJGj}\",[\"__D\"]]Ba[\"{c{{A@Ff{CCHh}}}BJGj}\",[\"__D\"]]3Ba[\"{c{{A@Ff{BNKj}}}BJGj}\",[\"__D\"]]44Ba[\"{c{{A@Ff{BEKn}}}BJGj}\",[\"__D\"]]5Ba[\"{c{{A@Ff{BLMl}}}BJGj}\",[\"__D\"]]6Ba[\"{c{{A@Ff{BJIj}}}BJGj}\",[\"__D\"]]Ba[\"{c{{A@Ff{BMNj}}}BJGj}\",[\"__D\"]]88B`[\"{c{{A@Ff{N@j}}}BJGj}\",[\"__D\"]]Ba[\"{c{{A@Ff{AMEf}}}BJGj}\",[\"__D\"]]::Ba[\"{c{{A@Ff{BJHd}}}BJGj}\",[\"__D\"]]Ba[\"{c{{A@Ff{BLMf}}}BJGj}\",[\"__D\"]]<>Ba[\"{c{{A@Ff{BJJb}}}BJGj}\",[\"__D\"]]?Ba[\"{c{{A@Ff{CEHh}}}BJGj}\",[\"__D\"]]Aa[\"{BEL`A@Dd}\",[]]A`[\"{BJIjH@`}\",[]]A`[\"{A@EhEId}\",[]]A`[\"{ADBdEId}\",[]]A`[\"{AGHnEId}\",[]]A`[\"{AMClEId}\",[]]A`[\"{A@B`EId}\",[]]A`[\"{A@DhEId}\",[]]A`[\"{AMCfEId}\",[]]A`[\"{CDJlEId}\",[]]Aa[\"{AMF`A@Dd}\",[]]A`[\"{DClA@Gn}\",[]]Ca[\"{{{ANCf{CAMf}}{ANCf{CCJb}}}{{A@Dd{A@Gn}}}}\",[]]Be[\"{{{ANCf{BEFn}}}{{A@Dd{ADBb}}}}\",[]]Cl[\"{{{ANCf{BMNf}}{ANCf{FLn}}}{{A@Ff{{A@Dd{BEL`}}A@Gn}}}}\",[]]Aj[\"{{{ANCf{CCIb}}}H@`}\",[]]Aj[\"{{{ANCf{AMF`}}}H@`}\",[]]Aj[\"{{{ANCf{BJLf}}}H@`}\",[]]Aj[\"{{{ANCf{BJIj}}}H@`}\",[]]Aj[\"{{{ANCf{CGJ`}}}H@`}\",[]]A`[\"{BEFnBEl}\",[]]A`[\"{CEFlBEl}\",[]]Aa[\"{BMNfA@Ad}\",[]]A`[\"{CALjBEl}\",[]]A`[\"{CDJbH@`}\",[]]A`[\"{CAMbH@`}\",[]]A`[\"{BNLfH@`}\",[]]A`[\"{AGFlH@`}\",[]]Aa[\"{AGJjA@Dd}\",[]]Aa[\"{CAL`A@Dd}\",[]]A`[\"{BNNhEId}\",[]]Bl[\"{{{ANCf{AMEn}}}{{A@Dd{{ANCf{FLn}}}}}}\",[]]Aa[\"{AMEjA@Gn}\",[]]A`[\"{CEHhFMh}\",[]]Dc[\"{{{ANCf{{BEl{BNMf}}}}EIdEIbH@`{ANCf{FLn}}}{{A@Ff{LAlA@Gn}}}}\",[]]Cf[\"{{{ANCf{BNLb}}{ANCf{FLn}}}{{BG`{{BEn{A@Bh}}}}}}\",[]]Cf[\"{{{ANCf{CCIf}}{ANCf{FLn}}}{{BG`{{BEn{A@Bh}}}}}}\",[]]Cf[\"{{{ANCf{CALj}}{ANCf{FLn}}}{{BG`{{BEn{A@Bh}}}}}}\",[]]Cf[\"{{{ANCf{CAKn}}{ANCf{FLn}}}{{BG`{{BEn{A@Bh}}}}}}\",[]]Cc[\"{{{ANCf{CAMf}}{ANCf{FLn}}}{{A@Ff{LAlA@Gn}}}}\",[]]Aa[\"{BJHjA@Gn}\",[]]Bd[\"{{{ANCf{CAN`}}}{{A@Dd{FMh}}}}\",[]]A`[\"{BMMhFMf}\",[]]A`[\"{B@I`FMf}\",[]]A`[\"{B@LbFMf}\",[]]Aj[\"{{{ANCf{FLn}}}BEHl}\",[]]Ca[\"{{{ANCf{BJI`}}{ANCf{BJI`}}}{{A@Dd{AGGl}}}}\",[]]Aa[\"{CAKfA@Gn}\",[]]Aa[\"{A@EbA@Gn}\",[]]Di[\"{{{NCl{CDKn}}{NCl{CIKb}}{NCl{CIKd}}{NCl{BNKb}}}{{A@Ff{BJJ`A@Gn}}}}\",[]]Cc[\"{{{NCl{CDKn}}{NCl{CIKd}}}{{A@Ff{BJJ`A@Gn}}}}\",[]]Cf[\"{{{NCl{CDKn}}{NCl{CIKd}}DCl}{{A@Ff{BJJ`A@Gn}}}}\",[]]Cn[\"{{{NCl{CDKn}}{NCl{CIKb}}{NCl{CIKd}}}{{A@Ff{BJJ`A@Gn}}}}\",[]]Aa[\"{AGHbA@Gn}\",[]]Aa[\"{AGH`A@Gn}\",[]]Aa[\"{AMEnA@Dd}\",[]]Aa[\"{AGHdA@Gn}\",[]]Aa[\"{BJJbA@Gn}\",[]]A`[\"{CAMbEId}\",[]]A`[\"{CDJlEId}\",[]]o[\"{MOhH@`}\",[]]Aa[\"{AMGbA@Dd}\",[]]Ak[\"{{{ANCf{CAMh}}}B@Jj}\",[]]Ak[\"{{{ANCf{BJK`}}}B@Jj}\",[]]A`[\"{BLNfEIb}\",[]]A`[\"{AMGfEIb}\",[]]A`[\"{BJGlEIb}\",[]]Aa[\"{BLMlA@Dd}\",[]]Aa[\"{CFOjA@Dd}\",[]]Aa[\"{B@GbA@Dd}\",[]]Aa[\"{BJGlA@Dd}\",[]]Aa[\"{B@GdA@Dd}\",[]]Aa[\"{AMF`A@Dd}\",[]]Aa[\"{BJHfA@Dd}\",[]]Ai[\"{{{A@Dd{EIb}}}EId}\",[]]A`[\"{BJKbDCl}\",[]]765432Aa[\"{ADBbA@Gn}\",[]]Aa[\"{CAMjA@Gn}\",[]]Aa[\"{BLGjA@Gn}\",[]]Ba[\"{{{ANCf{EIjBJK`}}BJJl}LAl}\",[]]Bf[\"{{{ANCf{BEFn}}{A@Dd{ADBb}}}LAl}\",[]]A`[\"{BJHjBGh}\",[]]A`[\"{CAMhBEl}\",[]]Aa[\"{CAM`A@@h}\",[]]Aa[\"{CCIlA@@h}\",[]]Aa[\"{BNKjA@Dd}\",[]]Aa[\"{BEInA@Gn}\",[]]Aa[\"{CEGdA@Gn}\",[]]Aj[\"{{{ANCf{BELh}}}DCj}\",[]]Aa[\"{BJGnADA`}\",[]]o[\"{EIdEId}\",[]]An[\"{{{ANCf{EIjBN@h}}}CGIf}\",[]]Bh[\"{{{ANCf{EIjBN@h}}}{{A@Dd{AGEd}}}}\",[]]011010010110010101010110010101101010010101100101011001100101011010011001101010010101010110101001011010011010010110100101101001011010011001101010100110011001101010100101011001100101010110011001011010101001100110101001101010011010011001A`[\"{BJLlEId}\",[]]Aa[\"{AGHnA@Dd}\",[]]1A`[\"{CAJfEId}\",[]]Fi[\"{{{ANCf{BNK`}}e}{{BG`{{BEn{A@Bh}}}}}G@j{{A@Bd{{ANCf{EIjBELd}}}{{A@Df{{A@Ff{cA@Gn}}}}}}G@j}}\",[\"T\",\"F\"]]Fi[\"{{{ANCf{BNMf}}e}{{BG`{{BEn{A@Bh}}}}}G@j{{A@Bd{{ANCf{EIjBELd}}}{{A@Df{{A@Ff{cA@Gn}}}}}}G@j}}\",[\"T\",\"F\"]]Aa[\"{BNLhA@Gn}\",[]]Aa[\"{AMEnA@Dd}\",[]]0Bi[\"{{c{BGh{B@Jf}}}NCb{{FOf{A@Gn}}}}\",[\"\"]]o[\"{{}A@Fj}\",[]]o[\"{{}ANCf}\",[]]Aa[\"{B@G`A@Gn}\",[]]Aa[\"{ADBbA@Gn}\",[]]Aa[\"{CIJnA@Gn}\",[]]Aa[\"{BLGjA@Gn}\",[]]Aa[\"{BMNhA@Gn}\",[]]Aa[\"{CALjA@Gn}\",[]]Aa[\"{BLI`A@@h}\",[]]Aa[\"{CFOhA@@h}\",[]]Ak[\"{{{ANCf{BNKf}}}A@@h}\",[]]Aa[\"{B@GbA@Dd}\",[]]Aa[\"{BMO`A@Dd}\",[]]Aa[\"{B@GdA@Dd}\",[]]Aa[\"{AMF`A@Dd}\",[]]Aa[\"{BJHfA@Dd}\",[]]Aa[\"{AMEnA@Dd}\",[]]21A`[\"{BNMnBEl}\",[]]A`[\"{CG@lH@`}\",[]]A`[\"{CG@nH@`}\",[]]A`[\"{CEFhDCl}\",[]]Aa[\"{BEGdA@Gn}\",[]]Aa[\"{BJGlA@Dd}\",[]]Aa[\"{CALjA@Dd}\",[]]Ce[\"{{{ANCf{G@f}}{ANCf{{OLl{BDn}}}}}{{ANCf{FLn}}}}\",[]]Aa[\"{BNLlA@Gn}\",[]]A`[\"{CG@hBEl}\",[]]Bm[\"{{{ANCf{CALf}}}{{A@Dd{{ANCf{AMEn}}}}}}\",[]]A`[\"{CEH`BEl}\",[]]Aj[\"{{{ANCf{CAMh}}}LAl}\",[]]Am[\"{{{ANCf{EIjBJK`}}}LAl}\",[]]n[\"{{}H@`}\",[]]Cc[\"{{{ANCf{BNJl}}{ANCf{FLn}}}{{A@Ff{LAlBNJj}}}}\",[]]Cf[\"{{{ANCf{BN@f}}{BEl{BNMf}}FMh}{{A@Ff{LAlA@Gn}}}}\",[]]Aa[\"{BLI`A@@h}\",[]]Bn[\"{{{BEGh{c}}}{{A@Ff{eBEHd}}}{}{}}\",[\"R\",\"D\"]]000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000Bk[\"{{{ANCf{BNLb}}}{{BG`{{BEn{A@Bh}}}}}}\",[]]Bk[\"{{{ANCf{CCIf}}}{{BG`{{BEn{A@Bh}}}}}}\",[]]Bk[\"{{{ANCf{CALj}}}{{BG`{{BEn{A@Bh}}}}}}\",[]]Bk[\"{{{ANCf{CAKn}}}{{BG`{{BEn{A@Bh}}}}}}\",[]]C`[\"{{{ANCf{BMNf}}}{{A@Ff{{BGh{BEL`}}A@Gn}}}}\",[]]C`[\"{{{ANCf{BJK`}}A@@h}{{BGh{{ANCf{AMEn}}}}}}\",[]]A`[\"{BEKjH@`}\",[]]A`[\"{BJJdH@`}\",[]]Aj[\"{{{ANCf{BJK`}}}H@`}\",[]]C`[\"{{{A@Dd{{ANCf{FLn}}}}{AGEl{BGf}}AGF`}H@`}\",[]]Aa[\"{B@FjA@Dd}\",[]]Aa[\"{B@GbA@Dd}\",[]]Aa[\"{B@GdA@Dd}\",[]]Aa[\"{AMF`A@Dd}\",[]]Aa[\"{BJHfA@Dd}\",[]]A`[\"{AMFdH@`}\",[]]Aj[\"{{{ANCf{CFOd}}}H@`}\",[]]A`[\"{CHH`H@`}\",[]]Aj[\"{{{ANCf{BLHj}}}H@`}\",[]]Aj[\"{{{ANCf{CHBd}}}H@`}\",[]]Aa[\"{CCH`A@@h}\",[]]Aa[\"{CCHbA@@h}\",[]]10Aa[\"{CDJbA@Dd}\",[]]Aa[\"{CDJfA@Gn}\",[]]Ai[\"{{}{{A@Dd{BNKj}}}}\",[]]A`[\"{CAK`FMf}\",[]]Aa[\"{BJJ`A@Dd}\",[]]A`[\"{ADBbH@`}\",[]]Aa[\"{CHAlBEJd}\",[]]Aa[\"{BJIfBEJd}\",[]]Aa[\"{CG@lAMF`}\",[]]Aa[\"{CG@nAMF`}\",[]]Aj[\"{{{ANCf{FLn}}}A@Gn}\",[]]Aj[\"{{{ANCf{G@f}}}ADA`}\",[]]Aa[\"{BLI`A@@h}\",[]]Aa[\"{CG@dA@Gn}\",[]]Cc[\"{{{NCl{CDKn}}{NCl{CIKd}}}{{A@Ff{BJJ`A@Gn}}}}\",[]]2Aa[\"{BLMhA@@h}\",[]]Db[\"{{{ANCf{{BEl{BNMf}}}}{ANCf{FLn}}}{{A@Ff{{BGh{BNLf}}A@Gn}}}}\",[]]A`[\"{CCJlBEl}\",[]]Be[\"{{{ANCf{FLn}}{ANCf{G@f}}}AGIl}\",[]]Cn[\"{{{ANCf{BNJl}}{ANCf{FLn}}{ANCf{FLn}}}{{A@Ff{LAlBNJj}}}}\",[]]Aa[\"{A@FnA@Gn}\",[]]Bo[\"{{{ANCf{BMNf}}A@GnEId}{{A@Ff{LAlA@Gn}}}}\",[]]Ai[\"{{{ANCf{MOj}}}H@`}\",[]]Aa[\"{AMGbA@Dd}\",[]]Bh[\"{{{NCl{BNKb}}}{{A@Ff{A@GnA@Gn}}}}\",[]]Aa[\"{BMOhA@Gn}\",[]]Bd[\"{{{ANCf{G@f}}}{{A@Dd{BEHl}}}}\",[]]Aa[\"{CEFdA@@h}\",[]]Fh[\"{{{ANCf{BMMh}}{ANCf{BJGn}}{ANCf{B@Fn}}{ANCf{c}}}{{A@Ff{BMMdBLHj}}}{{f{FMh{A@Dd{FMh}}}}G@jGA`}}\",[\"F\"]]Aa[\"{AMEnA@Dd}\",[]]6Aa[\"{BLGnBELh}\",[]]o[\"{{}ANCf}\",[]]0000000000000000000000Bj[\"{{{ANCf{EIjBLMj}}DCl}{{A@Dd{FMf}}}}\",[]]Aj[\"{{{ANCf{CAN`}}}FMh}\",[]]Ad[\"{{DClDCl}DCl}\",[]]Aa[\"{BEKjA@Dd}\",[]]Aa[\"{BJJdA@Dd}\",[]]Be[\"{{{ANCf{BJK`}}}{{A@Dd{A@@h}}}}\",[]]Aa[\"{BJK`A@Dd}\",[]]Aa[\"{BLIfA@Dd}\",[]]?Bk[\"{{A@Gn{NCl{BNKb}}}{{A@Ff{LAlA@Gn}}}}\",[]]Aa[\"{BEInA@Dd}\",[]]Aa[\"{CEGdA@Dd}\",[]]Cf[\"{{{NCl{BNKb}}{NCl{CCKn}}CEG`}{{A@Ff{EIdA@Gn}}}}\",[]]Bi[\"{{{ANCf{BNKb}}}{{A@Ff{ADA`A@Gn}}}}\",[]]Aa[\"{BLNfA@Dd}\",[]]Aa[\"{BJGlA@Dd}\",[]]A`[\"{CEH`BEl}\",[]]Bl[\"{{{ANCf{CAMh}}}{{A@Dd{{BEl{CDKl}}}}}}\",[]]A`[\"{CAMhBEl}\",[]]Aa[\"{B@J`A@Dd}\",[]]A`[\"{CG@hBEl}\",[]]Cf[\"{{{ANCf{BNK`}}{ANCf{FLn}}}{{BG`{{BEn{A@Bh}}}}}}\",[]]Cf[\"{{{ANCf{BNMf}}{ANCf{FLn}}}{{BG`{{BEn{A@Bh}}}}}}\",[]]Bl[\"{{{ANCf{B@Gl}}NCb}{{A@Ff{A@@hA@Gn}}}}\",[]]Aa[\"{CEG`A@Dd}\",[]]Co[\"{{{NCl{BNKb}}A@Gn{A@Dd{{BGh{A@Gn}}}}}{{A@Ff{CCHjA@Gn}}}}\",[]]Ck[\"{{{ANCf{BNLb}}BEKn{A@Dd{BNKj}}}{{BG`{{BEn{A@Bh}}}}}}\",[]]Ck[\"{{{ANCf{CCIf}}BEKn{A@Dd{BNKj}}}{{BG`{{BEn{A@Bh}}}}}}\",[]]Ck[\"{{{ANCf{CALj}}BEKn{A@Dd{BNKj}}}{{BG`{{BEn{A@Bh}}}}}}\",[]]Ck[\"{{{ANCf{CAKn}}BEKn{A@Dd{BNKj}}}{{BG`{{BEn{A@Bh}}}}}}\",[]]Cd[\"{{{ANCf{BNJl}}{ANCf{FLn}}}{{A@Ff{A@GnBNJj}}}}\",[]]Cf[\"{{{ANCf{BNLb}}{ANCf{FLn}}AMEf{A@Dd{BJHd}}}A@Gn}\",[]]Cf[\"{{{ANCf{CCIf}}{ANCf{FLn}}AMEf{A@Dd{BJHd}}}A@Gn}\",[]]Cf[\"{{{ANCf{CALj}}{ANCf{FLn}}AMEf{A@Dd{BJHd}}}A@Gn}\",[]]Cf[\"{{{ANCf{CAKn}}{ANCf{FLn}}AMEf{A@Dd{BJHd}}}A@Gn}\",[]]Ch[\"{{{ANCf{B@Hl}}{ANCf{FLn}}}{{A@Ff{cA@Gn}}}CAKj}\",[\"T\"]]Bk[\"{{{ANCf{BNLb}}}{{BG`{{BEn{A@Bh}}}}}}\",[]]Bk[\"{{{ANCf{CCIf}}}{{BG`{{BEn{A@Bh}}}}}}\",[]]Bk[\"{{{ANCf{CALj}}}{{BG`{{BEn{A@Bh}}}}}}\",[]]Bk[\"{{{ANCf{CAKn}}}{{BG`{{BEn{A@Bh}}}}}}\",[]]C`[\"{{{ANCf{CCIf}}{ANCf{FLn}}}{{A@Dd{AGJj}}}}\",[]]A`[\"{CAMhBEl}\",[]]Df[\"{{{NCl{CHO`}}{NCl{CGJn}}{NCl{BNKb}}A@GnBNKh}{{A@Ff{A@GnA@Gn}}}}\",[]]A`[\"{CEFlBEl}\",[]]Bd[\"{{{ANCf{BJIh}}}{{A@Dd{DCl}}}}\",[]]Aj[\"{{{ANCf{BJIh}}}DCl}\",[]]Aa[\"{BLMjA@Dd}\",[]]Cd[\"{{{ANCf{CAMh}}{ANCf{AMEn}}}{{A@Ff{LAlBEJn}}}}\",[]]Cm[\"{{{ANCf{{BEl{BNMf}}}}EId{ANCf{FLn}}}{{A@Ff{LAlA@Gn}}}}\",[]]Cf[\"{{{ANCf{BNLb}}{ANCf{FLn}}}{{BG`{{BEn{A@Bh}}}}}}\",[]]Cf[\"{{{ANCf{CCIf}}{ANCf{FLn}}}{{BG`{{BEn{A@Bh}}}}}}\",[]]Cf[\"{{{ANCf{CALj}}{ANCf{FLn}}}{{BG`{{BEn{A@Bh}}}}}}\",[]]Cf[\"{{{ANCf{CAKn}}{ANCf{FLn}}}{{BG`{{BEn{A@Bh}}}}}}\",[]]Be[\"{{{ANCf{BMMl}}{ANCf{FLn}}}LAl}\",[]]Be[\"{{{ANCf{FLn}}{ANCf{FLn}}}BJI`}\",[]]Aa[\"{AMF`A@Dd}\",[]]Aa[\"{BJHfA@Dd}\",[]]10A`[\"{BJJ`DCj}\",[]]Bk[\"{{{A@Dd{{ANCf{FLn}}}}}{{ANCf{FLn}}}}\",[]]Bh[\"{{{ANCf{FLn}}}{{A@Ff{A@GnA@Gn}}}}\",[]]Hm[\"{{{ANCf{CAKn}}eg}{{A@Ff{cAMGl}}}{CCIbBLIdMO`G@j}{{A@Bh{}{{A@Df{{A@Ff{cAMGl}}}}}}G@j}{{A@Bh{}{{A@Df{{A@Ff{cAMGl}}}}}}G@j}}\",[\"T\",\"F1\",\"F2\"]]Cc[\"{{{NCl{CDKn}}{NCl{CIKd}}}{{A@Ff{BJJ`A@Gn}}}}\",[]]Aa[\"{AGHbA@Gn}\",[]]Aa[\"{AGH`A@Gn}\",[]]Aa[\"{AGHdA@Gn}\",[]]Aa[\"{BJGhA@Dd}\",[]]Aa[\"{B@GdA@Dd}\",[]];:Bh[\"{{{ANCf{BLNd}}}{{A@Ff{LAlA@Gn}}}}\",[]]Ci[\"{{{ANCf{BJHb}}{ANCf{FLn}}H@`}{{BG`{{BEn{A@Bh}}}}}}\",[]]A`[\"{CEH`BEl}\",[]]A`[\"{CAMhBEl}\",[]]Am[\"{{{ANCf{EIjBLMl}}}LAl}\",[]]Ad[\"{{FMhFMd}FMh}\",[]]Aa[\"{B@GbA@Dd}\",[]]Aa[\"{BEJjA@Dd}\",[]]8Aa[\"{AMF`A@Dd}\",[]]Dg[\"{{{ANCf{CCIf}}{ANCf{FLn}}{ANCf{{OLl{AMF`}}}}}{{A@Ff{A@@hAMGl}}}}\",[]]Co[\"{{{ANCf{CCIf}}{ANCf{FLn}}A@@h}{{A@Ff{{BGh{AMF`}}AMGl}}}}\",[]]Aa[\"{CFOjA@Dd}\",[]]Aa[\"{BJGlA@Dd}\",[]]Bk[\"{{{ANCf{CAMh}}DCl}{{A@Ff{LAlA@Gn}}}}\",[]]Bi[\"{{{ANCf{CFOd}}H@`{A@Dd{A@Gn}}}LAl}\",[]]Be[\"{{{ANCf{BMMl}}{ANCf{FLn}}}H@`}\",[]]A`[\"{BJK`BGh}\",[]]A`[\"{BNNhFMh}\",[]]A`[\"{BEGbFMh}\",[]]?A`[\"{CG@hBEl}\",[]]Aa[\"{CEFdA@@h}\",[]]Ao[\"{ANCf{{A@Ff{A@GnBNNn}}}}\",[]]0000An[\"{{{ANCf{B@Kh}}BEGb}LAl}\",[]]A`[\"{BMOhH@`}\",[]]A`[\"{BNJlH@`}\",[]]Bd[\"{{{ANCf{CAN`}}}{{A@Dd{FMh}}}}\",[]]Aa[\"{AGF`BMMl}\",[]]o[\"{{}ANCf}\",[]]Ak[\"{{{ANCf{CAMh}}}BLGn}\",[]]Ak[\"{{{ANCf{BLMd}}}BLGn}\",[]]Ak[\"{{{ANCf{BEJh}}}BLGn}\",[]]Aa[\"{BEJhBLGn}\",[]]Ak[\"{{{ANCf{B@J`}}}BLGn}\",[]]Aa[\"{B@J`BLGn}\",[]]Aj[\"{{{ANCf{MOj}}}AMDl}\",[]]Bj[\"{{{ANCf{{OLl{B@Jf}}}}}{{BGh{NDb}}}}\",[]]Eh[\"{{{NCl{BNKb}}{NCl{CHO`}}{NCl{CHAh}}AMBjA@GnA@GnA@GnA@Gn}{{A@Ff{{BGh{EId}}A@Gn}}}}\",[]]Cj[\"{{{ANCf{CALj}}{ANCf{FLn}}}{{A@Ff{{BGh{BDn}}A@Gn}}}}\",[]]Cj[\"{{{ANCf{CAKn}}{ANCf{FLn}}}{{A@Ff{{BGh{BDn}}A@Gn}}}}\",[]]Bk[\"{{{NCl{BNKb}}CFOj}{{A@Ff{EIdA@Gn}}}}\",[]]A`[\"{BNJlNEh}\",[]]Aa[\"{CFOjA@Dd}\",[]]Aa[\"{BJGlA@Dd}\",[]]Aa[\"{BNKjA@Dd}\",[]]Be[\"{{{ANCf{BN@f}}{BEl{BNMf}}}FMh}\",[]]Be[\"{{{ANCf{BN@f}}{BEl{BNMf}}}EId}\",[]]Ch[\"{{{ANCf{CALj}}{ANCf{FLn}}}{{A@Ff{cAMGl}}}BEGj}\",[\"T\"]]Ak[\"{{{ANCf{AMEn}}}A@Gn}\",[]]Bn[\"{{{ANCf{{ACOf{A@Gn}}}}{ANCf{AMF`}}}H@`}\",[]]Aj[\"{{{ANCf{AMF`}}}H@`}\",[]]Bk[\"{{{NCl{BNKb}}A@Gn}{{A@Ff{H@`A@Gn}}}}\",[]]A`[\"{AMFhH@`}\",[]]A`[\"{B@J`BEl}\",[]]Aa[\"{CCHdA@Dd}\",[]]Aa[\"{BMNjA@Dd}\",[]]Cb[\"{{{ANCf{{BEl{BNMf}}}}EId}{{A@Ff{LAlA@Gn}}}}\",[]]Aj[\"{{{ANCf{CEFl}}}LAl}\",[]]Aa[\"{CFOhA@@h}\",[]]Ak[\"{{{ANCf{BNKf}}}A@@h}\",[]]Aa[\"{BNKfA@@h}\",[]]A`[\"{BEGdFMh}\",[]]Aa[\"{B@KbA@Gn}\",[]]Cg[\"{{{NCl{BNKb}}A@GnA@Gn}{{A@Ff{{BGh{BEJj}}A@Gn}}}}\",[]]Bj[\"{{{NCl{BNKb}}EId}{{A@Ff{LAlA@Gn}}}}\",[]]Cg[\"{{{NCl{CDKn}}{NCl{BNKb}}A@@h}{{A@Ff{BJJ`A@Gn}}}}\",[]]A`[\"{BLLnEId}\",[]]A`[\"{BNLnEId}\",[]]Aa[\"{AMGbA@Dd}\",[]]A`[\"{CG@dEId}\",[]]A`[\"{CHB`EId}\",[]]A`[\"{CHHfEId}\",[]]o[\"{NChEId}\",[]]A`[\"{AGI`EId}\",[]]A`[\"{ADBhEId}\",[]]Aa[\"{CFOjA@Dd}\",[]]Aa[\"{BJGlA@Dd}\",[]]F`[\"{{{ANCf{B@Gl}}NCbe}{{A@Ff{{A@Dd{c}}A@Gn}}}{}{{f{{ANCf{BGd}}}{{A@Df{{A@Fh{c}}}}}}}}\",[\"T\",\"F\"]]Fa[\"{{{ANCf{BNK`}}NCbe}{{BG`{{BEn{A@Bh}}}}}G@j{{f{{ANCf{BGd}}}{{A@Df{{A@Fh{c}}}}}}G@j}}\",[\"T\",\"F\"]]Fa[\"{{{ANCf{BNMf}}NCbe}{{BG`{{BEn{A@Bh}}}}}G@j{{f{{ANCf{BGd}}}{{A@Df{{A@Fh{c}}}}}}G@j}}\",[\"T\",\"F\"]]Cd[\"{{{ANCf{CAMf}}{ANCf{CCJb}}}{{A@Ff{LAlA@Gn}}}}\",[]]Bl[\"{{{ANCf{{BEl{BNMf}}}}}{{A@Dd{A@Gn}}}}\",[]]Aj[\"{{{ANCf{FLn}}}A@Gn}\",[]]Cd[\"{{{ANCf{CALj}}{ANCf{{A@Ff{cAMGl}}}}}LAl{}}\",[\"T\"]]Aj[\"{{{ANCf{CFOd}}}LAl}\",[]]Cc[\"{{{BEl{CAKn}}{BEl{BNMf}}}{{A@Ff{CCHbA@Gn}}}}\",[]]Aa[\"{BMO`A@Dd}\",[]]Aa[\"{BJHfA@Dd}\",[]]5Gm[\"{{{ANCf{BN@f}}{BEl{BNMf}}{ANCf{FLn}}{ANCf{FLn}}{ANCf{FLn}}{ANCf{{OLl{BDn}}}}{A@Dd{EIb}}{A@Dd{EIb}}}{{A@Ff{ADA`A@Gn}}}}\",[]]Ba[\"{{{ANCf{EIjBNMn}}AMBj}LAl}\",[]]Ba[\"{{{ANCf{EIjCEFl}}AMBj}LAl}\",[]]An[\"{{{ANCf{CAMh}}AMDh}LAl}\",[]]Be[\"{{{ANCf{CAMh}}{BEl{BNLb}}}LAl}\",[]]An[\"{{{ANCf{CEFl}}A@Gn}LAl}\",[]]Ah[\"{{}{{ACNj{BGj}}}}\",[]]Da[\"{{{NCl{BNKb}}{NCl{CHAh}}AMBjEIdA@GnA@Gn}{{A@Ff{LAlA@Gn}}}}\",[]]Aa[\"{CCIlA@Dd}\",[]]Be[\"{{{ANCf{CALj}}{ANCf{FLn}}}LAl}\",[]]Cd[\"{{{ANCf{CAMf}}{ANCf{CCJb}}}{{A@Ff{LAlA@Gn}}}}\",[]]Dc[\"{{{NCl{BNKb}}{NCl{CGJn}}A@GnA@GnA@GnA@Gn}{{A@Ff{A@GnA@Gn}}}}\",[]]Aj[\"{{{ANCf{CAMh}}}LAl}\",[]]Am[\"{{{ANCf{EIjBJK`}}}LAl}\",[]]Ed[\"{{{ANCf{BEFn}}{ANCf{FLn}}{ANCf{FLn}}{ANCf{FLn}}{ANCf{FLn}}}{{A@Ff{GAhA@Gn}}}}\",[]]n[\"{{}NEh}\",[]]n[\"{{}EIb}\",[]]Di[\"{{{ANCf{BNLb}}{ANCf{FLn}}{ANCf{{OLl{A@Gn}}}}}{{BG`{{BEn{A@Bh}}}}}}\",[]]Di[\"{{{ANCf{CCIf}}{ANCf{FLn}}{ANCf{{OLl{A@Gn}}}}}{{BG`{{BEn{A@Bh}}}}}}\",[]]Di[\"{{{ANCf{CALj}}{ANCf{FLn}}{ANCf{{OLl{A@Gn}}}}}{{BG`{{BEn{A@Bh}}}}}}\",[]]Di[\"{{{ANCf{CAKn}}{ANCf{FLn}}{ANCf{{OLl{A@Gn}}}}}{{BG`{{BEn{A@Bh}}}}}}\",[]]Am[\"{{{ANCf{BJIf}}H@`}H@`}\",[]]Am[\"{{{ANCf{CDKf}}H@`}H@`}\",[]]Df[\"{{{NCl{CDIl}}{NCl{BNKb}}{NCl{CHAf}}}{{A@Ff{{A@Dd{ADBb}}A@Gn}}}}\",[]]Aa[\"{CFOhA@@h}\",[]]A`[\"{BNNhFMh}\",[]]Ak[\"{{{ANCf{BLGn}}}A@Gn}\",[]]Ce[\"{{{NCl{BNKb}}{NCl{CHAh}}EId}{{A@Ff{LAlA@Gn}}}}\",[]]B`[\"{{ANCf{AECj{EIjBDn}}}LAl}\",[]]0000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000Aa[\"{BLH`A@Dd}\",[]]Aa[\"{ACOnA@Gn}\",[]]Aa[\"{BLI`A@@h}\",[]]Cc[\"{{{ANCf{G@f}}{ANCf{G@f}}}{{A@Ff{ADA`A@Gn}}}}\",[]]?>==Di[\"{{{NCl{CDKn}}{NCl{CIKb}}{NCl{CIKd}}{NCl{BNKb}}}{{A@Ff{BJJ`A@Gn}}}}\",[]]Dc[\"{{{NCl{CHO`}}A@GnA@Gn{A@Dd{{BGh{A@Gn}}}}}{{A@Ff{CGJ`A@Gn}}}}\",[]]Bo[\"{{{NCl{CHO`}}A@GnA@Gn}{{A@Ff{LAlA@Gn}}}}\",[]]Cc[\"{{{NCl{CHO`}}A@GnA@GnA@Gn}{{A@Ff{LAlA@Gn}}}}\",[]]Dc[\"{{{ANCf{CALj}}{ANCf{FLn}}{ANCf{c}}}{{A@Ff{LAlAMGl}}}AMH`}\",[\"T\"]]?>=Ch[\"{{{NCl{BNKb}}A@GnA@Gn}{{A@Ff{{A@Dd{CGJf}}A@Gn}}}}\",[]]Ba[\"{{{A@Dd{{ANCf{FLn}}}}}H@`}\",[]]Aj[\"{{{ANCf{AMBj}}}H@`}\",[]]n[\"{{}LAl}\",[]]Co[\"{{{NCl{CHO`}}A@Gn{A@Dd{A@@h}}}{{A@Ff{{BGh{AMF`}}A@Gn}}}}\",[]]Ef[\"{{{NCl{BNKb}}A@GnA@GnA@Gn{A@Dd{A@Gn}}{A@Dd{A@Gn}}{A@Dd{EIb}}}{{A@Ff{LAlA@Gn}}}}\",[]]Em[\"{{{ANCf{CALj}}{ANCf{FLn}}{A@Dd{{ANCf{FLn}}}}{A@Dd{DCl}}{A@Dd{EIb}}}{{A@Ff{A@GnAMGl}}}}\",[]]Ch[\"{{{NCl{CHO`}}A@GnA@Gn}{{A@Ff{{A@Dd{AMF`}}A@Gn}}}}\",[]]Eb[\"{{{NCl{CHO`}}A@GnA@Gn{A@Dd{A@Gn}}{A@Dd{DCl}}{A@Dd{EIb}}}{{A@Ff{A@GnA@Gn}}}}\",[]]") \ No newline at end of file diff --git a/api/search.index/generic_inverted_index/578391e8f1.js b/api/search.index/generic_inverted_index/578391e8f1.js new file mode 100644 index 00000000..54a36f09 --- /dev/null +++ b/api/search.index/generic_inverted_index/578391e8f1.js @@ -0,0 +1 @@ +rb_("Q0hjADswAAABAADZAQMA6ATbABEG2wBgEiEAOzAAAAEAADcCBgAIBBYAIAQQADMEggC3BDAAVgx/ALQN2wA7MAAAAQAAtwECAEsI2wDYDNsAOzAAAAEAAM0AWgDqAgAAKgoAANkRWwD6EgAA/BIBAAATAAACEwAABBMAAAYTAAAIEwEACxMAAA0TAAAQEwEAFBMAABYTAQAaEwAAHBMAAB4TAQAiEwAAJBMAACYTAQAqEwAALBMAAC4TAAAwEwEAMxMAADUTAAA4EwAAOhMAADwTAAA+EwEAQRMAAEQTAABGEwAASBMBAEwTAABOEwAAUBMAAFITAABUEwAAVhMBAFoTAQBdEwAAXxMAAGETAABjEwAAZhMBAGkTAABsEwAAbhMAAHATAQBzEwAAdRMAAHgTAAB6EwEAfhMAAIATAACCEwEAhRMAAIcTAACKEwAAjBMAAI4TAACQEwAAkhMBAJUTAACYEwAAmhMAAJwTAQCfEwAAohMAAKQTAACmEwEAqhMBAK4TAQCyEwAAtBMBALgTAAC6EwAAvBMAAL4TAADAEwEAxBMBAMcTAADKEwEAzhMAANATAADSEwEA1hMAANgTAAA7MAAAAQAAOwIHAAIPAAAYDwAAmg9sAHUQ2wBTEX8AshIAAI4VbwABOhUAADowAAABAAAAAABwABAAAAD5EvsS/hL/EgETAxMFEwcTChMMEw4TDxMSExMTFRMYExkTGxMdEyATIRMjEyUTKBMpEysTLRMvEzITNBM2EzcTORM7Ez0TQBNCE0MTRRNHE0oTSxNNE08TURNTE1UTWBNZE1wTXhNgE2ITZBNlE2gTahNrE20TbxNyE3QTdhN3E3kTfBN9E38TgROEE4YTiBOJE4sTjROPE5ETlBOWE5cTmRObE54ToBOhE6MTpROoE6kTrBOtE7ATsROzE7YTtxO5E7sTvRO/E8ITwxPGE8gTyRPME80TzxPRE9QT1RPXEzwXBMcCAAALDAAAmxYAABgXAAAAOzAAAAEAALwBBwBKANsAPgHbAPUFAAATDwAAtBgAAGkaAACiHAAAA48aAACXHAAAgh0AAAIWGAAAZhoAAAIUDwAA8hsAAAOHEgAAiRIAADYXAAAGFQ8AABYPAACIEgAAihIAADcXAAA4FwAAA/ULAAA1FQAANhUAAAE4FgAAAAAAAAACthYAAIUdAAAAASsaAABBR28AAAA7MAAAAQAA2wABANgM2wAAOzAAAAEAAG8AAQCOFW8AADowAAABAAAAAABvABAAAAD5EvsS/hL/EgETAxMFEwcTChMMEw4TDxMSExMTFRMYExkTGxMdEyATIRMjEyUTKBMpEysTLRMvEzITNBM2EzcTORM7Ez0TQBNCE0MTRRNHE0oTSxNNE08TURNTE1UTWBNZE1wTXhNgE2ITZBNlE2gTahNrE20TbxNyE3QTdhN3E3kTfBN9E38TgROEE4YTiBOJE4sTjROPE5ETlBOWE5cTmRObE54ToBOhE6MTpROoE6kTrBOtE7ATsROzE7YTtxO5E7sTvRO/E8ITwxPGE8gTyRPME80TzxPRE9QT1RPXEwAAOzAAAAEAALcBAgBKANsAPgHbAAKPGgAAlxwAAAFmGgAAARQPAAADhxIAAIkSAAA2FwAABhUPAAAWDwAAiBIAAIoSAAA3FwAAOBcAAAI1FQAANhUAAAAAAAAAAAK2FgAAhR0AAAABKxoAAENkAAAAAAAAAAAAADswAAABAAC3AQIASgDbAD4B2wAAAAAAAAAAAAAAAAABthYAAAABKxoAAENgAAAAAAAAAAAAADswAAABAAC3AQIASgDbAD4B2wAAAAAAAAAAAAAAAAAAAAErGgAA") \ No newline at end of file diff --git a/api/search.index/name/f647592692.js b/api/search.index/name/f647592692.js new file mode 100644 index 00000000..882ad4f8 --- /dev/null +++ b/api/search.index/name/f647592692.js @@ -0,0 +1 @@ +rd_("b()b->bFnbIobOkbOrbTvb[]beq000000000000000000000000000000000bid0000000000000000000000000000000bor000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000bu8cALL0cAll0cAndcAnycArccBoxcEndcErrcIntcMapcOff0cOnecOrdcPincPopcRowcUtccVeccWrycaddcand000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000ccmpcendcf32cf64cfmt0000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000cget00ci32ci64clencmpvcmutcnew0000000000000000000000000000000cruncsetcsid000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000csqlcstrctag0cu16cu32cu64curl0000dBooldDropdFlatdFromdHttp0dIdle00dIntodJazzdLogodLouddNextdNone0dNulldPathdRockdSenddSpandStopdSyncdTime0dTypedUserdVZipdWifidauth000dbody000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000dbooldconn00ddrop00demit0dfrom0000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000dinto000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000ditem000dkind0dload00dlock0dmaindmode00dname000000000000000000000dnext0dopendpath00dpingdplay000dportdpostdpushdrole0dseek0000dsend0dsize0dstop00000000dsync0dunitduser00dvzip000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000eAlbum0000eAlloweAudio000eCloneeDebugeEmptyeError00000000eFloateGenreeHtml5eInt64eLocal00eMbps1eMbps2eMbps4eMbps8eMovie00eMusiceMutex0eOther00ePauseeQueryeQuieteSizedeStart0eStateeThumbeTrackeValue00eVideo0eVocalealbum0000earrayecache00eclear00eclone0000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000ecodec0eemptyeerror00egainseindex00eitems00000000elabel00elevelelimit0eloginemedia000000emuted000epause000equeue0000ereset0erouteescopeesizesesliceestart0000estate0000000000estatsetitle000etokenetotaletouchetupleetypes000eusizeeutils0fArtistfBannerfBorrowfClient0fCustom000fFailed0fFnOncefFolderfFuturefHeaderfMbps10fMbps20fMoviesfNative000000fNormal0fOptionfOutputfPaused000fPerson0fPolicyfPrefixfQueued0fRemote000fResult00fRwLock0fSearch0fSeasonfSeekTofSeriesfServer00fString0fTvShow0fTypeIdfactionfactorsfalbumsfartist000fas_strfborrow000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000fcancelfclient0000fconfig0000fcreatefdeletefdetailfdevice0fdomain0fenable0fevents00ffailedffieldsfgenres0000fhandlefheaderfhybridfinline000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000flogoutfmodels0fonline0fparamsfpeople000fplayer000fpushedfreason0frecordfredactfremote0fremovefrepeat000freport0fresultfschema0fsearch000fsignalfsourcefstatus00ftargetftimers0fvolume0000000fworker0gAdvancegBuildergChannelgDefaultgDisplaygEpisodegHashMapgHashSetgKbps720gKeyring0gLibrarygLoading0gNetwork0000gOfflinegPathBufgPlaying0gPrimarygRequest0gRuntimegSeeking0gSessiongStarted0gStopped00gToOwnedgTryFromgTryIntogUnknowng_configgartists000000gbackend000gbitrate0gcatalog0gcomparegcontext0000gcurrent00gdecryptgdefault000000000000000000000000000000000gdestroygdisable0gdismissgenabled0gencryptgexecute00ghandlesghistorygimdb_idgitem_id00000000000000000000000000000glibrary0glog_dirgmessage00000000gmigrategnetwork0gnew_url00goffline000gpayload0gpercentgpinning0gpointergprimary0gqualitygreportsgseek_togservicegsession000gshuffle000000gskip_togskippedgsort_bygstorage000gsuccessgtimeoutgtmdb_idgtype_id000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000gurl_forguser_id000000000000gversion00hAPP_NAME0hBackdrophCellularhDataTypehDatabase00hDateTimehDbResulthDurationhEQ_BANDShEpisodes0hEqPresethEthernethFavorite0hFinishedhGenericshNotFound00hOnceLockhOrderinghOriginalhPlaylist000000hProgress000hQueuedOp0hREDACTEDhResolvedhResponse0hSpectaIDhSubtitlehSyncSinkhToStringhUserDatahUserInfohUserSkiphUserStophVerified0hadded_athalbum_id0000000000000hattemptshautoplay0hbase_urlhcan_seekhcommands0hdeferredhdownload00000hduration0000000000000hend_datehend_timehget_item000hget_jsonhget_modehhas_next00himage_id00his_mutedhitem_idshjellyfin0hjellytauhlanguage00hmedia_id0hobservedhoverview0000hpin_itemhplatformhplaylist0hpoll_nowhposition000000000000000000000hprevious0hpriority00hprogress0hregisterhregistryhreporter000hresolvedhsessions00hset_modehsettings000hspan_forhthrottlehto_owned0000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000htrack_id0htry_from000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000htry_into000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000husername00hverifiediAppHandleiBassBoostiBorrowMutiBuffering0iCHUNK_LENiCancelled0iClassicaliCompleted0iDEVICE_IDiDirectUrl0iEndReason0iErrorImpliErrorKindiForbiddeniFormatteriImageTags0iImageTypeiJRayActor0iMediaItem00iMediaKind0iMediaType0iMutexSafeiNamedTypeiNoneValueiPartialEqiPlayStateiPolicyExtiQueuedRowiReferenceiRepoError0iSemaphoreiSerialize0iSubstringiWordStartiabandonedicache_diricontainer0idevice_id00idownloadsievict_lruifavorites0ifile_path0000ifile_size0iget_items000iget_limitiget_tokeniimage_tag0iis_active00000iis_folder00iis_forced0iis_paused0000iis_played000iis_seasoniitem_name0000iitem_type0000000iitems_mutikind_rankilast_itemilock_safeilog_filesimax_width0imime_type000imove_itemioperation0iparent_id000iperson_idiplay_itemipost_jsoniquery_one00iqueued_atiread_safeirecursiveireference0000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000iremaining0irun_drain0isanitisediseason_id000iserialize000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000iseries_id0000000iserver_id00000000000000iset_limitiset_queue0isubtitles0ithresholdithumbnail0ito_string0000itrack_ids0itransientiunmetered0iuser_data0iuser_nameiwifi_onlyjAlbumTrackjArtistItemjAtomicBooljAuthResultjCachedItem0jCastActivejCommandArgjComparablejConnectionjEncryptionjEndOfTrackjEpisodeRowjEquivalent0jFileSystemjHttpClient0jHttpConfig0jLEVEL_FILEjMIGRATIONSjMarkPlayed000jMpvBackend0jMutexGuardjPartialOrdjQueryParamjRepeatMode0jRwLockSafejSerializer0jServerInfo0jSkipActionjSmartCachejStreamKind0jStructured0jSyncConfigj_cancelledjalbum_name0000000000000japp_handle00jauth_loginjbatch_sizejborrow_mut000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000jbytes_usedjclear_itemjclone_into0000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000jcontext_idjcreated_atjdb_service00jend_reasonjequivalent0000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000jexclusions0jfile_countjget_configjget_genres000jget_person000jget_statusjimage_tags0jimage_typejis_default0jis_enabledjis_episodejis_libraryjis_playing00jis_running0jis_shuffle0jis_stoppedjitem_count0jitem_typesjlevel_namejlibrary_id00jmaster_macjmax_height00jmedia_type00jnull_value000000000000000000000000000000000jplay_count00jplay_queuejplay_statejquery_many000jread_queuejrepository000jrequest_idjsave_tokenjserver_url0000jsession_id0jset_volume000jsize_bytesjslave_macsjsort_order00jstarted_atjstream_end0jstream_url0000jsync_drain0jtarget_dirjthumbnails0jtotal_sizejtrack_playjunpin_itemjwrite_safekAPP_VERSION0kAddPositionkAuthManagerkCacheConfig00kChannelItemkCommandItemkDeserializekDrainReportkEQ_GAIN_MAXkEQ_GAIN_MINkFunctionArgkInvokeErrorkIpcResponsekJRayContextkKeepPlayingkLevelFilterkLiveChannelkMediaLoaded0kMediaServerkMediaSource000kMediaStreamkNeedsReauth0kNetworkTypekNullBackend0kOfflineItemkPlayerError0kPlayerState00kPollingHintkPushFailurekQueueStatuskScopeObjectkSearchScopekSessionInfokTauriPluginkTransactionkTrebleBoostkVolumeLevelk_db_servicek_processingk_repositorykalbum_countkartist_name0000kartwork_urlkauth_headerkauth_logoutkclear_cachekclear_queuekconversions000kcredentials0kdelete_itemkdeserialize0000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000kdevice_namekdiagnostics000kdirect_playkdownload_id000000kduration_ms0kformat_timekget_item_idkget_session0khas_content0000khttp_client0000kis_checkingkis_favorite0000kitems_countkjellyfin_id0klimit_byteskmark_failedkmark_played000kmark_syncedkmaster_namekmax_bitratekmax_retries00kmpv_backendkparse_levelkpartial_cmpkperson_type0kplayer_nextkplayer_playkplayer_seekkplayer_stopkplaylist_id0000kposition_ms0krecoverablekrepeat_mode00kretry_count00ksearch_rankksearch_termkseason_name00000kseason_rankkseek_offsetkseries_name00000kserver_name00kset_contextkset_sessionkslave_namesksleep_timer0ksmart_cache0kstart_index00kstream_type0ktarget_lufsktarget_pathkticks_to_ms0kto_datatype0000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000ktotal_bytes0ktotal_itemsktrack_countktransaction0kvideo_codec0kvideo_widthkwith_paramslCachedPersonlDeserializerlDownloadInfolDownloadTasklEXCLUDED_IDSlFavoriteSinklImageOptionslJellyfinItemlJellyfinUserlLmsSyncGrouplMarkUnplayed0lMatchQualitylNetworkError0lNetworkStatelObservedTimelPlaybackInfolPlaybackMode0lPlayerStatuslPlaylistItemlQueueChanged0lQueueContext00lQueueManager0lReloadStream00000lReporterSinklResumeStream0lSERVICE_NAMElSearchResultlStateChanged0lStorageStatslVideoBackendlaccess_token00000lactive_count00lalbum_artist0000lartist_items00lauth_managerlauto_advance0lbase_secondslcache_subdirlcompleted_atlconnectivity0000lcontent_typelcontext_typelcurrent_hintlcurrent_itemlcurrent_modelcycle_repeat0ldefault_trueldelete_tokenlensure_spacelfailed_countlfrom_command000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000lget_channels000lget_sessionslget_upcominglhas_previous00lindex_is_duelindex_number0000lis_containerlis_reachable0lis_retryable0litems_cached0litems_pruned0ljellytau_libllast_checkedllibrary_namellist_optionslmax_episodeslmedia_server0lmerged_medialneeds_reauthlnetwork_type0lnext_episode0lnormalise_idlpartial_pathlpaused_countlplay_commandlplayer_pauselqueued_count0lread_pendinglrepositorieslresolve_pathlsave_to_filelsearch_querylseries_prefs0lsession_seeklshould_retrylshuffle_modelstorage_initlstorage_typelstored_levelltotal_tracksltry_downloadlvideo_heightlvolume_level0mActiveSessionmAdvanceToNextmAudioSettingsmCachedLibrarymCastDiscoverymCloneToUninitmCountdownTick0mDownloadErrormDownloadEvent0mDownloadStatsmEncryptedFilemExcludeHiddenmInternalStatemItemsResponsemMIGRATION_001mMIGRATION_002mMIGRATION_003mMIGRATION_004mMIGRATION_005mMIGRATION_006mMIGRATION_007mMIGRATION_008mMIGRATION_009mMIGRATION_010mMIGRATION_011mMIGRATION_012mMIGRATION_013mMIGRATION_014mMIGRATION_015mMIGRATION_016mMIGRATION_017mMIGRATION_018mMIGRATION_019mMIGRATION_020mMIGRATION_021mMIGRATION_022mMIGRATION_023mNamedDataTypemPanicHookInfomPlaybackEndedmPlaybackStart0mPlayerBackend0mPlaylistEntrymPreloadResultmResumeTrackermSearchOptionsmSubtitleTrack0mSyncProcessor0mSyncQueueItemmVideoSettingsmVolumeChanged0mallow_attemptmaudio_bitratemcalc_progressmcurrent_index000mcurrent_mediamdevice_get_idmdevice_set_idmdisplay_title0mdownload_itemmdownload_rootmerror_message0mevent_emitter0000mexecute_batch0mexecute_querymexpected_sizemfrom_jellyfin0mget_downloadsmget_favorites000mget_from_filemget_image_url000mget_json_fastmget_libraries000mget_user_datamhtml5_playingmimage_get_urlmis_monitoringmlast_durationmlast_position0mload_and_playmmark_deferredmmark_favorite000mmark_reportedmmatch_qualitymmedia_sources0mmedia_streams0mmerged_volumemmime_from_extmnormalize_urlmparallel_racemplayback_mode000mplayer_togglemplaylist_name00mpremiere_date000mprocess_queuempush_favoritemremote_volumemreported_timemresolve_scopemresume_offsetmruntime_ticks000msave_to_cachemsearch_peoplemseason_number0mseek_absolutemset_reachablemshould_reportmshuffle_ordermstorage_limit0mstream_resumemthread_handlemtracks_playedmtry_to_string0000mupdate_configmusing_keyring0mvideo_bitratemwith_durationnAuthentication00nControlCommand0nDownloadResultnDownloadWorker0nEventThrottler0nFunctionResultnHandOffToAudionIndexPassGuardnJellyfinClient0nJellyfinConfignLiveStreamInfonNewTrackLoadednNowPlayingItem0nPositionUpdate0nSECRET_HEADERSnSecurityStatusnSessionChanged0nSleepTimerMode00nThumbnailCache0nTypeCollectionnaudio_settings00000nclassify_errornconvert_paramsndevice_profile0ndownload_albumndownload_bytes0ndownload_videonencryption_keynepisode_number0nfavorites_onlynget_cache_sizenget_item_countnget_json_innernget_stream_urlnis_excluded_bynis_in_progressnis_item_pinnednis_video_medianlast_seek_timenlast_synced_atnlive_stream_idnmark_completednmark_reachablenmax_concurrent00nmax_size_bytesnnormalized_urlnoffline_searchnpause_downloadnplayer_skip_tonposition_ticks00000000nquality_preset0nquery_optional00nqueue_for_syncnread_last_syncnredact_headersnreport_outcomenreport_successnrun_index_passnrun_time_ticks0nsafe_componentnsave_thumbnailnsession_poller0nset_app_handle0nset_end_reasonnset_repositorynset_server_urlnspecta_buildernstart_downloadnstart_positionnstop_transcodensync_processornsync_to_servernthumbnail_saventoggle_shuffle0nverify_sessionoCredentialErroroCredentialStoreoDatabaseService0oDatabaseWrapperoDiagnosticsInfooDownloadManageroGetImageRequestoGetItemsOptionsoHtml5NativeSeekoLibrarySettingsoLocalNativeSeekoMediaRepositoryoMergedMediaItemoPendingFavoriteoPlayItemRequestoPlaybackContext0oPlaybackStopped0oRETRY_DELAYS_MSoRusqliteService0oRwLockReadGuardoSPECIALS_SEASONoSessionVerifier0oSessionsUpdated0oSleepTimerState0oSmartCacheStatsoThumbnailWorker0oTryReserveErroroadd_to_playlist000oallows_download0oauth_initializeoavailable_slotsoavailable_spaceobuild_af_filterocancel_downloadoclone_to_uninit0000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000ocollection_type0ocompleted_countoconfine_to_rootocreate_playlist000ocurrent_episode0ocurrent_sessionodelete_downloadodelete_playlist000odownload_seasonodownload_seriesodownload_sourceoequalizer_bandsoevict_lru_asyncofavorites_scopeoget_auth_headeroget_cached_pathoget_device_name0oget_jray_actors0oimage_semaphoreointo_media_itemois_episode_itemois_html5_activeois_transferring0ojellyfin_client0000omedia_local_urlomedia_source_id0onamed_data_type000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000onot_implementedoofficial_rating000oparse_queued_opopeek_end_reasonoplay_on_sessionoplay_queue_fromoplay_session_id0000oplayer_previousoplaylist_createoplaylist_deleteoplaylist_renameopost_json_inneroproduction_year000orecord_positionoremaining_countorename_playlist000oresume_downloadosave_to_keyringoseries_progress0oset_audio_track0oset_report_sinkoset_sleep_timeroshould_downloadostop_monitoringosync_clear_userotake_end_reasonotoggle_playbackounmark_favorite000oupdate_local_dbowith_start_timeA`AlbumStorageInfoA`AutoplayDecision00A`AutoplaySettings0A`BackendInitErrorA`BackgroundActionA`CachedItemPersonA`CredentialResultA`DeserializeOwnedA`HybridRepository0A`JellyfinUserDataA`MediaSessionType00A`OnlineRepository0A`PlayQueueContext0A`PlayQueueRequestA`PlaybackProgressA`PlaybackReporter0A`PlayerControllerA`PublicSystemInfoA`RwLockWriteGuardA`SECRET_JSON_KEYSA`StreamingQualityA`TICKS_PER_SECOND0A`ToStringFallibleA`VIDEO_ITEM_TYPESA`WebviewAudioLoad0A`_jellyfin_clientA`active_downloadsA`album_file_namesA`auth_get_sessionA`auth_set_sessionA`build_log_pluginA`bytes_downloaded00A`community_rating000A`connection_errorA`countdown_cancelA`credentials_pathA`default_eq_bandsA`delete_from_fileA`drain_sync_queueA`duration_or_lastA`duration_secondsA`enqueue_downloadA`erased_serialize000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000A`first_failure_atA`format_time_longA`gapless_playbackA`get_album_tracksA`get_from_keyringA`get_latest_items000A`get_queue_statusA`get_resume_items000A`get_subtitle_url000A`html5_is_playingA`is_truncated_endA`is_using_keyringA`jellyfin_item_id0A`last_played_date0A`last_report_timeA`libraries_failed0A`map_download_rowA`mark_unreachableA`mirror_user_dataA`normalize_volume0A`now_playing_item0A`open_live_stream000A`player_get_queueA`player_play_itemA`playlist_item_id0A`position_or_lastA`redact_emby_authA`resume_stream_atA`seconds_to_ticksA`session_verifierA`set_current_itemA`set_env_if_unsetA`set_polling_hintA`set_transferringA`start_event_loopA`start_monitoringA`start_tv_sessionA`storage_get_itemA`storage_get_pathA`storage_get_sizeA`sync_get_pendingA`sync_mark_failedA`ticks_to_secondsA`total_size_bytes0A`tv_session_endedA`without_excluded00AaAddToQueueRequestAaBackendNativeSeekAaCATALOG_INDEX_TTLAaCatalogIndexEventAaCatalogSyncResultAaCatalogSyncStatusAaDiagnosticsBundleAaDownloadDiskUsageAaDownloadsResponseAaHtml5ReloadStreamAaINDEX_IN_PROGRESSAaMAX_SYNC_ATTEMPTSAaMeaningfulContentAaOfflineRepository0AaPlayTracksContext0AaPlayTracksRequestAaPlaybackOperation00AaPlayerStatusEvent00AaRepositoryManager0AaSEARCH_EVENT_NAMEAaSECRET_QUERY_KEYSAaSKIP_BACK_SECONDSAaSTREAMING_QUALITYAaSearchUpdateEventAaSleepTimerChanged0AaSleepTimerExpiredAaSmartCacheWrapperAaTauriEventEmitter0AaVideoSeekResponse0AaVideoSeekStrategy0AaWaitingForNetworkAaabsolute_positionAaaudio_track_indexAaautoplay_settings0Aabackground_actionAabackground_policy0Aabelongs_to_seriesAabuild_auth_headerAaconnect_to_serverAacountdown_seconds00Aacreate_media_itemAadecrement_episodeAadirect_stream_url0Aaeq_filter_entriesAaequalizer_enabledAaexcluded_item_ids0Aaexcluded_snapshotAaget_playback_info000Aaget_player_statusAaget_resume_movies000Aaget_similar_items000Aais_active_sessionAalast_insert_rowid0Aalms_unsync_player0Aamerged_is_playingAaneeds_transcoding00Aaobserved_durationAaoffline_get_itemsAaon_playback_endedAapercent_to_volumeAaplayback_mode_setAaplayback_reporter00Aaplayer_controllerAaplayer_get_statusAaplayer_play_queueAaplayer_seek_videoAaplayer_set_volumeAaprimary_image_tag00000000000Aarace_with_refreshAaredact_server_urlAaregister_downloadAaremaining_seconds00Aareport_completionAareport_stopped_atAarepository_createAarepository_searchAasanitize_filename0Aasearch_cache_onlyAasessions_poll_nowAaset_event_emitter00Aaset_network_stateAasleep_timer_stateAasort_series_orderAastorage_get_itemsAastorage_get_usersAastorage_save_itemAastorage_save_userAastreaming_quality0Aasubtitle_profilesAasync_full_catalogAathreshold_reachedAathrottle_durationAatransfer_to_localAause_html5_elementAavolume_to_percentAawith_connectivityAbAuthManagerWrapperAbCATALOG_INDEX_TICKAbCATALOG_ITEM_TYPESAbConnectivityStatusAbDEFAULT_TIMEOUT_MSAbExclusionCandidateAbInvokeResponseBodyAbLIBRARY_HOLDS_ITEMAbLockscreenMetadataAbMediaServerWrapperAbNORMALIZE_REF_LUFSAbNORMALIZE_REF_PEAKAbNO_SUBTITLE_STREAMAbNetworkStateHandleAbPlaybackReportSinkAbPlayerEventEmitter0AbPlayerStateWrapperAbResumeQueuedResultAbSharedEventEmitterAbVIDEO_PLAY_SESSIONAb_calculate_backoffAbalbum_play_historyAbalready_downloadedAbapply_probe_resultAbcache_with_timeoutAbcalculate_intervalAbcalculate_progressAbcan_download_asyncAbcan_start_downloadAbcancel_sleep_timerAbcheck_reachabilityAbcrossfade_durationAbdevice_total_bytesAbdiagnostics_exportAbemit_html5_controlAbemit_queue_changedAbget_playlist_items000Abinclude_item_types0Abinstall_panic_hookAbkind_from_jellyfin0Abmax_audio_channelsAbmove_playlist_item000Abnormalize_usernameAbpartial_containersAbplayback_reporting000Abplayer_get_sessionAbplayer_play_tracksAbplayer_toggle_muteAbplaylist_add_itemsAbplaylist_get_itemsAbplaylist_move_itemAbposition_throttler00Abpost_json_responseAbqueue_album_tracksAbredact_json_valuesAbreport_html5_stateAbrepository_destroyAbrequest_with_retryAbrow_to_cached_item0Absave_to_cache_implAbsearch_server_onlyAbserved_audio_codec0Absession_set_volumeAbset_audio_settings000Abset_max_concurrentAbset_subtitle_track0Abshould_cache_albumAbstart_timer_threadAbstorage_get_personAbsupported_commandsAbtest_keyring_innerAbtotal_record_count00Abtransfer_to_remoteAbupdate_audio_trackAbuses_webview_audioAbvideo_audio_codecsAcAddTrackByIdRequestAcAlbumAffinityStatusAcAlbumRecommendationAcBackendReloadStreamAcBackgroundAudioPlanAcCATALOG_INDEX_EVENTAcConnectivityMonitorAcDEFAULT_MAX_RETRIESAcDownloadItemRequestAcJellyfinMediaSourceAcJellyfinMediaStreamAcMediaSessionManager0AcNetworkStateWrapperAcPlaybackModeChanged0AcPlaybackModeManagerAcRemoteSessionStatusAcRemoteVolumeControlAcSUBTITLE_QUERY_KEYSAcThumbnailCacheStatsAcapplication_versionAcauth_reauthenticateAcauth_verify_sessionAcbackdrop_image_tags00Acbase_retry_delay_msAccached_album_tracksAccatalog_sync_statusAcclaim_stream_resumeAcclear_reported_timeAcclear_watch_history000Acconfine_queued_pathAccurrent_interval_msAccurrent_jellyfin_idAcdelete_from_keyringAcdetect_audio_systemAcdo_erased_serialize000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000Acdownload_with_retryAcenvironment_summaryAcexponential_backoffAcfor_collection_typeAcformat_time_secondsAcget_items_by_person000Acis_audio_only_video0Acis_server_reachableAclms_get_sync_groups0Acmovie_session_endedAcparent_index_number000Acplayback_context_idAcplayer_add_to_queueAcplayer_cycle_repeatAcplayer_report_stateAcprune_stale_catalog0Acpump_download_queueAcrank_search_results0Acredact_query_paramsAcremote_send_commandAcremote_session_seekAcrepository_get_itemAcresolve_skip_actionAcsession_toggle_muteAcset_jellyfin_clientAcstart_audio_sessionAcstart_movie_sessionAcstorage_delete_userAcstorage_get_serversAcstorage_mark_playedAcstorage_mark_syncedAcstorage_save_personAcstorage_save_serverAcstorage_set_watchedAcsync_mark_completedAcsync_queue_mutationActemporary_ttl_hoursActhumbnail_get_statsActhumbnail_set_limitAcunregister_downloadAdCANDIDATE_SCAN_LIMITAdCREDENTIALS_FILENAMEAdConnectivityReporterAdDOWNLOADED_ITEMS_CTEAdDownloadManagerStatsAdDownloadVideoRequestAdFALLBACK_AUDIO_CODECAdJellyfinPlaylistItemAdLIBRARY_SETTINGS_KEYAdPlaybackCapabilitiesAdPlaybackStartRequestAdSKIP_FORWARD_SECONDSAdSessionPollerManagerAdSessionPollerWrapperAdShowNextEpisodePopup000AdVideoSettingsWrapperAdWEBVIEW_AUDIO_CODECSAdaudio_track_languageAdchanged_favorite_idsAdconnectivity_monitorAddelete_all_downloadsAddiagnostics_get_infoAdextract_jellyfin_idsAdget_active_downloadsAdget_audio_stream_url000Adget_credentials_pathAdget_downloaded_items0Adget_items_unfilteredAdget_live_tv_channels000Adget_next_up_episodes000Adget_video_stream_url0Adis_playing_or_pausedAdlibrary_get_settingsAdlibrary_set_settingsAdmark_download_failedAdmerge_search_resultsAdoffline_is_availableAdpanic_payload_stringAdpick_current_episodeAdplayable_media_typesAdplayback_mark_playedAdplayback_position_msAdplayer_move_in_queueAdqueue_precache_count0Adremove_from_playlist000Adrepository_get_itemsAdreset_autoplay_countAdresolve_in_cache_dirAdresume_audio_sessionAdsave_genres_to_cacheAdsend_general_commandAdsend_session_commandAdstorage_save_libraryAdstorage_search_itemsAdsupports_direct_play0Adsupports_transcoding0Adsync_mark_processingAdsync_process_pendingAdthumbnail_get_cachedAdunique_tracks_playedAeAddTracksByIdsRequestAeDEFAULT_REMOTE_VOLUMEAeFavoritesChangedEventAeFunctionResultVariantAeLAST_CATALOG_SYNC_KEYAeMAX_PROGRESS_FRACTIONAeMIN_PROGRESS_FRACTIONAePlayAlbumTrackRequestAePlaylistCreatedResultAePlaylistItemsResponseAeSTREAMING_QUALITY_KEYAeSeriesAudioPreferenceAeStreamQualityResponse0AeTICKS_PER_MILLISECONDAeThumbnailCacheWrapperAebackground_audio_baseAebackground_audio_planAeclear_stale_downloadsAeconnectivity_reporterAecreate_player_backendAederive_encryption_keyAediagnostics_set_levelAeenable_remote_controlAeexit_background_audioAefetch_series_episodesAeget_downloads_allowedAeget_rediscover_albums000Aeget_smart_cache_statsAelms_create_sync_group0Aeload_credentials_fileAenote_sleep_timer_stopAeplayback_context_typeAeplayback_mode_managerAeplayback_report_startAeplayer_get_eq_presetsAeplayer_recover_streamAeplayer_toggle_shuffleAeplaylist_remove_itemsAereclaim_expired_asyncAereport_html5_positionAereport_playback_start0000Aerepository_get_genresAerepository_get_personAesave_credentials_fileAeset_autoplay_settingsAeset_excluded_item_idsAeset_playback_reporterAeset_streaming_qualityAeshould_precache_queueAespawn_catalog_indexerAespawn_download_workerAespawn_favorites_drainAestorage_delete_serverAestorage_get_librariesAesupports_native_videoAethumbnail_clear_cacheAethumbnail_delete_itemAfCreatePlaylistResponseAfCredentialStoreWrapperAfDownloadManagerWrapperAfINCLUDE_CATALOG_BROWSEAfNetworkStateWrapperArgAfOFFLINE_CONFIRM_WINDOWAfPlaybackStoppedRequestAfSessionVerifierWrapperAfThumbnailDownloadErrorAfalbum_affinity_enabledAfaudio_forces_transcodeAfaudio_session_inactiveAfauth_connect_to_serverAfauth_stop_verificationAfauto_play_max_episodesAfauto_play_next_episodeAfautoplay_episode_countAfbuild_fts_prefix_queryAfbuild_next_up_endpointAfcompute_download_statsAfdelete_album_downloadsAfdelete_downloads_underAfenter_background_audioAfgenerate_shuffle_orderAfget_album_play_historyAfget_smart_cache_configAfget_video_download_url000Afinclude_catalog_browseAfnormalize_filter_entryAfplayback_reporter_initAfplayer_add_track_by_idAfplayer_dismiss_sessionAfplayer_get_sleep_timerAfplayer_report_positionAfplayer_set_audio_trackAfplayer_set_sleep_timerAfpush_remote_lockscreenAfqueue_precache_enabledAfremote_play_on_sessionAfreport_network_failureAfset_queue_with_contextAfshould_show_miniplayerAfspawn_sync_queue_drainAfsupports_direct_stream0Afsupports_media_controlAfsync_cleanup_completedAfsync_get_pending_countAftest_keyring_availableAfwith_countdown_clampedAfwith_crossfade_clampedAgConnectivityChangeEventAgFALLBACK_AUDIO_CHANNELSAgFAVORITES_CHANGED_EVENTAgPlaybackProgressRequestAgPlaybackReporterWrapperAgRETRY_CHECK_INTERVAL_MSAgalbum_primary_image_tagAgauth_start_verificationAgbackground_audio_activeAgbackground_audio_sourceAgcache_items_from_serverAgconnectivity_get_statusAgdownload_item_and_startAgdrain_pending_favoritesAgemit_server_reconnectedAgenqueue_video_downloadsAgget_download_disk_usage0Aglms_dissolve_sync_group0Agmark_download_completedAgon_video_playback_endedAgplayback_position_ticks0Agplayback_report_stoppedAgplayer_disable_jellyfinAgplayer_get_cache_configAgplayer_get_capabilitiesAgplayer_local_media_pathAgplayer_play_album_trackAgplayer_preload_upcomingAgplayer_set_cache_configAgreport_playback_stopped0000Agrepository_get_channelsAgresolve_current_episodeAgresume_queued_downloadsAgsave_libraries_to_cacheAgset_show_server_catalogAgstorage_get_active_userAgstorage_get_item_peopleAgstorage_set_active_userAgstorage_toggle_favoriteAgsubtitle_forces_burn_inAgsupports_remote_controlAgtransfer_to_local_innerAgtv_session_next_episodeAhAudioTrackSwitchResponse0AhIMAGE_DOWNLOAD_SEMAPHOREAhRESUME_BACKOFF_STEP_SECSAhRESUME_THRESHOLD_SECONDSAhRepositoryManagerWrapper0AhSYNC_QUEUE_CHANGED_EVENTAhSessionVerificationEvent0AhVERIFICATION_INTERVAL_MSAhadopt_video_play_sessionAhalbum_affinity_thresholdAhbegin_video_play_sessionAhbuild_favorites_endpointAhbuild_get_items_endpointAhcheck_for_local_downloadAhclamp_max_audio_channelsAhconvert_ticks_to_secondsAhcurrent_is_audio_episodeAhemit_backend_init_failedAhemit_connectivity_changeAhemit_sleep_timer_changedAhenqueue_playback_stoppedAhformat_time_seconds_longAhget_downloaded_libraries0Ahget_favorites_cache_onlyAhincrement_autoplay_countAhmaybe_run_scheduled_passAhpersist_library_settingsAhplay_selection_on_remoteAhplayback_report_progressAhplayer_add_tracks_by_idsAhplayer_background_actionAhplayer_on_playback_endedAhplayer_play_next_episodeAhplayer_remove_from_queueAhpost_json_response_innerAhrecoverable_error_resumeAhreport_playback_progress0000Ahrepository_get_favoritesAhrepository_get_image_urlAhrepository_get_librariesAhrepository_mark_favoriteAhresolve_local_media_pathAhrestore_library_settingsAhset_connectivity_monitorAhshould_show_video_playerAhstart_autoplay_countdownAhstorage_get_access_tokenAhstorage_save_item_peopleAhtransfer_to_remote_innerAhtv_session_episode_endedAhupdate_remaining_secondsAhwebview_can_decode_audioAhwith_validated_countdownAiCATALOG_INDEX_FIRST_CHECKAiEXTERNAL_SUBTITLE_FORMATSAiRemoteDisconnectRequestedAiaudio_track_display_titleAicached_item_to_media_itemAicancel_autoplay_countdownAiconnectivity_check_serverAiconvert_percent_to_volumeAiget_album_affinity_statusAiget_album_recommendationsAiget_favorites_server_onlyAiget_recently_played_audio000Aipersist_streaming_qualityAiplayback_mode_get_currentAiplayback_reporter_destroyAiplayer_cancel_sleep_timerAiplayer_configure_jellyfinAiplayer_get_audio_settingsAiplayer_get_video_settingsAiplayer_set_audio_settingsAiplayer_set_stream_qualityAiplayer_set_subtitle_trackAiplayer_set_video_settingsAiplayer_switch_audio_trackAiremote_session_set_volumeAireport_html5_media_loadedAireport_progress_throttledAirepository_jray_actors_atAirestore_streaming_qualityAisessions_set_polling_hintAiset_background_audio_baseAiset_connectivity_reporterAistream_kind_from_jellyfin0Aiupdate_current_stream_urlAiupdate_smart_cache_configAiwith_equalizer_normalisedAjAuthenticateByNameResponseAjConnectivityMonitorWrapperAjMediaSessionManagerWrapperAjPlaybackModeManagerWrapperAjaudio_settings_jni_payloadAjcompletion_report_positionAjdefinition_named_data_type000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000Ajget_download_manager_statsAjget_download_storage_statsAjis_background_audio_activeAjparent_backdrop_image_tags00Ajplayer_report_media_loadedAjremote_session_toggle_muteAjrepository_unmark_favoriteAjresolve_video_download_urlAjsend_remote_volume_commandAjset_include_catalog_browseAjstorage_get_active_sessionAjsupports_external_deliveryAjtake_background_audio_baseAjupdate_lockscreen_metadataAkDownloadItemAndStartRequestAkMAX_STALLED_RESUME_ATTEMPTSAkPlatformRemoteVolumeControlAkauto_play_countdown_secondsAkbuild_latest_items_endpointAkconnectivity_mark_reachableAkconnectivity_set_server_urlAkfetch_next_episode_for_itemAkrefresh_queue_local_sourcesAkrepository_get_latest_itemsAkrepository_get_resume_itemsAkrepository_get_subtitle_urlAkrepository_open_live_streamAkstorage_get_security_statusAlMAX_SUPPORTED_AUDIO_CHANNELSAlRESUME_PROGRESS_EPSILON_SECSAlauto_advance_to_next_episodeAlconnectivity_stop_monitoringAllog_available_vaapi_decodersAlplayer_exit_background_audioAlplayer_get_autoplay_settingsAlplayer_retry_restarts_streamAlplayer_set_autoplay_settingsAlrepository_get_playback_infoAlrepository_get_resume_moviesAlrepository_get_similar_itemsAlsave_playlist_items_to_cacheAlset_max_concurrent_downloadsAmconnectivity_mark_unreachableAmconnectivity_start_monitoringAmdetermine_video_seek_strategy0Amget_total_download_size_asyncAmplayback_mode_is_transferringAmplayer_enter_background_audioAmresolve_pending_download_urlsAmstorage_get_playback_progressAnplayback_mode_set_transferringAnplayback_subtitle_stream_indexAnplayer_get_streaming_qualitiesAnrepository_clear_watch_historyAnrepository_get_items_by_personAnrepository_get_series_episodesAnset_lockscreen_position_offsetAnstorage_get_pending_sync_countAnwithout_server_chosen_subtitleAoTRUNCATED_STREAM_TOLERANCE_SECSAoplayback_mode_get_remote_statusAoplayback_mode_transfer_to_localAorepository_get_audio_stream_urlAorepository_get_downloaded_itemsAorepository_get_live_tv_channelsAorepository_get_next_up_episodesAorepository_get_video_stream_urlAostorage_update_playback_contextB`library_get_exclusion_candidatesB`playback_mode_transfer_to_remoteB`player_cancel_autoplay_countdownB`repository_get_rediscover_albumsB`repository_report_playback_startB`requeue_mistyped_video_downloadsB`storage_update_playback_progressB`truncated_stream_resume_positionBarepository_get_video_download_urlBastart_position_ticks_from_secondsBbadvance_to_next_episode_audio_onlyBbrepository_get_download_disk_usageBbrepository_report_playback_stoppedBcget_audio_only_stream_url_for_video0000Bcrepository_get_downloaded_librariesBcrepository_report_playback_progressBcstorage_get_series_audio_preferenceBcsubtitle_supports_external_deliveryBddownloads_allowed_on_current_networkBdenable_linux_hardware_video_decodingBdrepository_get_recently_played_audioBdstorage_save_series_audio_preferenceBebuild_audio_only_stream_url_for_videoBerepository_get_series_current_episodeBnrepository_get_audio_only_stream_url_for_video") \ No newline at end of file diff --git a/api/search.index/normalizedName/6407ef22cc.js b/api/search.index/normalizedName/6407ef22cc.js new file mode 100644 index 00000000..93bac140 --- /dev/null +++ b/api/search.index/normalizedName/6407ef22cc.js @@ -0,0 +1 @@ +rd_("b()b->bfnbiobokborbtvb[]beq000000000000000000000000000000000bid00000000000000000000000000000004444444444444444444444444444444444444444444444444444444444444444444444444444444444444444444444444444444444444444444444444444444444444444444444444444444444444444444444444444444444444444444444444444444444444444444444444444bu8call000candcanycarccboxcendcerrcintcmapcoff0conecordcpincpopcrowcutccveccwrycaddcand000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000ccmp?cf32cf64cfmt0000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000cget00ci32ci64clencmpvcmutcnew0000000000000000000000000000000cruncsetcsid000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000csqlcstrctag0cu16cu32cu64curl0000dboolddropdflatdfromdhttp0didle00dintodjazzdlogodlouddnextdnone0dnulldpathdrockdsenddspandstopdsyncdtime0dtypeduserdvzipdwifidauth000dbody000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000dbooldconn00ddrop00demit0dfrom0000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000dinto000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000ditem000dkind0dload00dlock0dmaindmode00dname000000000000000000000dnext0dopendpath00dpingdplay000dportdpostdpushdrole0dseek0000dsend0dsize0dstop00000000dsync0dunitduser00dvzip000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000ealbum0000ealloweaudio000ecloneedebugeemptyeerror00000000efloategenreehtml5eint64elocal00embps1embps2embps4embps8emovie00emusicemutex0eother00epauseequeryequietesizedestart0estateethumbetrackevalue00evideo0evocalealbum0000earrayecache00eclear00eclone0000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000ecodec0eemptyeerror00egainseindex00eitems00000000elabel00elevelelimit0eloginemedia000000emuted000epause000equeue0000ereset0erouteescopeesizesesliceestart0000estate0000000000estatsetitle000etokenetotaletouchetupleetypes000eusizeeutils0fartistfbannerfborrowfclient0fcustom000ffailed0ffnonceffolderffuturefheaderfmbps10fmbps20fmoviesfnative000000fnormal0foptionfoutputfpaused000fperson0fpolicyfprefixfqueued0fremote000fresult00frwlock0fsearch0fseasonfseektofseriesfserver00fstring0ftvshow0ftypeidfactionfactorsfalbumsfartist000easstrfborrow000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000fcancelfclient0000fconfig0000fcreatefdeletefdetailfdevice0fdomain0fenable0fevents00ffailedffieldsfgenres0000fhandlefheaderfhybridfinline000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000flogoutfmodels0fonline0fparamsfpeople000fplayer000fpushedfreason0frecordfredactfremote0fremovefrepeat000freport0fresultfschema0fsearch000fsignalfsourcefstatus00ftargetftimers0fvolume0000000fworker0gadvancegbuildergchannelgdefaultgdisplaygepisodeghashmapghashsetgkbps720gkeyring0glibrarygloading0gnetwork0000gofflinegpathbufgplaying0gprimarygrequest0gruntimegseeking0gsessiongstarted0gstopped00gtoownedgtryfromgtryintogunknownfconfiggartists000000gbackend000gbitrate0gcatalog0gcomparegcontext0000gcurrent00gdecryptgdefault000000000000000000000000000000000gdestroygdisable0gdismissgenabled0gencryptgexecute00ghandlesghistoryfimdbidfitemid00000000000000000000000000000glibrary0flogdirgmessage00000000gmigrategnetwork0fnewurl00goffline000gpayload0gpercentgpinning0gpointergprimary0gqualitygreportsfseektogservicegsession000gshuffle000000fskiptogskippedfsortbygstorage000gsuccessgtimeoutftmdbidftypeid000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000furlforfuserid000000000000gversion00gappname0hbackdrophcellularhdatatypehdatabase00hdatetimehdbresulthdurationgeqbandshepisodes0heqpresethethernethfavorite0hfinishedhgenericshnotfound00honcelockhorderinghoriginalhplaylist000000hprogress000hqueuedop0hredactedhresolvedhresponse0hspectaidhsubtitlehsyncsinkhtostringhuserdatahuserinfohuserskiphuserstophverified0gaddedatgalbumid0000000000000hattemptshautoplay0gbaseurlgcanseekhcommands0hdeferredhdownload00000hduration0000000000000genddategendtimeggetitem000ggetjsonggetmodeghasnext00gimageid00gismutedgitemidshjellyfin0hjellytauhlanguage00gmediaid0hobservedhoverview0000gpinitemhplatformhplaylist0gpollnowhposition000000000000000000000hprevious0hpriority00hprogress0hregisterhregistryhreporter000hresolvedhsessions00gsetmodehsettings000gspanforhthrottlegtoowned0000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000gtrackid0gtryfrom000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000gtryinto000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000husername00hverifiediapphandleibassboostiborrowmutibuffering0hchunklenicancelled0iclassicalicompleted0hdeviceididirecturl0iendreason0ierrorimplierrorkindiforbiddeniformatteriimagetags0iimagetypeijrayactor0imediaitem00imediakind0imediatype0imutexsafeinamedtypeinonevalueipartialeqiplaystateipolicyextiqueuedrowireferenceirepoerror0isemaphoreiserialize0isubstringiwordstartiabandonedhcachediricontainer0hdeviceid00idownloadshevictlruifavorites0hfilepath0000hfilesize0hgetitems000hgetlimithgettokenhimagetag0hisactive00000hisfolder00hisforced0hispaused0000hisplayed000hisseasonhitemname0000hitemtype0000000hitemsmuthkindrankhlastitemhlocksafehlogfileshmaxwidth0hmimetype000hmoveitemioperation0hparentid000hpersonidhplayitemhpostjsonhqueryone00hqueuedathreadsafeirecursiveireference0000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000iremaining0hrundrain0isanitisedhseasonid000iserialize000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000hseriesid0000000hserverid00000000000000hsetlimithsetqueue0isubtitles0ithresholdithumbnail0htostring0000htrackids0itransientiunmetered0huserdata0husernamehwifionlyjalbumtrackjartistitemjatomicbooljauthresultjcacheditem0jcastactivejcommandargjcomparablejconnectionjencryptionjendoftrackjepisoderowjequivalent0jfilesystemjhttpclient0jhttpconfig0ilevelfilejmigrationsjmarkplayed000jmpvbackend0jmutexguardjpartialordjqueryparamjrepeatmode0jrwlocksafejserializer0jserverinfo0jskipactionjsmartcachejstreamkind0jstructured0jsyncconfigicancelledialbumname0000000000000iapphandle00iauthloginibatchsizeiborrowmut000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000ibytesusediclearitemicloneinto0000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000icontextidicreatedatidbservice00iendreasonjequivalent0000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000jexclusions0ifilecountigetconfigigetgenres000igetperson000igetstatusiimagetags0iimagetypeiisdefault0iisenablediisepisodeiislibraryiisplaying00iisrunning0iisshuffle0iisstoppediitemcount0iitemtypesilevelnameilibraryid00imastermacimaxheight00imediatype00inullvalue000000000000000000000000000000000iplaycount00iplayqueueiplaystateiquerymany000ireadqueuejrepository000irequestidisavetokeniserverurl0000isessionid0isetvolume000isizebytesislavemacsisortorder00istartedatistreamend0istreamurl0000isyncdrain0itargetdirjthumbnails0itotalsizeitrackplayiunpinitemiwritesafejappversion0kaddpositionkauthmanagerkcacheconfig00kchannelitemkcommanditemkdeserializekdrainreportieqgainmaxieqgainminkfunctionargkinvokeerrorkipcresponsekjraycontextkkeepplayingklevelfilterklivechannelkmedialoaded0kmediaserverkmediasource000kmediastreamkneedsreauth0knetworktypeknullbackend0kofflineitemkplayererror0kplayerstate00kpollinghintkpushfailurekqueuestatuskscopeobjectksearchscopeksessioninfoktauripluginktransactionktrebleboostkvolumelevelidbservicejprocessingjrepositoryjalbumcountjartistname0000jartworkurljauthheaderjauthlogoutjclearcachejclearqueuekconversions000kcredentials0jdeleteitemkdeserialize0000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000jdevicenamekdiagnostics000jdirectplayjdownloadid000000jdurationms0jformattimeigetitemidjgetsession0jhascontent0000jhttpclient0000jischeckingjisfavorite0000jitemscountjjellyfinid0jlimitbytesjmarkfailedjmarkplayed000jmarksyncedjmasternamejmaxbitratejmaxretries00jmpvbackendjparseleveljpartialcmpjpersontype0jplayernextjplayerplayjplayerseekjplayerstopjplaylistid0000jpositionms0krecoverablejrepeatmode00jretrycount00jsearchrankjsearchtermjseasonname00000jseasonrankjseekoffsetjseriesname00000jservername00jsetcontextjsetsessionjslavenamesjsleeptimer0jsmartcache0jstartindex00jstreamtype0jtargetlufsjtargetpathitickstoms0jtodatatype0000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000jtotalbytes0jtotalitemsjtrackcountktransaction0jvideocodec0jvideowidthjwithparamslcachedpersonldeserializerldownloadinfoldownloadtaskkexcludedidslfavoritesinklimageoptionsljellyfinitemljellyfinuserllmssyncgrouplmarkunplayed0lmatchqualitylnetworkerror0lnetworkstatelobservedtimelplaybackinfolplaybackmode0lplayerstatuslplaylistitemlqueuechanged0lqueuecontext00lqueuemanager0lreloadstream00000lreportersinklresumestream0kservicenamelsearchresultlstatechanged0lstoragestatslvideobackendkaccesstoken00000kactivecount00kalbumartist0000kartistitems00kauthmanagerkautoadvance0kbasesecondskcachesubdirkcompletedatlconnectivity0000kcontenttypekcontexttypekcurrenthintkcurrentitemkcurrentmodekcyclerepeat0kdefaulttruekdeletetokenkensurespacekfailedcountkfromcommand000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000kgetchannels000kgetsessionskgetupcomingkhasprevious00jindexisduekindexnumber0000kiscontainerkisreachable0kisretryable0kitemscached0kitemspruned0kjellytaulibklastcheckedklibrarynameklistoptionskmaxepisodeskmediaserver0kmergedmediakneedsreauthknetworktype0knextepisode0knormaliseidkpartialpathkpausedcountkplaycommandkplayerpausekqueuedcount0kreadpendinglrepositorieskresolvepathjsavetofileksearchquerykseriesprefs0ksessionseekkshouldretrykshufflemodekstorageinitkstoragetypekstoredlevelktotaltracksktrydownloadkvideoheightkvolumelevel0mactivesessionmadvancetonextmaudiosettingsmcachedlibrarymcastdiscoverymclonetouninitmcountdowntick0mdownloaderrormdownloadevent0mdownloadstatsmencryptedfilemexcludehiddenminternalstatemitemsresponselmigration001lmigration002lmigration003lmigration004lmigration005lmigration006lmigration007lmigration008lmigration009lmigration010lmigration011lmigration012lmigration013lmigration014lmigration015lmigration016lmigration017lmigration018lmigration019lmigration020lmigration021lmigration022lmigration023mnameddatatypempanichookinfomplaybackendedmplaybackstart0mplayerbackend0mplaylistentrympreloadresultmresumetrackermsearchoptionsmsubtitletrack0msyncprocessor0msyncqueueitemmvideosettingsmvolumechanged0lallowattemptlaudiobitratelcalcprogresslcurrentindex000lcurrentmediakdevicegetidkdevicesetidldisplaytitle0ldownloaditemldownloadrootlerrormessage0leventemitter0000lexecutebatch0lexecutequerylexpectedsizelfromjellyfin0lgetdownloadslgetfavorites000kgetfromfilekgetimageurl000kgetjsonfastlgetlibraries000kgetuserdatalhtml5playingkimagegeturllismonitoringllastdurationllastposition0kloadandplaylmarkdeferredlmarkfavorite000lmarkreportedlmatchqualitylmediasources0lmediastreams0lmergedvolumekmimefromextlnormalizeurllparallelracelplaybackmode000lplayertogglelplaylistname00lpremieredate000lprocessqueuelpushfavoritelremotevolumelreportedtimelresolvescopelresumeoffsetlruntimeticks000ksavetocachelsearchpeoplelseasonnumber0lseekabsolutelsetreachablelshouldreportlshuffleorderlstoragelimit0lstreamresumelthreadhandleltracksplayedktrytostring0000lupdateconfiglusingkeyring0lvideobitratelwithdurationnauthentication00ncontrolcommand0ndownloadresultndownloadworker0neventthrottler0nfunctionresultnhandofftoaudionindexpassguardnjellyfinclient0njellyfinconfignlivestreaminfonnewtrackloadednnowplayingitem0npositionupdate0msecretheadersnsecuritystatusnsessionchanged0nsleeptimermode00nthumbnailcache0ntypecollectionmaudiosettings00000mclassifyerrormconvertparamsmdeviceprofile0mdownloadalbummdownloadbytes0mdownloadvideomencryptionkeymepisodenumber0mfavoritesonlylgetcachesizelgetitemcountlgetjsoninnerlgetstreamurllisexcludedbylisinprogresslisitempinnedlisvideomediallastseektimellastsyncedatllivestreamidmmarkcompletedmmarkreachablemmaxconcurrent00lmaxsizebytesmnormalizedurlmofflinesearchmpausedownloadlplayerskiptompositionticks00000000mqualitypreset0mqueryoptional00lqueueforsynclreadlastsyncmredactheadersmreportoutcomemreportsuccesslrunindexpasslruntimeticks0msafecomponentmsavethumbnailmsessionpoller0lsetapphandle0lsetendreasonmsetrepositorylsetserverurlmspectabuildermstartdownloadmstartpositionmstoptranscodemsyncprocessorlsynctoservermthumbnailsavemtoggleshuffle0mverifysessionocredentialerrorocredentialstoreodatabaseservice0odatabasewrapperodiagnosticsinfoodownloadmanagerogetimagerequestogetitemsoptionsohtml5nativeseekolibrarysettingsolocalnativeseekomediarepositoryomergedmediaitemopendingfavoriteoplayitemrequestoplaybackcontext0oplaybackstopped0mretrydelaysmsorusqliteservice0orwlockreadguardnspecialsseasonosessionverifier0osessionsupdated0osleeptimerstate0osmartcachestatsothumbnailworker0otryreserveerrormaddtoplaylist000nallowsdownload0nauthinitializenavailableslotsnavailablespacembuildaffilterncanceldownloadmclonetouninit0000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000ncollectiontype0ncompletedcountmconfinetorootncreateplaylist000ncurrentepisode0ncurrentsessionndeletedownloadndeleteplaylist000ndownloadseasonndownloadseriesndownloadsourcenequalizerbandsmevictlruasyncnfavoritesscopemgetauthheadermgetcachedpathmgetdevicename0mgetjrayactors0nimagesemaphoremintomediaitemmisepisodeitemmishtml5activenistransferring0njellyfinclient0000mmedialocalurlmmediasourceid0mnameddatatype000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000nnotimplementednofficialrating000mparsequeuedopmpeekendreasonmplayonsessionmplayqueuefrommplaysessionid0000nplayerpreviousnplaylistcreatenplaylistdeletenplaylistrenamempostjsoninnernproductionyear000nrecordpositionnremainingcountnrenameplaylist000nresumedownloadmsavetokeyringnseriesprogress0msetaudiotrack0msetreportsinkmsetsleeptimernshoulddownloadnstopmonitoringmsyncclearusermtakeendreasonntoggleplaybacknunmarkfavorite000mupdatelocaldbmwithstarttimeA`albumstorageinfoA`autoplaydecision00A`autoplaysettings0A`backendiniterrorA`backgroundactionA`cacheditempersonA`credentialresultA`deserializeownedA`hybridrepository0A`jellyfinuserdataA`mediasessiontype00A`onlinerepository0A`playqueuecontext0A`playqueuerequestA`playbackprogressA`playbackreporter0A`playercontrollerA`publicsysteminfoA`rwlockwriteguardnsecretjsonkeysA`streamingqualityntickspersecond0A`tostringfalliblenvideoitemtypesA`webviewaudioload0njellyfinclientoactivedownloadsnalbumfilenamesnauthgetsessionnauthsetsessionnbuildlogpluginobytesdownloaded00ocommunityrating000oconnectionerrorocountdowncancelocredentialspathndefaulteqbandsndeletefromfilendrainsyncqueuendurationorlastodurationsecondsoenqueuedownloadoerasedserialize000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000nfirstfailureatnformattimelongogaplessplaybackngetalbumtracksngetfromkeyringngetlatestitems000ngetqueuestatusngetresumeitems000ngetsubtitleurl000nhtml5isplayingnistruncatedendnisusingkeyringnjellyfinitemid0nlastplayeddate0nlastreporttimeolibrariesfailed0nmapdownloadrowomarkunreachablenmirroruserdataonormalizevolume0nnowplayingitem0nopenlivestream000nplayergetqueuenplayerplayitemnplaylistitemid0npositionorlastnredactembyauthnresumestreamatnsecondstoticksosessionverifiernsetcurrentitemmsetenvifunsetnsetpollinghintosettransferringnstarteventloopostartmonitoringnstarttvsessionnstoragegetitemnstoragegetpathnstoragegetsizensyncgetpendingnsyncmarkfailedntickstosecondsntotalsizebytes0ntvsessionendedowithoutexcluded00AaaddtoqueuerequestAabackendnativeseekocatalogindexttlAacatalogindexeventAacatalogsyncresultAacatalogsyncstatusAadiagnosticsbundleAadownloaddiskusageAadownloadsresponseAahtml5reloadstreamoindexinprogressomaxsyncattemptsAameaningfulcontentAaofflinerepository0Aaplaytrackscontext0AaplaytracksrequestAaplaybackoperation00Aaplayerstatusevent00Aarepositorymanager0osearcheventnameosecretquerykeysoskipbacksecondsA`streamingqualityAasearchupdateeventAasleeptimerchanged0AasleeptimerexpiredAasmartcachewrapperAataurieventemitter0Aavideoseekresponse0Aavideoseekstrategy0AawaitingfornetworkA`absolutepositionoaudiotrackindexA`autoplaysettings0A`backgroundactionA`backgroundpolicy0obelongstoseriesobuildauthheaderoconnecttoserverA`countdownseconds00ocreatemediaitemA`decrementepisodeodirectstreamurl0oeqfilterentriesA`equalizerenabledoexcludeditemids0A`excludedsnapshotogetplaybackinfo000ogetplayerstatusogetresumemovies000ogetsimilaritems000oisactivesessionolastinsertrowid0olmsunsyncplayer0omergedisplayingA`needstranscoding00A`observeddurationoofflinegetitemsoonplaybackendedopercenttovolumeoplaybackmodesetA`playbackreporter00A`playercontrolleroplayergetstatusoplayerplayqueueoplayerseekvideooplayersetvolumeoprimaryimagetag00000000000oracewithrefreshoredactserverurlA`registerdownloadA`remainingseconds00A`reportcompletionoreportstoppedatA`repositorycreateA`repositorysearchA`sanitizefilename0osearchcacheonlyosessionspollnowoseteventemitter00osetnetworkstateosleeptimerstateosortseriesorderostoragegetitemsostoragegetusersostoragesaveitemostoragesaveuserA`streamingquality0A`subtitleprofilesosyncfullcatalogA`thresholdreachedA`throttledurationotransfertolocalousehtml5elementovolumetopercentA`withconnectivityAbauthmanagerwrapperA`catalogindextickA`catalogitemtypesAbconnectivitystatusA`defaulttimeoutmsAbexclusioncandidateAbinvokeresponsebodyA`libraryholdsitemAblockscreenmetadataAbmediaserverwrapperA`normalizereflufsA`normalizerefpeakA`nosubtitlestreamAbnetworkstatehandleAbplaybackreportsinkAbplayereventemitter0AbplayerstatewrapperAbresumequeuedresultAbsharedeventemitterA`videoplaysessionA`calculatebackoffA`albumplayhistoryAaalreadydownloadedA`applyproberesultA`cachewithtimeoutAacalculateintervalAacalculateprogressA`candownloadasyncA`canstartdownloadA`cancelsleeptimerAacheckreachabilityAacrossfadedurationA`devicetotalbytesAadiagnosticsexportA`emithtml5controlA`emitqueuechangedA`getplaylistitems000A`includeitemtypes0A`installpanichookA`kindfromjellyfin0A`maxaudiochannelsA`moveplaylistitem000AanormalizeusernameAapartialcontainersAaplaybackreporting000A`playergetsessionA`playerplaytracksA`playertogglemuteA`playlistadditemsA`playlistgetitemsA`playlistmoveitemAapositionthrottler00A`postjsonresponseA`queuealbumtracksA`redactjsonvaluesA`reporthtml5stateAarepositorydestroyA`requestwithretryorowtocacheditem0osavetocacheimplA`searchserveronlyA`servedaudiocodec0A`sessionsetvolumeA`setaudiosettings000A`setmaxconcurrentA`setsubtitletrack0A`shouldcachealbumA`starttimerthreadA`storagegetpersonAasupportedcommandsA`testkeyringinnerA`totalrecordcount00A`transfertoremoteA`updateaudiotrackA`useswebviewaudioA`videoaudiocodecsAcaddtrackbyidrequestAcalbumaffinitystatusAcalbumrecommendationAcbackendreloadstreamAcbackgroundaudioplanAacatalogindexeventAcconnectivitymonitorAadefaultmaxretriesAcdownloaditemrequestAcjellyfinmediasourceAcjellyfinmediastreamAcmediasessionmanager0AcnetworkstatewrapperAcplaybackmodechanged0AcplaybackmodemanagerAcremotesessionstatusAcremotevolumecontrolAasubtitlequerykeysActhumbnailcachestatsAbapplicationversionAbauthreauthenticateAaauthverifysessionAabackdropimagetags00A`baseretrydelaymsAacachedalbumtracksAacatalogsyncstatusAaclaimstreamresumeAaclearreportedtimeAaclearwatchhistory000AaconfinequeuedpathAacurrentintervalmsAacurrentjellyfinidAadeletefromkeyringAadetectaudiosystemAadoerasedserialize000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000AadownloadwithretryAbenvironmentsummaryAbexponentialbackoffAaforcollectiontypeAaformattimesecondsA`getitemsbyperson000A`isaudioonlyvideo0AaisserverreachableA`lmsgetsyncgroups0AamoviesessionendedAaparentindexnumber000AaplaybackcontextidA`playeraddtoqueueAaplayercyclerepeatAaplayerreportstateAaprunestalecatalog0AapumpdownloadqueueAaranksearchresults0AaredactqueryparamsAaremotesendcommandAaremotesessionseekAarepositorygetitemAaresolveskipactionAasessiontogglemuteAasetjellyfinclientAastartaudiosessionAastartmoviesessionAastoragedeleteuserAastoragegetserversAastoragemarkplayedAastoragemarksyncedAastoragesavepersonAastoragesaveserverAastoragesetwatchedAasyncmarkcompletedAasyncqueuemutationAatemporaryttlhoursAathumbnailgetstatsAathumbnailsetlimitAbunregisterdownloadAbcandidatescanlimitAccredentialsfilenameAdconnectivityreporterAbdownloadeditemscteAddownloadmanagerstatsAddownloadvideorequestAbfallbackaudiocodecAdjellyfinplaylistitemAblibrarysettingskeyAdplaybackcapabilitiesAdplaybackstartrequestAbskipforwardsecondsAdsessionpollermanagerAdsessionpollerwrapperAdshownextepisodepopup000AdvideosettingswrapperAbwebviewaudiocodecsAbaudiotracklanguageAbchangedfavoriteidsAcconnectivitymonitorAbdeletealldownloadsAbdiagnosticsgetinfoAbextractjellyfinidsAbgetactivedownloadsAagetaudiostreamurl000AbgetcredentialspathAbgetdownloadeditems0AbgetitemsunfilteredAagetlivetvchannels000Aagetnextupepisodes000Aagetvideostreamurl0AaisplayingorpausedAblibrarygetsettingsAblibrarysetsettingsAbmarkdownloadfailedAbmergesearchresultsAbofflineisavailableAbpanicpayloadstringAbpickcurrentepisodeAbplayablemediatypesAbplaybackmarkplayedAbplaybackpositionmsAaplayermoveinqueueAbqueueprecachecount0Abremovefromplaylist000AbrepositorygetitemsAbresetautoplaycountAaresolveincachedirAbresumeaudiosessionAasavegenrestocacheAbsendgeneralcommandAbsendsessioncommandAbstoragesavelibraryAbstoragesearchitemsAbsupportsdirectplay0Acsupportstranscoding0AbsyncmarkprocessingAbsyncprocesspendingAbthumbnailgetcachedAbuniquetracksplayedAeaddtracksbyidsrequestAcdefaultremotevolumeAefavoriteschangedeventAefunctionresultvariantAblastcatalogsynckeyAcmaxprogressfractionAcminprogressfractionAeplayalbumtrackrequestAeplaylistcreatedresultAeplaylistitemsresponseAcstreamingqualitykeyAeseriesaudiopreferenceAestreamqualityresponse0ActickspermillisecondAethumbnailcachewrapperAcbackgroundaudiobaseAcbackgroundaudioplanAcclearstaledownloadsAdconnectivityreporterAccreateplayerbackendAcderiveencryptionkeyAcdiagnosticssetlevelAcenableremotecontrolAcexitbackgroundaudioAcfetchseriesepisodesAcgetdownloadsallowedAcgetrediscoveralbums000AbgetsmartcachestatsAblmscreatesyncgroup0AcloadcredentialsfileAbnotesleeptimerstopAcplaybackcontexttypeAcplaybackmodemanagerAcplaybackreportstartAbplayergeteqpresetsAcplayerrecoverstreamAcplayertoggleshuffleAcplaylistremoveitemsAcreclaimexpiredasyncAcreporthtml5positionAcreportplaybackstart0000AcrepositorygetgenresAcrepositorygetpersonAcsavecredentialsfileAcsetautoplaysettingsAbsetexcludeditemidsAcsetplaybackreporterAcsetstreamingqualityAcshouldprecachequeueAcspawncatalogindexerAcspawndownloadworkerAcspawnfavoritesdrainAcstoragedeleteserverAcstoragegetlibrariesAcsupportsnativevideoActhumbnailclearcacheActhumbnaildeleteitemAfcreateplaylistresponseAfcredentialstorewrapperAfdownloadmanagerwrapperAdincludecatalogbrowseAfnetworkstatewrapperargAdofflineconfirmwindowAfplaybackstoppedrequestAfsessionverifierwrapperAfthumbnaildownloaderrorAdalbumaffinityenabledAdaudioforcestranscodeAdaudiosessioninactiveAcauthconnecttoserverAdauthstopverificationAcautoplaymaxepisodesAcautoplaynextepisodeAdautoplayepisodecountAcbuildftsprefixqueryAcbuildnextupendpointAdcomputedownloadstatsAddeletealbumdownloadsAddeletedownloadsunderAdenterbackgroundaudioAdgenerateshuffleorderAcgetalbumplayhistoryAcgetsmartcacheconfigAcgetvideodownloadurl000AdincludecatalogbrowseAdnormalizefilterentryAdplaybackreporterinitAbplayeraddtrackbyidAdplayerdismisssessionAcplayergetsleeptimerAdplayerreportpositionAcplayersetaudiotrackAcplayersetsleeptimerAdpushremotelockscreenAdqueueprecacheenabledAcremoteplayonsessionAdreportnetworkfailureAcsetqueuewithcontextAdshouldshowminiplayerAcspawnsyncqueuedrainAdsupportsdirectstream0AdsupportsmediacontrolAdsynccleanupcompletedAcsyncgetpendingcountAdtestkeyringavailableAdwithcountdownclampedAdwithcrossfadeclampedAgconnectivitychangeeventAefallbackaudiochannelsAefavoriteschangedeventAgplaybackprogressrequestAgplaybackreporterwrapperAdretrycheckintervalmsAdalbumprimaryimagetagAeauthstartverificationAebackgroundaudioactiveAebackgroundaudiosourceAdcacheitemsfromserverAeconnectivitygetstatusAddownloaditemandstartAedrainpendingfavoritesAeemitserverreconnectedAeenqueuevideodownloadsAdgetdownloaddiskusage0Adlmsdissolvesyncgroup0AemarkdownloadcompletedAdonvideoplaybackendedAeplaybackpositionticks0AeplaybackreportstoppedAeplayerdisablejellyfinAdplayergetcacheconfigAeplayergetcapabilitiesAdplayerlocalmediapathAdplayerplayalbumtrackAeplayerpreloadupcomingAdplayersetcacheconfigAereportplaybackstopped0000AerepositorygetchannelsAeresolvecurrentepisodeAeresumequeueddownloadsAdsavelibrariestocacheAdsetshowservercatalogAdstoragegetactiveuserAdstoragegetitempeopleAdstoragesetactiveuserAestoragetogglefavoriteAdsubtitleforcesburninAesupportsremotecontrolAdtransfertolocalinnerAdtvsessionnextepisodeAhaudiotrackswitchresponse0AfimagedownloadsemaphoreAeresumebackoffstepsecsAfresumethresholdsecondsAhrepositorymanagerwrapper0AesyncqueuechangedeventAhsessionverificationevent0AfverificationintervalmsAeadoptvideoplaysessionAfalbumaffinitythresholdAebeginvideoplaysessionAfbuildfavoritesendpointAebuildgetitemsendpointAecheckforlocaldownloadAeclampmaxaudiochannelsAeconverttickstosecondsAecurrentisaudioepisodeAeemitbackendinitfailedAfemitconnectivitychangeAeemitsleeptimerchangedAfenqueueplaybackstoppedAeformattimesecondslongAfgetdownloadedlibraries0AegetfavoritescacheonlyAfincrementautoplaycountAemayberunscheduledpassAfpersistlibrarysettingsAeplayselectiononremoteAfplaybackreportprogressAdplayeraddtracksbyidsAfplayerbackgroundactionAeplayeronplaybackendedAeplayerplaynextepisodeAeplayerremovefromqueueAepostjsonresponseinnerAfrecoverableerrorresumeAfreportplaybackprogress0000AfrepositorygetfavoritesAerepositorygetimageurlAfrepositorygetlibrariesAfrepositorymarkfavoriteAeresolvelocalmediapathAfrestorelibrarysettingsAfsetconnectivitymonitorAeshouldshowvideoplayerAfstartautoplaycountdownAestoragegetaccesstokenAestoragesaveitempeopleAetransfertoremoteinnerAetvsessionepisodeendedAfupdateremainingsecondsAewebviewcandecodeaudioAfwithvalidatedcountdownAfcatalogindexfirstcheckAgexternalsubtitleformatsAiremotedisconnectrequestedAfaudiotrackdisplaytitleAecacheditemtomediaitemAgcancelautoplaycountdownAgconnectivitycheckserverAfconvertpercenttovolumeAfgetalbumaffinitystatusAggetalbumrecommendationsAfgetfavoritesserveronlyAfgetrecentlyplayedaudio000AgpersiststreamingqualityAfplaybackmodegetcurrentAgplaybackreporterdestroyAfplayercancelsleeptimerAgplayerconfigurejellyfinAfplayergetaudiosettingsAfplayergetvideosettingsAfplayersetaudiosettingsAfplayersetstreamqualityAfplayersetsubtitletrackAfplayersetvideosettingsAfplayerswitchaudiotrackAfremotesessionsetvolumeAfreporthtml5medialoadedAgreportprogressthrottledAfrepositoryjrayactorsatAgrestorestreamingqualityAfsessionssetpollinghintAfsetbackgroundaudiobaseAgsetconnectivityreporterAfstreamkindfromjellyfin0AfupdatecurrentstreamurlAfupdatesmartcacheconfigAgwithequalizernormalisedAjauthenticatebynameresponseAjconnectivitymonitorwrapperAjmediasessionmanagerwrapperAjplaybackmodemanagerwrapperAgaudiosettingsjnipayloadAhcompletionreportpositionAgdefinitionnameddatatype000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000AggetdownloadmanagerstatsAggetdownloadstoragestatsAgisbackgroundaudioactiveAgparentbackdropimagetags00AgplayerreportmedialoadedAgremotesessiontogglemuteAhrepositoryunmarkfavoriteAgresolvevideodownloadurlAgsendremotevolumecommandAgsetincludecatalogbrowseAgstoragegetactivesessionAhsupportsexternaldeliveryAgtakebackgroundaudiobaseAhupdatelockscreenmetadataAkdownloaditemandstartrequestAhmaxstalledresumeattemptsAkplatformremotevolumecontrolAhautoplaycountdownsecondsAhbuildlatestitemsendpointAiconnectivitymarkreachableAhconnectivitysetserverurlAgfetchnextepisodeforitemAhrefreshqueuelocalsourcesAhrepositorygetlatestitemsAhrepositorygetresumeitemsAhrepositorygetsubtitleurlAhrepositoryopenlivestreamAhstoragegetsecuritystatusAimaxsupportedaudiochannelsAiresumeprogressepsilonsecsAhautoadvancetonextepisodeAjconnectivitystopmonitoringAilogavailablevaapidecodersAiplayerexitbackgroundaudioAiplayergetautoplaysettingsAiplayerretryrestartsstreamAiplayersetautoplaysettingsAirepositorygetplaybackinfoAirepositorygetresumemoviesAirepositorygetsimilaritemsAhsaveplaylistitemstocacheAisetmaxconcurrentdownloadsAkconnectivitymarkunreachableAkconnectivitystartmonitoringAjdeterminevideoseekstrategy0AigettotaldownloadsizeasyncAjplaybackmodeistransferringAjplayerenterbackgroundaudioAjresolvependingdownloadurlsAjstoragegetplaybackprogressAkplaybackmodesettransferringAkplaybacksubtitlestreamindexAkplayergetstreamingqualitiesAkrepositoryclearwatchhistoryAjrepositorygetitemsbypersonAkrepositorygetseriesepisodesAksetlockscreenpositionoffsetAjstoragegetpendingsynccountAkwithoutserverchosensubtitleAltruncatedstreamtolerancesecsAkplaybackmodegetremotestatusAkplaybackmodetransfertolocalAkrepositorygetaudiostreamurlAlrepositorygetdownloadeditemsAkrepositorygetlivetvchannelsAkrepositorygetnextupepisodesAkrepositorygetvideostreamurlAlstorageupdateplaybackcontextAmlibrarygetexclusioncandidatesAlplaybackmodetransfertoremoteAmplayercancelautoplaycountdownAmrepositorygetrediscoveralbumsAmrepositoryreportplaybackstartAmrequeuemistypedvideodownloadsAmstorageupdateplaybackprogressAmtruncatedstreamresumepositionAmrepositorygetvideodownloadurlAmstartpositionticksfromsecondsAmadvancetonextepisodeaudioonlyAnrepositorygetdownloaddiskusageAorepositoryreportplaybackstoppedAmgetaudioonlystreamurlforvideo0000B`repositorygetdownloadedlibrariesB`repositoryreportplaybackprogressAostoragegetseriesaudiopreferenceB`subtitlesupportsexternaldeliveryB`downloadsallowedoncurrentnetworkB`enablelinuxhardwarevideodecodingB`repositorygetrecentlyplayedaudioB`storagesaveseriesaudiopreferenceAobuildaudioonlystreamurlforvideoBarepositorygetseriescurrentepisodeBgrepositorygetaudioonlystreamurlforvideo") \ No newline at end of file diff --git a/api/search.index/path/26ae0cad8c.js b/api/search.index/path/26ae0cad8c.js new file mode 100644 index 00000000..e6d47975 --- /dev/null +++ b/api/search.index/path/26ae0cad8c.js @@ -0,0 +1 @@ +rd_("f[1,\"\"]0Al[10,\"core::ops\",\"core::ops\"]Eg[5,\"tower_http::follow_redirect::policy::or\",\"tower_http::follow_redirect::policy::or\"]22Ei[5,\"tower_http::follow_redirect::policy::and\",\"tower_http::follow_redirect::policy::and\"]A`[10,\"core::any\"]Ao[5,\"alloc::sync\",\"alloc::sync\"]Ba[5,\"alloc::boxed\",\"alloc::boxed\"]A`[10,\"core::cmp\"]Ak[5,\"core::pin\",\"core::pin\"]Bc[5,\"rusqlite::row\",\"rusqlite::row\"]Bo[5,\"chrono::offset::utc\",\"chrono::offset::utc\"]Am[5,\"alloc::vec\",\"alloc::vec\"]Ac[8,\"tauri\",\"tauri\"]<<<Ce[5,\"tokio::sync::semaphore\",\"tokio::sync::semaphore\"]Af[10,\"serde_core::ser\"]Ah[10,\"erased_serde::ser\"]Al[2,\"jellytau_lib::commands\"]f[1,\"\"]Ab[2,\"jellytau_lib\"]Di[5,\"jellytau_lib::commands::download\",\"jellytau_lib::commands::download\"]Dg[5,\"jellytau_lib::repository::types\",\"jellytau_lib::repository::types\"]Bm[8,\"core::sync::atomic\",\"core::sync::atomic\"]Bm[5,\"jellytau_lib::auth\",\"jellytau_lib::auth\"]Dg[5,\"jellytau_lib::commands::storage\",\"jellytau_lib::commands::storage\"]Dk[5,\"jellytau_lib::repository::offline\",\"jellytau_lib::repository::offline\"]Aj[10,\"tauri::ipc::command\"]Aa[10,\"equivalent\"]Ai[5,\"rusqlite\",\"rusqlite\"]A`[10,\"hashbrown\"]2Do[5,\"jellytau_lib::jellyfin::http_client\",\"jellytau_lib::jellyfin::http_client\"]0Cc[15,\"jellytau_lib::commands::sync_drain::QueuedOp\"]Dd[15,\"jellytau_lib::playback_reporting::reporter::PlaybackOperation\"]Dk[5,\"jellytau_lib::player::mpv_backend\",\"jellytau_lib::player::mpv_backend\"]Ci[5,\"std::sync::poison::mutex\",\"std::sync::poison::mutex\"]A`[10,\"core::cmp\"]Dk[6,\"jellytau_lib::storage::db_service\",\"jellytau_lib::storage::db_service\"]Co[6,\"jellytau_lib::player::queue\",\"jellytau_lib::player::queue\"]Cl[10,\"jellytau_lib::utils::lock\",\"jellytau_lib::utils::lock\"]Bh[10,\"serde_core::ser\",\"serde_core::ser\"]Bl[10,\"erased_serde::ser\",\"erased_serde::ser\"]Bm[5,\"jellytau_lib::auth\",\"jellytau_lib::auth\"]Dg[5,\"jellytau_lib::commands::storage\",\"jellytau_lib::commands::storage\"]Cm[6,\"jellytau_lib::player::seek\",\"jellytau_lib::player::seek\"]Dc[5,\"jellytau_lib::download::cache\",\"jellytau_lib::download::cache\"]Co[6,\"jellytau_lib::domain::media\",\"jellytau_lib::domain::media\"]Cb[15,\"jellytau_lib::repository::online::ImageTags\"]Fi[5,\"jellytau_lib::playback_reporting::sync_processor\",\"jellytau_lib::playback_reporting::sync_processor\"]Ak[2,\"jellytau_lib::storage\"]An[2,\"jellytau_lib::repository\"]Ab[2,\"jellytau_lib\"]Al[2,\"jellytau_lib::commands\"]Aj[2,\"jellytau_lib::player\"]1Be[2,\"jellytau_lib::commands::storage\"]Co[6,\"jellytau_lib::player::queue\",\"jellytau_lib::player::queue\"]=:De[5,\"jellytau_lib::thumbnail::cache\",\"jellytau_lib::thumbnail::cache\"]Bo[5,\"tauri::ipc::command\",\"tauri::ipc::command\"]Ae[10,\"serde_core::de\"]Dm[5,\"jellytau_lib::commands::sync_drain\",\"jellytau_lib::commands::sync_drain\"]Al[10,\"specta::function::arg\"]Am[5,\"tauri::ipc\",\"tauri::ipc\"]Aa[10,\"tauri::ipc\"]Di[5,\"jellytau_lib::repository::online\",\"jellytau_lib::repository::online\"]o[6,\"log\",\"log\"]Cf[15,\"jellytau_lib::player::events::PlayerStatusEvent\"]Cm[5,\"jellytau_lib::media_server\",\"jellytau_lib::media_server\"]Ba[2,\"jellytau_lib::player::media\"]Co[6,\"jellytau_lib::player::media\",\"jellytau_lib::player::media\"]Dg[5,\"jellytau_lib::repository::types\",\"jellytau_lib::repository::types\"]0De[15,\"jellytau_lib::auth::session_verifier::SessionVerificationEvent\"]Dg[6,\"jellytau_lib::download::network\",\"jellytau_lib::download::network\"]Dc[5,\"jellytau_lib::player::backend\",\"jellytau_lib::player::backend\"]Dg[5,\"jellytau_lib::commands::offline\",\"jellytau_lib::commands::offline\"]1Ba[2,\"jellytau_lib::player::state\"]Co[6,\"jellytau_lib::player::state\",\"jellytau_lib::player::state\"]Da[6,\"jellytau_lib::session_poller\",\"jellytau_lib::session_poller\"]Dm[5,\"jellytau_lib::commands::sync_drain\",\"jellytau_lib::commands::sync_drain\"]De[5,\"jellytau_lib::commands::player\",\"jellytau_lib::commands::player\"]Al[10,\"tauri::ipc::authority\"]Dg[6,\"jellytau_lib::repository::types\",\"jellytau_lib::repository::types\"]De[5,\"jellytau_lib::jellyfin::client\",\"jellytau_lib::jellyfin::client\"]Bc[5,\"tauri::plugin\",\"tauri::plugin\"]Dk[5,\"jellytau_lib::storage::db_service\",\"jellytau_lib::storage::db_service\"]Ce[6,\"jellytau_lib::settings\",\"jellytau_lib::settings\"]Al[2,\"jellytau_lib::commands\"]Ai[2,\"jellytau_lib::utils\"]Ab[2,\"jellytau_lib\"]21Al[2,\"jellytau_lib::jellyfin\"]Aj[2,\"jellytau_lib::player\"]Aj[2,\"jellytau_lib::domain\"]1Bf[2,\"jellytau_lib::commands::download\"]Eg[5,\"jellytau_lib::commands::storage::people\",\"jellytau_lib::commands::storage::people\"]Bf[10,\"serde_core::de\",\"serde_core::de\"]Ce[5,\"jellytau_lib::download\",\"jellytau_lib::download\"]0Dl[10,\"jellytau_lib::commands::favorites\",\"jellytau_lib::commands::favorites\"]Dg[5,\"jellytau_lib::repository::types\",\"jellytau_lib::repository::types\"]Di[5,\"jellytau_lib::repository::online\",\"jellytau_lib::repository::online\"]Bm[5,\"jellytau_lib::auth\",\"jellytau_lib::auth\"]De[5,\"jellytau_lib::jellyfin::client\",\"jellytau_lib::jellyfin::client\"]Cc[15,\"jellytau_lib::commands::sync_drain::QueuedOp\"]Dk[6,\"jellytau_lib::domain::search_rank\",\"jellytau_lib::domain::search_rank\"]De[15,\"jellytau_lib::auth::session_verifier::SessionVerificationEvent\"]Dg[5,\"jellytau_lib::download::network\",\"jellytau_lib::download::network\"]Di[5,\"jellytau_lib::player::stream_end\",\"jellytau_lib::player::stream_end\"]8Ba[2,\"jellytau_lib::playback_mode\"]Co[6,\"jellytau_lib::playback_mode\",\"jellytau_lib::playback_mode\"]De[5,\"jellytau_lib::commands::player\",\"jellytau_lib::commands::player\"]Dc[5,\"jellytau_lib::storage::models\",\"jellytau_lib::storage::models\"]Cf[15,\"jellytau_lib::player::events::PlayerStatusEvent\"]Ba[2,\"jellytau_lib::player::media\"]Co[6,\"jellytau_lib::player::media\",\"jellytau_lib::player::media\"]Co[5,\"jellytau_lib::player::queue\",\"jellytau_lib::player::queue\"]Ch[15,\"jellytau_lib::commands::player::VideoSeekResponse\"]Co[15,\"jellytau_lib::commands::player::AudioTrackSwitchResponse\"]Cl[15,\"jellytau_lib::commands::player::StreamQualityResponse\"]Ca[5,\"jellytau_lib::player\",\"jellytau_lib::player\"]Cg[15,\"jellytau_lib::player::autoplay::AutoplayDecision\"]Dg[5,\"jellytau_lib::repository::types\",\"jellytau_lib::repository::types\"]9Di[5,\"jellytau_lib::commands::download\",\"jellytau_lib::commands::download\"]De[6,\"jellytau_lib::commands::player\",\"jellytau_lib::commands::player\"]Ab[2,\"jellytau_lib\"]Al[2,\"jellytau_lib::commands\"]f[3,\"\"]2Be[2,\"jellytau_lib::commands::storage\"]Dg[5,\"jellytau_lib::commands::storage\",\"jellytau_lib::commands::storage\"]Ce[5,\"jellytau_lib::settings\",\"jellytau_lib::settings\"]1Ab[10,\"core::clone\"]Cf[15,\"jellytau_lib::player::events::PlayerStatusEvent\"]De[6,\"jellytau_lib::download::worker\",\"jellytau_lib::download::worker\"]Bd[2,\"jellytau_lib::download::events\"]De[6,\"jellytau_lib::download::events\",\"jellytau_lib::download::events\"]Dc[5,\"jellytau_lib::jellyfin::types\",\"jellytau_lib::jellyfin::types\"]Da[5,\"jellytau_lib::session_poller\",\"jellytau_lib::session_poller\"]Di[5,\"jellytau_lib::commands::sessions\",\"jellytau_lib::commands::sessions\"]8Cg[15,\"jellytau_lib::player::autoplay::AutoplayDecision\"]De[5,\"jellytau_lib::commands::player\",\"jellytau_lib::commands::player\"]?Dm[5,\"jellytau_lib::commands::repository\",\"jellytau_lib::commands::repository\"]Cm[6,\"specta::datatype::function\",\"specta::datatype::function\"]2Dg[5,\"jellytau_lib::repository::types\",\"jellytau_lib::repository::types\"]?Fc[5,\"jellytau_lib::commands::storage::series_prefs\",\"jellytau_lib::commands::storage::series_prefs\"]Bd[2,\"jellytau_lib::commands::player\"]De[6,\"jellytau_lib::commands::player\",\"jellytau_lib::commands::player\"]Dg[5,\"jellytau_lib::commands::storage\",\"jellytau_lib::commands::storage\"]Di[5,\"jellytau_lib::repository::online\",\"jellytau_lib::repository::online\"]1Di[5,\"jellytau_lib::commands::download\",\"jellytau_lib::commands::download\"]0=Da[5,\"jellytau_lib::commands::auth\",\"jellytau_lib::commands::auth\"]Dg[6,\"jellytau_lib::thumbnail::worker\",\"jellytau_lib::thumbnail::worker\"]Cm[5,\"jellytau_lib::connectivity\",\"jellytau_lib::connectivity\"]Dc[5,\"jellytau_lib::jellyfin::types\",\"jellytau_lib::jellyfin::types\"]Em[5,\"jellytau_lib::commands::playback_reporting\",\"jellytau_lib::commands::playback_reporting\"]98=Bj[2,\"jellytau_lib::auth::session_verifier\"]Ea[6,\"jellytau_lib::auth::session_verifier\",\"jellytau_lib::auth::session_verifier\"]Bm[5,\"jellytau_lib::auth\",\"jellytau_lib::auth\"]Ea[5,\"jellytau_lib::commands::connectivity\",\"jellytau_lib::commands::connectivity\"]De[5,\"jellytau_lib::commands::player\",\"jellytau_lib::commands::player\"]Ec[5,\"jellytau_lib::commands::playback_mode\",\"jellytau_lib::commands::playback_mode\"];Co[5,\"jellytau_lib::playback_mode\",\"jellytau_lib::playback_mode\"]") \ No newline at end of file diff --git a/api/search.index/root.js b/api/search.index/root.js new file mode 100644 index 00000000..eeebac85 --- /dev/null +++ b/api/search.index/root.js @@ -0,0 +1 @@ +rr_('{"normalizedName":{"I":"IcAAAFQWXhZBwAAAJgENB1MWXRYlAcAAABH7CTJSFlwWNQHAAABAHQLJAmsDBgc0aQNRFlsWccAAAB0CyQJrAwYHDAdPFlkWNQHAAAAwHALIAmoDMk4WWBZiFnX8wA8AAAAwFgj7CV8WEGAWEGEWEGIWHALIAmoDCwdNFlcWYRZF/MD/AwAAIBUIVRYQVhYQVxYQWBYQWRYQWhYgaQNbFhBcFhBdFhBeFgoHTBZWFmAWscIAAJAKBQLAAACqlRoZ9RZvcjUBwAAAGmgccgwcJxxdHaHHAAB7F/sCZGVBygAA9hb3FpoZmxkBQTswAAABAADcAAIAlg4AAHUQ2wArlMcAABw1pcLDAGAAABhrFxNVEBntGDQIEMNhZmUAALISEoJ0ZQEBwwAAGO1zZxKAAoGgAAABPCM3BNjxaXkxwwAA0RIfFiAWFYHGAAASDRy8GiMEAsQAABpaaHfyShUAAPIEocIAAEgV8gFyAAFygvEJAADi6hYAAHQXAAACHAAAEQDSAIL9IAAOxQAAHDPBAAAS7QDjAQoAAI4KAADlGQAArx0AAEEAEMVzdGFydAAAwg76gQIAZGulAcAAADEEDwUPORV0ChoFAsEAABnIHKdxHGFyBQLBAAAUKxoilBpFG2Z0ENFhbGlkYXRlZGNvdW50ZG93bgAArRwQyHRhcnR0aW1lAADRGBDFYXJhbXMAADoVENNlcnZlcmNob3NlbnN1YnRpdGxlAACPHbDHeGNsdWRlZAAAuRnygnV0AgBlcxDScXVhbGl6ZXJub3JtYWxpc2VkAADVHBDHdXJhdGlvbgAA4xYQzm9zc2ZhZGVjbGFtcGVkAAA+HBDNbnRkb3duY2xhbXBlZAAAPRwQyGVjdGl2aXR5AABMGhKAAQHCAAAcNG508oACAG518oACAG9y9g+A/gAHMAkCxQAAGmZydPKAAQFoYxSIwWkAABNubHkzEI0DcoACAsIAABwOZnRkbhWIwAAAH68d4RsQzWFuZGVjb2RlYXVkaW8AAKwcoMNvYWQAAPQYEMVvZGVjcwAAihvyBHVkaW8CAGNsAAGtGgAA8oR2aWV3AgBhY/KAAQFiZBUCwwAAFYobo/QYY2ytGgUCwQAAEXAbVs8a0BrRGtIaih1laBsCwwAAFnZjdAUCwAAAF4IbHbAdZGUQznRpbmdmb3JuZXR3b3JrAADlGfKAhANydHUAAP0RQQH+AAEuD10n5F9+JUydNc5y4HSxfALWO4gNcOdaPLLMAAAcrGOMgBGFBFxwsddrfstkr3sYy0EHPtEtuQlQWPLLFnF1etNLyQAAHKUAOzAAAAEAAAsBDQA5AQAAzQIfAPIGAAAICAAAQAgBAEsI2wAeDAQAJwwAALoOAABAEAAAlRkAADgaAAAOHAAAocIAAEAIBQLAAAATuBkaqhxucBMFAskAABxwZW4BrxkAAPsCZWhRxwAAmhubG5wbnRuVHTHGAABOEE8QCxQAQmlwOzAAAAEAANwAAgCMAwAAEQbbAAABgoJP4EzzsgUCwQAAFMESG1MaZXIQyG9wZXJjZW50AABLGjDEZXZlbAAA4xI6FjsWBQLAAAAUXB0kwRpkHW1uoMVhbmdlZAAAdBbygAGIaPIDdW1lwAKBAIjpCQAA6ZkSAACzFgAAxRYAAJgZAAAVGgAAHhoAAJsaAADIGwAAtRwAAMkcAAAwAQAAABDCYWwAACUH8oACAmNsa3IJAR45f61leWHCAAA+DD8MBBYFFgYWsBgFAsYAACBOHJ4dYiMcJBwlHCYcWx2hHXN1K4rFAAAdsBDEaWR0aAAAORUxxwAAohujG5cdEMZyYXBwZXIAAIkb8gV0aW5ncwEAdwFzFgAA0sMcAAAEAEd0cmF0ZWd5guMZAACCgB0AAKDHZXNwb25zZQAA4RnygWsCAHJz8oACAGV08oCCAXQARmVzc2lvbgFhGgAA0nwcAAACEoNsYXkBAscAABxUwAAAHKVzYmUQyHRlbXR5cGVzAADzGBDFZWlnaHQAADkWoMRvZGVjAAA3FQSFwAAAFWNrZW5kZhUVdHJhdGXiFhDKdWRpb2NvZGVjcwAArhr2HwJlb/2HgRLABCLDAAAXHcUAABt8giMHAADmERcAAB0aAABOGwAABhwAAKYdAACzHQAAtR0AAAAQQAAWBYAB/TRABRYj3RBLO9155PnEAAAZqswAABtGZBUBxAAAGlsdZAYcMcMAAEIaaRxrHFHIAACaG5sbnBudG5UdBQLBAAAQkRIUwBhueRWExAAAE5cazRZbwIoAwgAAFi1rA8YAABdCaG92xQHKAAAXfB1zfBoJAs8AAB2FwgAAG25ucmsDwQAAFixhZWwFAsQAABlnHBC9G2l5BQLCAAAQSBASxRJldDMA/SBhAMIAABJVywAAHKL3yAcAABsLKQEqAgQAAQCiBTUCwgAAFagcEPEbcHMKD0EaixoFAsAAABe4GxSvG2Nx+wJudAUCwwAAx7IbFJYccHEFAsAAABvcGxN2F25yNf3ACUAEAACpmRoXEhon9xiRGxNLFc0LHQxLDDHAAABAFl8drh3RwwAAHgwBQuOSEgAASRcAACwaAABoHQAAQAAFg8AAABQLFx21HBsCzAAAHINwdAUCwQAAFlAbGE0cYWMhwwAAmBq4HLHDAAAyFAUCwAAAFWgcHI8dYWhRxAAA4hvjG+Qb5RucHRWCwgAAGpgcGxT7AmJsAAJvbgAB4wAAEvJzg6ILAADhsxIAAMQaAAAQEgCEAcMAABvvdAABaxsAAKHCAABYEABHc2Vzc2lvbgFTFwAAAcYaAAA1AcAAABYQHHdtF24XqBkwwAAA2AvZC5MO8oABAWRyocQAAHkcEMludGVydmFsbXMAAHsc8gVhdGlvboSCANIWHAAAMPKAAgBjZfKAAgBpeVKAAQLuAAAQD2Zkbva/AAL+QAGYXsUAABpWaXMA6TsIAADJEgAAGxYAAE8XAADvGQAAbxsAAAQcAAAVHAAASRwAALQcAAAgEAAAALHDAABtFSIAAP2AQAnEAAASxswAABv7ywAAHGUBOwEAAAAAAAL+AF4uDwBQdgHSNkzlGX9r5TAjQHGhfoAERsVcH7XLAAAbum8M2uhYWH9bAsrCAAASnl5mdt4hLWGjdQNDZ2NghHvW6N/JI6/4SdNcb+Xp3mNyAOkYCAAA0wkAAN0OAAAZDwAAOhAAAFAXAAAhGAAAFBwAAEccAABUHQAAYFAAAAAVgcYAABijHXId8wWCAT0WAADzAgF04fMJAAB5FQAAAQUCwAAAE34XE38XbHATBQLLAAAddHN20qkbAACTMcEAANQaRBx7HAFCOzAAAAEAAF0AAgBBEAEA2RFbAAABZQAB4AAAGpFzgyAHAAA7MAAAAQAAIgACALYOAABgEiEAsgCQA80AABytZWltAAFnGgAA0oAB/QEhAckAAB10wwAAG8ps8AD9EUGAwANAAAo6UFpbXF0A8gYAAADFAhWEwAAAIeAS+xj4BgWUwAAAxrUYo14QIcMAALUKPRdbwCKA4AAAB/2gwmxzAAAECAAAAIjBAAAXPAE6AQAAADYAAAL9kdAKwwAAG3IpG0I1alUNn8OjwAAAGlFrE59xasEAAAgi0gAAHY/BAAAIE2NpADswAAABAADjAAkAzAIAACIIAAC+CQAAtgoAAJYOAAD/DgAAdRDbAO8SAABmGgAAwcIAAAsIocsAAGkXBQLBAAAU2xIVxBZpdiMAA8AAAAnMzgAAHDBlZnIB9gUAAEHCAADDGU8cUBykHRUByAAAEJkdc1IaE4EC4QAAEjVjcwBIZ2tleXJpbmeC4BYAAAGNGQAAkoACAooAAAgD4AAABxFuemNvscMAANEZAQACwwAAFiXFAAAXKWNv4yMIAADoDgAAUREAAKQbAAA0ABDMd2Vidmlld2F1ZGlvAACtGhDCb3AAANcLGNsAANYL8gACAGt0AAFAGgAAAENhbWXhkA4AADIQAAAgAYAaAAAQwmZvAADVCyKAAgCsAAALlWRuAENhdGHh1AsAADAQAAAB86AWAAA/ArgA8gD9CCECAOGLAwAADgYAAAL2QxUAAIYDeQEoAf8AAgAQy3RtbDVlbGVtZW50AABKGvIAwCBgAmR2AOIXBwAA1wcAACcWAAADAPIAhgRhaHF0ADowAAABAAAAAAAYABAAAADjCeQJ5Qk+DD8MQRLcEk8VBBYFFgYW+xawGIIZwRkAGhsaUBqwGsAazBpKHLYcbx2RHRWEwAAAEhkPcxsBggF0uvux6nQFA8AAAKvfGRXgEif7Fm8dZXB0Nf3ACQICAAAReRmgXhAXwRwUjBUTCNsSMxwxwAAArxawFmodocEAACoY8wECaXPk4gkAAMoSAAAUGAAAuBoAAEgcAAADAAChwQAA5hMTBAPEAAAZCcUAABkKbW9z5q8LAAD3CwAApBYAAOMWAAASGgAASBoAAG0aAAANAAAAccQAAKYdpx2oHakdqh2zHbUd8gJvcgABdgGUCwAAABIAAQHgAAAdhWZzhWwDAAA6MAAAAQAAAAAAMwAQAAAAkAqRCpIK7AugDqEOkhKTEpQSlRKWEqUSphKnEqgSqRLtEpcWmBaZFpoWoha1FhkXJxdJFykYhxmIGYkZihn1GfYZLBqSG5MblBuVG6IboxsjHCQcJRwmHJ8c0xxbHWgdbR2THZcdoR32DYAB/RUhA8EAABxtbAUCwAAAFhscVp4bnxugG6Eblh1ucAUCwgAAIgMWXRwVOhxpcAUCxwAAFpgdF58dY3AQz21hcnRjYWNoZWNvbmZpZwAA1BwQz2VtYWluaW5nc2Vjb25kcwAAqxwEAsJvYwAAE2xkYtAYHnNjcmVlbm1ldGFkYXRhYR1hawSYwAAAFG5maWffFh5ycmVudHN0cmVhbXVybNMcEMl1ZGlvdHJhY2sAAKwa9gkDYXRl/QUIAwPhAAAXb8MAABnaZGVwAIL4FgAAdgAAAf0UiALAAAAH/gHg63ws4QAAG1FkAONEFQAAhRsAAOcbAABRHAAAAxEFA8AAABuOHBGqGagMGmNleaHDAABNEgUCwAAAoBQWq1wbZHMVAcUAABbKG3buFiODAsEAABLAYXIFAsUAAB2gHRyQHXJ0I4MCwQAAGYxlc/sCYXQhxgAAlhl+HRDNaXN0ZXJkb3dubG9hZAAAdhv6kAGBZ6HEAABFFRDFbml0ZW0AALES8oCEAWygxXRlcmVkAAAuEABKcmtmYXZvcml0ZYTMGAAAAVodAADygIMAEMNvd24AABkKEoABAcEAAA6ZbmwBQTswAAABAACAAAIAQRYAAIIXfwArAsUAABc+ZGnBxgAA/xjyAAABeQENBgAAABDOdWV0cmFja3NwbGF5ZWQAAMYb8oACAXF0bvZrAP0AlQH9PCAGBiBe7QPGAAAbmVDALU1NAAHtAgAAAAAJ/X8ZIIUAAANpgQAAA2qDAAADa6AAAAEmJdB0+9wCzvIj4D4iYe50SNwzu6J1eMN3WhpYhNKiWgsF2Gd6sGdpEkfF2rHORzQvChB2TOq4FCf5ntb5CVtxyahao3x6ElW2GK+N1AAAHbAxMzY4bnByc3QA8hcMAAC0AjXCwQJAAAAWcRoVghkaNBwfECAQoBkFAsEAABPXGRVhG2tw84ICcnVRywAAnhufG6AboRuWHRUCxAAAFjMcFDwaZnOvHfsCdHgBQuLoEgAAvBYAANYZAABCAPuDBYXGAAAYPBwUpxoFAsEAAFM5EjoSOxI8EvkbFYoabnQVAcAAABL/E3SwCiUBygAAE88cYt8bdR2xzAAAVR1TggPLAAAchWRlZ/sCYXkFAsAAABt/HVL7FlAasBq2HG8dcnQbicoAAB1zKwLJAAAdaGV0BQLAAAA1eRvaG9AcpdMbcHPBxAAA/xj7gzUBxQAAFtccd7UajRukHAUCwgAAGGcdGn4dcnX7iRWCwwAAFD8chhwTgYPHAAActKHEAAA0FwBAhP8HAAD2VBIAAJ8GXAEnAAEANQEAQWQ7MAAAAQAA3AACAEIIAAC4CtsAABDJb2xsZWN0aW9uAAADF3IAAwHNAAAdnmNpc2QBigMAADswAAABAAD+ARYAqQsAAKoOAACyDgEAtQ4AAPcOBwAGDwMARBIAAF0SAgDREgAADhQBAD8UAQBFFOsAgxUBAB8WAQA1FgAAYxYAAAIYAQAsGHUA4BgCAEgbAADrGwAA3Bx1APKAggFyNg4AAf1UFAfFAAAcEscAABxKwQAAG9HHAAAcfXAA7/kGAAC0BwAAnwoAAEAMAABHFQAAfhUAAK4WAADvGAAA2RkAAEMaAABMGgAAbBoAAP8bAAC9HAAAxRwAAM0cAAAAAgQAAQAAAHHEAACZEpoSmxKcEh4amxrJHAUCwAAAJsMcxxw3ohujG5cdZXRRygAAIxwkHCUcJhyhHVODwARIyQAAHHygwm93AABACBDKZXh0ZXBpc29kZQAAcBwEAsAAABNkZWS4GRppc29kZWVuZGVkqhxucPIFc3Npb24CAGVuAAGvGQAA8oACAGVoVAMAAf0VQQAAxAAAG8rXyQAAHEYFCHMBBgAAAAAxwAAAoA6hDqIWFQHAAACgXhBkEwj7AmVsQIABAooAAAf+xAAAFgNSbGNlMoABAkYgnaDHcHJzMcAAAFYKkxq/HKHCAAAKGmUCwAAApucWIusYGhpjbHAawRreGzkcbhxkHfvKAAEBAQHGAAAY8mblPggAANMLAADBDgAAJhAAANoWAACqGwAAEARAUcEAAAgUCRQKFPcZthr7AmVuBQLAAAAdRByzHxRob6HDAAAQFtDHb3N0cmluZwAA2hYQyHJ2ZWVycm9yAAB2FxKCZXOCAckAAB13dABDbnRvOzAAAAEAANwAAgAYCgAAtA3bAAAAQ3JvbTswAAABAADcAAIAFwoAANgM2wAABQLDAAARyhoSaBdtcxDGd25sb2FkAAA4FvKAiILyAP0oAQUCYWMA9kYMAADsCTgAKgSxAOMAocUAAF4QBALFdHJlYW0AAB1lc3VtZXBvc2l0aW9uoB0cb2xlcmFuY2VzZWNzkB1ydBKFY2F0ZWQBgsEAABmMczKAAQLgAAAVim5jZQUCwgAAVIMZhBmFGYYZbB1VARoCGgMaBBp6HWltKwLDAAAcCXB1JYLEAAARsByBG2IdBQLBAAATxRgTkhlzdBOBAsQAABdIcnMFAsEAABWRHRXIG3N2E4ECxQAAG/F0dlHNAADiG+Mb5BvlG5wdUdAAALkcuhy7HLwcsR0Qx2xlYm9vc3QAAOIS1h2AAf5AA2wBTyqj31vCAAAYs2JVAcEAAEHjGeQZgB2BHWcmCicKBxR3FuIWBYbCAAAgTRcTHFIPGhAaERrBG8IbEMNlbnQAAC0QUcMAACIYIxisGYMdhx0QxG5uZXIAAKkc8gVlbW90ZYQAAasaAAABmh0AABjBAABvHPIEb2NhbIQAAUkaAAABkh0AAPKBbwIAbHLygmVyAQF0cjDFY3Rpb24AAOESNRU2FfKBcwMBYWZpYwWJwAAAFosbFPUWFQHDAAAWrxpyKhyhzAAAcRyhxgAAyxkVAcQAABbHG3KSHABFbGF5ZWQB2RYAAAHGGwAAdgEAAcIDIMUAABnNcAD1NxYAAEUDCwEJADsAGPgAALASpAHAAACgKxBz1gwSgAEBwgAAGedkbhDEb3VudAAANBX2CQD9BIEC/RoIAcoAAByxwAAAFmzFAAAbzgEfBwAA6TQQAAA/EAAAbhYAAMMYAAChGgAArBoAAC4cAABcHAAAxhwAAMgcAAAAERAAABKAAQHKAAAbkGt08oACAWNudPIA/REASIwA8mYDAADkBCHFAAAVGrUcFQHEAAAWvBlyWRvxA4LIAT0WAADFA8AAACcfGLIcFTYQoEMUZWlzCwglhMMAABRvHEkakh0risgAAB2QRQLEAACklRoTlxpkac0WuhtnHHwdpQHFAAAcch10eRUbAsIAABbvZHUBRDswAAABAACAAAIAQRYAAIIXfwAigAECjQAAB/3DAAAXPGNudBDFcmFja3MAADcWFAHDaXplAACkeXRlc7YZYq8SsMplY29yZGNvdW50AACoGhDEdGVtcwAAMxUARHl0ZXOCMRUAAAFuGgAA9gEBbP0CAQcBzwAAHYJkAfwHAAAAEoCBhMIAABmnBQLAAAAS7RkyTxfvGRUcaXYhxAAAtRmDHPsCY3IQx2FsbGlibGUAAPIY8gRyaW5nAQBm4dMLAAAmEAAABIXaFgAA8oABgnQARHduZWQ7MAAAAQAAgAACABYKAABWDH8AAABAAfsHAADk2g4AAJESAABnFQAAixUAAKccAAAAUAAaqAEBwwAAGMBueTHDAACIGmYbWR0ARmh1ZmZsZYJRFwAAAfAbAAAQx2xheWJhY2sAAMsYsgNnbGUCAsYAABxscHNmbQABuxYAAABHYXRhdHlwZTswAAABAADrAAEARRTrAAAAAP1IRA7+QAnuCGHfmm01B/NcdO4hZNXrsnVjjXLMB9YjQUF+auApZx18+9kcZph9yHd1Al9sdcIAABYtUnnywygeuF3rzkZWKkRSScNEbhstrCZXKQGqrr0mc70MXKQAOzAAAAEAAD0CCQB6AwAA6ATbADkIAAAYCgAAoQoAAK4KAAC0DdsAUxF/ACoXAAAFhsAAABSLGhhpGyHHAACuGX8dAUHkDAwAAE8MAABTEAAAHBQAAIcVAAAAEgD7wQQEFQHDAAAVWB1sfRYVAckAABh9HWSgGhOBAsUAABq2Y3IQw2JpZAAAtwp2AYAB/RFIAm7fPal/4AAAGlFkockAADUa0cMAAOgSFQHDAACwdRVzNRAzAAPmAAAKG2luc+IGCAAARggAAHAVAAADQEsDygAAHITAAAAPlWFldAkBZXZFXI1uocEAAAQIMcEAAIAbWhyJHQFA5Q0PAADTEQAAkBIAABQUAAB+FgAAWBsAADAEEAkBJFxaUHlsCQE1JykkFW1RwgAAhxmIGYkZihltHVHEAABuFm8WoRqiGsYcFYTEAAAUiB1ZGhMAAtUAAB2udHWCIRAAAAUCwgAAGG0cE68cY22eAwUA/gAIYAfGAAAaRccAABrChPcHAAD10QsAAK8KAQAwBt4A8oABgmwFAsIAABZbF1KXFpgWmRaaFp8ccnVhxAAAyRbKFssWzBY/F0AXFQHGAAATiRxsSRuxwwAA/hYxxgAA2xncGYccBYnAAAAycRdyFz0aEeob8gAAAXSC5wkAAAC2AAAB/RQQBMUAABndxAAAGqRzAPc4FAAAAQCNBKUBwQEDAJEAAAJ1dAABwAAAGlFtAbYKAAABZhoAALYBAALAAMHCAAAZem9ygogDAAD6DQoAAKABWQDiB2MBewFYALMBwQA8ATKAggLHAAAYomFwxf3AUgECAAAVwhkVbxoWjxsTWRcX3RvaE7HCAADkFhUBwAAAHdYcYsUa84GGAEZlY29uZHMBtRkAAAGDHAAAqNUAAEMU+pkCAG1zDALZAAAaaWxsaXNlY29uZNUbpWVjb25k8BhtczIAAgHJAAAdonB0ZgDkyRYAACsXAAA/FwAApxkAAFUcAAA4EBDyAAEAcwDhQhYAAE4aAAAQ8oABAmthc/a3gMACgv5ABhqJFHmlbEhoHsA1lAmDzwAAHK3KAAAaxgXAwgJKAAAWcBoayhwX8xsUkhorAsQAABpmaW0FAsIAABPRGBtGHHR2G4nMAAAcFisCxwAAGPpldLHHAAC5GROCAtIAAB2PZXMlAcYAABZNGne2EngVBYzCAAARcxAS7xIFAsEAABd9FxCFFWl0McUAAO4SGBjuGbUBwAAAErQLbhQH8YWGFhchxAAA+hY7F7sE0AAAHNVhbnFyBQLDAAAUTBoXFRxpdBsCwgAAHDRudBsCzAAAHD1udRsCzQAAHD5vcqDFb3JrZXIAAHQXpIPAAAASdmVQFxZ0bGltaXR1G60SBALCZXQAABVhY2hlZMUbFHRhdHN0G2NzBIrAAAAYbGV0ZWl0ZW0IHBt3bmxvYWRlcnJvchEcEMhlYXJjYWNoZQAABxykAsNjaGUAABR0YXRzwxoWcmFwcGVy1htzdwEX8oACAGFs8gNhaWzCFkAAgiQQAAABQhcAAPICbWIBAG4BHgcAAABRwAAA7BbtFowajRqOGhDGcmF0aW9uAABIGvIAkAAAAcscAADyBHR0bGUBAWRyAVUMAAAAIcIAAJQaRRsQxmVhY2hlZAAARxoSA29sZAEBxQAAHHVycwEjEAAAAX0cAAASgAGQwQAAEDdoEMVhbmRsZQAA2BbyAAEAaAABpBoAABKAAZDJAAAaxWSygAICwwAAGithc2Z08oCKAAAAAv6AdzYLC3Nk4GdAE7pDUsEAAAoHDxQsE4nGAAAW4y9T+bjeyAAAGPllfNY5DBtDX+etZiLfDCk+k6UxmDDzrrs0xAAAFTpS7ca/xGlF1SoXHfXqrPBydQA6MAAAAQAAAAAAHQAQAAAAggOOA48DkAORA+wF7QXuBc4Ozw7QDtEO0g4EDwUPzxLQEkIUORUeFiQWLBYZGAUZpRmxGdMalhtbHKIcJYHGAAASDRxeGrwaBQLBAAAUXB0kwRpkHW1u8wQBY/LFFgAAAwUFAsIAALY5GhOtGWVs+4oFjMAAABKtCxOLFQUDwAAAFAYZFu4bE40WYnB1ZQLAAAARahYTgxVydOwT7RPuE+8T8BPIGSvBBIDFAAAXR8YAABmqFQHGAAAXYR1tMBwjggLCAAAY0GFrIcIAAPgSCBwFAsAAABKpHBVnGm50a8AEhMAAABuMBQLAAAAT7RhA4xnkGYAdgR11eQUCwwAAEwcZFtYaZmsFAsEAABLfFiPeG24cZnShxQAAYxU7/YBADMoAABrXzQAAHNMFAsAAABRiGqKLFmN0GwLLAAAc1mF5BQLAAAAbHRwZjhtibBsCxwAAGqxsdaHHAADnGwUCwAAAExcYGLgcY2UFAsAAABFjGxbJHGV0GwLDAAAawGV0E4QCyAAAHVlzdAWGwQAAEQQcomkXBQLBAAAWYhsUfxxjcPvAAGQFAsAAALj8GBMDHG9yIcoAAMkbQRwFAsAAABaMHBV3G2Nu+wJhaFWCxAAAF2Ydfhl/GYAZgRlrHQSFxmV5cmluZwAAGHZhaWxhYmxlPBwUbm5lcqcaUoABhcIAAB2RawAAAf2dWUpyBHhZa8EAABKyHU/xuAg2D1k2C3BAtetDyQAAHCDBAAAVQMsAABvqzQAAHNTCAAAXFWNHF7UYwgAAEVFd6JpW2nQA68wOAACdEgAA/xMAADEVAACSFgAADxcAACYXAAC2GQAAbhoAAEwcAACeHAAAmR0AABABMQEAAFHAAABUEvMYTxp2Gnca8wIBc4j3DgAAKwLIAAAcsm95IcYAAGMXtxrRwwAA8g4xxAAAUhJTEhcXFQHHAAAWYh1ySxwVAcAAADDpC8cZYx1zdhYFAsAAACNqHKgcE9oYb3IQzHJhcnl0dGxob3VycwAAcxuygIgDwwAAFxxlaXS2LAAB/RchB8QAABmlxgAAGfMVFNVz2WB1NIlccAA6MAAAAQAAAAAANwAQAAAAxAXFBcYFxwUHDAgMCQwKDCMMrQ6uDq8OAQ8KDxIPNRA4EDkQQxBSEbESuhK7EtQS+BJCFVAVhhVyFoIW9hb3FmEXHxggGJoZmxmhGakZsBnzGUEaVBp8Gn0afhp/GosalRqWGtcaZBt+GwgcshxpHQAAAv7Af+o+RjqiJmQQzbDnNW27XyyrRxEYzRMValWR8UOuDWEjwwAAGdpSv4aUf0+b1uexxAAAGlpQ32MFeCBVdbQZDaWYBUZXG0qALCV3xoF4XIevwKMRTI7xwsIAABtqCmOvJ9dhRXxafTnKdeKfbXMAOjAAAAEAAAAAAFQAEAAAAB0H4gfrB+wH7QfuB+8H8AfxB/IH8wf0B/UHLQguCC8IMAgyCTMJ0QnSCSYKJwpdCl4KXwqNCrULtgsFDLgOhhLXEtgS2RL3E/gT+RP6E/sTBxRKFUoWdxapFqoWqxasFr8WwBbBFsIWxBbRFuIW+Bb5FmIXcRdyF7EYshjMGM0YzhjPGJAZkRkzGjwaPRpSGogakhqrGsUaWxtmG3obbByQHKEcWR1aHZodoccAAEUXNQHCAAAQgRpzxQ7GDg0WS8AEBeAAAAvQ4AAACTQFAsAAACJVGmEdFYkaYWQVA8MAABiRGxZeHSNpHGscZHN1OhArAscAABynY3S1AsIAAKZWFxZYF3N3qgsbkcQAABdKMccAAPwbdh14HSUBxAAAE6YcZLcbjRzzhAJjcysCxQAAGBhobwGBiE9aSO6uBALBaQAAq3ZlbnRlbWl0dGVy3xkVbHVnaW7gEmVwsgABwASBwQAAFhZyAAEXDAAAHMDMBAkAABJpcqwSE3Vmc0EUE2F0aEIU5gkSgAEBcQyi/ohndBDIbmRyZWFzb24AAMoYENJhY2tncm91bmRhdWRpb2Jhc2UAAGAd+pYCAGJlAUDjqA4AAEISAADHGgAAVR0AABEi8gAAAXOCZwMAAOLbDgAAHxoAAEUcAAAbAPYvAP1ABAn9DokGfzCq4U3AAAAVPn5sj05WAPjUCwAAXAQBAG8GPwK4AL4ADAMAAP5AZJRM/qCbaxNu1pjZMjbFvZ4DfcelBn1wpWNO8H2lOGHWcSIn8OAxwMRkjGobdBpgSHohKkRV+aEHksAAAAcGBSXCOZIUr+fjCQ4rLxbKSNmUDjICKovSHksg4QunQRCCOLNdqCnnoBcKZCX9OkoB3TlXXs0sDVIGNwVDMTZ+swt9APnSJQA7MAAAAQAArgOrADEBAAAeAqkAzAIAAO4CAABzAwAAfgMAAAYEAQDpBQEA9AUBAA0GAAADBwAAGQcAABsHAQDJBwEA4AcBAOYHBAAGCAAACQgBACIIAAAxCAIARggDACgJBAC+CQAA0AkAANQJBgDmCQAA9gkAAPoJAAALCgEAKwoqAFwKAACZCgAAtgoAAK4LAACzCwEAvwsGANoLAAANDAIAJQwBAJUOAQC5DgAAwg4AANkOAAD/DgAAFw8AAB4QAAAtEAAANxAAAEEQAQBEEAEAdRDbANQRAADZEVsANxIAAEUSAQBSEgEAWhICAIISAgCiEgAAvRIAAMMSAADaEgAA3RIAAOISAADnEgAA7xIAAOwTCQD8EwAAEBQAAB8UAgArFAAANRQAADQVAABTFQIAYhUAAG0VBwB9FQAAhRUAAIgVAQCNFQAAJRYAACgWAQA0FgAAORYAAD0WAABBFgAARRYBAGYWAQBrFgAAdhYAAIMWAACbFgAAtBYAAMgWAADTFgAA1RYBAOkWAADuFgAA8RYBABcXAAAfFwAAIxcCADQXAQBBFwAAWxcAAGMXAgB3FwMAghd/AAQYBQAOGAMAJBgEALoYBADbGAAA5RgCAPYYAAAJGQAAeRkAAKQZAACmGQAAqhkBALwZAAC/GQEAyBkAAMsZAgDRGQIA2hkAAPsZAAAWGgAAMhoAAEoaAQBfGgAAZRoBAG8aAACgGgAAqBoCAK8aAAC0GgAAtxoAAFobAABnGwAAdRsAAHcbAAB8GwAAgRsAALAbBQC3GwAAxxsAAMkbAQDOGwEA7RsAAPQbBAAPHAAAGRwAABscAAApHAAANBwAADscAAA/HAAAQRwBAEscAAB4HAIAfxwBAI0cAAC+HAAAzBwAAM4cAABiHQAAZh0AAI0dAQCYHQAAnR0AAAUCwwAAGcgcp3EcYXIrA8kAABqtxAAAG4llaXKhxQAATRKhxgAAEBYxxQAASxbEGdAbMcQAAM0ZxxtCHAUCwAAApQ4WMs8S0BIeFmN13wH+QABEA8kAABxu+5KxwgAADQwFwMAAigAA4SwUFPsZGAYc+4MFAsEAABOPG6lRG2lzG5LCAAAW8KWCxgAAsfwYexehzgAAURwFAsMAAKO/G6U3HHBz+wJyc6HFAABFEnv9EEEJwgAAHAPAAAAWB1HFAABKG0sbTBtNG4sdFQHCAAAWxxtykhz7AmlwBQLAAAATwhkTbRxucsv9AUBI0gAAHVTCAAAOlRUBwAAAEBYIMAsHFQHAAAAQFQgwCgclhMMAABPsGEMQ1xolgcEAABEfFwUUbRsQx29zZXJ2ZXIAAE8XIcQAAMEZzBoQwm5rAADSC/KAhAF0EMd1dGF0aW9uAAByGxDDdGVtAAByFhDLaGFuZ2VkZXZlbnQAAHgccgR1ZXVlAwHDAAAcNmNpbWQAAQgZAAChxAAADBoEAsVvY2VzcwAAMXJwFnEWThcWZW5kaW5nxBtvcPKAAQFybATAw2FyawoIAAAYb21wbGV0ZWRxGxVhaWxlZLQZGXJvY2Vzc2luZ8MbVQHDAACgURtzRBXnG+gbURxSHBQByHRwZW5kaW5nAAAUb3VudDscY7MZ8oCCAXIQynVsbGNhdGFsb2cAAEYaoMRyYWluAACqEhDDZmlnAABgEBKAAZDBAAAdjm4EAsJlYQAAG3VwY29tcGxldGVkOhwUdXNlcskYbnLygAIAbG8AAWPAHtr9EQQBRNP7qJMbcQ5pbyqa0xyuFdgtQ0h/VGH3lluKD18sXds+pKRA//bsMxh7GZB7xgAAGccCkjDS98EAABvLxAAAGcDhhwMAAAsGAAAB9jkXAAABANwAUwKJAZAB8IABAQAXbnMFhsAAABFRERmNGQXAwAxAAAASHhwXmRupDBoxwAAAXxtgG6gbEwECxQAAG8pzdv8xCAAAAQABAKcB1AGJBCsFCQF+AAUA7QHlAJ8ABgBqAaHEAABvF6DKcmFuc2NvZGluZwAAwRsQzGVtb3RlY29udHJvbAAAbhwQymF0aXZldmlkZW8AAAYcEMtlZGlhY29udHJvbAAAORwAT3h0ZXJuYWxkZWxpdmVyeQFfHQAAAa4dAAAEAsVpcmVjdAAAo2xheb8bpXRyZWFtNxxwc/KA/RgwBQAQx29tbWFuZHMAAKYaEoFkAYHLAAAdcGPyg29ydAIAZXPygAEBcGQARGNlc3MBtQoAAAE9FwAAAERyYWNrgm4WAADhoRoAAMYcAAAQENZwcG9ydHNleHRlcm5hbGRlbGl2ZXJ5AACuHfAAkAEA2XSCIRAAAAAQyHVlcnlrZXlzAADCGhDHcm9maWxlcwAARRoQyGVzYnVybmluAABtHBKCb3IBAcIAAByvY23wBGl0bGXACNiQAAIDBAbgAdELAAABjx0AABDFdHJpbmcAAMEOMoACAcEAABV8c3Rk9gqAwAMIwABndPLSioDFAAAVjBWCwgAAEx8XOhdDAAPFAAAXHs0AABy9ZXR5hxsKAAAFAsAAAKiQGaalFmxvAUPh6BIAALwWAABCFYTHAAAUlxyPGiMDAsMAABi0aXIBEw8AABXAwAQGAAAnLRp2GxJZEhMGFEUMJYTAAAAT7BhDENcaLwHABC7gAAAcsMIAABZqwwAAHHQFlsAAABSyGBdAFhakFhUBxAAAFs8bZLEYFYLDAAARFxauHGsDywAAG8thaHIxwQAAVgqTGr8cQcMAAOMZ5BmAHYEdMcYAACIYIxiDHQWGwgAAEBMcUg8aEBoRGsEbwhvzgQJjZvsCbnSgxXR1cmVkAABeEBKAAQHHAAAZjGNuAAJuZwABxgAAGPJmgj4IAADk0wsAAMEOAAAmEAAA2hYAAKobAAAARABxxwAAph2nHagdqR2qHbMdtR3yAnJsAAFmhaUSAADmGRcAAPUZAACSGwAAohsAANMcAACTHQAAlx0AAAExAACgwnBlAAA/FBKAAYjKAAAdkHkABWVzdW1lAAHGAAAdoHAB1xYAAAHNGgAAoMdlc3BvbnNlAADTG/IGdWFsaXR5AQByAAHFHAAApAHDaW5kAACrcm9tamVsbHlmaW7RHGZcEBDCZXkAANEb8gABAGvi7xgAANkZAABDGgAAABDT/xsAAL4QEoZxdWFsaXQBhMEAAB2JeRKAAQLBAAAdiMAAABb0Z2RmEoABAeAAABcgbmSgwm5kAACjEvYHAW3+AETBBgLAAAAZpuEAABaxYXMA684SAABYFQAAXxUAAJwZAADFGQAAWRoAALIaAAC5GgAA7xsAADccAABuHQAAdx0AAAUTAAABABYAgIHAAE7HAAAb8cIAABizxgAAGZLEAAAcCfYBAJYD4AAADEZhb3kBZgMAAAFKCAAABQLBAAAktRmDHBPtGWNyxQHAAACgQxRzCwhxwgAAZxVoFWkVahVrFWwVpxwxxAAAuhtnHHwdAUDkYQoAAGMaAADPGgAAIRwAAIodAAAAMAAQxWxldmVsAAA2FhIAAcIAQc0AAByjzgAAHM3FAAAcCmQAAVUXAAAEAs1wZGF0ZXBsYXliYWNrAAAWb250ZXh0mB0Xcm9ncmVzc58dY3AEAsAAABxnZ2xlZmF2b3JpdGVsHBJwZTUWb3kAQ2F0cwFlFQAAAVMdAAAEAsAAABljdGl2ZXVzZXJrHBZhdGNoZWRwG2F3EMhyY2hpdGVtcwAAvhvygAIAYXQQw3NlcgAAQhoQwmVyAABvGxDRZXNhdWRpb3ByZWZlcmVuY2UAALId+oECAGl2EMVlcnNvbgAAbhsQxmlicmFyeQAAvRscAfIAABVlb3BsZagccEEa8oJ2Zf0AiQoA8oADAGFldAwC8AAAFWxheWVkbBsVeW5jZWRtG3BzoMRpbWl0AADVFgCBboQBmQAAFjTAAAAY0mYQxHNlcnMAAEAaEMJ6ZQAAshkQw2VycwAAaxsYywAArR3ygAIAaXYQy3VyaXR5c3RhdHVzAABvHfKAAgBjcvKAhgAQzmF5YmFja3Byb2dyZXNzAACGHQQCwAAAHWRpbmdzeW5jY291bnSOHRNzb26lGm5yEMJ0aAAAsRnygAMAYWVsEMhpYnJhcmllcwAABRwcAswAABVlb3BsZWocED8acHOwGQQCw2l2ZQAAFmVzc2lvbl4dE3Nlcmkcc3UQyGVzc3Rva2VuAACnHPq4AgBjdPKCZXT9AYkKAAQCxWVsZXRlAAAVZXJ2ZXIEHBNzZXJqG3N18gJnZf1IGQ4AhLEKAAAA8oCDAXkQx2Fuc2NvZGUAAE0XEoABhMIAABYZcgACZWQAAsAAABoyxQAAHA9hcoMTCgAA5VESAABmFwAAVxwAAF8cAACIHAAApR0AAAEEAABJb25pdG9yaW5nAcgYAAABcx0AAHIAwACKAcoAABwWduGGAwAAAgYAAAjz1wsAADwI1wf2A4ACwILCwwAAGadwcgUCwAAAH2MdGHgaZXAFAsAAAKZcGxjZG2Nk+wJlbLHEAADRGfIBcwCCg+MJAAA6MAAAAQAAAAAADwAQAAAAQRLcEk8V+xaCGcEZABobGlAasBrAGswaShy2HG8dkR0AQAH2BwAA+mUVAADiACwBUAOxAAcAawA2ADYBAQCgxmhhbmdlZAAAYxVQAAECAMQAABpa/WNod+EdBwAA6wcAAArouA4AAIYSAADXEgAAShUAAEoWAABxFwAAPBoAAJIaAABbGwAAACABEADygAMAZXN1IcYAAIEbYh0Qx3Nlc3Npb24AAK8ZEMZ0aHJlYWQAAKQa8gJtZQEAcgAB0RgAAPKAAgBpdhQBx29zaXRpb24AAB9pY2tzZnJvbXNlY29uZHOiHXRMFxDJaWVzZXNzaW9uAABpGwBHaXRvcmluZwGuGQAAAX8dAADygW8CAG52sMRuZGV4AAA8FBDHZW50bG9vcAAArRmkgcAAABF0ohIRCvKAAgBkdgBHb3dubG9hZAFLFwAAAWoaAAAEAsF1AAAZaW9zZXNzaW9uaBseb3BsYXljb3VudGRvd26mHGR09g8BdP0ZkQTBAGDFAAAdd8oAABxG4RsHAADmBwAAFOXCDgAAZhYAAO0bAAD0GwAASxwAAJ0dAAABBAA2AYACwAYBwwAAEDrFAAAaiXJ0AAAD/fy/UkrpPuOSF98nek0TslwKUg2bXmcKJri8kCg8sMwtB8cAABl5xgAAGorGAAAcyzIOTHuYSfld2/VBEII4s80AAByPxgAAGosv0u5jNx7onFJmGCFB1sPHAAAalNAAAB2eYW9yADowAAABAAAAAAA8ABAAAAD1BQYIRghHCEgISQgLCgwKvwvAC8ELwgvDC8QLxQslDCYMlQ7iEnAVcRVyFXMVdBWbFlsXYxd3F3gXeRd6FwYYBxgIGAkYDhgPGBAYERi7GLwYvRi+GOcYCRmkGbwZzRmvGrcafBuBG7Ibsxu0G7UbxxvOGw8cQhxiHaHJAABpF/IAAIQBZQMAAAASgAGQwgAAFsNsocMAABQWocUAAMEYscQAAAQWBQLAAAAWGhygLxZpc/sCZnb7mjHEAACAFoEWsRxViMIAABekG0oSSxJMEosZDhphwQAA7Q7uDu8O8A7ZFsYb4wL9EgEEwgAAGXsB9wkAAAUCwAAAGtUbpfAYbXMEAsFhAAAWdWlsZGVyShcRZNALYmkQyWFsc3NlYXNvbgAAbBfygAIAaXRSgAECwwAAG8RjbnLRwwAA6A4hwQAABRmWGyHBAACMFX8XDMDiDkAAAB1hdGFsb2dpbmRleGVyARwdb3dubG9hZHdvcmtlcgIcHWF2b3JpdGVzZHJhaW4DHB15bmNxdWV1ZWRyYWluNhwUAcAAABJvclQMZoUD8oACA253Y3R1dgGAgwNkqEqwXGxvcgADcmNlAAIelGNJEyXly1LtaXMB4gkAAOPKEgAAFBgAALgaAABIHAAAMAAEA8AAABF5sAq0cmRlcp8SGmVyaWVzb3JkZXI+GmJvcxIAAQHBAAAaPnRkAOFwFgAAThcAABA2AIACwAQNWjN7MAYWPkYGTTjREFeTxAAAF1xydRWCwQAAERMM/w4VAcIAABYrHHNZCiHFAAAiHNQcEMZyYXBwZXIAAN4ZAER0YXRzAXMXAAAB5hsAAPIHcnRjYWNoZQIBc3dj4VsQAAA6FAAAAQBWAoCB/RBBCskAABw5xwAAFqPgAAAXaAUCwAAAEYkcEX4XbnQAgAEBigAAB+XDAAASSWNiMMJ0ZQAAcRdyFz0aGoiBiMAAABvqsMNvZGUAAP4WEMZ4cGlyZWQAAN0ZCFeC2xkAAAGHHAAA8gdlcHRpbWVy/RQQAgCCOBQAAPXGGAAApQHBAQMAkQAMAvAAABNhY3OeEhRhbWVzNxRtbvKAh4hBxAAAwxlPHFAcpB0IUwGuCgAAASoXAAAY3gAArwoQzW9yd2FyZHNlY29uZHMAAIIbEMphY2tzZWNvbmRzAADYGQBFY3Rpb24BWhAAAAFlGwAA8gFw/SOABAAAAdYLAABSAISSwAAAG38AAT4VAAAFAsEAAB6PHB+9HGxzCQF4wPpkbW5RwAAA0gtAFV4VxRhbGiUBwAAANuAW4RaNGWvlEsMba8CYCMAAABdZxgAAFxtRyQAABRoGGgcaCBp7HRWBwAAAEZwOEQcARHl0ZXMBnRIAAOEmFwAAthkAAAESAWXABUCBoAAABxqgAAAH5MMAAB2CggAGAAD4AwgAANAGAQCgATsC3wOIAJwCEMNuYWwAAOEJAEA7MAAAAQAAdQABAO8CdQCIBxAAAACA0BQA/gANHgti/0ZSVSVQyi+8PJA42spMUd37MsEAABAtxQAAFwrFAAAdcSk7ay5LAJ/Kq9UWR/3W10fxWxe+UTzCrDXAAAAPGSHBAADMCbcLAERyZGVyAdQWAAABIBwAABjOAAAzFvIEZmZsZQIAbW+HpwoAAOJPEgAAURcAAPAbAAARACUCwQAAFkcaFnUccnMjEH0cwM9leHRlcGlzb2RlcG9wdXAAAIUbEgABwQBCyAAAHDXLAAAcaMkAABylbgCCQAgAAAQCw2hvdwAAGWluaXBsYXllcjUcGmlkZW9wbGF5ZXKlHG12DALGAAATb3J00xYScnkyFnB0EMxyZWNhY2hlcXVldWUAAAAcGOAAAMcYEMlhY2hlYWxidW0AAKMa8oJsZMAGaABygAIC4AAAGft1d2x0EM9yZWRldmVudGVtaXR0ZXIAAGAa9gAAmf4ADMQDcMGewk7BAAAJ+c8AAB1qzgAAHDDBAAAJ+sgAABghAPL2BQAANRQFwMACiAAAFxoXE28aLV8drh0VAsQAABMgGCDgG4wdaXNIEhsCxwAAHXFpcwUCwQAAHJAcEgwUY3YFAsAAABAjFhOXHGRuAERsdW1lhJkSAADzHhoAAH0ALgISgIiEygAAHMcASmFuc2ZlcnJpbmcBrBkAAAGHHQAAAANuZ3MAwsAAyAAAHNrBAAAbf8UAABuJhFAMAADuPhYAAHMWAAAEFwAAXhcAANYYAADoGQAAnBoAAKUbAAD8GwAAjxwAAKMcAADCHAAAxxwAAHYdAAB4HQAAAFARMQACAADygAIAaXIAS2J0aXRsZXRyYWNrgqEaAAABxhwAABDJbmdxdWFsaXR5AAD/GxKEcmVhbYQBxQAAHMVxAEhlZXB0aW1lcgHGGAAAAS8cAAAQz293c2VydmVyY2F0YWxvZwAAaBwARHNpb24BNhQAAAH6GAAAAEZ2ZXJ1cmwBSRcAAAFoHQAAMoACAcQAABumcnN08gD9kAgMAAAB7hsAAAwC3QAAFXRzaW5rxRgVaXRvcnlIF3JzEMZjaGFibGUAANIW+pECAGFwpAHEdWV1ZQAAGml0aGNvbnRleHQ0HHcfEABJbGxpbmdoaW50AasZAAABzhwAABDOYXliYWNrcmVwb3J0ZXIAAP4b8oACAGxvEMtldHdvcmtzdGF0ZQAAPBoQwmRlAABPDBQBy3hjb25jdXJyZW50AAAYb3dubG9hZHN9HWSgGvKAiQAQ1mNrc2NyZWVucG9zaXRpb25vZmZzZXQAAI0dAENtaXQBHhAAAAF1GwAAMoCMgsIAABvdEM1lbGx5ZmluY2xpZW50AABnGxDSY2x1ZGVjYXRhbG9nYnJvd3NlAABdHRKAAQHgAAAWf25kEM1jbHVkZWRpdGVtaWRzAAD9G7DKZW50ZW1pdHRlcgAAORoEAsAAABZyZWFzb25HFxdpZnVuc2V0qhlkdvKAxQAEABDJcnJlbnRpdGVtAACpGRDDZXh0AAA1FAQCyGVjdGl2aXR5AAAWb25pdG9ypBwXZXBvcnRlctAcbXL6uwIAbnQygJiByAAAHF4Q0mFja2dyb3VuZGF1ZGlvYmFzZQAAzxwAR2V0dGluZ3MB/BsAAAF4HQAAEoVvcGxheQEBwwAAG7dzYwBEcmFja4LDGAAAAS4cAAAIQoScGgAAAcQcAADygmlvAgBzdPKAAgBkdKDHcGhhbmRsZQAARRcygAIBxwAAHGtwdWMAAP7Axe4LAW847AEKEMsPIzJDp1aFkD1U/UJZF/+IcqMVgZ2EsmWn/3yjKZVm5uoishrbXUixcf6IVnByDP8baoxcxDhIkIhTEdPe4lIIWFoilsUAABtwdwHuAgAA++AHAAABABkCuQF4CJ0CbAABAHUCbAB3AwUCwAAAFHsVpFYXY3KlAcYAABbYHHe6GgUCwAAAI7gZUxsaqhxucDQBwXIAABZyYXBwZXIQHHdtF24XqBmgymF0aW9uZXZlbnQAAHkc8oVlcmlmaQIAY2WxwgAA4BgASGdnbGVtdXRlAWYbAAABWR0AAPKAiAF5oMZwZGF0ZWQAAG8XEM1ldHBvbGxpbmdoaW50AADOHBDGb2xsbm93AAA4GgBGdm9sdW1lAZsaAAAByRwAAABBawExFgAAAWMbAADygAIAZXT2AAD9EIAKAcMAABrAdINMDAAAAQIWAACkAsVvbGxlcgAAFmFuYWdlcoMbFnJhcHBlcoQbbXdDFwCAAYGUAAAS38QAABwUZgBAgpcSAACFqxgAAPKAAgBkbqDFYW5nZWQAAPwWEoABiMQAABu8aPYPA2lvbv4AQSALA8kAABxwZW1u4RAKAACjCgAAAzowAAABAAAAAAAVABAAAADqE+sTNhQ8FlMXDBipGPkY+hivGQkaYRqGGsYaaBtpG7kbKxwyHHwcfhxeHTKAAQLKAAAarXNld6HFAAAKGhUCwAAAEtYLEtcLa3RAGlHDAACQDpEOkg4yEIAaYcMAANQLMBAxEKAW3xiXGRDDYW1lAABhFfICY2UBAG4BogoAAOPVEQAA5BIAAFYXAABpFwAAIBEAQnJshZISAADzSRcAAOMCPAO4wgAAMhSgwmZvAABYECKAAgCuAAAQD2RudgAAA/0EQBNiQjb4ixxsWh7CNzlLAdvgAAAba8UAABpWaW51gzsIAAD6yRIAAFIDAQAzAaACgAGVABEANABrAKDJdWRpb2NvZGVjAACZGhIAgQLGAAAaEsIAABVLZHQAAR0MAAAygAKCwwAAF3ZkcvKAhgAhxwAA4BuMHQyK0QAAomZzLxalZ3Jlc3PBGOjDAAAsFABBZIgHEAAAAABOdWRpb3ByZWZlcmVuY2UB0hsAANKtHQAABfYCAXP9AaEAA8wAAB20wwAAGj5jZW8BOggAAPITGAAA2gEAQIJWEAAAATwVAAASBGxpemUBiMMAABjccjswAAABAABuAAIAvw4BAJoPbAA7MAAAAQAAugEEALwSAAD5Et8ADBlsANgabAAygIMC7AAAC5XBAAAL1WRudgcAAv0YIAbJAAAayml2APqLAwAAgwIBAAEAMw+GA3kBKAH/AAIAMcQAAH8cgBxmHRDNZXNzaW9uY29tbWFuZAAAvBsQ0mVtb3Rldm9sdW1lY29tbWFuZAAAXB0QzWVuZXJhbGNvbW1hbmQAALsbdgEAwBBgAsUAABtiY3DhhAMAAP4FAAABABKAAQLDAAASR8YAAB2PZGFzAEZhcGhvcmUBvg4AAPIeGAAAVQRBxwAA4xnkGYAdgR2hxwAA4RkgwAAAOQihChKAiITBAAAXHhDFZmZzZXQAACsUoMJuZwAADgoQx2Jzb2x1dGUAANEW/gMW/QFBBMEAYMMAABodhfkFAAD37QsAACUIHwIsAQIAXgKmATHAAAB0HHEdkB0ASnJpdHlzdGF0dXMB+xYAAAFvHQAABMDCZXRgEAAAFmVhZGVyc/oWF3NvbmtleXPuGBh1ZXJ5a2V5c9cZEMZvdGlja3MAAKcZEgABAcIAAByJdGwA7HsVAAAKGQAAtRkAANgZAADwGQAALhoAAEkbAACCGwAAdRwAAIMcAACrHAAAZR0AAKIdAAAAACIAAAAA8gJuZAEAcwDh8BgAANUbAAAQ8oADAW9ydXMQw2FuawAAKhQEkcAAAOJtZSQUpG1iZXLPFsjQAACWD/ICb24DAGlucgE4CAAA8/EOAAB7CKYAEMpwZGF0ZWV2ZW50AADaGRDDZXJtAAAjFAQCwAAAE29wZd4SGHJ2ZXJvbmx5mBpjZQAEc3VsdAABFoyPnF9zAWIVAAAAEMJuawAAIhTygIMAEMR1ZXJ5AAAuFhDFZW9wbGUAAM4WEMZwdGlvbnMAAG0WEMh2ZW50bmFtZQAA1hkQyGFjaGVvbmx5AAA3GvYfAmNo/gAF8AeEwwAAG77hNggAAN0JAAAT8igXAAAMA/KAAgBycwAA/RUwB/6AYmI8ZlO/7qt6+95OL2MV5LR+EeWQ6p1InB8H9mvyhOJSbLIwkEsyz0jo7sIAABpTQ2oMivnKAAAaShCL+65lTf4hwvRDkX40PcYAABinwQAAC+xDhn63PMUAABdYCFhdBqoAOjAAAAEAAAAAAB0AEAAAABcH1wfYB9kH2geqC6sLrAvOC88LwhInFksWxBnhGeIZjxrQG9Mb1BvXGwkcDBwnHHEcchzPHNYcXR1gHRUCwQAAJ1UaYR0djR1tcDAcocsAAOcb84GDBQLCAACgEhYTjBxkbxsCxAAAG3djbgUCwgAAVeIb4xvkG+UbnB0QQBZheQUCwAAA0ewTocsZbngrg8MAABYNKwLLAAAcsG50BYbAAAAgTRcTHFIPGhAaERrBG8IbEMViamVjdAAA3RLyAWWIAAHjBwAA894SAADpA1ABdgGAAQTBAAAT/HBkbnV2IckAAMkbQRygwWEAANsJEoABAsMAABP2xwAAHI5tY2TygIKB9gCAAv0BAA3AAAAbessAAB20aG8FA8MAABaEHKhOGznSG60dsh1lb3AViMUAAB+vHeEbAUHkhAoAAIQWAADDGQAATxwAAKQdAACBAQDlAcEAABYJGnPdDjHBAADhEjUVNhXzggJvdqUBwgAAF1gcalcKAYIC0AAAHbLBAAAbb2l2FQHDAAAVqBxwQRoMAsEAABCREhRyaW5nwBhueRDDaWxlAAAtFhSExGFjaGUAABNtcGyXGs0W8oDAigAAEMd1bWJuYWlsAABCF/KAAgBobxDSYXlsaXN0aXRlbXN0b2NhY2hlAAB8HRKAAYLDAAAbbmwQyWVzdG9jYWNoZQAAZxwShWlicmFyhAHgAAAbvXkQzGVucmVzdG9jYWNoZQAAuhsQznJlZGVudGlhbHNmaWxlAAD7G/YHAMASiQPCAAAaQmlzdQABUBcAABKAgoHFAAAbqQQCw2l0aQAAEmVklQ+pZWZpbGVuYW1lNRpzehDIb21wb25lbnQAAEEX+goBAGMA9bQOAABOABYAPQFdAvYXgMEIBP1GCAzgAAAczAAA/kBld0YPBVmClMt5sGgSD2zS++6pZHflSew77GHCRkXbUhLQUZq0xLcwXjYLvVjkahdieJoQt9QLGg0zWD7FGlQXGmR3eeplRjVTvQTuJCRQex+AzVfgAAAHDMEAAAn74AAABw1Gdb72UjdzrHw2TNIECBZtud3Jw8gAABzaE1qItBsrWU8myUn2L0jOyAAAFx18kvvLIzEyNDc4YmRmZ2pucnN2dwA6MAAAAQAAAAAAqAEQAAAAuAe8B70Hvge/B8AHwQfCB8MHxAfkB/YH/wcACAEIAggECAUIFwg6CEQIRQg7CTwJPQk/CUAJQQlCCUMJRAm/CcAJwwnjCeQJ5QnnCegJGwocCh0KHgofCiAKIQpZCmAKoAq1CrALsQuyC7gLxgvHC8gLyQvpC+4L7wsUDD4MPwxDDEQMTAxNDE4MUAxRDFIMUwyoDqkOyg7MDs0O1Q7WDtcO2A4DDyEQIhArECwQSRA1EjYSORI6EjsSPBJBEkISQxJUEp0SnhKtEq4S3BLyEvMS9BL1EvYS9xLaE9sT3BPdE+YT5xP/EwgUCRQKFBkUGhQ3FEEUQxREFDEVMhUzFToVPxVBFU8VZRV1FXYVdxV7Ff4V/xUAFgEWAhYEFgUWBhYZFhoWKxYvFjAWNxY+FkcWbRZzFngWkRaSFpMWlBaVFpwWnRaeFp8WrxawFrEWshbJFsoWyxbMFvoW+xYEFwUXBhcHFwgXCRcLFw8XEBcbFyYXKxcsFy0XLhcvFzAXMRcyFzMXOxc9Fz4XPxdAF1wXXhdoF3MXfhcTGBUYHBgdGLAYwRjCGNYY1xjoGO4Y8xj3GPgYBhkKGXwZfhl/GYAZgRmCGYMZhBmFGYYZpxm1GbYZtxnBGcYZxxnXGdgZ6BnpGe0Z8BnxGfIZ9xn5GfoZABoBGgIaAxoEGgUaBhoHGggaExobGi4aLxowGj8aQBpFGk8aUBpRGlcaaBpuGnIacxp0GnUadhp3GnsagRqHGokaihqQGpEanBqdGp4anxqmGq4asBq2GsAawhrDGscayBrJGsoayxrMGtQaSRtRG1IbXxtgG2EbaxtzG3QbexuAG4IbihuMG44bkBuRG5cbmBuaG5sbnBudG54bnxugG6EbpRumG6gbrBuuG7YbvhvZG+Ab4hvjG+Qb5RvmG+4b8Rv5G/wb/RsFHBccHBwdHEAcRBxKHEwcThxVHFYcWhxkHGYcdBx1HHscghyDHIocixyOHI8ckRySHJkcmhybHJwcnRyeHKAcoxyrHK8cthy3HMIcwxzEHMccUh1THVUdVh1XHWMdZR1qHWsdbB1vHXAdcR10HXYdeB16HXsdfR2FHYYdiR2MHZAdkR2UHZUdlh2ZHZwdnh2fHaIdqx2sHQUCwQAAG10cFLAYbHYV/sJAQEgCAAAZXBwToRkalRwUHBoVhxoRFBUBwwAAEKQbZCcW+wNhbHIFAsYAABSTHCMfHIQdY3UxwgAAaBZpFtsb84ECZWcbAsEAABgVY24bhcMAAA6mAUXnTRoAAFYaAACEGwAACxwAABAcAABDHAAAdhwAANccAAAAAAASBQLAAAAYghtZzxrQGtEa0hqKHXJ0pMLDb2NrAGAAABhlYWRndWFyZGsXE2FmZVUQGXJpdGVndWFyZO0YNAjygAECbGFyAUHjkhIAAEkXAAAsGgAAaB0AAEAAFQHBAAATlxlkyRigy2xpdGVzZXJ2aWNlAABpF/KAAYJxAYQCwQAAGYwrUHJeXmVzFAHDaW1lAABkaWNrc8kWyhbLFswWPxdAF3QNChDIbmRleHBhc3MAAD4XoMRyYWluAACTD/YAAAP9FCACZqhZszZH6xKjMcsAAByOZGl0Ae0CAAAANgIAAv0VAAHDAAAYFhLW+Q8i4AAAFYpucwAByw4AABWQwAAAFkgayxwTAwJmyWRXjGRyAVUMAAAFAsUAABY+HBdtGmNkVYTAAABoxxrIGskaVR1WHVcdcgMDBAQEBQSnCyHBAACDFgUYBcDAAAsAACGYGrgcGkYbHJQcpQHDAAAQRRpzDBfBygAAtRgxwQAADBwnHF0dAUI7MAAAAQAA3AACAJYOAAB1ENsAocEAAAoaoMtvY2FjaGVkaXRlbQAAlRryAAEDdGltcwE5AQAAOzAAAAEAAN8ABQAICAAASwjbALoOAABAEAAAlRkAAFUBwAAAoFEbc0QV5xvoG1EcUhyhxQAA6xlF/cMDgAIAACVHHFQdM9cbzxxgHSOzGtgbFUgc3xsfHHUdhB0xxAAA2RjqGZMc+wJjdfOBAmFwYoABAooAAAfidG5wIcIAAOsYGhqhxgAA5xZiAIICoQAABfdjbAD2cBoAAFEAHQFbADUA9gAFBMIAACLlEsMbMXAWcRZOFxbEGxTDFmlvcHEigAGCoAAAA4NrAID9BAgY/WrwRyzeVNQSLwANtkk3mmkk1yGnr738xgAAGmVxoHtWAXFn4a6nO3FllvsngkKzQEtzdSH+LLgzvyxA73eBQEyAl82dEKjTFVpsaBQzzkck8yy5CQLPAAAddcMAABndaXAFAsAAABqwHWI+DD8MBBYFFgYWsBhkb6HJAABcGvuGocMAANUSQcIAAAgUCRQKFLYaEMJtcwAAaBcShGVsYXkBAcAAABrKc20EAsAAAB1lY2tpbnRlcnZhbG1zRByzdW50HxRobzIAAgJylptKBMwAAB13Y2RhcgDzMhYAAGIEsQDygAGEeRKAAcBgEMUAABb6xgAAGO7HAAAZ13IlAsIAABZHGhZ1HHJzIxB9HBMAAs8AAB1qb3EBKxoAAFHFAAABGgIaAxoEGnodUcQAAIMZhBmFGYYZbB0EAsAAAB5yZXNob2xkc2Vjb25kc3UcFWFja2VybBZocqSBxXRyZWFtAAARdKYZXxUEAsV1ZXVlZAAAGG93bmxvYWRzZhwVZXN1bHRfGmRyENFvZ3Jlc3NlcHNpbG9uc2VjcwAAcR0SgAGIxQAAHaByGO4AAMgWEMdvd25sb2FkAAC/GBDOYWNrb2Zmc3RlcHNlY3MAAHQcEMpkaW9zZXNzaW9uAAC5GxKAkAHFAAAdY3T6D/7AAnADAmltAPPXFgAA9gPLAQABdAACFoyPnF/FAAAbynN24TEIAADaCQAAIPuuCwAAiQQrBQkBfgAFAO0B5QCfAAYAagHygAIAbG0MAsEAAB5pYnJhcnlzZXR0aW5nc6McH3RyZWFtaW5ncXVhbGl0ec0cbHMSgAEBwwAAG7pyYxKAiALIAAAdd8wAABzNYXIAA25zZQACwgAAGlPDAAAcl2Jpgs4LAADpwhIAAEsWAADEGQAA4RkAAI8aAADQGwAA0xsAAAkcAABxHAAA1hwAAAABABAQEoCIgcEAABWMEM9pZGVvZG93bmxvYWR1cmwAAFsdBALAAAATb3BlxxYYaXBhY3Rpb25lG2NrENFuZGluZ2Rvd25sb2FkdXJscwAAhR0QwnRoAAAsFvKAgwAQzW9jYWxtZWRpYXBhdGgAAKIcEMluY2FjaGVkaXIAALgbIMAAAM0LSwwQzXVycmVudGVwaXNvZGUAAGUc8oNsdmX+AEMiCQAQzHV0b3BsYXljb3VudAAAtxsSAIEB4AAAG+5zguAHAADhswsAADQXAAABEoABAcYAABd2dHL2AwD9EMAMAg97uznAaHMA4kAJAAA5EgAA+RsAAEMAENdlbWlzdHlwZWR2aWRlb2Rvd25sb2FkcwAAnh0QyGl0aHJldHJ5AACUGioMAoKJAAASkMAAABywaXeCCwoAAP5bFwAACACEAdUAEQDiAAgAxQAFAEYABwBBADMAIAHygnVlAgBzdRDNbm1hcmtmYXZvcml0ZQAAWh0QxWVhcmNoAAA0GgyJwwAAEnJ0nR0UcHBlZKUdEMdyb2dyZXNzAACsHfKNZXBvcnRwbGF5YmFjawIAcHMQzXBlbmxpdmVzdHJlYW0AAG4dEMlrZmF2b3JpdGUAAKEcpAHEYWdlcgAApnJhcHBlcnYcd9QZ+rMCAG5yEMtyYXlhY3RvcnNhdAAAzBwEAsRpZGVvAAAab3dubG9hZHVybKEdGHRyZWFtdXJslx1kcxDJYnRpdGxldXJsAABtHRDKbWlsYXJpdGVtcwAAex0EAsRyaWVzAAAddXJyZW50ZXBpc29kZbQdF3Bpc29kZXOMHWNl8oCWAAQCw3VtZQAAFHRlbXNsHRVvdmllc3odaW0QzWlzY292ZXJhbGJ1bXMAAJwdENBlbnRseXBsYXllZGF1ZGlvAACxHfqewAZAAAQCwAAAFHJzb276GxpheWJhY2tpbmZveR1lbBDNZXh0dXBlcGlzb2RlcwAAlh0EAsAAABZyYXJpZXOgHBtldHZjaGFubmVsc5UdYnYQyXRlc3RpdGVtcwAAax3ygIUAFAHAAAAXeXBlcnNvbosdYrYb8gJlbQEAcwFkGwAAABDGYWdldXJsAACfHPKAAgBtdBDFZW5yZXMAAPkbEMhhdm9yaXRlcwAAnhwEAsFkAAAUdGVtc5QdGGlicmFyaWVzqx1pbBDIaXNrdXNhZ2UAAKQd+qwCAGRlEMdoYW5uZWxzAABkHBDIdHJlYW11cmwAAJMdENRubHlzdHJlYW11cmxmb3J2aWRlbwAAtR3yhHVkaW8CAG9z8oJldP5AW6oJABDGZXN0cm95AACTGgQCwAAAH2VhcndhdGNoaGlzdG9yeYodFGVhdGUzGmxy8gD9TFILAOGMEgAA5hIAADDkSBcAAGAXAADdGAAA4xgAAMkZAAAAERAQwmVzAAArFvKEaXRvcgIAaXnBwgAAghohwgAAxRhbGhDFY2Nlc3MAAD0XBQLAAAAR7RsRWxtydAAEcHBlZIEAmQAAGjIAAVccAADygIiB8gAChHR1AaAKAAAAIcYAAC0c2xwQyGhyb3R0bGVkAADLHPIGb2dyZXNzAQB0AAGRHAAACEKFXxwAAAGlHQAAAEJydIX0GwAAAZ0dAAD6pokACGaFmRwAAAGsHQAA8oZheWJhY2sCAHBz8oACiGxyEMZ1dGNvbWUAADwXEM1ldHdvcmtmYWlsdXJlAAAzHATAxHRtbDUASgAAGmVkaWFsb2FkZWTKHBdvc2l0aW9u8xsUdGF0ZZIaEMNpbmsAAF4VEgABA8UAABy/wgAAHCnFAAAcQ3NkaXeERwwAAOXpGAAAFxoAAHkbAADaGwAA/hsAANAcAAASAAAAQ2ltZQHGFgAAAc4aAADyAAEAdAABrRYAAPKAAgBkchDJb21wbGV0aW9uAAAxGv4PCP2U4AIDyQAAHVjCAAAZkmltdILYCQAA8r0SAAAWBKDEcnJvcgAAvA7ygAMAZXJzUMNvZGUAAFMQVBAcFB0UHhTyAmF0AQBthNQJAADhiBUAAFobAAAQ8oCKAAFBOzAAAAEAAHoABQC7DgAAGg92ANIbAACtHQAAsh0AAMDHbGF5bGlzdAAAuxj6JQEAcAABsxgAABKAAQHCAAAZ+G1iMoCBAlUIyEQsY3TBxwAAvxYFg8IAAByNHBb0GcjBAACyGxKDcm9tAQHDAAAclnBxGgIBhMMAABvxZgHTCQAAACHGAADeG24cAER0cm9sAcEaAAABZB0AABKBbwEBwwAAHVxubfIFb2x1bWUBAGMBxRYAAAHIGwAAEMlvZ2dsZW11dGUAAFkdEMRhdHVzAADAGgQCwAAAEWtjGxZ2b2x1bWXJHGV08oACAGV08oRzaW9uAgBzdBDIZGNvbW1hbmQAAGIb8oACAG5zEoCCAcMAAB2RdBDMbGF5b25zZXNzaW9uAAAyHBDSaXNjb25uZWN0cmVxdWVzdGVkAACwHP4BCcEESAPDAAAcqcgAABwwY2ls4S0IAADRCQAAMfOrGgAA5QEKAfKAAgB0dgBGZWNvbmRzgy4aAAABqxwAABDEb3VudAAAuhjyBWluaW5nAgBjc4KRDwAAAPKAiYYARXRyZWFthlgVAADSxRkAAO0SgmFkAQLEAAAWa8YAABxdc3J1EoCIhMwAAByjCG8BLRoAAAF2GwAA8gFyAQBkAUUMAAAAEoNpc3QCAJ4AAAxGZXIVAcEAABQaHHEqCBDQdWV1ZWxvY2Fsc291cmNlcwAAah3yA2VzaAEAcQABKxoAAABFcmVuY2U7MAAAAQAAdwACALsOAAAaD3YA89IbAADbAQUAdgCAAv0AiQLCAAAaV8IAABpY4QAAFi9lclHMAADiG+Mb5BvlG5wdwcEAAL8WEMhlcnZlcnVybAAALBoQynVlcnlwYXJhbXMAAGEbEMlzb252YWx1ZXMAAJEaEMZlYWRlcnMAADsXBALAAAAQzAsWYnlhdXRopRlkbfoh/gCkQAEAAdAJAAAAUoABAsIAABvyY3N0UgCBAxjeZKK1wwAAFjZlaWwA5fALAAAuEAAAXhAAAKgWAADdGQAAmRsAAAEQAKHCAACgDqWQxQAAovUZNxwxwwAA3hO/G8Ab+wNwc3VRzwAAuRy6HLscvByxHQUDwwAApLAbFjEcFAAcY2VxEMVyc2l2ZQAAGQ8VAckAABC3HHOxGhSCw2JsZQAAGnJyb3JyZXN1bWWYHBsUEoJlcoEBxAAAG+9zscQAAKgaEMdvc2l0aW9uAAC5GPIBZAEBcGMBzwkAAABygAICxQAAHE1ydm1uEM9haW1leHBpcmVkYXN5bmMAAPIb8oADA2xvdWFldDWCwgAAF8UazxLQEh4WAUH4DhYAAAEAwwBQAHQCugEXAhcAI4ECwwAAGmxpbBOBg8AAABpHAEJvboLNCQAA9qIOAAABADUDbwVhASIAEMNhZmUAABgPEMR1ZXVlAACLEhDGZW5kaW5nAAAqFhDHYXN0c3luYwAAOhf2AADAAFnIMADDAAAXa8QAABbYyQAAGmQAAaQaAAB2AoAC/QQQDEbLDLYOLkDacEFkcwAA/kAbbgP+gISAOFW3P9kwCYDRIf17TH30kn67Cl/fL3ir3o5JqiizoBCjtWOtKzwq4vlm/JR2hXlRzcw2BLgk4pFNpdcqxsYAABLiJ9o/8hDGAAAcwT0LlNVvS2m9uDDFAAAcCnn9uH2hAPkEBwAADwEXApQEHQR6BMkAFQRAAKWBwAAAEcwcHBjzBAFzgqsOAAATAALFAAASw2FjASsHAAABSzswAAABAADZAAIADBlsANgabAAVAcAAAEDDCToVCxdhG3NSEAXAwAMBAABT4hvjG+Qb5RucHRa7Gxa2FmXAwQBoAAAfTBwVvRIYCBmTD5QPqhKrEgMcNhwJgl7TqIASBQLDAAATKhwVkhxicyOCAsQAABtZb3IlgsIAABqYHDwQGxSgzGVhcmNocmVzdWx0cwAAXxvyAAEAcwDzAA8AACIFCAASgAECxAAAHZAwkZN0x2tjcwUCwAAAoswbGpAbaWoQyml0aHJlZnJlc2gAACsa8gABAHcAAbYWAABSgIICRCvDKMprdACAAv1KmUcjgfMl/2sypiA2dJ5q8ot0FWt6vHBnT6ZnagX8DZ4PhR2JbiVk6eipQDvAFT05akMSyR4KJ+6IQ1INPQ4XSBFLY2NuAAD9EUAY/oBT70sTwrexBheuhnxQYo0CTwMn6PMaDEf+Z+MoKUSTPGxRJ0gcrG3NYhFPWIzHP8c2aHbMrTf+xPB+ckSPyORPqExUeE8duqKMNY0CqZ7DAAAS8QU0/SGSWl1xvUBkuY7ikDDrtV+VKXE0fs8AOzAAAAEAALcBgQAFAAAASgDbADABAABmAwAAiwMAAA4GAgD6BggAFAcCAC8HAgC1BwIABwgAABIIAAAUCAAAOwgCAEoIAABGCQAAyAkDAPEJAQD0CQAAgwoAAJwKAACUCwAAqAsAAEUMAABHDAMAVAwAAKcOAACrDgEAvA4BAMQOAgDjDgIAVhABAJ8SAgCsEgAAthIAAMESAADFEgAAyRIAANUSAQDuEgAAOBQBADwVAABDFQAASBUBAFYVAQB4FQAAfBUAAAgWBQAbFgEARBYAAGwWAABwFgEAhhYEAM8WAQDUFgAA6hYDAAoXAAATFwEAGBcAAEMXAQBKFwAAThcBAFQXAABYFwAAWhcAAG0XAQB0FwIAgBcAABgYAAC0GAQAxhgAAMkYAADYGAAA6RgCAAMZAACoGQAA1BkBAN4ZAgDuGQEADBoBABcaAwA5GgIAPhoAAEIaAABNGgAAVhoAAFwaAgBgGgAAaxoAAIwaAgCnGgAAtRoAALoaAgC/GgAAVBsDAGobAABvGwAAeRsAAIMbAQCJGwAAjRsAALgbAADWGwAA2hsAAOwbAAD+GwAAARwBAAQcAAAKHAEAEBwBABUcAAAeHAAAIBwAACwcAAAvHAAANRwAAEMcAABJHAAAaRwAAGscAABvHAAAdhwBAJccAACkHAEAqRwAALQcAADAHAAA0BwAANccAgAVAcUAABDuG3OzCxWEwAAAqWkXZQMFhcQAABG+EhG/EiHEAACwCwYZAUU7MAAAAQAAXQACAEEQAQDZEVsAIoCCAZkAAAcZdiHGAADnGLwZAEV0YXR1cwHcEgAAAYIZAAAEAsZlY2FjaGUAAKRvdW50sBsWbmFibGVkMRxjZRKAAQHMAAAciHJsoMVuYWdlcgAAVhUSgIGU0wAAHZ7FAAAbchDFcnN5bmMAADkXEoCIAcEAABiqcgCAiIOcAAAOusEAABw2wwAAGl8AQILKCwAAAacYAAASgAEBxAAAGQtwd6jfAAAoFuIA/QVAAQKZAAAPF8cAABxmwgAAGtNkcIIrCAAAAABFbnRleHSDUxUAAILlGAAAAAVhbmdlZACCwwAAHHiCURUAAAFxGgAA8oACAGhvEMpsYnVtdHJhY2tzAACQGv4nJf0tkAL+AECCGMIAABZyygAAHWrMAAAcTskAABw0hdsHAAD+HxAAAAEAZQIGAGYA0gNFApgAfAA9AQUAUQBRAJYAIcMAANcZwhoABGFyYW0AAeAAABthcwFSEAAAAAQCwAAAsWUUD7Z0aW9uYWw2F25wwMNhbnkAAIcS8gF5AwFtb3BrARgHAADzLhYAAF8AjQUygAICTO56qKjKAAAbxnJ1c3QFA8IAABQVGBb4GRnVHGJlbqHHAADTG6DFcmVzZXQAADQXUgABAsEAABvRcGtyAZ8KAAD6RxUAAGcBQQLqAGoAAQC7Ab4ACAAIABKAAYTBAAAdiXnygmxpAQF0evKAhwC2AwCQwBkJywAAGfcAOzAAAAEAACIAAgAIACEAtw4AAAUCwQAAGBYco7MSaXMjAAJjENc0MGJlAcsCAAChwwAApQsFicAAAB6CHCjIGHMdBYnAAABklA5wEHEQchBFF0YXMr4OHhhzHKHDAABGEAUCwAAAIgMWXRwVOhxpcPsCbW5xxAAARBBFEPET8hPzE/QT9RM7A8YAABLCbG9yFYLAAAAdHBwiCBDPZW1vdGVsb2Nrc2NyZWVuAAAwHAQCwWEAABRsdXJl2xIVb3JpdGXEFml2YgFoAwCJAAAJzGVmcgH2BQAAABDOcGRvd25sb2FkcXVldWUAAF4bEM1saWNzeXN0ZW1pbmZvAADsGHYBgMABQgLjAAAbhXB0oMt0YWxlY2F0YWxvZwAAXBsSgm5lAQHhAAAWFHNkoccAAMwbIgRyZXNzAP0wAAXJAAAdccUAABxCxwAAHMvhxgsAAEMMAAAx6ngWAAAbFwAAwRgAAOgYAADGGQAAaBoAAJEcAACZHAAAhh0AAJ8dAACsHQAAABAABAAAwMp1Y3Rpb255ZWFyAAC1GDHBAABwFnEWThcY2wAAwxYAQm5nAeUSAAABwxsAAHKDZXNzAgLFAAAbxGlxb3B2AIDAFgACxgAAGmVxZ+Gup2JmsMRyaXR5AABADABHbWFnZXRhZ4wfGgAAAUUcAADyA2FyeYQA4QoKAACdCgAAAQDygAIAbW81AcIAABDuG3OzCzQXNRcFA8QAAKSwGxYxHBQAHGNlcQBEaW91c4I+DAAA4QQWAACwGAAAIMDIaWVyZWRhdGUAAL8WEMVlc3VsdAAAaxYSg29hZAGQxgAAHF1yMcUAANIbrR2yHQABeAABwwAAHBpxASoIAAAAMoCEAuEAABYvZXPygMEIAwJjc/KAngChxAAAvA4UhMdlc3BvbnNlAAAUbm5lcpccjxoQxG5uZXIAALQY8gNzb24CAGlyARMPAAAA8gABAGoB9QUAAAAJAS7UVCGqcqDFcGRhdGUAAPgWAANja3MAAckAAB2iZokrFwAAglUcAACwx3JvdHRsZXIAAIwa8oACAGhpEMRsYXN0AACkGRKAAQHDAAAdjXJmAEFzghkUAAABrhsAAPow/QBQDACWKAwAAPq1EgAA8AMBAKYAbQEtAQ0COgCuAMUA+riEiPKAAgBpdAXAxABKAAAayhwX8xsUkhrEAAAA/ZTxBsgAABoxMPjsuScA4skAAB1YzAAAHDPFAAAXPAMIubpLAiro1KrCAAAZkgH0BQAA9dgJAAABAOQIFgScAxKAAYHJAAAbc3TBwQAAhRvyAACQATgBAAAApQLBAAAWgxsWhBttd0MXAEJvdwEnDAAAATgaAAAARm5naGludAHaEgAA8qsZAAAjA/KAAoJpbhDCeHQAALkO8gJjeYIAASkIAACC6xkAAPKAAgBpbAACbnSCAJcAAAqcAOIbHAAAfxwAAGYdAAABAPYDgP0AiQMCPfyJd5VlbiHDAADgEvsYIcAAALMa2BshygAAGByVHCHGAABjGiEcsccAANMYMcMAAIAWgRaxHBDGZXF1ZXN0AADNGaDGb250ZXh0AADLGfIEYWNrcwIAY3IAAYcaAADygAGEciDDYXRlAAC4DoYSccUAANYY1xjoGekZ/Bt2HXgd2P0AAKsY8gRzaW9uhAAA82EaAAAbAgIAEM5lY3Rpb25vbnJlbW90ZQAAkBzygAIBbHN08oACAGV0GMYAAOcYEMNyb20AAKoYqMcAAOUY+iPACiAAAYUSAAABHBoAAABIbnNlc3Npb24BqRgAAAEyHAAAEMNhbWUAALMYEMhvdmVpdGVtcwAA8Rs6tgIBxAAAHAltbnO4wgAAvBYQx292ZWl0ZW0AAIsaGN8AANAbEgABAcUAAB18cnQAhHIaAACozQAAohnyAmVtAgBpcwFQFQAA4XwaAAB+GwAAMCKAAgCkAAAUFGR0EMdldGl0ZW1zAACKGhDEbnRyeQAAahYQxWVsZXRlAACyGBQBxXJlYXRlAAAWcmVzdWx0zxtksRgQx2RkaXRlbXMAAIka8gNpc3T9XTEBAOG/CwAAJQwAAGHkdxcAAAYYAAAOGAAAuxgAALIbAAAzMzAY0gAAYxf6BwEAcgESDwAAAaEZAABBwwAA9hb3FpoZmxkSAWcAjMYAABukgggKAADkShIAAMQSAAChFgAAixkAAA4aAAAgAADygAIAbnShwwAAkBlRxAAAuRy6HLscvByxHfMAAmFk5+0OAABKEAAAARQAAEUVAADZFgAAbBsAAK0bAADGGwAAMzEAABQCxW9nZ2xlAAATdXRliBoWaHVmZmxl8Bttc7sWEM5pdGNoYXVkaW90cmFjawAAyBywxHZlbnQAANEZ+ieCAAFPFQAAAQAaAAC0AcAAABZyYXBwZXJeGnfXEvqmkgASgIkAlQAAFBMQxGlwdG8AACoXBIzAAAAbZGVvc2V0dGluZ3PHHBRsdW1lHhoEA8AAABhlZXB0aW1lci8cG3JlYW1xdWFsaXR5xRwbYnRpdGxldHJhY2vGHGx0dRDKYWNoZWNvbmZpZwAAXhwQzW9wbGF5c2V0dGluZ3MAAHgdBALCaW8AABdldHRpbmdzxBwUcmFjay4cc3TygXUCAGR08oAEAGFjc3YUAcFrAAAUaWRlbx0adhIU8oACAGV08oD9EAQUABDQcnlyZXN0YXJ0c3N0cmVhbQAAdx0EwMNvcnQASgAAGmVkaWFsb2FkZWRYHRdvc2l0aW9uLRwUdGF0ZVsbEMxvdmVmcm9tcXVldWUAAJYcEMpvdmVyc3RyZWFtAADvG/qdwAKKAAwCwAAAG29hZHVwY29taW5nXRwUaW91c7AYbHYU/sJheUBASAIAABlsYnVtdHJhY2tcHBN0ZW2hGRpleHRlcGlzb2RllRwUdWV1ZRwaFXJhY2tzhxoRFBDDdXNlAAAnFvKAAwBhbHIQzm5wbGF5YmFja2VuZGVkAACUHBDDZXh0AAAQFBDKb3ZlaW5xdWV1ZQAArxsQzW9jYWxtZWRpYXBhdGgAAFscEMxpZGVvc2V0dGluZ3MAAMMcBALAAAATdHVzGxofZWFtaW5ncXVhbGl0aWVziR1hchDIZWVwdGltZXIAACwcEMVzc2lvbgAAhhrygAMAZWx0GOwAAKAZEMhxcHJlc2V0cwAA7hsEAsFhAAAYaGVjb25maWdZHBlhYmlsaXRpZXNaHGNwDALTAAAaaW9zZXR0aW5nc8IcHW9wbGF5c2V0dGluZ3N2HWR08oJldP5ABUAJABDRaXRiYWNrZ3JvdW5kYXVkaW8AAHUdoMplbnRlbWl0dGVyAABcGqDDcm9yAADVEhDSdGVyYmFja2dyb3VuZGF1ZGlvAACEHfKAxQAkAAQCwmlzAAAbYmxlamVsbHlmaW5YHBppc3NzZXNzaW9uKxxhbRDJY2xlcmVwZWF0AABaGwQCwW4AAB1pZ3VyZWplbGx5ZmluwRwmcm9sbGVy6xgaGmZ0EMlsZWVwdGltZXIAAMAcENB1dG9wbGF5Y291bnRkb3duAACbHfKEbmNlbAIAYXPygAMAYW95EMtyb3VuZGFjdGlvbgAAkxwAQm5kgmgWAAAB2xsAAPKDYWNrAgBlZwwCwAAAE3lpZCocFWJ5aWRzkhxicxDFcXVldWUAAFkb8oNkZHQCAG9y8gD9X/gHAITICQAA4gwaAAA1HAAApRwAABAAMoABAskAABwZcmRwNQHDAAAWZR1zphyzHJsd8gJudAABZIOCEgAA0rcbAADWEMRtYW5kAAAmFvKBbwIAbXUQ0WJ0aXRsZXN0cmVhbWluZGV4AACIHRj1AAAPHPIEcHBlZAEAcoJmFwAA4l8cAACIHAAApR0AAEAAGMEAAIEb8gJydAEAcoJmFgAA4fQbAACdHQAAQPKAiQDygAIAdHUEicAAABJydO0bFHBwZWRXHBDCbmsAAFsa8oACAGl0EMdyb2dyZXNzAACRHMDCbmcAAIIaEMZyYXBwZXIAAEMcEMNuaXQAACkcEMZlc3Ryb3kAAL8c8gFyAwBkaXfh6RgAABcaAAASAf4bAADyhWVwb3J0/RCBAgAYyQAAQhzyBm9ncmVzcwEAcgHoGAAA45kcAACGHQAAnx0AAKwdAABAAAQCxnNpdGlvbgAAEXOuG6RpY2tzVRxtdPKAAgBvcrDIcGVyYXRpb24AAM4ZBALJcmFuc2ZlcnRvAAAUb2NhbJIdFWVtb3Rlmh1schwB5QAAG3JhbnNmZXJyaW5nhx10FhokAcZhbmFnZXIAABZyYXBwZXLZHHe/GuwbEM1zdHJhbnNmZXJyaW5nAACDHQwCwgAAFnVycmVudL4cG2Vtb3Rlc3RhdHVzkR1jcqDGaGFuZ2VkAAC9GvICZGX9RBEGAOFNFQAAtxYAABMAEMhya3BsYXllZAAArRvygIkAAENuZm8BTBUAAOH8GQAAeR0AADAARG5kZWQBZRYAAPMUGgAAQAJAABDDeXBlAADrGyIFbnRleHQCAIkAABtYaXSCZBcAAAGYHQAAEMpwYWJpbGl0aWVzAACAG/KAiQD6I/0U0QMAANLLGAAAsBjRAADOG/IIYnVtdHJhY2sBAHIAAVwcAAAQzGxlbWVkaWF0eXBlcwAArBvygAIAYmwAAP7ARVIDwCQGPHvIKVkPZ2PWNhu+JvT4fnCdo34i6ZtaTRO+jRz9A9/xd6McZ307mmBTQ+osKNAvK+hP+Y8WqlTgVCK3yQAAHBcWptYmoYTwBQAA5vcJAADqCwAAsBIAAN4TAAARFAAApxYAAL8bAAABAAAQENJlbW90ZXZvbHVtZWNvbnRyb2wAAGQd8gRmb3JtAQByASQMAAAA8IACAQACmnR5bpQAAIH9EAFIAHa7dNQXzAAAGsScygAAGmUA8qQOAADzCxWBwgAAFPIb3RlhxwAAxxrIGskaVR1WHVcdqNwAAJoKEoCEgsAAABccAEN0ZW0BIwwAAAGxEgAAYgADAKAAAAXvZ2luATcBAAAAEM9rY3VycmVudGVwaXNvZGUAAKsbtgGAAsAEYsYAAB10DUb0trpjbgFA5f8HAABUEgAA8xgAAE8aAAB2GgAArBsAADAAEFHGAACeG58boBuhG5YdAUE7MAAAAQAA3AACAEIIAAC4CtsAAQADwAAAGjLFAAAcD8wAAB2eYXJ26BMKAACvCgAAURIAAGYXAAA9HAAAVxwAAF8cAACIHAAApR0AACABEEAABQLAAAAWShcR0AtiaRuDxAAAFo5LA8gAABdsxwAAFwNpb3R1AcAAAFNTEFQQHBQdFB4UbdQJ1QnWCdcJiBWJFVobKwLgAAAaWGt0BQLAAAARDRxTCw8MD84ZzxnQGXJ0ocQAAPAYqMsAAA4UKjQCAIkAAA8RaXSCJwgAAOc9EgAAOxUAANoYAAClGgAAShsAAG4bAAD6GwAAix0AADAAMAAEAsJzdAAAHmlicmFyeXNldHRpbmdzjxwfdHJlYW1pbmdxdWFsaXR5vRxsc/KAjIIAR292b2x1bWUBFRoAAAG1HAAA8gNlbnQBAHQBmQoAAAFLGgAAcgACAskAABvVY3NhbQDtWBcAAN4ZAABNGgAAVhoAAF4aAAC8GgAAhBsAAIkbAADWGwAAChwAABAcAABDHAAAdhwAANccAAAAAAAAAQASAEJsZYTECQAA884WAACcBT4AEoABAcMAABLdcGJRyQAAnBmdGZ4ZnxluHQAHYXZvcml0ZQAB4AAAHExzAWIXAAAAGgkBwAZAwwAAHDvKAAAdhccAAB2OZgDzKhYAAIkDEQISgIQBwwAAHBtw8gABAWRsAAHrBQAAEMprZW5kcmVhc29uAACoGPIA/RBgAf0NgQIAOzAAAAEAAAMCGwCKAwAA4wcAAKkLAACqDgAAsg4BALUOAAD3DgcABg8DAEQSAABdEgIA0RIAAN4SAAAOFAEAPxQBAEUU6wCDFQEAHxYBADUWAABjFgAAxxYAAAIYAQAXGAAALBh1AOAYAgBIGwAA6xsAANwcdQAFwMEOQAAAHQEcHQIcHQMcHTYcJQHBAAAU8BZnPheOHAUCwAAAIIwVfxcjWhBlG2V0IccAAIAbWhwABGxvYWQAAcQAABuqc4KXCgAAAdocAAAQxndubG9hZAAAKRcQxG91bnQAACUW8gACAGNvhCMIAADh6A4AAKQbAABA8gJzZQEAZOEXBwAA1wcAAAMBJxYAABDCdWYAAAcK8gFoAQBi4YIDAADsBQAAAurODgAAQhQAACQWAAAsFgAAGRgAAAUZAACxGQAA0xoAAJYbAABbHAAAohwAAEAAAAAAABDDYXRoAAAkFhDCcmQAAFEQBALAAAARcA0UGG50YWluZXJzgRptb9KDaWFs/RTAAACWAAAOtwQCwWUAABRldmVsDBQXdWV1ZWRvcKcYbHEEAsAAAMAND8lkZXhudW1iZXJUG2RusNBhY2tkcm9waW1hZ2V0YWdzAABVHfKBdAIAYmnyAAEAbgABKgoAAABAAcMJAADzOhUAANEBVgTyAAEAcwABUhAAABDHbGVscmFjZQAAthYygAIBwQAAEDxsbWLygP0RAAYAEMxheWxvYWRzdHJpbmcAAKobGOQAAGQW8gNvb2uEAAABeBoAAPKBYwIAaHASAIQBwQAADFRmAAGFAwAA8oD9ACBNwgNAAAD9EUkJwS/OI6BpXwBALN0BIVn/1+iwfT3yuo9h7fctvwgeNjfAEwuf7D7JAAAZ2GZxdNenHdQfHtnMAAAbgnmu8aF7OTgliRAXg5Fv0W08T/6xXtOBKFpPN1r3yAY+iEOSADswAAABAAADARcAMgEAADgBAAAaAgAAcgMAAHUDAQCGAwAAjAMAAAMEAgACBggAEQbbAPkJAACnCwAAygsBANYLAQANFAAAExQAAEQVAACnGAAArRkAAIUbAwDnGwEA6hsAAFEcAQBFgsEAABGcChscfxyAHGYdGwLGAAAY8250ocIAALwOAUw7MAAAAQAAbAABANgabAD7AmFyKwLEAAAchHByIcQAABUatRwVgsUAABRTG2kb8wCC4hcIAAABGgAAeh0AAAMA8wEBc4MOBwAAwcsAAHwaJQHCAAAQ8RtzCg+LGisCxAAAG69udNDDaWV3AAAeDBKAAQM05kueUMQAABvv4AAAFkB2YXN50gABA0bFXB+1cmZpcAAB0wkAAPKAgoxhwAAAPgw/DAQWBRYGFrAYCQJmXHzCY+AAABtzY3MFAsMAABk1HBqlHG12BQLBAAAT0xYSMhZwdIcBgcAGaMgAABqjxgAAGMfLAAAcABUBwAAAtrkZeOIHEMJ1dAAAIghWAAAB/RQQAsIAABc8wAAAGlHSAAAdj3AA9L4JAAD4ADkIdweWDIABwAZtwAAAB/3gAAADfTNuTtTMGmuw31d0AQICaFlG9vtmyWRXjGRyAVUMAABV/cMICQIAABuxHBTnGReLG61xHMMYxBisGi4cyBwFAsAAABKnGRmiGGNtscQAALkLFQHDAACkthlirxKxygAAqBoxxAAAMRUyFW4alwEB/QoBB88AAB2CwwAAFTPEAAAWNwH8BwAAsMJlcgAAFAe2CwAB/TEBB362etYl4AAAF35oAPODFgAAggH2AQUCwAAAGYIbFdccYXI1AsAAABHMHBI5F2F5RAgcGB0YAQEBZYs0cKBtOzAAAAEAANwAAgAICAAASwjbACuKwwAAHJgFicAAACIJGaQZG4EcJYTDAAAUqRyrGpodRwAAwgRTxAAAFjbNAAAco8MAABt8zgAAHM3FAAAcCvS+DgAAlwjJAFUEBQLAAAAVbRwYExxidCsC4QAADuZkcxuKywAAG0hRwQAAoxbIGK4Zcx1/HRDEaW5hbAAAvguWAIAB/RAwBMAAABYrwQAADqQnRu21aWcY1gAAvQv6OoQAAOOfEgAA1BYAAD4aAAAgHAAAIAASAILAAkggoAtzD8YAABi5wwAADsIBNgEAAPLPCQAAggYAAAL+wAW/XxinKGbzGLz0qfVExLeIh8QAAA6mMFA39L4Z3PNfuicZ1EvYOXiT6b5mJmmQJcUAABnlwQAAGAXEAAAbpABpuex7bK41vZ8G9KDqCMYAABmXXG2PRTR+hf35jBe0dDa+ZGk7MAAAAQAA3AACAAUAAABKANsAOjAAAAEAAAAAACMAEAAAAPoG+wb8Bv0G/gb/BgAHAQcCB7UHtge3B5QLVAyrDqwOvA69DsES1RLWEkgVSRVEFnAWcRYKF04XVBd2F9gYAxm1Go0bERykHDHIAADSG60dsh0BAgLAAAAaMsUAABwPYXLmEwoAAFESAABmFwAAVxwAAF8cAACIHAAApR0AACAQQAAhyQAAyBhzHWHIAADHGsgayRpVHVYdVx1BwAAAQRUZFm0WXBexwQAANhfyAm9uAAJhcwEhCAAAABKAhALGAAAXTc4AABx8cnYARHRpb26CCw8AAIPOGQAAEoCBAcIAABpLYwBJaXZlc3RyZWFthJwZAAABbh0AAPIAAQBsAesFAAAAMgACiMQAABLdbnIA9OMHAAD7CukDUAF2BwAC/gBApgwO8Hu2OHla9VAeygAAHBZldADrOAEAAHIDAACGAwAAAwQAAAIGAACnCwAAygsAANcLAAATFAAApxgAAK0ZAADqGwAAAAKAEAAAwcMAALUYocUAAPgWFQHCAAAWyht27hYVhMQAABSXHI8a+wJwdUvAAErCAAAckMoAABzbK4PBAAAUKgXAwAgkAAAUjR0WkBwkCRmkGVHAAADmE+cTGRQaFK4bpQHFAAAW2Bx3uhpbA8gAABtGYWVzJQHCAAAQ7hhzEhfcGyUBwAAAGO0Zc3oZiRwFAsAAALA2FxHAEmxyFQHDAADA8hJzxBo1AcEAABYQHHdtF24XqBmhygAAeRzzggJjZTOBA3SVzsRkaXN0ENBkZW9wbGF5YmFja2VuZGVkAABUHFKAhIPDAAAakaUCxAAAFoMbFoQbbXdDFwBLYXliYWNrZW5kZWQBFBoAAAGUHAAA8oABiGyhxAAAThtx0AAAph2nHagdqR2qHbMdtR3zAAJzdvczEAAA4gYiA2EA9AEsAOsApAHCbmUAAKllcG9zaXRvcnnjGHLBCfKAhAF5AQGCRJWneAwBPRYAAAWDwAAAE7YOonkcAUc7MAAAAQAAgAACAEEWAACCF38AFYTAAAAZRxtBFyHCAAC4GVMb+wJkdAFDOzAAAAEAAH8AAQBTEX8AZg4AAP4AQqgq4AAADsPJAAAcqsIAABkDATUBAAA7MAAAAQAAhQAEAH8DAQD3BgAAMgd/ABQPAgBFAsUAABh9HRavHWRuIxckFyUXoBqhxQAA/BYVAccAABCZHXNSGhDDb2NrAAC8C/IAAQBsAAERCAAAdgGAgv2BQAjEAAAbvAAA/gAFIggPA2BlcjpicS2rNyVtk5MZeMIRf1praUvt2BZJuIVScwRKvlNDYfb+r0R5L9yJcK2QFxcXnlJpMTF+mrUDWG6NgW5GdNXlYD4C5L7sHMnHeTdFiyFm/FcruaWrU/uzEVJ7rEY26jBhZGZnaWttbm9yc3R1eQA6MAAAAQAAAAAAkQAQAAAAIQgnCCgIOAhDCM0JzgkQCqMKpAqlCqYKogujC6QLrwv3C/gL+Qv6C/sL/Av9C/4L/wsADAEMAgwDDAQMCwwoDCkMKgwrDCwMLQwuDC8MMAwxDDIMMww0DDUMNgw3DDgMOQw6DDsMPAw9DKIOow7xDgsPDA8TDz0QPhBaENgRPRI+Ej8SQBKzErQStRLhEuoT6xM2FDUVNhU7FTwWpBalFqYW4xbkFuUW5hYDF0cXTBdTF2wXDBgSGKgYqRi5GMoY0xjUGNUY2RjaGPkY+hivGc4ZzxnQGeYZ6hkJGhIaMRpIGmEabRqGGqUasRrEGsYaShtLG0wbTRtlG2gbaRtuG3IbuRvMG80b8xv6GxYcKxwtHDIcRhx8HH4ckxzbHF4dix2gHQFA5fsHAADaDgAAkRIAAGcVAACLFQAApxwAAAAFACv9ECBBwwAAEsHKAAAaU8MAABjAEgAAhsIAABZkAQQAAAABeBoAADUBwgAAEEUacy0WDBcNFwUCwAAAMSsUyBaNHRZ0HGV0EMVlYXJjaAAAKBegyWVwb3NpdG9yeQAAyRkEAsAAABlhdmFpbGFibGWpGxJlbdQSc3QQx2V0aXRlbXMAABMaEMxvbmZpcm13aW5kb3cAAA4c8gNpbmX9RAEDAOEGCgAAkwoAAAMAwMpjaWFscmF0aW5nAACjGHIAAgLFAAAW72lsc3SCMwEAANJiGgAA5TKAAQPMAAAcE8MAABA/ZmlvdCHFAAB3FuIWBQLAAAAUZhUx1xvPHGAdY3MUAsVlcnZlZAAAF3VyYXRpb24SGhNpbWVLFWR0HQy2AIAB/REDAMUAABplwgAAEt1zAAD+gAipDv5Ad1ZxRLXlAM9RjxEPtGj7iw6dakJhsmll6BkVZTCkfmqEdW4KiBR2qd0/HDZ/DMJAGtQI4J4KE58MyDA/AJX4DOLJpn5Jd7H1vsQAABY5GcQewZdRPhlYlD96R9lXS6g9B4otrCZXKWqaAPO+cgd1vVbgAAABLkck8yy5ADswAAABAABuAioAAwAAAHoDAAB8AwAA6ATbAPMGAwAjBwEAOQgAABgKAAChCgAArgoAANULAAC0DdsAWBABAFMRfwDfEgAAPRUAAEwVAABkFgAA7xYAAPQWAAARFwAAKhcAAFkXAADSGAAA7BgAAPwZAwAdGgAArRoAAE4bAQCPGwAA3xsAAAYcAAAfHAAArBwAALkcAwB1HQAAeR0AAIQdAACmHQQAsR0AALMdAAC1HQAAVYLAAADCtRgsAYcSiBKJEooSFcHEBAgAABcLGR6IHB1OHK8bocwAAF8bJQHBAAAQ7hhzEhfcG5MA/RAoAsEAAA6ZwgAAChn40gsAAC4DIgUIABYBHgBnA5YBFQHCAAAT3xhkQxUFAsAAABg6HKP4FmNkAUPjCBYAAM8WAAATFwAAVBsAAEETAERhbHVlomASAAAAoMZhY2tlbmQAANIS8gFsAgBidgGBAwAAAPYAgAHEAErUAAAdsGxAymxheWluZ2l0ZW0AAPYW9xaaGZsZEgABAeAAAAoZcG4A8icMAAARDgQDwAAAHnNsZWVwdGltZXJzdG9w6hu0b3VuZLkLGm1wbGVtZW50ZWSiGGVmacX9w1IBAgAAFcIZFW8aFo8bE1kXF90b2hMQzGJ0aXRsZXN0cmVhbQAAWRrygJABdCHDAAAJGaQZoMVvbHVtZQAAmBkEAsAAABFstRYWZXJuYW1lgBpycwQCwmVmAAATdWZzVxoTZWFrWBpscBDKaWx0ZXJlbnRyeQAAKBwQw3VybAAAJxf6pP4ACoAMAAiAhAGJAAAWI+AAABzVZPKAAgBzevICYWyEAIIfCAAAAPKAAQFtbKQBwAAAFGFsdWW2DnZ/AxKAggLAAAAIEcQAAByQY3L2AYDCAOQBwwAAHY1mBYfAAAATtg6ieRyvgB0FAsAAABQoF6pcG2V0AUDn/hUAAHsaAACaGwAAQBwAAGQcAACCHAAAcB0AAJUdAAAwMAAAIwACwgAAErppc/L1CQAA0QgBQjswAAABAAB/AAEAUxF/ABvAAMTIAAAbqcEAABLUAUA7MAAAAQAAhQAGABYKAABWDH8Aww4AABQWAQAcFwAA3BgAAAUCwgAAFhscVp4bnxugG6Eblh1ucMHEAACFG0IGcGlzb2RlAAPHAAAdo8UAAB1pYWZwgiEWAADUGBwAAFgl3fomgpDhfgMAAOkFAAAB4g0MAAAQFAAAPRYAACAAsMJybAAAkAoQynJhY2tsb2FkZWQAAPUW8gACAHR1oM0CAAAAAUU7MAAAAQAAgAACAEEWAACCF38AKwLCAAAYBXJ1OP0AANESHxYgFhSBxnJhcHBlcgAAEnJnDRy8GhDFYW5kbGUAAFoa8gR0YXRlAgBodwFKFQAAATwaAACgxHJyb3IAAEgVcgNvcmsDAcUAABwzZXN0ZuEBCgAAjgoAAEHy5RkAAMoD8gABiHcAAbQLAAAEAsJkcwAANWVhdXRozxLQEh4WunJhbnNjb2RpbmcPGnJ0AAD9EAA0/gBT6gkZaa2YRycEkF7LDqmP73FoXM/fmEhh1TGkMMDcBbrGAAAaE1eIst04V9SjWMRQ2lh738kAAByqyAAAGtMKlmN4FRlNRpKUbY9vG54AOzAAAAEAAAMBCQA1AQAAfwMBAPcGAAAyB38ASAl1AMEJAQAGCgAAkwoDABQPAgAFAsAAABNKFh2vHHR1IccAAF8drh3zAAJkc+LhCQAAvgsAADYXAAAAIAkBD1ncUXFsYcAAAFoKWwpHEvgZEhwxHBMCwAQh2AAAHbDLAAAb3oI5CQAAMcMAAF0XXxe9GRIDaXZlAALDAAAcBnN2hxgIAAAAIcAAADcU+BjBxwAAuxgYxwAAtQ4AR2F0YXR5cGU7MAAAAQAAdgACAGMWAAAsGHUAOzAAAAEAAHUAAQDcHHUA8oACAGR0ug8BwABoxgAAHNZkltMFAAA7MAAAAQAAPAAUAKULAQCQDgIA8g4EADIQAABiEA0AVRIAAOgSBADZEwAABhQAACQUBQAsFAgAYRUAABgWAAC8FgIAGhgBALMYAADWGQAANRoBAIAaAAB4GwAAdgaAAv1GiQHDAAAcFG54Xia3wwAAGfvAAAASwG10AACb8AAAAAAAAAEAAAAAANh6XwJ4hTxtjlhjRVAuBcyzxM55CEabzhZJuIVSLFqAW21ZgD5Gnxg2IbWjV3FLTMFIKzM6Ix0XXzEuEktGNPcIoficummAYB23WzcmwSxKLvUULkGy7KhgOXCm+jQ3/uGyLjyOnn3lL+SdwIMAOjAAAAEAAAAAAMkAEAAAAAIANwHKAu0ChQMABAEEAgTPBesFywf7ByEIJwgoCDgIQwg3CTgJzQnOCRAKGQqjCqQKpQqmCqILowukC68L9wv4C/kL+gv7C/wL/Qv+C/8LAAwBDAIMAwwEDAsMFQwWDCgMKQwqDCsMLAwtDC4MLwwwDDEMMgwzDDQMNQw2DDcMOAw5DDoMOww8DD0MmQ6iDqMOpg7aDvEOCw8MDxMPkw+UDz0QPhBaEHMQ2BE9Ej4SPxJAEpESqhKrErMStBK1Er8S4BLhEuoT6xM2FDUVNhU7FWcVaBVpFWoVaxVsFYsVPBZJFo8WkBakFqUWphbjFuQW5RbmFgMXRxdMF1MXbBcMGBIYqBipGLkYyhjTGNQY1RjZGNoY+Rj6GPsYrxnOGc8Z0BnmGeoZCRoSGjEaSBphGm0aeRp6GoYapRqxGrMaxBrGGkobSxtMG00bZRtoG2kbbhtyG7kbzBvNG9gb8xv6GwMcFhwrHC0cMBwyHDYcRhxYHG0cfBx+HJMcphynHK0csxzBHNEc0hzbHF4dix2bHaAdJQHDAAAXoB1w1xbNGiHEAABjF7caFQHLAAAQtxxzsRrTgcACUs8AAB1kFQHEAACn0xtyxRwTgYPBAAAclgFD4fIOAABiEAAATaUBwgAAq9EcZlwQIcUAAH0Z1hr7hmHHAACPFpAWeRp6GtEc0hwlAcMAABT4GG6WFgcZBQLAAAAiYgq3ChcdHGJvQcIAAFISUxLnEhcXAUQ7MAAAAQAAbwABAI4VbwDzgQJtdQEBAXv9TDOHZuelEgAAGRcAAPUZAACSGwAAohsAANMcAACTHQAAlx0AAEATEADByQAA/xikAsAAABR1YXJkUBATYWZltA5ncxIHAECE0wcAAAETDAAA8gACAGR4APOIGgAA3gDzARIAgoHDAAAbcgHMAgAAOzAAAAEAAN0AAwCWDgAA/w4AAHUQ2wAQwmljAAARB/KAAgJzdG5yFQLAAAAw6QvHGWMdIPkGtAdzeXYWBQLAAAATQRcbcxtucgUDwAAAoD0cI2ocqBwT2hhkb3IFg8EAABM8EBAqChQBwAAANmFja2VuZE4QTxALFGLLArYoAAH9GVkNywAAG17DAAAXHAOrVeV6zgAAHILMAAAcRc4AABwcdgDyGgIAAPMRCYITNIdNgzUCxAAAUqMWyBiuGXMdfx0W1xxpd7UajRukHBDEbmRlZAAAUxvyBXNzaW9uggAAAWkbAADyAIIAARcIAADhARoAAHodAAAw+gMBAHODDgcAAADAy2xheWxpc3RpdGVtAAB8GgACZW0AAeAAABvxcwEKDwAAAYsaAAASgAEBxAAAG690bjIAAgFGxVwftWlwZgAB0wkAAPKAhgAFAskAABSSHRWaHWxyFQHCAAAbhx10FholAcYAABbZHHe/GuwbBQLCAAAWvhwbkR1jcqHGAAC9GqDBcwAAvwl+BwMB/UQRBswAAB2DbIPQBQAA6AwMAABPDAAAUxAAABwUAABNFQAAhxUAADMWAAC3FgAA/hYAAAASEAMg8oACAmR2bnQVAcAAABYrHHNZChsC0QAAHZ5zdAUCwAAAVgUaBhoHGggaex0X1RthbMHGAAC/FkHAAAAUDPkZ+hn9G/MAAXM7MAAAAQAAMQAGAGMKHQDbCw0A6RMAACAXAACOGQEAohkBABDLcm9ydXNlcmRhdGEAAJcZdQHAAAAS0Rtr7xjZGUMaRBr/G70czRwjhQLBAAAdiWl58wABcfIDFgAAWgYhwQAA9BbsGKHRAACAHRDPcm9ncmVzc2ZyYWN0aW9uAADNG9YBAAH9eAEAwQAAHYjFAAAcNXAAAb8SAAAMAsIAABZyb21leHS0FsN5cGUGD2Z0BPzADwAAABBfFhBgFhBhFhBiFgT8wP8DAAAQVRYQVhYQVxYQWBYQWRYQWhYQWxYQXBYQXRYQXhYE/MD+AwAAEEwWEE0WEE4WEE8WEFAWEFEWEFIWEFMWEFQW8oD8BwAAGrMCAKAAABBJMHMig3JhdIYAoAAACo32HoDAECb9HAgGwwAAEDZoeI0n9gUCwAAAGfIbELQWcHQF/cCRQUEAACRVGmEdoy4QHnUcY8kWyhbLFswWPxdAFxdLGhVsFsIGDwUCxQAAGGYcFV8aZHLBxgAAuxgbA8UAAB2g0AAAHXFsb3IlAcEAABFRGm22CmYaKwLDAAAWyGZ1BQLAAAAQohgV9BlkcCMAA8sAAByNxQAAG0ZhZXMBShoAABUBxAAAELccc7Ea8wCBgqMSAAD7AmR0BYrAAAAz4xI6FjsWEnoZocUAAHQWGwIeGqdfMGhvpYHEAAARphlfFRUBxQAAE4kcbEkbAENhZ2WJhAoAAIKEFgAA8gABAnNldADyNxQAAMEEAQABBKwkXIV0gucJAAAFAsAAABXWHBXdGXN4EMxlYXJjaHJlc3VsdHMAAKgbGOQAALMWFITEZWRpYQAAE3RlbWEXHRYQyHNwbGF5aW5nAAAOGvKAAwBpbXb6hwIAZHNWAQAB/RQQBgobZfSxYLeqa6PEAAAapGcA9zgUAAABAI0EpQHBAQMAkQABRzswAAABAADsAAMAYxYAACwYdQDcHHUAIcMAAFscohwIFQAB4AAAG6xz4bIOAABdEgAAEgAABHJlYW0BAKEAABaxcwHOEgAAAbkaAACgwWQAACoYEgR1cmNlAgChAAAWr2lzhMoSAAABuBoAALjDAADgGKQBxmFuYWdlcgAAFnJhcHBlctgcd7oa8oRzaW9uAgBtdDQBw3ZlcgAAFnJhcHBlclYad8kSGxYcFvKAAgByc/KAAwBlb3QY+wAAYBcQxWFsdXJsAAApGABDZGVkgscSAADSyhwAAI7ygW8CAGFjoMNpbmQAALAOCFqDrQ4AAPRhFwAAvgDUAb8CIoACAKEAAAwbZHT2CwFh/QANBwLFAAAcOWNwh8wHAADTHRYAAGCgMoCEA8UAABi/wgAADrVkb3QQzWluZ2Z1bGNvbnRlbnQAAMgZEoABAsUAAB1jyQAAG7ludHUAAP0JAAP+gEl+Ig7MiDl+Fyz9VHA+6+1YpGn9WZOJzQAAHHRC70ShwcUAABa0FpW0LbZQE+y162tavoL9BekmDqIhwyCS8woeq/jVa9hViVFzi9pBXE+UFoz2ADswAAABAAB4AC0AiAMBANMFFQDpCQcADQoAAKULAQCtCwAABgwAAJAOAgDyDgQAMhAAAGIQDQBVEgAAmRIDAOgSBADZEwAA6BMAAAYUAAAkFAUALBQIAEsVAABhFQAAGBYAALMWAAC8FgIAxRYBANcWAAAeFwAAPBcAABoYAQCzGAAA0RgAAJIZAACYGQEA1hkAABUaAAAeGgAANRoBAIAaAACbGgAAzRoBAHgbAADIGwAAmBwAALUcAADJHAAAAYIBD1ncUXFsAUHjCBYAAM8WAAATFwAAVBsAAEETFAHAAAAQFggwCwcUAcAAABAVCDAKBzqc/BYBAKAAAAcMoAAABw1yAAEDwwAAGaVwZW55AAEeBwAACYIc0pybjwUDxgAAFhIcJbAathwYfRxlc3QVAcAAABCeEnNZEhDSYmVydW5zY2hlZHVsZWRwYXNzAACOHKDEaWR0aAAABA8Qym5jYXR0ZW1wdHMAAMcZENRwcG9ydGVkYXVkaW9jaGFubmVscwAAcB0Q02FsbGVkcmVzdW1lYXR0ZW1wdHMAAGMdEMd6ZWJ5dGVzAAAmF/KA/QABTAAARmV0cmllc4MIFAAAAbYaAAAY4QAAzBuwxWVpZ2h0AABaEgBHcGlzb2RlcwEaFgAAARccAAAACW9uY3VycmVudAABxwAAHX1kgyMXAAABoBoAABDGaXRyYXRlAAAHFABMdWRpb2NoYW5uZWxzAXsaAAABghwAAPIA/ZeAEwAAAb4SAAABAgLCAAAZehmW+B5IbHMB6BMAACuGwAAADqcgyGhxdWFsaXR5AABHFa4WUgABAuAAAByvY3N0AAGmGQAABALDdGVyAAASYWNZEhNhbWUGFG1uAUfhHxoAAEUcAACw8wCE4goKAACdCgAARhsAAAEA0cIAAHAVNcLEAkAAACUiHNQcJHMX5hsW3hlbEDoUOxT7AmNpAEhlYWNoYWJsZQGWGQAAAX4dAACgxWxheWVkAABFFfKBbgIAcHIARXluY2VkAQUUAAABbRsAABDFb3J0ZWQAAK0WAEZjaGFibGUBIhcAAAFnHQAA+qYCAGFwAERheWVk4UoQAAABFAAAM9JsGwAAQRKAAQHHAAAbw2xyAEVvcml0ZYSpFgAA4swYAAChHAAAWh0AADAAAENsZWQBABQAAAG0GQAA+psCAGl2BALGd25sb2FkAAAYb21wbGV0ZWRTHBVhaWxlZKcbY2YQxmZlcnJlZAAAqBbygIoAAEhvbXBsZXRlZAEhFwAAAXEbAADygP0sgAsA8oABAmt0eTHDAAC+Dh4YcxwQym93bmxvYWRyb3cAAJUZ8gABAWRoATIBAAAB+QkAAKUCwgAAFLoYRi4aLxowGqscY3ORD/oMAIQBzwUAAII3CQAA9j8A/QCBZ8AaBTCgVpcRX/JQSTYAgtsJAAAAAP0TwQjCzvdbEu2wEymDFu1JQJCTOIk5ULXoZgMbWUuVNSeHwnJJnZGpWBU3KuV4E+OV4qRizXAv+jR13SxOAInETkFu3z2pf0FmoddrT5X6Jwl+oA0tlxRIlFLWAFJ38/xxjr4TiMQAABwOADswAAABAAAfAj4AdAMAAAgE3wDEBQMA7QYEACYHBAALCAMAFwoAAAcMAwAjDAEA2AzbAK0OAgABDwAACg8AABIPAAA1EAAAOBABAEMQAABSEAAAUhEAALESAAC6EgEAzhIAANQSAAD4EgAAIxQAAEIVAABQFQAAWBUFAF8VAQCGFQAAchYAAIIWAAD2FgEADhcAAGEXAAAfGAEAqhgAAJoZBQChGQAAqRkAALAZAADFGQAA8xkAAEEaAABUGgAAWRoAAHwaAwCLGgAAlRoBAKMaAACyGgAAuRoAANcaAABkGwAAfhsAAO8bAAAIHAAANxwBALIcAABpHQAAbh0AAHcdAAChxQAAdBcVgcAAABQWGMsOBQPAAAAcZBq4qBoT2xhhY3MFAsAAABG2FsOjGGN0+5MFAsEAABZ4GhEkFm50IcEAACcMOBoFAsAAABFVEhdfF210+4kFAsIAABXFGxR0G2NzFQHBAAB0ph2nHagdqR2qHbMdtR12lAsFAsEAABBIEBLFEmV0+4yhyAAAexcViMEAAB+vHeEbYwADxQAAFnZhZXMB8gYAAAXAw4BgAAASyxsVwBklwRnMGjHFAAAMHCccXR0AQXQBvgkAAAHvEgAA8gCQAAF8AwAAAAUCwAAAEk4aEb4ZaXQFAsAAABABHCO/GbQacnZTggPIAAAcrmVmdPIAAAFkAcsHAAABcxAAABKAAQHGAAAaT250EMRpbGVzAAADDxDCaXIAAIMKENV2YWlsYWJsZXZhYXBpZGVjb2RlcnMAAHQd9gsA/SlBAMABSMQAABj7AOMoCgAARhoAAFwbAABoHAAAEBAAR2V0YWRhdGEBVRoAAAFhHQAAEgRyZWVuAQHMAAAdjW1wAAEwHAAAAEJmZQECDwAAAVUQAADygAIAYWMSAAECxwAAF2vIAAAY7XNyd4LNBQAA4TQIAAC8CwAAECHIAABbHKIcBQLAAAAQ0BgWgRxibxDJYXRpdmVzZWVrAABfF7IBbAH9CBEKwwAAHG/FAAAdasEAABgpboMHBwAA8kkaAABJA/KAAgBhawWMwAAAF0UbNOoW6xYCHBUBxAAAFnwbchEXZQHBAAAQhR1zIxwkHCUcJhxbHaEdKwLFAAAcXXByBQLAAAARgxYQlRlvdyHEAABrFukW+4oVAsYAACR7G1IdFgscc3daF0HIAADDGU8cUBykHaHEAAAPFwECAk6ne8mlxQAAGrdhcgGCFgAAAIABAaEAAAn/wAAAFT1nZlKAAQLmAAAT325kdBDNZWRlbnRpYWxzZmlsZQAA6RsSgAGIxgAAHFNyEMVkcGxheQAApxYSgAECwgAAFw7CAAAaaW5scwAAA/6ADsQfFSK/K2c3sT+CLnvWG/fmaGAVGA9Mvwuy9ApxWMiIxAAAG6cXFnSoicMAABtebloaU+szH4fwX8IAABU+JOKN1G5ZctZYM1Ms8ju6YWNpg8oFAAA6MAAAAQAAAAAAGAAQAAAAlwqYCvEL8gvzC/QL9Qv2CzgWKRdLF3sXfBeBFw0YvxjHGPQY9RgLGS0aahp2G4Ec2hwSgAEB4AAABwNkdHQQgP1FAAj9AGAVABMdiQAAA31MPvI2XMAAABmtwAAAEFHAAAAXfiohxwAAWxyiHKDLbnN5bmNwbGF5ZXIAAAwaEMh5bmNncm91cAAARBWgzGV0c3luY2dyb3VwcwAAURug0Glzc29sdmVzeW5jZ3JvdXAAAFEcoM5yZWF0ZXN5bmNncm91cAAA5xvyAP1MAAoAAPPUGgAAcAE3APKAAYJzCYIGbtkL5gFC5wkIAAAoCQAARBAAAPETAADxFgAAJBgAAPYYAABnGwAAFBQUADWCwAAAErkOKQjrGewZi/0RAELKAAAaxOAAAAflyAAAGOxRyQAAmhubG5wbnRuVHSHBAABfHa4dBALAAAAQIBcSZm/0FmRu8gV0cmVhbYQAAOGcGQAAbh0AADAQxmhhbm5lbAAAxhL6mwICY3NydAUDwAAAE9UbENUcESMWY2RpBcDBAEYAABjxGxOzGBUJHLHDAAC8FsUCwAAAF9AbFnwdcnRyGqHBAACiGfMCAmlz4lAVAAB8GgAAfhsAAAMAKwLkAAAUFGR0FQHFAAAWzxtksRgQxnB0aW9ucwAAGRZWNACI/V0xAcYAABqJxAAAGLLDAAAWasYAABqKxgAAGosA5r8LAAAlDAAAdxcAAAYYAAAOGAAAuxgAALIbAABhMzMw8oABgnQQxHl0ZXMAAP8T8gJpdAEAYoLJBwAA9tkOAABFAbcGAQCfBAIAEMJleQAAfxvyBGluZ3MBAGsBXhcAANKPHAAAFBDHZXR0aW5ncwAAphvygmV0AgBzdBDDYW1lAAAYFrDBZAAAVhIQyG9sZHNpdGVtAABUGhjEAAClGxDSeGNsdXNpb25jYW5kaWRhdGVzAACZHfqFAgBlc/IA/cAhAgDh/gkAAIEKAAAB80kSAAD2A34FoMVhaWxlZAAAkxkSAmVzAQHFAAAcZ2Z0AOScFgAABRwAAIocAACgHAAAqx0AADAQAPKCYXICAGl5EgABAcAAABjycmwAARYWAAD2JYDBAUL9HCCEzAAAHK0IFqozfyGDT51EFZDAAAAXwhq3DgUDwAAA0s4OFssYFkUaYWxyIcQAANQWIBwxwgAAiBpmG1kdFYLBAAARohhKGisCxgAAG6xkbluawQAAFjMJAtcAAB2wwgAAFrZpcgUCwQAAGG0cE68cY20ricUAABxsFQLDAAAXkBwzAhgDGEgbb3QDF6HFAABcG1sDwgAAEjdhb3S1/sAAQcAVAAAk8BIHHBNSERTxEhvOGh3ZGxPJGFvPGtAa0RrSGoodLwcrAsoAABw6bnIYyAAAVRIEAsJpbAAAEEgQEmVyxRJldPocAgBmbgHIBwAA9uMSAAApASoCBAABAKIFEoCCgcoAAB10AUA7MAAAAQAAXQACAEEQAQDZEVsANQHCAAAQ+BhzNRo2Gngb8gAAAmF0AcoCAAABmQ4AAAAAAv7Aj/YHemASqydQPxqmtib9A8WvwwAAEuJB0gfDN1P0dQEFJ6IlpmYO1Wca+MYAABxYXNSkEbokFdPyKzfuh5j1WxRv7M5eKLR5WQtm+e41ObQs8msPsvY24V5RrdCVbnYAOjAAAAEAAAAAAEkAEAAAAHcDeAN5A/cF+AX3B/gH+Qf6B/4HOQk6CUUJxAnFCcYJxwlXClgKpwqoCqkKqgqrCqwKrQrRC1UMlA48EEgQcBBxEHIQTxJQEhsUDhYPFhAWERYtFkgWgBaBFpYWuxbOFtIW2BYMFw0XIhdFF0YXURdSF/IYBxmWGcIZWhpQG6kb6RvwG/sbPBxqHKgcsRxnHX4djx0FAsEAABOeEhQ3FG1uIc8AALMcmx0BAwHRAAAdZHIBJAwAAFWCxgAAF2Ydfhl/GYAZgRlrHYv9AoECxQAAGmLGAAAaZ8YAABpo8wACZWYBcwMAABWEwAAAVAUaBhoHGggaex2oCwUCwQAAoT0cH4IcZW0FAsEAABvyGxvNGmVzBQLBAAASnA4WChdjZhDDZGF0AAAfF/ICbmOCAAABOhcAABDGZWt0aW1lAAAeF/KAAgBleRDJZXBvcnR0aW1lAACSGQQCwAAAqGF5ZWRkYXRlkBmmc2l0aW9upRZsbwQCwAAAqXNlcnRyb3dpZAoaEmVtAQ9udBDHdXJhdGlvbgAApBYEAsAAABx0YWxvZ3N5bmNrZXnLGxVlY2tlZBcWYWjyAP0MgQMAANIJGQAAm/KAAQF0cwBEdWFnZYMYDAAAAYsbAADyAAEAZwDysxoAACUBAQECKImArjLLAAAddHN20qkbAACTuMYAAMUH8oCCAWz2H4DAAUT+AECETlEDEqbJAAD9EVEA/pA7ql89LoBBUFRjMfDsSmaD8EhF0e1QOyLLHHkyKNNgKaEOEp/wdlQTud0JGj1gi8RZDWeHlhkAb4mTwwAAG3M7tzke6SmawWhEN5kTQwcgbRvhLjhTIkPPISSdZZk/PCBG2gXnGy1kSbpmuqYGego0GgA6MAAAAQAAAAAAbAAQAAAAJwEoASkBKgFlA2wDbQNuA28DcANxA4ED/wMHBwgHCQclB8UHxgfHB8gH/AcfCCAIJwk0CeEJ9QmQCpEKkgq+C+wLnA6gDqEOpA4kECUQNhCSEpMSlBKVEpYSpRKmEqcSqBKpEsYS4xLtEgwUNhY6FjsWlxaYFpkWmhaiFrUWGRcnFzYXNxc4F0IXSRcpGAQZhxmIGYkZihm+GfUZ9hksGkkaZxpwGpcawRqSG5MblBuVG6IboxvdG94bIxwkHCUcJhw5HG4cnxzTHFsdZB1oHW0dkh2THZcdoR1BwwAAwxlPHFAcpB0FAsAAAKVFFSiWGX4dcHJbwABkwAAAEu0FA8AAABIeFyA5CKEKMtESHxYgFmlveQmCSaci2auxxwAAzhkFAsAAABErFBZ0HGV08wEBc9JiGgAA5fsCZnAFi8AAABaLGxGZDhT1FgGFAg2mFEuaAcKmHwthcAFF46kWAADMGAAAoRwAAFodAAAzAAWSwQAAIQAUtBkSMxz7Aml2G4nNAAAcgRWEwgAAaMcayBrJGlUdVh1XHacLBQLGAAAYUxwVpxtjZsv9EEEBxQAAFqjJAAAcsTUCxAAAEVgbE+sbaXRkF2UXmB0hxgAAIRdxGzsDwQAAFTRtbnUrmckAABuAywAAG6sFAsQAABZAHBN9G2hvKwLFAAAW0WJ1Ff3AI4AEAAAlWhBlGxrYGR2CGxKvCiGuCioX1gulAcAAAKArEHPWDDHAAAAOCg8K9hNxwQAATBVkFvwZ/Rn+Gf8ZeR0Qw2FuawAAAA8AS3JvbWplbGx5ZmlugnkaAACC0RwAADIAAoLAAAAZ52ZygsgFAADipQ4AALAOAABcEAAAARBygAHAGIDGAAAcRGTygAECbmRwAQACyQAAGlNjaiIg1mVy5PEJAABsFgAA6hYAAHQXAAACHAAAEBEAMcAAAO4Y1xnCGgADaW5nAIXHAAAcPMMAABqngvwJAAD24BYAAAEA3wG9ABAASQHyAAEBcnMA9RIXAABtBEwABgALABDHcGxheWluZwAAxBISgAECxgAAGMrCAAASwXBucjYCgALABSTRAAAdYOAAABYXC7zKjPVleRUBwgAAFq8aciocEMRzNzIwAAD7CfKAAQFweQAAA/5AG74ffJKjI+11NzZtlEGc2b7sEbcqCuBAoSBsC2nyiTAuLy4LRx4dCPE+QGlZvHc3An+tF+nCAAAKGTFRyiWTY7ZMDwk3yXFlXUm4cloOClnBi1t3w1t/p8MAABodyAAAGO1iZWkAOjAAAAEAAAAAADoAEAAAAAQAgwPNBc4F+QX6BfsF/AX9BR8HNAg1CAEKAgoDCgQKBQqOCo8KvAvSC+0LAA80ED8QEhQiFCoUPhVAFV4VMRZCFkMWbhZvFl0XXxfDGMQYxRjLGHsZvRnlGU4aWBpbGngaoRqiGqwaYxsuHFwcrhzGHMgcrx0hxAAAGBe0GCcAAv4ASIEIwgAAFpvCAAAY7l8V4SYUxAAAGpHyCwwAAAgDEMZvbnRleHQAAMMSAARjdG9yAAEsIt9hrnOCqw4AAADygmF5AgBhYxQBwmF1AAASaWIWFmwXDBQBw3NlcgAAE2F0Yd8YZEMVEMtsYXlsaXN0aXRlbQAAfhsEAsVlZGlhcwAAFHVyY2W4GhRyZWFtuRpvdBSEwmVtAAChZI4ZQhUAAAAB4AAAG5Bzgv0TAAAB1RoAAPKAAgBkdBDEbmZpZwAA8xYARGllbnTi8RYAACQYAAD2GAAAFAABZxsAAPKAAgBsb/ICaW79BJEIAIIVDAAA5I8WAAB5GgAAWBwAAMEcAADRHAAAEQAQ8oJseQIAZnQSgAEBwAAAEt1sYxDCenoAAHsDdgGAAwLHAAAc2mFlcm5zCYIJa4JnhBUBwAAAFBoccSoIAULkhhYAAN8ZAAA5GgAAXBoAAGAaAABBIQAFAsAAAB9NHB+HHGVsUcMAACYKJwoHFHcW4hYJARcspxaCcgmCCi2W6d8FAsAAABnIHKdxHGFyGwLPAAAchmhvBQLCAAAchRwq3xt1HWVnGwLCAAAT/2F5CYYQ2hqRJ8MAABccAEABVBIAAOLzGAAATxoAAHYaAAAAEPICcGUBAHOI9w4AAAASgAGIyAAAHLJ5IccAAIAcZh1RxwAAShtLG0wbTRuLHQBHZXNwb25zZQFLFgAAAdAbAACgxXJ1bmVkAAAUFhDCdXQAAP8OEMN1bnQAAPwToMRjaGVkAAASFjKAiQHAAAAbenT2AwDAAir9MkAMyAAAHEnFAAAXXMUAAB18yAAAG5mJvAcAADswAAABAAAkABEA1Q4DADMVAAB1FQIAfhkDAIMZAwAFGgMAExoAAD8aAAByGgMAiRoBAJcbAQC2GwAAvhsAAPEbAABrHQEAex0AAJQdAADY4QAA8g4AQAEUDAAA4fkZAAD9GwAAEPoqAQBznmMKAADi6RMAAI4ZAACiGQAAARAARG91bnSCUhIAAAEXFwAA9gUA/QQhBgNOp3vJpTnf4tLdYXByhMQFAAA6MAAAAQAAAAAAMQAQAAAABwwIDAkMCgwjDK0Org6vDgEPCg8SDzUQOBA5EFIRsRK6ErsS1BL4EkIVUBWGFXIWghb2FvcWYRcfGCAYmhmbGaEZqRmwGfMZQRpUGnwafRp+Gn8aixqVGpYaZBt+GwgcshxpHRYAAAH9QAEDwwAAGO3BAAAbjMIAABjYf8THftptAOi1CwAA9xMAAKkWAADEFgAAYhcAAMwYAABsHAAAoRwAAFodAAAUMAMAAAAAgv6AadJDSTgK3yxdXLSIQxmmzFsoxAAAHIUzOY6n0HtoWeU6VFkr+hdsYkOiBsoAABpxRrlBLg0Pn4zqTTEiMmTNQB7hHWEAOzAAAAEAAI0ADAAGBAEADQYAAMkHAQDZDgAAHhAAADQWAABBFgAA1RYBAIIXfwB1GwAAdxsAACkcAABBxQAAwxlPHFAcpB0QyWlkZW9tZWRpYQAAHRcQy3NpbmdrZXlyaW5nAACNGfUBwAAAHr0cdBsKAUHkYQoAAGMaAADPGgAAIRwAAIodAAAAMAArAsQAABYZcHIVAcAAACctGnYbZEUMKwLCAAAWam5yBcDAAEYAABjxGxOzGBUJHBDJbmNhdGVkZW5kAACMGQBJbnNmZXJyaW5ngiIYAAABgx0AAHKAkQLgAAAMRmV5AAAB/V15QidAEew+xgAAGokRMCrJVcQAABiyI1BSV2DGAAAaim98fX1NzQAAHI/GAAAaiy/S7mM3e1MX3qgmo+bu3dAAAB2ecgDpBggAAEYIAAC/CwAAJQwAAHAVAAB3FwAABhgAAA4YAAC7GAAAshsAAANhQzMzocwAAFEcEMVvcHBlZAAAURKgxXVmZmxlAABPEgQCwAAAE3NvbvEOHHZlcnJlYWNoYWJsZVAbYXL2AAADAsUAABwrZWh0b3MAAVkKAACgxW5uaW5nAABNEgQCwAAApmNoYWJsZQ4WpnJ5YWJsZRAWYXTygJIAEMdycGF1c2VkAACkG/ICbmeIAINKEgAA0osZAACDyMoAAO0OOh2GAUhtzckRdAAB9wkAANDEdXNlZAAA6A7ygAIAYWwQw3RlZAAAEwwQyG5pdG9yaW5nAACjFjKAmIQrRV6JuBDGaWJyYXJ5AABJErHBAADTGBDIZW1waW5uZWQAABwXEMhwcm9ncmVzcwAAGxfygAKIbnQQyW1sNWFjdGl2ZQAAIRgSgAGCwAAAC7d0BALAAACzZGVy4w6jY2Vk5g5sctDGdm9yaXRlAAD3E/KAiQAhwAAAlQ/VHBDIY2x1ZGVkYnkAABoXFITFaXNvZGUAABN0ZW0gGEgSEMVhYmxlZAAARxJ2AYDEAAwDwgAAG9XAAAAWI2NkaaDFZmF1bHQAAEUSEoCCkMAAABYHEMVhaW5lcgAADRYSgAEBywAAHLB0bhKAAQEc9UJpKm52EMZlY2tpbmcAAPYT8oACAGhvENRhY2tncm91bmRhdWRpb2FjdGl2ZQAAVB2gyG5seXZpZGVvAABOGxKDZGlviILFAAAchOQBxHRpdmUAABZlc3Npb24JGnPdDjKAAgIQa7daTsYAABupY3VidgCA/sBvpg8CWJAMPAhPnvgVEAYQDZOUL4o0sZldFfKNcBNc3y06eRqJX2wEcMJAOxusdmYORMpFFxo4CjF9FBPUoU3JS5Qq+XEJds1G6FQqEunycGQp4KBeXO+XVyG1ZXhla28hwQAArgoqFwWRwAAAEzUcE+ASBQLAAAAkWhBlGxTaHGN5EMhyZXNwb25zZQAAwhI2BQAB/SOIBMkAABnYzAAAG4LBAAAKr2MAOzAAAAEAAN0AAwCMAwAAEQbbANYLAABhwQAAPgw/DAQWBRYGFrAYVf3ECAkCAAAbsRwU5xkXixutcRzDGMQYrBouHMgcscMAAEAMBQLAAAAisxrYGzjSG60dsh1scgEDAiufrBkOeeiAmcBzdgGjHQAAocMAAPQYNQHDAAAgrhqKG3OZGpoafRtBxgAAexpAHIIccB37AmhvBYXAAAAy1xvPHGAdFXcWIcUAAEccVB0AAAD9N+gPThVg12gyL2sdgUQ6EOz9xQAAHITNAAAcEzsKciroZf9qi0VKm/lRQRj301jrQ8MhJzECKnjMyEjqVBXkexmZCLgBAwAAAOnzBgAA7xYAAK0aAADfGwAAHxwAAKwcAAC5HAAAdR0AAIQdAACxHQAAMAAAMAAJAl8SWeLY1AAAHbBzeCUBxgAAF80bZhsXxhlLA80AABxMyQAAG35lbHKhwgAAmgoFAsAAABAcF2AYF7QYpxpvHJccqRxkcvuGBQLEAAAUuBoUuRpvdCuHwAAAEr7AAAASvwFB7NULAABYEAAA3xIAAD0VAABMFQAAZBYAAPQWAABZFwAA0hgAAOwYAAD8GQAAjxsAAHkdAAABAAAAADAABQLAAAAUFBwQvgtjbAQCwmtlAAAUcnJvcsESG2VzcG9uc2Vib2R5UxplchKAiITNAAAcfhDIZWRpYWl0ZW0AAB8Y8gABAG07MAAAAQAA3AACAHoDAADoBNsAOzAAAAEAAFwBAwAYCgAAtA3bAFMRfwAVAcIAADHUGkQcexxtZxoQx2Fsc3RhdGUAAEoW8gFyAQFudgABnAoAAGIAAwCDAAAHBjZlbwExAQAA+NoSAACrAiYEcAJkAAEATgCYAKHHAAAKGhDMYWxscGFuaWNob29rAAB4GjIAAQLGAAAZCHRleQABuAcAAABDaW5lOzAAAAEAAHUAAQBICXUAAEHLAAB5Gnoa0RzSHBDEdWFyZAAA8BbyA2FzcwEAZwABPhcAAABFdW1iZXKFCBYAAIRUGwAABALAAAAYcHJvZ3Jlc3PGGRNkdWUHFm5zdgABeAMDbgpJk1HIAAAcrl8ii5UcaW5wZWZ0g7kHAADjPBQAAHkWAADnGQAAiB0AACMAMgCCA8AAABwOwgAADwBmb3IA48gFAAClDgAAsA4AAFwQAAAQERDSZW1lbnRhdXRvcGxheWNvdW50AACNHAFD4/EWAAAkGAAA9hgAAGcbAAAUAKDIdGVtdHlwZXMAAHYaAExhdGFsb2dicm93c2XSDBwAABsBXR0AAPKCZGUCAGNp8oCQhDKAAonFAAAbuMMAABbzbHIAAMEGwf5AXO0EUvyR1jJ5FQWfk202fbu+bVupoLJ5x0907m2EfMNcfQe5Fogbt0bqa33vLNqPPm1CpZ9WjBPOKAjN3YaVJl6YUwQurORzdxcjXnK0wwAAG6/EAAASvVpnPLaAADowAAABAAAAAAAZABAAAAA3Ac8Fywc3CTgJFQwWDJMPlA9zEKoSqxK/EuASjxaQFvsYeRp6GgMcNhxYHG0cwRzRHNIcJYLBAAAWohikDpcaAQABwwAAE/9i5ckHAADZDgAAHhAAANUWAAB1GwAAdxsAABABAFHHAAAFGgYaBxoIGnsd+wJsdBDDYmlkAABiCgEBhD1LuQ0C4QoKAACdCgAAAVHCAACXFpgWmRaaFp8cKPUAAKoORBIAQOGoDgAAQhIAABHhxxoAAFUdAAAi8gFnAQBzgtsOAADhHxoAAEUcAACw8oACAGF5EMhlbWFwaG9yZQAAHhgQxnB0aW9ucwAAQRW45wAAEAwQxWV0dXJsAACiFhDQb3dubG9hZHNlbWFwaG9yZQAAcxz2C4Fl/UhBBgLFAAAXW3J18oABAWdytgGAAv0QgQJrFPSmtcoAABrNYWQxwQAABA8FDzkVAQABxQAAG8dy+hQMAAAXBAEAEwW6BAEAkgEEAG0AlQAFAsEAAKfdGBSvGnBxIcIAAKYOSRYVAcAAABd3G2OZHSMCAsgAABytZHMBUhoAALjAAAB3A3YHAAH9GQAHOepmR8JsoCoAAAA7MAAAAQAA5AEjAO8CdQBCCAAARwkAAGIKHgC3CtwAlQsMANALAADbCw0AEAwCABsMAQDWDAEAnw4AAMcOAgANDwQAlg8DAAcQFgDTEQAAVhICAJASAACXEgEA3xMGAOkTAAD9EwEAFBQEACMWAAB+FgEAIBcAACoYAQCrGAQAjhkBAKIZAQAKGgEA1RoAAFgbAAAqHAAAAAD+JAI8A/7AHcKogQAAAsiDAAACyVHJicZ3eg1rEN0aNqRDYVT6GWOUD5oTSnsskEoG0BTnf8qudYO3aT4042N/H0YtzvTMDsIdelMZaXu0BU8Xlls7amJWiObNAAAbxh8jN4hFMpKbOy99lybQRwN3pJc1AAGNAwAAFQHAAAAW+hhz+gkFAsAAABrgGxaYGml2+wJydCv9FAEEwgAAEt7BAAAQdAbOuFzBBQLAAAARLhYdah1ydSHEAABHFa4W84GDBYPAAAAUOhUUzhYlAcQAABZNGne2EngVE4EC4AAACflucAWGwAAANO4SGBjuGVXPGtAa0RrSGoodCQPGAAAaNy79+L1JzQAAHD5hb3IUAcRicmlkAACpZXBvc2l0b3J53RhyRwkEAsAAAHRpZW50RBBFEPET8hPzE/QT9ROkbmZpZ0YQbG/yAXABAGOCdQMAAAAQy2Vsb2Fkc3RyZWFtAADFGRDFYXlpbmcAAKEWEoABiMUAABvzbBDJYXRpdmVzZWVrAABdFxDIc3BsYXlpbmcAAIsZ9gACbDX9AKEB/RUQAsQAABghxQAAGnDFAAAaSskAABzKwwAAGpIBBQcAAAAyAAKGwQAAFCPEAAAaZm10AOFaEgAAORYAACAFAsAAABd9F0DaEoUVqxnOHGl0AER0b3J5AWEKAADjYxoAAM8aAAAhHAAAih0AAAMAUoABwASEwgAAFknCAAAbvnMFAsAAABlmGiXeGdYbaXIFA8AAABIWFzNzF8Ma5hsUfBVpdHUFg8EAAB/VHBIAHCHDAAA3GowcocEAANsJBcDAEIIAAEJaElsSXBI5FhKXGh1JHCHAAAD6FjsX8gJlcgABc/IUCAAAMgHz7hIAACoF1gESgAEBwgAAGqNkbAAAgf4AR9wZYOCCMukdKrSUOCwvMrkexQAAHDFt+6vRXFm+ii19JeBs+e92uRs6dVT0/qGCOB1u7hEK9EcMocYAABnWDdIo/AcA6SwHAABbEAAA8BIAADoUAADNFgAAARcAALobAAAHHAAAZxwAAHwdAAAgAQEAAAkC0AAAHbDMAAAaYGRlCQLDAAAabH72HhMAaWywx3JldmlvdXMAAAQWsMNleHQAAA0MBALAAAASYXD5CRJldPoJbXPQxm9udGVudAAA7BPygMAiDAABAgLCAAASui2Tb/lEaXPy9QkAANEIEMlmZnRvYXVkaW8AAO8WCA0BAKAAAApgcwFFCQAA5JQOAABwEAAA2BYAAEUXAABaGgAAAgEA8oACAGxvUoABAlsC6yUQZGdu9gCAAgPHAAAcyG5zYnJ1AAD9EQFE/oA7/g1c0GsKvgUt2K61FyAFlPQYOTLahydcAKg2wQAACgcY/n5hVsYAABbjcMGewk7IAAAY+WmOG+vUZiLfDCl8SopU4tAAAB1pFFTtcWNcx2tcTV97LZMOesnlwEcKzERl6yoq4pfPHfXqrPAAOjAAAAEAAAAAACoAEAAAAIIDjgOPA5ADkQPsBe0F7gX2Bf0HNgg3CN0J3gnfCeAJzg7PDtAO0Q7SDgQPBQ/PEtASQhQ5FR4WJBYsFosWjBYoFxkYBRmlGbEZKxo0GtMalhtbHKIcQcAAAOMZ5BmAHYEdBQLAAABBGAwZDBoMixtBUBDwFmsX7RhnchsCyAAAHMFhchUCwAAAFqQbIb4J7xJydXwDxf3FUgECAAAVwhkVbxoWjxsTWRcX3RvaEyuJwAAACeEVwMEISgAAF2wcM4gaZhtZHRfLGDZRF1IX8Bu7FjHGAADgFuEWjRkFAsAAADLaEqsZzhxAWhJbElwSORZpdBXAwghKAAAXbBwziBpmG1kdF8sYNlEXUhfwG7sWFQHFAAAQTBxzYhcrAsQAABjybHYbncIAAA8DxwAAGeXHAAAZyAWMwAAAEYMKGoUdIcQAALoYOxxRwQAAlxaYFpkWmhafHAUCzAAAFpgdF58dY3D7AnByBQLDAAAVbBsVbRtwc6HEAADVFgUCwAAAEdIYETQWZmkrAuIAAAwQZG4BgQFORc7QSHQxwgAAqg5EEjUWCQLGAAAb7tAAAB2ZcXgAR3JlYW11cmyCohsAAAGXHQAAEoABgsUAABzDdABKb3dubG9hZHVybIQjHAAAAaEdAADyhGlkZW8CAGRzEMNhdGEAAKAWGokBAeAAABpAZHMQxmNvbWluZwAAAxYygAIBwAAAFqJwc3IQ02FsZG93bmxvYWRzaXplYXN5bmMAAIIdEMJlbgAA2g7ygW8CAGt0ocgAAFEbAElidGl0bGV1cmyEhxkAAAFtHQAAEMJybAAAGRcSg2VhbZCEygAAHYkAQXMBQRIAAPIbGgAALwISgXSQAeAAABt0c/KAAgBhcgQCyGFydGNhY2hlAAAVb25maWciHBR0YXRz5htjcwBJaWxhcml0ZW1zhAUaAAABex0AABKAAQHAAAAZsm16BfzCAFQAAB6tHR20HReMHRsCwgAAG2tpdgAEc2lvbgEAoAAAFgJzguoTAADy+RgAAI0BUoABwAKgygAAHW/EAAAbpXP2BYD9EBEMAscAABwsbHkARW92aWVzhAEaAAABeh0AAABEdGVtc4SDGQAAAWwdAADyg3VtZQIAaW0ATWlzY292ZXJhbGJ1bXOE4hsAAAGcHQAAAFBlbnRseXBsYXllZGF1ZGlvhLkcAAABsR0AAHqWwAZAAcgAAB2RbRDFdGF0dXMAAIIZ8gR1ZXVlAQBzAAGgGQAAIcIAAEIUsRnAyGlzdGl0ZW1zAAByGhDHcnN0YXR1cwAAABoAQ25mb4T8GQAAAXkdAAASg2Fja4QBxgAAHYZw8oJheQMAYmVsFQLEAAAUOxwYjh1jc7MZAENzb26EPRIAAPKlGgAAVQHygAEBcm7ygAKBZWwATWV4dHVwZXBpc29kZXOEnhsAAAGWHQAAEMNvZGUAAAwMAEtldHZjaGFubmVsc4SaGwAAAZUdAAAQwml0AADZDgBGcmFyaWVzhJwWAADSBRwAAJvygMEBAgAASXRlc3RpdGVtc4R+GQAAAWsdAAAygIWQwQAAFEEUAsJvbgAAE2FzdJsWFG5uZXIYF2ZpCwygyGF5YWN0b3JzAAAcGPKAAgBycxDJbmZpbHRlcmVkAACZGxj7AABcFwBHeXBlcnNvboRKGwAAAYsdAAByAAOCxgAAHIBib3WE1Q4AAPQTGgAALABLACwBEMRvdW50AAAXF1ICZW0DAYkAABPpxAAAHGpjaXNwhAcMAADysBkAALQBCGGElxYAAAGfHAAAEMZlcXVlc3QAAFsX8oNhZ2UCAHJ1MoACAuAAABZ+wQAAG49tdGRuAEVlbnJlc4Q5EgAAAfkbAAAEAsJvbQAAE2lsZZYWFmV5cmluZ30ZZmsQyWVydmVyb25seQAAuBwQyGFjaGVvbmx5AACMHPIHdm9yaXRlcwIAY3OEkhYAAAGeHAAA8oACAGFyFALAAAAWbGxvd2Vk4Rsab3JhZ2VzdGF0c1MdYXSRFhDLYW5hZ2Vyc3RhdHMAAFIdAEhpYnJhcmllc4KKHAAAAasdAAAIY4KXGwAAAZQdAADygWQCAGlsAEhpc2t1c2FnZYJPHAAAAaQdAADyhndubG9hZP0YEAIAoMh2aWNlbmFtZQAAGhgygIqEwAAAEqwQzWVkZW50aWFsc3BhdGgAAJYbEMRuZmlnAAA4EgBGYW5uZWxzhP4VAAABZBwAABDDaXplAAAWFxDDYXRoAAAZGPIAAQBwAAHFGwAAMoJoZQIBxAAAHFlkc2MSgAEByAAAHFpjcPYAgP2BQAGQxAAAHL4BAAF+Ah4cB3Pi2w4AAB8aAABFHAAAGwAQxmhlYWRlcgAAGBgSgAGIywAAHXZoAEdyZWFtdXJshJIbAAABkx0AABKAAYLFAAAcwnQAVG5seXN0cmVhbXVybGZvcnZpZGVvhaYdAAABtR0AAPKCaW8CAG9z8oACAGR0BP3DYnVtAYAFAAAdZmZpbml0eXN0YXR1c7YcGmxheWhpc3RvcnkhHB5lY29tbWVuZGF0aW9uc7ccFXJhY2tzfBkQyG93bmxvYWRzAACRGxKDaXZlAQLFAAAdXsIAABxpZHN1EoABAccAAByndGPygAMBY2x1ZwAA/kDb7g8DTdOsHwFk7qfD1jfOhDDTGBjWKhJa5O5TfQTG+1cIDAFaa/xtDPbR30ckEOL4fhYcwjRtrMVgSRP4jzYLCrKT+7RuFMJwLUCQxYk2Ldx3IgF7t2MxIhxm0+iAywAAHGxSynT85GVveYPFAgAAAeYJAAAAAAABxQAAG7p0hUAJAADhORIAAPkbAAAw+jEBAHMBBAcAAAAQwmNzAAC4CxDNZXNodWZmbGVvcmRlcgAAIBwSgAEBxgAAG7t0bPKBcoUA8oACAGVyNI8AAv1YWQsABwiYuZw7wwAAHD9wcXN0xQAAFUEr/xMfzDO8WK/YdW50AOiECgAAsQoAABgMAACEFgAAwxkAAIsbAABPHAAAhhwAAKQdAACDIQAQABUBxwAAHHQddjwcEMxsZXNzcGxheWJhY2sAAHsZBYXAAAARvhIRvxIigW4BAaAAAAe4c23ygAIBaXB2AACD/oB7+0VWWY+f/y9KYbvLCNoIbN1W0ly4lEsvgrYaOzXIVIwFoAQJa1LNqSujYAXnPq8e7wzNqCf6QMyxS/UMIxcRkcF6LsQAABj7Qv5t6pZBf915NH+r25zuTDFf2uwgrvQ7WwA6MAAAAQAAAAAAgQAQAAAAZwNoA+8F+AY+CD8ILQkuCS8JMAkxCfwJ/Qn/CQAKCAoJCg4KDwoaCigKKQqaCpsKvQvTC5cOmA7BDtsO3A6RD5IPJhAnECgQKRAqEDsQRhBHEGAQOBJKEksSTBJNEk4StxK4ErkSwBLEEuUS9hMDFioWoRajFtoW2xbcFt0W3hbfFuAW4RbzFiIYIxijGKQYpRimGMAYyBj/GAAZARkCGXoZfRmLGY0ZrBmuGbMZDhoPGhAaERofGiAaIRoiGiMaJBolGiYaJxooGikaKhpGGoIagxqEGoUa1hpcG10bqhvBG8IbwxvEGw0cIhxFHFkcXRxeHGgciRzUHHMdfx2DHYcdsB0FAsAAABQKFyZTF8YaZXMFA8AAABXvFhM/EBsaHG9yc0UCwAAAMSsUyBaNHRZ0HGV0QRQvFjAWVxoFAsAAABbIGRdGGmNsEMN1cmUAABMIFAHFZXN1bHQAABZhcmlhbnTKG3buFhDCcmcAAMAS8oR0aW9uAgBhchKAAQHBAAAZqmNz8oACAW50bAECAc8AAB1qcQErGgAAocUAAMwbBQLBAAAUoh0TSRxjcsHHAACyGyHGAAB9GdYaIcMAAJYWBxkAR2VsbHlmaW6CjxYAAOF5GgAA0RwAABEARm9tbWFuZDswAAABAABvAAEAjhVvAAC2CwFtAv4ADGEBwQAAFrTDAAAclmNqOzAAAAEAAOAAAgB0AwAACATfADswAAABAADdAAMAFwoAANgM2wCqGAAA8oCIg7HCAAC5CxQBxmVjb25kcwAAE29uZ4kcbEkbEMNvbmcAAHoZ8gJtZQIAbHMB6BMAAAAigIYAlwAADqcSgXQBAeAAAByvdHMSAIEB0QAAHWRyAAEkDAAAEMxsbGVjdGlvbnR5cGUAAEgbEoCIgjbl8ajIEMVpZGRlbgAApg52AADAAwL+AEAKGcIAAB1pywAAHIHFAAAZ5cIAABc5XG2PRTTJAAAbggDSlAsAAMAAQ2RlcgESCAAAg+MOAADyAAKQbHIA7NULAABYEAAA3xIAAD0VAABMFQAAZBYAAPQWAABZFwAA0hgAAOwYAAD8GQAAjxsAAHkdAAABAAAAADAAFIjAAAATbmNlEQgCAAhDOzAAAAEAAKYAAQAeAqYAAKHJAADJGQUCwAAAGakbEtQSc3THAAL9RAEDywAAHA7GAAAaE8QAABco4QYKAACTCgAAAwEAAsIAABYzN+6HmPVtb+OnCgAATxIAAFEXAADwGwAAYRAQwmF0AAADB+YAgImWmQAAA3PBAAAaVwEAkMkAABzB6y0JAAAaCgAARhAAAGAQAAA4EgAAtxIAAN8WAADzFgAAIhwAAFkcAABeHAAA1BwAAEAQAgAAAMHIAACjGCWGxAAApHkcGXscFhxGHPuFEMhhaWx1cmVhdAAAeRkaggEBwwAAHK5mYxKAAQHFAAAcDnNtFQHDAAAT3xhkQxUFAsUAABS4GhS5Gm90BQLAAAAZ0xoVBRhxdAFE4/EWAAAkGAAA9hgAAGcbAAAUABsCwwAAFvNsbwUDwAAAFhIcJbAathwYfRxlc3QBTzswAAABAAB1AAEA3Bx1ABWEwQAAoY4ZQhX7A2VpeTUBwAAAEJAbc/0T/hPVGhDDaGVkAAC3C/KAAQJzZHT2AgCE/RSQCMoAABt+AOUVDAAAjxYAAHkaAABYHAAAwRwAANEcAAAREAEFAsIAABL3GRAoHGl5KwLgAAAbmWRu8wKC8sUSAAC7BDUBwwAAEPgYczUaNhp4GxDEc3RlbQAAQxCgwnplAADTDvIAAgBpeQDyAw8AAEIL2OMAAM4OGPkAADcS8gDAAkgBbgD5SBAAAOUFGwBOAHYAAQD6AeICEgDygIIBdDHAAADYC9kLkw4QwmRzAAA/CTKAAQIw9hJExmxkcvYDAP0QKAH9REAgwgAAEDN9lybQRwABjQMAABDNZXJpZXNlcGlzb2RlcwAA4BsQ0WV4dGVwaXNvZGVmb3JpdGVtAABpHfKCY2gCAG5zEgABAscAABdBNJQaGlR0Y3IA9bQOAABOABYAPQFdAgUCwAAAF1EaEooVaXKDAv0QEAXFAAAZBsgAABq2ygAAG8g7MAAAAQAAJAADAPYJAAA0CiEARRIBAAUCwQAAFj4cF20aY2QQw29wZQAAFxgSgAGCxwAAHLhjEMNubHkAABUXEMJuawAAQBUgymFuZ2VkZXZlbnQAAMkbQRwSgAGBxgAAHIxo9gAA/QRBAgLDAAAcA8YAABx/ZGWCzA4AAOKSFgAATBwAAJ4cAAAwABIFb3JpdGUBhMEAABuMc4K1CwAA5/cTAACpFgAAxBYAAGIXAADMGAAAbBwAAKEcAABaHQAAQwAwAAQCyWFja2F1ZGlvYwAAFmFubmVsc0AcE2RlY30baG8SgWwBhMIAABjyYiWBwgAAEXkZ2xIzHBjLAACNFfo5AQBj4Q8IAAA+CQAAEPYAFAAAkwUBACAA8wHeAPqCgpD2AoADA8AAABabaWx2ZHN1AAD+ZESeBMgIyIEAAAIcgwAAAh1zLvrC1Uyk5KZ6ZUJcmDso9LR0SEWCcoKkVKV2cGpcYCcxZDcX5GJDbE+b2GAvQJL5IMIAABpYfYasNoxZotT462bu6pfFAPUzAQAAAQDTCFsQ5QAhxwAAWBzBHAUDwAAAFFoaFEkWGkoaYWl0AUXhCBYAAFQbAABDBQLAAAAQvhkihBXrG2x5BQLAAAARThog0xFYG2NkEM5hY3RqZWxseWZpbmlkcwAAkBtlA8UAABijHRZpHcSFG2FmcCEWIhYYHHAclRxyHRDOdWJ0aXRsZWZvcm1hdHMAAK8cEoNuYWwBAR0NIqQsc2TygAEBcnD2AAACAyExCbIZZXJpdHUA734DAADpBQAAKwoAAA0MAAC5DgAAwxIAABAUAAA1FAAAUxUAAD0WAAC0FgAAZBcAAOUYAADLGQAANBwAAJgdAAABQgAAIAERABWBwwAAFPIb3RkVAcIAABTwFmc+FxDNZW50aWFsYmFja29mZgAARxsSgAEBwAAAGm9uchDIY3RlZHNpemUAAI4W8oCKhQBPYmFja2dyb3VuZGF1ZGlvAd8bAAABdR0AABKAAQLHAAAZxsIAABYHdG5zIcMAAL8ZtBq0AsN1dGUAAKRhdGNoixYUdWVyeY0WYnFdClKAAQLgAAAcAWNydgAIYW5kaWRhdGUAAeAAAB2ZcwFSGgAAABKDaW9uAgChAAASNWNzGOEAAEkWEMduYXBzaG90AAD7GQBFZW1pZHOC+RkAAAH9GwAAIoACAJgAABU/ZHQyAAIBwAAAFxppc2IAg7kZAAD6tgIAZGjygmx1AgBkc/YJAP0UgQTAGETIAAAcrsMAABBQwgAADrQA5RIHAAC5BwAAPBQAAHkWAADnGQAAiB0AABIjAAEEATnOpaHvY+LFFgAAmBkAAMgbAAABAAUCwAAAGnQdErYOYWyhywAAgB0FAsYAABBOHBJbHXN1K4rFAAAdsPMCAmRzAQYcAACl/cBAoQYAABR+FkGfDscOyA7JDjPZExoYGximDBcUfxYZbho1CRSBw2xydQAAFHN5bmMWGMsO8oABgnQygAECbCHC7cJjZG8BAAIC0VV7mMIAABJVZm73yAcAABsLKQEqAgQAAQCiBaDIaHJvdHRsZXIAAOwWAEZtaXR0ZXKFhhYAAOPfGQAAORoAAFwaAABgGgAAEhBeACQDAqIAAAk7wgAAGa3CAAAZ1mVzdGxuAOhFFgAAvxkAANEZAADaGQAAtBoAAMkbAAA/HAAAQRwAAHgcAAAQIAAAIPKAAQFubPKAholxwgAAqg4GDwcPCA8JD0QSNRYFAsIAABOgFhBAGmRzS8AAaMUAABYDwAAAFqI1AsEAABWCGRo0HHN3HxAgEKAZI4ICxAAAGddydQUCwQAAGTwaW54bnxugG6Eblh10eCHCAAAMDE8MFQHLAAAYfR1koBr7iRUDwgAAE5sWFBgXE+4YZmlrCwyhyAAAHBhrA8sAABtnZXJzUcUAADkSOhI7EjwS+RsFAsIAABOWFhZ9GWZrVQLHAAAYjBwZuBxjc5IWkxaUFpUWnhz7AmFyEMNuZXQAALQLEoABgcMAABb6chKAggLMAAAcdcUAABdCcnUAAAH+wN/+XzCAyqWhTHJ4bIDRAAAcz0Ghcy2AVuDhsWZcf0UwYQfY5jxwN+k4LBAXl680YCNxm30fVyobnGZRAWzuUWxLvNEhaOV5Di41+aGrOHUdd6mfab0HacdX/Fm05zrDU5r3WjM4eaU2Pk3+MFaZew7bbWLQprFoAOzFAgAA7gIAABkHAADgBwAA5gkAAPoJAACzCwAAKxQAAMgWAAA0FwAAqhkAABYaAACNHQAAIAEAEAEAABUBxAAAEGEbc1IQBQLAAACxFA+2NhducMHDAACHEuMA/QDUAAvi4OY39xgHAAAWDxIATQCNBUUBTwABRvpNGgAACQAuAYcABQAzADMAAQBhAAEACQLMAAAcjh0sx/FkbnIJA8IAABYnEfpPo0AfS8Zj3WFscgUCwAAAIZgauBwclBxscBsC4AAAEEBudwUCwAAAsf4WGK8bZHah0gAAgB0rAscAABvVbG4FAsAAABBZEhSDG2Nu8wCNASMUAAAJgka0QKoQIcYAAC0adhsFAsEAABtYHBorHGFtYcIAANQLMBAxEKAW3xiXGduPxAAAHL8BgQIwqpBxcsEAABgVY25RwgAAIhgjGKwZgx2HHQXAwwBKAAAaWB0XLRwUWxshwAAA8AuoFgUCwQAAFk0cGO8bbnZvAf0NkATFAAAbUMsAAByWzwAAHXegxmVzc2FnZQAAhBYQw2luZAAApQ4Qw21wbAAApA56PQMBxAAAHJhpa21y4foGAAC1BwAAguq8DgAAwRIAANUSAABIFQAARBYAAAoXAABUFwAAdhcAANgYAAADGQAAERwAABARAAAAAPIAiIYBMAEAAAAFhsAAABwgHFILDwwPzhnPGdAZBQLAAABT4hvjG+Qb5RucHRa7G2JjAEtlZHNlcmlhbGl6ZTswAAABAABsAAEADBlsADswAAABAABsAAEA2BpsAJYEgAHABaUkPhagVHQVa3q8xQAAHZDAAAAcDXMAAAL+gFc+XyGbizDlVYJoiE00+vuXZWpmOIZbN6MiuQJX6TTWsALUMJ3aSBuYHznMAAAcW26FBFmPNr2ylttKGRtvtHOrgaUWHNM65y8eo7dZt0CVxq1/cf03LaFt+zRGHA9PFTeyYXIAOjAAAAEAAAAAAKYAEAAAAIsDDgYPBhAGFAcVBxYHBwgSCBQIOwg8CD0IRgnICckJygnLCfEJ8gn0CZwKRQxHDEgMSQxKDKcOxQ7GDuMO5A7lDlYQVxCfEqASoRK2EsUSyRLuEjgUORQ8FUMVVhVXFXgVCBYJFgoWCxYMFg0WGxYcFmwWhhaHFogWiRaKFs8W0BbUFuoW6xbsFu0WExcUFxgXQxdEF0oXTxdYF1oXbRduF3QXdReAFxgYtBjGGMkY6RjqGOsYqBnUGdUZ3hnfGeAZ7hnvGQwaDRoXGhgaGRoaGjkaOho7Gj4aQhpNGlYaXBpdGl4aYBprGowajRqOGqcauhq7GrwavxpUG1UbVhtXG2obbxt5G4MbhBuJG9Yb2hvsG/4bARwCHAQcChwLHBAcFRweHCAcLBwvHDUcQxxJHGkcaxxvHHYcdxyXHKUcqRy0HMAc0BzXHNgc2RwF/cAIwAEAABhmHBGnGBPTGhVfGhMBAtQAAB2eZG0BABwAABUBwQAAE8Iaa40WWwNM7nqoqHJzdQBGdmFsZW50OzAAAAEAAF0AAgBBEAEA2RFbAAAQxm5hYmxlZAAA+BkQxGFuZHMAABUYMoVsaXplcgIByAAAHNViZW7ygIWCAAVyZXNldAAB4AAAG+5zAbMLAAAABIXEYWlubQAAEXi+EhFuvxIQzGlsdGVyZW50cmllcwAA9xkIRAGwCwAAAQYZAADyAP1igAgAoggAAAABtw4AAAWJwQAAMnEXchc9GhHqG4ME/RQQAgobZfSxxQAAGd1gt6pro/c4FAAAAQCNBKUBwQEDAJEABYbAAAASdBwXcR0VgsQAABpxHWgaKwJxZ+Gup2ZnBQLFAACksBsWMRxjZfuKdQHBAABTUxBUEBwUHRQeFG3UCdUJ1gnXCYgViRVaGxsDzwAAHYXCAAAbbmFucmHCAADODs8O0A7RDtIOLBYAQIKxCwAA5RoWAACeGwAA4BsAABccAACMHQAAlh0AAAMAABDCb3cAAEAQoMV1bWJlcgAAExd2AARzb2RlwABk/TWBAMcAAB2jwwAAHBnDAAAcqsUAAB1pwgAAGCADbE1LPwH4CQAA/kgSAADZAwEA6AEBAOkBtwFtAE0ACwAUABEA3QBCAHYcgIT9EcgHW83reVhATOn8v8UAABLEocQAABMXAQABxQAAG7p04kAJAAA5EgAA+RsAAEMA8wEBcwEEBwAAIccAAFUaYR0FAsAAABe7Gx0gHGx0E4GFwQAAC7gQzW9ubWVudHN1bW1hcnkAAEYbEoFpAQHEAAAZqnJmBQPAAACn7BYmFRq1HBKDFWhvebUDwAAAFQwYF9McFUYbZXR1OwkFAsAAABL3GSBqFigcaXkFg8AAABLWGRWvHQWKwAAAE30WEocVBQLAAAASrRlbuRy6HLscvByxHW95sdAAAFUdYcUAAAoYCxj0GasbZRy0HQFF5IYWAADfGQAAORoAAFwaAABgGgAAQSEACF8BHxwAAAGEHQAA0oABwAQK4AAAGKJyAACC/Ys7B19AEjK6ywAAHI1PUk8E3McAAB19wgAAFYUE0EzyQcgAABrValTwOdhNo0w9KnkL95WwS1AC/JkU8Z1f51lwg4uBADswAAABAACVABwACQgBACgJBAAwCgIAmQoAAC0QAABBEAEARBABANkRWwDsEwkARRYBAPEWAQAjFwIAQRcAACQYBAD2GAAAvxkAAMgZAADRGQIA2hkAAEoaAQCgGgAAtBoAAGcbAADJGwAAPxwAAEEcAAB4HAIAvhwAABDGZXNwYWNlAACMFRKBdQEBxAAAHY9yYgTBxHVldWUECAAAF293bmxvYWQLGR5sYXliYWNrc3RvcHBlZIgcHWlkZW9kb3dubG9hZHNOHEHEAAAbHH8cgBxmHQUCwQAAFNgYFYUcZWYVAccAABBMHHNiF0MAwA5AwwAAHDvKAAAdhccAAB2O8yoWAACJAxEC84ECZ2lxwQAAZRa4GRQaUxtUHJQcqhwQw2ltZQAABgwAQ3NvbuGiDgAA2BEAABDzRxcAAGEBIgAanoECyAAAGrLPAAAdXGxtEMZmdHJhY2sAAD8QEMNhdGUAAAUM9lQA/QhABf1VoQJyFKPsCsUAABtizAAAG7vIAAAZvcwAABu80i8BAADs6oQDAAD+BQAAIgoAAE4QAACjEgAA0hIAAAsUAABmFQAAaBYAAIwZAADbGwAAATERABAAAUA7MAAAAQAAegAFALsOAAAaD3YA0hsAAK0dAACyHQAAAEJleQESFwAAAdwbAADyAm9uAQBrAT4QAAAAEMVkZmlsZQAASBbyA3lwdIYAAVwKAAAA8oABgnIBAQJvxMyrlw1GDz5YcHPl2RMAAGEVAAAaGAAAsxgAADUaAAB4GwAAABAQEMxlbW90ZWNvbnRyb2wAAN4bENlpbnV4aGFyZHdhcmV2aWRlb2RlY29kaW5nAACwHQBAgloKAAD0RxIAALEHGgIfAPICbGXABCEAgjkJAAAA8oABAWJtAAD+QANAC/0QmAk4QAHZ/iDTv8ICaHHaXB1Knr0I9jKU4giKeKvZvtAdbbjYpicLM9I9Zb9V6hN5aD+iMcwAAB2NbWymhkUkGjJyEgDrygIAAOsFAAD7BwAAmQ4AAKYOAADaDgAAkRIAAGcVAACLFQAASRYAADAcAACnHAAAAAAABQAABYPBAAAUchswiBpmG1kdAQGDywAAHI1zZQ0FRQFKGgAABQLCAAAT8xkUrBtpdPsCZG4xwAAA6QvHGWMdIMAAAPkGtAfyAAEBeXMAAXYWAAASgAGOENoakSfDAAAXHMsAABtzdAEAAWUbMUnIczswAAABAAAiAAQAYwodAOkTAACOGQEAohkBAAQCwAAAH3J2ZXJyZWNvbm5lY3RlZE0cH2VlcHRpbWVyY2hhbmdlZIccZWwQy3VldWVjaGFuZ2VkAABxGhDLdG1sNWNvbnRyb2wAAHAaENFvbm5lY3Rpdml0eWNoYW5nZQAAhhwQ0GFja2VuZGluaXRmYWlsZWQAAIUc9gEAwCNQATEiMmTNdIIGBAAAADYAgAH9GCACST/vIh7BAAAY7NIAAB2edDaCAAL9F2APKR+cv9nEAAAZpXBjCckeKicL7/0oEBPjWjnf4tLdYHU0iVxZHhcCZ2lwADowAAABAAAAAAA3ABAAAADEBcUFxgXHBQcMCAwJDAoMIwytDq4Orw4BDwoPEg81EDgQORBDEFIRsRK6ErsS1BL4EkIVUBWGFXIWghb2FvcWYRcfGCAYmhmbGaEZqRmwGfMZQRpUGnwafRp+Gn8aixqVGpYa1xpkG34bCByyHGkdAADFAL79/09aJTN8TTNQvQL47zGAJaqABRmtUthrQTxHt3DvfdAqFzthcaFGyZ8r8UxsWMk1KWCetIR3edl91gPi9bHfXr3Je7MxvsD6khRbzYi6FJAJj1Y/TR93fC7HfXEsRv5ACjY76nQnGmDnLAQKKoNxYYIYiEZCm2qQSM0PZeJwT9QAOzAAAAEAALUHDAE1AQAAdwMCAH8DAQCIAwIA0AUYAPcFAQAABgEA9wYAAAQHAAAOBwIAFwcAAB0HAAAgBwIALAcCADIHfwDXBwgA4gcBAOUHAADrBwoA9wcDAP4HAAADCAAAEQgAABMIAAAYCAYALQgDADIJAQA1CQEAOQkBAEUJAABICXUAwQkBAMQJAwDRCQIA4gkAAOkJBwDzCQAA+AkAAAYKAAANCgAAJgoBACoKAABXCgEAXQoCAIQKCQCTCgMAogoAAKcKBgCxCgMApQsBAKkLBAC1CwEAzgsBANELAAAFDAEADAwAABgMAgBPDAAAVQwAAJAOAgCUDgAAqg4AALIOBAC4DgAAuw4AAL4OAgDTDgEA3Q4FAPIODAACDwAABg8DABQPAgAYD3gAmg9sAB8QAQAyEAAAOhAAADwQAABIEAAAUxACAFsQAABiEBAAdBAAANURAgBEEgAASBIAAE8SAQBVEgAAXRIkAIUSAQCLEgAAmRIDAK8SAACyEgAAvBIAAMISAADKEgMA0RIAANcSAgDbEgAA3hIAAOQSAADoEgQA8BIBAPkS4ADoEwAA9xMEAAYUAQAOFAEAGxQDACQUBQAsFAgAOhQBAD8UAQBFFOsAShUBAE0VAQBhFQAAeRUBAIMVAQCHFQAAihUAAIwVAAAHFgAADhYDABgWAAAfFgMAJxYAAC0WAAAzFgAANRYAAEgWAABKFgEAYxYAAHcWAACAFgEAhBYBAI4WAACWFgAAqRYDALMWAAC2FhEAzRYBANEWAQDXFgEA4hYAAPgWAQD+FgQADBcBABYXAAAeFwAAIhcAADwXAABFFwEATRcAAFAXAgBVFwIAYhcAAGkXAQBxFwEAfRcAAH8XAAACGAEAChgBABQYAAAXGAAAGhgBAB4YAAAhGAAALBh1ALEYAgDMGAMA0RgAAOAYAgDyGAAABxkBAAwZbACQGQIAlhkAAJgZAQCgGQAAshkAAMIZAgDWGQAA4RkBAPQZAAAVGgAAHBoAAB4aAAAzGgAANRoBADwaAQBSGgAAWhoAAIAaAACIGgAAjxoAAJIaAACbGgAAqxoAALgaAADFGgAAzRoBANgabABIGwAAUBsAAFkbAABbGwAAXhsAAGYbAAB4GwAAehsAAIsbAACpGwAAqxsAAK8bAAC6GwAAyBsAANAbAADSGwIA1xsAAOkbAADrGwAA8BsAAPsbAAAAHAAABxwAAAkcAAAMHAAAExwBABgcAAAnHAAAMxwAADwcAABHHAEATxwBAGUcAABnHAAAahwAAGwcAABwHAMAhBwAAIYcAACQHAAAlRwBAJgcAAChHAAAqBwAALEcAAC1HAAAyRwAAM8cAADWHAAA3Bx1AFQdAABZHQEAXR0AAGAdAABnHQAAch0AAHwdAAB+HQAAjx0AAJodAACkHQAArR0AALIdAAC0HQAAAQAByAAAGmRkOzAAAAEAAG0AAgCSA2wAUxoAAAWNwAAAH7AdF0UbNOoW6xYCHBUCwwAAGJ4dFnwbZHIRFwEDATmKipiodOHzCQAAeRUAAAE7jcMAABazIcQAAIsSXhsFg8AAABi9GRwbHBWEwwAAE2EXHRYVAsUAACR7G1IdFgscc3daF/uDBQLAAAAbuxsdgBxudBuSwgAAF2tBygAAeRp6GtEc0hwFAsUAABKMGxh/HGlzKwLCAAAbp2l2W/0BAQXBAAAWSMsAABwawcgAALUYdQHAAAAQhR1zJxcjHCQcJRwmHFsdoR0QxmVjb25kcwAAChkQxXJsYXN0AAAJGaDBcwAA5hPyBHRpb24DAG1vc+GvCwAA9wsAAA31pBYAAD8ALwM2ACUA8oCBAWw2AIAB/RSIAOAAABYHxQAAHI7FAAAcXXIhwAAAug6VGQAAAIQx2111LOFyAwAAAwQAAAIBpwsAAFKAAQLAAAAWg3BvdxDIeW5jcXVldWUAAAgZEMVlcG9ydAAAvRIQz2VuZGluZ2Zhdm9yaXRlcwAATBzyAW7AAGgAAOOTDwAAqhIAAAMcAAA2HAAAEQASgIQBwAAADwBuMoCJgncg3isiAQABzwAAHHx04coLAACnGAAAEAUCwAAAHa8dEcMOY2UFAsAAABfvFhQ/EGZ0ocMAAEIWQcYAAPAZ8RnyGWUdBQLAAAAUBBkVPRxhbGUBwgAAEIUdcyMcJBwlHCYcWx2hHQBEcmtlcoLqFgAAAQIcAAAQx3RocmV0cnkAAEUb8oCMABQBxGlkZW8AABZlcXVlc3R8G3IRFxDDYXNrAAA+FQBCdHMBRxYAAAEcHAAAEoCBiMgAAB1TEMdlc3BvbnNlAADEGRDEdXJjZQAAFBgQw2llcwAAExgY3gAAEhgygAIBxQAAHHNhcm0Qz25jdXJyZW50bmV0d29yawAArx3yBmxsb3dlZIgAAAHhGwAA9gEA/RFABZTGAAAdgsMAABweAcoOAAD6kRYAAGYClwIDAEgARAAxABgAFwEhAACAiAGZAAAWg+AAABmVdxDEc3VsdAAA6RbygIoAEMZyYXBwZXIAAAscAER0YXRzAXsbAAABUh0AAPIGYW5hZ2VyAgBzdwFaFwAAABDGZXF1ZXN0AAC3GhQBx25kc3RhcnQAABZlcXVlc3RiHXJLHPICZW0CAGFyAYIWAAAAEMJmbwAAPRVigMAEhACmAAAT36DDZW50AABFFgBDcm9yAUQWAAABERwAADHIAACKHIscqx0QwnRlAAB6G/IEdGVtcwEAYwDhlxsAAJQdAAAQ8gCEAWwA4fwYAABkGgAAIPKAwQQgAABIaXNrdXNhZ2UBwxkAAOFPHAAApB0AABCgxHl0ZXMAAA8XEMNidW0AAA4XEoABAcIAABppbHMAA29hZP7ARoQbBDntc/XhP3rZzKslRHeDGH1j2/vUO+EnAGN4dOl1rgLtch8mNkZbWGpszXb68k1w/TqpeVH+Tr7HAAAcU8QAABunwwAAG15dgniefWNmcXWG8QsAAP44FgAA8QAiADAAAQAFAIwAsgAIAEQAIgE9AAwBCwHwAAHAAsAAJygpbADUphwAAAcG6PIAAQBuAAEOHAAAoMNhaW4AADcJAE5yYXNlZHNlcmlhbGl6ZTswAAABAABsAAEA2BpsAADwgAPACAwAAQIuLzBlbXcBAgEvf42DDnMBUhoAABMAAuAAABU/YXOH3xMAAKHNAABRHAGBAswAABywHPVCaSpudgBEaXRsZYKAFgAAAbEcAAASA2xheQGEwQAAGg50AfcJAAAAAANpc3MAAcUAABwrcwFZCgAAAAADYmxlAAHGAAAcWGqCVwoAAAD2AoADwIJAXlzvl1dhbXCgwnJsAACgDqjAAAD1GfIFdHJlYW2QAACCNxwAAAhFAd4TAACCvxsAAPKCY3QDAHBzdfIAggAA9YMKAABBBOgD0AI8BlUBwwAAEKwbc7IOsw5dEl4SXxIFAsEAAEPHEsgSyhxYHRUpGGFjocMAALAOBQLAAACgGwxyrQ6uDq8OYRcfGPMZshxkdMT9x25vc3RpY3NSAQIAABV1bmRsZcIZFXhwb3J0bxoWZXRpbmZvjxsTbmZvWRcXZXRsZXZlbN0b2hPWEQAB/QSNB8UAABw5P1ky6p7IAAAXYEoJ4hXyZwDjzAcAAB0WAAB9FgAAHRcAAGAA9gCAA/0IYAReEUlLjhNZKnWZA5Do+2xhcnMVAcIAAKR2GnQgGCsCywAAHYNzdBWIxAAAE6MdrBxVgsIAABZgGkUWRhbJG0EceBwQyW90YWxieXRlcwAAbhoQxGV0aWQAAH8WoMZyb2ZpbGUAAAwXAENhbWUB2RMAAIIaGAAAQMFkAACfDscOyA7JDhjDAAB+FvICY2X9QKEGAII1CQAAAPKAhIIFAsgAABSSHRWaHWxyoNVtaW5ldmlkZW9zZWVrc3RyYXRlZ3kAAIAdEMx0YXVkaW9zeXN0ZW0AANca8oACAGNyEMJpbAAANAnygIMBcgBDcm95AVYKAADykxoAACwCFQHAAAAbhx10FhoQxHduZWQAANwYEgZpYWxpemUCAKAAABU8b3I7MAAAAQAA4AACALwSAAD5Et8AAPKAAQFydPIAAgBldADmsQsAABoWAACeGwAA4BsAABccAACMHQAAlh0AABAwAAAxwAAA+hY7F3QdIcIAAEQWERwQzmVlbmNyeXB0aW9ua2V5AADcGxKAAQHAAAALvXZu0gCEA8AAABBAb3JzAO0SCAAAFAgAAEYJAAD0CQAA4w4AAJ8SAADuEgAA1BYAAEoXAAAYGAAA7hkAAD4aAAAeHAAAIBwAAAAAIgAAAAAhxAAAXx2uHQUCwQAAEcoaEmgXbXMQxG9rZW4AAIsVwMdsYXlsaXN0AAAOGABDdGVtAfgSAAABCBwAAAQCw3JvbQAAE2lsZQcZFmV5cmluZ9YaZmsUAcdvd25sb2FkAAAVdW5kZXIeHHMNGAQCwWwAABt1bWRvd25sb2Fkcx0cGWRvd25sb2Fkc44bYmz+AyX9KYEEAsQAABwEwgAAG2pzdQEzCQAAAbIYAABygIID4QAACb9haXMAU25pdGlvbm5hbWVkZGF0YXR5cGU7MAAAAQAAdQABANwcdQAAAERycmVkAfALAAABqBYAAAQCwAAAF21lb3V0bXNRGhJ1ZYoVaXIQy2Vtb3Rldm9sdW1lAADIGxDJYXhyZXRyaWVzAAC2GhDGcWJhbmRzAAAGGfIDdWx0/RAQBQA7MAAAAQAAIgACAPYJAAA0CiEAgkUSAABygIeIxAAAHWkhwAAArhqKGwUCwAAAFKwcEXQdYXIbhsEAAB2wGwLBAAAcGWR1scQAANMYocUAAL0aMcsAAAwcJxxdHQQCwAAAG21lbnRlcGlzb2Rl9BkScHQzCmV59gYAAf2BSQLEAAAcPnIA47IHAAA3FQAAmRoAAH0bAAAREBDCdWcAAPgGAADBC+H92fEgZSPQiWl5l7MgDURxlFXCa+ODWKAenBXNIQcPP+6lWXwwJnVHRmpBQyq96gTiB+Dl43PDAAAcqiNNxI3UxAAAFkkDcZYmHnQ4YukkRiy/TOoODe5zwANsTUs/eyWoZj8AOjAAAAEAAAAAACUAEAAAANAF0QXSBfgJDAxPDFMQVBBIEhwUHRQeFE0VThWHFSEWIhYzFrcWuBa5FroW/hb/FgAXTRcKGAsY9BmrGxMcGBxlHHAchByVHHIdtB0VAcAAAKIPF3QaFyHBAABiCrcKQMZlcnZpY2UAANUR1hHXEeQSEMVlc3VsdAAArgvyAAICcnNpeQAB0BgAACHDAABpGvIbFQHDAAAVyxp0DhcxwgAA2RjqGZMcBQLAAAAQzAsWpRlkbVcAAf4A5EABxQAAFzvIAAAakckAABthxwAAGiwB0AkAABUBwgAAELccc7EaFQLAAAAXdxse1BxjbZkdBQLCAAAT0BgeYR1ha6UBwAAAGK0cY28XBZjAAAAU3xYe0xwQw2ltZQAArQvWEgAB/R2IA8gAABqswwAAGdppg5vLfc4AAByrdADkBQwAAL8WAAD4FgAAkBkAAFIaAAADEQAAQ3lwZQGpCwAAOzAAAAEAANgBBABFFOsAYxYAACwYdQDcHHUAtALDYXNlAACmZXJ2aWNlVhcWcmFwcGVyWBdzd6oL8gACAGJ0APjUCwAAXAQBAG8GPwK4AL4ADAPyAIOEAPfaCwAAPQO9As4A2wKiARMDtgSAAf0kKAvFAAAXgMQAABanwAAAEDsiRHbBU3QAAP0TQQnLPt8lnZxuOE7ibYkhb+ipLFZqO7TjxhdtuoL0DYlNQERBv1MhkBngKunXBlb7uhxVyY7JPDEILT9ixAAAFtg6g/m8QTynwFDAbor5ZUNpfjBsmWgRMCXIB7z160oKG9/sbTZqcKPeW+NgezJVd4bv5QA7MAAAAQAAvgSpACoAHwArAQAALwEAADYBAAA9AdwAGwIAAO8CdQB9AwAAhAMAAMgFBAD+BQEAGgcAANMHAwAPCAEAIwgDACsIAQBCCAAAPgkAAEcJAADMCQAAzwkAABEKBQAiCgMAWgoBAGIKHgCXCgEArwoAALcK3ACVCwwAtwsAALkLAgDMCwEA0AsAANgLAQDbCw0A8AsGABAMAwAbDAIASwwAAFYMgQCTDgAAmg4BAJ0OAgClDgAAsA4BAMMOAADHDgIA5g4KAA0PBACVDwQABxAWACMQAAAuEAEAShAHAFwQAwBhEAAAUREAANMRAABHEgAAURIAAFYSAgCQEgAAlxIBAKMSAQDHEgEA0hIBAN8TBgDpEwAA/RMBAAAUBQALFAAAFBQEAEUVAQBRFQEAYxUBAGYVAACOFW8AEhYDABcWAAAjFgAAJhYAADgWAABlFgAAaBYBAHQWAQB+FgEAqBYAAK0WAADZFgAA5xYBAPAWAAD1FgAA/BYBABwXAAAgFwEAKRcAAEsXAABmFwEAaxcAAG8XAQB7FwEAgRcAAA0YAAAqGAEAohgAAKsYBAC/GAAAxxgAANwYAADtGAAA8BgBAPQYAQD8GAIACxkAAIwZAACOGQEAkxkBAKIZAQC0GQAAuBkDANsZAgD4GQAAChoBABQaAAAtGgAARxoAAGQaAABqGgAAcRoAAKQaAAC9GgEA1RoAAFMbAABYGwAAYhsAAGwbAQBwGwEAdhsAAJkbAACkGwAApxsAAK0bAAC7GwEAxRsBANUbAADbGwAA4RsAABIcAAAqHAAAMRwAADocAAA9HAEATRwAAFMcAQBXHAAAXxwEAH0cAACBHAAAhRwAAIccAQCUHAAAqhwAALAcAADKHAEA1RwAANocAABYHQAAXB0AAKUdAAAV/cQMEQAAABt4HBQ2HBNyFhdyGwgZBQLDAAAxcBZxFk4XFsQbb3ATgQLDAAAWeGNnSwPKAAAbqkC/vg3UYWxyscMAANMYBQLBAADFoxgWbBdyc/sCYXMVAcgAABQ7HGOzGRsCWxuVKOBlcqHEAACqEgUCwAAAE2AQEo4dbnUFAsIAABs6HBTJGG5yJQHCAAB0ZxVoFWkVahVrFWwVpxx0tQo9F/sDZWxvAEhsZXJlcGVhdIKIFQAAAVobAAASAAGCwQAADrljAOEpCAAA6xkAAAG1AsEAAKSLFhSNFmJxXQoFA8MAABZiGhdnGhdoGmJpcMDDdG9tAAALCCHIAAD7Fm8dEMVzc2lvbgAADBgSgIIBxgAAHNN0BIrAAAATZGlhfRYSZGWHFRDJZWxseWZpbmlkAADVGgh6AYYVAAABqRkAABDMYXVkaW9lcGlzb2RlAACEHAQCwAAAwmV4eRYXZXJ2YWxtc9QaZHTygMAAxAAQw2ludAAAhRUARnBpc29kZYIKGAAA86sbAAC6AE8B/gM8/ZATAgLHAAAdfcUAAB2vZG6DMAoAAOIjFwAAoBoAAL4cAAAgADKAAQLCAAAPGXJpc/KAAgJyc2x0FQHCAAAiEhfcG2s+ECMChsQAABZI0jMKAAApEMd1cmF0aW9uAABtGhKGc3NmYWRlAQHFAAAcPmRjBcDAYBAAABb6FhfuGBjXGQUCwAAAFMISEsAZcHUFg8MAAByNHBb0GRQBw29yZQAAFnJhcHBlcgocd1UXAENhdGgBBRkAAAGWGwAAGOoAAHgb8gNpbGUBAG4A0ukbAAAS8gDACIgAgvYSAAAAGNIAANsYEMRycm9yAABUF/KGZW50aWFsAwBlcnOhyAAA5xvEAcNpc3QAABdlc3BvbnNlCRxyBhgQyHJiYWNrZW5kAADbG/q3AgBlbBDIZWRpYWl0ZW0AAPMZAICBAZkAABHUxAAAG89y+iLABAoBcwEyCQAA8rEYAACCAbYDgAL9EBAGJ9o/8hBhZPKAigF5AYIDNOZLnlDEAAAb7+AAABZAYXN5FQLBAAC0qBoXuRhjcM8JRYjBAAAV3RLjB94SxxYXGKDDaWNrAABCFgBGZWNvbmRzg/AZAAABZR0AABDEbmNlbAAABBkSgIEBxAAAHD1s8gNvd27AAsAAANSmHAAABwbo8gJudAEAZAA6MAAAAQAAAAAAHwAQAAAANxJSElMSghKDEoQS5xL8Ex8UIBQhFDQVbRVuFW8VjRUlFigWKRYXFwQYuhioGqkaqhqwG7EbtxsZHDscjRyOHUUBxgAAGH0dZCMXJBclF6AaEM1pY2tzdG9zZWNvbmRzAACDHASDwAAAFHJhbXMLFx1yY2VudHRvdm9sdW1ltRzygAIAcHTAxGlvbnMAAPIS8oJlcgIAc3Sgxm9tbWFuZAAA5xYSAm9sAQEzfImfAGNsAPZwGgAAUQAdAVsANQD2AAhiAYQVAAAB6xsAAAh+AdMRAAABWBsAAPIBdAIAaXSFKwoAAOfDEgAANRQAAFMVAABkFwAA5RgAAMsZAAA0HAAAmB0AAAAhEQAYwgAAgxX6AQEAdADh7BMAAMgZAABA8oACAG54AARpbmVyAAHgAAAagXOCxQ4AAAENFgAA8oADAGFlcgBHb3NlcnZlcgHvGQAAARUcAAAQy3Btb25pdG9yaW5nAABzHQQCwAAAG3Rtb25pdG9yaW5nfx0SdXNQGnJ08oCJABDKdHNlcnZlcnVybAAAaB3ygAIAZXQAR2Vwb3J0ZXLSeRsAAGEB0BwAABDGcmFwcGVyAADXHPIFbml0b3IBAHfStRoAANgBpBwAAAQCwnJrAAAYZWFjaGFibGVnHRpucmVhY2hhYmxlfh1ydfKAiQAQyGV0c3RhdHVzAABKHBDIY2tzZXJ2ZXIAALQcEMR2ZW50AAA/HPIDbmdlggAAAYYcAADygWiDAPIDaXR5wBJiAIV+FQAAAUwaAAAUgsFuAAAUcnJvcgMZPRDygAIAb3YygmN0AgLAAAAcTccAABywaXRlcvIAggCDAAQAAAAEAsFlAAAZdWV1ZWRwYXRo0xoVb3Jvb3QFGHF0AAAAkMkAABzB4S0JAAAaCgAAQOlGEAAAYBAAADgSAAC3EgAA3xYAAPMWAAAiHAAAWRwAAF4cAADUHAAAEAIAAAAygWkCAcYAABwOZ25y9gGAwQiEAkNh9v6vY2QhwgAAAxZdHBDPdGVkb3dubG9hZHN0YXRzAAAcHBDNZXBvcnRwb3NpdGlvbgAA2xzyAm9uAQByAAExGgAAEMRvdW50AAAEGCoZAgCZAAAVfWFjgp0OAAD0IRcAAFAEyQAZAPKCZXSGAASDwXIAABNibGU8EBAqCnKAA4jDAAAXQWFsdRUBxwAAELccc7EawMpuaXR5cmF0aW5nAAD/GABAgu4LAAABphoAABDDdGVtAAC7EhDCcmcAADsQ8gJuZAMAYWlzADswAAABAAB2AAYAjhVvACYWAADnFgEAYhsAALsbAQBcHQAA8oCRgrKAAobgAAAXPG1wCF6CAhgAAAFIGwAA8gdsZWN0aW9uAQB0AAEDFwAAYcIAAA8aEBoRGsEbwhuwHfAAAAGgc4KyBwAA4jcVAACZGgAAfRsAABEAEgABAsMAAByswAAAHXRjYXIA8k0XAADGBPKAgoTygP0IOAjBACgFwMIKCAAAGHEbFbQZGcMbAEABGgIAAAENFAAA8oABgXAAR291bmluaXQ7MAAAAQAAgAACAEEWAACCF38AAABDbnRvOzAAAAEAAH8AAQBTEX8AAPICbmUCAGl0OzAAAAEAAIAAAgD3BgAAMgd/AAAAQ2VudOEJCAAAKAkAABTlRBAAAPETAADxFgAAJBgAAPYYAABnGwAAFBQAMcUAAIgViRVaGwBLYXRjaGhpc3RvcnmEzxoAAAGKHQAAEM10YWxlZG93bmxvYWRzAADZGxDLZXBvcnRlZHRpbWUAAM4aEMR1ZXVlAADxEhjMAABSEQBEYWNoZQHwEgAAAQccAAD2AwD+AEHAEZDCAAAYyYMvBwAAABKAAQHKAAAcOnJu8oCBAXIEAsJzaQAAEmFsnA4WeWVycm9yChdjZqHBAAA9HBDPYXhhdWRpb2NoYW5uZWxzAACCHPKBcAGCbRDLdHJlYW1yZXN1bWUAAM0aEoFtAYLKAAAb8nPygAMAaW1z9gCAj5AYLNYMowUCwQAAGuAbFpgaaXYrA8IAABLewQAAEHRjZWkVAcIAADBfG2AbqBtzYhWhxAAAcRzzgQJwdSuDwQAAFCIFg8EAACRHFa4WEi4WFYTBAAATZBZ4GhsDxAAAFm3JAAAdj29wc1HGAADPGtAa0RrSGoodEMRrbGVuAACZDhKAAQHIAAAZ2m5wBQLAAAASlxodSRxtdAUCwAAAc7cSuBK5EiIcWRxeHNQcorAbbnUFAsEAABD2ExdEHGd0EMtlYWNoYWJpbGl0eQAAbBoQz29ybG9jYWxkb3dubG9hZAAAgRy2AAACA8AAABYXxAAAHLRmcmVpcwABrhwAAPKAAYhrFgEAAf5ARlQZwwAAGqM08bdY48UAABwxWb6KLX12uRs6dcMAABwACvRHDKHGAAAZ1g3SKPwHYwDpLAcAAFsQAADwEgAAOhQAAM0WAAABFwAAuhsAAAccAABnHAAAfB0AACABAQAAGMkAALoSEgJlbIQBLZNv+URzAfUJAAABxhIAADHEAADJG0EceBwQymF2b3JpdGVpZHMAAIwb8gABgmYA51EVAABjFQAAdBYAAPwWAADbGQAAcRoAAL0aAACHHAAAEREQEBojAYLDAAAcP2QAAYYcAADygAIAZ24SgAECDNjCQ3THAAAcyG5idQAAk/4AYfgDJluKg/w9y0g0xHp8FwIdxwAAGjcCnH165cMAABu+0AAAHWlmMrOZ0cQAABbOOTg4SNJZhU2jW1sJe9rowgAAFCMA5f0HAAA2CAAA3QkAAIsWAAAoFwAANBoAAAExAAGBAkZWKkRSxgAAGm5udKHGAAAMFyUCwAAAXLkcuhy7HLwcsR0nFRq1HGx0mQpLGkHCAADZE2EVGhgbGPsCYXRhwQAAnw7HDsgOyQ4qGCsYRYHAAAARHxfmDucOBRRtGyHJAABrGsAcIdAAALMcmx0xwQAAmg6bDmEQEMNsYXIAAKgL8oCQgjYBAAH9CUACxgAAF4HBAAALvGwA8icJAADdD7YLAAH9SKEWwwAAFn4V55ACQckAABorbAA7MAAAAQAAlQAXAOUHAAARCAAANQkBAOIJAADzCQAAogoAALsOAAAaD3YA1RECAMoSAwDkEgAAeRUBAIwVAAC2FgAAVhcBAGkXAQB/FwAAFBgAALgaAADSGwAASBwAAK0dAACyHQAAIckAAIAbWhxVA8IAAKR5HBl7HBbEGmVpduQW5RbmFhYcRhwJAsEAABmMK1ByXl5lcyMAAswAABzWYmQBxRoAACDFdGF0dXMAAMEZzBoQxWVzdWx0AADAGTKDeW5jAgHBAAAby3JzaxDHZW10eXBlcwAATxoEAsAAABJja04aEWy+GWl0EMlpcnN0Y2hlY2sAAK4cKNgAAL8ZtBoSgAEB4AAAHAF2cvKDZGV4AwBlZnTygAIAbnQyA2xvZwIBCNoIbN1pc2KCKAoAAOJGGgAAXBsAAGgcAAABAHKAgQPEAAAZx2VpdAwC/gAAFWN0aXZlOhAYaXNjb3ZlcnlAFmFkBALAAAASZWvtCxthcnRkb3dubG9hZGoaZXQQy3dubG9hZGFzeW5jAABpGhDHYW5saW1pdAAAdxvyAAEAYwABmR0AAPIEZGF0ZQEAcwABUhoAADKAjILIAAAcrABJbGVlcHRpbWVyAWsaAAABwBwAADDCZWQAAJoOmw5hEBDHb3dubG9hZAAAgRcAUHV0b3BsYXljb3VudGRvd24BsxwAAAGbHQAA+hP9CQgCAAEnCQAAAQQZAABygMAGQAHDAAAbd2wEA8RsYXRlAAAWYWNrb2ZmYhoXbnRlcnZhbGcaF3JvZ3Jlc3NoGmJpcBDHcm9ncmVzcwAAeBbygAIAcHUWAAAB/QgxCgw9BjUjwwAAHG9HCJh1ScgAABdfxQAAHWrBAAAYKWMA5AcHAAAlBwAAnA4AAEkaAACSHQAAIAAAIcUAAN4Z1hsQyXRodGltZW91dAAAZhrygIQBcjHDAABzF8Ma5hsQxGJkaXIAAHwVUoCQAsEAABcWaXQQzWVtc2Zyb21zZXJ2ZXIAAEkcEoABAcEAABqXdG0QxHJzb24AADsVEoCCgcEAABgZEMZpYnJhcnkAAD8WEMpvbWVkaWFpdGVtAACyHBDFZXJzb24AANoY8gJlbQIAcHSCOBAAAIKVGgAAAEABxA4AAAG4GwAA8oACAHJ0EMpsYnVtdHJhY2tzAADLGvIA/QGJAAAA4RIWAADFGwAAEKHCAACwGwBDZmlng7cSAADUIhwAADcFdvKBbwGQbvYBAmhl/QwBEgTDAAAao8UAABwxdrkbOnXDAAAcAGFlb3GDLAcAAPtbEAAAlQJKAQEAkgI0AAEAuARNAGAAFQHygMACxQFwAAD9kVhJ/oBbYQN/vU9WF0Gkqpcod5zg/qFH7byvOQTm4VJvCiZNQZssNSn/+jwiFeSgXySjD0DCAAAQNmnk0T67GX/OrBrJAAAaRnvqEllbBBsnulpNzOceeUHhms7BHFKU4jEmU5bFZwIsJyE+AO4tAQAAOgEAAIcDAAALBgAAEQcAALIHAABZEgAANxUAADkXAAAWGAAAaRoAAJkaAAB9GwAA8hsAAIIdAAABAQEBEAEAABUCxAAAFYobo/QYY2ytGhODAswAABysYWMBg4IlMWv9ORUBxAAAF+YZcNEWQcIAANUR1hHXEeQSFQLBAAAXEhoTSxVkdB0M84KGOwPDAAAOwWVvdAGB/JYBBO4kJFB7H4DNV+AAAAcMwQAACfvgAAAHDRUBwAAAFscbcpIcIwECxQAAGq9ycwEqHAAAEMNzZWQAAFERsMlvd25sb2FkZWQAAPwY8gJlcwIAZHUA5p0SAAD/EwAAMRUAAA8XAAAmFwAAthkAAG4aAAAAEQEAVgAAAf0BoQDCAAAZpcoAABzWDL3t27N0APKwCgAAagwQzWV4dHVwZW5kcG9pbnQAABscEMdncGx1Z2luAAD7GBDRdGVzdGl0ZW1zZW5kcG9pbnQAAGYd8oCJABDPZXRpdGVtc2VuZHBvaW50AACAHAQCwAAAH3Zvcml0ZXNlbmRwb2ludH8cHHNwcmVmaXhxdWVyeRocYXQAQXIB9AkAAAFKFwAAEMdoaGVhZGVyAADuGRDXaW9vbmx5c3RyZWFtdXJsZm9ydmlkZW8AALMd8oACAGR0EMZmaWx0ZXIAAIAX8oACAGZ18oJsZP1xKAAAoMVlcmluZwAAlw7yAAEAZgABBwoAADYAgALAECbgAAAG+F76xudBwgAAGcLCAAAcbWZpAEJ1dDswAAABAADcAAIAlg4AAHUQ2wAA8gNyb3cBAG07MAAAAQAA3AACAAgIAABLCNsAACHBAACVDuISAEDScQMAAI4BNhAAAPKAAQFscwBBeTswAAABAABsAAEAkgNsAAFTGgAAcoD9CEAhAKAAAAEuBQLCAAAigBtaHBBsGml5JQHAAAASpg5kYgq3CgBEcmF0ZYImCgAA8wcUAABwAmsA8oABAnRkbAEAksMAABplzQAAHI7jCBYAAM8WAAATFwAAVBsAAEETEMtuZ3N0b3NlcmllcwAA7RnyAIgAAIPFBwAAENJpbnZpZGVvcGxheXNlc3Npb24AAH4c8oACAWdschDDaXplAAB0EPICY2gBAHMAgosWAAAQxWJvb3N0AACVDhDCcmwAAOwLocQAAFYXEMRvbmRzAAB7Ffq4AQFjchDLZXRyeWRlbGF5bXMAAMoacgADAcUAABdYcnN1dwDjqgsAANcbAADPHAAAYB0AACAA8oACAGVzBQLAAAATww4wsAsVGAYZb3MQwmVyAAAHCPKAAQFuZAEGAcUAABxCcuToGAAAmRwAAIYdAACfHQAArB0AAAQAAAUCxgAAEa4bpFUcbXT7Am9yJQHBAAAXdBxzYhpHGxsCS+BjKSpmcGHDAABMFfwZ/Rn+Gf8ZeR01AsUAABFYGxPrG2l0ZBdlF5gdK4nJAAAbgAUCxQAAFkAcE30baG+gxW9saWN5AADrGRDFb3VyY2UAAEgcIMNsYW4AALMa2BsAQ2FzZQHXGwAA0s8cAACRAEVjdGl2ZQFHHAAAAVQdAADyA2Rpb/0DgAIAAPTfGwAAQABWAQ8AAER0aW9u8tkYAAARAQGTHAAA8oACAGN18oVyb3VuZAIAYXBBwQAAZRYUGlQclBwQy2Vsb2Fkc3RyZWFtAACyGhDJYXRpdmVzZWVrAAC9GRDEcnJvcgAA2BgSg25pdIIBxAAAHIVm8gJuZAOCaW5yhCIKAADlThAAANISAAALFAAAZhUAAGgWAADbGwAAEQAQAEhtYWdldGFnc4PHGgAAg1UdAADyA3JvcIQAAacLAAAA9hsBawP9BdEDAn+tF+lX1Zm0qQ7aOlKGZGVnANLLGAAAsPKAwALEAAAA/RFBSMFE7S8K9s1wYS1BRNI+s+d7E0R/5pZfT+KtnPVUJgzaqcEAABV8wgAAEt0YhnLYDSYm6XkDGywusih5OQkyojUCbbbtOB59CMt3CoEFVgDzHgcAAPgOugKhwgAAsA4JAtEAAB1gxwAAGMpiZfMAhgFYGgAABQPDAAAWEhwlsBq2HBh9HGVzdCOBAsIAABeAbG5VAcAAABhBF2O0DgIPGA9VELIS+wJlZgGDgk/gTPOyBALAAAATb3RzfhcTYWNlfxdscBIGaWxhYmxlAQHLAAAddHN2ANKpGwAAk1KAgYouS009lwEAAsMAABYlxQAAFyljb+IjCAAA6A4AAKQbAAA0APMBAWTiFwcAANcHAAAnFgAAAwAFAsEAAKvfGRXgEmVwAEdldHRpbmdz4dYYAADoGQAAEfP8GwAAegECABDKZXh0ZXBpc29kZQAAGBwQymF4ZXBpc29kZXMAABccEMtwaXNvZGVjb3VudAAAGRywx2VjaXNpb24AANMYEMZlY29uZHMAAGUd8gNvd24BAHMA06YcAAAN6PIEb3VudAEAZADStxsAANbyA2xhef0cMAIAguoLAAAApAHGZHZhbmNlAAAcb25leHRlcGlzb2Rlch10eRXygAIAYXAQzGVyaWZ5c2Vzc2lvbgAAxhoEicAAAB5ydHZlcmlmaWNhdGlvbkYcHXB2ZXJpZmljYXRpb24WHBDIdHNlc3Npb24AAPoY8oACAGV0DALfAAAbdXRoZW50aWNhdGXFGhN1bHQ3EGFzJAHGYW5hZ2VyAAAWcmFwcGVyTRp3thJ4FQSMwm9nAAARbnMQEnV07xIQyW5pdGlhbGl6ZQAAfRcARWVhZGVyAe4SAADyGBgAANYBEMlldHNlc3Npb24AAPkYsMJvbgAA5BYQzXluYW1lcmVzcG9uc2UAANYc8gABAGIAAcUaAADyhm50aWNhdIYAEM5vbm5lY3R0b3NlcnZlcgAAFRzyAP4AdYYJAISOAwAA4s8SAAAeFgAApRkAABAA8oACAGhvoM13aXRjaHJlc3BvbnNlAABxHBDHYW5ndWFnZQAAixsQxG5kZXgAAOcZEMtpc3BsYXl0aXRsZQAAsRzyBHJhY2v9CAkCAAD1wxgAAAEA6AGCAZoAUccAAJIbkxuUG5Ubkx0QyW5pcGF5bG9hZAAA2hzyBXRpbmdzAQBq4T4WAAAEFwAABeKcGgAAwhwAAMQcAAAwABDHbmFjdGl2ZQAAFBzyBHNpb26EAADSaBsAAFHygAIAc3RSgIIDwwAAHEjDAAAa1290eRDOb3JjZXN0cmFuc2NvZGUAABMcMcIAANcbzxxgHRDFdHJhdGUAAHcW8oCEgfYAAmlvwAnA/RXIAE4VYNdoRDoQ7P3FAAAchDsKciroSpv5UUEY99NY64TzBgAA6O8WAACtGgAA3xsAAB8cAACsHAAAuRwAAHUdAACEHQAAsR0AAAAAAwAAtgEAAsAAYUyOsb7uZHQAARcMAAABAgHgAAAbrHM7MAAAAQAA3gEHAKkLAACyDgEAXRICAEUU6wBjFgAALBh1ANwcdQABAYJSBrtjoDowAAABAAAAAAASABAAAADjCeQJ5QlBEtwSTxX7FoIZwRkAGhsaUBqwGsAazBpKHLYcbx2RHQFA/PYHAABvDeIALAFQA7EABwBrADYAkwCjAAEAUcMAAFMQVBAcFB0UHhSlBMEAABFwG1bPGtAa0RrSGoodJkcVrhYTdBBlaHFzixYAQAHpCwAA8scZAACcA/ICcHQBAHMAAXYWAAASgAEB4AAADqdtchKAgoRrS/EvbhDHbWljYm9vbAAANhC28AAC/bURSgDEnMFBK6seCZt2BAcG7VxiHUyHEahNesNvdADucwMAAAMHAADUCQAA2gsAABcPAADUEQAAohIAAH0VAACIFQAAHxcAAHkZAACmGQAAMhoAAFobAADMHAAAADAAABAAAABBwgAAFhhpGvIbgh2xxwAABBaxwwAADQwFAsAAABL5CRL6CW1z0cYAAOwTBQLAAAASnA4WChdjZoIAAQOXAAAISsMAAA6VwwAAFvB0YmdpAPI+FwAAUAW2KoAB/ZTkRF0btNUn4AAAFT4N7vCvaR6yYjg+c1HLAADPGtAa0RrSGoodBQLAAAAUDBQXpxhscRsCywAAG9lldBXAwQQJAAASrBITQRQTQhTmCfMAgvM7EAAAhQJNCQUCwAAAwA0PyVQbZG4jgQJPUk8E3GJpIwDBBAzLAAAaYMYAABdgywAAHbABKgoAAAEAAsUAABuCzwAAHbBzd/RQEAAAoAZ7AIIBCcABA8EAABA8xgAAFrYlZOnoqRUBwgAAFqQactEYGwLGAAAZr2l2FQHHAAAfoh10TBcFAsEAACeuGX8dGWkbbnalgcAAABGiEhEKGwLGAAAZrWR2IccAAEsXahoFAsEAABloGx6mHGR0EMZvcmt1cmwAAO0SscMAADwUBQLAAAARDRQYgRptbxOB/RTAAMAAAA63wQAAEFHCAAAWJNDDYW1lAADoEhQBw3RlbQAAsHUVczUQMgF0AwCmAAAKG2luc+EGCAAARggAAAOFcBUAAPKAAQJzYW52TwAC/kAHpAt5wojYPmJ604fBxQAAHXfKAAAcRml3AOcbBwAA5gcAAMIOAABmFgAA7RsAAPQbAABLHAAAnR0AABQBBAAAgIGCngAAByvJAAAaziHDAADwEgccEgAAAjPg8SpfYWgBLQEAAAAAAMACoP5AVkFVJovwldoGAVDgqgvaWpkOC6lW4TMG6eYAw0aoMgwzKXKGtGBKlzGgCiuJ4OCZwwAAEvFQmD/IBMIAABjJWmmc3Dw+FwUlRgDiLwcAAKgLAAC1GAAAIDAFAsAAACeAG1ocIVscohxidKDGZXJzaW9uAACzEqjDAAClCwQCwAAAHWNhdGlvbnZlcnNpb27EGhtwcm9iZXJlc3VsdGUaaXkARWFuZGxl4ZQOAABwEAAAAoJFFwAA9gCAwSAFghIb0gZNNgAAAf2JCQLJAAAZlUOGHLyjxwAAHXTKAAAZe8IAABn7cADyMgEAAMcINQHAAACsXxtzAA8iFCoUBQLBAAASlQ+pNRpzehWEwwAAE2QWeBobAssAABuqaHBbwAKEygAAGcgAQAEsAQAAhIcSAAAVAcMAABZiHXJLHPMAAXT2sAsAAD4AAQAmDPEAoAEFwsAIBAAAGO8WEsMOGmkaAQEB4AAACmBz5UUJAACUDgAAcBAAANgWAABFFwAAWhoAAAAgEAEDAS9/jYMOcwFSGgAAGwLBAAASu2R0xgUAAP0RyQLBAAAQO8gAAByswgAAFqc7MAAAAQAA3AACACsBAAA+AdsAOzAAAAEAAHYABgCOFW8AJhYAAOcWAQBiGwAAuxsBAFwdAAAAAAL9ZS0ORwAyXfdLxVYomG0gJlz1U8/z6x3BAAAMVBbuhlhnTS5VLGItRmmkkMMAABt3GXFBSbl5j8eKXeAAABvKyQAAHDpkeQDzhQMAAC4XJQEBAAHgAAAakXM7MAAAAQAAJQADACAHAgC2DgAAYBIhABsCwAAAGCllcgUCwAAAJ1scohww1BpEHHscZXMBQTswAAABAABdAAIAQRABANkRWwChxgAAXBtfAMAGNMcAABvZ4AAADrfCAAAXVBUBxgAAGIIdc4EcIcYAAF8drh1rA+AAABjQYmVvBQPEAAAWYhoXZxoXaBpiaXAFAsAAABS7GxeBGm1uq/0A0AjAAAAUDcYAABZ4wcQAAKMYscgAAKgaEMxkeWRvd25sb2FkZWQAAGQaMoCBAsIAABjbY3PygIKBBQLAAAAeYx0UthZkbAUCyQAAFkAcE30baG+gyGRvd25sb2FkAAB7FxDGdHRlbXB0AAB2FjIBdwKCRR1iGYBhcwHyBgAAALYAAIj9GoEAxwAAG47CAAAY8scAABp4hCcBAAAAMcMAADEVMhVuGjHAAAB8GZAayxoqFAACxQAAG85ycwE0EAAAAVwcAAAQyW9yYWdlaW5mbwAA0hjyAAEAdAFFCAAA4eIbAACcHQAAMAANZWNvbW1lbmRhdGlvbgAB4AAAHLdzAbEaAAAAEM1pbWFyeWltYWdldGFnAABFHABJYXloaXN0b3J5AWMaAAABIRwAAPKAAgBscghKjmIQAAAAAEFkjtsLAAAAEMhpbGVuYW1lcwAA+BgY7wAA5xLQxHRpc3QAAHAVEMhocmVzaG9sZAAAfRwARXRhdHVzAbAaAAABthwAABDGbmFibGVkAAASHPKGZmluaXR5AwBlc3TygAIAZnL2HwFt/SWhBwHHAAAcHWTh7QYAACYHAABE8g4XAACVA1KAkALEAAAbR2F5AADAASH9HPEOFUi8jolBBPr+sS/By6aSB+mdtXkZrAKCEWsyPtKTcU9rH2JU5sIIdMgAABdfbwdRmQnCAAAWJCtSMkpFxAAAFjceh/j7cQDqBwcAACUHAAD8BwAAHwgAAOEJAAC+CwAAnA4AADYXAABJGgAAZxoAAJIdAAAgAQACAAAFAsAAABE9FaD/CWZnK8AEhOYAABPfKob59EEFAsAAABdTHB3pG29yBcDAAMUAABMOFxWnFhNpGiFVGmEdFQHDAAAWfBtyERcQyHVkaW9vbmx5AACjHfIGcGlzb2RlgQAAAXIdAADyBW9uZXh0ggABPRYAAADyA25jZQEAdAHzCQAAgnkVAADygIGEENJwdHZpZGVvcGxheXNlc3Npb24AAHwcBQLAAABGwxlPHFAcpB0TiRpzdBDGZXF1ZXN0AADHG/IFYnlpZHMBAHIAAZIcAAAYwQAArxryA3lpZAEAcgABKhwAAPKDYWNrAgBicxjCAAC8GfIEdWV1ZQEAcgABWRsAAMDHbGF5bGlzdAAAdxfygAIAcHHygAIAb3IQx29zaXRpb24AALUSEMNkYXQAANoL8gADhGVwdAE9AQAAAAAAA/7AfeZXX2eX8Y4Htw1xX3mpWIiKK6zFbMFoYBUYDybHQ9Z/GvmDDVfEAAAbp8MAABdrxAAAFtgmZVauVsYAABc6FxZ0qInFAAAWKmgRMCXIbloaU+t2JhfJPsIAABU+JOKN1G5TLPI7uskAABpkZG92ADowAAABAAAAAAAcABAAAADKBcsFzAWXCpgK8QvyC/ML9Av1C/YLOBYpF0sXexd8F4EXDRi/GMcY9Bj1GAsZLRpqGqQadhuBHNocNQHAAAAaKxp3jBW2Fn8XBQLAAAAZkBsYkRplcwAAAIHAAAAczAFECAAAghwYAADyAXIBAHMAgqsOAAAhwwAAaRxrHABGZXNzaW9uATwWAADyCRoAAFUDAEhvd25sb2FkcwH3GAAAAZEbAAC42AAAbRXyAWXABkCQAOXdDgAAOhAAACEYAAAUHAAARxwAAFQdAABQAAAAQW4BQwgAAPpaEAAAhwJUAgEAowMRAXsBZwABAMYA8oACAG92NgEAjP4ApEABdco6v+bFAAAXO8kAABthxwAAGiwAAdAJAAAASGVzc3Rva2VuhmcVAAABpxwAAHYAAAL9kEQCcA/QyfIJGfk+K8QAABw54AAAEp5jdAABWRIAAAUCwwAAIoAbWhwQbBppebHBAADFBxjIAADmGfIFb2x1dGUBAHAAAdEWAAC1AsEAAKZWFxZYF3N3qgsQxWRvbmVkAADDDvKAAQFuc/YAgAL9EAkIDRBqpArEAAAXSmFzAAD+gAOqD/1hFfB20aazPQmOQln7asFGq28k5U8vpER5Rg3tJugZlj5sTA1K9jYUtrxCItxo7JFaq0dMUQdRVos7yQAAHXRDV90XHSyWVLjBUcsAkUYxJrIbhwErGogpWSE/rRIh8jK7hED9veZcwAAAA3sA68wHAADbCQAA1AsAADAQAAAdFgAAfRYAAKAWAAAdFwAA3xgAAJcZAABVGgAAYR0AAGEBAAAAABDBXQAABwAQwT4AAAEAEMEpAAAAAACA8AAAAAAAIQAAAAAACP7//wAQRksx9MA0K0WZXyLTvH7yApu4TxsMcxHXCS9zFWaeN/NupcljKtNNo3+AwzATa1K2R+VOLbpzihR9JG9JBMeCQBlrAVWc/EWYt38TAwTYagxOQjtjaBjKGhZKLTWwBE8EIUYgjwqCIqfcDG3tmPdsqZtNf3HRN8yOS0ep1Llv2AwIQuAAAAAANCiE5D9r3lH93BuekA2xGax/5t4c7yfRqHZ76vBGPXSPlnVs7HIVQwcPy3feM+UXZsLgAAAAAeAAAAAHLdz4mIoTfWAd9jfyt852KTAxMjM0NTY3ODk+XXh5eg==","N":"AMKf","E":"OjAAAAAAAAA=","H":"ZAfvIsw="},"crateNames":{"N":"b","E":"OjAAAAAAAAA=","H":"odpdLnY="},"name":{"N":"AMKf","E":"OjAAAAAAAAA=","H":"9kdZJpI="},"path":{"N":"BBb","E":"OzAAAAEAAJMbGAEDAAEABgAAAAgAHQEnAQMALwEGADgBAAA9Ad4AHgKpAMoCAQDNApgAZwMBAGwDBQBzAwAAdQMEAHsDBgCDAwAAhgMAAIkDAACNAwAAkANuAAAEzAHOBS4A/gUDAAMGBwAMBgAADgbeAO8GAwD0BgIAAAcDAAUHAQAIBwUADwcCABQHAwAZBwAAHAcAAB4HAQAjBwcALQcAAC8HoQDTBwcA3AcCAOAHBADmBw4A9gcHAAEIAQAFCAIACggAAAwIAgAQCAAAEggAABUIAgAbCAUAJQgCACoIAAAsCAAALwgBADcIAwA8CAEAPwgAAEEIAABDCOgALQkHADYJAAA4CQIAPAkAAD4JCABICXYAwAkBAMMJAADFCQIAygkGANIJCADcCQoA6AkIAPMJAAD1CQAA+AkAAPsJAgAACgAAAgoEAAkKAQAPCgAAEgoAABQKAQAZCgsAJgoBACkKVwCCCgsAjwoDAJQKAQCXCgIAmwoAAJ0KBQCkCgEApwoJALMK9QCrCwAAsAsAALILAAC0CwAAtgsBALoLAQC+CwAAwgsDAMgLAQDMCwAA0QsAANYLAQDZCxEA7AsBAO8LAQDzCyEAFgwAABgMDAAmDCMASwwAAE0MAgBSDAIAVgw9ApUOAACYDgEAmw4BAJ4OAQChDgEApg4AAKsOAACtDgAAsA4AALIOAADBDgoAzQ5MABsPCAElEA4AOhAAAD4QAgBDEAEARhAAAEgQAQBMEAIAUxAAAFwQAABfEAAAYRB1AdgRXAA2ElUAjRIBAJASEgCkEgUAqxIBAK4SBgC3EgAAuhIAAL4SAQDEEgAAxhIAAMgSAADLEgAA0BIAANISAADVEgAA2BIAAOISAADkEg0A9BIBAPcS4gDcExgA9hMUAAwUFQAjFBUAOxT/AD8VAABGFQAASRUAAFIVAABUFQAAVhUAAFsVAgBgFQEAZBUAAGcVFgCAFZUAFxYDABwWEgAwFgsAPRYAAEAWAABDFgAASBYAAEwWFgBlFgAAZxYBAG4WAABwFgAAdRYZAJAWJgC5FioA5RYBAOgWAADqFgAA7BYAAO8WAADxFgAA9RYBAPkWAQD9FgAA/xYAAAEXAAAEFwcADRc1AEQXCQBPFwQAVhcAAF0XAABfFwAAZBcAAGcXAgBsFwEAcBcBAHQXAAB3F0kBwhgPANQYAADWGAAA3RgAAOEYAADjGAAA6RgAAO4YAADwGAEA8xgAAPUYsgCpGRIAvRkBAMUZAgDJGQAAzxkAANIZAADUGQAA1hkDANwZAQDfGQAA4xkAAOUZBQDsGWAAThoBAFEaAABUGgAAVxoCAFwaAABgGiEAhBoqALIaAAC0GgAAthoAALoaAAC+GgAAwhoAAMQatAB6GwAAfRsAAH8bAACCGwAAhxsBAIobPADIGwAAyxsCANEbAADVGwAA1xsxAAwcAAAOHAAAEhwsAEAcAQBEHCwAcxwDAHgcAAB7HFoA2hyHAGMdAABlHVAA","H":"Jq4MrYw="},"entry":{"N":"KIeLAkDNi","E":"OjAAAAEAAAAAABwBEAAAAAAAAQACAAUABwAmASsBLAEtAS4BNgE3ATkBOgE7ATwBHAIdAsgCyQLMAmYDaQNqA2sDcgN0A3oDggOEA4cDiAOKA4wDjgOPA/8DzQUCBgsGDQbtBu4G8wb3BvgG+Qb6BvsG/Ab9Bv4G/wYHBw4HEgcTBxoHGwcdByAHIQciBysHLAfbB+UH/gf/BwAIAwgECAgICQgLCA8IEQgTCBQIGAgZCBoIIQgiCCMIJAgpCCsILQguCDEIMggzCDQINQg2CDsIPghACEIINQk3CTsJvwnECcgJyQnRCdsJ5wn0CfYJ9wn5CfoJ/wkBCgcKCAoLCgwKDQoOChEKEwoWChcKGAooCoEKjgqTCpoKnAqjCrEKsgqpC6oLrQuvC7ELtQu4C7kLvAu9C78LwAvBC8YLxwvKC84LzwvQC9ML2AvuC/EL8gsVDCUMTAxQDFEMlA6WDpcOmg6dDqAOpA6nDqgOtQ62DrcOuQ67DrwOvg6/DsAOzA4aDyQQNhA7EDwQPRBBEEIQShBLEFAQURBWEFcQXhA1EowSoxKqEq0SuxK8EsASwRLCEsUSxxLKEs8S1xLdEuAS8hLzEvYS2hPbEzoUPBVFFUgVTRVRFVMVWBVZFVoVXxVjFX4VfxUbFi8WQRZCFkUWYxZkFmYWdBaPFrcWuBbkFucW7hb4FvwW/hYDFwwXQxdmF2sXbxd2F8EY0xjcGOAY5RjtGPIY9BjLGc4Z0RnbGeEZ6xlTGoIagxq9GoUbhhvKG9MbcRx5HA==","H":"PLeYu+XbAvVIOWTyKmiY"},"desc":{"N":"GIm","E":"OzAAAAEAABgWIQIAAAIABQAyADkAAwA+AAIAQgDkACkBBQAxAQIANQEHAD4BiAHIAgQAzwIAANECAADUAgIA3QIBAOACAQDjAgAA5QIBAOgCAgDtAoAAcAMHAHoDAgCBAwMAhwMBAIoDAACMAwAAjgMCAJIDcwAHBAAAHwQAADEEAQC2BAAAxwUAAMsFAgDPBQAA0gUIANwFAQDfBQIA4wUFAO4FAADyBQIA9gUBAPsFAQD/BQMACAYBAAsGAAANBuEA8AYAAPIGAgD2BgkAAwcAAAYHAgAKBwUAEQcCABoHDgAqBwIAMgeCALYHAQC6BwQAwgcDAMkHAQDMBwQA0gcBANUHAADZBwIA3gcBAOUHAADoBwAA6wcAAO0HAADwBwEA8wcGAPsHAQD+Bw0ADQgAAA8IAAARCAkAIQgDACcIAAApCAAAKwgAAC0IAgAxCAcAOggGAEIIAABECAIASAgAAEoI3AApCQIALQkFADUJAAA3CQAAOgkBAD8JfgC/CQAAwQkAAMMJAQDGCQQAzQkBANEJAADUCQEA2gkBAN4JAADgCQAA4wkCAOcJAADpCQAA7AkBAO8JAQD0CQAA9gkFAP8JAAABCgcACgoEABEKAAATCgAAFQoDABoKAwAfCgMAJAoEACoKAAAsCgEAMQoAADMKIwBYCgAAXAoAAF4KAQBiCh8AhAoCAIgKBACOCgAAkwoAAJUKAwCaCgAAnAoEAKMKAACmCgUAsAoCALYK3QCVCwkAoAsAAKILBQCpCwIArQsAAK8LAACxCwAAtQsAALgLAQC7CwIAvwsCAMMLAQDGCwEAyQsBAM4LAwDTCwAA2AsLAOULBADrCwAA7QsCAPELAQD3CwIA/gsBAAMMAAAFDAEACAwAAA0MAAATDAQAGQwBAB4MBAAkDAEAKAwEADYMAQA7DAEAQAwEAEgMAQBMDAAAUAwBAFYMQQKaDgAAnA4BAKAOAACiDgAApA4AAKcOAgCrDgAArQ4AALAOAACyDgEAtQ4HAL4OAgDEDgYAzA4AAM4OBQDWDgIA2w4DAOMOAADlDgYA7Q4EAPMOAwD4DgIA/A4AAP4OAAAEDwQACw8GABQPAAAWDwEAGQ93AJIPAQCWD3AACBABAAwQDQAbEAIAIxABACYQBgAuEAAAMBACADYQAAA5EAAAOxACAEEQAwBGEAAAShAEAFAQAQBTEAAAVhABAFwQAABeEAEAYRAKAG0QBQB0EN0AUxGDANgRXQA6EgIAPhIAAEASAABCEgQASBIAAEoSAQBNEgEAUxIAAFUSBgBdEgEAYBIkAIYSAQCJEgUAkBIAAJISBQCbEggApRIFAKwSAQCvEgAAsxIBALcSAAC7EgEAwBICAMUSAADHEgAAyhIBAM8SAQDSEgAA1BIBANcSAQDdEgAA4BIAAOISAADkEgIA6BIEAPISAQD2EgAA+RLiAN4TBwDsEwkA9xMFAP4TAgACFAAABBQAAAYUAAAJFAIADRQBABAUBQAXFAEAHBQAAB8UAgAjFAYALBQIADcUAwA8FAIAQBQAAEIUAQBFFO8ANhUAADoVAAA8FQAAQhUBAEUVAABIFQEATRUAAFEVAABTFQEAVhUAAFgVAgBfFQAAYRUAAGMVAABnFQcAcBUEAHYVAgB6FQAAfRUDAIQVAQCHFQAAjRVwAP8VAgAEFgAACBYEAA8WAAASFgAAFBYAABYWAAAZFgAAGxYAAB4WAgAiFgAAJRYDACsWAAAtFgIAMxYAADUWAAA3FgAAOhYAAEEWAQBFFgAAShYBAGMWAQBmFgIAbhYAAHAWAAB0FgAAeRYAAH0WAACAFgEAhBYBAIgWAgCMFgMAlhYAAJgWAgCdFgIAoRYAAKMWAACmFgAAqhYCAK8WAwC0FgAAtxYCALsWBQDCFgAAxBYAAMYWAADJFgIAzxYBANUWAADXFgIA4BYAAOQWAwDqFgAA7BYAAO4WAADxFgAA9hYCAPwWAAD+FgEAARcAAAMXAAAGFwMADBcAABMXAQAYFwAAHhcAACAXAQAjFwAAKhcMADgXAAA/FwEAQxcAAFYXAABkFwAAZhcAAGgXAQBrFwAAbRcAAG8XAABxFwAAdBcAAHYXAAB4FwIAfhcBAIIXggAHGAIACxgBAA8YAgAUGAAAHhgBACMYAgAnGAEAKhh8AKsYBQC0GAQAuhgAALwYAgDAGAEAxxgAAM0YAADPGAAA0xgBANYYAADcGAEA4BgBAOMYAADlGAAA6RgAAOwYAQDyGAAA9BgAAPYYAAD8GAYABBkAAAcZAAAMGWwAfRkAAH8ZAwCEGQAAhhkAAIgZAgCOGQAAkBkDAJoZAQCdGQMAoxkAAKgZAACqGQAAtxkAALoZAQDAGQEAyRkCAM4ZAQDRGQEA1BkAANsZAADfGQAA4RkAAOMZAADnGQAA6RkAAOsZAADtGQAA8hkAAPUZAQD9GQMAAhoAAAQaAAAGGgIACxoAABEaAAAYGgMAHhoIACgaAAAwGgAARxoBAFEaAABTGgAAXBoAAF8aAABzGgQAeRoAAH0aAgCCGgIAiBoAAI0aAQCVGgIAnhoBAKYaBAC2GgAAuBoCAL0aAADEGgAAxxoDANAaAADSGgAA1BoAANYaAADYGmwASxsAAE0bAABUGwYAXxsAAHgbAACFGwEAixsAAI0bAACTGwMAmxsCAJ8bAAChGwAArBsAAK8bAACzGwIAvxsDAMYbAADKGwAA0xsAANcbAADcGwAA4xsCAOsbAQDwGwAA9hsCAPsbAAAOHAAAGRwAACQcAAAmHAAANxwCADwcAABEHAEARxwAAFYcAABhHAIAbhwBAHEcAAB2HAAAeRwAAHscAACWHAEAmxwCAKkcAACxHAAAuhwCAMIcAgDHHAAA0RwAANYcAADcHHUAVR0CAIAdAACnHQEAqh0AAA==","H":"EMph4J8="},"function":{"N":"AJEj","E":"OzAAAAEAAFsDkAAAAAcAJgEWABwCAQDIAgEAzAIAAGYDAABpAwIAcQMgAP8DAADNBQEA/QUAAAIGAAAEBgAACwYCAO0GOAArBwMA0QcBANsHAQDfBwAA5QcAAPUHAAD+B0QALAkAADUJAwA7CQIARwkAAL8JAQDCCQAAxAkBAMgJAwDRCQEA2wkBAOcJAQDxCSgAJQoAACgKAQCBCgEAjgoBAJMKAQCWCgAAmgoCAKMKAQCmCgAAsQoDAKcLCACxCxoAzQsMAOsLAADuCwEA8QsDABUMAgAlDAEASgwAAEwMAQBQDAMAVQwAAJQOBACaDgQAoA4iAMwOAQAaDwAAJBABADQQEwBKEBYA1xEAADUSAQCMEgEAjxIAAKMSAQCqEgEArRIBALUSCADAEiMA8hIFANoTAwD1EwAACxQAACIUAAA5FAIAQxQAADsVAwBAFSAAYhUEAH4VAwAWFgAAGxYBAC8WAQA8Fg8AYxYSAI8WAQC3FgMA5BYVAPsWCAAMFwEAQxcBAE4XAABUFxMAaRcCAG0XCQDBGAEA0hgbAO8YAADyGAAA9BgBAKgZAAC8GQEAvxkGAMgZDQDaGQsA6xkBAE0aAABQGgAAUhoEAFoaBgB5GgAAghoDAK8aBAC1GgAAtxoKAMMaAABfGwAAeRsDAH4bAACAGwEAgxsGAMcbAADJGwEAzhsCANIbAgDWGwAACRwCAA0cAAAPHAIAPxwAAEIcAQBxHAEAdhwBAHkcAQCwHAAA0RwAANYcAwBiHQAAZB0AAIAdAAA=","H":"qkq00vc="},"type":{"N":"AId","E":"OzAAAAEAACEc2QADAAEABgAAAAgAHQEnAQMALAEAAC8BBwA4AQAAPQHeAB4CqQDKAgEAzQKYAGcDAQBsAw0AewMGAIMDAACGAwAAiQMBAIwDcgAABAwCDgbeAO8GAwD0BgIA+AYAAAAHAwAFBwEACAcFAA8HAgAUBwMAGQcAABwHAAAeBwEAIwcHACwHuADmBxcA/wcDAAQIBAAKCAAADAgCABAIAAASCAAAFQgCABsIBQAlCAIAKggAACwIAAAvCAEANwgDADwIAQA/CAAAQQgAAEMIsAH1CQMA+wkCAAAKAAACCgQACQoBAA8KAAASCgAAFAqHAJ0KCwGrCwAArgsAALALAACyCwAAtAsAALYLAQC6CwEAvgsAAMILAwDICwIAzAsAANELAADTCwAA1gsBANkLugKVDgEAmA4BAJsOAQCeDgEAoQ4BAKYOAACoDgAAqw4AAK0OAACwDgAAsg4AALUOAgC5DgAAvA4AAMAOWQAbDxgBOhACAD4QBgBGEAAASBABAEwQAgBREAAAUxAAAFwQAABfEAAAYRBTArcSAAC6EgAAvhICAMISAADEEgAAxhIAAMgSAADKEgEA0BIAANISAADVEgAA1xIBAN0SAADiEgAA5BJWAj8VAABGFQAASRUAAE0VAABSFQIAVhUAAFsVAgBgFQEAZBUAAGcV1AA9FgAAQBYBAEMWAABFFgAASBYAAEwWFgBlFgAAZxYBAG4WAABwFgAAdRZuAOUWAQDoFgAA6hYAAOwWAADuFgEA8RYAAPUWAQD5FgEA/RYCAAEXAAAEF08AVhcAAF0XAABfFwAAZBcAAGcXAgBsFwEAcBcBAHQXAAB3F1oB0xgBANYYAADdGAAA4BgBAOMYAADlGAAA6RgAAO4YAADwGAMA9RjGAL0ZAQDFGQIAyRkAAMsZAADOGQEA0RkBANQZAADWGQMA3BkBAN8ZAADhGQAA4xkAAOUZZwBOGgEAURoAAFQaAABXGgIAXBoAAGAaTgCyGgAAtBoAALYaAAC6GgAAvBoAAL4aAADCGgAAxBq0AHobAAB9GwAAfxsAAIIbAACHGwEAihs8AMgbAADLGwIA0RsAANMbAADVGwAA1xsxAAwcAAAOHAAAEhwsAEAcAQBEHC0AcxwDAHgcAQB7HFoA2hyHAGMdAABlHVAA","H":"MYz3wyw="},"alias":{"N":"`","E":"OzAAAAEAALUdAQAAALUd","H":"YhUMUyw="},"generic_inverted_index":{"N":"d","E":"OjAAAAAAAAA=","H":"V4OR6PE="}}') \ No newline at end of file diff --git a/api/search.index/type/318cf7c32c.js b/api/search.index/type/318cf7c32c.js new file mode 100644 index 00000000..7af3ddc9 --- /dev/null +++ b/api/search.index/type/318cf7c32c.js @@ -0,0 +1 @@ +rd_("EDk[\"AAAAAAAAAAABExwAAAABmhoAAA==\",\"AAXtAgAAzwUAAHgaAAB0HQAAsB0AAAcvBwAA/xsAAAEcAAADHAAANhwAAGgcAABdHQAAOjAAAAEAAAAAABkAEAAAAAMGCgYwB+YH6gdXClgK8RKIFSIXPRdRF8gYrRmuGWsacRqkGs4atxveG00chxyjHLMczRw6MAAAAQAAAAAAKwAQAAAA7gIDBAQEBQQGBAcE/gX/BTEH4AfhBycJOQk6CdkJWQqNCk8MiRXHFt8WRxdJF1IXxhirGawZuBlTG3YbuRv8G/0bFBwfHH4chhyqHKscyhzLHM8c0ByNHTswAAABAAAzASwA6QUAAPAFAAAFBgEA1wcAAOkHAAAyCQAAVgoAAD4MAACTDwAAUhGAADUUAQCtFgAAwxYAAEUXAQBIFwAATRcAAIIXfwC5GAAAxRgAAMsYAACWGQAAqhkAADEaAQA5GgIAPhoAAHAaAACgGgAArBoAAGcbAgDzGwAA/hsAABYcAAAzHAAAWBwAAHAcAACOHAAAphwAAL0cAAC/HAAAYR0AAGcdAAByHQEAfx0AAJsdAAA6MAAAAQAAAAAAMwAQAAAA8QXyBfMF+QUHBggGCQbYB9kH2gdFCc8JIwwSD5kSsRJ/FtEW0hYpFw0YwxjJGKYZqRmvGQwaFhpFGmUakhqTGs0aZhtuG3EbwxvqGyEcMBxRHF4chRyPHJgczhzUHFgdWR1oHX0dhx07MAAAAQAADgExAD0BAAD6BQIAMwkAANgJAAAgEAAAmhICAPASAAAFFAAAixUAADEWAACnFgAAIRcAADkXAAA8FwAATxcAALIYAADEGAAA0BgAAPoYAAAHGQAADBlsALQZAAANGgAAQRoAAEkaAACbGgAAoRoAANYaAADYGmwAYBsAAGIbAQBtGwAApxsAAO4bAAD7GwAABxwAACscAAAtHAAAUhwAAGscAAChHAAApBwAAKgcAADJHAAAWh0AAH4dAACJHQEAkh0AAKMdAAA6MAAAAQAAAAAAIgAQAAAAygXLBcwFyw4eEB8QhRKwEowVgRezGJwanRqeGp8aohqrGlsbXhtqG3AbdRutG7wb5xsEHAgcNBxTHG8cXB2aHZ0dnx2sHTowAAABAAAAAAARABAAAAD4Ei0WqBa/GMAYlxk8GokaixpsG+gb8Rv0GzIcRhxXHF8cqRw6MAAAAQAAAAAACgAQAAAA9QUTD+8Sqhi0GG8bAhyRHJkcfB2lHTowAAABAAAAAAAJABAAAAC+CQAUSxepGAsZuxspHIgckBzBHAKQGwAAmB0AAAT9BwAA7RsAAJQcAABpHQAAA70bAABOHAAAsh0AAA==\"]Go[\"AAAAAAAAAAAAAAAAAAEUDwAAA4cSAACJEgAANhcAAAYVDwAAFg8AAIgSAACKEgAANxcAADgXAAAD9QsAADUVAAA2FQAAATgWAAAAAAAAAAGFHQAAAAErGgAA\",\"\"]Fo[\"AAAAAAAAAAAAAAAAAAEUDwAAA4cSAACJEgAANhcAAAYVDwAAFg8AAIgSAACKEgAANxcAADgXAAAB9QsAAAE4FgAAAAAAAAABhR0AAA==\",\"\"]Bk[\"\",\"AAAAAAAAAAAAADswAAABAADbAAEASgDbAA==\"]AAg[\"AAAAAAL9GwAAHBwAAAMLFwAA+BgAAD4aAAAC9xkAADAcAAA7MAAAAQAA7QAEABsPdQCDFQAALBh1AGAbAAACExwAAGccAAAAOzAAAAEAABAABwDNFgAAdxcDAAYYAwCaGgAAXBsBALIbAwB8HQAAArobAACQHAAAApcaAACQGwAAAasbAAAAAAGQGgAAAAAAAUIXAAAAAAGFHQAA\",\"AAAB3BsAAAABuAcAAAHABwAAAAH/DgAAAAAAAAAAAAAAArALAABoFwAAARIXAAA=\"]Hc[\"AAAAAAA7MAAAAQAAfwABAIIXfwAAAYMVAAAAAAAAAAAAAAAAAAABQhcAAA==\",\"AAACAA8AANwbAAAAAAAAAAP2CwAADxcAABAXAAABRRsAAAAAAAAAAAAAAZkKAAA=\"]Bk[\"\",\"AAAAAAAAAAAAADswAAABAADbAAEAPgHbAA==\"]BEi[\"AAAAAALOAgAA6QIAADowAAABAAAAAAAJABAAAADjAtkOFhcXFzoXSBfFGDkaOho7GgTRAgAA5QIAAOobAACeHQAABecCAADwEgAAIRcAAD4XAACkHAAAB9kCAADaAgAAyw4AAB4QAACMFQAAuhgAAF4bAAA6MAAAAQAAAAAACAAQAAAA2wLdAosS+BIqFqgWyxrbG1QcBAgZAABpGgAAAhwAAKIcAAAEABQAAEwcAACIHAAAgh0AAAPiAgAAFhgAABkYAAAD/QcAAPIbAABpHQAAAAABkBoAAAAAAAFCFwAAAAABhR0AAA==\",\"AAAAAUUMAAABjBoAAALdBwAAhxYAAARGDAAAJhgAABcaAACRGwAAAcUCAAAAAAAAAAAAAAAAOjAAAAEAAAAAAEQAEAAAAMsCAAQCBN4H7Qf0By4JLwkxCcEJ5AkkCpUKoAodDEkMYRDVEdYR2BFNEk4SjhKUEuQS5RLmEvET8hP0EzgUeBWFFYcVKxaGFogWihahFqMWxRbGFtcW2BYeFwwYIxgkGCUYJxgoGPYY9xgEGXkZkhnpGRgaGRoaGmMajRqOGtQa1xvaG+wbGRxHHA==\",1]AIa[\"AAAAAAAAAAAAAd0CAAA=\",\"AAAAAAA7MAAAAQAADQAEAP4VAwCcFgMAChoBAJobAwACXQoAAF8KAAA7MAAAAQAANgAPAPYFAAAHDAMAPRIDAAEUAwCLFgEAqRYDAA4YAwDMGAMAnBkDAPwZAwABGgMAchoDAM8aAwCSGwMAuRwDADswAAABAAAUAAYAORIDAJIWAwDEFgAA9RsDAGAcAwCaHAMAOzAAAAEAABgABwDdCQMA1Q4DALsYAwB+GQMABRoDAEobAwDbGwAAOzAAAAEAABsABwB3FwMABhgDAIMZAwB8GgMAnhsDALIbAwDiGwMAAAAABKYdAACnHQAAqB0AAKodAAAGFQ8AABYPAACIEgAAihIAADcXAAA4FwAAAjUVAAA2FQAA\",1]AGg[\"\",\"AAAAAAA7MAAAAQAADQAEAP4VAwCcFgMAChoBAJobAwACXQoAAF8KAAA7MAAAAQAANgAPAPYFAAAHDAMAPRIDAAEUAwCLFgEAqRYDAA4YAwDMGAMAnBkDAPwZAwABGgMAchoDAM8aAwCSGwMAuRwDADswAAABAAAUAAYAORIDAJIWAwDEFgAA9RsDAGAcAwCaHAMAOzAAAAEAABcABgDdCQMA1Q4DALsYAwB+GQMABRoDAEobAwA7MAAAAQAAGwAHAHcXAwAGGAMAgxkDAHwaAwCeGwMAshsDAOIbAwAAAAAEph0AAKcdAACoHQAAqh0AAAYVDwAAFg8AAIgSAACKEgAANxcAADgXAAACNRUAADYVAAA=\"]Fc[\"AAAAAAOVGQAAlRoAAJYaAAAAAAAAAAAAAAEUDwAAA4cSAACJEgAANhcAAAYVDwAAFg8AAIgSAACKEgAANxcAADgXAAA=\",\"\"]Ag[\"AAAAAAAAAAEHFgAA\",\"\"]BBg[\"AAAAAAAAAToVAAADPQEAACAQAACoHAAABB8QAACFEgAA5xsAADQcAAAFkRYAAIkaAADoGwAA8RsAADIcAAACqhgAALEYAAABqRgAAAAAAU4cAAA=\",\"AAACBhkAAPkZAAAAA2AKAAADDwAAVBIAAAILFwAA+BgAADowAAABAAAAAAAMABAAAAAnDAIWAxb3GTgaRRpRG1IbaxshHIocixy2HDowAAABAAAAAAAKABAAAAATGkAa7hsFHCAcahygHIkdlR2ZHasdOjAAAAEAAAAAAAgAEAAAAPYLDxcQFygXfBmKGowb4BuMHTowAAABAAAAAAAMABAAAACLEioWzhYcGB0YsxnLGkUb+Ru3HMwceh2xHQO+GwAASRwAAGsdAAAETBwAAGwdAACWHQAAnB0AAAITGAAAkBsAAAADhxIAAIkSAAASGAAAAQ4XAAABkBoAAAE/GgAAOjAAAAEAAAAAABcAEAAAALwHvQe+B78HwQfCB8MHxAdECEUIwwlhChQMTgzKDiEQIhArECwQnhI3FNQWFRj6GQ==\"]Ag[\"\",\"AAACShcAAPsYAAA=\"]Kk[\"AAABmBkAAAAAAAKZEgAA9xkAAAOaEgAAmxIAAJwSAAABHhoAAA==\",\"AAACBhkAAJgZAAAF6gkAAOsJAADsCQAA7wkAAEEUAAABuAcAAAAAAe4bAAAAAAAAAAAAAAAEvhIAAL8SAABXGgAAWBoAAAbpCQAA7QkAAO4JAADwCQAAsxYAAG0aAAA=\"]BFo[\"AAA6MAAAAQAAAAAABwAQAAAA6BN6GacZFRpLGkkbiRy1HAR4FgAAaBoAANgbAACiHQAAB9EYAAAfHAAAyhwAAMscAADPHAAA2xwAAI0dAAAFuRgAAIwZAACkGQAAMhoAAPMbAAA6MAAAAQAAAAAABwAQAAAA+QXPCXYW0RYJGaYZZRtYHQT6BQAA+wUAAPwFAAAtHAAAAxIUAACrGgAAmh0AAAUcGAAAHRgAAFccAACpHAAAzBwAAAKqGAAAkRwAAAGEHQAAAALtGwAAtR0AADswAAABAAAGAAMAHRoAAKYdBACzHQAAAcgcAAAAAAHFHAAA\",\"AAAFtRkAABUaAABLGgAAgxwAALUcAAA6MAAAAQAAAAAACQAQAAAAMQw1DDYMPQx4FqUW5hloGt8bYB06MAAAAQAAAAAACAAQAAAA+gv9C/4LBAwyDKQWEhrbHKAdAqQZAAB1HQAAAwkZAADNGgAAmBwAAAAAAAAAAAAAAAA6MAAAAQAAAAAABwAQAAAA8BjYGYIbzBvNG3UccR2QHTowAAABAAAAAAAVABAAAADmCfsL/AsADAEMKAwpDC0MLgwvDDAMMww0DDcMOAw6DDsMPAxDDEQMKxR7FQ==\"]AGc[\"AAAAASoUAAACOQkAADoJAAAAA8MYAAAuHAAAOhwAAAXEGAAAmxoAAKEaAADGHAAAyRwAAAKiGgAAXB0AAAGzGQAABocZAACIGQAAiRkAAIoZAAC+GwAAbR0AAAIAFAAAlx0AAAKiGwAAoxsAAAG1HQAAOjAAAAEAAAAAAAkAEAAAABIYHRq9G6Ydpx2oHakdqh2yHbMdAcgcAAAAAT8aAAABxRwAAAABQhcAAA==\",\"AAGIHQAAAAAAAAM6HAAAOxwAAI4dAAAAAboYAAAAAAAAAAAAAARsFwAAxxkAAFkaAADIGwAAOjAAAAEAAAAAAA0AEAAAALkHuge7B8wJ8AtCDMMOkQ+CEp8SoRIfFCAUIRQ=\"]B@k[\"AAAFLwcAAOEJAABEFAAAtRkAAIMcAAABRQwAAAItGgAAdhsAAAAEKRcAAA0YAABxGwAAwxsAAAcxFgAAIRcAALQZAABJGgAAYxsAAKcbAACSHQAAOjAAAAEAAAAAABIAEAAAAIEXXhv1G/Yb9xv4G1McYBxhHGIcYxxvHJocmxycHJ0cnR2fHawdBKgWAAC/GAAA9BsAAF8cAAADAhwAAJkcAAClHQAABQAUAABLFwAAqRgAAAsZAACIHAAAAZgdAAAAAU4cAAAAAAAAAAAAAAGFHQAA\",\"AAACRBQAAKcZAAACKhQAAKIdAAAAARcXAAAFRgwAABEXAACOGwAAkRsAANkbAAACHRwAAB4cAAABghYAAAAAAnIbAABLHAAAARMYAAAAARIYAAABDhcAAAGQGgAAAvEYAADVGwAAOzAAAAEAAB4ADwAuAAAANwAAADoAAAAGDAAAUREAAFMSAADfEwcA/BMAABkUAQAxFQAAMxUBACsXAQAuFwUA/BgBAG4aAAA=\"]EEk[\"AAAAADswAAABAAD9AGYAAwQCADEHAADgBwEAJwkAAFkKAABFFAAASBQBAEsUAABOFAAAUBQBAFQUAABWFAAAWBQAAFoUAABcFAEAYBQAAGIUAABkFAEAZxQAAGkUAABsFAAAbhQAAHAUAQB0FAAAdhQAAHgUAQB8FAEAgBQAAIIUAACEFAEAhxQAAIoUAQCOFAEAkRQAAJMUAACWFAAAmBQAAJoUAACcFAAAnhQBAKEUAACjFAAAphQAAKgUAQCrFAAArhQBALEUAAC0FAAAthQBALkUAAC8FAAAvhQBAMEUAADEFAAAxhQBAMkUAADMFAEA0BQBANMUAADVFAAA1xQAANoUAQDeFAEA4hQBAOUUAADnFAAA6RQAAOwUAADuFAAA8BQBAPQUAQD4FAAA+hQAAPwUAAD+FAEAAhUBAAYVAAAIFQEACxUAAA0VAAAPFQAAEhUBABYVAQAZFQAAGxUAAB4VAQAhFQAAIxUAACYVAQApFQAALBUBADAVAACJFQAAxxYAAFIXAAC4GQAA9BkAAFMbAAC5GwAAFBwAAKocAQDcHHUAOzAAAAEAANICYwBICXUAdRDbAFMRfwA1FAAARhQBAEoUAABMFAEATxQAAFIUAQBVFAAAVxQAAFkUAABbFAAAXhQBAGEUAABjFAAAZhQAAGgUAABqFAEAbRQAAG8UAAByFAEAdRQAAHcUAAB6FAEAfhQBAIEUAACDFAAAhhQAAIgUAQCMFAEAkBQAAJIUAACUFAEAlxQAAJkUAACbFAAAnRQAAKAUAACiFAAApBQBAKcUAACqFAAArBQBALAUAACyFAEAtRQAALgUAAC6FAEAvRQAAMAUAADCFAEAxRQAAMgUAADKFAEAzhQBANIUAADUFAAA1hQAANgUAQDcFAEA4BQBAOQUAADmFAAA6BQAAOoUAQDtFAAA7xQAAPIUAQD2FAEA+RQAAPsUAAD9FAAAABUBAAQVAQAHFQAAChUAAAwVAAAOFQAAEBUBABQVAQAYFQAAGhUAABwVAQAgFQAAIhUAACQVAQAoFQAAKhUBAC4VAQBFFwEAghd/ALkYAAA+GgAAoBoAAKwaAABoGwEAcBwAANMcAAA7MAAAAQAAtgALAB4CpgDqBQAA8QUCAAcGAgDYBwIAzwkAANMJAAA/DAAACg8AAHYWAACvGQAAOzAAAAEAANMBDgA9AQAA+gUCAF4KAACuCgAA/w4AABsPdQAgEAAAmhICACwYdQDEGAAADBlsANgabABgGwAApBwAADswAAABAAAIAAQAygUCAJwaAwCiGgAANBwAAAAAAAAAAAACNRUAADYVAAA=\",\"AAAAAAA7MAAAAQAA2wABAHUQ2wAAAf8OAAA=\"]DAk[\"AAAAOjAAAAEAAAAAABAAEAAAANAJDBQjFjsXQRelGSwaNRo2GoAakRquGmEbbRysHNIcjx0F0RgAAHoaAABIGwAAGhwAAK4dAAA6MAAAAQAAAAAADgAQAAAA7wVGCVYKlAtSESwWrRauFrQWtRbTFk0XqhntGXAaOjAAAAEAAAAAAAkAEAAAAEUJVAygFu4ZmRqjGkYbMBxIHIUcOzAAAAEAAEIAHwDFAgAAMwkAADMKAABcCgAABwwDANoOAAA9EgMAARQEAIsVAAAHFgAAixYBAJYWAACpFgMADhgDAMwYAwAHGQAAfRkAAJwZAwDvGQAA/BkDAA0aAAByGgMAzxoEANYaAABgGwAAkhsDALgbAABSHAAAfxwAAGYdAACjHQAAOjAAAAEAAAAAACgAEAAAAMcC9gsLDDkSOhI7EjwSsBLrE5cWmBaZFpoWmxbEFg8XEBcYF7oYfBm8G+Ab9Rv2G/cb+BsTHBscYBxhHGIcYxxlHG8cgByBHJocmxycHJ0cXB06MAAAAQAAAAAALQAQAAAA4gfdCd4J3wngCdUO1g7XDtgOixKREvgSKhYtFqgWzhYcGB0Yuxi8GL0YvhjAGH4ZfxmAGYEZlxkFGgYaBxoIGjcamBrLGkUbShtLG0wbTRuXG5gbmRvoG1QcqRw7MAAAAQAALAATAPUFAAATDwAAzRYAAHcXAwAGGAMAtBgAAAgZAACDGQcAaRoAAHwaAwCaGgAAXBsBAJ4bAwCyGwMA4hsDAEkcAACiHAAAWx0AAHwdAAA6MAAAAQAAAAAACgAQAAAAvgkAFKcYjxq6G7sbTByIHJAclxyCHQUWGAAAGRgAAJcaAACiGwAAoxsAADowAAABAAAAAAAJABAAAADLB/0HUxerG/IbIxwkHCUcJhxpHTswAAABAAAFAAIAph0EALMdAAAAAZAaAAAAAAABQhcAAAAAAYUdAAA=\",\"AAACGhgAABsYAAABVRIAAAXHBwAASggAADQJAACfCwAAoQsAAAL9EwAAtBYAAAFFGgAAAYMVAAAAAAGaGgAA\"]Ck[\"AAAAArQHAADIFgAA\",\"AAAAAAAAAAAAAeIHAAAAAAAAAAAAAAH0BQAA\"]Mo[\"AAABRxsAAAGCHAAAAWIaAAACphwAAHIdAAAAAAACixoAAEUbAAAEfBoAAH0aAAB+GgAAfxoAAAE0GgAA\",\"AAF7GgAAAAGCHAAAAVwSAAAAAnYWAADNGgAAAAAAAAAAAAAAAAS2GgAAQBwAAGMdAABwHQAAOjAAAAEAAAAAAA8AEAAAAOkLkg9SEpASCBQJFAoUGhbwGfEZ8hkuGi8aMBoXHGUd\"]A@k[\"AAAAAcgWAAAAAAFUDAAAAATLDgAAHhAAAIwVAAB1GwAAAAFpGgAAAAEWGAAAAAAAAfULAAABOBYAAA==\",\"AAAAA8oCAAB3FgAAyBYAAATUDgAABxQAAOIWAABnGgAAAtkOAAAWFwAAArIZAACYHAAAAAAAAAGCHQAAARYYAAAAAAAABZkOAABRGgAARBwAAHQcAAB7HAAAOjAAAAEAAAAAAA0AEAAAABsC6AedEq8S/xPVFtYWJhd/F/4Ythm3Gcoacxs=\"]Am[\"AAAAAAHqAgAAAAE6FQAA\",\"\",1]Dg[\"AAAAASQWAAACAw8AADYWAAABLBYAAAFFCQAAA4MVAAAFGAAA0xoAAAAB4gcAAA==\",\"\"]Io[\"AAAAAAAAAAAAAAAAAWYaAAAAAAYVDwAAFg8AAIgSAACKEgAANxcAADgXAAAD9QsAADUVAAA2FQAAATgWAAAAAAAAAAG2FgAAAAErGgAA\",\"AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAABthYAAAABKxoAAA==\"]Dk[\"AAAAAcoCAAAAARMAAAABewIAAAAAAAAAAAAAAAAAAhsCAADoBwAA\",\"AAAAAAAAAVQMAAA=\"]Bk[\"AAAAAAAAAAAAAAAAAAAAAAH1CwAAATgWAAA=\",\"\"]Bg[\"AAAAAAAAAAAAAAAAAAAAAAAAAQYMAAA=\",\"\"]Gk[\"AAAAATQHAAAAAZwPAAABHwIAAAAAAAAAAAAAAAAABCsAAADUBQAADxAAAB8aAAA=\",\"AAAAATQHAAAB/BIAAAAAAAAAAAAAAVMXAAAAAAAAAg4GAAAQBgAA\"]C@k[\"AAACaBwAAF0dAAAB2BsAAAd7FwAAfBcAAKwZAADqGQAAehoAACgcAACGHAAAAYEdAAAF0hYAAPcZAABlGgAAZRsAAIcdAAAC2AkAAJMcAAAExBYAAHAbAAATHAAAbBwAAAADmhoAAJEcAACZHAAAAQAUAAAAAAEdGgAAAcgcAAAAAAHFHAAA\",\"AASKFQAApxoAACccAAA8HAAAA+EJAAANFgAAHRcAADowAAABAAAAAAAqABAAAADUBw8M4g7sDvAO8Q5HEkgSTBJPElASURLsE+0T7hPvE/ATBhYOFhAWERYyFhsXIRgiGIsZjRkJGmoabBpOG08bpBsAHDUcbRyEHI0cpRysHFQddx2vHTswAAABAABhAAYA2RFbAHsXAQD0GQAALRoAAOEbAACuHQAAOzAAAAEAACoACgAIACEA7wUAANMWAAAgGAAAjBkAAO0ZAADvGwAAtBwAANMcAACDHQAABQoPAAAaFwAAHBcAAKMaAACpGwAAAQcWAAACExwAAGwcAAABxhoAAAFpGgAAAAFCGgAAAAAAAAA6MAAAAQAAAAAASwAQAAAA0wfVB9YHWgpbCqcKqAqpCqoKqwqsCq0KtQrqCw0MDgyTDt0O3w7gDuEO4w7kDuUO5g7nDukO6g7rDu0OLRAuEC8QMxBFEkYSSRJKEksS3hP2E/cT+BP5ExsUeRV6FQQWBRYPFh4W4BbhFscYexmZGfgZDhoPGhAaERpHGkoarRpQG78bwBvBG8IbBhwSHBgcMRw3HDgcbhw=\"]E@o[\"\",\"AAXtAgAAzwUAAHgaAAB0HQAAsB0AAAcvBwAA/xsAAAEcAAADHAAANhwAAGgcAABdHQAAOjAAAAEAAAAAABkAEAAAAAMGCgYwB+YH6gdXClgK8RKIFSIXPRdRF8gYrRmuGWsacRqkGs4atxveG00chxyjHLMczRw6MAAAAQAAAAAAKgAQAAAA7gIDBAQEBQQGBAcE/gX/BTEH4AfhBycJOQk6CdkJWQqNCk8MiRXHFt8WRxdJF1IXxhirGawZuBlTG3YbuRv8G/0bFBwfHIYcqhyrHMocyxzPHNAcjR07MAAAAQAAMwEsAOkFAADwBQAABQYBANcHAADpBwAAMgkAAFYKAAA+DAAAkw8AAFIRgAA1FAEArRYAAMMWAABFFwEASBcAAE0XAACCF38AuRgAAMUYAADLGAAAlhkAAKoZAAAxGgEAORoCAD4aAABwGgAAoBoAAKwaAABnGwIA8xsAAP4bAAAWHAAAMxwAAFgcAABwHAAAjhwAAKYcAAC9HAAAvxwAAGEdAABnHQAAch0BAH8dAACbHQAAOjAAAAEAAAAAAC8AEAAAAPEF8gXzBfkFBwYIBgkG2AfZB9oHRQnPCSMMEg+ZErESfxbRFtIWKRcNGMMYyRimGakZrxkMGhYaZRqSGpMaZhtuG3EbwxvqGzAcURxeHIUcjxzOHNQcWB1ZHWgdfR2HHTswAAABAAAMATAAPQEAAPoFAgAzCQAA2AkAACAQAACaEgIA8BIAAAUUAACLFQAAMRYAAKcWAAAhFwAAORcAADwXAABPFwAAshgAAMQYAADQGAAA+hgAAAcZAAAMGWwAtBkAAA0aAABBGgAASRoAAJsaAAChGgAA1hoAANgabABgGwAAYhsBAG0bAACnGwAA+xsAAAccAAArHAAALRwAAFIcAABrHAAAoRwAAKQcAACoHAAAyRwAAFodAAB+HQAAih0AAJIdAACjHQAAOjAAAAEAAAAAACIAEAAAAMoFywXMBcsOHhAfEIUSsBKMFYEXsxicGp0anhqfGqIaqxpbG14bahtwG3UbrRu8G+cbBBwIHDQcUxxvHFwdmh2dHZ8drB06MAAAAQAAAAAAEQAQAAAA+BItFqgWvxjAGJcZPBqJGosabBvoG/Eb9BsyHEYcVxxfHKkcOjAAAAEAAAAAAAoAEAAAAPUFEw/vEqoYtBhvGwIckRyZHHwdpR06MAAAAQAAAAAACQAQAAAAvgkAFEsXqRgLGbsbKRyIHJAcwRwBmB0AAAP9BwAA7RsAAJQcAAADvRsAAE4cAACyHQAA\"]Bk[\"AAAAAAAAAAAAAAAAAAAAAAAAAuALAABoEAAA\",\"\"]Bk[\"AAAAAAAAAAAAAAAAAAAAAAAAAuULAABtEAAA\",\"\"]Bk[\"AAAAAAAAAAAAAAAAAAAAAAAAAt8OAAABDwAA\",\"\"]Fg[\"AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAABthYAAAABKxoAAA==\",\"AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAABthYAAAABKxoAAA==\"]Ac[\"\",\"AAAAAbQHAAA=\"]Bk[\"AAAAAAAAAAAAAAAAAAAAAAAAArcHAADQBwAA\",\"\"]Bk[\"AAAAAAAAAAAAAAAAAAAAAAAAAocKAAAbFAAA\",\"\"]Bg[\"AAAAAjIWAAAKFwAA\",\"AAAAAAAAAZQaAAA=\"]Ag[\"\",\"AAAAAAAB2hwAAA==\"]Ao[\"\",\"AAAAOzAAAAEAAGwAAQCSA2wA\"]Bg[\"\",\"AAAAAAAAADswAAABAABsAAEADBlsAA==\"]Fg[\"AAAAAZwHAAAAAfkPAAABqAIAAAAAAAABuhsAAAAAAAAAAANEAAAA5QUAAOcSAAA=\",\"AAAAAZwHAAABqhMAAAAAAAAB+RsAAA==\"]Bk[\"AAAAAAAAAAAAAAAAAAAAAAAAAtIOAACPGQAA\",\"\"]Bk[\"AAAAAAAAAAAAAAAAAAAAAAAAAsQFAADgDgAA\",\"\"]Eg[\"AAAAAAHpAgAAAAHqGwAAAecCAAAC2QIAAF4bAAAAAQIcAAA=\",\"AAAAAAAB3QcAAANGDAAAJhgAAJEbAAA=\"]Do[\"AAAAAAAAAAGkHAAAAtkCAADaAgAAA9sCAADdAgAA2xsAAAAAAeICAAA=\",\"AAAAAAAAARcaAAA=\"]Ig[\"AAAAAa0HAAAAAANdCgAAXwoAAI0WAAABXgoAAAAAAAAAARQPAAADhxIAAIkSAAA2FwAABhUPAAAWDwAAiBIAAIoSAAA3FwAAOBcAAAAAAmUDAADDCQAA\",\"AAAAAa0HAAAB6gIAAAABOhUAAA==\"]Ao[\"\",\"AAAAAAADAg8AABgPAACyEgAA\"]Cc[\"AAAAAAAAAAAAAAAAAAAAAAAAAy0KAAB8CgAAMRcAAA==\",\"\"]CEg[\"AAAAAAA6MAAAAQAAAAAAHgAQAAAANBZ+FqAZsRmGGswaWhvvG/AbFhwiHCwcShxYHFkctBy9HL4cvxzAHMIcwxxSHWcdbx1zHXUddh1/HYMdmx06MAAAAQAAAAAALQAQAAAAIwyxEn8WERccFykXDRgpGMkY+RiyGQwaFho4GpMaURtrG24bcRuOG6YbqRvDG9kbFRwuHC8cOhw7HFEcXhxkHI8clhy2HMQczhzUHFMdWB1ZHWgdfR2HHY4dpB06MAAAAQAAAAAAMQAQAAAAERQnFrsWshj6GLAZtBkTGhsaQBpBGogapRrFGmIbYxtkG20bdBunG68b+hsFHAccHRweHCscLRxbHGkcahxrHJMcoByhHKccqBzGHMkcWh1uHXkdfh2KHZEdkh2THZUdmR2rHTowAAABAAAAAAAeABAAAAASFIIWKBcqF4EXsxgeGkYaihpZG1sbahtwG3UbrRvmG+cbBBwIHFMcbByVHF4dhh2MHZodnR2fHawdrR20HTowAAABAAAAAAAXABAAAABzEBMUkRa/GLMZPBqJGosaxhpsG7Yb8Rv5GzIcRhxXHLcczBx4HXodex2LHZQdsR06MAAAAQAAAAAADgAQAAAA7xJ9F7EYoRkcGm8bvhuRHJ4cnxzHHGsdbR2hHaUdOjAAAAEAAAAAABMAEAAAABAUohZLF1AXsBgLGTQachvFGykcKhxLHGYckhzBHGwdhB2WHZcdnB0DExgAAEIaAACYHQAAA+0bAACUHAAAtR0AAAYSGAAAHRoAAL0bAABOHAAAXRwAALIdAAAFDhcAADMaAACHGgAAXBwAAMgcAAAAAT8aAAABxRwAAA==\",\"\"]Ag[\"\",\"AAAAAAABCxcAAA==\"]Cc[\"AAAAAAAAAAH7GwAAAAAAAbsbAAA=\",\"AAAAAAAB6RsAAA==\"]AEc[\"AAAAAAAAADowAAABAAAAAABvABAAAAD5EvsS/hL/EgETAxMFEwcTChMMEw4TDxMSExMTFRMYExkTGxMdEyATIRMjEyUTKBMpEysTLRMvEzITNBM2EzcTORM7Ez0TQBNCE0MTRRNHE0oTSxNNE08TURNTE1UTWBNZE1wTXhNgE2ITZBNlE2gTahNrE20TbxNyE3QTdhN3E3kTfBN9E38TgROEE4YTiBOJE4sTjROPE5ETlBOWE5cTmRObE54ToBOhE6MTpROoE6kTrBOtE7ATsROzE7YTtxO5E7sTvRO/E8ITwxPGE8gTyRPME80TzxPRE9QT1RPXEw==\",\"\"]Ck[\"\",\"AAAB3BsAAAABuAcAAAAAAAAAAAAAAAAAAAKwCwAAaBcAAAESFwAA\"]No[\"AAAAAAL9GwAAHBwAAAMLFwAA+BgAAD4aAAAC9xkAADAcAAA7MAAAAQAA7QAEABsPdQCDFQAALBh1AGAbAAACExwAAGccAAAAOzAAAAEAABAABwDNFgAAdxcDAAYYAwCaGgAAXBsBALIbAwB8HQAAArobAACQHAAAApcaAACQGwAAAasbAAAAAAGQGgAAAAAAAUIXAAAAAAGFHQAA\",\"AAAAAAABwAcAAAAB/w4AAA==\"]Fi[\"AAAAAAAAAAABExwAAAABmhoAAA==\",\"AAAAAAF+HAAAAARFGgAAzRoAACEcAACYHAAAAu4bAACJHQAAAAAAAAGQGwAAAWkdAAA=\",1]ANo[\"AAAAAAABoBoAAAXTCQAACg8AAAMWAACWHAAAfR0AADowAAABAAAAAAAMABAAAACuCiAQARoCGgMaBBqvGyAcuRy6HLscvBxmHQUfEAAAhRIAACoXAAAbHAAANBwAADswAAABAAAMAAYAzhYAAH4ZAwAFGgMAMhwAAHodAQCxHQAAOzAAAAEAAA0ABQCqGAAAgxkDAJ4bAwDiGwMAax0AAAWpGAAAkBwAAGwdAACWHQAAnB0AAAGQGwAA\",\"AAAAA28VAAAkFwAAsBsAAAF7FgAAAAONFgAAIRwAAJ4dAAADXgoAACAcAABqHQAAAWccAAAAA80WAABcGwAAXRsAAAG6GwAAApcaAACQGwAAAfIbAAAAAAABdxsAADowAAABAAAAAAAhABAAAAD8Bz4JrwpLDCMQdBA3EjwUPRRtFW4VjRUSFhMWFBYVFiUWKBYpFjcW2RYjFyUXfhcEGJMZlBlkGqgaqRqqGrEbxht9HA==\"]Cg[\"\",\"AAAAAAAAAAAAAAAAAAAAAAAABCgJAAAqCQAAKwkAAPMTAAA=\"]Bg[\"AAAAAAAAAAAAAAAAAAAAAAAAAcUHAAA=\",\"\"]Cc[\"AAAAAAAAAAAAAAAAAAAAAAAAA7YHAAB2CgAA4xMAAA==\",\"\"]Ck[\"AAAAAAAAAAAAAAAAAAAAAAI1FQAANhUAAAAAAAAAAAAAASsaAAA=\",\"\"]AKa[\"AAAAAAAAAAAAAAAAAWYaAAAAAAAAAAAAAAAAArYWAACFHQAAAAErGgAA\",\"AAAAAAA7MAAAAQAADQAEAP4VAwCcFgMAChoBAJobAwACXQoAAF8KAAA7MAAAAQAANgAPAPYFAAAHDAMAPRIDAAEUAwCLFgEAqRYDAA4YAwDMGAMAnBkDAPwZAwABGgMAchoDAM8aAwCSGwMAuRwDADswAAABAAAUAAYAORIDAJIWAwDEFgAA9RsDAGAcAwCaHAMAOzAAAAEAABcABgDdCQMA1Q4DALsYAwB+GQMABRoDAEobAwA7MAAAAQAAGwAHAHcXAwAGGAMAgxkDAHwaAwCeGwMAshsDAOIbAwAAAAAEph0AAKcdAACoHQAAqh0AAAYVDwAAFg8AAIgSAACKEgAANxcAADgXAAACNRUAADYVAAA=\",1]Ag[\"\",\"AAAAAAABRgkAAA==\"]Bg[\"AAAAAAAAAAAAAAAAAAAAAAAAAS0MAAA=\",\"\"]Bg[\"AAAAAAAAAAAAAAAAAAAAAAAAAbUKAAA=\",\"\"]Bg[\"AAAAAAAAAAAAAAAAAAAAAAAAAS8MAAA=\",\"\"]HFm[\"AAAAAioUAACCHAAAA7IcAADbHAAArh0AADowAAABAAAAAAAHABAAAAA2FLQWjBmWGaQZZxv+GzMcOjAAAAEAAAAAAAwAEAAAANEC5AJUDNIWCRnuGWUabxqSGkYbZRvqG0gcOjAAAAEAAAAAABEAEAAAAOcCBxb6GPMZARoCGgMaBBqhGiAcfxy5HLocuxy8HMYcZh1+HTowAAABAAAAAAAXABAAAADZAtoCORI6EjsSPBKwEpIWkxaUFpUWlxaYFpkWmhaiGqsaWxsTHBscgByMHLgcmh06MAAAAQAAAAAAJwAQAAAA2wLdAt0J3gnfCeAJ1Q7WDtcO2A6RFn4ZfxmAGYEZsxkFGgYaBxoIGjcamBpKG0sbTBtNG5cbmBuZG7Yb2xv0G/kbXxypHHodex2LHZQdsR06MAAAAQAAAAAAFwAQAAAAqhixGIMZhBmFGYYZmhpvG54bnxugG6EbvhviG+Mb5BvlG0kcmRyeHJ8cWx1rHaEdOjAAAAEAAAAAAAkAEAAAAKcYqRg0GnIbuhu7G2wdlh2XHZwdBuICAAATGAAAQhoAAKIbAACjGwAAmB0AADowAAABAAAAAAAHABAAAAD9B+0bIxwkHCUcJhyUHLUdOjAAAAEAAAAAAAkAEAAAABIYHRq9G6Ydpx2oHakdqh2yHbMdAcgcAAAB9QsAAAI4FgAAPxoAAAHFHAAAAAFCFwAAAAABhR0AAA==\",\"AAABGRYAAAJ8HAAAoh0AADowAAABAAAAAAAYABAAAACdCvoL/Qv+CwQMMgzUDjgSVBJcEuoTBxQ2FnsWpBbiFqgYyhgSGtUaSBsaHCgcfhygHTowAAABAAAAAAB6ABAAAAAyCv0TRhRHFEoUTBRNFE8UUhRTFFUUVxRZFFsUXhRfFGEUYxRmFGgUahRrFG0UbxRyFHMUdRR3FHoUexR+FH8UgRSDFIYUiBSJFIwUjRSQFJIUlBSVFJcUmRSbFJ0UoBSiFKQUpRSnFKoUrBStFLAUshSzFLUUuBS6FLsUvRTAFMIUwxTFFMgUyhTLFM4UzxTSFNQU1hTYFNkU3BTdFOAU4RTkFOYU6BTqFOsU7RTvFPIU8xT2FPcU+RT7FP0UABUBFQQVBRUHFQoVDBUOFRAVERUUFRUVGBUaFRwVHRUgFSIVJBUlFSgVKhUrFS4VLxWGFYcWOhc6MAAAAQAAAAAAEAAQAAAA6gXTCT8MVAzpEw0UdhagFiYY+RgJGbIZFxqZGqMazRqYHAfFAgAArgoAALAZAAClGgAAWxwAAGkcAACnHAAAB+sTAABlHAAAgRwAAF4dAACGHQAArR0AALQdAAAAA30XAACaGgAAohwAAAHFGwAAARkYAAACqxsAAGkdAAABNhcAAAAAADswAAABAAA6AY0AOwABAEkAAABnAwEA3QUBAPcFAQAABgEAJgcEALIHAQC1BwAAxQcBAMkHAQDQBwAA4wcAAEYIAQBJCAAAKQkAAD8JBQDGCQEAGwoGACYKAQArCgAALQoAAGMKAABrCgAAlwoBAJ4KAQChCgAAsAoAAJwLAACkCwAA2gsAAN4LAADhCwMA5gsCAO0LAAD3CwIA/wsAAAIMAQAFDAAAEAwDABgMBAAeDAQAOQwAAEAMAQDFDgEAyA4AANMOAADbDgEA3g4AAOgOAADuDgEA8g4FAPoOAQABDwAABA8EAA0PAwAZDwAAlg8DAAcQAQALEAMAFBAAABoQAAAwEAIAYhACAGkQAwBuEAMA0xEBAEISAQBWEgIAWhIBAIMSAQCGEgAAmBIAAKASAACiEgAA5xIGANkTAADnEwAA+hMBABYUAAAcFAAAIxQGACwUBQA+FAAAMhUAADgVAQBwFQcAfRUAAIIVAAAIFgQAFxYAAB0WAAAzFgAAORYBAHkWAQB8FgEAgBYBAIQWAQCJFgAAjhYAAKYWAACvFgMAvxYDAMkWAwDPFgEAExcCAB8XAQAtFwAANBcBAD8XAQBMFwAAAhgAABcYAAArGAAAoxgDAKsYAgCvGAAAtRgDAP8YBAAKGQAAjxkCAJoZAQDnGQAA9RkBAB8aCwB2GgEAphoAAMQaAADHGgIAVBsEAIsbAACNGwAArBsAAK4bAADrGwAAORwAAEUcAABVHAEAsRwAAFUdAgBfHQAA\",1]Go[\"AAAAAAAAAAAAAAAAAWYaAAABFA8AAAOHEgAAiRIAADYXAAAGFQ8AABYPAACIEgAAihIAADcXAAA4FwAAAjUVAAA2FQAAAAAAAAAAArYWAACFHQAAAAErGgAA\",\"\"]Bk[\"AAAAAAAAAAAAAAAAAAAAAAAAAncKAADkEwAA\",\"\"]Cc[\"AAAAAAAAAAAAAAAAAAAAAAAAA84HAAD8CwAANAwAAA==\",\"\"]Fg[\"AAAAAZEHAAAAAiMAAADuDwAAAZ0CAAAAAAAAAAAAAAAAAAVBAAAA4gUAAPgFAAAPFAAAKRoAAA==\",\"AAAAAZEHAAABkxMAAA==\"]Eg[\"AAAAAAAAAAAAADswAAABAAC3AQIASgDbAD4B2wA=\",\"AAAAAAAAAAAAADswAAABAAC3AQIASgDbAD4B2wA=\"]Bk[\"AAAAAAAAAAAAAAAAAAAAAAAAAnIKAADfEwAA\",\"\"]Bg[\"AAAAAAAAAAAAAAAAAAAAAAAAAZcSAAA=\",\"\"]Bk[\"AAAAAAAAAAAAAAAAAAAAAAAAAqcSAACOGQAA\",\"\"]Bc[\"\",\"AAAAAAAAOzAAAAEAAKYAAQAeAqYA\"]JCi[\"AAAAAAAAAAE8FwAAAAAAAAFmGgAAAAAAAjUVAAA2FQAAAAAAAAAAAbYWAAAAASsaAAA=\",\"AAA7MAAAAQAA2wABALQN2wA7MAAAAQAASgEEAJIDbADYDNsApRsAAFocAAA6MAAAAQAAAAAAewAQAAAA1wLYAucH+hL8Ev0SABMCEwQTBhMIEwkTCxMNExATERMUExYTFxMaExwTHhMfEyITJBMmEycTKhMsEy4TMBMxEzMTNRM4EzoTPBM+Ez8TQRNEE0YTSBNJE0wTThNQE1ITVBNWE1cTWhNbE10TXxNhE2MTZhNnE2kTbBNuE3ATcRNzE3UTeBN6E3sTfhOAE4ITgxOFE4cTihOME44TkBOSE5MTlROYE5oTnBOdE58TohOkE6YTpxOqE6sTrhOvE7ITtBO1E7gTuhO8E74TwBPBE8QTxRPHE8oTyxPOE9AT0hPTE9YT2BPaFtsW3BbdFt4WjxvEG+EbjR07MAAAAQAADwEsAOkFAADwBQAABQYBANcHAADpBwAAPgwAAJMPAQCaD2wAjhVvADQWAAB+FgAAgxYAALUWAADDFgAAyxgAAKAZAACxGQAAFBoAAIYaAADMGgAAWhsAAN0bAADpGwAA7xsBABYcAAAiHAAALBwAAEocAABPHAEAWBwBAI4cAAC0HAAAvhwCAMIcAQDaHAAAUh0AAGEdAABnHQAAbx0AAHMdAAB1HQEAfx0AAIMdAACbHQAAOjAAAAEAAAAAAEYAEAAAAPEF8gXzBfkFBwYIBgkG2AfZB9oHIwwnDBIPmRKxEgIWfxaNFtEWERccFykXDRgpGMMYyRj5GKYZqRmyGQwaFho4Gm8akxqUGlEbUhtmG2sbbhtxG44bphupG8Mb2RsVHC4cLxw6HDscURxeHGQcihyLHJYcthzEHM4c1BxTHVgdWR1oHX0dhx2OHZ4dpB07MAAAAQAApQGlAPoFAgAzCQAA2AkAADMKAABcCgAAXgoAANoOAACaEgIA8BIAAPkSAAD7EgAA/hIBAAETAAADEwAABRMAAAcTAAAKEwAADBMAAA4TAQASEwEAFRMAABgTAQAbEwAAHRMAACATAQAjEwAAJRMAACgTAQArEwAALRMAAC8TAAAyEwAANBMAADYTAQA5EwAAOxMAAD0TAABAEwAAQhMBAEUTAABHEwAAShMBAE0TAABPEwAAURMAAFMTAABVEwAAWBMBAFwTAABeEwAAYBMAAGITAABkEwEAaBMAAGoTAQBtEwAAbxMAAHITAAB0EwAAdhMBAHkTAAB8EwEAfxMAAIETAACEEwAAhhMAAIgTAQCLEwAAjRMAAI8TAACREwAAlBMAAJYTAQCZEwAAmxMAAJ4TAACgEwEAoxMAAKUTAACoEwEArBMBALATAQCzEwAAthMBALkTAAC7EwAAvRMAAL8TAADCEwEAxhMAAMgTAQDMEwEAzxMAANETAADUEwEA1xMAAAUUAAARFAAAixUAACcWAAAxFgAAlhYAAKcWAAC7FgAAIRcAADkXAAA+FwAATxcAAAUYAACyGAAAxBgAANAYAAD6GAAABxkAAAwZbAB9GQAAsBkAALQZAADvGQAA8xkAAA0aAAATGgAAGxoAAEAaAQBJGgAAiBoAAJsaAAChGgAApRoAAMUaAADTGgAA1hoAANgabABiGwIAbRsAAHQbAACnGwAArxsAALgbAADuGwAA+hsBAAUcAAAHHAAAHRwBACscAAAtHAAAUhwAAFscAABpHAIAkxwAAKAcAQCnHAEAxhwAAMkcAABaHQAAah0AAG4dAAB5HQAAfh0AAIkdAQCRHQIAlR0AAJkdAACjHQAAqx0AADowAAABAAAAAAA/ABAAAADHAsoFywXMBfYLCwzLDh4QHxCFEusTEhSMFYIWmxYPFxAXGBcoFyoXgRezGLoYfBkeGkYaihqcGp0anhqfGqIaqxpZG1sbahtwG3UbrRu8G+Ab5hvnGwQcCBxTHGUcZxxsHG8cgRyMHJUcuBxcHV4dhh2MHZodnR2fHawdrR20HTowAAABAAAAAAAvABAAAADiB3MQixKREvgSExQqFi0WkRaoFs4WHBgdGL8YwBiXGbMZNxo8GokaixqYGsYayxpFG2wblxuYG5kbthvoG/Eb9Bv5GzIcRhxUHFccXxypHLcczBx4HXodex2LHZQdsR06MAAAAQAAAAAAGgAQAAAA9QUTD+8SzRZ9F6oYsRi0GAgZoRkcGlwbXRtvG74bSRyRHJkcnhyfHKIcxxxrHW0dfB2hHaUdOjAAAAEAAAAAAB8AEAAAAL4JABQQFKIWSxdQF6cYqRiwGAsZNBqPGnIbuhu7G8UbKRwqHEscTBxmHIgckBySHJccwRxsHYIdhB2WHZcdnB06MAAAAQAAAAAACQAQAAAA4gITGBYYQhpmGpcakBuiG6MbmB06MAAAAQAAAAAABwAQAAAAywcUD1MX7RvyG5QcaR21HTowAAABAAAAAAAKABAAAACHEokSNhcSGB0avRtOHF0cqR2yHbMdBQ4XAAAzGgAAhxoAAFwcAADIHAAAAvULAACQGgAAAjgWAAA/GgAAAcUcAAAAAUIXAAAAAAK2FgAAhR0AAAABKxoAAA==\",1]Gg[\"AAAAAAAAAAAAAAAAAAEUDwAAA4cSAACJEgAANhcAAAYVDwAAFg8AAIgSAACKEgAANxcAADgXAAA=\",\"AAAAAAXrBQAAjQoAAJUZAACVGgAAlhoAAA==\"]Bo[\"\",\"AAAAAAAAAAAAAAAAAAAAAAADPxUAANkZAABhGgAA\"]Ag[\"AAAAAAAAAdECAAA=\",\"\"]Bg[\"AAAAAAAAAAAAAAAAAAAAAAAAAS4WAAA=\",\"\"]Bg[\"AAAAAAAAAAAAAAAAAAAAAAAAAYsKAAA=\",\"\"]JFo[\"AAAAAh8YAAB8HAAABM4CAADqAgAASRcAAP0bAAAH4wIAADIJAACWGQAAMhoAAN0bAAAzHAAA0xwAADowAAABAAAAAAAgABAAAADkAuUCIwyxEjoVfxbSFhoXHBcpGMkYrxkMGmUabxqSGpMaZhuOG6kb2RsVHDscSBxRHGQchRzOHFMdWR1oHY4dpB06MAAAAQAAAAAAJQAQAAAAMRayGLAZtBkTGkAaQRpJGpsapRrFGmIbYxtkG20bpxv6GwUcHRweHFscaRxqHGscoByhHKccyRxaHW4deR1+HYodkh2THZUdmR2rHTowAAABAAAAAAAZABAAAADaAigXsxhGGooaqxpbG2obcBuMG60bvBvmG+cbBBwIHFMcbByGHYwdmh2dHZ8drB2tHbQdOjAAAAEAAAAAABcAEAAAAHMQkRazGYkaixrGGmwbthvoG/Eb9Bv5GzIcRhxXHF8ctxzMHHgdeh17HYsdlB2xHTowAAABAAAAAAAaABAAAADvEncXeBd5F3oXBhgHGAgYCRixGFwbXRtvG7Ibsxu0G7UbvhsCHJEcmRyeHJ8cax1tHaEdpR06MAAAAQAAAAAAEAAQAAAAohZLF1AXqRgLGTQachvFGykcKhxmHJIcwRxsHZYdlx2cHQMTGAAAQhoAAJgdAAAD7RsAAJQcAAC1HQAABhIYAAAdGgAAvRsAAE4cAABdHAAAsh0AAAUOFwAAMxoAAIcaAABcHAAAyBwAAAI1FQAANhUAAAE/GgAAAcUcAAAAAAAAAYUdAAA=\",\"AAHXGgAAOjAAAAEAAAAAAAoAEAAAACYQJxAoECkQKhDoE3oZ+Rn7GUkbiRw6MAAAAQAAAAAAFQAQAAAA0AnsC+4SIxYZFzsXQReAFxgYpRksGjUaNhqAGpEarhphG6UbqhtaHHwcjx06MAAAAQAAAAAAEwAQAAAA1wLYAucHYAqdClQS2hbbFtwW3RbeFtEY1RqPG8Qb4RsaHCgcfhyNHTowAAABAAAAAAAsABAAAAAFBukHlAuTD5QPNBZ+FoMWtRbDFjoX+BigGbEZFBqGGswaWhvdG+8b8BsWHCIcLBxKHFgcWRyOHLQcvhy/HMAcwhzDHNocUh1hHWcdbx1zHXUddh1/HYMdmx06MAAAAQAAAAAAOwAQAAAAIwwnDLES6RMCFn8WjRbRFhEXHBcpFw0YKRjJGPkYphmyGe4Z9xkMGhYaOBpvGpMamRpGG1EbUhtmG2sbbhtxG44bphupG8Mb2RsVHCEcLhwvHDocOxxRHF4cZByWHLYcxBzOHNQcUx1YHVkdaB19HYcdjh2eHaQdOjAAAAEAAAAAAFAAEAAAADMJ2AkzClwKXgraDvASBRQRFCcWMRaWFrsWIRc5Fz4XTxcFGLIY0Bj6GH0ZsBm0Ge8Z8xkNGhMaGxpAGkEaSRqIGpsapRrFGtMaYhtjG2QbbRt0G6cbrxu4G+4b+hsFHAccHRweHCscLRxSHFscaRxqHGscfxyTHKAcoRynHKgcxhzJHFodZh1qHW4deR1+HYkdih2RHZIdkx2VHZkdox2rHTowAAABAAAAAAAzABAAAADHAssOHhDrExIUjBWCFpcWmBaZFpoWmxYPFxAXKBcqF4EXsxi6GB4aRhqKGqsaWRtbG2obcBt1G4wbrRu8G+Yb5xsEHAgcGxxTHGwcbxyAHIEclRxcHV4dhh2MHZodnR2fHawdrR20HTowAAABAAAAAAAhABAAAABzEIsS+BITFCoWkRaoFr8Ysxk8GokaixrGGssabBu2G+gb8Rv0G/kbMhxGHFQcVxxfHKkctxzMHHgdeh17HYsdlB2xHTowAAABAAAAAAAXABAAAAD1Be8SfRexGAgZhxmIGYkZihmhGRwabxu+G5EcmRyeHJ8cohzHHFsdax1tHaEdpR06MAAAAQAAAAAAHAAQAAAAvgkAFBAUohZLF1AXpxipGLAYCxk0GnIbuxvFGykcKhxLHEwcZhyIHJAckhzBHGwdgh2EHZYdlx2cHQcTGAAAFhgAAEIaAACQGwAAohsAAKMbAACYHQAAOjAAAAEAAAAAAAsAEAAAAMsHFA9TF+0b8hsjHCQcJRwmHJQcaR21HTowAAABAAAAAAAKABAAAACHEokSNhcSGB0avRtOHF0cqR2yHbMdBQ4XAAAzGgAAhxoAAFwcAADIHAAAAZAaAAABPxoAADswAAABAADjAFIAKgADAC8ABwA4AAEAPQALAGUDAABsAwQA0QUAANMFCQDfBQkA7AUAALYHAQDIBwAA7AcAAPIHAAD3BwQAQwgAAEgIAADNCQEA4wkAAOUJAABiCgAAZAoGAGwKFACDCgkAkAoCALcKAACVCwYAnQsBAKALAACiCwEA2wsCAN8LAQDlCwAAKgwCANYMAQCQDgIAxw4AAMkOAADODgMA+A4BAPwOAgAJDwAACw8BABEPAAAXDwAACRABAA8QBAAVEAQAGxACAGUQAwBtEAAARBIAAFkSAABdEgEAkhIBAJUSAgClEgQArBIAAP4TAAAGFAAADhQBABQUAQAXFAEAMhQCAD8UAQA3FQAAZxUFAHwVAACEFQAAGBYAACYWAAAuFgAANRYAALwWAgAnFwAAAxgAABQYAAAqGAAArhgAAI4ZAACiGQEAxRwAAAABQhcAAAAAAYUdAAA=\"]Cc[\"AAAAAAAAAAAAAAAAAAAAAAAAA8UFAADhDgAAChAAAA==\",\"\"]Ao[\"\",\"AAA7MAAAAQAA2wABALgK2wA=\"]Ac[\"\",\"AAABShcAAA==\"]Cc[\"\",\"AAAAAAAAAUYMAAAAAAAAAAAAAAAAAALkBwAAgRoAAA==\"]Co[\"AAAAAAAAARoXAAAAAl4bAACMGwAAAAECHAAA\",\"AAAB+xkAAAAAAAGRGwAA\"]Hc[\"AAAAAY4HAAAAAesPAAABmgIAAAABZxwAAAAAAAAAAAAAAAU/AAAA4AUAANwOAAADGAAAFxgAAA==\",\"AAAAAY4HAAABjhMAAAAD5AIAAIocAACLHAAAAqAcAACrHQAA\"]Bg[\"AAAAAAAAAAAAAAAAAAAAAAAAAcwHAAA=\",\"\"]Bg[\"AAAAAAAAAAAAAAAAAAAAAAAAAYgKAAA=\",\"\"]Io[\"AAAB0wIAAAHrAgAAAusFAADnBwAAAAAAAAABAhwAAA==\",\"AAGWGwAAAAEkFgAAAu0FAAADDwAAAYMWAAAAAgUYAAC4GwAAAAHiBwAAAAABGRgAAAAAAAAABe4FAADEDgAA0g4AAEIUAAAFGQAAAAFCFwAA\"]Cc[\"AAAAAAAAAAAAAAAAAAAAAAAAA80HAAD7CwAAMwwAAA==\",\"\"]Ag[\"AAAAAAAAAZQaAAA=\",\"\"]Ag[\"AAAAAAAAAUUJAAA=\",\"\"]AEc[\"AAAAAAAAADowAAABAAAAAABvABAAAAD5EvsS/hL/EgETAxMFEwcTChMMEw4TDxMSExMTFRMYExkTGxMdEyATIRMjEyUTKBMpEysTLRMvEzITNBM2EzcTORM7Ez0TQBNCE0MTRRNHE0oTSxNNE08TURNTE1UTWBNZE1wTXhNgE2ITZBNlE2gTahNrE20TbxNyE3QTdhN3E3kTfBN9E38TgROEE4YTiBOJE4sTjROPE5ETlBOWE5cTmRObE54ToBOhE6MTpROoE6kTrBOtE7ATsROzE7YTtxO5E7sTvRO/E8ITwxPGE8gTyRPME80TzxPRE9QT1RPXEw==\",\"\"]Bk[\"AAAAAAAAAAAAAAAAAAAAAAAAAs8HAADmCQAA\",\"\"]Ic[\"AAAAATYHAAAAAp4PAAA2FAAAASECAAAB+hgAAAAAAAAAAAAAAAA6MAAAAQAAAAAABwAQAAAAlQuQDpMOERCSEjIUaBUeFg==\",\"AAAAATYHAAACABMAAOoTAAAAAfkYAAAAAAABfRcAAA==\"]Bk[\"AAAAAAAAAAAAAAAAAAAAAAAAAnMKAADgEwAA\",\"\"]Bk[\"AAAAAAAAAAAAAAAAAAAAAAAAAn4KAAAzFwAA\",\"\"]Be[\"AAAAAAA7MAAAAQAAfwABAIIXfwA=\",\"\",1]BAc[\"AAAAAAAAADswAAABAADrAAIAGw91ACwYdQA=\",\"AAAAAAA7MAAAAQAA6wBXAEgJdQBGFAEAShQAAEwUAQBPFAAAUhQBAFUUAABXFAAAWRQAAFsUAABeFAEAYRQAAGMUAABmFAAAaBQAAGoUAQBtFAAAbxQAAHIUAQB1FAAAdxQAAHoUAQB+FAEAgRQAAIMUAACGFAAAiBQBAIwUAQCQFAAAkhQAAJQUAQCXFAAAmRQAAJsUAACdFAAAoBQAAKIUAACkFAEApxQAAKoUAACsFAEAsBQAALIUAQC1FAAAuBQAALoUAQC9FAAAwBQAAMIUAQDFFAAAyBQAAMoUAQDOFAEA0hQAANQUAADWFAAA2BQBANwUAQDgFAEA5BQAAOYUAADoFAAA6hQBAO0UAADvFAAA8hQBAPYUAQD5FAAA+xQAAP0UAAAAFQEABBUBAAcVAAAKFQAADBUAAA4VAAAQFQEAFBUBABgVAAAaFQAAHBUBACAVAAAiFQAAJBUBACgVAAAqFQEALhUBAA==\"]Bg[\"AAAAAAAAAAAAAAAAAAAAAAAAAYwKAAA=\",\"\"]Do[\"AAAAAaIKAAAD7QUAAI0KAADUDgAAAAAAAAAAAAAAAAAAAAIABAAA7gUAAA==\",\"AAAAAAHrBQAA\"]Ag[\"AAAAAAAAAAEHFgAA\",\"\"]Fc[\"AAAB4xYAAAAAAAABBxYAAA==\",\"AAABRxsAAAABYhoAAAAAAAAAAAAAAAAAAAS+GQAAThoAAA4cAACuHAAAArYKAABIGgAA\"]Bg[\"AAAAAAAAAAAAAAAAAAAAAAAAAZIPAAA=\",\"\"]Dg[\"AAAAAagHAAABuAcAAAInAAAAABAAAAG6AgAA\",\"AAAAAagHAAAByxMAAAAAAe4bAAA=\"]Bk[\"AAAAAAAAAAAAAAAAAAAAAAAAAnAKAAD5EwAA\",\"\"]Bc[\"AAAAAAA7MAAAAQAAdQABAEgJdQA=\",\"\"]Bg[\"AAAAAAAAAAAAAAAAAAAAAAAAAYoKAAA=\",\"\"]Bg[\"\",\"AAAAAAAAAAAAAAAAAAAAAAABcxwAAA==\"]Bc[\"\",\"AAAAAAEqCgAAARoCAAABDRQAAA==\"]Bk[\"AAAAAAAAAAAAAAAAAAAAAAAAAhUUAAC9FgAA\",\"\"]Bk[\"AAAAAAAAAAAAAAAAAAAAAAAAAhQUAAC8FgAA\",\"\"]Bk[\"AAAAAAAAAAAAAAAAAAAAAAAAAhcUAAC+FgAA\",\"\"]Ck[\"AAAAAAAAAAAAAAAAAAAAAAAABXQKAABEDAAA4RMAADIVAAD9GAAA\",\"\"]Cc[\"AAAAAAAAAAAAAAAAAAAAAAAAA30KAADrDgAAMhcAAA==\",\"\"]Dc[\"AAAAAVcHAAAAAQoAAAABWgIAAAH2BQAA\",\"AAAAAVcHAAAAAAAAAAAAAacYAAA=\"]Bk[\"AAAAAAABEgAAAAF6AgAA\",\"AAAAAAABLBYAAA==\"]Ag[\"\",\"AAAAAAAAAZQaAAA=\"]Ac[\"\",\"AAAAAbQHAAA=\"]Ao[\"\",\"ADswAAABAAB1AAEA7wJ1AA==\"]Bc[\"AAAAAAAAAAH2BQAAAAABCBkAAA==\",\"\"]Hc[\"AAAAAY8HAAABshwAAAHsDwAAAZsCAAAAAAAAAAAAAAAAADowAAABAAAAAAAHABAAAADuDoMS+hOQGVgbrhvrG1Uc\",\"AAFKCgAAAbYEAAABjwcAAAGQEwAAAAGgFgAA\"]Fg[\"AAAAAUcHAAAAAbcPAAABSQIAAAAAAAAAAAAAAAAABDMAAACRDgAA3Q4AABUQAAA=\",\"AAAAAUcHAAABLhMAAAAAAkAaAABpHAAA\"]Bg[\"AAAAAAAAAAAAAAAAAAAAAAAAARAGAAA=\",\"\"]BCg[\"AAAE3wIAAAEcAAADHAAANhwAAAOjHAAAzRwAAK8dAAADjxsAAMQbAADhGwAABpMPAACUDwAARRcAAEYXAADdGwAAjhwAAATRAgAAbxoAAEYbAACFHAAAOjAAAAEAAAAAAG8AEAAAAPkS+xL+Ev8SARMDEwUTBxMKEwwTDhMPExITExMVExgTGRMbEx0TIBMhEyMTJRMoEykTKxMtEy8TMhM0EzYTNxM5EzsTPRNAE0ITQxNFE0cTShNLE00TTxNRE1MTVRNYE1kTXBNeE2ATYhNkE2UTaBNqE2sTbRNvE3ITdBN2E3cTeRN8E30TfxOBE4QThhOIE4kTixONE48TkROUE5YTlxOZE5sTnhOgE6EToxOlE6gTqROsE60TsBOxE7MTthO3E7kTuxO9E78TwhPDE8YTyBPJE8wTzRPPE9ET1BPVE9cTAV4bAAAEvxgAADwaAADbGwAARhwAAAICHAAAnhwAAAVLFwAACxkAADQaAABLHAAAZhwAAAAAAk4cAABdHAAAAQ4XAAA=\",\"AAAAAAAAAAAAAAAAAAAAAAAAAXIQAAA=\"]Bg[\"AAAAAAAAAAAAAAAAAAAAAAAAAZkKAAA=\",\"\"]Bk[\"AAAAAAAAAAAAAAAAAAAAAAAAAngKAADlEwAA\",\"\"]Cc[\"AAAAAAAAAAAAAAAAAAAAAAAAA3UKAADRDgAA4hMAAA==\",\"\"]Bg[\"AAAAAAAAAAAAAAAAAAAAAAAAAXADAAA=\",\"\"]Do[\"AAAAAXgHAAABRxcAAAIVAAAA2g8AAAKAAgAA6hsAAA==\",\"AAAAAXgHAAADbhMAAKgYAADKGAAA\"]Bg[\"\",\"AAAAAAAAADswAAABAABsAAEA2BpsAA==\"]Cc[\"AAAAAW8HAAAAAREAAAABdQIAAA==\",\"AAAAAm8HAAAKFwAA\"]Bc[\"AAAAAAAAOzAAAAEAAKYAAQAeAqYA\",\"\"]Bg[\"AAAAAAGdCgAAAAG1AgAA\",\"AAAAAAHEEwAA\"]Ek[\"AAAAAZ0HAAABSggAAAH6DwAAAakCAAAABJcWAACYFgAAmRYAAJoWAAAAAZ8cAAA=\",\"AAAAAZ0HAAABqxMAAA==\"]Fo[\"AAAAAaEHAAAAAf4PAAABrQIAAAAAAAAAAAAAAAAABOYFAABiCgAAtwoAAP4TAAA=\",\"AAAAAaEHAAABtBMAAAAAAAADHBgAAB0YAADMHAAA\"]AHk[\"AAAABjIEAACFBwAAGRcAAE4bAABPGwAAdx0AAAA6MAAAAQAAAAAACAAQAAAAHgDjD/0TIBgxGqwaaBtpG3AcBI4CAAASDwAAqRkAAK8ZAAADPQEAACAQAACnFgAABsoFAADLBQAAzAUAAB8QAACFEgAANBwAAAABqhgAAAGQHAAAAZAbAAAAAAAAADowAAABAAAAAAAXABAAAAA9AN4FKQf6B0kI4gkeCuQLAgwRDPsOCxAaECIQbBBfEu0SFhQ4FTkVdRU5FhAaJxo=\",\"AAAAAYUHAAABgBMAAAPABwAAMgoAAIYVAAAE6gUAANMJAAA/DAAAAxYAAAOuCgAA/w4AAPMZAAAAAAAAAAAAAAAAOzAAAAEAAAUAAgDEBQEAzAcDAA==\"]AKc[\"AAABuRkAAAcfBAAAkgcAAPAOAADxDgAASBIAAO0TAAAbFwAAAAXvDwAA7RkAAD4aAACmHAAAch0AAAKeAgAAGhcAAAFgGwAAAAGXGQAAAc0WAAAAAZcaAAABqxsAAAAAAAA6MAAAAQAAAAAAIgAQAAAAQgDIBeMFQgnGCSAK5wsSDCEM5A79Dg8PmA8NEB0QMBBvEFgS5xMoFDAUcxV2FQsWrxaxFsEWzBalGLcYARkqGsgaVhtWHQ==\",\"AAFLCgAAAbkZAAACkgcAAB8YAAAClRMAALIcAAAAAANkGwAA+hsAAJUdAAAFfBkAAOAbAABlHAAAjB0AALQdAAADzhYAAHodAACxHQAAAkkcAABrHQAAA2wdAACWHQAAnB0AAAACqxsAAGkdAAAAAAAABcYFAAAhFgAAIhYAAAoYAAALGAAA\"]Fg[\"AAACAA8AAA0WAAABXgcAAAACDAAAAMsPAAABZAIAAA==\",\"AAE3CgAAAAFeBwAAAlYTAAB6GgAAAAAAAAAAAAAAAAAAAAHIBQAA\"]Eg[\"AAABHRcAAAGGBwAAAAIfAAAA5A8AAAGPAgAA\",\"AAAAAYYHAAABghMAAAAAAAAAAAAAAAAAAAABXxIAAA==\"]Ag[\"AAAAAAABAg8AAA==\",\"\"]Fk[\"AAAAAW0HAAAAAdUPAAABcwIAAAAAAAAAAAAAAAAAB+0LAAATDAAA6A4AABwUAAAzFgAAOhYAAC0XAAA=\",\"AAAAAW0HAAABaRMAAA==\"]Eo[\"AAAAAVkHAAAAAAFcAgAAAAAAAAAAAAAAAAAFNwAAAGsKAACYCgAADA8AACAUAAA=\",\"AAAAAVkHAAAAAAAAAAGLEgAA\"]Bg[\"\",\"AAAAAAAAADswAAABAAB1AAEAGw91AA==\"]ADo[\"AAAAAY0HAAAAAeoPAAACmAIAAJkCAAABPBcAAAAAAAABZhoAAAAAAAAAAAAAAAABthYAAAABKxoAAA==\",\"AAAAAY0HAAABjBMAAAJPHAAAUBwAAAKKHAAAixwAAAA6MAAAAQAAAAAABwAQAAAACwwYF3wZ4BtlHGccjBy4HDowAAABAAAAAAAIABAAAADOFhwYHRiXGTcamBqXG5gbmRsHEw8AAM0WAAC0GAAAXBsAAF0bAABJHAAAfB0AAAOPGgAAuhsAAJccAAAEZhoAAJcaAACiGwAAoxsAAAACqR0AALMdAAAAAAAAAAAAAAG2FgAAAAErGgAA\"]Ac[\"\",\"AAABHhgAAA==\"]Cc[\"AAAAAAAAAAAAAAP1BQAAEw8AALQYAAACjxoAAJccAAA=\",\"\"]AB@m[\"AAA7MAAAAQAAYAEDALgK2wBWDH8AJhAEADswAAABAADgAWwAxgIAAMoCAADoAgAAHwQAADEEAQADBgAACgYAADAHAAAyB38A1AcAAOYHAADqBwAA7gcCAPMHAABLCNsA0AkAAOoJAgDvCQAAVwoBAKIKAADsCwAADAwAAA8MAAAxDAAANQwBAD0MAABHDAAA4g4AAOwOAADwDgEAQRIAAEcSAQBMEgAATxICAO4SAADxEgAA7BMEAAwUAAAdFAEAQRQAAG8VAACIFQAABhYAAA4WAAAQFgEAIxYBADIWAAB3FgAApRYAAAQXAgAIFwAAChcAABkXAAAbFwAAIhcAACQXAAA7FwAAPRcAAEEXAABRFwAAgBcAABgYAAAhGAEAyBgAAIIZAACLGQAAjRkAAKUZAACtGQEA5hkAAOgZAAAAGgAACRoAACwaAAA1GgEAPRoAAGoaAgBxGgAAgBoAAJEaAACkGgAArhoAAM4aAABOGwEAYRsAAKQbAACqGwAAsBsAALcbAADeGwEAABwAADUcAABNHAAAbRwAAIQcAACHHAAAjRwAAKMcAAClHAAArBwAALMcAADNHAAA0hwAAFQdAABgHQAAdx0AAI8dAACvHQAAOzAAAAEAAKcBpQDuAgAAAwQEAOsFAADtBQAA/gUBADEHAAC4BwAAxwcAAOAHAQBKCAAAJwkAADQJAAA5CQEA2QkAACoKAAAuCgAAMAoAAFkKAABgCgAAjQoAAJ0KAACfCwAAoQsAAPoLAAD9CwEABAwAADIMAABPDAAA1A4AAAMPAADZEVsAOBIAAFwSAADqEwAABxQAAEUUAABIFAEASxQAAE4UAABQFAEAVBQAAFYUAABYFAAAWhQAAFwUAQBgFAAAYhQAAGQUAQBnFAAAaRQAAGwUAABuFAAAcBQBAHQUAAB2FAAAeBQBAHwUAQCAFAAAghQAAIQUAQCHFAAAihQBAI4UAQCRFAAAkxQAAJYUAACYFAAAmhQAAJwUAACeFAEAoRQAAKMUAACmFAAAqBQBAKsUAACuFAEAsRQAALQUAAC2FAEAuRQAALwUAAC+FAEAwRQAAMQUAADGFAEAyRQAAMwUAQDQFAEA0xQAANUUAADXFAAA2hQBAN4UAQDiFAEA5RQAAOcUAADpFAAA7BQAAO4UAADwFAEA9BQBAPgUAAD6FAAA/BQAAP4UAQACFQEABhUAAAgVAQALFQAADRUAAA8VAAASFQEAFhUBABkVAAAbFQAAHhUBACEVAAAjFQAAJhUBACkVAAAsFQEAMBUAAIkVAAA2FgAAexYAAKQWAADHFgAA2hYFAOIWAABHFwAASRcAAFIXAAB7FwEAqBgAAMYYAADKGAAA0RgAAJUZAACrGQEAuBkAAPQZAAASGgAALRoAAGIaAABnGgAAehoAAIwaAACVGgEA1RoAAEgbAABTGwAAdhsAALkbAAD8GwEAFBwAABocAAAcHAAAHxwAAIYcAACqHAEAyhwBAM8cAQDcHHUAoB0AAK4dAAA7MAAAAQAAtAOhAAgAIQAaAgAA6QUAAO8FAQAFBgEAwAcAANcHAADdBwAA6QcAADIJAABGCQAASAl1ADIKAABWCgAAlAsAAD4MAADZDgAAAg8AABgPAACTDwEAmg9sAHUQ2wBSEYAAshIAAP0TAAA1FAEARhQBAEoUAABMFAEATxQAAFIUAQBVFAAAVxQAAFkUAABbFAAAXhQBAGEUAABjFAAAZhQAAGgUAABqFAEAbRQAAG8UAAByFAEAdRQAAHcUAAB6FAEAfhQBAIEUAACDFAAAhhQAAIgUAQCMFAEAkBQAAJIUAACUFAEAlxQAAJkUAACbFAAAnRQAAKAUAACiFAAApBQBAKcUAACqFAAArBQBALAUAACyFAEAtRQAALgUAAC6FAEAvRQAAMAUAADCFAEAxRQAAMgUAADKFAEAzhQBANIUAADUFAAA1hQAANgUAQDcFAEA4BQBAOQUAADmFAAA6BQAAOoUAQDtFAAA7xQAAPIUAQD2FAEA+RQAAPsUAAD9FAAAABUBAAQVAQAHFQAAChUAAAwVAAAOFQAAEBUBABQVAQAYFQAAGhUAABwVAQAgFQAAIhUAACQVAQAoFQAAKhUBAC4VAQCGFQAA/hUDACwWAACDFgAAhxYAAJwWAwCtFgEAtBYBAMMWAADTFgAACxcAABYXAQA6FwAARRcBAEgXAABNFwAAghd/ACAYAAC5GAAAxRgAAMsYAAD4GAAAlhkAAKQZAACqGQAA7RkAAAoaAQAUGgAAMRoBADkaAgA+GgAAcBoAAKAaAACsGgAAZxsCAJobAwDpGwAA8xsAAP4bAAAzHAAATxwBAHAcAACOHAAAphwAAL0cAADTHAAA2hwAAGEdAAByHQAAOzAAAAEAAOMANQAeAqYA6gUAAPEFAgD5BQAABwYCANgHAgBFCQAAzwkAANMJAABdCgAAXwoAACcMAAA/DAAAVAwAAAoPAAASDwAAmRIAAOkTAAANFAAAAhYBAHYWAACNFgAAoBYAANEWAQAaFwAAJhgAAMMYAAAJGQAAphkAAKkZAACvGQAA7hkAAPcZAAAXGgAAZRoAAJIaAACUGgAAmRoAAKMaAADNGgAARhsAAFIbAABmGwAAkRsAAOobAAAhHAAAMBwAAEgcAACFHAAAihwBAI8cAACYHAAAnh0AADswAAABAACgApcAPQEAAMUCAAD2BQAA+gUCADMJAADYCQAAMwoAAFwKAABeCgAArgoAAAcMAwDaDgAA/w4AABsPdQAgEAAAPRIDAJoSAgDwEgAA+RIAAPsSAAD+EgEAARMAAAMTAAAFEwAABxMAAAoTAAAMEwAADhMBABITAQAVEwAAGBMBABsTAAAdEwAAIBMBACMTAAAlEwAAKBMBACsTAAAtEwAALxMAADITAAA0EwAANhMBADkTAAA7EwAAPRMAAEATAABCEwEARRMAAEcTAABKEwEATRMAAE8TAABREwAAUxMAAFUTAABYEwEAXBMAAF4TAABgEwAAYhMAAGQTAQBoEwAAahMBAG0TAABvEwAAchMAAHQTAAB2EwEAeRMAAHwTAQB/EwAAgRMAAIQTAACGEwAAiBMBAIsTAACNEwAAjxMAAJETAACUEwAAlhMBAJkTAACbEwAAnhMAAKATAQCjEwAApRMAAKgTAQCsEwEAsBMBALMTAAC2EwEAuRMAALsTAAC9EwAAvxMAAMITAQDGEwAAyBMBAMwTAQDPEwAA0RMAANQTAQDXEwAAARQEAIMVAACLFQAABxYAADEWAACLFgEAlhYAAKcWAACpFgMAIRcAADkXAAA8FwAATxcAAAUYAAAOGAMALBh1AMQYAADMGAQABxkAAAwZbAB9GQAAnBkDAO8ZAADzGQAA/BkDAAEaAwANGgAASRoAAHIaAwCbGgAAoRoAAM8aBADWGgAA2BpsAGAbAACSGwMAuBsAAPsbAAAgHAAAUhwAAH8cAACkHAAAuRwDAGYdAABqHQAAox0AADowAAABAAAAAAA/ABAAAADHAsoFywXMBfYLCwzLDh4QHxA5EjoSOxI8EoUSsBLrE4wVkhaTFpQWlRaXFpgWmRaaFpsWxBYPFxAXGBe6GHwZnBqdGp4anxqiGqsajBu8G+Ab9Rv2G/cb+BsTHBscNBxgHGEcYhxjHGUcZxxvHIAcgRyMHJocmxycHJ0cuBxcHTowAAABAAAAAAAvABAAAADiB90J3gnfCeAJ1Q7WDtcO2A6LEpES+BIqFi0WqBbOFhwYHRi7GLwYvRi+GMAYfhl/GYAZgRmXGQUaBhoHGggaNxqYGssaRRtKG0sbTBtNG5cbmBuZG+gb9BtUHF8cqRw7MAAAAQAALgAVAPUFAAATDwAAzRYAAHcXAwAGGAMAqhgAALQYAAAIGQAAgxkHAGkaAAB8GgMAmhoAAFwbAQCeGwMAshsDAOIbAwBJHAAAmRwAAKIcAABbHQAAfB0AADowAAABAAAAAAALABAAAAC+CQAUpxipGI8auhu7G0wciByQHJccgh0HFhgAABkYAABmGgAAlxoAAJAbAACiGwAAoxsAADowAAABAAAAAAAKABAAAADLB/0HFA9TF6sb8hsjHCQcJRwmHGkdOjAAAAEAAAAAAAgAEAAAAIcSiRI2F6Ydpx2oHakdqh2zHQYVDwAAFg8AAIgSAACKEgAANxcAADgXAAAE9QsAADUVAAA2FQAAkBoAAAE4FgAAAAABQhcAAAAAArYWAACFHQAAAAErGgAA\",\"AAADGhgAABsYAAAeGAAAOzAAAAEAANwAAgBLCNsAVRIAADowAAABAAAAAAAHABAAAADtBccHSgg0CS4KMAqfC6ELOzAAAAEAAOAABgDABwAAMgoAAHUQ2wD9EwAAhhUAALQWAAAF6gUAAD8MAABGDAAAAxYAAEUaAAADrgoAAP8OAACDFQAAAAABmhoAAAAAAAAAADswAAABAAAwABcApQsBAMwLAACfDgAASBABALMSAQBhFQAATBYWAPoWAADuGAAA8xgAANYZAQBPGgAAtBoAAMIaAAB4GwAAfRsAAH8bAACKGwAAyxsAANEbAABBHAAAeBwAAK8cAAADAQQAACIKAAAkDAAA\",1]Gg[\"AAAAATwHAAAAAggAAAD4GAAAAS0CAAAAAAAAAAAAAAABkBoAAAAFLwAAANYFAABkEAAA6hIAAAgWAAA=\",\"AAAAAh8EAAA8BwAAAAAAAAAByxoAAA==\"]Eg[\"AAAAAZAHAAAAAiIAAADtDwAAAZwCAAAAAAAAAAAAAAAAAAJAAAAA4QUAAA==\",\"AAAAAZAHAAABkhMAAA==\"]Ek[\"AAAAAAAAAAAAAAAAAAAAAAH1CwAAATgWAAA=\",\"AAAAAUUMAAAAAAFGDAAAAAAAAAAAAAAAAALGGQAADBwAAA==\"]Gc[\"AAAAATUHAAAAAZ0PAAABIAIAAAAAAAAAAAAAAAAAAw4GAAAQEAAAZxUAAA==\",\"AAAAATUHAAAB/RIAAAAAAcUaAAAAAXMQAAAAAAABywcAAA==\"]Ko[\"AAAAAUwHAAAAAbwPAAABTgIAAAFBGgAAAAAAAAAAAAAAADowAAABAAAAAAAVABAAAAA1ANkFQAkbCuELHgz4Dg0Plg8IEGkQVhIlFC0UcBUJFskWoxi1GP8YIxpUGw==\",\"AAAAAUwHAAACOBMAAJUaAAAAAAGwGQAAAAABvhsAAAAAAAAAAAE/GgAA\"]Ic[\"AAAAAAGyHAAAAAGXAgAAAAAAAAAAAAAAAAA6MAAAAQAAAAAAGgAQAAAAPgDfBUEJHwrmCyAM4w78Dg4Plw8MEBwQbhBXEicULxRyFQoWwBbLFqQYthgAGSgaxxpVG1Ud\",\"AAAAAAGWGgAA\"]Ck[\"AAAAAegCAAAB6QIAAAABjRYAAAAAAAAAAAEUDwAAAocSAAA2FwAA\",\"\"]Fg[\"AAACzQIAANACAAABcAcAAAAB7wUAAALlAgAAlBoAAAABmxYAAAAAAAAAAAAAAAIqCQAAMAkAAA==\",\"AAAAAXAHAAAB2AIAAA==\"]Fg[\"AAAAAW4HAAAB2AIAAAABdAIAAAAAAAAAAAAAAAAAArYKAAAJFAAA\",\"AAE8CgAAAAFuBwAAAAAAAAAAAAAAAAAAAAABMAkAAA==\"]Bg[\"AAAAAAAAAAAAAAAAAAAAAAAAAW4KAAA=\",\"\"]Bg[\"AAAAAAAAAAAAAAAAAAAAAAAAAX8KAAA=\",\"\"]Jo[\"AAAABfMHAADvCQAAPQwAAAgXAACtGQAAAgQEAAAEDAAAAAPzBQAACQYAANoHAAAC/AUAAJwSAAACzAUAAJ8aAAAAAAAAAAAAAAA6MAAAAQAAAAAABwAQAAAAywL0Bx0MiRYJFx4XGRqOGg==\",\"AAAAAAAAAAAAAAAAAeICAAA=\"]Ag[\"\",\"AAAAAAABAg8AAA==\"]Cc[\"AAAAAa4HAAAAAQsXAAACvwIAADoVAAA=\",\"AAAAAa4HAAA=\"]Gc[\"AAAAAX8HAAAAAhkAAADeDwAAAYgCAAA=\",\"AAFDCgAAAAN/BwAAHRQAAB4UAAABdRMAAAAAAAAAAAAAAAAAAAAE1AkAANUJAADWCQAA1wkAAA==\"]Ak[\"AAAAAAACGA8AALISAAA=\",\"\"]Bc[\"AAAAAAA7MAAAAQAAbAABAJoPbAA=\",\"\"]Bk[\"AAAAAAAAADswAAABAADZAAIADBlsANgabAA=\",\"\"]Fg[\"AAAAATMHAAAAAZsPAAABHgIAAAAAAAAAAAAAAAAABCoAAADTBQAAogsAACcXAAA=\",\"AAAAATMHAAAB+hIAAAABFRwAAAHvGQAA\"]Fc[\"AAAAAUYHAAAAAbYPAAABSAIAAAAAAAAAAAAAAAAABDIAAABsAwAA1wUAAKQLAAA=\",\"AAAAAUYHAAABLBMAAAABaxsAAA==\"]Cg[\"AAAAAXsHAAAAARcAAAABgwIAAA==\",\"AAAAAXsHAAAAAAFlGwAA\"]Gk[\"AAAAA2QHAACwGwAAABwAAAI4EgAA3xYAAAACoxoAACEcAAAAAbASAAAAAWkaAAABgh0AAAEWGAAAAfIbAAAAAAAAAi4JAABjGgAA\",\"AAAB1AIAAAFkBwAA\"]Ek[\"AAAAAV8HAAAAAg0AAADMDwAAAWUCAAA=\",\"AAE4CgAAAAJfBwAA0hwAAAFXEwAAAAAAAAAAAAAAAAAAAAHJBQAA\"]Bg[\"AAAAAAAAAAAAAAAAAAAAAAAAAZ4KAAA=\",\"\"]Eg[\"AAAAAAAAAAAAAAAAAAAAAAAAA3QQAAAKFAAAyhoAAA==\",\"AAE9CgAAAAAAAAAAAAAAAAAAAAAAAAEaCgAA\"]Cc[\"AAAAAYEHAAAAARoAAAABigIAAAE9AQAA\",\"AAAAAYEHAAA=\"]Fg[\"AAAAAALOAgAA6hMAAAE2FAAAAALvGQAApBwAAAAAAAG+CQAAAALLBwAAUxcAAAAAAAAD8RMAAAwYAACNGwAA\",\"AAABzQIAAA==\"]Jk[\"AAAB1AIAAAFjBwAAAd8WAAABzg8AAANpAgAAXhwAANQcAAAAAAAAAAAAAAAAAAczEAAA1hYAAHMbAACxGwAAEhwAADEcAAB9HAAA\",\"AAE5CgAAAAFjBwAAAjgSAABbEwAAAiIcAABZHAAAAAAAAAAAAAAAAAAAAS0JAAA=\"]Ec[\"AAAAAusCAACxBwAAAAABwgIAAAAAAAAAAAAAAAAAA1sKAAB8FQAAJhcAAA==\",\"AAFUCgAAAAGxBwAA\"]Bc[\"AAAAAAA7MAAAAQAAbwABAI4VbwA=\",\"\"]Cc[\"\",\"AAAAAAAAAAADxwIAAAsMAAAYFwAAAAACjxoAAJccAAA=\"]Gk[\"AAAAAVgHAAAAAgsAAADGDwAAAVsCAAAAAAAAAAAAAAAAAATMCQAA8AsAAMMOAACRDwAA\",\"AAE0CgAAAAFYBwAAAk4TAADEGwAAAZQPAAAAAAAAAQgZAAA=\"]Bc[\"\",\"AAAAAAA7MAAAAQAAbwABAI4VbwA=\"]Dg[\"AAAAAaIHAAAAAAGuAgAAAAAAAAAAAAAAAAABRAgAAA==\",\"AAAAAaIHAAABtRMAAA==\"]Bg[\"AAAAAVUSAAA=\",\"AAAAAQwUAAABNhYAAA==\"]Bg[\"AAAAAAAAAAAAAAAAAAAAAAAAAQEMAAA=\",\"\"]Dc[\"AAAAAewLAAAAAZQLAAAAAAAAAAAAAAAAAAAC9AUAAPsHAAA=\",\"AAAAAAHnBwAA\"]Eg[\"AAAAAYcHAAAAAiAAAADlDwAAAZACAAA=\",\"AAAAAYcHAAABgxMAAAABSBwAAAAAAAAAAAAAAAAAAeIJAAA=\"]Gc[\"AAAAAZQHAAAAAfEPAAABoAIAAAAAAAAAAAAAAAAAOjAAAAEAAAAAAAgAEAAAAEMA5AUABiYKxQ71Gb8bwRs3HA==\",\"AAAAAZQHAAABmhMAAA==\"]Gc[\"AAAAAZMHAAAAAfAPAAABnwIAAAAAAAAAAAAAAAAAOjAAAAEAAAAAAAgAEAAAAMkFsge6BxkM5g5FEj8UgBZfHQ==\",\"AAAAAZMHAAABmBMAAA==\"]Bg[\"AAAAAAAAAAAAAAAAAAAAAAAAAc0JAAA=\",\"\"]Eg[\"AAAAAWYHAAAAAg8AAADQDwAAAWsCAAA=\",\"AAAAAWYHAAABXxMAAAAAAAAAAAAAAAAAAAACHxYAACAWAAA=\"]Hk[\"AAAABPAHAADsCQAANgwAAAYXAAAB/gsAAAAD8gUAAAgGAADZBwAAAvsFAACbEgAAAssFAACeGgAAAAAAAAAAAAAABfEHAADtCQAA/wsAADcMAAAHFwAA\",\"AALeAgAAQQoAAA==\"]Gc[\"AAAAAAAByA8AAAFeAgAAAAAAAAAAAAAAAAA6MAAAAQAAAAAABwAQAAAAOQDcBRwK4gv5DmoQyhYlGg==\",\"AAAAAAFSEwAAAAABExoAAAEoFwAA\"]AAc[\"AAAAAXwHAAAAAAKEAgAAhQIAAAAAAAAAAAAAAAAAAYYKAAA=\",\"AAGiGAAAAAF8BwAAAAbpBQAA8AUAAAYGAADXBwAAPgwAAMsYAAA6MAAAAQAAAAAADQAQAAAA8QXyBfMF+QUHBggGCQbYB9kH2gcSD5kSwxipGTowAAABAAAAAAAIABAAAAD6BfsF/AWaEpsSnBKnFsQYoRo6MAAAAQAAAAAACQAQAAAAygXLBcwFHxCFEpwanRqeGp8aohoAAaoYAAAAAeICAAA=\"]Go[\"AAAAA3kHAADsDgAATBIAAAEyDAAAAdsPAAABgQIAAA==\",\"AAFACgAAAAV5BwAA7gcAAO8HAADwBwAA8wcAAAFwEwAAAAAAAAAAAAAAAAAAAALrBwAA8QcAAA==\"]Cg[\"AAAAAaYHAAACqxkAAGcaAAABJQAAAAG4AgAA\",\"AAAAAaYHAAA=\"]Bk[\"AAAAAAAAAAAAAAAAAAAAAAAAAs4JAAAtEAAA\",\"\"]Hk[\"AAAAAAABrQ8AAAE2AgAAAAAAAAAAAAAAAAAGvAcAANUJAACoCgAADQwAAAQWAAB5FgAA\",\"AAAAAYIZAAAAA6AZAABaGwAA8BsAAAGWHAAAAa8bAAABWRsAAAAAAiocAACSHAAA\"]Gc[\"AAAAAZgHAAABVBIAAAIkAAAA9Q8AAAGkAgAAAX8cAAAGkhYAAJMWAACUFgAAlRYAAIwcAAC4HAAAAAGeHAAA\",\"AAAAAZgHAAACohMAAEgbAAA=\"]Ik[\"AAAAAWsHAAAAAdMPAAACcQIAADAcAAAAAAAAAAAAAAAAADowAAABAAAAAAANABAAAAA7ACkJnAvIDt4OMhCGEtkTmxmmGsQarBs5HG4c\",\"AAAAAWsHAAABZhMAAAADJwwAAAIWAAA4GgAAAAHrEwAA\"]Ac[\"\",\"AAAB+xgAAA==\"]Dg[\"AAAAAAAAAAFeCgAAAAAAAAAAAYkSAAAAAjUVAAA2FQAAAAEBBAAA\",\"AAAAAegCAAA=\"]Fc[\"AAAAAqcHAABBFAAAASgcAAACJgAAAP8PAAABuQIAAA==\",\"AAFPCgAAAAGnBwAAAcoTAAAAAAAAAAAAAAAAAAAAATsWAAA=\"]Gg[\"AAAAAU0HAAAAAb0PAAACTwIAAG4bAAAAAAAAAAAAAAAAAAc2AAAA2gUAAAUMAAAfDAAAGBAAAL8WAAAkGgAA\",\"AAAAAU0HAAABOhMAAAAAAaUaAAA=\"]ADo[\"AAAAADowAAABAAAAAABvABAAAAD6EvwS/RIAEwITBBMGEwgTCRMLEw0TEBMRExQTFhMXExoTHBMeEx8TIhMkEyYTJxMqEywTLhMwEzETMxM1EzgTOhM8Ez4TPxNBE0QTRhNIE0kTTBNOE1ATUhNUE1YTVxNaE1sTXRNfE2ETYxNmE2cTaRNsE24TcBNxE3MTdRN4E3oTexN+E4ATghODE4UThxOKE4wTjhOQE5ITkxOVE5gTmhOcE50TnxOiE6QTphOnE6oTqxOuE68TshO0E7UTuBO6E7wTvhPAE8ETxBPFE8cTyhPLE84T0BPSE9MT1hPYEw==\",\"\"]Jg[\"AAAAAWEHAAABHBwAAAHNDwAAAWcCAAAAAAAAAAAAAAAAADowAAABAAAAAAAYABAAAAA6AOUJcQqbC0IMQwzQDtMO9g4IDxcPaxBeEqIS7BIhFCYULhR9FYUW0BYUFzUXFBj8GA==\",\"AAAAAWEHAAACWhMAAJUZAAA=\"]Do[\"AAAAAWIHAAAAAAFoAgAAAAAAAAAAAAAAAfULAAABOBYAAAJtAwAAQhQAAA==\",\"AAAAAWIHAAA=\"]Bc[\"AAAAAAAAAAABxBYAAAAAAUwcAAA=\",\"\"]Gk[\"AAAAAZ4HAAAAAfsPAAABqgIAAAAElxYAAJgWAACZFgAAmhYAAAABnxwAAAAAAAAAAAAEaAMAAJ8KAAAFDwAAWxIAAA==\",\"AAFOCgAAAAGeBwAAAa4TAAA=\"]Kc[\"AAAAAR8YAAAAAAGzAgAAAAAAAAAAAAAAAAA6MAAAAQAAAAAAHQAQAAAARwDnBSoHRAnHCSEK6AsiDOUO/g4QD5kPDhAxEEMSKRQxFHQVdxUMFrAWshbCFkAXphi4GAIZyRpXG1cd\",\"AAAAAAHAEwAAAAAAAAAAAAAAAAAAAAHHBQAA\"]Eo[\"AAAAAAAAASQCAAAAAAAAAAAAAAAAAAQtAAAA1QUAABMQAAAgGgAA\",\"AAAAAAEGEwAAAAAAAAAAAAAAAAAAAAEPBgAA\"]Fg[\"AAAAAWoHAAAAAdIPAAABcAIAAAAAAAAAAAAAAAAABFkSAACeEgAABhQAADcUAAA=\",\"AAAAAWoHAAABYxMAAAACURsAAFIbAAA=\"]Bg[\"AAAAAAAAAAAAAAAAAAAAAAAAAW8KAAA=\",\"\"]Co[\"AAAAAWAHAAAAAg4AAAAaAgAAAmYCAAANFAAA\",\"AAAAAWAHAAAAAa4WAAA=\"]Bg[\"AAAAAAAAAAAAAAAAAAAAAAAAAYUKAAA=\",\"\"]Fg[\"AAAAAWcHAAAC7gIAAHsXAAACEAAAANEPAAABbAIAAAAAAAAAAAAAAAAAAi8QAAAgFgAA\",\"AAE6CgAAAALGAgAAZwcAAAFhEwAA\"]Fk[\"AAAAAowHAAClFgAAAuAHAACkFgAAArkYAACkGQAAA5UCAADPCQAACRkAAAAAAAAAAAAAAAAAAgMMAAA8DAAA\",\"AAFICgAAAAGMBwAA\"]Fo[\"AAAAApoHAADvEwAAAAH3DwAAAaYCAAAAAAAAAAAAAAAAAAWoEgAA3hMAACoYAACuGAAAERoAAA==\",\"AAAAAZoHAAABphMAAAAAAXkdAAA=\"]Eg[\"AAAAAXIHAAACTwwAAGcaAAACFAAAANkPAAACfAIAABYaAAA=\",\"AAAAAnIHAAAMDAAAAWwTAAABvhwAAA==\"]Mo[\"AAAAAAABqw8AAAE0AgAAAAAAAAAAAAAAAAA6MAAAAQAAAAAACwAQAAAA0wfrB9QJ6QkjCqcK+AspDB0WsxYOGkoa\",\"AAAAAQAaAAAAAAEuHAAABhEUAAAnFgAAuxYAABsaAACIGgAAxhwAAAQSFAAAKhcAAB4aAACVHAAAARMUAAACoRkAABwaAAADEBQAALAYAACEHQAAAAAAAocaAABcHAAA\"]Eo[\"AAAAAa8HAAAAAQUQAAABwAIAAAAAAAAAAAAAAAAABUkAAACACgAA2gsAAKESAAAYFAAA\",\"AAAAAa8HAAAB1hMAAA==\"]Dc[\"AAAAAAAAAAAAAAAAAAAAAAAABr8HAADWCQAArAoAAA4MAAAFFgAAehYAAA==\",\"\"]Fg[\"AAAAAYMHAAAAAxwAAADhDwAANRQAAAGMAgAAAAE0HAAA\",\"AAFFCgAAAAGDBwAAAi4KAAB7EwAAAAAAAAAAAAAAAAAAAAEvCgAA\"]Ok[\"AAAABYAHAAAPDAAAUBIAAB4UAAAGFgAABTEHAAAuCgAAiRUAAHsWAABSFwAABcAHAAAyCgAA3w8AADUUAADTHAAABokCAADqBQAA0wkAAD8MAAAKDwAAAxYAAAU9AQAArgoAAP8OAAAgEAAAIBwAAAE0HAAAAAAAAAAAAAAAB8EHAADXCQAALwoAAGEKAACtCgAAfBYAANQWAAA=\",\"AALhAgAARAoAAAABgAcAAAF4EwAAAd0HAAA=\"]Bk[\"AAAAAAAAAAAAAAAAAAAAAAAAApAKAAArFAAA\",\"\"]Bk[\"AAAAAAAAAAAAAAAAAAAAAAAAApEKAAAuDAAA\",\"\"]Bk[\"AAAAAAAAAAAAAAAAAAAAAAAAApIKAAAwDAAA\",\"\"]Bk[\"AAAAAAH/BQAAAAAAAAAAAAAAAAAAAAFJDAAA\",\"\"]Bg[\"AAAAAAAAAAAAAAAAAAAAAAAAATsMAAA=\",\"\"]Lg[\"AAABuhkAAAOVBwAA7hMAAKgbAAAAAfIPAAABoQIAAAAAAAAAAAAAAAAAAsIHAACoGgAA\",\"AAABuhkAAAKVBwAAqBsAAAGcEwAAAAFkHAAAAAKMHAAAuBwAADowAAABAAAAAAAIABAAAAA3GpgalxuYG5kbtht7HYsdlB0BnhwAAAE0GgAAAAAAAAAAAdoJAAA=\"]Bk[\"AAAAAAAAAAAAAAAAAAAAAAAAAvIHAAAbDAAA\",\"\"]Eg[\"AAAAAT0HAAAAAaQPAAABLgIAAAAAAAAAAAAAAAAAA0UIAAAxFQAAMxUAAA==\",\"AAAAAT0HAAAAAAFTHQAA\"]Ck[\"AAAAAAABrg8AAAE3AgAA\",\"AAAAAAAAAAAAAAAAAAAAAAAAASMKAAA=\"]Fo[\"AAAAAUgHAAAAAbgPAAABSgIAAAAAAAAAAAAAAAAABpoLAACSDgAAFhAAAJMSAAA0FAAAahUAAA==\",\"AAAAAUgHAAABMBMAAAAAAAFeHQAA\"]Nc[\"AAACPhwAANUcAAACqQcAAIAXAAAAAgEQAADaHAAAArsCAADEHAAAAAScGgAAnRoAAJ4aAACfGgAAAAAAAAAAAAAABjsWAAAVGAAAexkAAJkZAAD4GQAAbRoAAA==\",\"AAFQCgAAAj4cAADVHAAABakHAAAEFwAABRcAAAYXAAAIFwAAAc4TAAABwhwAAAHEHAAAAAAAAAAAAAAAAAACBxcAAAkXAAA=\"]Fg[\"AAAAAUsHAAAAAbsPAAABTQIAAAAAAAAAAAAAAAAABTQAAADYBQAA2w4AABcQAAACGAAA\",\"AAAAAUsHAAABNRMAAAAAAQUcAAA=\"]Bg[\"AAAAAAAAAAAAAAAAAAAAAAAAAS8aAAA=\",\"\"]Dk[\"AAAAAlESAAAQFgAAAAACbgIAAG8CAAA=\",\"AAAAAAAAAAAAAAAAAAAAAAH1CwAAATgWAAA=\"]Cg[\"AAAAAWUHAAAAAc8PAAABagIAAA==\",\"AAAAAWUHAAABXRMAAA==\"]Hc[\"AAAAAToHAAAAAaIPAAABKAIAAAAAAAAAAAAAAAAABvwHAABtFQAAjRUAACUWAAAoFgAABBgAAA==\",\"AAAAAToHAAACCxMAABwcAAAAAAAAAAAAAAAAAAAAAfYHAAA=\"]Fo[\"AAABuxkAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAbYWAAAAASsaAAA=\",\"AAABuxkAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAbYWAAAAASsaAAA=\"]Bk[\"AAAAAAAAAAAAAAAAAAAAAAAAAvAJAAB9FgAA\",\"\"]Co[\"AAAAAAAAAa8CAAAAAAAAAAAAAAAAAALDBwAAqRoAAA==\",\"AAAAAAG4EwAA\"]Cg[\"\",\"AAAAADswAAABAAB1AAEA3Bx1AAAAOzAAAAEAAHUAAQAsGHUA\"]Ac[\"AAAAAaobAAA=\",\"\"]Bk[\"AAAAAAAAAAAAAAAAAAAAAAAAAmwKAAArFwAA\",\"\"]Gk[\"AAAABO8HAADrCQAANQwAAAUXAAAB/QsAAAAD8QUAAAcGAADYBwAAA/oFAACaEgAAxBgAAAPKBQAAnRoAAKIaAAAB3QIAAA==\",\"AAAAAAAAAAAAAdsbAAA=\"]Eo[\"AAAAAZ8HAAAAAfwPAAABqwIAAAAAAAF8HQAAAAAAAAAAAALGBQAAohkAAA==\",\"AAAAAZ8HAAABrxMAAAAAAAGKGgAA\"]Eg[\"AAAAAAABsw8AAAFCAgAAAAAAAAAAAAAAAAADrwoAACkWAABkGgAA\",\"AAAAAAAAAAAAAAAAAAABXRwAAA==\"]Dc[\"AAAAAAHhBwAAAAKWAgAAdhYAAAAAAAAAAAAAAAAAAukLAACmFgAA\",\"AAFJCgAA\"]Hk[\"AAAAAZkHAAABxxYAAAH2DwAAAaUCAAAAAAbdCQAA3gkAAN8JAADgCQAANxoAAJgaAAAAATQaAAAAAAAAAAAEygcAAOMHAAAjFAAAdxoAAA==\",\"AAFNCgAAAAGZBwAAAaQTAAA=\"]Fg[\"AAAAAYQHAAAAAh0AAADiDwAAAY0CAAAAAAAAAAAAAAAAAAVvAwAAuQcAAMYHAAAYDAAACQ8AAA==\",\"AAAAAYQHAAABfhMAAA==\"]Fk[\"AAAAAAFiGgAAAwUGAADpBwAAwxYAAAAAAAAAAAAAAAAAAAYaCgAAYRAAAOQSAADlEgAA5hIAAPYYAAA=\",\"AAAAAAAAAAAAAdsCAAA=\"]Go[\"AAAAATgHAAAAAaAPAAABJgIAAAAAAAAAAAAAAAAAOjAAAAEAAAAAAAkAEAAAAC4A4wljCpcKlgvyDgsP1BEfFIQW\",\"AAAAATgHAAABCBMAAAAAAAABsxkAAA==\"]Hc[\"AAABPRwAAAGrBwAAAAEDEAAAAb0CAAAAAAABxxwAAAAAAAAAAAAERBoAABccAAAYHAAAZR0AAA==\",\"AAFSCgAAAT0cAAABqwcAAAHSEwAAAcMcAAAAAAAAAcccAAA=\"]Bk[\"AAAAAAAAAAAAAAAAAAAAAAAAAtYHAADuCQAA\",\"\"]Bg[\"AAAAAAAAAAAAAAAAAAAAAAAAAYkKAAA=\",\"\"]Bk[\"AAAAAAAAAAAAAAAAAAAAAAAAAkMIAAA5DAAA\",\"\"]Do[\"AAAAAAAAAW0CAAAAAAAAAAAAAAAAAAH+GAAA\",\"AAAAAAAAAAAAAAAAAAAAAAH1CwAAATgWAAA=\"]Co[\"AAAAAAAAAAAAAAAAAAAAAAH1CwAAATgWAAACKAkAAAgUAAA=\",\"AAHWAgAA\"]Gk[\"AAAAAjAHAAB2BwAAAANSEQAArRYAANMWAAAAAAAC3QIAANsbAAAAAAHiAgAAAAAAAAACkhkAAEgaAAA=\",\"AALcAgAAPgoAAAHjFgAAAXYHAAABjBoAAA==\"]Ac[\"AAAAAAEDBAAA\",\"\"]Lo[\"AAAAAmkHAAAYGAAAAAFnGwAAAwIWAABSGwAAZhsAAAbnAgAAMwkAADEWAAANGgAAmxoAAFIcAAAFxwIAANkCAADaAgAA6xMAALwbAAAE2wIAAOgbAAD0GwAAXxwAAAL1BQAAmRwAAAKpGAAAuxsAAAAAAAAAAAIvCQAA8xMAAA==\",\"AAAAAWkHAAAB1wIAAAABJhgAAA==\"]Do[\"AAAAAXEHAAAB1wIAAAABdgIAAAAAAAAAAAAAAAAAA8kOAACVEgAAaxUAAA==\",\"AAAAAXEHAAA=\"]Fc[\"AAAAAZsHAAAAAfgPAAABpwIAAAAAAAAAAAAAAAAABKkSAAAgFwAAKxgAAK8YAAA=\",\"AAAAAZsHAAABpxMAAAAAAW4dAAA=\"]Gk[\"AAAAAjEEAABsBwAAAAHUDwAAAXICAAAAAAAAAAAAAAAAADowAAABAAAAAAAKABAAAAA8AN0FJwcdCuML+g5CEnEVPxcmGkUc\",\"AAAAAWwHAAABZxMAAA==\"]Bk[\"AAAAAAAAAAAAAAAAAAAAAAAAAgAMAAA4DAAA\",\"\"]Ec[\"AAAAAUkHAAAAAbkPAAABSwIAAAAAAAAAAAAAAAAAAjUWAADgFgAA\",\"AAAAAUkHAAABMRMAAAFvHQAA\"]Bg[\"AAAAAAAAAAAAAAAAAAAAAAAAAaUKAAA=\",\"\"]Fc[\"AAAAAYoHAAABxhgAAAIhAAAA6A8AAAKTAgAALxwAAA==\",\"AAAAAYoHAAABhxMAAAAAAAAAAAAAAAAAAAAC0AUAANIFAAA=\"]Go[\"AAAAAUcSAAAAA9kOAAAWFwAAFxcAAAAC8BIAALgbAAADyw4AAB4QAACMFQAAAfgSAAAAAAEZGAAAAf0HAAAAAAAAAjEJAADEDgAAAAFCFwAA\",\"AAAAAesCAAA=\"]CNk[\"AAAAADswAAABAADrAFgARRQAAEgUAQBLFAAAThQAAFAUAQBUFAAAVhQAAFgUAABaFAAAXBQBAGAUAABiFAAAZBQBAGcUAABpFAAAbBQAAG4UAABwFAEAdBQAAHYUAAB4FAEAfBQBAIAUAACCFAAAhBQBAIcUAACKFAEAjhQBAJEUAACTFAAAlhQAAJgUAACaFAAAnBQAAJ4UAQChFAAAoxQAAKYUAACoFAEAqxQAAK4UAQCxFAAAtBQAALYUAQC5FAAAvBQAAL4UAQDBFAAAxBQAAMYUAQDJFAAAzBQBANAUAQDTFAAA1RQAANcUAADaFAEA3hQBAOIUAQDlFAAA5xQAAOkUAADsFAAA7hQAAPAUAQD0FAEA+BQAAPoUAAD8FAAA/hQBAAIVAQAGFQAACBUBAAsVAAANFQAADxUAABIVAQAWFQEAGRUAABsVAAAeFQEAIRUAACMVAAAmFQEAKRUAACwVAQAwFQAA3Bx1ADswAAABAADrAFcASAl1AEYUAQBKFAAATBQBAE8UAABSFAEAVRQAAFcUAABZFAAAWxQAAF4UAQBhFAAAYxQAAGYUAABoFAAAahQBAG0UAABvFAAAchQBAHUUAAB3FAAAehQBAH4UAQCBFAAAgxQAAIYUAACIFAEAjBQBAJAUAACSFAAAlBQBAJcUAACZFAAAmxQAAJ0UAACgFAAAohQAAKQUAQCnFAAAqhQAAKwUAQCwFAAAshQBALUUAAC4FAAAuhQBAL0UAADAFAAAwhQBAMUUAADIFAAAyhQBAM4UAQDSFAAA1BQAANYUAADYFAEA3BQBAOAUAQDkFAAA5hQAAOgUAADqFAEA7RQAAO8UAADyFAEA9hQBAPkUAAD7FAAA/RQAAAAVAQAEFQEABxUAAAoVAAAMFQAADhUAABAVAQAUFQEAGBUAABoVAAAcFQEAIBUAACIVAAAkFQEAKBUAACoVAQAuFQEAADswAAABAADrAAIAGw91ACwYdQA=\",\"\"]Fk[\"AAAAAAAAAmICAABjAgAA\",\"AAAAAAAB6RsAAAA6MAAAAQAAAAAACAAQAAAAMwpcCtoOixWWFgcZfRnWGvsbAAOREgAALRYAAMAYAAA=\"]Hg[\"AAAAAY0ZAAAAAekbAAAAOjAAAAEAAAAAAAgAEAAAADMKXAraDosVlhYHGX0Z1hr7GwADkRIAAC0WAADAGAAAAAAAAAAAAAAD4RYAABIXAAAFGQAA\",\"AALSAgAANgoAAA==\"]Eo[\"AAAAAAABChoAAAFdCgAAAYsWAAAAAAJpGgAAohwAAAGCHQAAARYYAAAB8hsAAAADFQ8AAIgSAAA3FwAAATUVAAA=\",\"\"]AOc[\"AAAAAAAGNBYAAH4WAACDFgAAsRkAAMwaAAC9HAAAOjAAAAEAAAAAABUAEAAAACMMsRJ/FhEXHBcpFw0YyRiyGWsbbhtxG44bphupG8Mb2Rs6HDscjxxTHY4dOjAAAAEAAAAAABMAEAAAALAZtBnzGRMaQBpBGqUabRt0G6cbBRwHHB0cHhxbHGkcahxrHKgcah06MAAAAQAAAAAAEwAQAAAAghYoFyoXgRdGGlkbahtwG3Ub5hsEHAgcUxxsHIEclRxeHYYdnx2tHQeRFgAAvxgAALMZAAA8GgAAbBsAALccAAB4HQAAB30XAAChGQAAHBoAAG8bAAC+GwAAxxwAAKUdAAA6MAAAAQAAAAAADgAQAAAAEBSiFksXUBewGAsZchvFGykcKhxLHGYckhzBHIQdAxMYAABCGgAAmB0AAAGUHAAABRIYAAC9GwAAThwAAF0cAACyHQAABA4XAAAzGgAAhxoAAFwcAAAAAT8aAAA=\",\"\"]Ek[\"AAAAAVEHAAAAAcEPAAABVAIAAAAAAAAAAAAAAAAAA8gHAACDCgAAthkAAA==\",\"AAAAAVEHAAACRBMAAI8bAAA=\"]Eo[\"AAAAA28VAAAkFwAAahoAAAItGgAAdhsAAAGgGgAAAZEbAAAAAAAAAAAAAAAAAAIlFwAA9xgAAA==\",\"AAAB0wIAAA==\"]Ec[\"AAAAAAAAAVICAAAAAAAAAaIWAAAAAAAAAAAFZwMAAGkKAAAEDwAARBIAAFoSAAA=\",\"AAAAAAE/EwAA\"]Mk[\"AAAAAZcHAAAAAfQPAAABowIAAAF/HAAAB5IWAACTFgAAlBYAAJUWAACAHAAAjBwAALgcAAA7MAAAAQAADQAGANUOAwBKGwMAlxsCALYbAACLHQAAlB0AAAJJHAAAnhwAAAAAAAAAAAA6MAAAAQAAAAAACAAQAAAAyQc/CUMJsAoZD6ASPhQVF3Ya\",\"AAFMCgAAARkWAAABlwcAAAGfEwAA\"]Gg[\"AAABlQ8AAAGsBwAAAAIpAAAABBAAAAO+AgAAphsAAI8cAAAAAAAAAAAAAAAAAAH6GQAA\",\"AAFTCgAAAZUPAAACrAcAAKUbAAAB0xMAAAABphsAAA==\"]Ok[\"AAAAAAAE/hUAAJwWAABIFwAAmhsAAAGZGgAAOjAAAAEAAAAAAAwAEAAAAAcMPRIBFKkWDhjMGJwZ/BkBGnIazxqSG7kcOjAAAAEAAAAAAAcAEAAAADkSkhaXFuAb9RtgHGUcmhw6MAAAAQAAAAAABwAQAAAA2wLdCdUOuxh+GQUaShtUHDowAAABAAAAAAAIABAAAAB3FwYYgxmHGXwanhuyG+IbWx0AAAIjHAAAaR0AAAGmHQAA\",\"\"]Gg[\"AAAAAUMHAAAAAawPAAABNQIAAAAAAAAAAAAAAAAAOjAAAAEAAAAAAAgAEAAAADAAJgf3B0YI3gv5CxAMXRIhGg==\",\"AAAAAzEEAAAyBAAAQwcAAA==\"]Ec[\"AAAAAVYHAAAAAQkAAAABWQIAAAAAAAAAAAAAAAAAAmoKAAD4EwAA\",\"AAAAAVYHAAAAAAAAAAEqFgAA\"]Gk[\"AAAAAAAAATgCAAAB8xkAAAGVHAAAAAGhGQAAAYQdAAAAAAAAAAA6MAAAAQAAAAAACwAQAAAAMQD4B0cI9w4HEBQQIRCmEjcVChkPGiIa\",\"AAAAAAEWEwAA\"]Dc[\"AAAAAXQHAAAAAAF9AgAAAAAAAAAAAAAAAAAC0xEAAIQVAAA=\",\"AAAAAXQHAAA=\"]Bk[\"AAAAAAAAAAAAAAAAAAAAAAAAAm0KAAAsFwAA\",\"\"]A@g[\"AAAAAAAG4wIAANkOAAAWFwAAFxcAADoXAAALGgAAAl8KAACeHQAABPASAACMFgAAIRcAAD4XAAAG2gIAAMsOAAAeEAAAjBUAALoYAABeGwAABtsCAACLEgAA+BIAACoWAACoFgAAyxoAAAEIGQAAAwAUAABMHAAAiBwAAAEZGAAAAf0HAAAAAxYPAACKEgAAOBcAAAI2FQAAkBoAAAABAgQAAAABQhcAAAAAAYUdAAA=\",\"AAAAAaIKAAAB6QIAAA==\"]Ag[\"\",\"AAAAAAABGA8AAA==\"]Ec[\"AAAAAgMGAADmBwAAAAFFFwAAAAAAAAAAAAAAAAAABMcOAABwEAAATRIAAHgVAAA=\",\"AAAAAAHOAgAA\"]Bg[\"AAAAAAAAAAAAAAAAAAAAAAAAAU4MAAA=\",\"\"]Hc[\"AAAAAosHAADiDgAAAycJAAD0GQAAqxwAAAHpDwAAAZQCAAAAAAAAAAAAAAAAAALSBQAAMBoAAA==\",\"AAFHCgAAAAKLBwAAPRoAAAGKEwAAAiwcAADAHAAAAS8cAAA=\"]Fc[\"AAAAAUAHAAAAAacPAAABMQIAAAAAAAAAAAAAAAAABS0JAACvEgAA/BMAANUWAAB/FwAA\",\"AAAAAUAHAAAAAAAAAeYbAAA=\"]Dc[\"AAAAAAAAAAAB9gsAAAFFGwAAAAAAAAAAAAABKwkAAA==\",\"AALsAgAAVQoAAA==\"]Bk[\"\",\"AAAAAAXaFgAA2xYAANwWAADdFgAA3hYAAA==\"]Eg[\"AAAAAT4HAAAAAaUPAAABLwIAAAAAAAAAAAAAAAAABdsLAABlEAAAUREAAOsSAAA0FQAA\",\"AAAAAT4HAAA=\"]Dc[\"AAAAAYgHAAAAAeYPAAABkQIAAA==\",\"AAAAAYgHAAAAARQaAAAAAAABVBwAAA==\"]Hg[\"AAABrRwAAAGJBwAAAfwbAAAB5w8AAAGSAgAAAAABeB0AAAAAAAAAAAAAA1oKAAAaFgAA8RkAAA==\",\"AAFGCgAAAa0cAAACiQcAAOgZAAABhRMAAAF2HQAAAAAAAXgdAAA=\"]Dg[\"AAAAATIHAAAAAZoPAAAAAAAAAAAAAAAAAAADIgoAAIQKAAAkDAAA\",\"AAAAATIHAAA=\"]Dg[\"AAAAAYIHAAAAAhsAAADgDwAAAYsCAAA=\",\"AAAAAYIHAAACehMAAOoZAAAAAAGTHAAA\"]Gc[\"AAAAAU4HAAAAAb4PAAABUAIAAAGoHAAAAAAAAAAAAAAAAAb3BQAAaAoAABEPAAAZEAAAnxIAAA4UAAA=\",\"AAAAAU4HAAABPBMAAAAAAWocAAA=\"]Bk[\"AAAAAAAAAWECAAA=\",\"AAAAAAAAAAAAAZESAAA=\"]Ak[\"\",\"AAAAAAAAAAABmxYAAA==\"]AEo[\"AAAAAAGhCwAABTIJAAABFgAAnxYAAJ0bAABQHAAAAYscAAA6MAAAAQAAAAAADQAQAAAACgxAEgQUrBY+FxEYzxifGf8ZBBp1GtIalRu8HDowAAABAAAAAAAJABAAAAA8EpUWmhYQF3wZ+BtjHIwcnRy4HDowAAABAAAAAAAKABAAAADgCdgOHRi+GIEZCBo3GpgaTRuYG5kbOjAAAAEAAAAAAAkAEAAAAHoXCRiGGYoZfxpdG6EbtRvlG0kcAAJmGgAAoxsAAAEmHAAAAqkdAACqHQAAAAAAAsEJAACVCgAAAAAAAAG2FgAAAAErGgAA\",\"AAAAAeYCAAAAAAABxQIAAA==\"]Eo[\"AAABtgQAAAGjBwAAAAABtAIAAAAAAAAAAAAAAAAABe8OAACEEgAA+xMAAJEZAABWHAAA\",\"AAAAAaMHAAABwRMAAA==\"]Gc[\"AAAAA34HAAAJGgAApBsAAAADGAAAAN0PAACGFQAAAYcCAAA=\",\"AAAAAX4HAAACMAoAAHMTAAABhhoAAAAAAAAAAAAAAAAAAAIxCgAApQoAAA==\"]AEc[\"AAAAA+YCAADuEgAATBoAAAGfCwAABAAWAACeFgAATRcAAJwbAAAAOjAAAAEAAAAAAA0AEAAAAAkMPxIDFKsWPBcQGM4Ynhn+GQMadBrRGpQbuxw6MAAAAQAAAAAACAAQAAAACww7EpQWmRYPFxgX9xtiHJwcB98JAADXDgAAHBgAAL0YAACAGQAABxoAAEwbAAA6MAAAAQAAAAAACQAQAAAAEw95FwgYtBiFGYkZfhqgG7Qb5BsCjxoAAJccAAABohsAAAElHAAAAqgdAACzHQAAAAAABaALAACWEgAA9BMAAGwVAACCFQAA\",\"AAAAAUwaAAAAAAHlAgAA\"]Bc[\"AAAAAAAAATkCAAA=\",\"AAAAAAEXEwAA\"]Do[\"AAAAAAAAAToCAAAAAAABHBoAAAAAAAAAAAAEvQcAACsKAACpCgAAPBQAAA==\",\"AAAAAAEaEwAA\"]Fg[\"AAAAAUoHAAAAAboPAAABTAIAAAAAAAAAAAAAAAAABWcKAADtDgAAghIAAPcTAAAZFAAA\",\"AAAAAUoHAAABMxMAAAAAAAGGHQAA\"]Hg[\"AAAAAXMHAAAAAf4bAAAB6RMAAAXYCQAABRQAADkXAABPFwAA0BgAAAAC3QIAANsbAAAAAAHiAgAAAAAAAAADnQsAANURAAAlGAAA\",\"AAAAAXMHAAAAAAEXGgAAAAHaAgAA\"]AMo[\"AAAAOjAAAAEAAAAAABwAEAAAANQH7gfqCTEMTxLxEh0UiBUEF1EXIRiCGYsZ5hnoGQAaPRprGnEapBrOGrcb3xuEHIccjRyzHFQdYB06MAAAAQAAAAAADgAQAAAA2Qn6C0cXqBjGGMoYEhqMGtUa/BsfHMocyxzPHKAdOjAAAAEAAAAAABQAEAAAAOkF8AUGBtcH3Qc+DIcWSBcgGMUYyxgUGjEaMho6GnAaZxvzG/4bphxyHTowAAABAAAAAAALABAAAAD5BRIPmRLRFiYYwximGakZFxqSGs0amBwEpxYAAKEaAABqHQAAox0AAATZAgAAHxAAAIUSAACcGgAAAVQcAAABqhgAAAGQHAAAAAFpHQAAAAAAADowAAABAAAAAAASABAAAADVB94HJAqgCtgRjhI4FIgWoRbGFtcWJxgEGekZGBqNGtcbGRxHHA==\",\"AAE/CgAAAAAAAAAAAAHdAgAA\"]Dc[\"AAAAAAAAASICAAAAAAAAAAAAAAAAAAMsAAAAowsAADMUAAA=\",\"AAAAAAECEwAA\"]Ag[\"\",\"AAAAAAABshIAAA==\"]Jc[\"AAAB/xsAAAKqBwAAdxYAAAXHBwAANAkAAFwSAAAHFAAA4hYAAAMoAAAAAhAAAL0cAAABvAIAAAAAAAAAAAAAAAAAAcUcAAA=\",\"AAJRCgAAQxoAAAABqgcAAAHQEwAAAAABiR0AAAAAAAAAAAAAAAABRBoAAA==\"]Cg[\"AAAAAAAAAAAAAAAAAAAAAAAABG4DAADqCwAAHAwAADoMAAA=\",\"\"]Dc[\"AAAAAAAAAUMCAAAAAVkbAAAAAAAAAAAAAAACvgcAACoMAAA=\",\"AAAAAAEiEwAA\"]Eo[\"AAAAAVIHAAAAAcIPAAABVQIAAAAAAAAAAAAAAAAABbUHAADsBwAAEhYAABQWAACTGQAA\",\"AAAAAVIHAAABRhMAAA==\"]Fg[\"AAAAAVMHAAAAAcMPAAABVgIAAAAAAAAAAAAAAAAAAxMWAAAVFgAAlBkAAA==\",\"AAAAAVMHAAABSBMAAAAAAT4XAAABRhoAAA==\"]Do[\"AAAAAVQHAAAAAcQPAAABVwIAAAAAAAAAAAAAAAAAAR8XAAA=\",\"AAAAAVQHAAABSRMAAAHMGgAA\"]Eo[\"AAAAAVAHAAAAAcAPAAABUwIAAAAAAAAAAAAAAAAAA+wFAAA3EgAAnRIAAA==\",\"AAAAAVAHAAABQRMAAAABbxoAAA==\"]Fk[\"AAAAAZYHAAAAAfMPAAABogIAAAAAAAAAAAAAAAAABOQHAABSEgAAbhoAAIEaAAA=\",\"AAAAAZYHAAABnRMAAAJPHAAAUBwAAAGkHQAA\"]Ek[\"AAAAATsHAAAAAaMPAAABKQIAAAAAAAAAAAAAAAAAAvYHAADKDgAA\",\"AAAAATsHAAABDRMAAAAAAAABkRYAAA==\"]Fk[\"AAAAAewTAAAAAAAAAAAAAAAAAAAAAAAAAAAAAbYWAAAAASsaAAA=\",\"AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAABthYAAAABKxoAAA==\"]ACg[\"AAAAAeYCAAAABP8VAACdFgAAmxsAAE8cAAACoBYAAIocAAA6MAAAAQAAAAAADAAQAAAACAw+EgIUqhYPGM0YnRn9GQIacxrQGpMbuhwHOhIAAJMWAACYFgAA9hsAAGEcAABnHAAAmxwAADowAAABAAAAAAAIABAAAADeCdYOzha8GH8ZlxkGGksblxs6MAAAAQAAAAAACgAQAAAAzRZ4FwcYhBmIGX0aXBufG7Mb4xt8HQG6GwAAAZcaAAABJBwAAAGnHQAAAAAAA54LAAAbEAAA1hEAAA==\",\"AAAAAAAB4wIAAA==\"]Ck[\"AAAAAAAAAT0CAAA=\",\"AAAAAAEfEwAAAAAAAAAAAAAAAAAAAAEsCgAA\"]Ec[\"AAAAAAAAATwCAAAAAAAAAAAAAAGHGgAAAAAFLAoAAKsKAAArEAAAPRQAAEwXAAA=\",\"AAAAAAEeEwAA\"]Ec[\"AAAAAXUHAAAD/gUAAP8FAADZCQAAAAJ+AgAA6RMAAATYCQAAORcAAE8XAADQGAAA\",\"AAAAAXUHAAA=\"]Co[\"AAAAAX0HAAACBgQAAAcEAAAB3A8AAAGGAgAA\",\"AAAAAX0HAAABcRMAAA==\"]Dc[\"AAAAAAFgCgAAAjIJAABWCgAAAAHFAgAAAAAAAAAAAAAAAAErFgAA\",\"AAHPAgAA\"]Do[\"AAAAAUQHAAAAAbQPAAABRgIAAAAAAAAAAAAAAAAAAtoJAACQEgAA\",\"AAAAAUQHAAABJxMAAA==\"]Bk[\"AAAAAAAAAAAAAAAAAAAAAAAAAtAFAAAuGgAA\",\"\"]Eg[\"AAAAAAACIhwAAFkcAAADXhwAALYcAADUHAAAAAKCFgAA5hsAAAJsGwAAtxwAAAABSxwAAAAAAV0cAAA=\",\"\"]Cg[\"AAAAAAEHBAAAAAAAAAAAAAAAAAAAAAFyEAAA\",\"AAAB3wIAAA==\"]Cg[\"AAAAAAABrw8AAAE+AgAA\",\"AAAAAAAAAAAAAAAAAAABHRoAAA==\"]Cg[\"AAAAAXoHAAAAARYAAAABggIAAA==\",\"AAAAAXoHAAAAAYEdAAA=\"]Dg[\"AAAAAAAAAvkYAAAVHAAAAvoYAADFGgAAAANzEAAAxhoAAEYcAAAC7xIAAH0XAAA=\",\"\"]Fo[\"AAAAAVsHAAAAAckPAAACXwIAANECAAAAAAAAAAAAAAAAAAT2EwAAFxYAAAMZAABQGwAA\",\"AAE1CgAAAAJbBwAAQRIAAAFUEwAAAUocAAA=\"]Fc[\"AAAAAVoHAAAAAccPAAABXQIAAAAAAAAAAAAAAAAABDgAAADbBQAASRIAABgWAAA=\",\"AAAAAVoHAAABUBMAAAAAAZkdAAA=\"]Ao[\"\",\"AAAAOzAAAAEAAGwAAQCSA2wA\"]Eo[\"AAAAAXcHAAAAAWEdAAABfwIAAAAAAAAAAAAAAAAABigHAAD5BwAASAgAAEsSAADmEwAAGhQAAA==\",\"AAAAAXcHAAA=\"]Ag[\"AAAAAAAAASkYAAA=\",\"\"]Ec[\"AAAAAsYCAABoBwAAAu4CAAB8FwAAAAAAAAAAAAAAAAAAAAHtBwAA\",\"AALVAgAAOwoAAAABaAcAAA==\"]Ak[\"AAAAAAH+BQAAAcUYAAA=\",\"\"]Dc[\"AAAAAAEGBAAAAzkaAAA6GgAAOxoAAAAAAAAAAAHiAgAA\",\"AAAAAAABhxYAAA==\"]ADg[\"AAAAAAA6MAAAAQAAAAAACgAQAAAAoBlaG+8b8BssHFgcwBzCHHUddh2bHTowAAABAAAAAAAIABAAAAAMGlEbLhwvHFEclhzEHFgdWR06MAAAAQAAAAAADQAQAAAAERQnFrsWGxqIGmIbYxuvGyscLRyTHMYcyRyRHQcSFAAAKhcAAB4aAABZGwAAWxsAAOcbAACVHAAAAxMUAAAyHAAAeB0AAAOhGQAAHBoAAMccAAAGEBQAALAYAAAqHAAAkhwAAMEcAACEHQAAAAGUHAAAAh0aAABdHAAABDMaAACHGgAAXBwAAMgcAAAAAAHFHAAA\",\"\"]Gc[\"AAAAAVUHAAAAAcUPAAABWAIAAAAAAAAAAAAAAAAAAj4JAABLDAAA\",\"AAAAAVUHAAABTBMAAAAAAAAAAAFmHAAAAAAAAAAAAAAAAAABhR0AAA==\"]Dc[\"AAAAAAAAAUQCAAAAAAAAASocAAAAAAAAAAACKwwAANcMAAA=\",\"AAAAAAEkEwAA\"]Ek[\"AAAAAUIHAAAAAakPAAABMwIAAAAAAAAAAAAAAAAABN0LAAAjEAAARxoAAMYbAAA=\",\"AAAAAUIHAAAAAAG2HAAA\"]Fc[\"AAAAAUEHAAAAAagPAAABMgIAAAAAAAAAAAAAAAAABdwLAABmEAAANxYAANkWAADHGAAA\",\"AAAAAUEHAAAAAAAAAAG3HAAA\"]Cg[\"AAAAAAAAAAAAAAAAAAAAAAAAAqEKAAB7FQAA\",\"AAAAAdgbAAA=\"]Gg[\"AAAABkcMAABBEgAAIhcAAMgYAACuGQAAbBoAAAFJFwAAAkYXAACWGQAAAAGkHAAAAAAAAAAAAAAAAARIDAAAlBIAAPITAACjFgAA\",\"AAAB0AIAAA==\"]Fc[\"AAAAAAAAASsCAAAAAYIWAAAAAAAAAAAAAAA6MAAAAQAAAAAACAAQAAAAZQqYC0AMzg70DgYPYxDpEo4W\",\"AAAAAAEREwAA\"]Fk[\"AAAAAaUHAAAAAAG3AgAAAAAAAAAAAAAAAAA6MAAAAQAAAAAACAAQAAAASADoBQEGJwrGDvYZwBvCGzgc\",\"AAAAAaUHAAABxxMAAA==\"]Fg[\"AAAAAaQHAAAAAAG2AgAAAAAAAAAAAAAAAAAHswcAALsHAAAaDAAA5w4AAEYSAABAFAAAgRYAAA==\",\"AAAAAaQHAAABxRMAAA==\"]Hc[\"AAAAAjUcAAClHAAABzAKAABZCgAAuBkAAFMbAAC5GwAAFBwAAKocAAAErBoAAGgbAABpGwAAcBwAAAGvGQAAAAAAAAAAAAAAAAABMQoAAA==\",\"AALgAgAAQgoAAA==\"]Bk[\"AAAAAAAAAAAAAAAAAAAAAAAAAtEFAACYEgAA\",\"\"]Ik[\"AAAAAwwMAAAiGAAA3hsAAAJPDAAArBkAAAE5GgAAAALnAgAASRoAAAOrGgAAbxwAAFwdAAABqRwAAAAAAZAbAAAAAAAAAAaHFQAAhhYAAMUWAAAjGAAAJBgAABoaAAA=\",\"AAAAAAAAAAAB2QIAAA==\"]Dk[\"AAAAAAABqg8AAAAAAAAAAAAAAAAAAAT3CwAAKAwAAEoSAACaGQAA\",\"AAAAAAAAAAGRHQAA\"]Ak[\"AAAAAVcKAAABOQkAAA==\",\"\"]Eo[\"AAAAAbAHAAAAAQYQAAABwQIAAAAAAAAAAAAAAAAAA1MSAAD/EwAAtxkAAA==\",\"AAAAAbAHAAAB2BMAAAAAAXQbAAA=\"]Ig[\"AAAABV0HAAAOFgAAPRcAAEwaAABNHAAAAoYcAADQHAAAATMcAAAC0hYAAGUaAAAAAAAAAAAAAAAAAAPkCQAAcRAAAHkZAAA=\",\"AAAAAl0HAABHDAAAAAAB0QIAAAAAAAAAAAAAAAAAAUgMAAA=\"]Eg[\"AAAAAT8HAAAAAaYPAAABMAIAAAAAAAAAAAAAAAAAA24VAAAjFwAAfhcAAA==\",\"AAAAAT8HAAAAAVIdAAA=\"]Fk[\"AAAAAAAAAiwCAAARFwAAAAAAAAAAAAAAAAA6MAAAAQAAAAAACgAQAAAAZgqZC0EMzw71DgcPJBQsFM8WExc0Fw==\",\"AAAAAAEUEwAA\"]Co[\"AAAAAAAAAbICAAAAAAAAAAAAAAAAAALHBQAAoxkAAA==\",\"AAAAAAG+EwAA\"]Dc[\"AAAAAAABsg8AAAFBAgAAAAAAAAAAAAAAAAACrRoAAAYcAAA=\",\"AAAAAVocAAA=\"]Dg[\"AAAAAAAB1g8AAAF3AgAAAAAAAAAAAAAAAAAFeQoAAOkOAAAmFgAALhcAAKsYAAA=\",\"\"]Go[\"AAAAAgoGAADqBwAAAwUEAACrGQAA0BwAAAE7GgAAAScMAAAAAAAAAAAAAAAAADowAAABAAAAAAAHABAAAABOEoUVihbYFigY1BraG+wb\",\"AAAAAAAAAAHnAgAA\"]Ak[\"AAAAAAAAAjgaAADOHAAA\",\"\"]Cg[\"AAAAAAAAAAAAAAAAAAAAAAAABHkVAAAhFgAAChgAAPAZAAA=\",\"\"]Cg[\"AAAAAAAAAAAAAAAAAAAAAAAABHoVAAAiFgAACxgAAPIZAAA=\",\"\"]Cc[\"AAAAAAABwxwAAAAAAAABxxwAAAAAAAAAAAABxRwAAA==\",\"\"]Dc[\"AAAAAAAAAUUCAAAAAAAAAZIcAAAAAAAAAAACLAwAACwQAAA=\",\"AAAAAAEmEwAA\"]Dk[\"AAAAAUUHAAAAAbUPAAABRwIAAAAAAAAAAAAAAAAAARQMAAA=\",\"AAAAAUUHAAABKhMAAA==\"]AEo[\"\",\"AAAAADowAAABAAAAAAB1ABAAAABFFEgUSRRLFE4UUBRRFFQUVhRYFFoUXBRdFGAUYhRkFGUUZxRpFGwUbhRwFHEUdBR2FHgUeRR8FH0UgBSCFIQUhRSHFIoUixSOFI8UkRSTFJYUmBSaFJwUnhSfFKEUoxSmFKgUqRSrFK4UrxSxFLQUthS3FLkUvBS+FL8UwRTEFMYUxxTJFMwUzRTQFNEU0xTVFNcU2hTbFN4U3xTiFOMU5RTnFOkU7BTuFPAU8RT0FPUU+BT6FPwU/hT/FAIVAxUGFQgVCRULFQ0VDxUSFRMVFhUXFRkVGxUeFR8VIRUjFSYVJxUpFSwVLRUwFQ==\"]Do[\"AAAAAAAAATsCAAAAAAAAAAAAAAFcHAAAAAAEqgoAAN8LAADWDAAAZxAAAA==\",\"AAAAAAEcEwAA\"]Ek[\"AAAAAqAHAADwEwAAAAH9DwAAAawCAAAAAAAAAAAAAAAAAAFFAAAA\",\"AAAAAaAHAAABshMAAAAAAAAAAbEYAAA=\"]Co[\"AAAAAAAAAbECAAAAAAAAAAAAAAAAAALEBwAAqhoAAA==\",\"AAAAAAG8EwAA\"]Fc[\"AAAAAU8HAAAAAb8PAAABUQIAAAAAAAAAAAAAAAAABAkQAADnGQAAixsAALEcAAA=\",\"AAAAAU8HAAABPhMAAAAAAAGtHQAA\"]Ck[\"AAAAAAABsQ8AAAFAAgAA\",\"AAAAAAAAAAAAAAAAAAAAAAAAAcUcAAA=\"]Co[\"AAAAAAAAAAJ0GwAABxwAAAJ1GwAACBwAAAAAA6IWAABQFwAAxRsAAA==\",\"\"]Cg[\"AAAAAAAAAbACAAAAAAAAAAAAAAAAAAFGAAAA\",\"AAAAAAG6EwAA\"]Co[\"AAAAAAABbx0AAAABpxwAAANqGwAABBwAAF4dAAAAAX0XAAAAAUIaAAA=\",\"\"]Eo[\"AAAAAAABUh0AAAF9HQAAAAGBFwAAAr8YAAA8GgAAAARLFwAACxkAAEscAABmHAAAAAACThwAAF0cAAABDhcAAA==\",\"\"]Eg[\"AAAAATkHAAAAAaEPAAABJwIAAAAAATwaAAAAAAAAAAAAAAIuEAAAHxYAAA==\",\"AAAAATkHAAABCRMAAA==\"]Ck[\"AAAAAAAB1w8AAAF4AgAAAAAAAAAAAAAAAAADegoAAC8XAACsGAAA\",\"\"]Bg[\"AAAAAAABFhwAAAAAAAFGHAAAAe8SAAA=\",\"\"]Ck[\"AAAAAREWAAAAAALDAgAAxAIAAA==\",\"AAAAAAAAAAAB9gsAAAFFGwAA\"]Dc[\"AAAAAVwHAAAAAcoPAAABYAIAAAAAAAAAAAAAAAAAAQ8WAAA=\",\"AAAAAVwHAAA=\"]Dc[\"AAAAAAAB2A8AAAF5AgAAAAAAAAAAAAAAAAAEewoAAOoOAAAwFwAArRgAAA==\",\"\"]Ck[\"AAAAAAABvxwAAAAAAa0bAAABVxwAAAGRHAAAASkcAAAAAe0bAAA=\",\"\"]Cg[\"AAAAAAABsA8AAAE/AgAA\",\"AAAAAAAAAAAAAAAAAAAAAcgcAAA=\"]ADg[\"AAAAAAAAA5MaAABkHAAApB0AADowAAABAAAAAAAMABAAAACyGGQb+hugHKEcWh1uHXkdih2THZUdmR2rHQezGAAARhoAAIoaAACMHQAAnR0AAKwdAAC0HQAAOjAAAAEAAAAAAAoAEAAAAIkaixq2G/Eb+RvMHHodex2LHZQdsR0HsRgAAJ4cAACfHAAAax0AAG0dAAChHQAApR0AADowAAABAAAAAAAIABAAAACiFjQaKhxmHJIcbB2WHZcdnB0AApQcAAC1HQAAAh0aAABOHAAABQ4XAAAzGgAAhxoAAFwcAADIHAAAAAABxRwAAA==\",\"\"]Bo[\"AAAAATcHAAAAAZ8PAAABJQIAAA==\",\"AAAAATcHAAA=\"]Dc[\"AAAAAAAAASMCAAAAAAAAAAAAAAAAAAMPBgAAEhAAAGkVAAA=\",\"AAAAAAEEEwAA\"]Eo[\"AAAAAAAFShwAALQcAABnHQAAcx0AAH8dAAABaB0AAAF+HQAAAa0bAAABVxwAAAGRHAAAAAAB7RsAAAABMxoAAA==\",\"\"]Ec[\"AAAAAAABhhoAAAABKxwAAAABExQAAAKhGQAAHBoAAAMQFAAAsBgAAIQdAAAAAAAChxoAAFwcAAA=\",\"\"]Hc[\"AAAAAAACvhwAAIMdAAACFhoAAIcdAAAHERQAACcWAAC7FgAAGxoAAIgaAACRHQAAkh0AAAMSFAAAHhoAAJodAAABExQAAAACEBQAALAYAAAAAAAChxoAAFwcAAA=\",\"\"]Eo[\"AAAAAAAAASoCAAAAAAAAAUscAAAAAAAAAAAHZAoAAJcLAADzDgAAYhAAAKUSAACsEgAA6BIAAA==\",\"AAAAAAEQEwAA\"]Ak[\"AAAAAVgKAAABOgkAAA==\",\"\"]") \ No newline at end of file diff --git a/api/settings.html b/api/settings.html new file mode 100644 index 00000000..df344dda --- /dev/null +++ b/api/settings.html @@ -0,0 +1 @@ +SettingsSkip to main content

All

Rustdoc settings

Back
\ No newline at end of file diff --git a/api/src-files.js b/api/src-files.js new file mode 100644 index 00000000..50b0584c --- /dev/null +++ b/api/src-files.js @@ -0,0 +1,2 @@ +createSrcSidebar('[["jellytau",["",[],["main.rs"]]],["jellytau_lib",["",[["auth",[],["mod.rs","session_verifier.rs"]],["commands",[["download",[],["mod.rs","pinning.rs","smart_cache.rs"]],["player",[],["mod.rs","queue.rs","remote.rs","session.rs","settings.rs","timers.rs"]],["storage",[],["mod.rs","people.rs","series_prefs.rs","thumbnails.rs"]]],["auth.rs","catalog.rs","connectivity.rs","conversions.rs","device.rs","diagnostics.rs","favorites.rs","library.rs","mod.rs","offline.rs","playback_mode.rs","playback_reporting.rs","playlist.rs","repository.rs","sessions.rs","sync.rs","sync_drain.rs"]],["connectivity",[],["mod.rs"]],["domain",[],["from_jellyfin.rs","media.rs","mod.rs","search_rank.rs"]],["download",[],["cache.rs","events.rs","mod.rs","network.rs","stop.rs","worker.rs"]],["jellyfin",[],["client.rs","http_client.rs","mod.rs","types.rs"]],["playback_mode",[],["mod.rs"]],["playback_reporting",[],["mod.rs","reporter.rs","sync_processor.rs","throttle.rs"]],["player",[],["autoplay.rs","backend.rs","background_policy.rs","events.rs","media.rs","mod.rs","mpv_backend.rs","queue.rs","seek.rs","session.rs","sleep_timer.rs","state.rs","stream_end.rs"]],["repository",[],["device_profile.rs","exclusions.rs","hybrid.rs","mod.rs","offline.rs","online.rs","series_progress.rs","types.rs"]],["session_poller",[],["mod.rs"]],["storage",[],["db_service.rs","mod.rs","models.rs","schema.rs"]],["thumbnail",[],["cache.rs","mod.rs","worker.rs"]],["utils",[],["conversions.rs","diagnostics.rs","lock.rs","mod.rs"]]],["credentials.rs","lib.rs","media_server.rs","settings.rs"]]]]'); +//{"start":19,"fragment_lengths":[32,1529]} \ No newline at end of file diff --git a/api/src/jellytau/main.rs.html b/api/src/jellytau/main.rs.html new file mode 100644 index 00000000..8c831793 --- /dev/null +++ b/api/src/jellytau/main.rs.html @@ -0,0 +1,7 @@ +main.rs - sourceSkip to main content

jellytau/
main.rs

1// Prevents additional console window on Windows in release, DO NOT REMOVE!!
+2#![cfg_attr(not(debug_assertions), windows_subsystem = "windows")]
+3
+4fn main() {
+5    jellytau_lib::run()
+6}
+
\ No newline at end of file diff --git a/api/src/jellytau_lib/auth/mod.rs.html b/api/src/jellytau_lib/auth/mod.rs.html new file mode 100644 index 00000000..5523acc5 --- /dev/null +++ b/api/src/jellytau_lib/auth/mod.rs.html @@ -0,0 +1,490 @@ +mod.rs - sourceSkip to main content

jellytau_lib/auth/
mod.rs

1pub mod session_verifier;
+2
+3use serde::{Deserialize, Serialize};
+4use std::sync::Arc;
+5use tokio::sync::RwLock;
+6
+7use crate::connectivity::ConnectivityMonitor;
+8use crate::jellyfin::http_client::HttpClient;
+9
+10pub use session_verifier::SessionVerifier;
+11
+12/// Server information returned from Jellyfin
+13#[derive(specta::Type, Debug, Clone, Serialize, Deserialize)]
+14#[serde(rename_all = "camelCase")]
+15#[specta(rename = "AuthServerInfo")]
+16pub struct ServerInfo {
+17    pub name: String,
+18    pub version: String,
+19    pub id: String,
+20    /// Normalized server URL with protocol and no trailing slash
+21    pub normalized_url: String,
+22}
+23
+24/// User information
+25#[derive(specta::Type, Debug, Clone, Serialize, Deserialize)]
+26#[serde(rename_all = "camelCase")]
+27pub struct User {
+28    pub id: String,
+29    pub name: String,
+30    pub server_id: String,
+31    pub primary_image_tag: Option<String>,
+32}
+33
+34/// Authentication result
+35#[derive(specta::Type, Debug, Clone, Serialize, Deserialize)]
+36#[serde(rename_all = "camelCase")]
+37pub struct AuthResult {
+38    pub user: User,
+39    pub access_token: String,
+40    pub server_id: String,
+41}
+42
+43/// Active session for restoration
+44#[derive(specta::Type, Debug, Clone, Serialize, Deserialize)]
+45#[serde(rename_all = "camelCase")]
+46pub struct Session {
+47    pub user_id: String,
+48    pub username: String,
+49    pub server_id: String,
+50    pub server_url: String,
+51    pub server_name: String,
+52    pub access_token: String,
+53    pub verified: bool,
+54    pub needs_reauth: bool,
+55}
+56
+57// Jellyfin API response types (PascalCase from server)
+58
+59#[derive(specta::Type, Debug, Deserialize)]
+60#[serde(rename_all = "PascalCase")]
+61struct PublicSystemInfo {
+62    server_name: String,
+63    version: String,
+64    id: String,
+65}
+66
+67#[derive(specta::Type, Debug, Deserialize)]
+68#[serde(rename_all = "PascalCase")]
+69struct AuthenticateByNameResponse {
+70    user: JellyfinUser,
+71    access_token: String,
+72    server_id: String,
+73}
+74
+75#[derive(specta::Type, Debug, Deserialize)]
+76#[serde(rename_all = "PascalCase")]
+77struct JellyfinUser {
+78    id: String,
+79    name: String,
+80    server_id: String,
+81    primary_image_tag: Option<String>,
+82}
+83
+84/// Authentication manager
+85pub struct AuthManager {
+86    http_client: Arc<HttpClient>,
+87    current_session: Arc<RwLock<Option<Session>>>,
+88    connectivity_monitor: Option<Arc<tokio::sync::Mutex<ConnectivityMonitor>>>,
+89}
+90
+91impl AuthManager {
+92    /// Create a new auth manager
+93    pub fn new(http_client: HttpClient) -> Self {
+94        Self {
+95            http_client: Arc::new(http_client),
+96            current_session: Arc::new(RwLock::new(None)),
+97            connectivity_monitor: None,
+98        }
+99    }
+100
+101    /// Set the connectivity monitor (for marking server reachability)
+102    pub fn set_connectivity_monitor(
+103        &mut self,
+104        monitor: Arc<tokio::sync::Mutex<ConnectivityMonitor>>,
+105    ) {
+106        self.connectivity_monitor = Some(monitor);
+107    }
+108
+109    /// Normalize and validate server URL.
+110    /// Enforces HTTPS — plain HTTP is rejected for security.
+111    pub fn normalize_url(url: &str) -> Result<String, String> {
+112        let mut normalized = url.trim().to_string();
+113
+114        // Reject plain HTTP — all connections must use HTTPS
+115        if normalized.starts_with("http://") {
+116            return Err("HTTP connections are not allowed. Please use HTTPS (e.g., https://your-server.com).".to_string());
+117        }
+118
+119        // Add https:// if no protocol specified
+120        if !normalized.starts_with("https://") {
+121            normalized = format!("https://{}", normalized);
+122        }
+123
+124        // Remove trailing slash
+125        if normalized.ends_with('/') {
+126            normalized.pop();
+127        }
+128
+129        Ok(normalized)
+130    }
+131
+132    /// Normalize a username before it goes to the server.
+133    ///
+134    /// Only surrounding whitespace is stripped — interior spaces are legal in
+135    /// Jellyfin usernames. Without this, a trailing space from a soft keyboard's
+136    /// autocorrect makes the server report an unknown user, which surfaces as a
+137    /// 401 that looks exactly like a wrong password.
+138    ///
+139    /// TRACES: UR-042 | DR-054
+140    pub fn normalize_username(username: &str) -> String {
+141        username.trim().to_string()
+142    }
+143
+144    /// Connect to server and get server info
+145    pub async fn connect_to_server(&self, server_url: &str) -> Result<ServerInfo, String> {
+146        let normalized_url = Self::normalize_url(server_url)?;
+147        let endpoint = format!("{}/System/Info/Public", normalized_url);
+148
+149        log::info!("[AuthManager] Connecting to server: {}", normalized_url);
+150
+151        match self
+152            .http_client
+153            .get_json_fast::<PublicSystemInfo>(&endpoint)
+154            .await
+155        {
+156            Ok(info) => {
+157                log::info!(
+158                    "[AuthManager] Connected to server: {} ({})",
+159                    info.server_name,
+160                    info.version
+161                );
+162
+163                // Mark server as reachable
+164                if let Some(monitor) = &self.connectivity_monitor {
+165                    let monitor = monitor.lock().await;
+166                    monitor.mark_reachable().await;
+167                }
+168
+169                Ok(ServerInfo {
+170                    name: info.server_name,
+171                    version: info.version,
+172                    id: info.id,
+173                    normalized_url,
+174                })
+175            }
+176            Err(e) => {
+177                log::error!("[AuthManager] Failed to connect to server: {}", e);
+178
+179                // Mark server as unreachable
+180                if let Some(monitor) = &self.connectivity_monitor {
+181                    let monitor = monitor.lock().await;
+182                    monitor.mark_unreachable(Some(e.clone())).await;
+183                }
+184
+185                Err(e)
+186            }
+187        }
+188    }
+189
+190    /// Authenticate by username and password
+191    pub async fn login(
+192        &self,
+193        server_url: &str,
+194        username: &str,
+195        password: &str,
+196        device_id: &str,
+197    ) -> Result<AuthResult, String> {
+198        let url = Self::normalize_url(server_url)?;
+199        let endpoint = format!("{}/Users/AuthenticateByName", url);
+200        let username = Self::normalize_username(username);
+201
+202        log::info!("[AuthManager] Authenticating user: {}", username);
+203
+204        // Build auth header for login request
+205        let auth_header = HttpClient::build_auth_header(None, device_id);
+206
+207        // Build request manually for custom headers
+208        let request = self
+209            .http_client
+210            .client
+211            .post(&endpoint)
+212            .header("Content-Type", "application/json")
+213            .header("X-Emby-Authorization", auth_header)
+214            .json(&serde_json::json!({
+215                "Username": username,
+216                "Pw": password,
+217            }))
+218            .build()
+219            .map_err(|e| format!("Failed to build request: {}", e))?;
+220
+221        // Use retry logic
+222        let response = self
+223            .http_client
+224            .request_with_retry(request)
+225            .await
+226            .map_err(|e| format!("Login request failed: {}", e))?;
+227
+228        if !response.status().is_success() {
+229            let status = response.status();
+230            let error_text = response
+231                .text()
+232                .await
+233                .unwrap_or_else(|_| "Unknown error".to_string());
+234            return Err(format!("Login failed: HTTP {}: {}", status, error_text));
+235        }
+236
+237        let auth_response: AuthenticateByNameResponse = response
+238            .json()
+239            .await
+240            .map_err(|e| format!("Failed to parse login response: {}", e))?;
+241
+242        log::info!(
+243            "[AuthManager] Login successful for user: {} ({})",
+244            auth_response.user.name,
+245            auth_response.user.id
+246        );
+247
+248        // Mark server as reachable
+249        if let Some(monitor) = &self.connectivity_monitor {
+250            let monitor = monitor.lock().await;
+251            monitor.mark_reachable().await;
+252        }
+253
+254        let user = User {
+255            id: auth_response.user.id,
+256            name: auth_response.user.name,
+257            server_id: auth_response.user.server_id,
+258            primary_image_tag: auth_response.user.primary_image_tag,
+259        };
+260
+261        Ok(AuthResult {
+262            user,
+263            access_token: auth_response.access_token,
+264            server_id: auth_response.server_id,
+265        })
+266    }
+267
+268    /// Verify current session by fetching user info
+269    pub async fn verify_session(
+270        &self,
+271        server_url: &str,
+272        user_id: &str,
+273        access_token: &str,
+274        device_id: &str,
+275    ) -> Result<User, String> {
+276        let url = Self::normalize_url(server_url)?;
+277        let endpoint = format!("{}/Users/{}", url, user_id);
+278
+279        log::info!("[AuthManager] Verifying session for user: {}", user_id);
+280
+281        // Build auth header
+282        let auth_header = HttpClient::build_auth_header(Some(access_token), device_id);
+283
+284        // Build request manually for custom headers
+285        let request = self
+286            .http_client
+287            .client
+288            .get(&endpoint)
+289            .header("X-Emby-Authorization", auth_header)
+290            .build()
+291            .map_err(|e| format!("Failed to build request: {}", e))?;
+292
+293        // Use retry logic
+294        let response = self
+295            .http_client
+296            .request_with_retry(request)
+297            .await
+298            .map_err(|e| {
+299                log::warn!("[AuthManager] Session verification failed: {}", e);
+300                format!("Session verification failed: {}", e)
+301            })?;
+302
+303        if !response.status().is_success() {
+304            let status = response.status();
+305            let error_text = response
+306                .text()
+307                .await
+308                .unwrap_or_else(|_| "Unknown error".to_string());
+309
+310            // Mark server as unreachable for auth errors
+311            if status.as_u16() == 401 || status.as_u16() == 403 {
+312                log::warn!("[AuthManager] Session invalid: HTTP {}", status);
+313                if let Some(monitor) = &self.connectivity_monitor {
+314                    let monitor = monitor.lock().await;
+315                    monitor
+316                        .mark_unreachable(Some(format!("Authentication failed: {}", status)))
+317                        .await;
+318                }
+319            }
+320
+321            return Err(format!("HTTP {}: {}", status, error_text));
+322        }
+323
+324        let user_response: JellyfinUser = response
+325            .json()
+326            .await
+327            .map_err(|e| format!("Failed to parse user response: {}", e))?;
+328
+329        log::info!(
+330            "[AuthManager] Session verified successfully for: {}",
+331            user_response.name
+332        );
+333
+334        // Mark server as reachable
+335        if let Some(monitor) = &self.connectivity_monitor {
+336            let monitor = monitor.lock().await;
+337            monitor.mark_reachable().await;
+338        }
+339
+340        Ok(User {
+341            id: user_response.id,
+342            name: user_response.name,
+343            server_id: user_response.server_id,
+344            primary_image_tag: user_response.primary_image_tag,
+345        })
+346    }
+347
+348    /// Logout (call Jellyfin logout endpoint)
+349    pub async fn logout(
+350        &self,
+351        server_url: &str,
+352        access_token: &str,
+353        device_id: &str,
+354    ) -> Result<(), String> {
+355        let url = Self::normalize_url(server_url)?;
+356        let endpoint = format!("{}/Sessions/Logout", url);
+357
+358        log::info!("[AuthManager] Logging out");
+359
+360        // Build auth header
+361        let auth_header = HttpClient::build_auth_header(Some(access_token), device_id);
+362
+363        // Build request
+364        let request = self
+365            .http_client
+366            .client
+367            .post(&endpoint)
+368            .header("X-Emby-Authorization", auth_header)
+369            .build()
+370            .map_err(|e| format!("Failed to build request: {}", e))?;
+371
+372        // Don't retry logout - if it fails, we'll still clear local state
+373        match self.http_client.client.execute(request).await {
+374            Ok(response) => {
+375                if response.status().is_success() {
+376                    log::info!("[AuthManager] Logout successful");
+377                } else {
+378                    log::warn!("[AuthManager] Logout request failed: {}", response.status());
+379                }
+380            }
+381            Err(e) => {
+382                log::warn!("[AuthManager] Logout request failed: {}", e);
+383            }
+384        }
+385
+386        Ok(())
+387    }
+388
+389    /// Get current session
+390    pub async fn get_session(&self) -> Option<Session> {
+391        self.current_session.read().await.clone()
+392    }
+393
+394    /// Set current session
+395    pub async fn set_session(&self, session: Option<Session>) {
+396        *self.current_session.write().await = session;
+397    }
+398}
+399
+400#[cfg(test)]
+401mod tests {
+402    use super::*;
+403
+404    /// Test URL normalization - adds https:// when missing
+405    #[test]
+406    fn test_normalize_url_adds_https() {
+407        assert_eq!(
+408            AuthManager::normalize_url("jellyfin.example.com").unwrap(),
+409            "https://jellyfin.example.com"
+410        );
+411        assert_eq!(
+412            AuthManager::normalize_url("192.168.1.100:8096").unwrap(),
+413            "https://192.168.1.100:8096"
+414        );
+415    }
+416
+417    /// Test URL normalization - preserves existing https
+418    #[test]
+419    fn test_normalize_url_preserves_https() {
+420        assert_eq!(
+421            AuthManager::normalize_url("https://jellyfin.example.com").unwrap(),
+422            "https://jellyfin.example.com"
+423        );
+424    }
+425
+426    /// Test URL normalization - rejects HTTP
+427    #[test]
+428    fn test_normalize_url_rejects_http() {
+429        assert!(AuthManager::normalize_url("http://localhost:8096").is_err());
+430        assert!(AuthManager::normalize_url("http://jellyfin.example.com").is_err());
+431    }
+432
+433    /// Test URL normalization - removes trailing slash
+434    #[test]
+435    fn test_normalize_url_removes_trailing_slash() {
+436        assert_eq!(
+437            AuthManager::normalize_url("https://jellyfin.example.com/").unwrap(),
+438            "https://jellyfin.example.com"
+439        );
+440        assert_eq!(
+441            AuthManager::normalize_url("jellyfin.example.com/").unwrap(),
+442            "https://jellyfin.example.com"
+443        );
+444    }
+445
+446    /// Test URL normalization - trims whitespace
+447    #[test]
+448    fn test_normalize_url_trims_whitespace() {
+449        assert_eq!(
+450            AuthManager::normalize_url("  jellyfin.example.com  ").unwrap(),
+451            "https://jellyfin.example.com"
+452        );
+453        assert_eq!(
+454            AuthManager::normalize_url("  https://jellyfin.example.com/  ").unwrap(),
+455            "https://jellyfin.example.com"
+456        );
+457    }
+458
+459    /// Usernames must be trimmed before they reach the server: the Android soft
+460    /// keyboard appends a trailing space after autocorrect, and Jellyfin then
+461    /// reports an unknown user — a 401 indistinguishable from a wrong password.
+462    #[test]
+463    fn test_normalize_username_trims_whitespace() {
+464        assert_eq!(AuthManager::normalize_username("duncan "), "duncan");
+465        assert_eq!(AuthManager::normalize_username(" duncan"), "duncan");
+466        assert_eq!(AuthManager::normalize_username("  duncan  "), "duncan");
+467        assert_eq!(AuthManager::normalize_username("duncan\n"), "duncan");
+468    }
+469
+470    /// Interior spaces are legal in Jellyfin usernames and must survive.
+471    #[test]
+472    fn test_normalize_username_preserves_interior_spaces() {
+473        assert_eq!(
+474            AuthManager::normalize_username("  duncan tourolle  "),
+475            "duncan tourolle"
+476        );
+477    }
+478
+479    /// Test URL normalization - real world case
+480    #[test]
+481    fn test_normalize_url_real_world_case() {
+482        let input = "jellyfin.tourolle.paris";
+483        let normalized = AuthManager::normalize_url(input).unwrap();
+484
+485        assert_eq!(normalized, "https://jellyfin.tourolle.paris");
+486        assert!(normalized.starts_with("https://"));
+487        assert!(!normalized.ends_with('/'));
+488    }
+489}
+
\ No newline at end of file diff --git a/api/src/jellytau_lib/auth/session_verifier.rs.html b/api/src/jellytau_lib/auth/session_verifier.rs.html new file mode 100644 index 00000000..bbcff24a --- /dev/null +++ b/api/src/jellytau_lib/auth/session_verifier.rs.html @@ -0,0 +1,171 @@ +session_verifier.rs - sourceSkip to main content

jellytau_lib/auth/
session_verifier.rs

1use serde::Serialize;
+2use std::sync::atomic::{AtomicBool, Ordering};
+3use std::sync::Arc;
+4use std::time::Duration;
+5use tauri::{AppHandle, Emitter};
+6
+7use super::{AuthManager, User};
+8
+9// Verification interval (5 minutes)
+10const VERIFICATION_INTERVAL_MS: u64 = 300000;
+11
+12/// Session verification result event emitted to frontend
+13#[derive(Debug, Clone, Serialize)]
+14#[serde(rename_all = "camelCase", tag = "type")]
+15pub enum SessionVerificationEvent {
+16    Verified { user: User },
+17    NeedsReauth { reason: String },
+18    NetworkError { message: String },
+19}
+20
+21/// Background session verifier
+22pub struct SessionVerifier {
+23    auth_manager: Arc<AuthManager>,
+24    is_running: Arc<AtomicBool>,
+25    device_id: String,
+26    app_handle: Option<AppHandle>,
+27}
+28
+29impl SessionVerifier {
+30    /// Create a new session verifier
+31    pub fn new(auth_manager: Arc<AuthManager>, device_id: String) -> Self {
+32        Self {
+33            auth_manager,
+34            is_running: Arc::new(AtomicBool::new(false)),
+35            device_id,
+36            app_handle: None,
+37        }
+38    }
+39
+40    /// Set the Tauri app handle for event emission
+41    pub fn set_app_handle(&mut self, app_handle: AppHandle) {
+42        self.app_handle = Some(app_handle);
+43    }
+44
+45    /// Start periodic session verification
+46    pub async fn start(&self) {
+47        if self.is_running.swap(true, Ordering::SeqCst) {
+48            log::info!("[SessionVerifier] Already running");
+49            return;
+50        }
+51
+52        log::info!("[SessionVerifier] Starting background verification");
+53
+54        let auth_manager = Arc::clone(&self.auth_manager);
+55        let is_running = Arc::clone(&self.is_running);
+56        let device_id = self.device_id.clone();
+57        let app_handle = self.app_handle.clone();
+58
+59        tokio::spawn(async move {
+60            // Initial verification after short delay
+61            tokio::time::sleep(Duration::from_millis(2000)).await;
+62
+63            while is_running.load(Ordering::SeqCst) {
+64                // Get current session
+65                let session = auth_manager.get_session().await;
+66
+67                if let Some(session) = session {
+68                    log::debug!(
+69                        "[SessionVerifier] Verifying session for: {}",
+70                        session.username
+71                    );
+72
+73                    // Verify the session
+74                    match auth_manager
+75                        .verify_session(
+76                            &session.server_url,
+77                            &session.user_id,
+78                            &session.access_token,
+79                            &device_id,
+80                        )
+81                        .await
+82                    {
+83                        Ok(user) => {
+84                            log::info!("[SessionVerifier] Session verified successfully");
+85
+86                            // Emit success event
+87                            if let Some(app) = &app_handle {
+88                                let event = SessionVerificationEvent::Verified { user };
+89                                if let Err(e) = app.emit("auth:session-verified", event) {
+90                                    log::error!("[SessionVerifier] Failed to emit event: {}", e);
+91                                }
+92                            }
+93
+94                            // Update session as verified
+95                            let mut updated_session = session;
+96                            updated_session.verified = true;
+97                            updated_session.needs_reauth = false;
+98                            auth_manager.set_session(Some(updated_session)).await;
+99                        }
+100                        Err(e) => {
+101                            log::warn!("[SessionVerifier] Verification failed: {}", e);
+102
+103                            // Classify error
+104                            let is_auth_error = e.contains("401") || e.contains("403");
+105                            let is_network_error = e.contains("network")
+106                                || e.contains("timeout")
+107                                || e.contains("connection")
+108                                || e.contains("DNS");
+109
+110                            if is_auth_error {
+111                                // Token is invalid - need re-authentication
+112                                log::warn!("[SessionVerifier] Session requires re-authentication");
+113
+114                                if let Some(app) = &app_handle {
+115                                    let event = SessionVerificationEvent::NeedsReauth {
+116                                        reason: "Session expired".to_string(),
+117                                    };
+118                                    if let Err(e) = app.emit("auth:needs-reauth", event) {
+119                                        log::error!(
+120                                            "[SessionVerifier] Failed to emit event: {}",
+121                                            e
+122                                        );
+123                                    }
+124                                }
+125
+126                                // Update session
+127                                let mut updated_session = session;
+128                                updated_session.verified = false;
+129                                updated_session.needs_reauth = true;
+130                                auth_manager.set_session(Some(updated_session)).await;
+131                            } else if is_network_error {
+132                                // Network error - keep using cached session
+133                                log::info!("[SessionVerifier] Network error during verification, keeping cached session");
+134
+135                                if let Some(app) = &app_handle {
+136                                    let event = SessionVerificationEvent::NetworkError {
+137                                        message: e.clone(),
+138                                    };
+139                                    if let Err(e) = app.emit("auth:network-error", event) {
+140                                        log::error!(
+141                                            "[SessionVerifier] Failed to emit event: {}",
+142                                            e
+143                                        );
+144                                    }
+145                                }
+146                            } else {
+147                                // Unknown error - log but don't invalidate
+148                                log::error!(
+149                                    "[SessionVerifier] Unknown error during verification: {}",
+150                                    e
+151                                );
+152                            }
+153                        }
+154                    }
+155                }
+156
+157                // Wait for next verification
+158                tokio::time::sleep(Duration::from_millis(VERIFICATION_INTERVAL_MS)).await;
+159            }
+160
+161            log::info!("[SessionVerifier] Stopped");
+162        });
+163    }
+164
+165    /// Stop periodic verification
+166    pub fn stop(&self) {
+167        log::info!("[SessionVerifier] Stopping background verification");
+168        self.is_running.store(false, Ordering::SeqCst);
+169    }
+170}
+
\ No newline at end of file diff --git a/api/src/jellytau_lib/commands/auth.rs.html b/api/src/jellytau_lib/commands/auth.rs.html new file mode 100644 index 00000000..47ac835b --- /dev/null +++ b/api/src/jellytau_lib/commands/auth.rs.html @@ -0,0 +1,477 @@ +auth.rs - sourceSkip to main content

jellytau_lib/commands/
auth.rs

1//! Authentication and session-lifecycle commands.
+2//!
+3//! TRACES: UR-042 | IR-009, IR-014, JA-002 | DR-054
+4
+5use std::sync::Arc;
+6use tauri::State;
+7
+8use crate::auth::{AuthManager, AuthResult, ServerInfo, Session, SessionVerifier};
+9
+10/// Wrapper for AuthManager to manage in Tauri state
+11pub struct AuthManagerWrapper(pub Arc<AuthManager>);
+12
+13/// Wrapper for SessionVerifier to manage in Tauri state
+14pub struct SessionVerifierWrapper(pub Arc<tokio::sync::Mutex<Option<SessionVerifier>>>);
+15
+16/// Initialize the auth manager (call on app startup)
+17/// Restores session from storage if available
+18#[tauri::command]
+19#[specta::specta]
+20pub async fn auth_initialize(
+21    auth_manager: State<'_, AuthManagerWrapper>,
+22    database: State<'_, crate::commands::DatabaseWrapper>,
+23    credentials: State<'_, crate::commands::CredentialStoreWrapper>,
+24) -> Result<Option<Session>, String> {
+25    // First check if we already have a session in memory
+26    if let Some(session) = auth_manager.0.get_session().await {
+27        return Ok(Some(session));
+28    }
+29
+30    // Try to restore session from storage
+31    log::info!("[AuthManager] Restoring session from storage...");
+32
+33    // Use the existing storage_get_active_session function
+34    let active_session =
+35        match crate::commands::storage::storage_get_active_session(database, credentials).await {
+36            Ok(Some(session)) => session,
+37            Ok(None) => {
+38                log::info!("[AuthManager] No active session in storage");
+39                return Ok(None);
+40            }
+41            Err(e) => {
+42                log::error!("[AuthManager] Failed to get active session: {}", e);
+43                return Err(e);
+44            }
+45        };
+46
+47    // Create session object from active session with normalized URL
+48    let normalized_url = crate::auth::AuthManager::normalize_url(&active_session.server_url)?;
+49
+50    let session = Session {
+51        user_id: active_session.user_id,
+52        username: active_session.username,
+53        server_id: active_session.server_id,
+54        server_url: normalized_url,
+55        server_name: active_session.server_name,
+56        access_token: active_session.access_token,
+57        verified: false, // Will be verified in background
+58        needs_reauth: false,
+59    };
+60
+61    // Store in AuthManager
+62    auth_manager.0.set_session(Some(session.clone())).await;
+63
+64    log::info!(
+65        "[AuthManager] Session restored for user: {} with normalized URL: {}",
+66        session.username,
+67        session.server_url
+68    );
+69    Ok(Some(session))
+70}
+71
+72/// Connect to a Jellyfin server and get server info
+73#[tauri::command]
+74#[specta::specta]
+75pub async fn auth_connect_to_server(
+76    server_url: String,
+77    auth_manager: State<'_, AuthManagerWrapper>,
+78) -> Result<ServerInfo, String> {
+79    auth_manager.0.connect_to_server(&server_url).await
+80}
+81
+82/// Login with username and password
+83#[tauri::command]
+84#[specta::specta]
+85pub async fn auth_login(
+86    server_url: String,
+87    username: String,
+88    password: String,
+89    device_id: String,
+90    auth_manager: State<'_, AuthManagerWrapper>,
+91) -> Result<AuthResult, String> {
+92    let result = auth_manager
+93        .0
+94        .login(&server_url, &username, &password, &device_id)
+95        .await?;
+96
+97    // Create session from auth result with normalized URL
+98    let normalized_url = crate::auth::AuthManager::normalize_url(&server_url)?;
+99
+100    let session = Session {
+101        user_id: result.user.id.clone(),
+102        username: result.user.name.clone(),
+103        server_id: result.server_id.clone(),
+104        server_url: normalized_url,
+105        server_name: String::new(), // Will be set by frontend
+106        access_token: result.access_token.clone(),
+107        verified: true,
+108        needs_reauth: false,
+109    };
+110
+111    auth_manager.0.set_session(Some(session)).await;
+112
+113    Ok(result)
+114}
+115
+116/// Verify current session
+117#[tauri::command]
+118#[specta::specta]
+119pub async fn auth_verify_session(
+120    server_url: String,
+121    user_id: String,
+122    access_token: String,
+123    device_id: String,
+124    auth_manager: State<'_, AuthManagerWrapper>,
+125) -> Result<bool, String> {
+126    match auth_manager
+127        .0
+128        .verify_session(&server_url, &user_id, &access_token, &device_id)
+129        .await
+130    {
+131        Ok(_) => Ok(true),
+132        Err(e) => {
+133            log::warn!("[AuthCommands] Session verification failed: {}", e);
+134            Ok(false)
+135        }
+136    }
+137}
+138
+139/// Logout (clear session and call Jellyfin logout endpoint)
+140#[tauri::command]
+141#[specta::specta]
+142pub async fn auth_logout(
+143    server_url: String,
+144    access_token: String,
+145    device_id: String,
+146    auth_manager: State<'_, AuthManagerWrapper>,
+147    session_verifier: State<'_, SessionVerifierWrapper>,
+148) -> Result<(), String> {
+149    // Stop session verification
+150    let mut verifier_guard = session_verifier.0.lock().await;
+151    if let Some(verifier) = verifier_guard.take() {
+152        verifier.stop();
+153    }
+154    drop(verifier_guard);
+155
+156    // Call Jellyfin logout endpoint
+157    auth_manager
+158        .0
+159        .logout(&server_url, &access_token, &device_id)
+160        .await?;
+161
+162    // Clear session
+163    auth_manager.0.set_session(None).await;
+164
+165    Ok(())
+166}
+167
+168/// Get current session
+169#[tauri::command]
+170#[specta::specta]
+171pub async fn auth_get_session(
+172    auth_manager: State<'_, AuthManagerWrapper>,
+173) -> Result<Option<Session>, String> {
+174    Ok(auth_manager.0.get_session().await)
+175}
+176
+177/// Set current session (for restoration from storage)
+178#[tauri::command]
+179#[specta::specta]
+180pub async fn auth_set_session(
+181    session: Option<Session>,
+182    auth_manager: State<'_, AuthManagerWrapper>,
+183) -> Result<(), String> {
+184    // Normalize the server URL if session is provided
+185    let normalized_session = match session {
+186        Some(mut s) => {
+187            s.server_url = crate::auth::AuthManager::normalize_url(&s.server_url)?;
+188            Some(s)
+189        }
+190        None => None,
+191    };
+192
+193    auth_manager.0.set_session(normalized_session).await;
+194    Ok(())
+195}
+196
+197/// Start background session verification
+198#[tauri::command]
+199#[specta::specta]
+200pub async fn auth_start_verification(
+201    device_id: String,
+202    app_handle: tauri::AppHandle,
+203    auth_manager: State<'_, AuthManagerWrapper>,
+204    session_verifier: State<'_, SessionVerifierWrapper>,
+205) -> Result<(), String> {
+206    let mut verifier_guard = session_verifier.0.lock().await;
+207
+208    // Stop existing verifier if any
+209    if let Some(verifier) = verifier_guard.take() {
+210        verifier.stop();
+211    }
+212
+213    // Get AuthManager Arc
+214    let manager = auth_manager.0.clone();
+215
+216    // Create new verifier
+217    let mut verifier = SessionVerifier::new(manager, device_id);
+218    verifier.set_app_handle(app_handle);
+219    verifier.start().await;
+220
+221    *verifier_guard = Some(verifier);
+222
+223    Ok(())
+224}
+225
+226/// Stop background session verification
+227#[tauri::command]
+228#[specta::specta]
+229pub async fn auth_stop_verification(
+230    session_verifier: State<'_, SessionVerifierWrapper>,
+231) -> Result<(), String> {
+232    let mut verifier_guard = session_verifier.0.lock().await;
+233
+234    if let Some(verifier) = verifier_guard.take() {
+235        verifier.stop();
+236    }
+237
+238    Ok(())
+239}
+240
+241/// Re-authenticate with password (when session expired)
+242#[tauri::command]
+243#[specta::specta]
+244pub async fn auth_reauthenticate(
+245    password: String,
+246    device_id: String,
+247    auth_manager: State<'_, AuthManagerWrapper>,
+248) -> Result<AuthResult, String> {
+249    // Get current session to extract server_url and username
+250    let session = auth_manager
+251        .0
+252        .get_session()
+253        .await
+254        .ok_or_else(|| "No active session to re-authenticate".to_string())?;
+255
+256    // Re-login with stored credentials
+257    let result = auth_manager
+258        .0
+259        .login(
+260            &session.server_url,
+261            &session.username,
+262            &password,
+263            &device_id,
+264        )
+265        .await?;
+266
+267    // Update session with new token
+268    let updated_session = Session {
+269        user_id: result.user.id.clone(),
+270        username: result.user.name.clone(),
+271        server_id: result.server_id.clone(),
+272        server_url: session.server_url,
+273        server_name: session.server_name,
+274        access_token: result.access_token.clone(),
+275        verified: true,
+276        needs_reauth: false,
+277    };
+278
+279    auth_manager.0.set_session(Some(updated_session)).await;
+280
+281    Ok(result)
+282}
+283
+284#[cfg(test)]
+285mod tests {
+286    use super::*;
+287
+288    #[test]
+289    fn test_session_serialization() {
+290        let session = Session {
+291            user_id: "user-123".to_string(),
+292            username: "john_doe".to_string(),
+293            server_id: "server-456".to_string(),
+294            server_url: "https://jellyfin.example.com".to_string(),
+295            server_name: "My Jellyfin".to_string(),
+296            access_token: "token-789-xyz".to_string(),
+297            verified: true,
+298            needs_reauth: false,
+299        };
+300
+301        // Should serialize successfully
+302        let json = serde_json::to_string(&session);
+303        assert!(json.is_ok());
+304        let serialized = json.unwrap();
+305        assert!(serialized.contains("user-123"));
+306        assert!(serialized.contains("john_doe"));
+307        assert!(serialized.contains("server-456"));
+308    }
+309
+310    #[test]
+311    fn test_session_deserialization() {
+312        let json = r#"{
+313            "userId": "user-123",
+314            "username": "john_doe",
+315            "serverId": "server-456",
+316            "serverUrl": "https://jellyfin.example.com",
+317            "serverName": "My Jellyfin",
+318            "accessToken": "token-789",
+319            "verified": true,
+320            "needsReauth": false
+321        }"#;
+322
+323        let result: Result<Session, _> = serde_json::from_str(json);
+324        assert!(result.is_ok());
+325
+326        let session = result.unwrap();
+327        assert_eq!(session.user_id, "user-123");
+328        assert_eq!(session.username, "john_doe");
+329        assert_eq!(session.server_id, "server-456");
+330        assert!(session.verified);
+331        assert!(!session.needs_reauth);
+332    }
+333
+334    #[test]
+335    fn test_session_roundtrip() {
+336        let original = Session {
+337            user_id: "user-999".to_string(),
+338            username: "alice".to_string(),
+339            server_id: "server-111".to_string(),
+340            server_url: "https://server.local".to_string(),
+341            server_name: "Home Server".to_string(),
+342            access_token: "very-long-token-string".to_string(),
+343            verified: true,
+344            needs_reauth: false,
+345        };
+346
+347        let json = serde_json::to_string(&original).unwrap();
+348        let deserialized: Session = serde_json::from_str(&json).unwrap();
+349
+350        assert_eq!(original.user_id, deserialized.user_id);
+351        assert_eq!(original.username, deserialized.username);
+352        assert_eq!(original.server_id, deserialized.server_id);
+353        assert_eq!(original.server_url, deserialized.server_url);
+354        assert_eq!(original.access_token, deserialized.access_token);
+355        assert_eq!(original.verified, deserialized.verified);
+356    }
+357
+358    #[test]
+359    fn test_session_clone() {
+360        let session = Session {
+361            user_id: "user-clone".to_string(),
+362            username: "test_user".to_string(),
+363            server_id: "server-clone".to_string(),
+364            server_url: "https://clone.example.com".to_string(),
+365            server_name: "Clone Server".to_string(),
+366            access_token: "clone-token".to_string(),
+367            verified: false,
+368            needs_reauth: true,
+369        };
+370
+371        let cloned = session.clone();
+372        assert_eq!(session.user_id, cloned.user_id);
+373        assert_eq!(session.username, cloned.username);
+374        assert_eq!(session.verified, cloned.verified);
+375        assert_eq!(session.needs_reauth, cloned.needs_reauth);
+376    }
+377
+378    #[test]
+379    fn test_session_unverified() {
+380        let session = Session {
+381            user_id: "user-unverified".to_string(),
+382            username: "newuser".to_string(),
+383            server_id: "server-new".to_string(),
+384            server_url: "https://new.example.com".to_string(),
+385            server_name: "New Server".to_string(),
+386            access_token: "new-token".to_string(),
+387            verified: false,
+388            needs_reauth: true,
+389        };
+390
+391        let json = serde_json::to_string(&session).unwrap();
+392        assert!(json.contains("false")); // verified: false
+393        assert!(json.contains("true")); // needs_reauth: true
+394
+395        let deserialized: Session = serde_json::from_str(&json).unwrap();
+396        assert!(!deserialized.verified);
+397        assert!(deserialized.needs_reauth);
+398    }
+399
+400    #[test]
+401    fn test_session_debug() {
+402        let session = Session {
+403            user_id: "user-debug".to_string(),
+404            username: "debug_user".to_string(),
+405            server_id: "server-debug".to_string(),
+406            server_url: "https://debug.example.com".to_string(),
+407            server_name: "Debug Server".to_string(),
+408            access_token: "debug-token".to_string(),
+409            verified: true,
+410            needs_reauth: false,
+411        };
+412
+413        let debug_str = format!("{:?}", session);
+414        assert!(debug_str.contains("user-debug"));
+415        assert!(debug_str.contains("Session"));
+416    }
+417
+418    #[test]
+419    fn test_auth_manager_wrapper_structure() {
+420        // Verify wrapper type exists and has correct structure
+421        assert!(std::mem::size_of::<AuthManagerWrapper>() > 0);
+422    }
+423
+424    #[test]
+425    fn test_session_verifier_wrapper_structure() {
+426        // Verify wrapper type exists and has correct structure
+427        assert!(std::mem::size_of::<SessionVerifierWrapper>() > 0);
+428    }
+429
+430    #[test]
+431    fn test_session_with_special_characters() {
+432        let session = Session {
+433            user_id: "user-special-éñ".to_string(),
+434            username: "user@example.com".to_string(),
+435            server_id: "server/123".to_string(),
+436            server_url: "https://jellyfin.example.com:8096".to_string(),
+437            server_name: "My Jellyfin (v10.8.0)".to_string(),
+438            access_token: "token+with/special=chars".to_string(),
+439            verified: true,
+440            needs_reauth: false,
+441        };
+442
+443        let json = serde_json::to_string(&session).unwrap();
+444        let deserialized: Session = serde_json::from_str(&json).unwrap();
+445
+446        assert_eq!(session.username, deserialized.username);
+447        assert_eq!(session.server_name, deserialized.server_name);
+448        assert_eq!(session.access_token, deserialized.access_token);
+449    }
+450
+451    #[test]
+452    fn test_session_field_presence() {
+453        let session = Session {
+454            user_id: "u1".to_string(),
+455            username: "user1".to_string(),
+456            server_id: "s1".to_string(),
+457            server_url: "url1".to_string(),
+458            server_name: "name1".to_string(),
+459            access_token: "token1".to_string(),
+460            verified: true,
+461            needs_reauth: false,
+462        };
+463
+464        let json = serde_json::to_string(&session).unwrap();
+465
+466        // Verify camelCase serialization (serde rename_all = "camelCase")
+467        assert!(json.contains("userId"));
+468        assert!(json.contains("username"));
+469        assert!(json.contains("serverId"));
+470        assert!(json.contains("serverUrl"));
+471        assert!(json.contains("serverName"));
+472        assert!(json.contains("accessToken"));
+473        assert!(json.contains("verified"));
+474        assert!(json.contains("needsReauth"));
+475    }
+476}
+
\ No newline at end of file diff --git a/api/src/jellytau_lib/commands/catalog.rs.html b/api/src/jellytau_lib/commands/catalog.rs.html new file mode 100644 index 00000000..d4df98a4 --- /dev/null +++ b/api/src/jellytau_lib/commands/catalog.rs.html @@ -0,0 +1,1148 @@ +catalog.rs - sourceSkip to main content

jellytau_lib/commands/
catalog.rs

1//! Tauri commands for the offline "browse & queue" feature.
+2//!
+3//! TRACES: UR-002, UR-007, UR-024 | JA-004, JA-016 | DR-012, DR-027
+4//!
+5//! Two backend pieces support browsing the full server catalog while offline
+6//! and queueing downloads that fire on reconnect:
+7//!
+8//! - [`sync_full_catalog`] walks every library while online and persists all
+9//!   items to the offline cache so the whole catalog is browsable (greyed out)
+10//!   offline. It reuses [`HybridRepository::cache_items_from_server`], which in
+11//!   turn reuses `OfflineRepository::save_to_cache` (sets `synced_at`, which is
+12//!   what `get_items` branch 3 serves offline).
+13//! - [`resume_queued_downloads`] resolves and pumps the `pending` download rows
+14//!   that were queued offline (they have `stream_url IS NULL`), mirroring the
+15//!   heal-and-pump pattern in `player_preload_upcoming`.
+16
+17use std::sync::atomic::{AtomicBool, Ordering};
+18use std::sync::Arc;
+19use std::time::Duration;
+20
+21use log::{info, warn};
+22use tauri::{Emitter, Manager, State};
+23
+24use crate::commands::download::{pump_download_queue, DownloadManagerWrapper};
+25use crate::commands::repository::RepositoryManagerWrapper;
+26use crate::commands::storage::DatabaseWrapper;
+27use crate::repository::types::GetItemsOptions;
+28use crate::storage::db_service::{DatabaseService, Query, QueryParam};
+29
+30/// app_settings key holding the RFC-3339 timestamp of the last successful
+31/// full-catalog sync.
+32const LAST_CATALOG_SYNC_KEY: &str = "last_catalog_sync";
+33
+34/// How long an index stays fresh before a re-index is due.
+35///
+36/// This lives in Rust rather than being a frontend constant because it decides
+37/// *whether the local cache is authoritative* — the same class of decision as
+38/// `include_catalog_browse`, and squarely the "sync policy" the spec review
+39/// checklist keeps out of the presentation layer. If it later becomes
+40/// user-configurable it stays a Rust-owned setting edited through a command.
+41const CATALOG_INDEX_TTL: Duration = Duration::from_secs(6 * 60 * 60);
+42
+43/// How often the scheduler wakes to *check* staleness. Far shorter than the TTL
+44/// because a tick is nearly free — one indexed `app_settings` lookup — and it is
+45/// what makes the indexer responsive to events it cannot subscribe to: signing
+46/// in, and coming back online. The TTL, not the tick, decides whether a crawl
+47/// actually happens.
+48const CATALOG_INDEX_TICK: Duration = Duration::from_secs(5 * 60);
+49
+50/// Delay before the first staleness check, to let sign-in complete and the
+51/// repository be registered. Without it the first check runs against an empty
+52/// repository manager and a fresh install would sit unindexed until the next
+53/// tick.
+54const CATALOG_INDEX_FIRST_CHECK: Duration = Duration::from_secs(15);
+55
+56/// Kebab-case, per the project's event convention.
+57pub const CATALOG_INDEX_EVENT: &str = "catalog-index-event";
+58
+59/// Guards against two passes running at once. Replaces the frontend's
+60/// `syncInProgress` boolean in `offlineCatalog.ts`, which could not see a pass
+61/// started by the scheduler.
+62static INDEX_IN_PROGRESS: AtomicBool = AtomicBool::new(false);
+63
+64/// Clears [`INDEX_IN_PROGRESS`] however the pass leaves — including on the `?`
+65/// early return when `get_libraries` fails, which a plain store at the end of
+66/// the function would leak.
+67struct IndexPassGuard;
+68
+69impl Drop for IndexPassGuard {
+70    fn drop(&mut self) {
+71        INDEX_IN_PROGRESS.store(false, Ordering::SeqCst);
+72    }
+73}
+74
+75/// Progress of a background index pass, for the staleness hint in the UI.
+76#[derive(specta::Type, Debug, Clone, serde::Serialize, serde::Deserialize)]
+77#[serde(rename_all = "camelCase")]
+78pub struct CatalogIndexEvent {
+79    /// `started` | `finished` | `failed`
+80    pub state: String,
+81    pub items_cached: usize,
+82    pub items_pruned: usize,
+83    pub libraries_failed: usize,
+84    /// Present on `failed`.
+85    pub error: Option<String>,
+86}
+87
+88/// Item types worth caching for offline browsing: containers the library
+89/// landing pages render plus the playable leaves users queue for download.
+90/// `MusicArtist` and `Playlist` are here because search groups results by them
+91/// (UR-060's Artists group). Without them in the crawl, the local index can
+92/// never answer an artist query and those groups can only ever be filled by the
+93/// server leg. Keep this in step with what `prune_stale_catalog` is allowed to
+94/// sweep — the crawl is only authoritative for the types it asks for.
+95///
+96/// TRACES: UR-065, UR-060 | DR-111
+97const CATALOG_ITEM_TYPES: &[&str] = &[
+98    "MusicAlbum",
+99    "MusicArtist",
+100    "Movie",
+101    "Series",
+102    "Season",
+103    "Episode",
+104    "Audio",
+105    "BoxSet",
+106    "Playlist",
+107];
+108
+109/// Jellyfin item types whose download is a *video* stream rather than an audio
+110/// one. The download queue stores an opaque `media_type` ('audio'/'video'); this
+111/// is where the taxonomy that produces it lives, so the frontend never has to
+112/// know which item types are video.
+113///
+114/// TRACES: UR-071 | DR-135
+115const VIDEO_ITEM_TYPES: &[&str] = &["Movie", "Episode", "Video", "MusicVideo"];
+116
+117#[derive(specta::Type, Debug, Clone, serde::Serialize, serde::Deserialize)]
+118#[serde(rename_all = "camelCase")]
+119pub struct CatalogSyncResult {
+120    /// Total items persisted to the offline cache across all libraries.
+121    pub items_cached: usize,
+122    /// Libraries that failed to sync (e.g. server hiccup); best-effort.
+123    pub libraries_failed: usize,
+124    /// Entries removed because the server no longer has them. Always 0 when any
+125    /// library failed, since a partial crawl cannot prove an item is gone.
+126    pub items_pruned: usize,
+127}
+128
+129#[derive(specta::Type, Debug, Clone, serde::Serialize, serde::Deserialize)]
+130#[serde(rename_all = "camelCase")]
+131pub struct CatalogSyncStatus {
+132    /// RFC-3339 timestamp of the last successful sync, if any.
+133    pub last_synced_at: Option<String>,
+134}
+135
+136/// Walk every library on the server and persist all items to the offline cache
+137/// so the full catalog is browsable offline (greyed out when not downloaded).
+138///
+139/// Best-effort: a library that fails to fetch is counted and skipped rather than
+140/// aborting the whole sync. Runs libraries sequentially to avoid hammering the
+141/// server. Uses `Recursive=true` so a single request per library returns the
+142/// containers and their playable children.
+143#[tauri::command]
+144#[specta::specta]
+145pub async fn sync_full_catalog(
+146    repository: State<'_, RepositoryManagerWrapper>,
+147    db: State<'_, DatabaseWrapper>,
+148    handle: String,
+149) -> Result<CatalogSyncResult, String> {
+150    let repo = repository.0.get(&handle).ok_or("Repository not found")?;
+151
+152    let db_service = {
+153        let database = db.0.lock().map_err(|e| e.to_string())?;
+154        Arc::new(database.service())
+155    };
+156
+157    run_index_pass(repo, db_service).await
+158}
+159
+160/// One full-catalog indexing pass, shared by the [`sync_full_catalog`] command
+161/// and the background scheduler (DR-109) so there is exactly one implementation
+162/// and one concurrency guard.
+163///
+164/// TRACES: UR-065 | DR-109, DR-110
+165pub(crate) async fn run_index_pass(
+166    repo: Arc<crate::repository::HybridRepository>,
+167    db_service: Arc<crate::storage::db_service::RusqliteService>,
+168) -> Result<CatalogSyncResult, String> {
+169    use crate::repository::MediaRepository;
+170
+171    // One pass at a time. The command and the scheduler can both land here, and
+172    // two concurrent crawls would double the server load and race on the sweep.
+173    if INDEX_IN_PROGRESS.swap(true, Ordering::SeqCst) {
+174        return Err("A catalog index pass is already running".to_string());
+175    }
+176    let _guard = IndexPassGuard;
+177
+178    let libraries = repo.get_libraries().await.map_err(|e| e.to_string())?;
+179    info!(
+180        "[Catalog] Full sync starting across {} libraries",
+181        libraries.len()
+182    );
+183
+184    let include_types: Vec<String> = CATALOG_ITEM_TYPES.iter().map(|s| s.to_string()).collect();
+185
+186    // Taken before the crawl: every row the crawl writes gets a `synced_at`
+187    // newer than this, so anything still older afterwards is gone server-side.
+188    let pass_started_at = chrono::Utc::now().to_rfc3339();
+189
+190    let mut items_cached = 0usize;
+191    let mut libraries_failed = 0usize;
+192
+193    for library in &libraries {
+194        let opts = GetItemsOptions {
+195            recursive: Some(true),
+196            include_item_types: Some(include_types.clone()),
+197            limit: Some(100_000),
+198            ..Default::default()
+199        };
+200
+201        match repo.cache_items_from_server(&library.id, Some(opts)).await {
+202            Ok(items) => {
+203                info!(
+204                    "[Catalog] Cached {} items from library '{}'",
+205                    items.len(),
+206                    library.name
+207                );
+208                items_cached += items.len();
+209            }
+210            Err(e) => {
+211                warn!(
+212                    "[Catalog] Failed to sync library '{}': {:?}",
+213                    library.name, e
+214                );
+215                libraries_failed += 1;
+216            }
+217        }
+218    }
+219
+220    // Propagate server-side deletions — but only after a *complete* crawl.
+221    // `sync_full_catalog` is best-effort per library, and `items.parent_id` is
+222    // ON DELETE CASCADE, so sweeping when a library failed to fetch could
+223    // cascade a whole series away because one request timed out.
+224    let mut items_pruned = 0usize;
+225    if libraries_failed == 0 && !libraries.is_empty() {
+226        match repo
+227            .prune_stale_catalog(&pass_started_at, &include_types)
+228            .await
+229        {
+230            Ok(removed) => {
+231                items_pruned = removed;
+232                if removed > 0 {
+233                    info!(
+234                        "[Catalog] Pruned {} entries no longer on the server",
+235                        removed
+236                    );
+237                }
+238            }
+239            Err(e) => warn!("[Catalog] Prune of stale catalog entries failed: {:?}", e),
+240        }
+241    } else if libraries_failed > 0 {
+242        info!(
+243            "[Catalog] Skipping stale-entry prune: {} librar{} failed to sync, so the crawl is not authoritative",
+244            libraries_failed,
+245            if libraries_failed == 1 { "y" } else { "ies" }
+246        );
+247    }
+248
+249    // Record the sync time so callers can skip re-syncing too eagerly.
+250    let now = chrono::Utc::now().to_rfc3339();
+251    let upsert = Query::with_params(
+252        "INSERT INTO app_settings (key, value, updated_at) VALUES (?, ?, CURRENT_TIMESTAMP)
+253         ON CONFLICT(key) DO UPDATE SET value = excluded.value, updated_at = CURRENT_TIMESTAMP",
+254        vec![
+255            QueryParam::String(LAST_CATALOG_SYNC_KEY.to_string()),
+256            QueryParam::String(now),
+257        ],
+258    );
+259    if let Err(e) = db_service.execute(upsert).await {
+260        warn!("[Catalog] Failed to persist last-sync timestamp: {}", e);
+261    }
+262
+263    info!(
+264        "[Catalog] Full sync complete: {} items cached, {} pruned, {} libraries failed",
+265        items_cached, items_pruned, libraries_failed
+266    );
+267
+268    Ok(CatalogSyncResult {
+269        items_cached,
+270        libraries_failed,
+271        items_pruned,
+272    })
+273}
+274
+275/// Whether an index pass is due, given when one last completed.
+276///
+277/// Pure so the policy is unit-testable without a clock, a server, or a database.
+278/// `None` (never indexed) and an unparseable stored value both mean "due" — a
+279/// corrupt timestamp should trigger a re-index, not silently freeze the catalog.
+280///
+281/// TRACES: UR-065 | DR-109 | UT-115
+282pub(crate) fn index_is_due(
+283    last_synced_at: Option<&str>,
+284    now: chrono::DateTime<chrono::Utc>,
+285    ttl: Duration,
+286) -> bool {
+287    let Some(raw) = last_synced_at else {
+288        return true;
+289    };
+290    let Ok(last) = chrono::DateTime::parse_from_rfc3339(raw) else {
+291        return true;
+292    };
+293    now.signed_duration_since(last.with_timezone(&chrono::Utc))
+294        .to_std()
+295        .map(|elapsed| elapsed >= ttl)
+296        // Negative elapsed => the stored stamp is in the future (clock skew).
+297        // Not due; a future stamp will age into due-ness on its own.
+298        .unwrap_or(false)
+299}
+300
+301/// Read the last-sync timestamp straight from `app_settings`.
+302async fn read_last_sync(
+303    db_service: &Arc<crate::storage::db_service::RusqliteService>,
+304) -> Option<String> {
+305    db_service
+306        .query_optional(
+307            Query::with_params(
+308                "SELECT value FROM app_settings WHERE key = ?",
+309                vec![QueryParam::String(LAST_CATALOG_SYNC_KEY.to_string())],
+310            ),
+311            |row| row.get(0),
+312        )
+313        .await
+314        .ok()
+315        .flatten()
+316}
+317
+318/// Start the background catalog indexer.
+319///
+320/// Replaces the frontend's startup-only `syncCatalog()` call: index freshness is
+321/// sync policy and belongs in Rust (see the layer assignment in
+322/// docs/architecture/03-data-flow.md, "Search Flow"). Ticks every
+323/// [`CATALOG_INDEX_TICK`] and
+324/// runs a pass when a repository exists, the server is reachable, and the index
+325/// is older than [`CATALOG_INDEX_TTL`].
+326///
+327/// TRACES: UR-065 | DR-109, IR-030
+328pub fn spawn_catalog_indexer(app: tauri::AppHandle) {
+329    tauri::async_runtime::spawn(async move {
+330        // Check shortly after launch, then on every tick — not tick-then-check,
+331        // which would leave a fresh install unindexed for a full tick.
+332        tokio::time::sleep(CATALOG_INDEX_FIRST_CHECK).await;
+333
+334        loop {
+335            if let Err(e) = maybe_run_scheduled_pass(&app).await {
+336                // Never fatal — a failed pass leaves the existing index in place
+337                // and we retry on the next tick.
+338                warn!("[Catalog] Scheduled index pass skipped: {}", e);
+339            }
+340
+341            tokio::time::sleep(CATALOG_INDEX_TICK).await;
+342        }
+343    });
+344}
+345
+346/// One scheduler tick: check the preconditions, then index if due.
+347async fn maybe_run_scheduled_pass(app: &tauri::AppHandle) -> Result<(), String> {
+348    if INDEX_IN_PROGRESS.load(Ordering::SeqCst) {
+349        return Ok(());
+350    }
+351
+352    let db_service = {
+353        let db = app.state::<DatabaseWrapper>();
+354        let database = db.0.lock().map_err(|e| e.to_string())?;
+355        Arc::new(database.service())
+356    };
+357
+358    if !index_is_due(
+359        read_last_sync(&db_service).await.as_deref(),
+360        chrono::Utc::now(),
+361        CATALOG_INDEX_TTL,
+362    ) {
+363        return Ok(());
+364    }
+365
+366    // Offline: leave the index alone. The crawl would fail every library and,
+367    // more importantly, a partial crawl must never reach the deletion sweep.
+368    {
+369        let monitor = app.state::<crate::commands::connectivity::ConnectivityMonitorWrapper>();
+370        let monitor = monitor.0.lock().await;
+371        if !monitor.get_status().await.is_server_reachable {
+372            return Ok(());
+373        }
+374    }
+375
+376    let repo = {
+377        let manager = app.state::<RepositoryManagerWrapper>();
+378        let handles = manager.0.handles();
+379        let Some(handle) = handles.first() else {
+380            // Not signed in yet.
+381            return Ok(());
+382        };
+383        manager.0.get(handle).ok_or("Repository not found")?
+384    };
+385
+386    info!("[Catalog] Index is stale; starting a scheduled pass");
+387    let _ = app.emit(
+388        CATALOG_INDEX_EVENT,
+389        CatalogIndexEvent {
+390            state: "started".to_string(),
+391            items_cached: 0,
+392            items_pruned: 0,
+393            libraries_failed: 0,
+394            error: None,
+395        },
+396    );
+397
+398    match run_index_pass(repo, db_service).await {
+399        Ok(result) => {
+400            let _ = app.emit(
+401                CATALOG_INDEX_EVENT,
+402                CatalogIndexEvent {
+403                    state: "finished".to_string(),
+404                    items_cached: result.items_cached,
+405                    items_pruned: result.items_pruned,
+406                    libraries_failed: result.libraries_failed,
+407                    error: None,
+408                },
+409            );
+410            Ok(())
+411        }
+412        Err(e) => {
+413            let _ = app.emit(
+414                CATALOG_INDEX_EVENT,
+415                CatalogIndexEvent {
+416                    state: "failed".to_string(),
+417                    items_cached: 0,
+418                    items_pruned: 0,
+419                    libraries_failed: 0,
+420                    error: Some(e.clone()),
+421                },
+422            );
+423            Err(e)
+424        }
+425    }
+426}
+427
+428/// Report the last-synced timestamp so the UI can show a hint / decide whether
+429/// to trigger a fresh sync.
+430#[tauri::command]
+431#[specta::specta]
+432pub async fn catalog_sync_status(
+433    db: State<'_, DatabaseWrapper>,
+434) -> Result<CatalogSyncStatus, String> {
+435    let db_service = {
+436        let database = db.0.lock().map_err(|e| e.to_string())?;
+437        Arc::new(database.service())
+438    };
+439
+440    let query = Query::with_params(
+441        "SELECT value FROM app_settings WHERE key = ?",
+442        vec![QueryParam::String(LAST_CATALOG_SYNC_KEY.to_string())],
+443    );
+444    let last_synced_at: Option<String> = db_service
+445        .query_optional(query, |row| row.get(0))
+446        .await
+447        .map_err(|e| e.to_string())?;
+448
+449    Ok(CatalogSyncStatus { last_synced_at })
+450}
+451
+452/// Control whether offline library queries reveal the full synced catalog
+453/// (greyed-out, non-downloaded media) or only downloaded/local media.
+454///
+455/// The frontend calls this from the "Show all server media" toggle: pass `true`
+456/// when online, or when offline with the toggle on; pass `false` when offline
+457/// with the toggle off so library pages show downloaded media only. Fixes the
+458/// bug where offline library pages showed every server item regardless of the
+459/// toggle.
+460#[tauri::command]
+461#[specta::specta]
+462pub fn set_show_server_catalog(show: bool) {
+463    crate::repository::offline::set_include_catalog_browse(show);
+464}
+465
+466#[derive(specta::Type, Debug, Clone, serde::Serialize, serde::Deserialize)]
+467#[serde(rename_all = "camelCase")]
+468pub struct ResumeQueuedResult {
+469    /// Rows whose stream URL was resolved and are now pump-eligible.
+470    pub resolved: usize,
+471    /// Rows that couldn't be resolved (item metadata / URL lookup failed).
+472    pub failed: usize,
+473}
+474
+475/// Requeue video downloads that were fetched as audio.
+476///
+477/// Before [`resolve_pending_download_urls`] consulted the item's type, a row
+478/// with no `media_type` — which is every row queued from a media card, since
+479/// `download_item` does not record one — resolved against
+480/// `get_audio_stream_url`. A movie queued that way completed with an audio-only
+481/// transcode on disk, so playing it offline could only ever fail. Those rows are
+482/// identifiable after the fact (no `media_type`, but a video item), so reset them
+483/// to pending with no URL and let the resolver fetch the real video.
+484///
+485/// Rows carrying an explicit `media_type` were resolved correctly and are left
+486/// alone, as are genuine audio downloads.
+487///
+488/// Returns the number of rows requeued.
+489///
+490/// TRACES: UR-071 | DR-136 | UT-126
+491pub(crate) async fn requeue_mistyped_video_downloads(
+492    db_service: &Arc<crate::storage::db_service::RusqliteService>,
+493) -> Result<usize, String> {
+494    let video_types = VIDEO_ITEM_TYPES
+495        .iter()
+496        .map(|t| format!("'{t}'"))
+497        .collect::<Vec<_>>()
+498        .join(", ");
+499
+500    let query = Query::new(format!(
+501        "UPDATE downloads
+502         SET status = 'pending', stream_url = NULL, progress = 0,
+503             bytes_downloaded = 0, started_at = NULL, completed_at = NULL
+504         WHERE media_type IS NULL
+505           AND status = 'completed'
+506           AND item_id IN (SELECT id FROM items WHERE item_type IN ({video_types}))"
+507    ));
+508
+509    let n = db_service.execute(query).await.map_err(|e| e.to_string())? as usize;
+510
+511    if n > 0 {
+512        info!(
+513            "[Catalog] Requeued {} video download(s) that were fetched as audio",
+514            n
+515        );
+516    }
+517    Ok(n)
+518}
+519
+520/// Core of [`resume_queued_downloads`], factored out for testing: select every
+521/// `pending`/`stream_url IS NULL` row, resolve each via `resolve` (returning
+522/// `None` leaves the row pending), and heal the row so the pump can start it.
+523/// The `resolve` closure receives `(item_id, media_type, quality_preset)`.
+524///
+525/// `only_ids` restricts the sweep to specific download rows. Reconnect passes
+526/// `None` and heals everything; a bulk enqueue (an album, say) passes the rows
+527/// it just created, so clicking download on one album cannot also start every
+528/// unrelated row that has been sitting pending.
+529pub(crate) async fn resolve_pending_download_urls<F, Fut>(
+530    db_service: &Arc<crate::storage::db_service::RusqliteService>,
+531    target_dir: &str,
+532    only_ids: Option<&[i64]>,
+533    resolve: F,
+534) -> Result<ResumeQueuedResult, String>
+535where
+536    F: Fn(String, String, String) -> Fut,
+537    Fut: std::future::Future<Output = Option<String>>,
+538{
+539    if only_ids.is_some_and(|ids| ids.is_empty()) {
+540        return Ok(ResumeQueuedResult {
+541            resolved: 0,
+542            failed: 0,
+543        });
+544    }
+545    // A row's own media_type wins; otherwise the *item's* type decides. Rows
+546    // queued from a media card never carry one (`download_item` does not record
+547    // it), and defaulting that NULL to 'audio' resolved movies against
+548    // `get_audio_stream_url` — the file on disk was an audio-only transcode, so
+549    // offline video could never play. Falling back to 'audio' only when the item
+550    // is unknown keeps the historical behaviour for uncached items.
+551    // TRACES: UR-071, UR-052 | DR-135
+552    let video_types = VIDEO_ITEM_TYPES
+553        .iter()
+554        .map(|t| format!("'{t}'"))
+555        .collect::<Vec<_>>()
+556        .join(", ");
+557    let id_filter = match only_ids {
+558        Some(ids) => format!(
+559            " AND d.id IN ({})",
+560            ids.iter()
+561                .map(|id| id.to_string())
+562                .collect::<Vec<_>>()
+563                .join(", ")
+564        ),
+565        None => String::new(),
+566    };
+567    let rows_query = Query::new(format!(
+568        "SELECT d.id, d.item_id,
+569                COALESCE(
+570                    d.media_type,
+571                    CASE WHEN i.item_type IN ({video_types}) THEN 'video'
+572                         WHEN i.item_type IS NOT NULL THEN 'audio'
+573                    END,
+574                    'audio'),
+575                COALESCE(d.quality_preset, 'original')
+576         FROM downloads d
+577         LEFT JOIN items i ON i.id = d.item_id
+578         WHERE d.status = 'pending' AND d.stream_url IS NULL{id_filter}"
+579    ));
+580    let rows: Vec<(i64, String, String, String)> = db_service
+581        .query_many(rows_query, |row| {
+582            Ok((row.get(0)?, row.get(1)?, row.get(2)?, row.get(3)?))
+583        })
+584        .await
+585        .map_err(|e| e.to_string())?;
+586
+587    if rows.is_empty() {
+588        return Ok(ResumeQueuedResult {
+589            resolved: 0,
+590            failed: 0,
+591        });
+592    }
+593
+594    info!(
+595        "[Catalog] Resolving {} offline-queued downloads on reconnect",
+596        rows.len()
+597    );
+598
+599    let mut resolved = 0usize;
+600    let mut failed = 0usize;
+601
+602    for (download_id, item_id, media_type, quality) in rows {
+603        let stream_url = match resolve(item_id.clone(), media_type, quality).await {
+604            Some(url) => url,
+605            None => {
+606                failed += 1;
+607                continue;
+608            }
+609        };
+610
+611        // Heal the row so the pump can start it. Guard on stream_url IS NULL so a
+612        // concurrent resolver doesn't clobber an already-started row.
+613        let update = Query::with_params(
+614            "UPDATE downloads SET stream_url = ?, target_dir = ?
+615             WHERE id = ? AND status = 'pending' AND stream_url IS NULL",
+616            vec![
+617                QueryParam::String(stream_url),
+618                QueryParam::String(target_dir.to_string()),
+619                QueryParam::Int64(download_id),
+620            ],
+621        );
+622        match db_service.execute(update).await {
+623            Ok(n) if n > 0 => resolved += 1,
+624            Ok(_) => {} // already resolved by someone else; not a failure
+625            Err(e) => {
+626                warn!(
+627                    "[Catalog] Failed to persist URL for download {}: {}",
+628                    download_id, e
+629                );
+630                failed += 1;
+631            }
+632        }
+633    }
+634
+635    Ok(ResumeQueuedResult { resolved, failed })
+636}
+637
+638/// Resolve the stream URL for every download row that was queued while offline
+639/// (`status = 'pending' AND stream_url IS NULL`), then pump the queue so they
+640/// start. Call this on reconnect.
+641///
+642/// Audio rows resolve via `get_audio_stream_url`; video rows (media_type =
+643/// 'video') via the pure `get_video_download_url` builder using the row's stored
+644/// `quality_preset` — mirroring `enqueue_video_downloads`. Rows whose URL can't
+645/// be resolved are left pending (they retry on the next reconnect).
+646#[tauri::command]
+647#[specta::specta]
+648pub async fn resume_queued_downloads(
+649    repository: State<'_, RepositoryManagerWrapper>,
+650    db: State<'_, DatabaseWrapper>,
+651    download_manager: State<'_, DownloadManagerWrapper>,
+652    app: tauri::AppHandle,
+653    handle: String,
+654) -> Result<ResumeQueuedResult, String> {
+655    use crate::repository::MediaRepository;
+656
+657    let repo = repository.0.get(&handle).ok_or("Repository not found")?;
+658
+659    // The pump needs a target_dir; use the same storage root the other download
+660    // paths use (the database's parent directory — see `storage_get_path`).
+661    let (db_service, target_dir) = {
+662        let database = db.0.lock().map_err(|e| e.to_string())?;
+663        let target_dir = database
+664            .path()
+665            .parent()
+666            .ok_or_else(|| "Database path has no parent directory".to_string())?
+667            .to_string_lossy()
+668            .to_string();
+669        (Arc::new(database.service()), target_dir)
+670    };
+671
+672    // Recover stale downloads: rows left in 'downloading' when the app was killed
+673    // mid-transfer are orphaned — nothing ever restarts them, so they show as
+674    // permanently "downloading". Reset them to 'pending' and clear the stale
+675    // stream_url so they get re-resolved and restarted from scratch below.
+676    let recover_query = Query::new(
+677        "UPDATE downloads SET status = 'pending', stream_url = NULL, progress = 0, \
+678         bytes_downloaded = 0, started_at = NULL \
+679         WHERE status = 'downloading'",
+680    );
+681    match db_service.execute(recover_query).await {
+682        Ok(n) if n > 0 => info!("[Catalog] Reset {} stale 'downloading' rows to pending", n),
+683        Ok(_) => {}
+684        Err(e) => warn!("[Catalog] Failed to reset stale downloads: {}", e),
+685    }
+686
+687    // Repair rows that completed as audio because their media_type was missing;
+688    // they hold an audio-only transcode where a video should be, so requeue them
+689    // for the resolver below. TRACES: UR-071 | DR-136
+690    if let Err(e) = requeue_mistyped_video_downloads(&db_service).await {
+691        warn!(
+692            "[Catalog] Failed to requeue mis-typed video downloads: {}",
+693            e
+694        );
+695    }
+696
+697    // Resolve each row's URL against the (now reachable) repository.
+698    let repo_for_resolve = Arc::clone(&repo);
+699    let outcome = resolve_pending_download_urls(
+700        &db_service,
+701        &target_dir,
+702        None,
+703        move |item_id: String, media_type: String, quality: String| {
+704            let repo = Arc::clone(&repo_for_resolve);
+705            async move {
+706                if media_type == "video" {
+707                    Some(
+708                        crate::repository::resolve_video_download_url(
+709                            repo.as_ref(),
+710                            &item_id,
+711                            &quality,
+712                            None,
+713                        )
+714                        .await,
+715                    )
+716                } else {
+717                    match repo.get_audio_stream_url(&item_id).await {
+718                        Ok(url) => Some(url),
+719                        Err(e) => {
+720                            warn!(
+721                                "[Catalog] Failed to resolve audio URL for {}: {:?}",
+722                                item_id, e
+723                            );
+724                            None
+725                        }
+726                    }
+727                }
+728            }
+729        },
+730    )
+731    .await
+732    .map_err(|e| e.to_string())?;
+733
+734    let ResumeQueuedResult { resolved, failed } = outcome;
+735
+736    // Kick the pump so the newly-resolved rows actually start.
+737    if resolved > 0 {
+738        let active_downloads = {
+739            let manager = download_manager.0.lock().map_err(|e| e.to_string())?;
+740            manager.get_active_downloads()
+741        };
+742        pump_download_queue(app, db_service, active_downloads).await;
+743    }
+744
+745    info!(
+746        "[Catalog] Resume complete: {} resolved, {} failed",
+747        resolved, failed
+748    );
+749
+750    Ok(ResumeQueuedResult { resolved, failed })
+751}
+752
+753#[cfg(test)]
+754mod tests {
+755    use super::*;
+756    use crate::storage::db_service::RusqliteService;
+757    use crate::utils::lock::MutexSafe;
+758    use rusqlite::Connection;
+759    use std::sync::Mutex;
+760
+761    /// The re-index policy. Pure, so it is testable without a clock, a server or
+762    /// a database — which is the reason it was factored out of the scheduler.
+763    ///
+764    /// TRACES: UR-065 | DR-109 | UT-115
+765    #[test]
+766    fn test_index_is_due() {
+767        let ttl = Duration::from_secs(6 * 60 * 60);
+768        let now = chrono::DateTime::parse_from_rfc3339("2026-08-04T12:00:00+00:00")
+769            .unwrap()
+770            .with_timezone(&chrono::Utc);
+771
+772        // Never indexed => due. This is the first-run case.
+773        assert!(index_is_due(None, now, ttl));
+774
+775        // Indexed 7 hours ago => past the 6h TTL => due.
+776        assert!(index_is_due(Some("2026-08-04T05:00:00+00:00"), now, ttl));
+777
+778        // Indexed 1 hour ago => fresh => not due. This is what stops the
+779        // scheduler re-crawling every tick.
+780        assert!(!index_is_due(Some("2026-08-04T11:00:00+00:00"), now, ttl));
+781
+782        // Exactly at the TTL boundary counts as due.
+783        assert!(index_is_due(Some("2026-08-04T06:00:00+00:00"), now, ttl));
+784
+785        // A corrupt stored value must trigger a re-index, not freeze the
+786        // catalog forever behind an unparseable timestamp.
+787        assert!(index_is_due(Some("not-a-timestamp"), now, ttl));
+788        assert!(index_is_due(Some(""), now, ttl));
+789
+790        // A timestamp in the future (clock skew, or a restored backup) is not
+791        // due — it ages into due-ness rather than causing a crawl every tick.
+792        assert!(!index_is_due(Some("2026-08-05T00:00:00+00:00"), now, ttl));
+793
+794        // Offsets other than UTC are compared as instants, not as strings.
+795        assert!(!index_is_due(Some("2026-08-04T13:30:00+02:00"), now, ttl));
+796    }
+797
+798    fn test_db() -> Arc<RusqliteService> {
+799        let conn = Connection::open_in_memory().unwrap();
+800        conn.execute_batch(
+801            r#"
+802            CREATE TABLE downloads (
+803                id INTEGER PRIMARY KEY AUTOINCREMENT,
+804                item_id TEXT NOT NULL,
+805                status TEXT NOT NULL,
+806                stream_url TEXT,
+807                target_dir TEXT,
+808                media_type TEXT,
+809                quality_preset TEXT,
+810                progress REAL DEFAULT 0,
+811                bytes_downloaded INTEGER DEFAULT 0,
+812                started_at TEXT,
+813                completed_at TEXT
+814            );
+815            CREATE TABLE items (
+816                id TEXT PRIMARY KEY,
+817                item_type TEXT
+818            );
+819            "#,
+820        )
+821        .unwrap();
+822        Arc::new(RusqliteService::new(Arc::new(Mutex::new(conn))))
+823    }
+824
+825    async fn insert_item(db: &Arc<RusqliteService>, item_id: &str, item_type: &str) {
+826        db.execute(Query::with_params(
+827            "INSERT INTO items (id, item_type) VALUES (?, ?)",
+828            vec![
+829                QueryParam::String(item_id.to_string()),
+830                QueryParam::String(item_type.to_string()),
+831            ],
+832        ))
+833        .await
+834        .unwrap();
+835    }
+836
+837    async fn insert_download(
+838        db: &Arc<RusqliteService>,
+839        item_id: &str,
+840        status: &str,
+841        stream_url: Option<&str>,
+842        media_type: Option<&str>,
+843    ) {
+844        let q = Query::with_params(
+845            "INSERT INTO downloads (item_id, status, stream_url, media_type) VALUES (?, ?, ?, ?)",
+846            vec![
+847                QueryParam::String(item_id.to_string()),
+848                QueryParam::String(status.to_string()),
+849                stream_url
+850                    .map(|s| QueryParam::String(s.to_string()))
+851                    .unwrap_or(QueryParam::Null),
+852                media_type
+853                    .map(|s| QueryParam::String(s.to_string()))
+854                    .unwrap_or(QueryParam::Null),
+855            ],
+856        );
+857        db.execute(q).await.unwrap();
+858    }
+859
+860    async fn get_row(
+861        db: &Arc<RusqliteService>,
+862        item_id: &str,
+863    ) -> (String, Option<String>, Option<String>) {
+864        let q = Query::with_params(
+865            "SELECT status, stream_url, target_dir FROM downloads WHERE item_id = ?",
+866            vec![QueryParam::String(item_id.to_string())],
+867        );
+868        db.query_one(q, |row| Ok((row.get(0)?, row.get(1)?, row.get(2)?)))
+869            .await
+870            .unwrap()
+871    }
+872
+873    /// IT-017: a download queued from a greyed-out offline catalog entry
+874    /// (pending, `stream_url IS NULL`) persists, and on reconnect its URL is
+875    /// resolved and the row is healed (URL + target dir) so the pump can start
+876    /// it — while already-resolved rows are left untouched.
+877    ///
+878    /// TRACES: UR-052, UR-011 | IT-017
+879    #[tokio::test]
+880    async fn resolves_offline_queued_row_and_leaves_resolved_rows_untouched() {
+881        let db = test_db();
+882        // A row queued offline: pending with no URL yet.
+883        insert_download(&db, "queued-1", "pending", None, None).await;
+884        // An already-resolved pending row: must NOT be touched.
+885        insert_download(&db, "already", "pending", Some("http://existing/url"), None).await;
+886        // A completed row: irrelevant.
+887        insert_download(&db, "done", "completed", Some("http://done/url"), None).await;
+888
+889        let out = resolve_pending_download_urls(
+890            &db,
+891            "/data/downloads",
+892            None,
+893            |item_id, _mt, _q| async move { Some(format!("http://resolved/{item_id}")) },
+894        )
+895        .await
+896        .unwrap();
+897
+898        assert_eq!(out.resolved, 1);
+899        assert_eq!(out.failed, 0);
+900
+901        // The offline-queued row now has a URL + target dir and stays pending.
+902        let (status, url, target) = get_row(&db, "queued-1").await;
+903        assert_eq!(status, "pending");
+904        assert_eq!(url.as_deref(), Some("http://resolved/queued-1"));
+905        assert_eq!(target.as_deref(), Some("/data/downloads"));
+906
+907        // The already-resolved row is unchanged (not re-resolved).
+908        let (_s, url2, _t) = get_row(&db, "already").await;
+909        assert_eq!(url2.as_deref(), Some("http://existing/url"));
+910    }
+911
+912    /// A bulk enqueue resolves only the rows it just created. Downloading one
+913    /// album must not also start every unrelated row that has been sitting
+914    /// pending with no URL (the smart cache leaves plenty of those).
+915    ///
+916    /// TRACES: UR-018, UR-055 | DR-173 | UT-171
+917    #[tokio::test]
+918    async fn only_ids_restricts_the_sweep_to_the_given_rows() {
+919        let db = test_db();
+920        insert_download(&db, "mine", "pending", None, Some("audio")).await;
+921        insert_download(&db, "someone-elses", "pending", None, Some("audio")).await;
+922
+923        let mine: i64 = db
+924            .query_one(
+925                Query::new("SELECT id FROM downloads WHERE item_id = 'mine'"),
+926                |row| row.get(0),
+927            )
+928            .await
+929            .unwrap();
+930
+931        let out = resolve_pending_download_urls(
+932            &db,
+933            "/data",
+934            Some(&[mine]),
+935            |item_id, _mt, _q| async move { Some(format!("http://resolved/{item_id}")) },
+936        )
+937        .await
+938        .unwrap();
+939
+940        assert_eq!(out.resolved, 1);
+941        assert_eq!(out.failed, 0);
+942
+943        let (_s, url, _t) = get_row(&db, "mine").await;
+944        assert_eq!(url.as_deref(), Some("http://resolved/mine"));
+945
+946        let (status, other_url, _t) = get_row(&db, "someone-elses").await;
+947        assert_eq!(status, "pending");
+948        assert_eq!(
+949            other_url, None,
+950            "a scoped resolve must leave unrelated pending rows alone"
+951        );
+952    }
+953
+954    /// An empty id list resolves nothing — it must not fall through to "sweep
+955    /// everything", which is what an unguarded `IN ()` would amount to.
+956    ///
+957    /// TRACES: UR-018, UR-055 | DR-173 | UT-171
+958    #[tokio::test]
+959    async fn an_empty_id_list_resolves_nothing() {
+960        let db = test_db();
+961        insert_download(&db, "untouched", "pending", None, Some("audio")).await;
+962
+963        let out =
+964            resolve_pending_download_urls(&db, "/data", Some(&[]), |item_id, _mt, _q| async move {
+965                Some(format!("http://resolved/{item_id}"))
+966            })
+967            .await
+968            .unwrap();
+969
+970        assert_eq!(out.resolved, 0);
+971        let (_s, url, _t) = get_row(&db, "untouched").await;
+972        assert_eq!(url, None);
+973    }
+974
+975    #[tokio::test]
+976    async fn counts_unresolvable_rows_as_failed_and_leaves_them_pending() {
+977        let db = test_db();
+978        insert_download(&db, "bad", "pending", None, None).await;
+979
+980        // Resolver returns None (e.g. server lookup failed).
+981        let out =
+982            resolve_pending_download_urls(&db, "/data", None, |_id, _mt, _q| async move { None })
+983                .await
+984                .unwrap();
+985
+986        assert_eq!(out.resolved, 0);
+987        assert_eq!(out.failed, 1);
+988
+989        // Still pending with no URL, so a later reconnect can retry it.
+990        let (status, url, _t) = get_row(&db, "bad").await;
+991        assert_eq!(status, "pending");
+992        assert_eq!(url, None);
+993    }
+994
+995    /// A movie queued from a media card has no `media_type` — `download_item`
+996    /// never records one. Defaulting that NULL to 'audio' resolved the row
+997    /// against `get_audio_stream_url`, so the "downloaded movie" on disk was an
+998    /// audio-only transcode and offline video playback could never work. The
+999    /// item's own type is the authority.
+1000    ///
+1001    /// TRACES: UR-071, UR-052 | DR-135 | UT-125
+1002    #[tokio::test]
+1003    async fn null_media_type_resolves_from_the_item_type_not_audio() {
+1004        let db = test_db();
+1005        insert_item(&db, "movie-1", "Movie").await;
+1006        insert_item(&db, "ep-1", "Episode").await;
+1007        insert_item(&db, "track-1", "Audio").await;
+1008        for id in ["movie-1", "ep-1", "track-1"] {
+1009            insert_download(&db, id, "pending", None, None).await;
+1010        }
+1011
+1012        let seen = Arc::new(Mutex::new(Vec::new()));
+1013        let seen_c = Arc::clone(&seen);
+1014        resolve_pending_download_urls(&db, "/data", None, move |item_id, media_type, _q| {
+1015            let seen = Arc::clone(&seen_c);
+1016            async move {
+1017                seen.lock_safe().push((item_id.clone(), media_type));
+1018                Some(format!("http://resolved/{item_id}"))
+1019            }
+1020        })
+1021        .await
+1022        .unwrap();
+1023
+1024        let seen = seen.lock_safe().clone();
+1025        let of = |id: &str| {
+1026            seen.iter()
+1027                .find(|(i, _)| i == id)
+1028                .map(|(_, m)| m.clone())
+1029                .unwrap()
+1030        };
+1031        assert_eq!(of("movie-1"), "video", "a Movie must download as video");
+1032        assert_eq!(of("ep-1"), "video", "an Episode must download as video");
+1033        assert_eq!(of("track-1"), "audio", "a track is still audio");
+1034    }
+1035
+1036    /// An unknown item (never cached locally) has no type to derive from, so it
+1037    /// keeps the historical audio default rather than failing the row.
+1038    ///
+1039    /// TRACES: UR-071 | DR-135 | UT-125
+1040    #[tokio::test]
+1041    async fn unknown_item_falls_back_to_audio() {
+1042        let db = test_db();
+1043        insert_download(&db, "ghost", "pending", None, None).await;
+1044
+1045        let seen = Arc::new(Mutex::new(String::new()));
+1046        let seen_c = Arc::clone(&seen);
+1047        resolve_pending_download_urls(&db, "/data", None, move |_id, media_type, _q| {
+1048            let seen = Arc::clone(&seen_c);
+1049            async move {
+1050                *seen.lock_safe() = media_type;
+1051                Some("http://x".to_string())
+1052            }
+1053        })
+1054        .await
+1055        .unwrap();
+1056
+1057        assert_eq!(*seen.lock_safe(), "audio");
+1058    }
+1059
+1060    /// An explicit `media_type` on the row always wins over the item's type.
+1061    ///
+1062    /// TRACES: UR-071 | DR-135 | UT-125
+1063    #[tokio::test]
+1064    async fn explicit_media_type_beats_the_item_type() {
+1065        let db = test_db();
+1066        insert_item(&db, "odd", "Audio").await;
+1067        insert_download(&db, "odd", "pending", None, Some("video")).await;
+1068
+1069        let seen = Arc::new(Mutex::new(String::new()));
+1070        let seen_c = Arc::clone(&seen);
+1071        resolve_pending_download_urls(&db, "/data", None, move |_id, media_type, _q| {
+1072            let seen = Arc::clone(&seen_c);
+1073            async move {
+1074                *seen.lock_safe() = media_type;
+1075                Some("http://x".to_string())
+1076            }
+1077        })
+1078        .await
+1079        .unwrap();
+1080
+1081        assert_eq!(*seen.lock_safe(), "video");
+1082    }
+1083
+1084    /// Rows already downloaded under the audio default hold an audio-only
+1085    /// transcode on disk, so they play as a broken video forever. They are
+1086    /// identifiable — no `media_type` but a video item — and are requeued so the
+1087    /// resolver fetches the real video. Correctly-typed rows and genuine audio
+1088    /// downloads must be left alone.
+1089    ///
+1090    /// TRACES: UR-071 | DR-136 | UT-126
+1091    #[tokio::test]
+1092    async fn requeues_video_downloaded_under_the_audio_default() {
+1093        let db = test_db();
+1094        insert_item(&db, "movie-1", "Movie").await;
+1095        insert_item(&db, "track-1", "Audio").await;
+1096        insert_item(&db, "movie-ok", "Movie").await;
+1097        // Mis-downloaded: completed, no media_type, video item.
+1098        insert_download(&db, "movie-1", "completed", Some("http://audio/url"), None).await;
+1099        // A real audio download: untouched.
+1100        insert_download(&db, "track-1", "completed", Some("http://audio/ok"), None).await;
+1101        // A correctly-typed video download: untouched.
+1102        insert_download(
+1103            &db,
+1104            "movie-ok",
+1105            "completed",
+1106            Some("http://video/ok"),
+1107            Some("video"),
+1108        )
+1109        .await;
+1110
+1111        let requeued = requeue_mistyped_video_downloads(&db).await.unwrap();
+1112        assert_eq!(requeued, 1);
+1113
+1114        let (status, url, _t) = get_row(&db, "movie-1").await;
+1115        assert_eq!(status, "pending", "the mis-typed row must download again");
+1116        assert_eq!(url, None, "its audio URL must be cleared so it re-resolves");
+1117
+1118        let (status, url, _t) = get_row(&db, "track-1").await;
+1119        assert_eq!(status, "completed", "a real audio download is untouched");
+1120        assert_eq!(url.as_deref(), Some("http://audio/ok"));
+1121
+1122        let (status, _u, _t) = get_row(&db, "movie-ok").await;
+1123        assert_eq!(status, "completed", "a correct video download is untouched");
+1124    }
+1125
+1126    #[tokio::test]
+1127    async fn video_rows_use_media_type_in_resolver() {
+1128        let db = test_db();
+1129        insert_download(&db, "vid-1", "pending", None, Some("video")).await;
+1130
+1131        let out = resolve_pending_download_urls(
+1132            &db,
+1133            "/data",
+1134            None,
+1135            |item_id, media_type, _q| async move {
+1136                assert_eq!(media_type, "video");
+1137                Some(format!("http://transcode/{item_id}"))
+1138            },
+1139        )
+1140        .await
+1141        .unwrap();
+1142
+1143        assert_eq!(out.resolved, 1);
+1144        let (_s, url, _t) = get_row(&db, "vid-1").await;
+1145        assert_eq!(url.as_deref(), Some("http://transcode/vid-1"));
+1146    }
+1147}
+
\ No newline at end of file diff --git a/api/src/jellytau_lib/commands/connectivity.rs.html b/api/src/jellytau_lib/commands/connectivity.rs.html new file mode 100644 index 00000000..29e2ef1a --- /dev/null +++ b/api/src/jellytau_lib/commands/connectivity.rs.html @@ -0,0 +1,103 @@ +connectivity.rs - sourceSkip to main content

jellytau_lib/commands/
connectivity.rs

1//! Server-reachability / connectivity commands.
+2//!
+3//! TRACES: UR-043 | IR-027 | DR-055
+4
+5use crate::connectivity::{ConnectivityMonitor, ConnectivityStatus};
+6use std::sync::Arc;
+7use tauri::State;
+8
+9/// Wrapper for ConnectivityMonitor managed state
+10pub struct ConnectivityMonitorWrapper(pub Arc<tokio::sync::Mutex<ConnectivityMonitor>>);
+11
+12/// Check if the server is currently reachable
+13#[tauri::command]
+14#[specta::specta]
+15pub async fn connectivity_check_server(
+16    state: State<'_, ConnectivityMonitorWrapper>,
+17) -> Result<bool, String> {
+18    let monitor = state.0.lock().await;
+19    Ok(monitor.check_reachability().await)
+20}
+21
+22/// Set the server URL and trigger an immediate check
+23#[tauri::command]
+24#[specta::specta]
+25pub async fn connectivity_set_server_url(
+26    url: String,
+27    state: State<'_, ConnectivityMonitorWrapper>,
+28) -> Result<(), String> {
+29    let monitor = state.0.lock().await;
+30    monitor.set_server_url(url).await;
+31    Ok(())
+32}
+33
+34/// Get the current connectivity status
+35#[tauri::command]
+36#[specta::specta]
+37pub async fn connectivity_get_status(
+38    state: State<'_, ConnectivityMonitorWrapper>,
+39) -> Result<ConnectivityStatus, String> {
+40    let monitor = state.0.lock().await;
+41    Ok(monitor.get_status().await)
+42}
+43
+44/// Start monitoring connectivity with adaptive polling
+45#[tauri::command]
+46#[specta::specta]
+47pub async fn connectivity_start_monitoring(
+48    state: State<'_, ConnectivityMonitorWrapper>,
+49) -> Result<(), String> {
+50    let monitor = state.0.lock().await;
+51    monitor.start_monitoring().await;
+52    Ok(())
+53}
+54
+55/// Stop monitoring connectivity
+56#[tauri::command]
+57#[specta::specta]
+58pub async fn connectivity_stop_monitoring(
+59    state: State<'_, ConnectivityMonitorWrapper>,
+60) -> Result<(), String> {
+61    let monitor = state.0.lock().await;
+62    monitor.stop_monitoring();
+63    Ok(())
+64}
+65
+66/// Mark the server as reachable (called after successful API calls)
+67#[tauri::command]
+68#[specta::specta]
+69pub async fn connectivity_mark_reachable(
+70    state: State<'_, ConnectivityMonitorWrapper>,
+71) -> Result<(), String> {
+72    let monitor = state.0.lock().await;
+73    monitor.mark_reachable().await;
+74    Ok(())
+75}
+76
+77/// Mark the server as unreachable (called after failed API calls)
+78#[tauri::command]
+79#[specta::specta]
+80pub async fn connectivity_mark_unreachable(
+81    error: Option<String>,
+82    state: State<'_, ConnectivityMonitorWrapper>,
+83) -> Result<(), String> {
+84    let monitor = state.0.lock().await;
+85    monitor.mark_unreachable(error).await;
+86    Ok(())
+87}
+88
+89#[cfg(test)]
+90mod tests {
+91    use super::*;
+92
+93    #[test]
+94    fn test_connectivity_monitor_wrapper_structure() {
+95        // Test that wrapper can be created and holds Arc
+96        // We can't instantiate ConnectivityMonitor directly in tests
+97        // due to its dependencies, so we just test the wrapper type structure
+98
+99        // This verifies the wrapper type exists and can hold Arc<Mutex>
+100        assert!(std::mem::size_of::<ConnectivityMonitorWrapper>() > 0);
+101    }
+102}
+
\ No newline at end of file diff --git a/api/src/jellytau_lib/commands/conversions.rs.html b/api/src/jellytau_lib/commands/conversions.rs.html new file mode 100644 index 00000000..65aab840 --- /dev/null +++ b/api/src/jellytau_lib/commands/conversions.rs.html @@ -0,0 +1,131 @@ +conversions.rs - sourceSkip to main content

jellytau_lib/commands/
conversions.rs

1//! Tauri commands for unit conversions and formatting
+2//!
+3//! TRACES: UR-005 | DR-009
+4//!
+5//! These commands expose conversion utilities to the frontend,
+6//! allowing centralized conversion logic in Rust.
+7
+8use crate::utils::conversions::{
+9    calculate_progress, format_time, format_time_long, percent_to_volume, ticks_to_seconds,
+10};
+11
+12/// Format time in seconds to MM:SS display string
+13///
+14/// # Arguments
+15/// * `seconds` - Time in seconds
+16///
+17/// # Returns
+18/// Formatted string like "3:45" or "12:09"
+19#[tauri::command]
+20#[specta::specta]
+21pub fn format_time_seconds(seconds: f64) -> String {
+22    format_time(seconds)
+23}
+24
+25/// Format time in seconds to HH:MM:SS or MM:SS display string
+26///
+27/// Automatically chooses format based on duration:
+28/// - Less than 1 hour: Returns MM:SS format
+29/// - 1 hour or more: Returns HH:MM:SS format
+30///
+31/// # Arguments
+32/// * `seconds` - Time in seconds
+33///
+34/// # Returns
+35/// Formatted string like "1:23:45" or "3:45"
+36#[tauri::command]
+37#[specta::specta]
+38pub fn format_time_seconds_long(seconds: f64) -> String {
+39    format_time_long(seconds)
+40}
+41
+42/// Convert Jellyfin ticks to seconds
+43///
+44/// # Arguments
+45/// * `ticks` - Time in Jellyfin ticks (10,000,000 ticks = 1 second)
+46///
+47/// # Returns
+48/// Time in seconds
+49#[tauri::command]
+50#[specta::specta]
+51pub fn convert_ticks_to_seconds(ticks: i64) -> f64 {
+52    ticks_to_seconds(ticks)
+53}
+54
+55/// Calculate progress percentage from position and duration
+56///
+57/// # Arguments
+58/// * `position` - Current position in seconds
+59/// * `duration` - Total duration in seconds
+60///
+61/// # Returns
+62/// Progress as percentage (0.0 to 100.0)
+63#[tauri::command]
+64#[specta::specta]
+65pub fn calc_progress(position: f64, duration: f64) -> f64 {
+66    calculate_progress(position, duration)
+67}
+68
+69/// Convert percentage volume (0-100) to normalized (0.0-1.0)
+70///
+71/// # Arguments
+72/// * `percent` - Volume as percentage (0 to 100)
+73///
+74/// # Returns
+75/// Normalized volume (0.0 to 1.0)
+76#[tauri::command]
+77#[specta::specta]
+78pub fn convert_percent_to_volume(percent: f64) -> f64 {
+79    percent_to_volume(percent)
+80}
+81
+82#[cfg(test)]
+83mod tests {
+84    use super::*;
+85
+86    #[test]
+87    fn test_format_time_seconds() {
+88        assert_eq!(format_time_seconds(0.0), "0:00");
+89        assert_eq!(format_time_seconds(59.0), "0:59");
+90        assert_eq!(format_time_seconds(60.0), "1:00");
+91        assert_eq!(format_time_seconds(125.0), "2:05");
+92        assert_eq!(format_time_seconds(3661.0), "61:01");
+93    }
+94
+95    #[test]
+96    fn test_format_time_seconds_long() {
+97        assert_eq!(format_time_seconds_long(0.0), "0:00");
+98        assert_eq!(format_time_seconds_long(59.0), "0:59");
+99        assert_eq!(format_time_seconds_long(3599.0), "59:59");
+100        assert_eq!(format_time_seconds_long(3600.0), "1:00:00");
+101        assert_eq!(format_time_seconds_long(3661.0), "1:01:01");
+102        assert_eq!(format_time_seconds_long(7384.0), "2:03:04");
+103    }
+104
+105    #[test]
+106    fn test_convert_ticks_to_seconds() {
+107        assert_eq!(convert_ticks_to_seconds(0), 0.0);
+108        assert_eq!(convert_ticks_to_seconds(10_000_000), 1.0);
+109        assert_eq!(convert_ticks_to_seconds(5_000_000), 0.5);
+110        assert_eq!(convert_ticks_to_seconds(60_000_000), 6.0);
+111    }
+112
+113    #[test]
+114    fn test_calc_progress() {
+115        assert_eq!(calc_progress(0.0, 100.0), 0.0);
+116        assert_eq!(calc_progress(50.0, 100.0), 50.0);
+117        assert_eq!(calc_progress(100.0, 100.0), 100.0);
+118        assert_eq!(calc_progress(25.0, 0.0), 0.0); // Invalid duration
+119        assert_eq!(calc_progress(150.0, 100.0), 100.0); // Clamped
+120    }
+121
+122    #[test]
+123    fn test_convert_percent_to_volume() {
+124        assert_eq!(convert_percent_to_volume(0.0), 0.0);
+125        assert_eq!(convert_percent_to_volume(50.0), 0.5);
+126        assert_eq!(convert_percent_to_volume(100.0), 1.0);
+127        assert_eq!(convert_percent_to_volume(150.0), 1.0); // Clamped
+128        assert_eq!(convert_percent_to_volume(-10.0), 0.0); // Clamped
+129    }
+130}
+
\ No newline at end of file diff --git a/api/src/jellytau_lib/commands/device.rs.html b/api/src/jellytau_lib/commands/device.rs.html new file mode 100644 index 00000000..c964d974 --- /dev/null +++ b/api/src/jellytau_lib/commands/device.rs.html @@ -0,0 +1,134 @@ +device.rs - sourceSkip to main content

jellytau_lib/commands/
device.rs

1//! Device identification commands
+2//!
+3//! Handles persistent device ID generation and retrieval for Jellyfin server communication.
+4//! TRACES: UR-009 | DR-011
+5
+6use std::sync::Arc;
+7
+8use log::info;
+9use tauri::State;
+10use uuid::Uuid;
+11
+12use crate::commands::storage::DatabaseWrapper;
+13use crate::storage::db_service::{DatabaseService, Query, QueryParam};
+14
+15/// Get or create the device ID.
+16/// Device ID is a UUID v4 that persists across app restarts.
+17/// On first call, generates and stores a new UUID.
+18/// On subsequent calls, retrieves the stored UUID.
+19///
+20/// # Returns
+21/// - `Ok(String)` - The device ID (UUID v4)
+22/// - `Err(String)` - If database operation fails
+23///
+24/// TRACES: UR-009 | DR-011
+25#[tauri::command]
+26#[specta::specta]
+27pub async fn device_get_id(db: State<'_, DatabaseWrapper>) -> Result<String, String> {
+28    let db_service = {
+29        let database = db.0.lock().map_err(|e| e.to_string())?;
+30        Arc::new(database.service())
+31    };
+32
+33    // Try to get existing device ID from database
+34    let query = Query::with_params(
+35        "SELECT value FROM app_settings WHERE key = ?",
+36        vec![QueryParam::String("device_id".to_string())],
+37    );
+38
+39    let existing_id: Option<String> = db_service
+40        .query_one(query, |row| row.get(0))
+41        .await
+42        .ok()
+43        .flatten();
+44
+45    if let Some(device_id) = existing_id {
+46        info!("[Device] Retrieved existing device ID");
+47        return Ok(device_id);
+48    }
+49
+50    // Generate new device ID
+51    let device_id = Uuid::new_v4().to_string();
+52
+53    // Store it in database
+54    let insert_query = Query::with_params(
+55        "INSERT INTO app_settings (key, value) VALUES (?, ?)",
+56        vec![
+57            QueryParam::String("device_id".to_string()),
+58            QueryParam::String(device_id.clone()),
+59        ],
+60    );
+61
+62    db_service
+63        .execute(insert_query)
+64        .await
+65        .map_err(|e| e.to_string())?;
+66
+67    info!("[Device] Generated and stored new device ID");
+68    Ok(device_id)
+69}
+70
+71/// Set the device ID (primarily for testing or recovery).
+72/// Overwrites any existing device ID.
+73///
+74/// # Arguments
+75/// * `device_id` - The device ID to store (should be UUID v4 format)
+76///
+77/// # Returns
+78/// - `Ok(())` - If device ID was stored successfully
+79/// - `Err(String)` - If database operation fails
+80///
+81/// TRACES: UR-009 | DR-011
+82#[tauri::command]
+83#[specta::specta]
+84pub async fn device_set_id(
+85    device_id: String,
+86    db: State<'_, DatabaseWrapper>,
+87) -> Result<(), String> {
+88    let db_service = {
+89        let database = db.0.lock().map_err(|e| e.to_string())?;
+90        Arc::new(database.service())
+91    };
+92
+93    let query = Query::with_params(
+94        "INSERT OR REPLACE INTO app_settings (key, value) VALUES (?, ?)",
+95        vec![
+96            QueryParam::String("device_id".to_string()),
+97            QueryParam::String(device_id),
+98        ],
+99    );
+100
+101    db_service.execute(query).await.map_err(|e| e.to_string())?;
+102
+103    info!("[Device] Device ID set");
+104    Ok(())
+105}
+106
+107#[cfg(test)]
+108mod tests {
+109    use super::*;
+110
+111    #[test]
+112    fn test_device_id_is_valid_uuid() {
+113        let id = Uuid::new_v4().to_string();
+114        // Should parse as UUID
+115        let parsed = Uuid::parse_str(&id);
+116        assert!(parsed.is_ok(), "Device ID should be a valid UUID");
+117    }
+118
+119    #[test]
+120    fn test_device_id_format() {
+121        let id = Uuid::new_v4().to_string();
+122        // UUID v4 format: xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx (36 chars with hyphens)
+123        assert_eq!(id.len(), 36, "Device ID should be 36 characters");
+124        assert!(id.contains('-'), "Device ID should contain hyphens");
+125    }
+126
+127    #[test]
+128    fn test_device_ids_are_unique() {
+129        let id1 = Uuid::new_v4().to_string();
+130        let id2 = Uuid::new_v4().to_string();
+131        assert_ne!(id1, id2, "Generated device IDs should be unique");
+132    }
+133}
+
\ No newline at end of file diff --git a/api/src/jellytau_lib/commands/diagnostics.rs.html b/api/src/jellytau_lib/commands/diagnostics.rs.html new file mode 100644 index 00000000..1834d5b1 --- /dev/null +++ b/api/src/jellytau_lib/commands/diagnostics.rs.html @@ -0,0 +1,331 @@ +diagnostics.rs - sourceSkip to main content

jellytau_lib/commands/
diagnostics.rs

1//! Diagnostics: log level control and the exportable bug-report bundle.
+2//!
+3//! TRACES: UR-078 | DR-218
+4//!
+5//! The app used to forget everything it did the moment it exited. A user
+6//! reporting "the episode randomly restarted" was reporting the symptom of a
+7//! race whose evidence had been discarded microseconds later, and the only way
+8//! to recover it was to talk them through `adb logcat` — which is how several
+9//! bugs in this project's history actually got diagnosed.
+10//!
+11//! This module is the other half of that: the log is on disk, it survives a
+12//! crash, and the user can hand the whole thing over as one file.
+13//!
+14//! Everything written here has been through [`crate::utils::diagnostics::redact`]
+15//! twice — once in the log formatter, and again on the way into the archive, so
+16//! files written by a build that predates the formatter pass are covered too.
+17
+18use std::fs;
+19use std::io::{Read, Write};
+20use std::path::{Path, PathBuf};
+21
+22use log::{info, warn, LevelFilter};
+23use serde::{Deserialize, Serialize};
+24use tauri::{AppHandle, Manager};
+25
+26use crate::utils::diagnostics::{redact, redact_server_url};
+27
+28/// Where an export landed, so the UI can tell the user where to find it.
+29#[derive(Debug, Clone, Serialize, Deserialize, specta::Type)]
+30#[serde(rename_all = "camelCase")]
+31pub struct DiagnosticsBundle {
+32    /// Absolute path to the written archive.
+33    pub path: String,
+34    pub size_bytes: u64,
+35    /// How many log files went in, excluding the environment summary.
+36    pub file_count: usize,
+37}
+38
+39/// Where logs live and how verbose they currently are.
+40#[derive(Debug, Clone, Serialize, Deserialize, specta::Type)]
+41#[serde(rename_all = "camelCase")]
+42pub struct DiagnosticsInfo {
+43    /// Directory holding the rotating log files.
+44    pub log_dir: String,
+45    /// Active level, lowercase: "error" | "warn" | "info" | "debug" | "trace".
+46    pub level: String,
+47    /// Total bytes currently held by log files.
+48    pub total_size_bytes: u64,
+49}
+50
+51/// Name of the file holding the user's chosen level, in the app config dir.
+52const LEVEL_FILE: &str = "log-level";
+53
+54/// Parse a stored/user-supplied level name.
+55///
+56/// Unknown values fall back to Info rather than erroring: this is read at
+57/// startup, and a corrupt one-line file must not stop the app from launching.
+58pub fn parse_level(raw: &str) -> LevelFilter {
+59    match raw.trim().to_ascii_lowercase().as_str() {
+60        "error" => LevelFilter::Error,
+61        "warn" => LevelFilter::Warn,
+62        "debug" => LevelFilter::Debug,
+63        "trace" => LevelFilter::Trace,
+64        _ => LevelFilter::Info,
+65    }
+66}
+67
+68/// Read the persisted level, if the user has ever set one.
+69///
+70/// Persisted rather than session-only on purpose: somebody reproducing a bug
+71/// needs debug logging to survive *the restart that reproduces it*.
+72pub fn stored_level(config_dir: &Path) -> Option<LevelFilter> {
+73    fs::read_to_string(config_dir.join(LEVEL_FILE))
+74        .ok()
+75        .map(|raw| parse_level(&raw))
+76}
+77
+78fn level_name(level: LevelFilter) -> &'static str {
+79    match level {
+80        LevelFilter::Off => "off",
+81        LevelFilter::Error => "error",
+82        LevelFilter::Warn => "warn",
+83        LevelFilter::Info => "info",
+84        LevelFilter::Debug => "debug",
+85        LevelFilter::Trace => "trace",
+86    }
+87}
+88
+89/// Current log level and where the files are.
+90///
+91/// TRACES: UR-078 | DR-218
+92#[tauri::command]
+93#[specta::specta]
+94pub async fn diagnostics_get_info(app: AppHandle) -> Result<DiagnosticsInfo, String> {
+95    let log_dir = app
+96        .path()
+97        .app_log_dir()
+98        .map_err(|e| format!("no log directory: {e}"))?;
+99
+100    let total_size_bytes = log_files(&log_dir)
+101        .iter()
+102        .filter_map(|p| fs::metadata(p).ok())
+103        .map(|m| m.len())
+104        .sum();
+105
+106    Ok(DiagnosticsInfo {
+107        log_dir: log_dir.to_string_lossy().to_string(),
+108        level: level_name(log::max_level()).to_string(),
+109        total_size_bytes,
+110    })
+111}
+112
+113/// Set the log level, for this session and the next.
+114///
+115/// TRACES: UR-078 | DR-218
+116#[tauri::command]
+117#[specta::specta]
+118pub async fn diagnostics_set_level(app: AppHandle, level: String) -> Result<String, String> {
+119    let parsed = parse_level(&level);
+120    log::set_max_level(parsed);
+121
+122    let config_dir = app
+123        .path()
+124        .app_config_dir()
+125        .map_err(|e| format!("no config directory: {e}"))?;
+126    fs::create_dir_all(&config_dir).map_err(|e| e.to_string())?;
+127    fs::write(config_dir.join(LEVEL_FILE), level_name(parsed)).map_err(|e| e.to_string())?;
+128
+129    info!("[DIAG] log level set to {}", level_name(parsed));
+130    Ok(level_name(parsed).to_string())
+131}
+132
+133/// Collect every log file in the log directory, newest first.
+134fn log_files(log_dir: &Path) -> Vec<PathBuf> {
+135    let Ok(entries) = fs::read_dir(log_dir) else {
+136        return Vec::new();
+137    };
+138    let mut files: Vec<PathBuf> = entries
+139        .filter_map(|e| e.ok())
+140        .map(|e| e.path())
+141        .filter(|p| p.is_file())
+142        .filter(|p| {
+143            p.extension()
+144                .is_some_and(|ext| ext == "log" || ext == "txt")
+145        })
+146        .collect();
+147    files.sort();
+148    files.reverse();
+149    files
+150}
+151
+152/// A short, non-identifying description of the environment.
+153///
+154/// Deliberately excludes the token, the username, and any path outside the
+155/// app's own directories. The server URL is reduced to scheme and host, which is
+156/// diagnostic (https? LAN address? reverse proxy?) without being a credential.
+157fn environment_summary(app: &AppHandle, server_url: Option<&str>) -> String {
+158    let package = app.package_info();
+159    let mut out = String::new();
+160    out.push_str("JellyTau diagnostics\n");
+161    out.push_str("====================\n\n");
+162    out.push_str(&format!("app version:   {}\n", package.version));
+163    out.push_str(&format!("tauri version: {}\n", tauri::VERSION));
+164    out.push_str(&format!("os:            {}\n", std::env::consts::OS));
+165    out.push_str(&format!("arch:          {}\n", std::env::consts::ARCH));
+166    out.push_str(&format!("debug build:   {}\n", cfg!(debug_assertions)));
+167    out.push_str(&format!(
+168        "log level:     {}\n",
+169        level_name(log::max_level())
+170    ));
+171    out.push_str(&format!(
+172        "server:        {}\n",
+173        server_url.map_or("not configured".to_string(), redact_server_url)
+174    ));
+175    out.push_str("\nNo access token, password or username is included in this file.\n");
+176    out
+177}
+178
+179/// Write a redacted diagnostics archive and return where it went.
+180///
+181/// # Blocking I/O
+182///
+183/// This reads and rewrites every log file. It is an `async` command so it does
+184/// not block the IPC thread, but it must never be called from a player event
+185/// callback — see the deadlock note in CLAUDE.md.
+186///
+187/// TRACES: UR-078 | DR-218
+188#[tauri::command]
+189#[specta::specta]
+190pub async fn diagnostics_export(
+191    app: AppHandle,
+192    server_url: Option<String>,
+193) -> Result<DiagnosticsBundle, String> {
+194    let log_dir = app
+195        .path()
+196        .app_log_dir()
+197        .map_err(|e| format!("no log directory: {e}"))?;
+198
+199    // Written into the app's own data directory. Choosing an arbitrary
+200    // user-visible location would need a file dialog on desktop and a storage
+201    // permission on Android; the UI reports the path and can reveal it.
+202    let out_dir = app
+203        .path()
+204        .app_data_dir()
+205        .map_err(|e| format!("no data directory: {e}"))?;
+206    fs::create_dir_all(&out_dir).map_err(|e| e.to_string())?;
+207
+208    let archive_path = out_dir.join("jellytau-diagnostics.zip");
+209    let file = fs::File::create(&archive_path).map_err(|e| e.to_string())?;
+210    let mut zip = zip::ZipWriter::new(file);
+211    let options: zip::write::FileOptions<'_, ()> =
+212        zip::write::FileOptions::default().compression_method(zip::CompressionMethod::Deflated);
+213
+214    let files = log_files(&log_dir);
+215    let mut written = 0usize;
+216
+217    for path in &files {
+218        let Some(name) = path.file_name().and_then(|n| n.to_str()) else {
+219            continue;
+220        };
+221
+222        let mut contents = String::new();
+223        match fs::File::open(path).and_then(|mut f| f.read_to_string(&mut contents)) {
+224            Ok(_) => {}
+225            Err(e) => {
+226                // A log we cannot read is not a reason to produce no bundle.
+227                warn!("[DIAG] skipping unreadable log {name}: {e}");
+228                continue;
+229            }
+230        }
+231
+232        // Second redaction pass. The formatter already cleaned anything this
+233        // build wrote; this covers files left by an older build.
+234        let cleaned: String = contents.lines().map(redact).collect::<Vec<_>>().join("\n");
+235
+236        zip.start_file(name, options).map_err(|e| e.to_string())?;
+237        zip.write_all(cleaned.as_bytes())
+238            .map_err(|e| e.to_string())?;
+239        written += 1;
+240    }
+241
+242    zip.start_file("environment.txt", options)
+243        .map_err(|e| e.to_string())?;
+244    zip.write_all(environment_summary(&app, server_url.as_deref()).as_bytes())
+245        .map_err(|e| e.to_string())?;
+246
+247    zip.finish().map_err(|e| e.to_string())?;
+248
+249    let size_bytes = fs::metadata(&archive_path)
+250        .map_err(|e| e.to_string())?
+251        .len();
+252    info!(
+253        "[DIAG] exported {written} log file(s), {size_bytes} bytes -> {}",
+254        archive_path.display()
+255    );
+256
+257    Ok(DiagnosticsBundle {
+258        path: archive_path.to_string_lossy().to_string(),
+259        size_bytes,
+260        file_count: written,
+261    })
+262}
+263
+264#[cfg(test)]
+265mod tests {
+266    use super::*;
+267
+268    #[test]
+269    fn parses_every_level_name_case_insensitively() {
+270        assert_eq!(parse_level("debug"), LevelFilter::Debug);
+271        assert_eq!(parse_level("DEBUG"), LevelFilter::Debug);
+272        assert_eq!(parse_level("  warn\n"), LevelFilter::Warn);
+273        assert_eq!(parse_level("error"), LevelFilter::Error);
+274        assert_eq!(parse_level("trace"), LevelFilter::Trace);
+275    }
+276
+277    #[test]
+278    fn unknown_level_falls_back_to_info_rather_than_failing() {
+279        // This is read at startup from a file on disk. A corrupt value must not
+280        // stop the app launching.
+281        assert_eq!(parse_level("banana"), LevelFilter::Info);
+282        assert_eq!(parse_level(""), LevelFilter::Info);
+283    }
+284
+285    #[test]
+286    fn level_names_round_trip() {
+287        for name in ["error", "warn", "info", "debug", "trace"] {
+288            assert_eq!(level_name(parse_level(name)), name);
+289        }
+290    }
+291
+292    #[test]
+293    fn stored_level_is_none_when_never_set() {
+294        let dir = std::env::temp_dir().join("jellytau-diag-test-empty");
+295        let _ = fs::create_dir_all(&dir);
+296        let _ = fs::remove_file(dir.join(LEVEL_FILE));
+297        assert!(stored_level(&dir).is_none());
+298    }
+299
+300    #[test]
+301    fn stored_level_reads_back_what_was_written() {
+302        let dir = std::env::temp_dir().join("jellytau-diag-test-roundtrip");
+303        fs::create_dir_all(&dir).unwrap();
+304        fs::write(dir.join(LEVEL_FILE), "debug").unwrap();
+305        assert_eq!(stored_level(&dir), Some(LevelFilter::Debug));
+306        let _ = fs::remove_file(dir.join(LEVEL_FILE));
+307    }
+308
+309    #[test]
+310    fn log_files_ignores_non_log_files() {
+311        let dir = std::env::temp_dir().join("jellytau-diag-test-listing");
+312        fs::create_dir_all(&dir).unwrap();
+313        fs::write(dir.join("jellytau.log"), "x").unwrap();
+314        fs::write(dir.join("notes.md"), "x").unwrap();
+315        fs::write(dir.join("jellytau.zip"), "x").unwrap();
+316
+317        let found = log_files(&dir);
+318        let names: Vec<String> = found
+319            .iter()
+320            .filter_map(|p| p.file_name()?.to_str().map(String::from))
+321            .collect();
+322
+323        assert!(names.contains(&"jellytau.log".to_string()));
+324        // The export archive itself lives elsewhere, but never re-zip a zip.
+325        assert!(!names.contains(&"jellytau.zip".to_string()));
+326        assert!(!names.contains(&"notes.md".to_string()));
+327
+328        let _ = fs::remove_dir_all(&dir);
+329    }
+330}
+
\ No newline at end of file diff --git a/api/src/jellytau_lib/commands/download/mod.rs.html b/api/src/jellytau_lib/commands/download/mod.rs.html new file mode 100644 index 00000000..afaa4ac5 --- /dev/null +++ b/api/src/jellytau_lib/commands/download/mod.rs.html @@ -0,0 +1,3459 @@ +mod.rs - sourceSkip to main content

jellytau_lib/commands/download/
mod.rs

1//! Tauri commands for download operations
+2
+3#[cfg(test)]
+4use crate::utils::lock::MutexSafe;
+5use log::{debug, error, info, warn};
+6use std::path::{Component, Path, PathBuf};
+7use std::sync::{Arc, Mutex};
+8use tauri::{Manager, State};
+9
+10use super::{DatabaseWrapper, SmartCacheWrapper};
+11use crate::download::network::{NetworkState, NetworkStateHandle, NetworkType};
+12use crate::download::{DownloadInfo, DownloadManager};
+13use crate::storage::db_service::{DatabaseService, Query, QueryParam};
+14
+15// Cohesive command clusters in their own submodules, re-exported so the command
+16// names remain at `commands::download::*` (invoke_handler unchanged).
+17mod pinning;
+18mod smart_cache;
+19pub use pinning::*;
+20pub use smart_cache::*;
+21
+22/// One row of the series episode listing used when queueing a whole series:
+23/// `(id, name, season_name, index_number, parent_index_number)`.
+24type EpisodeRow = (String, String, Option<String>, Option<i32>, Option<i32>);
+25
+26/// Wrapper for DownloadManager to be used as Tauri state
+27pub struct DownloadManagerWrapper(pub Mutex<DownloadManager>);
+28
+29/// Wrapper for the current network transport, used by the WiFi-only gate.
+30///
+31/// TRACES: UR-053 | DR-074
+32pub struct NetworkStateWrapper(pub NetworkStateHandle);
+33
+34/// Report the device's current network transport (Android → Rust).
+35///
+36/// The frontend calls this on startup and whenever the native network callback
+37/// fires. Updating to an acceptable network re-pumps the download queue, so a
+38/// queue parked on "waiting for WiFi" drains itself without user action.
+39///
+40/// TRACES: UR-053 | DR-074
+41#[tauri::command]
+42#[specta::specta]
+43pub async fn set_network_state(
+44    app: tauri::AppHandle,
+45    network: NetworkStateWrapperArg,
+46    db: State<'_, DatabaseWrapper>,
+47    download_manager: State<'_, DownloadManagerWrapper>,
+48) -> Result<(), String> {
+49    let new_state = NetworkState {
+50        network_type: network.network_type,
+51        unmetered: network.unmetered,
+52    };
+53
+54    let handle = app.state::<NetworkStateWrapper>().0.clone();
+55    let previous = handle.get().await;
+56    handle.set(new_state).await;
+57
+58    if previous != new_state {
+59        info!(
+60            "[network] Transport changed: {:?} (unmetered={}) -> {:?} (unmetered={})",
+61            previous.network_type, previous.unmetered, new_state.network_type, new_state.unmetered
+62        );
+63    }
+64
+65    // If the new network unblocks the gate, drain whatever was waiting.
+66    if downloads_allowed_on_current_network(&app).await {
+67        let db_service = {
+68            let database = db.0.lock().map_err(|e| e.to_string())?;
+69            Arc::new(database.service())
+70        };
+71        let active = {
+72            let manager = download_manager.0.lock().map_err(|e| e.to_string())?;
+73            manager.get_active_downloads()
+74        };
+75        pump_download_queue(app.clone(), db_service, active).await;
+76    }
+77
+78    Ok(())
+79}
+80
+81/// Argument struct for [`set_network_state`].
+82///
+83/// TRACES: UR-053 | DR-074
+84#[derive(specta::Type, Debug, Clone, serde::Serialize, serde::Deserialize)]
+85#[serde(rename_all = "camelCase")]
+86pub struct NetworkStateWrapperArg {
+87    pub network_type: NetworkType,
+88    pub unmetered: bool,
+89}
+90
+91/// Whether downloads are currently permitted by the WiFi-only gate.
+92///
+93/// The downloads UI uses this to render "Waiting for WiFi" on pending rows
+94/// rather than leaving them looking silently stuck.
+95///
+96/// TRACES: UR-053 | DR-074
+97#[tauri::command]
+98#[specta::specta]
+99pub async fn get_downloads_allowed(app: tauri::AppHandle) -> Result<bool, String> {
+100    Ok(downloads_allowed_on_current_network(&app).await)
+101}
+102
+103/// Download statistics computed server-side
+104#[allow(dead_code)]
+105#[derive(specta::Type, Debug, Clone, serde::Serialize, serde::Deserialize)]
+106#[serde(rename_all = "camelCase")]
+107pub struct DownloadStats {
+108    pub total: usize,
+109    pub active_count: usize,
+110    pub queued_count: usize,
+111    pub completed_count: usize,
+112    pub failed_count: usize,
+113    pub paused_count: usize,
+114}
+115
+116/// Enhanced response with pre-computed stats
+117#[allow(dead_code)]
+118#[derive(specta::Type, Debug, Clone, serde::Serialize, serde::Deserialize)]
+119#[serde(rename_all = "camelCase")]
+120pub struct DownloadsResponse {
+121    pub downloads: Vec<DownloadInfo>,
+122    pub stats: DownloadStats,
+123}
+124
+125/// Sanitize filename by removing invalid characters
+126fn sanitize_filename(name: &str) -> String {
+127    name.chars()
+128        .map(|c| match c {
+129            '/' | '\\' | ':' | '*' | '?' | '"' | '<' | '>' | '|' => '_',
+130            _ => c,
+131        })
+132        .collect()
+133}
+134
+135/// The directory every download has to stay inside: the storage root
+136/// `storage_get_path` hands the frontend, which is the database's parent.
+137///
+138/// TRACES: DR-211 | UT-205
+139fn download_root(db: &DatabaseWrapper) -> Result<PathBuf, String> {
+140    let database = db.0.lock().map_err(|e| e.to_string())?;
+141    database
+142        .path()
+143        .parent()
+144        .map(|p| p.to_path_buf())
+145        .ok_or_else(|| "Database path has no parent directory".to_string())
+146}
+147
+148/// Fold `..` out of `candidate` and require what is left to sit inside `root`.
+149///
+150/// Lexical rather than `canonicalize`, the same way `media_server::resolve_path`
+151/// does it: the file usually does not exist yet, so canonicalising would fail on
+152/// the ordinary case. The check has to come *after* the caller's join, because
+153/// `Path::join` drops the base when the joined half is absolute — such a path is
+154/// not folded, it is obeyed, and only the `starts_with` below catches it.
+155///
+156/// TRACES: DR-211 | UT-205
+157fn confine_to_root(root: &Path, candidate: &Path) -> Result<PathBuf, String> {
+158    let mut resolved = PathBuf::new();
+159    for component in candidate.components() {
+160        match component {
+161            Component::ParentDir => {
+162                resolved.pop();
+163            }
+164            Component::CurDir => {}
+165            other => resolved.push(other),
+166        }
+167    }
+168
+169    if resolved.starts_with(root) {
+170        Ok(resolved)
+171    } else {
+172        Err(format!(
+173            "Refusing a download path outside the download directory: {}",
+174            candidate.display()
+175        ))
+176    }
+177}
+178
+179/// Sanitize a queued download's path and confine it to the download directory.
+180///
+181/// Every path the app builds for itself comes back unchanged — files on disk and
+182/// `downloads` rows point at these exact spellings — and [`sanitize_filename`]
+183/// is idempotent, so the already-safe name `download_item_and_start` passes in
+184/// is not sanitized into a second, different one.
+185///
+186/// TRACES: DR-211 | UT-205
+187fn confine_queued_path(root: &Path, file_path: &str) -> Result<String, String> {
+188    let mut sanitized = PathBuf::new();
+189    for component in Path::new(file_path).components() {
+190        match component {
+191            Component::Normal(part) => sanitized.push(sanitize_filename(&part.to_string_lossy())),
+192            // Kept as they are, so `confine_to_root` is the single thing
+193            // deciding whether what they add up to is still inside the root.
+194            other => sanitized.push(other),
+195        }
+196    }
+197
+198    confine_to_root(root, &root.join(&sanitized))?;
+199    Ok(sanitized.to_string_lossy().to_string())
+200}
+201
+202/// Request payload for download_item_and_start (bundled to stay within specta's
+203/// 10-argument command limit).
+204#[derive(Debug, specta::Type, serde::Deserialize)]
+205#[serde(rename_all = "camelCase")]
+206pub struct DownloadItemAndStartRequest {
+207    pub item_id: String,
+208    pub user_id: String,
+209    pub stream_url: String,
+210    pub target_dir: String,
+211    pub item_name: Option<String>,
+212    pub artist_name: Option<String>,
+213    pub album_name: Option<String>,
+214}
+215
+216/// Request payload for download_item.
+217#[derive(Debug, specta::Type, serde::Deserialize)]
+218#[serde(rename_all = "camelCase")]
+219pub struct DownloadItemRequest {
+220    pub item_id: String,
+221    pub user_id: String,
+222    pub file_path: String,
+223    pub mime_type: Option<String>,
+224    pub priority: Option<i32>,
+225    pub item_name: Option<String>,
+226    pub artist_name: Option<String>,
+227    pub album_name: Option<String>,
+228    pub expected_size: Option<i64>,
+229}
+230
+231/// Request payload for download_video.
+232#[derive(Debug, specta::Type, serde::Deserialize)]
+233#[serde(rename_all = "camelCase")]
+234pub struct DownloadVideoRequest {
+235    pub item_id: String,
+236    pub user_id: String,
+237    pub file_path: String,
+238    pub mime_type: Option<String>,
+239    pub priority: Option<i32>,
+240    pub item_name: Option<String>,
+241    pub quality_preset: Option<String>,
+242    pub series_name: Option<String>,
+243    pub season_name: Option<String>,
+244    pub episode_number: Option<i32>,
+245    pub season_number: Option<i32>,
+246}
+247
+248/// Queue and start a download in a single atomic operation
+249/// This simplifies the frontend flow by combining multiple steps
+250#[tauri::command]
+251#[specta::specta]
+252pub async fn download_item_and_start(
+253    db: State<'_, DatabaseWrapper>,
+254    smart_cache: State<'_, SmartCacheWrapper>,
+255    download_manager: State<'_, DownloadManagerWrapper>,
+256    app: tauri::AppHandle,
+257    request: DownloadItemAndStartRequest,
+258) -> Result<i64, String> {
+259    let DownloadItemAndStartRequest {
+260        item_id,
+261        user_id,
+262        stream_url,
+263        target_dir,
+264        item_name,
+265        artist_name,
+266        album_name,
+267    } = request;
+268    // Sanitize filename
+269    let safe_name = sanitize_filename(item_name.as_deref().unwrap_or(&item_id));
+270    let file_path = format!("downloads/{}.mp3", safe_name);
+271
+272    // Queue the download
+273    let download_id = download_item(
+274        db.clone(),
+275        smart_cache.clone(),
+276        DownloadItemRequest {
+277            item_id,
+278            user_id,
+279            file_path,
+280            mime_type: None,
+281            priority: None,
+282            item_name,
+283            artist_name,
+284            album_name,
+285            expected_size: None,
+286        },
+287    )
+288    .await?;
+289
+290    // Start the download immediately
+291    start_download(
+292        db,
+293        download_manager,
+294        app,
+295        download_id,
+296        stream_url,
+297        target_dir,
+298    )
+299    .await?;
+300
+301    Ok(download_id)
+302}
+303
+304/// Queue a media item for download
+305#[tauri::command]
+306#[specta::specta]
+307pub async fn download_item(
+308    db: State<'_, DatabaseWrapper>,
+309    smart_cache: State<'_, SmartCacheWrapper>,
+310    request: DownloadItemRequest,
+311) -> Result<i64, String> {
+312    let DownloadItemRequest {
+313        item_id,
+314        user_id,
+315        file_path,
+316        mime_type,
+317        priority,
+318        item_name,
+319        artist_name,
+320        album_name,
+321        expected_size,
+322    } = request;
+323
+324    // `start_download` joins this onto the target directory, and `Path::join`
+325    // drops the base when the second half is absolute, so the row itself has to
+326    // be confined — not only the place it is used. `download_item_and_start`
+327    // sanitizes the name it builds, but `download_item` is a command in its own
+328    // right, so that guard was simply routed around by calling this directly.
+329    // TRACES: DR-211 | UT-205
+330    let file_path = {
+331        let root = download_root(&db)?;
+332        confine_queued_path(&root, &file_path)?
+333    };
+334
+335    let db_service = {
+336        let database = db.0.lock().map_err(|e| e.to_string())?;
+337        Arc::new(database.service())
+338    };
+339
+340    // Check storage limit if size is known
+341    if let Some(size) = expected_size {
+342        // Clone Arc to avoid holding lock during async operations
+343        let cache_arc = {
+344            let guard = smart_cache.0.lock().map_err(|e| e.to_string())?;
+345            guard.clone()
+346        };
+347
+348        // Check if we have space
+349        let can_download = cache_arc
+350            .can_download_async(&db_service, &user_id, size as u64)
+351            .await;
+352
+353        if !can_download {
+354            warn!("Storage limit reached. Attempting to free space...");
+355
+356            // Reclaim expired temporary entries first: they are dead weight, so
+357            // freeing them may avoid evicting cache that is still within its
+358            // life. Best-effort — a failure here just means eviction does more.
+359            // TRACES: UR-071 | DR-127
+360            match cache_arc
+361                .reclaim_expired_async(&db_service, &user_id, &chrono::Utc::now().to_rfc3339())
+362                .await
+363            {
+364                Ok(n) if n > 0 => info!("Reclaimed {} expired cache entries", n),
+365                Ok(_) => {}
+366                Err(e) => warn!("Expired-entry reclaim failed: {}", e),
+367            }
+368
+369            // Try to evict LRU items to make space
+370            match cache_arc
+371                .evict_lru_async(&db_service, &user_id, size as u64)
+372                .await
+373            {
+374                Ok(freed) if freed > 0 => {
+375                    info!("Freed {} bytes, proceeding with download", freed);
+376                }
+377                Ok(_) => {
+378                    let storage_limit =
+379                        cache_arc.get_config().map(|c| c.storage_limit).unwrap_or(0);
+380                    return Err(format!(
+381                        "Storage limit reached ({} bytes). Unable to free enough space.",
+382                        storage_limit
+383                    ));
+384                }
+385                Err(e) => {
+386                    error!("Failed to evict items: {}", e);
+387                    return Err(format!("Storage limit reached. Eviction failed: {}", e));
+388                }
+389            }
+390        }
+391    }
+392
+393    // Insert or update download record with metadata
+394    let insert_query = Query::with_params(
+395        "INSERT INTO downloads (item_id, user_id, file_path, mime_type, status, priority, queued_at, item_name, artist_name, album_name)
+396         VALUES (?, ?, ?, ?, 'pending', ?, CURRENT_TIMESTAMP, ?, ?, ?)
+397         ON CONFLICT(item_id, user_id) DO UPDATE SET
+398           priority = excluded.priority,
+399           status = 'pending',
+400           queued_at = CURRENT_TIMESTAMP,
+401           item_name = COALESCE(excluded.item_name, downloads.item_name),
+402           artist_name = COALESCE(excluded.artist_name, downloads.artist_name),
+403           album_name = COALESCE(excluded.album_name, downloads.album_name)",
+404        vec![
+405            QueryParam::String(item_id.clone()),
+406            QueryParam::String(user_id.clone()),
+407            QueryParam::String(file_path),
+408            mime_type.map(QueryParam::String).unwrap_or(QueryParam::Null),
+409            QueryParam::Int(priority.unwrap_or(0)),
+410            item_name.map(QueryParam::String).unwrap_or(QueryParam::Null),
+411            artist_name.map(QueryParam::String).unwrap_or(QueryParam::Null),
+412            album_name.map(QueryParam::String).unwrap_or(QueryParam::Null),
+413        ],
+414    );
+415
+416    db_service
+417        .execute(insert_query)
+418        .await
+419        .map_err(|e| e.to_string())?;
+420
+421    // Query for the download ID by unique constraint columns
+422    // NOTE: last_insert_rowid() doesn't work reliably with UPSERT - it only updates on INSERT, not UPDATE
+423    let id_query = Query::with_params(
+424        "SELECT id FROM downloads WHERE item_id = ? AND user_id = ?",
+425        vec![QueryParam::String(item_id), QueryParam::String(user_id)],
+426    );
+427
+428    let download_id: i64 = db_service
+429        .query_one(id_query, |row| row.get(0))
+430        .await
+431        .map_err(|e| e.to_string())?;
+432
+433    Ok(download_id)
+434}
+435
+436/// One track of an album, as the album-download path queues it.
+437///
+438/// `artist_name` carries whatever the catalog holds for the track's artists (a
+439/// JSON array, as stored on `items.artists`); it is display metadata for the
+440/// downloads list, not a lookup key.
+441///
+442/// TRACES: UR-018, UR-055 | DR-173
+443#[derive(Debug, Clone, PartialEq)]
+444pub(crate) struct AlbumTrack {
+445    pub id: String,
+446    pub name: String,
+447    pub artist_name: Option<String>,
+448    pub album_name: Option<String>,
+449    pub index_number: Option<i32>,
+450}
+451
+452impl From<&crate::repository::types::MediaItem> for AlbumTrack {
+453    fn from(item: &crate::repository::types::MediaItem) -> Self {
+454        Self {
+455            id: item.id.clone(),
+456            name: item.name.clone(),
+457            artist_name: item
+458                .artists
+459                .as_ref()
+460                .and_then(|a| serde_json::to_string(a).ok()),
+461            album_name: item.album_name.clone(),
+462            index_number: item.index_number,
+463        }
+464    }
+465}
+466
+467/// The album's tracks as the local catalog cache knows them.
+468///
+469/// Only a fallback for [`download_album`]: the cache links a track to its album
+470/// through `items.album_id`, which Jellyfin does not populate on every listing
+471/// endpoint, so this can legitimately return fewer tracks than the album has.
+472///
+473/// TRACES: UR-018, UR-055 | DR-173
+474pub(crate) async fn cached_album_tracks(
+475    db_service: &Arc<crate::storage::db_service::RusqliteService>,
+476    album_id: &str,
+477) -> Result<Vec<AlbumTrack>, String> {
+478    let tracks_query = Query::with_params(
+479        "SELECT id, name, artists, album_name, index_number FROM items
+480         WHERE (album_id = ? OR parent_id = ?) AND item_type = 'Audio'
+481         ORDER BY index_number",
+482        vec![
+483            QueryParam::String(album_id.to_string()),
+484            QueryParam::String(album_id.to_string()),
+485        ],
+486    );
+487
+488    db_service
+489        .query_many(tracks_query, |row| {
+490            Ok(AlbumTrack {
+491                id: row.get(0)?,
+492                name: row.get(1)?,
+493                artist_name: row.get(2)?,
+494                album_name: row.get(3)?,
+495                index_number: row.get(4)?,
+496            })
+497        })
+498        .await
+499        .map_err(|e| e.to_string())
+500}
+501
+502/// Queue one download row per track and link every track to its album.
+503///
+504/// The linkage is the half that is easy to miss: offline browsing joins a track
+505/// to its album on `items.album_id` (see `OfflineRepository::get_items`), so a
+506/// track whose cached row lacks it stays invisible under the album even after
+507/// its file is on disk. Queuing a track *is* the statement that it belongs to
+508/// this album, so the link is written here rather than hoped for from whichever
+509/// listing endpoint happened to cache the row.
+510///
+511/// Idempotent: re-queuing an album fills in what is missing and returns the same
+512/// row ids, in the order the tracks were given.
+513///
+514/// A file name per track, unique within the album.
+515///
+516/// A title is not a unique name inside its own album: a deluxe edition carries
+517/// the album version and a demo of the same song, and a two-disc set repeats
+518/// titles across discs. Naming files after the title alone gave those tracks one
+519/// path, and each download overwrote the previous one — an album that quietly
+520/// ends up short by however many titles it repeats. The track number
+521/// disambiguates the ordinary case; anything still colliding falls back to the
+522/// item id, which is unique by construction.
+523///
+524/// TRACES: UR-018, UR-055 | DR-173 | UT-172
+525pub(crate) fn album_file_names(tracks: &[AlbumTrack]) -> Vec<String> {
+526    let mut counts: std::collections::HashMap<String, usize> = std::collections::HashMap::new();
+527    for track in tracks {
+528        *counts.entry(track.name.to_lowercase()).or_default() += 1;
+529    }
+530
+531    tracks
+532        .iter()
+533        .map(|track| {
+534            let title = sanitize_filename(&track.name);
+535            if counts.get(&track.name.to_lowercase()).copied().unwrap_or(0) <= 1 {
+536                return format!("{}.mp3", title);
+537            }
+538            match track.index_number {
+539                Some(n) => format!("{:02} - {} [{}].mp3", n, title, track.id),
+540                None => format!("{} [{}].mp3", title, track.id),
+541            }
+542        })
+543        .collect()
+544}
+545
+546/// TRACES: UR-018, UR-055 | DR-173 | UT-170
+547pub(crate) async fn queue_album_tracks(
+548    db_service: &Arc<crate::storage::db_service::RusqliteService>,
+549    album_id: &str,
+550    tracks: &[AlbumTrack],
+551    user_id: &str,
+552    base_path: &str,
+553) -> Result<Vec<i64>, String> {
+554    let mut download_ids = Vec::with_capacity(tracks.len());
+555    let file_names = album_file_names(tracks);
+556
+557    for (track, file_name) in tracks.iter().zip(file_names) {
+558        // Cache a row for a track the catalog has never seen, borrowing the
+559        // album's server. Nothing is inserted when the album itself is unknown,
+560        // which also keeps the parent_id foreign key satisfiable.
+561        let cache_query = Query::with_params(
+562            "INSERT OR IGNORE INTO items
+563                (id, server_id, parent_id, name, item_type, album_id, album_name, artists, index_number)
+564             SELECT ?, a.server_id, a.id, ?, 'Audio', a.id, ?, ?, ?
+565             FROM items a WHERE a.id = ?",
+566            vec![
+567                QueryParam::String(track.id.clone()),
+568                QueryParam::String(track.name.clone()),
+569                track
+570                    .album_name
+571                    .clone()
+572                    .map(QueryParam::String)
+573                    .unwrap_or(QueryParam::Null),
+574                track
+575                    .artist_name
+576                    .clone()
+577                    .map(QueryParam::String)
+578                    .unwrap_or(QueryParam::Null),
+579                track
+580                    .index_number
+581                    .map(QueryParam::Int)
+582                    .unwrap_or(QueryParam::Null),
+583                QueryParam::String(album_id.to_string()),
+584            ],
+585        );
+586        db_service
+587            .execute(cache_query)
+588            .await
+589            .map_err(|e| e.to_string())?;
+590
+591        // Link an already-cached track to the album. The parent_id subquery
+592        // resolves to NULL when the album is not cached, so the foreign key
+593        // holds either way.
+594        let link_query = Query::with_params(
+595            "UPDATE items
+596                SET album_id = ?,
+597                    parent_id = COALESCE(parent_id, (SELECT id FROM items WHERE id = ?))
+598              WHERE id = ?",
+599            vec![
+600                QueryParam::String(album_id.to_string()),
+601                QueryParam::String(album_id.to_string()),
+602                QueryParam::String(track.id.clone()),
+603            ],
+604        );
+605        db_service
+606            .execute(link_query)
+607            .await
+608            .map_err(|e| e.to_string())?;
+609
+610        let file_path = format!("{}/{}", base_path, file_name);
+611
+612        // Queue at album priority (100). A track already downloaded stays
+613        // completed — re-queuing an album must fill the gaps, not re-fetch it.
+614        let insert_query = Query::with_params(
+615            "INSERT INTO downloads (item_id, user_id, file_path, status, priority, queued_at, item_name, artist_name, album_name, media_type)
+616             VALUES (?, ?, ?, 'pending', 100, CURRENT_TIMESTAMP, ?, ?, ?, 'audio')
+617             ON CONFLICT(item_id, user_id) DO UPDATE SET
+618               priority = 100,
+619               status = CASE WHEN downloads.status = 'completed' THEN 'completed' ELSE 'pending' END,
+620               media_type = 'audio',
+621               item_name = COALESCE(excluded.item_name, downloads.item_name),
+622               artist_name = COALESCE(excluded.artist_name, downloads.artist_name),
+623               album_name = COALESCE(excluded.album_name, downloads.album_name)",
+624            vec![
+625                QueryParam::String(track.id.clone()),
+626                QueryParam::String(user_id.to_string()),
+627                QueryParam::String(file_path),
+628                QueryParam::String(track.name.clone()),
+629                track
+630                    .artist_name
+631                    .clone()
+632                    .map(QueryParam::String)
+633                    .unwrap_or(QueryParam::Null),
+634                track
+635                    .album_name
+636                    .clone()
+637                    .map(QueryParam::String)
+638                    .unwrap_or(QueryParam::Null),
+639            ],
+640        );
+641
+642        db_service
+643            .execute(insert_query)
+644            .await
+645            .map_err(|e| e.to_string())?;
+646
+647        // Query for the actual download ID (last_insert_rowid doesn't work with UPSERT)
+648        let id_query = Query::with_params(
+649            "SELECT id FROM downloads WHERE item_id = ? AND user_id = ?",
+650            vec![
+651                QueryParam::String(track.id.clone()),
+652                QueryParam::String(user_id.to_string()),
+653            ],
+654        );
+655
+656        let download_id: i64 = db_service
+657            .query_one(id_query, |row| row.get(0))
+658            .await
+659            .map_err(|e| e.to_string())?;
+660        download_ids.push(download_id);
+661    }
+662
+663    Ok(download_ids)
+664}
+665
+666/// Queue an entire album for download.
+667///
+668/// Owns the whole operation: the album's track list comes from the server (the
+669/// only place that knows all of it), every track is queued and linked to its
+670/// album, each row's stream URL is resolved here, and the queue is pumped.
+671///
+672/// The frontend used to do the second half — resolve one URL per track and pair
+673/// it with the returned ids **by position**. That pairing had no basis: the ids
+674/// came back in the backend's own order over a different set of rows, so
+675/// whenever the two lists disagreed a row was handed another track's URL, and
+676/// any track past the end of the shorter list was never started at all. Nothing
+677/// crosses the boundary now except the album id.
+678///
+679/// TRACES: UR-018, UR-055 | DR-173 | UT-170
+680#[tauri::command]
+681#[specta::specta]
+682// Three of the eight arguments are Tauri `State<'_, _>` injections plus the
+683// `AppHandle`, not caller input. Folding the rest into a struct would change the
+684// IPC contract and the generated TypeScript for no readability gain.
+685#[allow(clippy::too_many_arguments)]
+686pub async fn download_album(
+687    db: State<'_, DatabaseWrapper>,
+688    repository: State<'_, crate::commands::repository::RepositoryManagerWrapper>,
+689    download_manager: State<'_, DownloadManagerWrapper>,
+690    app: tauri::AppHandle,
+691    handle: String,
+692    album_id: String,
+693    user_id: String,
+694    base_path: String,
+695) -> Result<Vec<i64>, String> {
+696    let db_service = {
+697        let database = db.0.lock().map_err(|e| e.to_string())?;
+698        Arc::new(database.service())
+699    };
+700
+701    let repo = repository.0.get(&handle);
+702
+703    // Ask the server what the album contains; the cache is only a fallback for
+704    // when it cannot answer.
+705    let tracks: Vec<AlbumTrack> = match &repo {
+706        Some(repo) => match repo.get_album_tracks(&album_id).await {
+707            Ok(items) if !items.is_empty() => items.iter().map(AlbumTrack::from).collect(),
+708            Ok(_) => cached_album_tracks(&db_service, &album_id).await?,
+709            Err(e) => {
+710                warn!(
+711                    "[download_album] Could not list album {} from the repository ({:?}); \
+712                     falling back to the cached track list",
+713                    album_id, e
+714                );
+715                cached_album_tracks(&db_service, &album_id).await?
+716            }
+717        },
+718        None => cached_album_tracks(&db_service, &album_id).await?,
+719    };
+720
+721    if tracks.is_empty() {
+722        warn!("[download_album] No tracks found for album {}", album_id);
+723        return Ok(Vec::new());
+724    }
+725
+726    let download_ids =
+727        queue_album_tracks(&db_service, &album_id, &tracks, &user_id, &base_path).await?;
+728
+729    info!(
+730        "[download_album] Queued {} track(s) for album {}",
+731        download_ids.len(),
+732        album_id
+733    );
+734
+735    // Resolve each queued row's stream URL here, then pump. Without a
+736    // repository (or while offline) the rows stay pending with no URL and
+737    // `resume_queued_downloads` picks them up on reconnect.
+738    let Some(repo) = repo else {
+739        return Ok(download_ids);
+740    };
+741
+742    let target_dir = {
+743        let database = db.0.lock().map_err(|e| e.to_string())?;
+744        database
+745            .path()
+746            .parent()
+747            .ok_or_else(|| "Database path has no parent directory".to_string())?
+748            .to_string_lossy()
+749            .to_string()
+750    };
+751
+752    let repo_for_resolve = Arc::clone(&repo);
+753    let outcome = crate::commands::catalog::resolve_pending_download_urls(
+754        &db_service,
+755        &target_dir,
+756        Some(&download_ids),
+757        move |item_id: String, _media_type: String, _quality: String| {
+758            let repo = Arc::clone(&repo_for_resolve);
+759            async move {
+760                use crate::repository::MediaRepository;
+761                match repo.get_audio_stream_url(&item_id).await {
+762                    Ok(url) => Some(url),
+763                    Err(e) => {
+764                        warn!(
+765                            "[download_album] Failed to resolve stream URL for {}: {:?}",
+766                            item_id, e
+767                        );
+768                        None
+769                    }
+770                }
+771            }
+772        },
+773    )
+774    .await?;
+775
+776    if outcome.failed > 0 {
+777        warn!(
+778            "[download_album] {} track(s) could not be resolved and stay queued for the next \
+779             reconnect",
+780            outcome.failed
+781        );
+782    }
+783
+784    let active_downloads = {
+785        let manager = download_manager.0.lock().map_err(|e| e.to_string())?;
+786        manager.get_active_downloads()
+787    };
+788    pump_download_queue(app, db_service, active_downloads).await;
+789
+790    Ok(download_ids)
+791}
+792
+793/// Queue a video item (movie or episode) for download with quality preset
+794#[tauri::command]
+795#[specta::specta]
+796pub async fn download_video(
+797    db: State<'_, DatabaseWrapper>,
+798    request: DownloadVideoRequest,
+799) -> Result<i64, String> {
+800    let DownloadVideoRequest {
+801        item_id,
+802        user_id,
+803        file_path,
+804        mime_type,
+805        priority,
+806        item_name,
+807        quality_preset,
+808        series_name,
+809        season_name,
+810        episode_number,
+811        season_number,
+812    } = request;
+813    let db_service = {
+814        let database = db.0.lock().map_err(|e| e.to_string())?;
+815        Arc::new(database.service())
+816    };
+817
+818    let quality = quality_preset.unwrap_or_else(|| "original".to_string());
+819
+820    // Insert or update download record with video metadata
+821    let insert_query = Query::with_params(
+822        "INSERT INTO downloads (item_id, user_id, file_path, mime_type, status, priority, queued_at,
+823                                item_name, quality_preset, media_type, series_name, season_name,
+824                                episode_number, season_number)
+825         VALUES (?, ?, ?, ?, 'pending', ?, CURRENT_TIMESTAMP, ?, ?, 'video', ?, ?, ?, ?)
+826         ON CONFLICT(item_id, user_id) DO UPDATE SET
+827           priority = excluded.priority,
+828           status = 'pending',
+829           queued_at = CURRENT_TIMESTAMP,
+830           quality_preset = excluded.quality_preset,
+831           item_name = COALESCE(excluded.item_name, downloads.item_name),
+832           series_name = COALESCE(excluded.series_name, downloads.series_name),
+833           season_name = COALESCE(excluded.season_name, downloads.season_name),
+834           episode_number = COALESCE(excluded.episode_number, downloads.episode_number),
+835           season_number = COALESCE(excluded.season_number, downloads.season_number)",
+836        vec![
+837            QueryParam::String(item_id.clone()),
+838            QueryParam::String(user_id.clone()),
+839            QueryParam::String(file_path),
+840            mime_type.map(QueryParam::String).unwrap_or(QueryParam::Null),
+841            QueryParam::Int(priority.unwrap_or(0)),
+842            item_name.map(QueryParam::String).unwrap_or(QueryParam::Null),
+843            QueryParam::String(quality),
+844            series_name.map(QueryParam::String).unwrap_or(QueryParam::Null),
+845            season_name.map(QueryParam::String).unwrap_or(QueryParam::Null),
+846            episode_number.map(QueryParam::Int).unwrap_or(QueryParam::Null),
+847            season_number.map(QueryParam::Int).unwrap_or(QueryParam::Null),
+848        ],
+849    );
+850
+851    db_service
+852        .execute(insert_query)
+853        .await
+854        .map_err(|e| e.to_string())?;
+855
+856    // Query for the download ID by unique constraint columns
+857    let id_query = Query::with_params(
+858        "SELECT id FROM downloads WHERE item_id = ? AND user_id = ?",
+859        vec![QueryParam::String(item_id), QueryParam::String(user_id)],
+860    );
+861
+862    let download_id: i64 = db_service
+863        .query_one(id_query, |row| row.get(0))
+864        .await
+865        .map_err(|e| e.to_string())?;
+866
+867    Ok(download_id)
+868}
+869
+870/// Queue all episodes of a series for download
+871#[tauri::command]
+872#[specta::specta]
+873pub async fn download_series(
+874    db: State<'_, DatabaseWrapper>,
+875    series_id: String,
+876    series_name: String,
+877    user_id: String,
+878    base_path: String,
+879    quality_preset: Option<String>,
+880) -> Result<Vec<i64>, String> {
+881    let db_service = {
+882        let database = db.0.lock().map_err(|e| e.to_string())?;
+883        Arc::new(database.service())
+884    };
+885
+886    let quality = quality_preset.unwrap_or_else(|| "original".to_string());
+887
+888    // Get all episodes for this series, ordered by season and episode number
+889    let episodes_query = Query::with_params(
+890        "SELECT id, name, season_name, index_number, parent_index_number
+891         FROM items
+892         WHERE series_id = ? AND item_type = 'Episode'
+893         ORDER BY parent_index_number, index_number",
+894        vec![QueryParam::String(series_id)],
+895    );
+896
+897    let episodes: Vec<EpisodeRow> = db_service
+898        .query_many(episodes_query, |row| {
+899            Ok((
+900                row.get(0)?,
+901                row.get(1)?,
+902                row.get(2)?,
+903                row.get(3)?,
+904                row.get(4)?,
+905            ))
+906        })
+907        .await
+908        .map_err(|e| e.to_string())?;
+909
+910    let mut download_ids = Vec::new();
+911
+912    // Queue each episode with descending priority (first episodes download first)
+913    // Priority starts high and decreases so earlier episodes finish first
+914    let total_episodes = episodes.len() as i32;
+915    for (idx, (episode_id, episode_name, season_name, episode_number, season_number)) in
+916        episodes.into_iter().enumerate()
+917    {
+918        let priority = 1000 - idx as i32; // High priority for first episodes
+919
+920        // Create path like: videos/SeriesName/S01E01_Title.mp4
+921        let season_num = season_number.unwrap_or(1);
+922        let episode_num = episode_number.unwrap_or(1);
+923        let file_name = format!(
+924            "S{:02}E{:02}_{}.mp4",
+925            season_num,
+926            episode_num,
+927            sanitize_filename(&episode_name)
+928        );
+929        let file_path = format!(
+930            "{}/{}/{}",
+931            base_path,
+932            sanitize_filename(&series_name),
+933            file_name
+934        );
+935
+936        let insert_query = Query::with_params(
+937            "INSERT INTO downloads (item_id, user_id, file_path, status, priority, queued_at,
+938                                    item_name, quality_preset, media_type, series_name, season_name,
+939                                    episode_number, season_number)
+940             VALUES (?, ?, ?, 'pending', ?, CURRENT_TIMESTAMP, ?, ?, 'video', ?, ?, ?, ?)
+941             ON CONFLICT(item_id, user_id) DO UPDATE SET
+942               priority = excluded.priority,
+943               status = 'pending',
+944               queued_at = CURRENT_TIMESTAMP,
+945               quality_preset = excluded.quality_preset,
+946               item_name = COALESCE(excluded.item_name, downloads.item_name),
+947               series_name = COALESCE(excluded.series_name, downloads.series_name),
+948               season_name = COALESCE(excluded.season_name, downloads.season_name),
+949               episode_number = COALESCE(excluded.episode_number, downloads.episode_number),
+950               season_number = COALESCE(excluded.season_number, downloads.season_number)",
+951            vec![
+952                QueryParam::String(episode_id.clone()),
+953                QueryParam::String(user_id.clone()),
+954                QueryParam::String(file_path),
+955                QueryParam::Int(priority),
+956                QueryParam::String(episode_name),
+957                QueryParam::String(quality.clone()),
+958                QueryParam::String(series_name.clone()),
+959                season_name
+960                    .map(QueryParam::String)
+961                    .unwrap_or(QueryParam::Null),
+962                episode_number
+963                    .map(QueryParam::Int)
+964                    .unwrap_or(QueryParam::Null),
+965                season_number
+966                    .map(QueryParam::Int)
+967                    .unwrap_or(QueryParam::Null),
+968            ],
+969        );
+970
+971        db_service
+972            .execute(insert_query)
+973            .await
+974            .map_err(|e| e.to_string())?;
+975
+976        let id_query = Query::with_params(
+977            "SELECT id FROM downloads WHERE item_id = ? AND user_id = ?",
+978            vec![
+979                QueryParam::String(episode_id),
+980                QueryParam::String(user_id.clone()),
+981            ],
+982        );
+983
+984        let download_id: i64 = db_service
+985            .query_one(id_query, |row| row.get(0))
+986            .await
+987            .map_err(|e| e.to_string())?;
+988
+989        download_ids.push(download_id);
+990    }
+991
+992    info!(
+993        "[download_series] Queued {} episodes for series '{}'",
+994        total_episodes, series_name
+995    );
+996    Ok(download_ids)
+997}
+998
+999/// Queue all episodes of a specific season for download
+1000#[tauri::command]
+1001#[specta::specta]
+1002// One of the eight arguments is a Tauri `State<'_, _>` injection; the rest are
+1003// the season's identifying fields. Folding them into a struct would change the
+1004// IPC contract and the generated TypeScript for no readability gain.
+1005#[allow(clippy::too_many_arguments)]
+1006pub async fn download_season(
+1007    db: State<'_, DatabaseWrapper>,
+1008    season_id: String,
+1009    series_name: String,
+1010    season_name: String,
+1011    season_number: i32,
+1012    user_id: String,
+1013    base_path: String,
+1014    quality_preset: Option<String>,
+1015) -> Result<Vec<i64>, String> {
+1016    let db_service = {
+1017        let database = db.0.lock().map_err(|e| e.to_string())?;
+1018        Arc::new(database.service())
+1019    };
+1020
+1021    let quality = quality_preset.unwrap_or_else(|| "original".to_string());
+1022
+1023    // Get all episodes for this season, ordered by episode number
+1024    let episodes_query = Query::with_params(
+1025        "SELECT id, name, index_number
+1026         FROM items
+1027         WHERE parent_id = ? AND item_type = 'Episode'
+1028         ORDER BY index_number",
+1029        vec![QueryParam::String(season_id)],
+1030    );
+1031
+1032    let episodes: Vec<(String, String, Option<i32>)> = db_service
+1033        .query_many(episodes_query, |row| {
+1034            Ok((row.get(0)?, row.get(1)?, row.get(2)?))
+1035        })
+1036        .await
+1037        .map_err(|e| e.to_string())?;
+1038
+1039    let mut download_ids = Vec::new();
+1040
+1041    // Queue each episode with priority based on episode number
+1042    for (idx, (episode_id, episode_name, episode_number)) in episodes.into_iter().enumerate() {
+1043        let priority = 1000 - idx as i32;
+1044        let episode_num = episode_number.unwrap_or(1);
+1045
+1046        let file_name = format!(
+1047            "S{:02}E{:02}_{}.mp4",
+1048            season_number,
+1049            episode_num,
+1050            sanitize_filename(&episode_name)
+1051        );
+1052        let file_path = format!(
+1053            "{}/{}/{}",
+1054            base_path,
+1055            sanitize_filename(&series_name),
+1056            file_name
+1057        );
+1058
+1059        let insert_query = Query::with_params(
+1060            "INSERT INTO downloads (item_id, user_id, file_path, status, priority, queued_at,
+1061                                    item_name, quality_preset, media_type, series_name, season_name,
+1062                                    episode_number, season_number)
+1063             VALUES (?, ?, ?, 'pending', ?, CURRENT_TIMESTAMP, ?, ?, 'video', ?, ?, ?, ?)
+1064             ON CONFLICT(item_id, user_id) DO UPDATE SET
+1065               priority = excluded.priority,
+1066               status = 'pending',
+1067               queued_at = CURRENT_TIMESTAMP,
+1068               quality_preset = excluded.quality_preset",
+1069            vec![
+1070                QueryParam::String(episode_id.clone()),
+1071                QueryParam::String(user_id.clone()),
+1072                QueryParam::String(file_path),
+1073                QueryParam::Int(priority),
+1074                QueryParam::String(episode_name),
+1075                QueryParam::String(quality.clone()),
+1076                QueryParam::String(series_name.clone()),
+1077                QueryParam::String(season_name.clone()),
+1078                QueryParam::Int(episode_num),
+1079                QueryParam::Int(season_number),
+1080            ],
+1081        );
+1082
+1083        db_service
+1084            .execute(insert_query)
+1085            .await
+1086            .map_err(|e| e.to_string())?;
+1087
+1088        let id_query = Query::with_params(
+1089            "SELECT id FROM downloads WHERE item_id = ? AND user_id = ?",
+1090            vec![
+1091                QueryParam::String(episode_id),
+1092                QueryParam::String(user_id.clone()),
+1093            ],
+1094        );
+1095
+1096        let download_id: i64 = db_service
+1097            .query_one(id_query, |row| row.get(0))
+1098            .await
+1099            .map_err(|e| e.to_string())?;
+1100
+1101        download_ids.push(download_id);
+1102    }
+1103
+1104    info!(
+1105        "[download_season] Queued {} episodes for {} - {}",
+1106        download_ids.len(),
+1107        series_name,
+1108        season_name
+1109    );
+1110    Ok(download_ids)
+1111}
+1112
+1113/// Helper to compute download statistics from a list of downloads
+1114#[allow(dead_code)]
+1115fn compute_download_stats(downloads: &[DownloadInfo]) -> DownloadStats {
+1116    let mut stats = DownloadStats {
+1117        total: downloads.len(),
+1118        active_count: 0,
+1119        queued_count: 0,
+1120        completed_count: 0,
+1121        failed_count: 0,
+1122        paused_count: 0,
+1123    };
+1124
+1125    for download in downloads {
+1126        match download.status.as_str() {
+1127            "downloading" => stats.active_count += 1,
+1128            "pending" => stats.queued_count += 1,
+1129            "completed" => stats.completed_count += 1,
+1130            "failed" => stats.failed_count += 1,
+1131            "paused" => stats.paused_count += 1,
+1132            _ => {}
+1133        }
+1134    }
+1135
+1136    stats
+1137}
+1138
+1139/// Get all downloads for a user, optionally filtered by status
+1140#[tauri::command]
+1141#[specta::specta]
+1142pub async fn get_downloads(
+1143    db: State<'_, DatabaseWrapper>,
+1144    user_id: String,
+1145    status_filter: Option<Vec<String>>,
+1146) -> Result<DownloadsResponse, String> {
+1147    let db_service = {
+1148        let database = db.0.lock().map_err(|e| e.to_string())?;
+1149        Arc::new(database.service())
+1150    };
+1151
+1152    // Use COALESCE to prefer stored metadata over LEFT JOIN results
+1153    // This ensures correct display even when items aren't synced locally
+1154    let (query_str, params) = if let Some(ref statuses) = status_filter {
+1155        let placeholders = statuses.iter().map(|_| "?").collect::<Vec<_>>().join(",");
+1156        let sql = format!(
+1157            "SELECT d.id, d.item_id, d.user_id, d.file_path, d.file_size, d.mime_type, d.status, d.progress,
+1158                    d.bytes_downloaded, d.queued_at, d.started_at, d.completed_at, d.error_message,
+1159                    d.retry_count, d.priority,
+1160                    COALESCE(d.item_name, i.name) as item_name,
+1161                    COALESCE(d.artist_name, i.artists) as artist_name,
+1162                    COALESCE(d.album_name, i.album_name) as album_name,
+1163                    COALESCE(d.series_name, i.series_name) as series_name,
+1164                    COALESCE(d.season_name, i.season_name) as season_name,
+1165                    COALESCE(d.episode_number, i.index_number) as episode_number,
+1166                    COALESCE(d.season_number, i.parent_index_number) as season_number,
+1167                    d.quality_preset,
+1168                    COALESCE(d.media_type, 'audio') as media_type,
+1169                    COALESCE(d.download_source, 'user') as download_source
+1170             FROM downloads d
+1171             LEFT JOIN items i ON d.item_id = i.id
+1172             WHERE d.user_id = ? AND d.status IN ({})
+1173             ORDER BY d.priority DESC, d.queued_at ASC",
+1174            placeholders
+1175        );
+1176        let mut p = vec![QueryParam::String(user_id.clone())];
+1177        p.extend(statuses.iter().map(|s| QueryParam::String(s.clone())));
+1178        (sql, p)
+1179    } else {
+1180        let sql = "SELECT d.id, d.item_id, d.user_id, d.file_path, d.file_size, d.mime_type, d.status, d.progress,
+1181                d.bytes_downloaded, d.queued_at, d.started_at, d.completed_at, d.error_message,
+1182                d.retry_count, d.priority,
+1183                COALESCE(d.item_name, i.name) as item_name,
+1184                COALESCE(d.artist_name, i.artists) as artist_name,
+1185                COALESCE(d.album_name, i.album_name) as album_name,
+1186                COALESCE(d.series_name, i.series_name) as series_name,
+1187                COALESCE(d.season_name, i.season_name) as season_name,
+1188                COALESCE(d.episode_number, i.index_number) as episode_number,
+1189                COALESCE(d.season_number, i.parent_index_number) as season_number,
+1190                d.quality_preset,
+1191                COALESCE(d.media_type, 'audio') as media_type,
+1192                COALESCE(d.download_source, 'user') as download_source
+1193         FROM downloads d
+1194         LEFT JOIN items i ON d.item_id = i.id
+1195         WHERE d.user_id = ?
+1196         ORDER BY d.priority DESC, d.queued_at ASC"
+1197            .to_string();
+1198        (sql, vec![QueryParam::String(user_id)])
+1199    };
+1200
+1201    let query = Query::with_params(query_str, params);
+1202
+1203    let downloads = db_service
+1204        .query_many(query, map_download_row)
+1205        .await
+1206        .map_err(|e| e.to_string())?;
+1207
+1208    // Compute stats in single pass
+1209    let stats = compute_download_stats(&downloads);
+1210
+1211    Ok(DownloadsResponse { downloads, stats })
+1212}
+1213
+1214/// Pause a download.
+1215///
+1216/// Writing `status = 'paused'` is only half of it, and used to be all of it: the
+1217/// streaming task knew nothing about the row and kept running, then overwrote it
+1218/// with `completed`/`failed` when it finished. The row flicked to "paused" and
+1219/// undid itself — the reported "pause does not work". Signalling the worker is
+1220/// what actually stops the bytes; it leaves the `.part` file in place so
+1221/// [`resume_download`] can continue from it.
+1222///
+1223/// A queued (not yet started) download has no worker to signal, and the status
+1224/// write alone is enough — the pump skips anything that is not `pending`.
+1225///
+1226/// TRACES: UR-055 | DR-168
+1227#[tauri::command]
+1228#[specta::specta]
+1229pub async fn pause_download(
+1230    db: State<'_, DatabaseWrapper>,
+1231    download_id: i64,
+1232) -> Result<(), String> {
+1233    let db_service = {
+1234        let database = db.0.lock().map_err(|e| e.to_string())?;
+1235        Arc::new(database.service())
+1236    };
+1237
+1238    let query = Query::with_params(
+1239        "UPDATE downloads SET status = 'paused' WHERE id = ? AND status IN ('downloading', 'pending')",
+1240        vec![QueryParam::Int64(download_id)],
+1241    );
+1242
+1243    db_service.execute(query).await.map_err(|e| e.to_string())?;
+1244
+1245    let was_running = crate::download::stop::signal(download_id);
+1246    info!(
+1247        "[pause] Download {} paused (in flight: {})",
+1248        download_id, was_running
+1249    );
+1250    Ok(())
+1251}
+1252
+1253/// Resume a paused download.
+1254///
+1255/// Flipping the row back to `pending` is likewise not enough on its own: the
+1256/// pump is not a poller, it runs when something calls it, so a resumed download
+1257/// sat untouched until some unrelated event happened to pump the queue. That is
+1258/// the other half of "resume does not work".
+1259///
+1260/// TRACES: UR-055 | DR-168
+1261#[tauri::command]
+1262#[specta::specta]
+1263pub async fn resume_download(
+1264    app: tauri::AppHandle,
+1265    db: State<'_, DatabaseWrapper>,
+1266    download_manager: State<'_, DownloadManagerWrapper>,
+1267    download_id: i64,
+1268) -> Result<(), String> {
+1269    let db_service = {
+1270        let database = db.0.lock().map_err(|e| e.to_string())?;
+1271        Arc::new(database.service())
+1272    };
+1273
+1274    let query = Query::with_params(
+1275        "UPDATE downloads SET status = 'pending', error_message = NULL WHERE id = ? AND status IN ('paused', 'failed')",
+1276        vec![QueryParam::Int64(download_id)],
+1277    );
+1278
+1279    db_service.execute(query).await.map_err(|e| e.to_string())?;
+1280
+1281    // Drop any stale stop flag before the pump can start this id again, or the
+1282    // resumed run would read the pause that stopped it and halt immediately.
+1283    crate::download::stop::clear(download_id);
+1284
+1285    let active_downloads = {
+1286        let manager = download_manager.0.lock().map_err(|e| e.to_string())?;
+1287        manager.get_active_downloads()
+1288    };
+1289    pump_download_queue(app, db_service, active_downloads).await;
+1290
+1291    Ok(())
+1292}
+1293
+1294/// Cancel a download
+1295#[tauri::command]
+1296#[specta::specta]
+1297pub async fn cancel_download(
+1298    db: State<'_, DatabaseWrapper>,
+1299    download_manager: State<'_, DownloadManagerWrapper>,
+1300    download_id: i64,
+1301) -> Result<(), String> {
+1302    let db_service = {
+1303        let database = db.0.lock().map_err(|e| e.to_string())?;
+1304        Arc::new(database.service())
+1305    };
+1306
+1307    // Get file path before deleting
+1308    let file_query = Query::with_params(
+1309        "SELECT file_path FROM downloads WHERE id = ?",
+1310        vec![QueryParam::Int64(download_id)],
+1311    );
+1312
+1313    let file_path: Option<String> = db_service
+1314        .query_optional(file_query, |row| row.get(0))
+1315        .await
+1316        .ok()
+1317        .flatten();
+1318
+1319    // Delete from database
+1320    let delete_query = Query::with_params(
+1321        "DELETE FROM downloads WHERE id = ?",
+1322        vec![QueryParam::Int64(download_id)],
+1323    );
+1324
+1325    db_service
+1326        .execute(delete_query)
+1327        .await
+1328        .map_err(|e| e.to_string())?;
+1329
+1330    // Stop the worker if this download is actually running. Without this the
+1331    // task keeps streaming into a `.part` file whose `downloads` row has just
+1332    // been deleted — bytes with nothing pointing at them, and the file below is
+1333    // removed while still being written to. (DR-168)
+1334    crate::download::stop::signal(download_id);
+1335    crate::download::stop::clear(download_id);
+1336
+1337    // Unregister from download manager (in case it was active)
+1338    {
+1339        let manager = download_manager.0.lock().map_err(|e| e.to_string())?;
+1340        manager.unregister_download(download_id);
+1341        info!(
+1342            "Cancelled download {}. Active downloads: {}",
+1343            download_id,
+1344            manager.active_count()
+1345        );
+1346    }
+1347
+1348    // Delete the partial file, and any completed file, if present. Both go
+1349    // through `partial_path` so this cannot drift from what the worker writes —
+1350    // it did, and every cancelled download leaked its partial. (DR-169)
+1351    if let Some(path) = file_path {
+1352        let target = std::path::PathBuf::from(&path);
+1353        let _ = std::fs::remove_file(crate::download::worker::partial_path(&target));
+1354    }
+1355
+1356    Ok(())
+1357}
+1358
+1359/// Mark a download as completed
+1360#[tauri::command]
+1361#[specta::specta]
+1362pub async fn mark_download_completed(
+1363    db: State<'_, DatabaseWrapper>,
+1364    download_id: i64,
+1365    bytes_downloaded: i64,
+1366    file_path: String,
+1367) -> Result<(), String> {
+1368    // Deleting a download reads this straight back into `std::fs::remove_file`,
+1369    // so a row must never come to name a file outside the download directory.
+1370    // The worker reports the absolute path it wrote, and joining an absolute
+1371    // path onto the root yields it unchanged, so that case is stored verbatim;
+1372    // the frontend's fallback to the row's own (relative) path resolves under
+1373    // the root, where the worker put it.
+1374    // TRACES: DR-211 | UT-205
+1375    let file_path = {
+1376        let root = download_root(&db)?;
+1377        confine_to_root(&root, &root.join(&file_path))?
+1378            .to_string_lossy()
+1379            .to_string()
+1380    };
+1381
+1382    let db_service = {
+1383        let database = db.0.lock().map_err(|e| e.to_string())?;
+1384        Arc::new(database.service())
+1385    };
+1386
+1387    let query = Query::with_params(
+1388        "UPDATE downloads SET status = 'completed', progress = 1.0, bytes_downloaded = ?,
+1389         file_size = ?, file_path = ?, completed_at = CURRENT_TIMESTAMP WHERE id = ?",
+1390        vec![
+1391            QueryParam::Int64(bytes_downloaded),
+1392            QueryParam::Int64(bytes_downloaded),
+1393            QueryParam::String(file_path),
+1394            QueryParam::Int64(download_id),
+1395        ],
+1396    );
+1397
+1398    db_service.execute(query).await.map_err(|e| e.to_string())?;
+1399    Ok(())
+1400}
+1401
+1402/// Mark a download as failed
+1403#[tauri::command]
+1404#[specta::specta]
+1405pub async fn mark_download_failed(
+1406    db: State<'_, DatabaseWrapper>,
+1407    download_id: i64,
+1408    error_message: String,
+1409) -> Result<(), String> {
+1410    let db_service = {
+1411        let database = db.0.lock().map_err(|e| e.to_string())?;
+1412        Arc::new(database.service())
+1413    };
+1414
+1415    let query = Query::with_params(
+1416        "UPDATE downloads SET status = 'failed', error_message = ? WHERE id = ?",
+1417        vec![
+1418            QueryParam::String(error_message),
+1419            QueryParam::Int64(download_id),
+1420        ],
+1421    );
+1422
+1423    db_service.execute(query).await.map_err(|e| e.to_string())?;
+1424    Ok(())
+1425}
+1426
+1427/// Start downloading a file immediately
+1428/// This command actually downloads the file using the worker
+1429#[tauri::command]
+1430#[specta::specta]
+1431pub async fn start_download(
+1432    db: State<'_, DatabaseWrapper>,
+1433    download_manager: State<'_, DownloadManagerWrapper>,
+1434    app: tauri::AppHandle,
+1435    download_id: i64,
+1436    stream_url: String,
+1437    target_dir: String,
+1438) -> Result<(), String> {
+1439    use crate::download::events::DownloadEvent;
+1440    use tauri::Emitter;
+1441
+1442    debug!("start_download called for download_id: {}", download_id);
+1443    debug!("   stream_url: {}", stream_url);
+1444    debug!("   target_dir: {}", target_dir);
+1445
+1446    // Check concurrent download limit and register this download
+1447    {
+1448        let manager = download_manager.0.lock().map_err(|e| {
+1449            error!("Failed to lock download manager: {}", e);
+1450            format!("Failed to lock download manager: {}", e)
+1451        })?;
+1452
+1453        if !manager.can_start_download() {
+1454            warn!(
+1455                "Cannot start download: maximum concurrent downloads ({}) reached",
+1456                manager.max_concurrent()
+1457            );
+1458            debug!("   Active downloads: {}", manager.active_count());
+1459            return Err(format!(
+1460                "Maximum concurrent downloads ({}) reached. Please wait for existing downloads to complete.",
+1461                manager.max_concurrent()
+1462            ));
+1463        }
+1464
+1465        // Register this download as active
+1466        let registered = manager.register_download(download_id);
+1467        if !registered {
+1468            warn!(
+1469                "Failed to register download {}: already registered or limit reached",
+1470                download_id
+1471            );
+1472            return Err("Download already in progress or limit reached".to_string());
+1473        }
+1474
+1475        info!(
+1476            "Download {} registered. Active downloads: {}/{}",
+1477            download_id,
+1478            manager.active_count(),
+1479            manager.max_concurrent()
+1480        );
+1481    }
+1482
+1483    // Get download info from DB
+1484    let db_service = {
+1485        let database = db.0.lock().map_err(|e| {
+1486            error!("Failed to lock database: {}", e);
+1487            e.to_string()
+1488        })?;
+1489        Arc::new(database.service())
+1490    };
+1491
+1492    let info_query = Query::with_params(
+1493        "SELECT item_id, file_path, file_size FROM downloads WHERE id = ?",
+1494        vec![QueryParam::Int64(download_id)],
+1495    );
+1496
+1497    let (item_id, file_path, file_size): (String, String, Option<i64>) = db_service
+1498        .query_one(info_query, |row| {
+1499            Ok((row.get(0)?, row.get(1)?, row.get(2)?))
+1500        })
+1501        .await
+1502        .map_err(|e| {
+1503            error!("Failed to query download info: {}", e);
+1504            e.to_string()
+1505        })?;
+1506
+1507    debug!(
+1508        "   Retrieved: item_id={}, file_path={}, file_size={:?}",
+1509        item_id, file_path, file_size
+1510    );
+1511
+1512    // Both halves of this join reached us from the frontend, so resolve them
+1513    // against the download directory before a single byte is written.
+1514    // TRACES: DR-211 | UT-205
+1515    let target_path = {
+1516        let root = download_root(&db)?;
+1517        confine_to_root(&root, &PathBuf::from(&target_dir).join(&file_path))?
+1518    };
+1519
+1520    // Make a HEAD request to get the file size from Content-Length header
+1521    debug!("Making HEAD request to get file size...");
+1522    let head_response = reqwest::Client::new().head(&stream_url).send().await;
+1523
+1524    let file_size_from_server = match head_response {
+1525        Ok(response) => {
+1526            let size = response
+1527                .headers()
+1528                .get(reqwest::header::CONTENT_LENGTH)
+1529                .and_then(|v| v.to_str().ok())
+1530                .and_then(|v| v.parse::<i64>().ok());
+1531
+1532            if let Some(size) = size {
+1533                debug!(
+1534                    "   Got file size from server: {} bytes ({} MB)",
+1535                    size,
+1536                    size / 1024 / 1024
+1537                );
+1538            } else {
+1539                warn!("   Server didn't provide Content-Length header");
+1540            }
+1541            size
+1542        }
+1543        Err(e) => {
+1544            warn!("   HEAD request failed: {}, continuing anyway...", e);
+1545            None
+1546        }
+1547    };
+1548
+1549    // Update status to downloading and save file_size if we got it.
+1550    // Also persist the resolved stream URL + target dir so the queue pump can
+1551    // restart/resume this download by itself if needed.
+1552    let update_query = if let Some(size) = file_size_from_server {
+1553        Query::with_params(
+1554            "UPDATE downloads SET status = 'downloading', started_at = CURRENT_TIMESTAMP, file_size = ?, stream_url = ?, target_dir = ? WHERE id = ?",
+1555            vec![
+1556                QueryParam::Int64(size),
+1557                QueryParam::String(stream_url.clone()),
+1558                QueryParam::String(target_dir.clone()),
+1559                QueryParam::Int64(download_id),
+1560            ],
+1561        )
+1562    } else {
+1563        Query::with_params(
+1564            "UPDATE downloads SET status = 'downloading', started_at = CURRENT_TIMESTAMP, stream_url = ?, target_dir = ? WHERE id = ?",
+1565            vec![
+1566                QueryParam::String(stream_url.clone()),
+1567                QueryParam::String(target_dir.clone()),
+1568                QueryParam::Int64(download_id),
+1569            ],
+1570        )
+1571    };
+1572
+1573    db_service
+1574        .execute(update_query)
+1575        .await
+1576        .map_err(|e| e.to_string())?;
+1577
+1578    // Emit started event
+1579    let started_event = DownloadEvent::Started {
+1580        download_id,
+1581        item_id: item_id.clone(),
+1582    };
+1583    debug!("Emitting download-event: {:?}", started_event);
+1584    debug!(
+1585        "   Serialized: {}",
+1586        serde_json::to_string(&started_event).unwrap_or_default()
+1587    );
+1588    match app.emit("download-event", started_event) {
+1589        Ok(_) => debug!("   Event emitted successfully"),
+1590        Err(e) => error!("   Event emit failed: {:?}", e),
+1591    }
+1592
+1593    // Get a clone of the active downloads Arc for unregistering later
+1594    let active_downloads = {
+1595        let manager = download_manager.0.lock().map_err(|e| e.to_string())?;
+1596        manager.get_active_downloads()
+1597    };
+1598
+1599    // Run the worker in the background; on completion/failure it frees the slot
+1600    // and pumps the next pending download.
+1601    spawn_download_worker(
+1602        app.clone(),
+1603        download_id,
+1604        item_id,
+1605        stream_url,
+1606        target_path,
+1607        active_downloads,
+1608    );
+1609
+1610    Ok(())
+1611}
+1612
+1613/// Enqueue a download with its resolved stream URL, then let the queue pump
+1614/// start it (or a higher-priority pending item) when a slot is free.
+1615///
+1616/// Unlike [`start_download`], this never errors when the concurrency limit is
+1617/// reached: the URL is persisted on the row and the pump will pick it up once a
+1618/// slot frees. This is the path bulk operations (album/series/season) use so
+1619/// every queued item eventually downloads without the frontend re-issuing it.
+1620#[tauri::command]
+1621#[specta::specta]
+1622pub async fn enqueue_download(
+1623    db: State<'_, DatabaseWrapper>,
+1624    download_manager: State<'_, DownloadManagerWrapper>,
+1625    app: tauri::AppHandle,
+1626    download_id: i64,
+1627    stream_url: String,
+1628    target_dir: String,
+1629) -> Result<(), String> {
+1630    let db_service = {
+1631        let database = db.0.lock().map_err(|e| e.to_string())?;
+1632        Arc::new(database.service())
+1633    };
+1634
+1635    // Persist the resolved URL/dir and mark the row pending so the pump can
+1636    // start it. We don't flip to 'downloading' here — the pump owns that.
+1637    let update_query = Query::with_params(
+1638        "UPDATE downloads SET status = 'pending', stream_url = ?, target_dir = ? WHERE id = ?",
+1639        vec![
+1640            QueryParam::String(stream_url),
+1641            QueryParam::String(target_dir),
+1642            QueryParam::Int64(download_id),
+1643        ],
+1644    );
+1645    db_service
+1646        .execute(update_query)
+1647        .await
+1648        .map_err(|e| e.to_string())?;
+1649
+1650    // Kick the pump: it will start as many pending downloads as there are slots.
+1651    let active_downloads = {
+1652        let manager = download_manager.0.lock().map_err(|e| e.to_string())?;
+1653        manager.get_active_downloads()
+1654    };
+1655    pump_download_queue(app, db_service, active_downloads).await;
+1656
+1657    Ok(())
+1658}
+1659
+1660/// Enqueue a batch of already-queued video downloads, resolving each one's
+1661/// transcode URL from the repository using the `quality_preset` stored on the
+1662/// row. Then let the pump start them subject to the concurrency limit.
+1663///
+1664/// This is the bulk video path (series/season): `download_series`/
+1665/// `download_season` insert the rows, then this resolves URLs and enqueues them
+1666/// so they actually start. Resolving server-side avoids round-tripping every
+1667/// episode URL through the frontend.
+1668#[tauri::command]
+1669#[specta::specta]
+1670pub async fn enqueue_video_downloads(
+1671    db: State<'_, DatabaseWrapper>,
+1672    download_manager: State<'_, DownloadManagerWrapper>,
+1673    repository: State<'_, crate::commands::repository::RepositoryManagerWrapper>,
+1674    app: tauri::AppHandle,
+1675    handle: String,
+1676    download_ids: Vec<i64>,
+1677    target_dir: String,
+1678) -> Result<(), String> {
+1679    let repo = repository.0.get(&handle).ok_or("Repository not found")?;
+1680
+1681    let db_service = {
+1682        let database = db.0.lock().map_err(|e| e.to_string())?;
+1683        Arc::new(database.service())
+1684    };
+1685
+1686    for download_id in download_ids {
+1687        // Read the item + quality preset for this queued download.
+1688        let info_query = Query::with_params(
+1689            "SELECT item_id, COALESCE(quality_preset, 'original') FROM downloads WHERE id = ?",
+1690            vec![QueryParam::Int64(download_id)],
+1691        );
+1692        let (item_id, quality): (String, String) = match db_service
+1693            .query_one(info_query, |row| Ok((row.get(0)?, row.get(1)?)))
+1694            .await
+1695        {
+1696            Ok(row) => row,
+1697            Err(e) => {
+1698                warn!("[enqueue_video] Skipping download {}: {}", download_id, e);
+1699                continue;
+1700            }
+1701        };
+1702
+1703        // Build the download URL, resolving the source's audio codec first so a
+1704        // track this device cannot decode is re-encoded on the way down rather
+1705        // than saved as a silent file (DR-167).
+1706        let stream_url =
+1707            crate::repository::resolve_video_download_url(repo.as_ref(), &item_id, &quality, None)
+1708                .await;
+1709
+1710        let update_query = Query::with_params(
+1711            "UPDATE downloads SET status = 'pending', stream_url = ?, target_dir = ? WHERE id = ?",
+1712            vec![
+1713                QueryParam::String(stream_url),
+1714                QueryParam::String(target_dir.clone()),
+1715                QueryParam::Int64(download_id),
+1716            ],
+1717        );
+1718        if let Err(e) = db_service.execute(update_query).await {
+1719            warn!(
+1720                "[enqueue_video] Failed to persist URL for download {}: {}",
+1721                download_id, e
+1722            );
+1723        }
+1724    }
+1725
+1726    // Pump once: starts up to max_concurrent, the rest drain as slots free.
+1727    let active_downloads = {
+1728        let manager = download_manager.0.lock().map_err(|e| e.to_string())?;
+1729        manager.get_active_downloads()
+1730    };
+1731    pump_download_queue(app, db_service, active_downloads).await;
+1732
+1733    Ok(())
+1734}
+1735
+1736/// Whether the current network permits downloads, given the user's WiFi-only
+1737/// preference.
+1738///
+1739/// Reads `wifi_only` from the SmartCache config (the single home of the
+1740/// setting) and checks it against the transport reported by the platform. On
+1741/// desktop the transport defaults to unmetered ethernet, so this is always
+1742/// true there.
+1743///
+1744/// TRACES: UR-053 | DR-074
+1745pub(crate) async fn downloads_allowed_on_current_network(app: &tauri::AppHandle) -> bool {
+1746    let wifi_only = {
+1747        let smart_cache = app.state::<SmartCacheWrapper>();
+1748        let cache = match smart_cache.0.lock() {
+1749            Ok(c) => c,
+1750            Err(e) => {
+1751                error!("[pump] Failed to lock smart cache: {}", e);
+1752                // Fail open: a lock problem must not silently wedge downloads.
+1753                return true;
+1754            }
+1755        };
+1756        cache.get_config().map(|c| c.wifi_only).unwrap_or(false)
+1757    };
+1758
+1759    if !wifi_only {
+1760        return true;
+1761    }
+1762
+1763    let network = app.state::<NetworkStateWrapper>();
+1764    network.0.allows_download(true).await
+1765}
+1766
+1767/// Start as many pending downloads as there are free concurrency slots.
+1768///
+1769/// Picks the highest-priority `pending` rows that have a persisted `stream_url`
+1770/// (FIFO within a priority), registers each, flips it to `downloading`, and
+1771/// spawns a worker. Each spawned worker calls this again on completion/failure,
+1772/// so the queue drains itself without any frontend involvement.
+1773pub(crate) async fn pump_download_queue(
+1774    app: tauri::AppHandle,
+1775    db_service: Arc<crate::storage::db_service::RusqliteService>,
+1776    active_downloads: Arc<Mutex<std::collections::HashSet<i64>>>,
+1777) {
+1778    use crate::download::events::DownloadEvent;
+1779    use tauri::Emitter;
+1780
+1781    // WiFi-only gate (UR-053): when the user has restricted downloads to
+1782    // unmetered networks and we're on cellular (or can't tell), leave every
+1783    // pending row exactly as it is. They stay 'pending' and the Android
+1784    // network callback re-pumps us as soon as an acceptable network appears.
+1785    if !downloads_allowed_on_current_network(&app).await {
+1786        info!("[pump] Downloads paused: waiting for an unmetered network (WiFi-only enabled)");
+1787        let _ = app.emit("download-event", DownloadEvent::WaitingForNetwork);
+1788        return;
+1789    }
+1790
+1791    let max_concurrent = {
+1792        let manager = app.state::<DownloadManagerWrapper>();
+1793        let manager = match manager.0.lock() {
+1794            Ok(m) => m,
+1795            Err(e) => {
+1796                error!("[pump] Failed to lock download manager: {}", e);
+1797                return;
+1798            }
+1799        };
+1800        manager.max_concurrent()
+1801    };
+1802
+1803    loop {
+1804        // How many slots are free right now?
+1805        let free_slots = {
+1806            let active = match active_downloads.lock() {
+1807                Ok(a) => a,
+1808                Err(e) => {
+1809                    error!("[pump] Failed to lock active downloads: {}", e);
+1810                    return;
+1811                }
+1812            };
+1813            max_concurrent.saturating_sub(active.len())
+1814        };
+1815        if free_slots == 0 {
+1816            return;
+1817        }
+1818
+1819        // Find the next pending, startable download (has a stream URL). Exclude
+1820        // anything already registered as active to avoid double-starting.
+1821        let next_query = Query::with_params(
+1822            "SELECT id, item_id, file_path, stream_url, target_dir
+1823             FROM downloads
+1824             WHERE status = 'pending'
+1825               AND stream_url IS NOT NULL
+1826               AND target_dir IS NOT NULL
+1827             ORDER BY priority DESC, queued_at ASC",
+1828            vec![],
+1829        );
+1830
+1831        let candidates: Vec<(i64, String, String, String, String)> = match db_service
+1832            .query_many(next_query, |row| {
+1833                Ok((
+1834                    row.get(0)?,
+1835                    row.get(1)?,
+1836                    row.get(2)?,
+1837                    row.get(3)?,
+1838                    row.get(4)?,
+1839                ))
+1840            })
+1841            .await
+1842        {
+1843            Ok(rows) => rows,
+1844            Err(e) => {
+1845                error!("[pump] Failed to query pending downloads: {}", e);
+1846                return;
+1847            }
+1848        };
+1849
+1850        // Pick the first candidate not already active.
+1851        let next = candidates.into_iter().find(|(id, _, _, _, _)| {
+1852            active_downloads
+1853                .lock()
+1854                .map(|active| !active.contains(id))
+1855                .unwrap_or(false)
+1856        });
+1857
+1858        let (download_id, item_id, file_path, stream_url, target_dir) = match next {
+1859            Some(n) => n,
+1860            None => return, // Nothing pending to start
+1861        };
+1862
+1863        // Confine the row's path before it takes a slot. A row whose target
+1864        // escapes the download directory can never start, so it is failed here
+1865        // rather than picked again on the next pass — this loop re-queries, so
+1866        // merely skipping it would not terminate.
+1867        // TRACES: DR-211 | UT-205
+1868        let confined = {
+1869            let db_state = app.state::<DatabaseWrapper>();
+1870            download_root(&db_state).and_then(|root| {
+1871                confine_to_root(&root, &PathBuf::from(&target_dir).join(&file_path))
+1872            })
+1873        };
+1874        let target_path = match confined {
+1875            Ok(path) => path,
+1876            Err(e) => {
+1877                error!("[pump] Refusing download {}: {}", download_id, e);
+1878                let fail_query = Query::with_params(
+1879                    "UPDATE downloads SET status = 'failed', error_message = ? WHERE id = ?",
+1880                    vec![QueryParam::String(e), QueryParam::Int64(download_id)],
+1881                );
+1882                if let Err(db_err) = db_service.execute(fail_query).await {
+1883                    error!(
+1884                        "[pump] Failed to mark download {} failed: {}",
+1885                        download_id, db_err
+1886                    );
+1887                    return;
+1888                }
+1889                continue;
+1890            }
+1891        };
+1892
+1893        // Register the slot. If registration fails (race: another pump filled
+1894        // the last slot), stop — we'll be re-pumped when a slot frees.
+1895        {
+1896            let manager = app.state::<DownloadManagerWrapper>();
+1897            let manager = match manager.0.lock() {
+1898                Ok(m) => m,
+1899                Err(e) => {
+1900                    error!("[pump] Failed to lock download manager: {}", e);
+1901                    return;
+1902                }
+1903            };
+1904            if !manager.register_download(download_id) {
+1905                return;
+1906            }
+1907            info!(
+1908                "[pump] Download {} started. Active downloads: {}/{}",
+1909                download_id,
+1910                manager.active_count(),
+1911                manager.max_concurrent()
+1912            );
+1913        }
+1914
+1915        // Mark as downloading and stamp started_at.
+1916        let update_query = Query::with_params(
+1917            "UPDATE downloads SET status = 'downloading', started_at = CURRENT_TIMESTAMP WHERE id = ?",
+1918            vec![QueryParam::Int64(download_id)],
+1919        );
+1920        if let Err(e) = db_service.execute(update_query).await {
+1921            error!(
+1922                "[pump] Failed to mark download {} downloading: {}",
+1923                download_id, e
+1924            );
+1925            if let Ok(mut a) = active_downloads.lock() {
+1926                a.remove(&download_id);
+1927            }
+1928            continue;
+1929        }
+1930
+1931        // Emit started event so the UI flips the row.
+1932        let _ = app.emit(
+1933            "download-event",
+1934            DownloadEvent::Started {
+1935                download_id,
+1936                item_id: item_id.clone(),
+1937            },
+1938        );
+1939
+1940        spawn_download_worker(
+1941            app.clone(),
+1942            download_id,
+1943            item_id,
+1944            stream_url,
+1945            target_path,
+1946            active_downloads.clone(),
+1947        );
+1948    }
+1949}
+1950
+1951/// Spawn the background worker for one download. On completion or failure it
+1952/// unregisters the slot, emits the terminal event, and pumps the queue so the
+1953/// next pending download starts automatically.
+1954fn spawn_download_worker(
+1955    app: tauri::AppHandle,
+1956    download_id: i64,
+1957    item_id: String,
+1958    stream_url: String,
+1959    target_path: std::path::PathBuf,
+1960    active_downloads: Arc<Mutex<std::collections::HashSet<i64>>>,
+1961) {
+1962    use crate::download::events::DownloadEvent;
+1963    use crate::download::{DownloadTask, DownloadWorker};
+1964    use tauri::Emitter;
+1965
+1966    let task = DownloadTask {
+1967        url: stream_url,
+1968        target_path: target_path.clone(),
+1969    };
+1970
+1971    tauri::async_runtime::spawn(async move {
+1972        debug!("Download task started for download_id: {}", download_id);
+1973        let worker = DownloadWorker::new();
+1974
+1975        // Progress callback that emits events to the frontend
+1976        let progress_app = app.clone();
+1977        let progress_item_id = item_id.clone();
+1978        let on_progress = move |bytes_downloaded: u64, total_bytes: Option<u64>| {
+1979            let progress = total_bytes
+1980                .filter(|&t| t > 0)
+1981                .map(|t| bytes_downloaded as f64 / t as f64)
+1982                .unwrap_or(0.0);
+1983
+1984            let event = DownloadEvent::Progress {
+1985                download_id,
+1986                item_id: progress_item_id.clone(),
+1987                bytes_downloaded: bytes_downloaded as i64,
+1988                total_bytes: total_bytes.map(|t| t as i64),
+1989                progress,
+1990            };
+1991            let _ = progress_app.emit("download-event", event);
+1992        };
+1993
+1994        // Registering returns a fresh flag, so a download resumed after a pause
+1995        // does not inherit the stop that ended its previous run. (DR-168)
+1996        let stop_flag = crate::download::stop::register(download_id);
+1997        let result = worker.download(&task, &stop_flag, on_progress).await;
+1998        crate::download::stop::clear(download_id);
+1999
+2000        // Free the slot before pumping so the next download can take it.
+2001        if let Ok(mut active) = active_downloads.lock() {
+2002            active.remove(&download_id);
+2003            debug!(
+2004                "   Unregistered download {}. Active downloads: {}",
+2005                download_id,
+2006                active.len()
+2007            );
+2008        }
+2009
+2010        // The pump runs downloads in the background, so the terminal status MUST
+2011        // be persisted to the DB here — the frontend event handler only writes it
+2012        // when that download happens to be loaded in its store, which is not the
+2013        // case for auto-pumped rows (or any completion while the downloads page is
+2014        // closed). `check_for_local_download` filters on status = 'completed', so a
+2015        // missed write leaves finished files unrecognized: albums never show as
+2016        // downloaded and playback never switches from the (expiring) stream to the
+2017        // local file, cutting tracks off mid-play.
+2018        let db_service = {
+2019            let db = app.state::<DatabaseWrapper>();
+2020            let database = match db.0.lock() {
+2021                Ok(d) => d,
+2022                Err(e) => {
+2023                    error!(
+2024                        "[pump] Failed to lock database after download {}: {}",
+2025                        download_id, e
+2026                    );
+2027                    return;
+2028                }
+2029            };
+2030            Arc::new(database.service())
+2031        };
+2032
+2033        match result {
+2034            Ok(res) => {
+2035                info!(
+2036                    "Download completed successfully: {} bytes",
+2037                    res.bytes_downloaded
+2038                );
+2039                let file_path = target_path.to_string_lossy().to_string();
+2040
+2041                let update = Query::with_params(
+2042                    "UPDATE downloads SET status = 'completed', progress = 1.0, \
+2043                     bytes_downloaded = ?, file_size = ?, file_path = ?, \
+2044                     completed_at = CURRENT_TIMESTAMP WHERE id = ?",
+2045                    vec![
+2046                        QueryParam::Int64(res.bytes_downloaded as i64),
+2047                        QueryParam::Int64(res.bytes_downloaded as i64),
+2048                        QueryParam::String(file_path.clone()),
+2049                        QueryParam::Int64(download_id),
+2050                    ],
+2051                );
+2052                if let Err(e) = db_service.execute(update).await {
+2053                    error!(
+2054                        "[pump] Failed to persist completed status for download {}: {}",
+2055                        download_id, e
+2056                    );
+2057                }
+2058
+2059                let completed_event = DownloadEvent::Completed {
+2060                    download_id,
+2061                    item_id,
+2062                    file_path,
+2063                };
+2064                match app.emit("download-event", completed_event) {
+2065                    Ok(_) => debug!("   Completed event emitted successfully"),
+2066                    Err(e) => error!("   Completed event emit failed: {:?}", e),
+2067                }
+2068            }
+2069            // A pause or cancel is not a failure. The row already says `paused`
+2070            // (or the row is gone, for a cancel), and overwriting that with
+2071            // `failed` is what made a pause look like an error and stranded the
+2072            // download outside the resumable set. The `.part` file is deliberately
+2073            // left alone — it is what the resume continues from. (DR-168)
+2074            Err(e) if e.is_stopped() => {
+2075                info!(
+2076                    "[pump] Download {} stopped by request; partial file kept for resume",
+2077                    download_id
+2078                );
+2079            }
+2080            Err(e) => {
+2081                error!("Download failed: {:?}", e);
+2082
+2083                let update = Query::with_params(
+2084                    "UPDATE downloads SET status = 'failed', error_message = ? WHERE id = ?",
+2085                    vec![
+2086                        QueryParam::String(e.to_string()),
+2087                        QueryParam::Int64(download_id),
+2088                    ],
+2089                );
+2090                if let Err(db_err) = db_service.execute(update).await {
+2091                    error!(
+2092                        "[pump] Failed to persist failed status for download {}: {}",
+2093                        download_id, db_err
+2094                    );
+2095                }
+2096
+2097                let failed_event = DownloadEvent::Failed {
+2098                    download_id,
+2099                    item_id,
+2100                    error: e.to_string(),
+2101                };
+2102                match app.emit("download-event", failed_event) {
+2103                    Ok(_) => debug!("   Failed event emitted successfully"),
+2104                    Err(e) => error!("   Failed event emit failed: {:?}", e),
+2105                }
+2106            }
+2107        }
+2108
+2109        // A slot just freed — start the next pending download (if any).
+2110        pump_download_queue(app.clone(), db_service, active_downloads).await;
+2111    });
+2112}
+2113
+2114/// Delete a completed download
+2115#[tauri::command]
+2116#[specta::specta]
+2117pub async fn delete_download(
+2118    db: State<'_, DatabaseWrapper>,
+2119    download_id: i64,
+2120) -> Result<(), String> {
+2121    let db_service = {
+2122        let database = db.0.lock().map_err(|e| e.to_string())?;
+2123        Arc::new(database.service())
+2124    };
+2125
+2126    // Get file path
+2127    let file_query = Query::with_params(
+2128        "SELECT file_path FROM downloads WHERE id = ?",
+2129        vec![QueryParam::Int64(download_id)],
+2130    );
+2131
+2132    let file_path: Option<String> = db_service
+2133        .query_optional(file_query, |row| row.get(0))
+2134        .await
+2135        .ok()
+2136        .flatten();
+2137
+2138    // Delete from database
+2139    let delete_query = Query::with_params(
+2140        "DELETE FROM downloads WHERE id = ?",
+2141        vec![QueryParam::Int64(download_id)],
+2142    );
+2143
+2144    db_service
+2145        .execute(delete_query)
+2146        .await
+2147        .map_err(|e| e.to_string())?;
+2148
+2149    // Delete actual file if exists
+2150    if let Some(path) = file_path {
+2151        let _ = std::fs::remove_file(&path); // Ignore errors
+2152    }
+2153
+2154    Ok(())
+2155}
+2156
+2157/// Helper function to map database row to DownloadInfo
+2158fn map_download_row(row: &rusqlite::Row) -> rusqlite::Result<DownloadInfo> {
+2159    Ok(DownloadInfo {
+2160        id: row.get(0)?,
+2161        item_id: row.get(1)?,
+2162        user_id: row.get(2)?,
+2163        file_path: row.get(3)?,
+2164        file_size: row.get(4)?,
+2165        mime_type: row.get(5)?,
+2166        status: row.get(6)?,
+2167        progress: row.get(7)?,
+2168        bytes_downloaded: row.get(8)?,
+2169        queued_at: row.get(9)?,
+2170        started_at: row.get(10)?,
+2171        completed_at: row.get(11)?,
+2172        error_message: row.get(12)?,
+2173        retry_count: row.get(13)?,
+2174        priority: row.get(14)?,
+2175        item_name: row.get(15)?,
+2176        artist_name: row.get(16)?,
+2177        album_name: row.get(17)?,
+2178        // Video-specific metadata
+2179        series_name: row.get(18)?,
+2180        season_name: row.get(19)?,
+2181        episode_number: row.get(20)?,
+2182        season_number: row.get(21)?,
+2183        quality_preset: row.get(22)?,
+2184        media_type: row
+2185            .get::<_, Option<String>>(23)?
+2186            .unwrap_or_else(|| "audio".to_string()),
+2187        download_source: row
+2188            .get::<_, Option<String>>(24)?
+2189            .unwrap_or_else(|| "user".to_string()),
+2190    })
+2191}
+2192
+2193/// Storage statistics for downloads
+2194#[derive(specta::Type, Debug, Clone, serde::Serialize)]
+2195pub struct StorageStats {
+2196    pub total_bytes: i64,
+2197    pub total_items: i64,
+2198    pub albums: Vec<AlbumStorageInfo>,
+2199}
+2200
+2201/// Storage info for a single album
+2202#[derive(specta::Type, Debug, Clone, serde::Serialize)]
+2203pub struct AlbumStorageInfo {
+2204    pub album_id: String,
+2205    pub album_name: String,
+2206    pub artist_name: Option<String>,
+2207    pub bytes_used: i64,
+2208    pub track_count: i64,
+2209}
+2210
+2211/// Get storage statistics for downloads
+2212#[tauri::command]
+2213#[specta::specta]
+2214pub async fn get_download_storage_stats(
+2215    db: State<'_, DatabaseWrapper>,
+2216    user_id: String,
+2217) -> Result<StorageStats, String> {
+2218    let db_service = {
+2219        let database = db.0.lock().map_err(|e| e.to_string())?;
+2220        Arc::new(database.service())
+2221    };
+2222
+2223    // Get total storage and item count
+2224    let total_query = Query::with_params(
+2225        "SELECT COALESCE(SUM(file_size), 0), COUNT(*)
+2226         FROM downloads
+2227         WHERE user_id = ? AND status = 'completed'",
+2228        vec![QueryParam::String(user_id.clone())],
+2229    );
+2230
+2231    let (total_bytes, total_items): (i64, i64) = db_service
+2232        .query_one(total_query, |row| Ok((row.get(0)?, row.get(1)?)))
+2233        .await
+2234        .map_err(|e| e.to_string())?;
+2235
+2236    // Get storage breakdown by album
+2237    let albums_query = Query::with_params(
+2238        "SELECT
+2239            COALESCE(i.album_id, 'unknown') as album_id,
+2240            COALESCE(i.album_name, 'Unknown Album') as album_name,
+2241            i.artists as artist_name,
+2242            COALESCE(SUM(d.file_size), 0) as bytes_used,
+2243            COUNT(*) as track_count
+2244         FROM downloads d
+2245         LEFT JOIN items i ON d.item_id = i.id
+2246         WHERE d.user_id = ? AND d.status = 'completed'
+2247         GROUP BY COALESCE(i.album_id, 'unknown')
+2248         ORDER BY bytes_used DESC",
+2249        vec![QueryParam::String(user_id)],
+2250    );
+2251
+2252    let albums: Vec<AlbumStorageInfo> = db_service
+2253        .query_many(albums_query, |row| {
+2254            Ok(AlbumStorageInfo {
+2255                album_id: row.get(0)?,
+2256                album_name: row.get(1)?,
+2257                artist_name: row.get(2)?,
+2258                bytes_used: row.get(3)?,
+2259                track_count: row.get(4)?,
+2260            })
+2261        })
+2262        .await
+2263        .map_err(|e| e.to_string())?;
+2264
+2265    Ok(StorageStats {
+2266        total_bytes,
+2267        total_items,
+2268        albums,
+2269    })
+2270}
+2271
+2272/// Delete all downloads for a user
+2273#[tauri::command]
+2274#[specta::specta]
+2275pub async fn delete_all_downloads(
+2276    db: State<'_, DatabaseWrapper>,
+2277    user_id: String,
+2278) -> Result<i64, String> {
+2279    let db_service = {
+2280        let database = db.0.lock().map_err(|e| e.to_string())?;
+2281        Arc::new(database.service())
+2282    };
+2283
+2284    // Get all file paths for completed downloads
+2285    let file_query = Query::with_params(
+2286        "SELECT file_path FROM downloads WHERE user_id = ? AND status = 'completed'",
+2287        vec![QueryParam::String(user_id.clone())],
+2288    );
+2289
+2290    let file_paths: Vec<String> = db_service
+2291        .query_many(file_query, |row| row.get(0))
+2292        .await
+2293        .map_err(|e| e.to_string())?;
+2294
+2295    // Delete all downloads for user
+2296    let delete_query = Query::with_params(
+2297        "DELETE FROM downloads WHERE user_id = ?",
+2298        vec![QueryParam::String(user_id)],
+2299    );
+2300
+2301    let deleted_count = db_service
+2302        .execute(delete_query)
+2303        .await
+2304        .map_err(|e| e.to_string())?;
+2305
+2306    // Delete actual files
+2307    for path in file_paths {
+2308        let _ = std::fs::remove_file(&path); // Ignore errors
+2309        let _ = std::fs::remove_file(format!("{}.part", path)); // Also clean up partials
+2310    }
+2311
+2312    Ok(deleted_count as i64)
+2313}
+2314
+2315/// Clear all stale pending/failed/paused downloads
+2316#[tauri::command]
+2317#[specta::specta]
+2318pub async fn clear_stale_downloads(
+2319    db: State<'_, DatabaseWrapper>,
+2320    user_id: String,
+2321) -> Result<i64, String> {
+2322    let db_service = {
+2323        let database = db.0.lock().map_err(|e| e.to_string())?;
+2324        Arc::new(database.service())
+2325    };
+2326
+2327    // Ids as well as paths: a stale row may still have a worker attached (a
+2328    // 'downloading' row that was paused mid-flight is 'paused' here), and
+2329    // deleting the row without stopping the task leaves it writing to a file we
+2330    // are about to remove. (DR-168)
+2331    let file_query = Query::with_params(
+2332        "SELECT id, file_path FROM downloads WHERE user_id = ? AND status IN ('pending', 'paused', 'failed')",
+2333        vec![QueryParam::String(user_id.clone())],
+2334    );
+2335
+2336    let stale: Vec<(i64, String)> = db_service
+2337        .query_many(file_query, |row| Ok((row.get(0)?, row.get(1)?)))
+2338        .await
+2339        .map_err(|e| e.to_string())?;
+2340
+2341    for (id, _) in &stale {
+2342        crate::download::stop::signal(*id);
+2343        crate::download::stop::clear(*id);
+2344    }
+2345    let file_paths: Vec<String> = stale.into_iter().map(|(_, path)| path).collect();
+2346
+2347    // Delete all pending, paused, and failed downloads (but keep completed ones)
+2348    let delete_query = Query::with_params(
+2349        "DELETE FROM downloads WHERE user_id = ? AND status IN ('pending', 'paused', 'failed')",
+2350        vec![QueryParam::String(user_id)],
+2351    );
+2352
+2353    let deleted_count = db_service
+2354        .execute(delete_query)
+2355        .await
+2356        .map_err(|e| e.to_string())?;
+2357
+2358    // Delete any partial files, via the shared helper so this cannot drift from
+2359    // what the worker actually writes. (DR-169)
+2360    for path in file_paths {
+2361        let target = std::path::PathBuf::from(&path);
+2362        let _ = std::fs::remove_file(&target);
+2363        let _ = std::fs::remove_file(crate::download::worker::partial_path(&target));
+2364    }
+2365
+2366    Ok(deleted_count as i64)
+2367}
+2368
+2369/// Delete all downloads for a specific album
+2370#[tauri::command]
+2371#[specta::specta]
+2372pub async fn delete_album_downloads(
+2373    db: State<'_, DatabaseWrapper>,
+2374    album_id: String,
+2375    user_id: String,
+2376) -> Result<i64, String> {
+2377    let db_service = {
+2378        let database = db.0.lock().map_err(|e| e.to_string())?;
+2379        Arc::new(database.service())
+2380    };
+2381
+2382    // Get file paths for this album's downloads
+2383    let file_query = Query::with_params(
+2384        "SELECT d.file_path FROM downloads d
+2385         JOIN items i ON d.item_id = i.id
+2386         WHERE d.user_id = ? AND i.album_id = ? AND d.status = 'completed'",
+2387        vec![
+2388            QueryParam::String(user_id.clone()),
+2389            QueryParam::String(album_id.clone()),
+2390        ],
+2391    );
+2392
+2393    let file_paths: Vec<String> = db_service
+2394        .query_many(file_query, |row| row.get(0))
+2395        .await
+2396        .map_err(|e| e.to_string())?;
+2397
+2398    // Delete downloads for this album
+2399    let delete_query = Query::with_params(
+2400        "DELETE FROM downloads WHERE user_id = ? AND item_id IN (SELECT id FROM items WHERE album_id = ?)",
+2401        vec![QueryParam::String(user_id), QueryParam::String(album_id)],
+2402    );
+2403
+2404    let deleted_count = db_service
+2405        .execute(delete_query)
+2406        .await
+2407        .map_err(|e| e.to_string())?;
+2408
+2409    // Delete actual files
+2410    for path in file_paths {
+2411        let _ = std::fs::remove_file(&path);
+2412        let _ = std::fs::remove_file(format!("{}.part", path));
+2413    }
+2414
+2415    Ok(deleted_count as i64)
+2416}
+2417
+2418/// Remove every completed download at or under a container item.
+2419///
+2420/// Works at any level of the Downloaded browse: a leaf (removes just that
+2421/// download), an album/season/series (removes all downloaded descendants linked
+2422/// via album_id/season_id/series_id/parent_id). Deletes the DB rows and the
+2423/// on-disk files. Returns the number of downloads removed. Idempotent.
+2424///
+2425/// TRACES: UR-055 | DR-083
+2426#[tauri::command]
+2427#[specta::specta]
+2428pub async fn delete_downloads_under(
+2429    db: State<'_, DatabaseWrapper>,
+2430    item_id: String,
+2431    user_id: String,
+2432) -> Result<i64, String> {
+2433    let db_service = {
+2434        let database = db.0.lock().map_err(|e| e.to_string())?;
+2435        Arc::new(database.service())
+2436    };
+2437
+2438    // The item itself, or any child linked to it by container id.
+2439    const SCOPE: &str = "d.user_id = ? AND d.status = 'completed'
+2440         AND (
+2441             d.item_id = ?
+2442             OR d.item_id IN (
+2443                 SELECT c.id FROM items c
+2444                 WHERE c.album_id = ? OR c.season_id = ? OR c.series_id = ? OR c.parent_id = ?
+2445             )
+2446         )";
+2447
+2448    let file_query = Query::with_params(
+2449        format!("SELECT d.file_path FROM downloads d WHERE {SCOPE}"),
+2450        vec![
+2451            QueryParam::String(user_id.clone()),
+2452            QueryParam::String(item_id.clone()),
+2453            QueryParam::String(item_id.clone()),
+2454            QueryParam::String(item_id.clone()),
+2455            QueryParam::String(item_id.clone()),
+2456            QueryParam::String(item_id.clone()),
+2457        ],
+2458    );
+2459    let file_paths: Vec<String> = db_service
+2460        .query_many(file_query, |row| row.get(0))
+2461        .await
+2462        .map_err(|e| e.to_string())?;
+2463
+2464    let delete_query = Query::with_params(
+2465        format!("DELETE FROM downloads WHERE id IN (SELECT d.id FROM downloads d WHERE {SCOPE})"),
+2466        vec![
+2467            QueryParam::String(user_id),
+2468            QueryParam::String(item_id.clone()),
+2469            QueryParam::String(item_id.clone()),
+2470            QueryParam::String(item_id.clone()),
+2471            QueryParam::String(item_id.clone()),
+2472            QueryParam::String(item_id),
+2473        ],
+2474    );
+2475    let deleted_count = db_service
+2476        .execute(delete_query)
+2477        .await
+2478        .map_err(|e| e.to_string())?;
+2479
+2480    for path in file_paths {
+2481        let _ = std::fs::remove_file(&path);
+2482        let _ = std::fs::remove_file(format!("{}.part", path));
+2483    }
+2484
+2485    Ok(deleted_count as i64)
+2486}
+2487
+2488/// Download manager statistics
+2489#[derive(specta::Type, Debug, Clone, serde::Serialize)]
+2490pub struct DownloadManagerStats {
+2491    pub max_concurrent: usize,
+2492    pub active_count: usize,
+2493    pub available_slots: usize,
+2494}
+2495
+2496/// Get download manager statistics
+2497#[tauri::command]
+2498#[specta::specta]
+2499pub async fn get_download_manager_stats(
+2500    download_manager: State<'_, DownloadManagerWrapper>,
+2501) -> Result<DownloadManagerStats, String> {
+2502    let manager = download_manager.0.lock().map_err(|e| e.to_string())?;
+2503
+2504    let active_count = manager.active_count();
+2505    let max_concurrent = manager.max_concurrent();
+2506
+2507    Ok(DownloadManagerStats {
+2508        max_concurrent,
+2509        active_count,
+2510        available_slots: max_concurrent.saturating_sub(active_count),
+2511    })
+2512}
+2513
+2514/// Set the maximum concurrent downloads
+2515#[tauri::command]
+2516#[specta::specta]
+2517pub async fn set_max_concurrent_downloads(
+2518    download_manager: State<'_, DownloadManagerWrapper>,
+2519    max: usize,
+2520) -> Result<(), String> {
+2521    let mut manager = download_manager.0.lock().map_err(|e| e.to_string())?;
+2522    manager.set_max_concurrent(max);
+2523    info!("Set max concurrent downloads to: {}", max);
+2524    Ok(())
+2525}
+2526
+2527// TRACES: UR-011, UR-018 | DR-015, DR-018 | UT-042, UT-043
+2528#[cfg(test)]
+2529mod tests {
+2530    use super::*;
+2531    use crate::storage::Database;
+2532    use rusqlite::params;
+2533
+2534    #[test]
+2535    fn test_sanitize_filename() {
+2536        assert_eq!(sanitize_filename("normal.mp3"), "normal.mp3");
+2537        assert_eq!(
+2538            sanitize_filename("track/with\\invalid:chars"),
+2539            "track_with_invalid_chars"
+2540        );
+2541        assert_eq!(sanitize_filename("song?.mp3"), "song_.mp3");
+2542        assert_eq!(sanitize_filename("file<>|?.txt"), "file____.txt");
+2543    }
+2544
+2545    #[test]
+2546    fn test_sanitize_filename_preserves_extension() {
+2547        assert_eq!(sanitize_filename("my:song.mp3"), "my_song.mp3");
+2548        assert_eq!(sanitize_filename("track/1.flac"), "track_1.flac");
+2549    }
+2550
+2551    /// The download directory as it looks on a device, for the path tests.
+2552    const TEST_ROOT: &str = "/data/data/com.dtourolle.jellytau/files";
+2553
+2554    /// A queued `file_path` cannot walk out of the download directory.
+2555    ///
+2556    /// `download_item` is a command in its own right, so sanitizing in
+2557    /// `download_item_and_start` was routed around by invoking it directly, and
+2558    /// `start_download` then joined the raw string onto the target directory.
+2559    ///
+2560    /// TRACES: DR-211 | UT-205
+2561    #[test]
+2562    fn test_queued_download_paths_cannot_escape_the_download_directory() {
+2563        let root = Path::new(TEST_ROOT);
+2564
+2565        assert!(confine_queued_path(root, "downloads/../../../../etc/cron.d/pwn").is_err());
+2566        assert!(confine_queued_path(root, "../.bashrc").is_err());
+2567        assert!(confine_queued_path(root, "/etc/cron.d/pwn").is_err());
+2568
+2569        // Why the absolute case needs its own guard rather than folding: the
+2570        // join the download path performs discards the base entirely.
+2571        //
+2572        // clippy::join_absolute_paths flags exactly this shape, and is right to
+2573        // in production code — here the discarded base *is* the assertion, so
+2574        // the lint is allowed rather than the code changed. Note the lint would
+2575        // not have caught the original defect: the real join sites take a
+2576        // variable, and the lint only fires on a literal starting with `/`.
+2577        #[allow(clippy::join_absolute_paths)]
+2578        {
+2579            assert_eq!(
+2580                PathBuf::from(root).join("/etc/cron.d/pwn"),
+2581                PathBuf::from("/etc/cron.d/pwn")
+2582            );
+2583        }
+2584    }
+2585
+2586    /// The paths the app builds for itself have to survive unchanged: files are
+2587    /// already on disk and `downloads` rows point at these exact spellings.
+2588    ///
+2589    /// TRACES: DR-211 | UT-205
+2590    #[test]
+2591    fn test_queued_download_paths_are_otherwise_unchanged() {
+2592        let root = Path::new(TEST_ROOT);
+2593
+2594        for path in [
+2595            "downloads/9f8e7d6c",             // MediaCard's queue-for-reconnect
+2596            "videos/movies/Arrival.mp4",      // VideoDownloadButton
+2597            "albums/abc123/01 - Opening.mp3", // queue_album_tracks
+2598            // download_series/download_season build an absolute path, because
+2599            // their base_path is `${targetDir}/videos`.
+2600            "/data/data/com.dtourolle.jellytau/files/videos/Show/S01E02_Pilot.mp4",
+2601        ] {
+2602            assert_eq!(confine_queued_path(root, path).unwrap(), path);
+2603        }
+2604
+2605        // `download_item_and_start` sanitizes the name before calling
+2606        // `download_item`; sanitizing it again must not yield a second, different
+2607        // name, which would orphan the row and the file it names.
+2608        let already = format!("downloads/{}.mp3", sanitize_filename("AC/DC: Live?"));
+2609        assert_eq!(confine_queued_path(root, &already).unwrap(), already);
+2610    }
+2611
+2612    /// A completed row's `file_path` is read straight back into
+2613    /// `std::fs::remove_file` when the download is deleted, so `mark_download_completed`
+2614    /// must not be able to register a file outside the download directory.
+2615    ///
+2616    /// TRACES: DR-211 | UT-205
+2617    #[test]
+2618    fn test_a_completed_download_cannot_register_a_file_outside_the_root() {
+2619        let root = Path::new(TEST_ROOT);
+2620
+2621        // What the worker actually reports — the absolute path it wrote. Stored
+2622        // exactly as it arrives.
+2623        let written = "/data/data/com.dtourolle.jellytau/files/downloads/9f8e7d6c";
+2624        assert_eq!(
+2625            confine_to_root(root, Path::new(written)).unwrap(),
+2626            PathBuf::from(written)
+2627        );
+2628
+2629        // The row's own path, if the frontend falls back to it: relative, and it
+2630        // resolves to where the worker wrote the file.
+2631        assert_eq!(
+2632            confine_to_root(root, &root.join("downloads/9f8e7d6c")).unwrap(),
+2633            PathBuf::from(written)
+2634        );
+2635
+2636        assert!(confine_to_root(root, Path::new("/home/u/.ssh/id_ed25519")).is_err());
+2637        assert!(confine_to_root(
+2638            root,
+2639            Path::new("/data/data/com.dtourolle.jellytau/files/../../../../etc/passwd")
+2640        )
+2641        .is_err());
+2642    }
+2643
+2644    /// Helper to set up test database with required foreign key data
+2645    fn setup_test_db() -> Database {
+2646        let db = Database::open_in_memory().unwrap();
+2647        let conn = db.connection();
+2648        let conn = conn.lock_safe();
+2649
+2650        // Create server
+2651        conn.execute(
+2652            "INSERT INTO servers (id, name, url) VALUES (?1, ?2, ?3)",
+2653            params!["server1", "Test Server", "http://localhost:8096"],
+2654        )
+2655        .unwrap();
+2656
+2657        // Create user
+2658        conn.execute(
+2659            "INSERT INTO users (id, server_id, username) VALUES (?1, ?2, ?3)",
+2660            params!["user1", "server1", "testuser"],
+2661        )
+2662        .unwrap();
+2663
+2664        // Create some items for download testing
+2665        conn.execute(
+2666            "INSERT INTO items (id, server_id, name, item_type, album_id, index_number) VALUES (?1, ?2, ?3, ?4, ?5, ?6)",
+2667            params!["item1", "server1", "Test Song 1", "Audio", "album1", 1],
+2668        )
+2669        .unwrap();
+2670
+2671        conn.execute(
+2672            "INSERT INTO items (id, server_id, name, item_type, album_id, index_number) VALUES (?1, ?2, ?3, ?4, ?5, ?6)",
+2673            params!["item2", "server1", "Test Song 2", "Audio", "album1", 2],
+2674        )
+2675        .unwrap();
+2676
+2677        conn.execute(
+2678            "INSERT INTO items (id, server_id, name, item_type, album_id, index_number) VALUES (?1, ?2, ?3, ?4, ?5, ?6)",
+2679            params!["item3", "server1", "Test Song 3", "Audio", "album1", 3],
+2680        )
+2681        .unwrap();
+2682
+2683        drop(conn);
+2684        db
+2685    }
+2686
+2687    #[test]
+2688    fn test_download_item_returns_correct_id_on_insert() {
+2689        let db = setup_test_db();
+2690        let conn = db.connection();
+2691        let conn = conn.lock_safe();
+2692
+2693        // Insert a new download
+2694        conn.execute(
+2695            "INSERT INTO downloads (item_id, user_id, file_path, mime_type, status, priority, queued_at)
+2696             VALUES (?1, ?2, ?3, ?4, 'pending', ?5, CURRENT_TIMESTAMP)",
+2697            params!["item1", "user1", "/path/to/file.mp3", "audio/mpeg", 0],
+2698        )
+2699        .unwrap();
+2700
+2701        // Query for the download ID (like our fixed code does)
+2702        let download_id: i64 = conn
+2703            .query_row(
+2704                "SELECT id FROM downloads WHERE item_id = ?1 AND user_id = ?2",
+2705                params!["item1", "user1"],
+2706                |row| row.get(0),
+2707            )
+2708            .unwrap();
+2709
+2710        assert!(download_id > 0, "Download ID should be positive");
+2711
+2712        // Verify the download record exists with correct data
+2713        let (status, file_path): (String, String) = conn
+2714            .query_row(
+2715                "SELECT status, file_path FROM downloads WHERE id = ?1",
+2716                params![download_id],
+2717                |row| Ok((row.get(0)?, row.get(1)?)),
+2718            )
+2719            .unwrap();
+2720
+2721        assert_eq!(status, "pending");
+2722        assert_eq!(file_path, "/path/to/file.mp3");
+2723    }
+2724
+2725    #[test]
+2726    fn test_download_item_upsert_returns_correct_id() {
+2727        let db = setup_test_db();
+2728        let conn = db.connection();
+2729        let conn = conn.lock_safe();
+2730
+2731        // First insert
+2732        conn.execute(
+2733            "INSERT INTO downloads (item_id, user_id, file_path, status, priority, queued_at)
+2734             VALUES (?1, ?2, ?3, 'pending', 0, CURRENT_TIMESTAMP)",
+2735            params!["item1", "user1", "/path/to/file.mp3"],
+2736        )
+2737        .unwrap();
+2738
+2739        let first_id: i64 = conn
+2740            .query_row(
+2741                "SELECT id FROM downloads WHERE item_id = ?1 AND user_id = ?2",
+2742                params!["item1", "user1"],
+2743                |row| row.get(0),
+2744            )
+2745            .unwrap();
+2746
+2747        // Simulate failure - mark as failed
+2748        conn.execute(
+2749            "UPDATE downloads SET status = 'failed', error_message = 'Network error' WHERE id = ?1",
+2750            params![first_id],
+2751        )
+2752        .unwrap();
+2753
+2754        // Now re-download (UPSERT) - this is the scenario that was broken
+2755        conn.execute(
+2756            "INSERT INTO downloads (item_id, user_id, file_path, status, priority, queued_at)
+2757             VALUES (?1, ?2, ?3, 'pending', 0, CURRENT_TIMESTAMP)
+2758             ON CONFLICT(item_id, user_id) DO UPDATE SET
+2759               priority = excluded.priority,
+2760               status = 'pending',
+2761               queued_at = CURRENT_TIMESTAMP",
+2762            params!["item1", "user1", "/path/to/file.mp3"],
+2763        )
+2764        .unwrap();
+2765
+2766        // The old buggy code used last_insert_rowid() which would return 0 or wrong value
+2767        // Our fixed code queries by unique constraint
+2768        let second_id: i64 = conn
+2769            .query_row(
+2770                "SELECT id FROM downloads WHERE item_id = ?1 AND user_id = ?2",
+2771                params!["item1", "user1"],
+2772                |row| row.get(0),
+2773            )
+2774            .unwrap();
+2775
+2776        // The ID should be the same as the first insert (UPSERT updated the existing row)
+2777        assert_eq!(first_id, second_id, "UPSERT should return the same row ID");
+2778
+2779        // Verify the status was reset to pending
+2780        let status: String = conn
+2781            .query_row(
+2782                "SELECT status FROM downloads WHERE id = ?1",
+2783                params![second_id],
+2784                |row| row.get(0),
+2785            )
+2786            .unwrap();
+2787
+2788        assert_eq!(
+2789            status, "pending",
+2790            "Status should be reset to pending after UPSERT"
+2791        );
+2792    }
+2793
+2794    #[test]
+2795    fn test_query_by_unique_constraint_is_reliable() {
+2796        // This test demonstrates that querying by unique constraint is always reliable,
+2797        // unlike last_insert_rowid() which has undefined behavior with UPSERT.
+2798        //
+2799        // SQLite documentation states that last_insert_rowid() behavior is undefined
+2800        // when ON CONFLICT triggers an UPDATE instead of INSERT. Some versions return 0,
+2801        // others return the existing row ID - it's not consistent.
+2802        //
+2803        // Our fix: always query by the unique constraint columns (item_id, user_id)
+2804        // to get the correct download ID, regardless of whether INSERT or UPDATE occurred.
+2805        let db = setup_test_db();
+2806        let conn = db.connection();
+2807        let conn = conn.lock_safe();
+2808
+2809        // First insert
+2810        conn.execute(
+2811            "INSERT INTO downloads (item_id, user_id, file_path, status, priority, queued_at)
+2812             VALUES (?1, ?2, ?3, 'pending', 0, CURRENT_TIMESTAMP)",
+2813            params!["item1", "user1", "/path/to/file.mp3"],
+2814        )
+2815        .unwrap();
+2816
+2817        let first_id: i64 = conn
+2818            .query_row(
+2819                "SELECT id FROM downloads WHERE item_id = ?1 AND user_id = ?2",
+2820                params!["item1", "user1"],
+2821                |row| row.get(0),
+2822            )
+2823            .unwrap();
+2824
+2825        // UPSERT (triggers UPDATE)
+2826        conn.execute(
+2827            "INSERT INTO downloads (item_id, user_id, file_path, status, priority, queued_at)
+2828             VALUES (?1, ?2, ?3, 'pending', 0, CURRENT_TIMESTAMP)
+2829             ON CONFLICT(item_id, user_id) DO UPDATE SET
+2830               status = 'pending'",
+2831            params!["item1", "user1", "/path/to/file.mp3"],
+2832        )
+2833        .unwrap();
+2834
+2835        // Our approach: query by unique constraint - always works!
+2836        let id_after_upsert: i64 = conn
+2837            .query_row(
+2838                "SELECT id FROM downloads WHERE item_id = ?1 AND user_id = ?2",
+2839                params!["item1", "user1"],
+2840                |row| row.get(0),
+2841            )
+2842            .unwrap();
+2843
+2844        // This should ALWAYS be the same ID - our approach is reliable
+2845        assert_eq!(
+2846            first_id, id_after_upsert,
+2847            "Query by unique constraint should always return correct ID"
+2848        );
+2849    }
+2850
+2851    #[test]
+2852    fn test_download_album_returns_correct_ids() {
+2853        let db = setup_test_db();
+2854        let conn = db.connection();
+2855        let conn = conn.lock_safe();
+2856
+2857        // Insert downloads for multiple items (simulating album download)
+2858        let track_ids = vec!["item1", "item2", "item3"];
+2859        let mut download_ids = Vec::new();
+2860
+2861        for track_id in &track_ids {
+2862            conn.execute(
+2863                "INSERT INTO downloads (item_id, user_id, file_path, status, priority, queued_at)
+2864                 VALUES (?1, ?2, ?3, 'pending', 100, CURRENT_TIMESTAMP)
+2865                 ON CONFLICT(item_id, user_id) DO UPDATE SET
+2866                   priority = 100,
+2867                   status = 'pending'",
+2868                params![track_id, "user1", format!("/path/{}.mp3", track_id)],
+2869            )
+2870            .unwrap();
+2871
+2872            // Use our fixed approach - query by unique constraint
+2873            let download_id: i64 = conn
+2874                .query_row(
+2875                    "SELECT id FROM downloads WHERE item_id = ?1 AND user_id = ?2",
+2876                    params![track_id, "user1"],
+2877                    |row| row.get(0),
+2878                )
+2879                .unwrap();
+2880
+2881            download_ids.push(download_id);
+2882        }
+2883
+2884        // All IDs should be unique and positive
+2885        assert_eq!(download_ids.len(), 3);
+2886        for id in &download_ids {
+2887            assert!(*id > 0, "Download ID should be positive");
+2888        }
+2889
+2890        // IDs should be unique
+2891        let mut sorted_ids = download_ids.clone();
+2892        sorted_ids.sort();
+2893        sorted_ids.dedup();
+2894        assert_eq!(sorted_ids.len(), 3, "All download IDs should be unique");
+2895    }
+2896
+2897    #[test]
+2898    fn test_download_album_upsert_returns_correct_ids() {
+2899        let db = setup_test_db();
+2900        let conn = db.connection();
+2901        let conn = conn.lock_safe();
+2902
+2903        // First download attempt - insert all
+2904        let track_ids = vec!["item1", "item2", "item3"];
+2905        let mut first_ids = Vec::new();
+2906
+2907        for track_id in &track_ids {
+2908            conn.execute(
+2909                "INSERT INTO downloads (item_id, user_id, file_path, status, priority, queued_at)
+2910                 VALUES (?1, ?2, ?3, 'pending', 100, CURRENT_TIMESTAMP)",
+2911                params![track_id, "user1", format!("/path/{}.mp3", track_id)],
+2912            )
+2913            .unwrap();
+2914
+2915            let id: i64 = conn
+2916                .query_row(
+2917                    "SELECT id FROM downloads WHERE item_id = ?1 AND user_id = ?2",
+2918                    params![track_id, "user1"],
+2919                    |row| row.get(0),
+2920                )
+2921                .unwrap();
+2922            first_ids.push(id);
+2923        }
+2924
+2925        // Mark all as failed
+2926        conn.execute("UPDATE downloads SET status = 'failed'", [])
+2927            .unwrap();
+2928
+2929        // Re-download (UPSERT all)
+2930        let mut second_ids = Vec::new();
+2931
+2932        for track_id in &track_ids {
+2933            conn.execute(
+2934                "INSERT INTO downloads (item_id, user_id, file_path, status, priority, queued_at)
+2935                 VALUES (?1, ?2, ?3, 'pending', 100, CURRENT_TIMESTAMP)
+2936                 ON CONFLICT(item_id, user_id) DO UPDATE SET
+2937                   priority = 100,
+2938                   status = 'pending'",
+2939                params![track_id, "user1", format!("/path/{}.mp3", track_id)],
+2940            )
+2941            .unwrap();
+2942
+2943            let id: i64 = conn
+2944                .query_row(
+2945                    "SELECT id FROM downloads WHERE item_id = ?1 AND user_id = ?2",
+2946                    params![track_id, "user1"],
+2947                    |row| row.get(0),
+2948                )
+2949                .unwrap();
+2950            second_ids.push(id);
+2951        }
+2952
+2953        // IDs should be the same (UPSERT updates existing rows)
+2954        assert_eq!(first_ids, second_ids, "UPSERT should preserve original IDs");
+2955    }
+2956
+2957    #[test]
+2958    fn test_get_downloads_returns_correct_metadata() {
+2959        let db = setup_test_db();
+2960        let conn = db.connection();
+2961        let conn = conn.lock_safe();
+2962
+2963        // Insert a download
+2964        conn.execute(
+2965            "INSERT INTO downloads (item_id, user_id, file_path, status, progress, priority)
+2966             VALUES (?1, ?2, ?3, 'downloading', 0.5, 10)",
+2967            params!["item1", "user1", "/path/to/song.mp3"],
+2968        )
+2969        .unwrap();
+2970
+2971        // Query downloads with item metadata
+2972        let download: DownloadInfo = conn
+2973            .query_row(
+2974                "SELECT d.id, d.item_id, d.user_id, d.file_path, d.file_size, d.mime_type, d.status, d.progress,
+2975                        d.bytes_downloaded, d.queued_at, d.started_at, d.completed_at, d.error_message,
+2976                        d.retry_count, d.priority,
+2977                        COALESCE(d.item_name, i.name) as item_name,
+2978                        COALESCE(d.artist_name, i.artists) as artist_name,
+2979                        COALESCE(d.album_name, i.album_name) as album_name,
+2980                        COALESCE(d.series_name, i.series_name) as series_name,
+2981                        COALESCE(d.season_name, i.season_name) as season_name,
+2982                        COALESCE(d.episode_number, i.index_number) as episode_number,
+2983                        COALESCE(d.season_number, i.parent_index_number) as season_number,
+2984                        d.quality_preset,
+2985                        COALESCE(d.media_type, 'audio') as media_type,
+2986                        COALESCE(d.download_source, 'user') as download_source
+2987                 FROM downloads d
+2988                 LEFT JOIN items i ON d.item_id = i.id
+2989                 WHERE d.user_id = ?1",
+2990                params!["user1"],
+2991                map_download_row,
+2992            )
+2993            .unwrap();
+2994
+2995        assert_eq!(download.item_id, "item1");
+2996        assert_eq!(download.status, "downloading");
+2997        assert!((download.progress - 0.5).abs() < 0.001);
+2998        assert_eq!(download.priority, 10);
+2999        assert_eq!(download.item_name, Some("Test Song 1".to_string()));
+3000    }
+3001
+3002    #[test]
+3003    fn test_download_status_transitions() {
+3004        let db = setup_test_db();
+3005        let conn = db.connection();
+3006        let conn = conn.lock_safe();
+3007
+3008        // Insert pending download
+3009        conn.execute(
+3010            "INSERT INTO downloads (item_id, user_id, file_path, status)
+3011             VALUES (?1, ?2, ?3, 'pending')",
+3012            params!["item1", "user1", "/path/to/song.mp3"],
+3013        )
+3014        .unwrap();
+3015
+3016        let id: i64 = conn.last_insert_rowid();
+3017
+3018        // Transition: pending -> downloading
+3019        conn.execute(
+3020            "UPDATE downloads SET status = 'downloading', started_at = CURRENT_TIMESTAMP WHERE id = ?1",
+3021            params![id],
+3022        )
+3023        .unwrap();
+3024
+3025        let status: String = conn
+3026            .query_row(
+3027                "SELECT status FROM downloads WHERE id = ?1",
+3028                params![id],
+3029                |row| row.get(0),
+3030            )
+3031            .unwrap();
+3032        assert_eq!(status, "downloading");
+3033
+3034        // Transition: downloading -> completed
+3035        conn.execute(
+3036            "UPDATE downloads SET status = 'completed', progress = 1.0, completed_at = CURRENT_TIMESTAMP WHERE id = ?1",
+3037            params![id],
+3038        )
+3039        .unwrap();
+3040
+3041        let (status, progress): (String, f64) = conn
+3042            .query_row(
+3043                "SELECT status, progress FROM downloads WHERE id = ?1",
+3044                params![id],
+3045                |row| Ok((row.get(0)?, row.get(1)?)),
+3046            )
+3047            .unwrap();
+3048        assert_eq!(status, "completed");
+3049        assert!((progress - 1.0).abs() < 0.001);
+3050    }
+3051
+3052    #[test]
+3053    fn test_download_progress_updates() {
+3054        let db = setup_test_db();
+3055        let conn = db.connection();
+3056        let conn = conn.lock_safe();
+3057
+3058        conn.execute(
+3059            "INSERT INTO downloads (item_id, user_id, file_path, status, progress, bytes_downloaded, file_size)
+3060             VALUES (?1, ?2, ?3, 'downloading', 0.0, 0, 10000000)",
+3061            params!["item1", "user1", "/path/to/song.mp3"],
+3062        )
+3063        .unwrap();
+3064
+3065        let id: i64 = conn.last_insert_rowid();
+3066
+3067        // Simulate progress updates
+3068        for i in 1..=10 {
+3069            let progress = i as f64 / 10.0;
+3070            let bytes = i * 1000000;
+3071
+3072            conn.execute(
+3073                "UPDATE downloads SET progress = ?1, bytes_downloaded = ?2 WHERE id = ?3",
+3074                params![progress, bytes, id],
+3075            )
+3076            .unwrap();
+3077
+3078            let (actual_progress, actual_bytes): (f64, i64) = conn
+3079                .query_row(
+3080                    "SELECT progress, bytes_downloaded FROM downloads WHERE id = ?1",
+3081                    params![id],
+3082                    |row| Ok((row.get(0)?, row.get(1)?)),
+3083                )
+3084                .unwrap();
+3085
+3086            assert!((actual_progress - progress).abs() < 0.001);
+3087            assert_eq!(actual_bytes, bytes);
+3088        }
+3089    }
+3090
+3091    #[test]
+3092    fn test_compute_download_stats_empty() {
+3093        let downloads = vec![];
+3094        let stats = compute_download_stats(&downloads);
+3095        assert_eq!(stats.total, 0);
+3096        assert_eq!(stats.active_count, 0);
+3097        assert_eq!(stats.queued_count, 0);
+3098        assert_eq!(stats.completed_count, 0);
+3099        assert_eq!(stats.failed_count, 0);
+3100        assert_eq!(stats.paused_count, 0);
+3101    }
+3102
+3103    #[test]
+3104    fn test_compute_download_stats_mixed() {
+3105        let downloads = vec![
+3106            create_test_download(1, "downloading"),
+3107            create_test_download(2, "pending"),
+3108            create_test_download(3, "downloading"),
+3109            create_test_download(4, "completed"),
+3110            create_test_download(5, "failed"),
+3111            create_test_download(6, "paused"),
+3112        ];
+3113        let stats = compute_download_stats(&downloads);
+3114        assert_eq!(stats.total, 6);
+3115        assert_eq!(stats.active_count, 2);
+3116        assert_eq!(stats.queued_count, 1);
+3117        assert_eq!(stats.completed_count, 1);
+3118        assert_eq!(stats.failed_count, 1);
+3119        assert_eq!(stats.paused_count, 1);
+3120    }
+3121
+3122    #[test]
+3123    fn test_compute_download_stats_all_same_status() {
+3124        let downloads = vec![
+3125            create_test_download(1, "completed"),
+3126            create_test_download(2, "completed"),
+3127            create_test_download(3, "completed"),
+3128        ];
+3129        let stats = compute_download_stats(&downloads);
+3130        assert_eq!(stats.total, 3);
+3131        assert_eq!(stats.active_count, 0);
+3132        assert_eq!(stats.queued_count, 0);
+3133        assert_eq!(stats.completed_count, 3);
+3134        assert_eq!(stats.failed_count, 0);
+3135        assert_eq!(stats.paused_count, 0);
+3136    }
+3137
+3138    /// Helper to create a test DownloadInfo for stats testing
+3139    fn create_test_download(id: i64, status: &str) -> DownloadInfo {
+3140        DownloadInfo {
+3141            id,
+3142            item_id: format!("item{}", id),
+3143            user_id: "test_user".to_string(),
+3144            file_path: format!("/tmp/download{}", id),
+3145            file_size: Some(1000),
+3146            mime_type: Some("audio/flac".to_string()),
+3147            status: status.to_string(),
+3148            progress: 0.0,
+3149            bytes_downloaded: 0,
+3150            queued_at: "2024-01-01T00:00:00Z".to_string(),
+3151            started_at: None,
+3152            completed_at: None,
+3153            error_message: None,
+3154            retry_count: 0,
+3155            priority: 0,
+3156            item_name: Some(format!("Track {}", id)),
+3157            artist_name: None,
+3158            album_name: None,
+3159            series_name: None,
+3160            season_name: None,
+3161            episode_number: None,
+3162            season_number: None,
+3163            quality_preset: None,
+3164            media_type: "audio".to_string(),
+3165            download_source: "user".to_string(),
+3166        }
+3167    }
+3168
+3169    // ===== Album download: track sourcing and album linkage =====
+3170
+3171    /// A database with just the tables the album-download path touches.
+3172    fn album_test_db() -> Arc<crate::storage::db_service::RusqliteService> {
+3173        let conn = rusqlite::Connection::open_in_memory().unwrap();
+3174        conn.execute_batch(
+3175            r#"
+3176            CREATE TABLE items (
+3177                id TEXT PRIMARY KEY,
+3178                server_id TEXT NOT NULL,
+3179                parent_id TEXT,
+3180                name TEXT NOT NULL,
+3181                item_type TEXT NOT NULL,
+3182                album_id TEXT,
+3183                album_name TEXT,
+3184                album_artist TEXT,
+3185                artists TEXT,
+3186                index_number INTEGER
+3187            );
+3188            CREATE TABLE downloads (
+3189                id INTEGER PRIMARY KEY AUTOINCREMENT,
+3190                item_id TEXT NOT NULL,
+3191                user_id TEXT NOT NULL,
+3192                file_path TEXT NOT NULL,
+3193                status TEXT DEFAULT 'pending',
+3194                priority INTEGER DEFAULT 0,
+3195                progress REAL DEFAULT 0,
+3196                queued_at TEXT,
+3197                item_name TEXT,
+3198                artist_name TEXT,
+3199                album_name TEXT,
+3200                media_type TEXT,
+3201                stream_url TEXT,
+3202                target_dir TEXT,
+3203                UNIQUE(item_id, user_id)
+3204            );
+3205            INSERT INTO items (id, server_id, name, item_type)
+3206                VALUES ('album1', 'server1', 'The Golden Age', 'MusicAlbum');
+3207            "#,
+3208        )
+3209        .unwrap();
+3210        Arc::new(crate::storage::db_service::RusqliteService::new(Arc::new(
+3211            Mutex::new(conn),
+3212        )))
+3213    }
+3214
+3215    fn album_track(id: &str, name: &str, index: i32) -> AlbumTrack {
+3216        AlbumTrack {
+3217            id: id.to_string(),
+3218            name: name.to_string(),
+3219            artist_name: Some("Woodkid".to_string()),
+3220            album_name: Some("The Golden Age".to_string()),
+3221            index_number: Some(index),
+3222        }
+3223    }
+3224
+3225    /// The album-download regression: every track the album actually has must be
+3226    /// queued, and each queued track must be linked to its album.
+3227    ///
+3228    /// `download_album` used to take its track list from
+3229    /// `items WHERE album_id = ?`. Jellyfin does not return `AlbumId` on every
+3230    /// listing endpoint, so tracks cached from those endpoints sit in `items`
+3231    /// with a NULL `album_id` — invisible to that query. "Download album" then
+3232    /// silently queued only the subset that happened to carry the link, which is
+3233    /// the reported "only 4-5 songs downloaded". The same column is what offline
+3234    /// browsing joins tracks to their album on (`i.album_id = ?` in
+3235    /// `OfflineRepository::get_items`), so even a track that did download stayed
+3236    /// invisible under its album offline.
+3237    ///
+3238    /// TRACES: UR-018, UR-055 | DR-173 | UT-170
+3239    #[tokio::test]
+3240    async fn test_queue_album_tracks_queues_every_track_and_links_it_to_the_album() {
+3241        let db = album_test_db();
+3242
+3243        // The cache holds all three tracks, but only one carries `album_id` —
+3244        // exactly the state the bug report's database is in.
+3245        for sql in [
+3246            "INSERT INTO items (id, server_id, name, item_type, album_id) \
+3247             VALUES ('t1', 'server1', 'Run Boy Run', 'Audio', 'album1')",
+3248            "INSERT INTO items (id, server_id, name, item_type, album_id) \
+3249             VALUES ('t2', 'server1', 'The Great Escape', 'Audio', NULL)",
+3250            "INSERT INTO items (id, server_id, name, item_type, album_id) \
+3251             VALUES ('t3', 'server1', 'Boat Song', 'Audio', NULL)",
+3252        ] {
+3253            db.execute(Query::new(sql)).await.unwrap();
+3254        }
+3255
+3256        let tracks = vec![
+3257            album_track("t1", "Run Boy Run", 1),
+3258            album_track("t2", "The Great Escape", 2),
+3259            album_track("t3", "Boat Song", 3),
+3260        ];
+3261
+3262        let ids = queue_album_tracks(&db, "album1", &tracks, "user1", "albums/album1")
+3263            .await
+3264            .unwrap();
+3265
+3266        assert_eq!(
+3267            ids.len(),
+3268            3,
+3269            "every track of the album must get a download row"
+3270        );
+3271
+3272        let queued: i64 = db
+3273            .query_one(
+3274                Query::new("SELECT COUNT(*) FROM downloads WHERE status = 'pending'"),
+3275                |row| row.get(0),
+3276            )
+3277            .await
+3278            .unwrap();
+3279        assert_eq!(queued, 3);
+3280
+3281        // Each track is now linked to its album, so the offline album page can
+3282        // find it once the download completes.
+3283        let linked: i64 = db
+3284            .query_one(
+3285                Query::new("SELECT COUNT(*) FROM items WHERE album_id = 'album1'"),
+3286                |row| row.get(0),
+3287            )
+3288            .await
+3289            .unwrap();
+3290        assert_eq!(
+3291            linked, 3,
+3292            "queued tracks must be linked to their album; offline browsing joins on album_id"
+3293        );
+3294    }
+3295
+3296    /// The returned ids must line up with the tracks that were passed in. The
+3297    /// frontend used to pair `downloadIds[i]` with its own `tracks[i]`, which is
+3298    /// only sound if both lists agree — they did not, because the backend
+3299    /// ordered by `index_number` over a different set of rows. Resolving URLs in
+3300    /// Rust removes the pairing entirely, but the order is still the contract
+3301    /// for anything that reads the ids back.
+3302    ///
+3303    /// TRACES: UR-018, UR-055 | DR-173 | UT-170
+3304    #[tokio::test]
+3305    async fn test_queue_album_tracks_returns_ids_in_track_order() {
+3306        let db = album_test_db();
+3307        let tracks = vec![
+3308            album_track("t1", "Run Boy Run", 1),
+3309            album_track("t2", "The Great Escape", 2),
+3310        ];
+3311
+3312        let ids = queue_album_tracks(&db, "album1", &tracks, "user1", "albums/album1")
+3313            .await
+3314            .unwrap();
+3315
+3316        for (id, track) in ids.iter().zip(tracks.iter()) {
+3317            let item_id: String = db
+3318                .query_one(
+3319                    Query::with_params(
+3320                        "SELECT item_id FROM downloads WHERE id = ?",
+3321                        vec![QueryParam::Int64(*id)],
+3322                    ),
+3323                    |row| row.get(0),
+3324                )
+3325                .await
+3326                .unwrap();
+3327            assert_eq!(&item_id, &track.id, "id {} must be {}'s row", id, track.id);
+3328        }
+3329    }
+3330
+3331    /// Re-queueing an album already partly downloaded must not duplicate rows or
+3332    /// reset a completed track — it fills in what is missing.
+3333    ///
+3334    /// TRACES: UR-018, UR-055 | DR-173 | UT-170
+3335    #[tokio::test]
+3336    async fn test_queue_album_tracks_is_idempotent() {
+3337        let db = album_test_db();
+3338        let tracks = vec![
+3339            album_track("t1", "Run Boy Run", 1),
+3340            album_track("t2", "The Great Escape", 2),
+3341        ];
+3342
+3343        let first = queue_album_tracks(&db, "album1", &tracks, "user1", "albums/album1")
+3344            .await
+3345            .unwrap();
+3346        let second = queue_album_tracks(&db, "album1", &tracks, "user1", "albums/album1")
+3347            .await
+3348            .unwrap();
+3349
+3350        assert_eq!(first, second, "the same tracks must map to the same rows");
+3351
+3352        let rows: i64 = db
+3353            .query_one(Query::new("SELECT COUNT(*) FROM downloads"), |row| {
+3354                row.get(0)
+3355            })
+3356            .await
+3357            .unwrap();
+3358        assert_eq!(rows, 2, "re-queueing must not duplicate download rows");
+3359    }
+3360
+3361    /// Two tracks of one album can share a title — a deluxe edition carrying the
+3362    /// album version and a demo of the same song, or the same song on two discs.
+3363    /// Naming the file after the title alone gave them one path, so the second
+3364    /// download overwrote the first and the album ended up short however many
+3365    /// duplicates it had.
+3366    ///
+3367    /// TRACES: UR-018, UR-055 | DR-173 | UT-172
+3368    #[test]
+3369    fn test_album_file_names_are_unique_within_the_album() {
+3370        let tracks = vec![
+3371            album_track("t1", "Crucified Again", 5),
+3372            album_track("t2", "Crucified Again", 5),
+3373            album_track("t3", "Get Right", 7),
+3374        ];
+3375
+3376        let names = album_file_names(&tracks);
+3377
+3378        assert_eq!(names.len(), 3);
+3379        let unique: std::collections::HashSet<_> = names.iter().collect();
+3380        assert_eq!(
+3381            unique.len(),
+3382            3,
+3383            "every track of an album needs its own file: {:?}",
+3384            names
+3385        );
+3386        assert!(names.iter().all(|n| n.ends_with(".mp3")), "{:?}", names);
+3387        assert!(
+3388            names[2].contains("Get Right"),
+3389            "an unambiguous title keeps its name: {}",
+3390            names[2]
+3391        );
+3392    }
+3393
+3394    /// Path separators in a track title must not escape the album directory.
+3395    ///
+3396    /// TRACES: UR-018, UR-055 | DR-173 | UT-172
+3397    #[test]
+3398    fn test_album_file_names_sanitize_the_title() {
+3399        let names = album_file_names(&[album_track("t1", "AC/DC: Live?", 1)]);
+3400        assert!(!names[0].contains('/'), "{}", names[0]);
+3401        assert!(!names[0].contains(':'), "{}", names[0]);
+3402    }
+3403
+3404    /// The offline fallback reads the catalog directly, not through the
+3405    /// availability-gated offline listing: queueing an album while the server is
+3406    /// unreachable is a supported flow (the rows resolve on reconnect), and
+3407    /// gating it on what is already downloaded would queue only the tracks the
+3408    /// device already has.
+3409    ///
+3410    /// TRACES: UR-018, UR-055 | DR-173 | UT-170
+3411    #[tokio::test]
+3412    async fn test_cached_album_tracks_finds_tracks_by_either_album_link() {
+3413        let db = album_test_db();
+3414        for sql in [
+3415            "INSERT INTO items (id, server_id, name, item_type, album_id, index_number) \
+3416             VALUES ('t1', 'server1', 'Run Boy Run', 'Audio', 'album1', 1)",
+3417            // Linked by parent_id only — how a track cached from a folder
+3418            // listing lands in the catalog.
+3419            "INSERT INTO items (id, server_id, name, item_type, parent_id, index_number) \
+3420             VALUES ('t2', 'server1', 'The Great Escape', 'Audio', 'album1', 2)",
+3421            // A different album's track must not be swept in.
+3422            "INSERT INTO items (id, server_id, name, item_type, album_id) \
+3423             VALUES ('other', 'server1', 'Iron', 'Audio', 'album2')",
+3424        ] {
+3425            db.execute(Query::new(sql)).await.unwrap();
+3426        }
+3427
+3428        let tracks = cached_album_tracks(&db, "album1").await.unwrap();
+3429        let ids: Vec<_> = tracks.iter().map(|t| t.id.as_str()).collect();
+3430        assert_eq!(ids, vec!["t1", "t2"]);
+3431    }
+3432
+3433    /// Tracks the cache has never seen still get queued: the row is created and
+3434    /// an `items` row is written for it, so the download is both startable and
+3435    /// visible offline afterwards.
+3436    ///
+3437    /// TRACES: UR-018, UR-055 | DR-173 | UT-170
+3438    #[tokio::test]
+3439    async fn test_queue_album_tracks_handles_tracks_absent_from_the_cache() {
+3440        let db = album_test_db();
+3441        let tracks = vec![album_track("never-cached", "Iron", 1)];
+3442
+3443        let ids = queue_album_tracks(&db, "album1", &tracks, "user1", "albums/album1")
+3444            .await
+3445            .unwrap();
+3446        assert_eq!(ids.len(), 1);
+3447
+3448        let (item_type, album_id): (String, Option<String>) = db
+3449            .query_one(
+3450                Query::new("SELECT item_type, album_id FROM items WHERE id = 'never-cached'"),
+3451                |row| Ok((row.get(0)?, row.get(1)?)),
+3452            )
+3453            .await
+3454            .unwrap();
+3455        assert_eq!(item_type, "Audio");
+3456        assert_eq!(album_id.as_deref(), Some("album1"));
+3457    }
+3458}
+
\ No newline at end of file diff --git a/api/src/jellytau_lib/commands/download/pinning.rs.html b/api/src/jellytau_lib/commands/download/pinning.rs.html new file mode 100644 index 00000000..7e7d6867 --- /dev/null +++ b/api/src/jellytau_lib/commands/download/pinning.rs.html @@ -0,0 +1,72 @@ +pinning.rs - sourceSkip to main content

jellytau_lib/commands/download/
pinning.rs

1//! Pinning commands - protect an item's cached metadata from cache clearing.
+2//!
+3//! TRACES: UR-044 | DR-056
+4
+5use std::sync::Arc;
+6use tauri::State;
+7
+8use crate::commands::DatabaseWrapper;
+9use crate::storage::db_service::{DatabaseService, Query, QueryParam};
+10
+11/// Pin an item's metadata (protects from cache clear)
+12#[tauri::command]
+13#[specta::specta]
+14pub async fn pin_item(db: State<'_, DatabaseWrapper>, item_id: String) -> Result<(), String> {
+15    let db_service = {
+16        let database = db.0.lock().map_err(|e| e.to_string())?;
+17        Arc::new(database.service())
+18    };
+19
+20    let query = Query::with_params(
+21        "UPDATE items SET is_pinned = 1 WHERE id = ?",
+22        vec![QueryParam::String(item_id)],
+23    );
+24
+25    db_service.execute(query).await.map_err(|e| e.to_string())?;
+26    Ok(())
+27}
+28
+29/// Unpin an item's metadata
+30#[tauri::command]
+31#[specta::specta]
+32pub async fn unpin_item(db: State<'_, DatabaseWrapper>, item_id: String) -> Result<(), String> {
+33    let db_service = {
+34        let database = db.0.lock().map_err(|e| e.to_string())?;
+35        Arc::new(database.service())
+36    };
+37
+38    let query = Query::with_params(
+39        "UPDATE items SET is_pinned = 0 WHERE id = ?",
+40        vec![QueryParam::String(item_id)],
+41    );
+42
+43    db_service.execute(query).await.map_err(|e| e.to_string())?;
+44    Ok(())
+45}
+46
+47/// Check if an item is pinned
+48#[tauri::command]
+49#[specta::specta]
+50pub async fn is_item_pinned(
+51    db: State<'_, DatabaseWrapper>,
+52    item_id: String,
+53) -> Result<bool, String> {
+54    let db_service = {
+55        let database = db.0.lock().map_err(|e| e.to_string())?;
+56        Arc::new(database.service())
+57    };
+58
+59    let query = Query::with_params(
+60        "SELECT COALESCE(is_pinned, 0) FROM items WHERE id = ?",
+61        vec![QueryParam::String(item_id)],
+62    );
+63
+64    let is_pinned: i32 = db_service
+65        .query_optional(query, |row| row.get(0))
+66        .await
+67        .map_err(|e| e.to_string())?
+68        .unwrap_or(0);
+69
+70    Ok(is_pinned == 1)
+71}
+
\ No newline at end of file diff --git a/api/src/jellytau_lib/commands/download/smart_cache.rs.html b/api/src/jellytau_lib/commands/download/smart_cache.rs.html new file mode 100644 index 00000000..15649a0e --- /dev/null +++ b/api/src/jellytau_lib/commands/download/smart_cache.rs.html @@ -0,0 +1,231 @@ +smart_cache.rs - sourceSkip to main content

jellytau_lib/commands/download/
smart_cache.rs

1//! Smart-cache statistics/config and album recommendation commands.
+2//!
+3//! TRACES: UR-045 | DR-057
+4
+5use log::info;
+6use std::sync::Arc;
+7use tauri::State;
+8
+9use crate::commands::{DatabaseWrapper, SmartCacheWrapper};
+10use crate::storage::db_service::{DatabaseService, Query, QueryParam};
+11
+12/// SmartCache statistics
+13#[derive(specta::Type, Debug, Clone, serde::Serialize)]
+14pub struct SmartCacheStats {
+15    pub total_size: u64,
+16    pub storage_limit: u64,
+17    pub available_space: u64,
+18    pub items_count: i64,
+19    pub config: crate::download::cache::CacheConfig,
+20}
+21
+22/// Get SmartCache statistics
+23#[tauri::command]
+24#[specta::specta]
+25pub async fn get_smart_cache_stats(
+26    db: State<'_, DatabaseWrapper>,
+27    smart_cache: State<'_, SmartCacheWrapper>,
+28    user_id: String,
+29) -> Result<SmartCacheStats, String> {
+30    let db_service = {
+31        let database = db.0.lock().map_err(|e| e.to_string())?;
+32        Arc::new(database.service())
+33    };
+34
+35    // Clone cache to avoid holding lock across async operations
+36    let cache = {
+37        let guard = smart_cache.0.lock().map_err(|e| e.to_string())?;
+38        guard.clone()
+39    };
+40
+41    let total_size = cache
+42        .get_total_download_size_async(&db_service, &user_id)
+43        .await?;
+44
+45    let config = cache
+46        .get_config()
+47        .ok_or_else(|| "Failed to get cache config".to_string())?;
+48
+49    let storage_limit = config.storage_limit;
+50    let available_space = storage_limit.saturating_sub(total_size);
+51
+52    // Get item count
+53    let count_query = Query::with_params(
+54        "SELECT COUNT(*) FROM downloads WHERE user_id = ? AND status = 'completed'",
+55        vec![QueryParam::String(user_id)],
+56    );
+57    let items_count: i64 = db_service
+58        .query_one(count_query, |row| row.get(0))
+59        .await
+60        .unwrap_or(0);
+61
+62    Ok(SmartCacheStats {
+63        total_size,
+64        storage_limit,
+65        available_space,
+66        items_count,
+67        config,
+68    })
+69}
+70
+71/// Update SmartCache configuration
+72#[tauri::command]
+73#[specta::specta]
+74pub async fn update_smart_cache_config(
+75    smart_cache: State<'_, SmartCacheWrapper>,
+76    config: crate::download::cache::CacheConfig,
+77) -> Result<(), String> {
+78    let cache = smart_cache.0.lock().map_err(|e| e.to_string())?;
+79    cache.update_config(config);
+80    info!("Updated SmartCache configuration");
+81    Ok(())
+82}
+83
+84/// Get SmartCache configuration
+85#[tauri::command]
+86#[specta::specta]
+87pub async fn get_smart_cache_config(
+88    smart_cache: State<'_, SmartCacheWrapper>,
+89) -> Result<crate::download::cache::CacheConfig, String> {
+90    let cache = smart_cache.0.lock().map_err(|e| e.to_string())?;
+91    cache
+92        .get_config()
+93        .ok_or_else(|| "Failed to get cache config".to_string())
+94}
+95
+96/// Album recommendation info
+97#[derive(specta::Type, Debug, Clone, serde::Serialize)]
+98pub struct AlbumRecommendation {
+99    pub album_id: String,
+100    pub album_name: String,
+101    pub tracks_played: usize,
+102    pub total_tracks: usize,
+103    pub should_download: bool,
+104}
+105
+106/// Album affinity status info
+107#[derive(specta::Type, Debug, Clone, serde::Serialize)]
+108#[serde(rename_all = "camelCase")]
+109pub struct AlbumAffinityStatus {
+110    pub album_id: String,
+111    pub unique_tracks_played: usize,
+112    pub threshold: usize,
+113    pub threshold_reached: bool,
+114}
+115
+116/// Get album recommendations based on play history
+117#[tauri::command]
+118#[specta::specta]
+119pub async fn get_album_recommendations(
+120    db: State<'_, DatabaseWrapper>,
+121    smart_cache: State<'_, SmartCacheWrapper>,
+122    user_id: String,
+123) -> Result<Vec<AlbumRecommendation>, String> {
+124    let db_service = {
+125        let database = db.0.lock().map_err(|e| e.to_string())?;
+126        Arc::new(database.service())
+127    };
+128
+129    // Clone cache to avoid holding lock across async operations
+130    let cache = {
+131        let guard = smart_cache.0.lock().map_err(|e| e.to_string())?;
+132        guard.clone()
+133    };
+134
+135    // Get all albums that user has played tracks from
+136    let query = Query::with_params(
+137        "SELECT DISTINCT i.album_id, a.name
+138         FROM user_data ud
+139         JOIN items i ON ud.item_id = i.id
+140         JOIN items a ON i.album_id = a.id
+141         WHERE ud.user_id = ?
+142           AND ud.play_count > 0
+143           AND i.item_type = 'Audio'
+144           AND i.album_id IS NOT NULL",
+145        vec![QueryParam::String(user_id.clone())],
+146    );
+147
+148    let albums: Vec<(String, String)> = db_service
+149        .query_many(query, |row| Ok((row.get(0)?, row.get(1)?)))
+150        .await
+151        .unwrap_or_default();
+152
+153    let mut recommendations = Vec::new();
+154
+155    for (album_id, album_name) in albums {
+156        // Check if should cache
+157        let should_download = cache.should_cache_album(&album_id).unwrap_or(false);
+158
+159        // Get track counts
+160        let tracks_query = Query::with_params(
+161            "SELECT
+162                COUNT(*) as total,
+163                COUNT(ud.id) as played
+164             FROM items i
+165             LEFT JOIN user_data ud ON i.id = ud.item_id AND ud.user_id = ?
+166             WHERE i.album_id = ? AND i.item_type = 'Audio'",
+167            vec![
+168                QueryParam::String(user_id.clone()),
+169                QueryParam::String(album_id.clone()),
+170            ],
+171        );
+172
+173        let (total_tracks, tracks_played): (i64, i64) = db_service
+174            .query_one(tracks_query, |row| Ok((row.get(0)?, row.get(1)?)))
+175            .await
+176            .unwrap_or((0, 0));
+177
+178        if tracks_played > 0 {
+179            recommendations.push(AlbumRecommendation {
+180                album_id,
+181                album_name,
+182                tracks_played: tracks_played as usize,
+183                total_tracks: total_tracks as usize,
+184                should_download,
+185            });
+186        }
+187    }
+188
+189    // Sort by tracks played (descending)
+190    recommendations.sort_by_key(|r| std::cmp::Reverse(r.tracks_played));
+191
+192    Ok(recommendations)
+193}
+194
+195/// Get album affinity status for all tracked albums
+196/// This shows the SmartCache's internal play history and threshold status
+197#[tauri::command]
+198#[specta::specta]
+199pub fn get_album_affinity_status(
+200    smart_cache: State<'_, SmartCacheWrapper>,
+201) -> Result<Vec<AlbumAffinityStatus>, String> {
+202    let cache = smart_cache.0.lock().map_err(|e| e.to_string())?;
+203
+204    // Get the threshold from config
+205    let threshold = cache
+206        .get_config()
+207        .map(|c| c.album_affinity_threshold)
+208        .unwrap_or(3);
+209
+210    // Get all tracked albums with their play counts
+211    let play_history = cache.get_album_play_history();
+212
+213    let mut statuses: Vec<AlbumAffinityStatus> = play_history
+214        .into_iter()
+215        .map(|(album_id, unique_tracks_played)| {
+216            let threshold_reached = unique_tracks_played >= threshold;
+217            AlbumAffinityStatus {
+218                album_id,
+219                unique_tracks_played,
+220                threshold,
+221                threshold_reached,
+222            }
+223        })
+224        .collect();
+225
+226    // Sort by play count (descending)
+227    statuses.sort_by_key(|s| std::cmp::Reverse(s.unique_tracks_played));
+228
+229    Ok(statuses)
+230}
+
\ No newline at end of file diff --git a/api/src/jellytau_lib/commands/favorites.rs.html b/api/src/jellytau_lib/commands/favorites.rs.html new file mode 100644 index 00000000..3a1323b0 --- /dev/null +++ b/api/src/jellytau_lib/commands/favorites.rs.html @@ -0,0 +1,369 @@ +favorites.rs - sourceSkip to main content

jellytau_lib/commands/
favorites.rs

1//! Pushing favourite toggles made while the server was unreachable.
+2//!
+3//! Favouriting works offline: `storage_toggle_favorite` writes the local
+4//! `user_data` row and sets `pending_sync = 1`. Until DR-120 nothing ever
+5//! cleared that flag — the offline `mark_favorite`/`unmark_favorite` are no-ops
+6//! and `syncService.queueFavorite` had no callers — so an offline toggle was
+7//! silently lost.
+8//!
+9//! The drain lives in Rust, not the frontend, because it must run whether or
+10//! not any view is mounted; a drain started by a component dies with it.
+11//!
+12//! TRACES: UR-069 | DR-120 | UT-103
+13
+14use std::sync::Arc;
+15
+16use async_trait::async_trait;
+17use log::{debug, info, warn};
+18use tauri::{Emitter, Listener, Manager};
+19
+20use crate::repository::types::RepoError;
+21use crate::repository::MediaRepository;
+22use crate::storage::db_service::{DatabaseService, Query, QueryParam, RusqliteService};
+23
+24/// The subset of the repository the drain needs.
+25///
+26/// Narrow on purpose: a test double for `MediaRepository` would be forty
+27/// unimplemented methods, which is how a drain ends up untested.
+28#[async_trait]
+29pub trait FavoriteSink: Send + Sync {
+30    async fn push_favorite(&self, item_id: &str, is_favorite: bool) -> Result<(), RepoError>;
+31}
+32
+33#[async_trait]
+34impl<T: MediaRepository + ?Sized> FavoriteSink for T {
+35    async fn push_favorite(&self, item_id: &str, is_favorite: bool) -> Result<(), RepoError> {
+36        if is_favorite {
+37            self.mark_favorite(item_id).await
+38        } else {
+39            self.unmark_favorite(item_id).await
+40        }
+41    }
+42}
+43
+44/// A local favourite change still waiting to reach the server.
+45#[derive(Debug, Clone, PartialEq, Eq)]
+46pub struct PendingFavorite {
+47    pub item_id: String,
+48    pub is_favorite: bool,
+49}
+50
+51/// Read every favourite change this user has pending.
+52async fn read_pending(
+53    db: &Arc<RusqliteService>,
+54    user_id: &str,
+55) -> Result<Vec<PendingFavorite>, String> {
+56    db.query_many(
+57        Query::with_params(
+58            "SELECT item_id, is_favorite FROM user_data \
+59             WHERE user_id = ? AND pending_sync = 1 AND is_favorite IS NOT NULL",
+60            vec![QueryParam::String(user_id.to_string())],
+61        ),
+62        |row| {
+63            Ok(PendingFavorite {
+64                item_id: row.get::<_, String>(0)?,
+65                is_favorite: row.get::<_, Option<i32>>(1)?.unwrap_or(0) != 0,
+66            })
+67        },
+68    )
+69    .await
+70}
+71
+72/// Push pending favourite changes to the server and clear their flags.
+73///
+74/// Returns the ids that reached the server, for the `favorites-changed` event.
+75/// A row whose push fails keeps `pending_sync = 1` and is retried on the next
+76/// reconnect rather than being dropped.
+77///
+78/// TRACES: UR-069 | DR-120 | UT-103
+79pub async fn drain_pending_favorites(
+80    db: &Arc<RusqliteService>,
+81    sink: &dyn FavoriteSink,
+82    user_id: &str,
+83) -> Result<Vec<String>, String> {
+84    let pending = read_pending(db, user_id).await?;
+85    if pending.is_empty() {
+86        return Ok(Vec::new());
+87    }
+88
+89    info!(
+90        "[Favorites] Pushing {} favourite change(s) queued while offline",
+91        pending.len()
+92    );
+93
+94    let mut pushed = Vec::new();
+95    for change in pending {
+96        match sink
+97            .push_favorite(&change.item_id, change.is_favorite)
+98            .await
+99        {
+100            Ok(()) => {
+101                let cleared = db
+102                    .execute(Query::with_params(
+103                        "UPDATE user_data SET pending_sync = 0, synced_at = ? \
+104                         WHERE user_id = ? AND item_id = ?",
+105                        vec![
+106                            QueryParam::String(chrono::Utc::now().to_rfc3339()),
+107                            QueryParam::String(user_id.to_string()),
+108                            QueryParam::String(change.item_id.clone()),
+109                        ],
+110                    ))
+111                    .await;
+112
+113                match cleared {
+114                    Ok(_) => pushed.push(change.item_id),
+115                    // The server took it; failing to clear the flag only means
+116                    // we push it again next time, which is harmless.
+117                    Err(e) => warn!(
+118                        "[Favorites] Pushed {} but could not clear pending_sync: {}",
+119                        change.item_id, e
+120                    ),
+121                }
+122            }
+123            Err(e) => {
+124                // Still pending — retried on the next reconnect.
+125                debug!(
+126                    "[Favorites] Deferring {}, server rejected the push: {:?}",
+127                    change.item_id, e
+128                );
+129            }
+130        }
+131    }
+132
+133    Ok(pushed)
+134}
+135
+136/// Drain on every offline→online transition.
+137///
+138/// Hooks the `connectivity:reconnected` event the `ConnectivityMonitor`
+139/// already emits, rather than polling — reachability is derived from real
+140/// traffic (DR-055) and this just reacts to it.
+141///
+142/// TRACES: UR-069 | DR-120
+143pub fn spawn_favorites_drain(app: tauri::AppHandle) {
+144    let handle = app.clone();
+145    app.listen("connectivity:reconnected", move |_event| {
+146        let app = handle.clone();
+147        tauri::async_runtime::spawn(async move {
+148            if let Err(e) = run_drain(&app).await {
+149                warn!("[Favorites] Drain skipped: {}", e);
+150            }
+151        });
+152    });
+153}
+154
+155async fn run_drain(app: &tauri::AppHandle) -> Result<(), String> {
+156    let db_service: Arc<RusqliteService> = {
+157        let db = app.state::<crate::commands::storage::DatabaseWrapper>();
+158        let database = db.0.lock().map_err(|e| e.to_string())?;
+159        Arc::new(database.service())
+160    };
+161
+162    let (repo, user_id) = {
+163        let manager = app.state::<crate::commands::repository::RepositoryManagerWrapper>();
+164        let handles = manager.0.handles();
+165        let Some(handle) = handles.first() else {
+166            // Not signed in — nothing to push on behalf of.
+167            return Ok(());
+168        };
+169        let repo = manager.0.get(handle).ok_or("Repository not found")?;
+170        let user_id = repo.user_id().to_string();
+171        (repo, user_id)
+172    };
+173
+174    let pushed = drain_pending_favorites(&db_service, repo.as_ref(), &user_id).await?;
+175
+176    if !pushed.is_empty() {
+177        let event = crate::commands::repository::FavoritesChangedEvent { item_ids: pushed };
+178        if let Err(e) = app.emit(crate::commands::repository::FAVORITES_CHANGED_EVENT, &event) {
+179            warn!("[Favorites] Failed to emit change event: {}", e);
+180        }
+181    }
+182
+183    Ok(())
+184}
+185
+186#[cfg(test)]
+187mod tests {
+188    use super::*;
+189    use crate::utils::lock::MutexSafe;
+190    use rusqlite::Connection;
+191    use std::sync::Mutex;
+192
+193    /// Records what the server was asked to do, and can be told to fail.
+194    struct RecordingSink {
+195        calls: Mutex<Vec<(String, bool)>>,
+196        fail_for: Option<String>,
+197    }
+198
+199    impl RecordingSink {
+200        fn new() -> Self {
+201            Self {
+202                calls: Mutex::new(Vec::new()),
+203                fail_for: None,
+204            }
+205        }
+206
+207        fn failing_for(item_id: &str) -> Self {
+208            Self {
+209                calls: Mutex::new(Vec::new()),
+210                fail_for: Some(item_id.to_string()),
+211            }
+212        }
+213
+214        fn calls(&self) -> Vec<(String, bool)> {
+215            let mut calls = self.calls.lock_safe().clone();
+216            calls.sort();
+217            calls
+218        }
+219    }
+220
+221    #[async_trait]
+222    impl FavoriteSink for RecordingSink {
+223        async fn push_favorite(&self, item_id: &str, is_favorite: bool) -> Result<(), RepoError> {
+224            if self.fail_for.as_deref() == Some(item_id) {
+225                return Err(RepoError::Offline);
+226            }
+227            self.calls
+228                .lock()
+229                .unwrap()
+230                .push((item_id.to_string(), is_favorite));
+231            Ok(())
+232        }
+233    }
+234
+235    fn test_db() -> Arc<RusqliteService> {
+236        let conn = Connection::open_in_memory().unwrap();
+237        conn.execute_batch(
+238            r#"
+239            CREATE TABLE user_data (
+240                user_id TEXT NOT NULL,
+241                item_id TEXT NOT NULL,
+242                is_favorite INTEGER,
+243                synced_at TEXT,
+244                pending_sync INTEGER DEFAULT 0,
+245                PRIMARY KEY (user_id, item_id)
+246            );
+247            "#,
+248        )
+249        .unwrap();
+250        Arc::new(RusqliteService::new(Arc::new(Mutex::new(conn))))
+251    }
+252
+253    async fn seed(db: &Arc<RusqliteService>, rows: &[(&str, &str, i32, i32)]) {
+254        for (user, item, fav, pending) in rows {
+255            db.execute(Query::with_params(
+256                "INSERT INTO user_data (user_id, item_id, is_favorite, pending_sync) \
+257                 VALUES (?, ?, ?, ?)",
+258                vec![
+259                    QueryParam::String(user.to_string()),
+260                    QueryParam::String(item.to_string()),
+261                    QueryParam::Int(*fav),
+262                    QueryParam::Int(*pending),
+263                ],
+264            ))
+265            .await
+266            .unwrap();
+267        }
+268    }
+269
+270    async fn pending_flag(db: &Arc<RusqliteService>, item_id: &str) -> Option<i32> {
+271        db.query_optional(
+272            Query::with_params(
+273                "SELECT pending_sync FROM user_data WHERE item_id = ?",
+274                vec![QueryParam::String(item_id.to_string())],
+275            ),
+276            |row| row.get::<_, Option<i32>>(0),
+277        )
+278        .await
+279        .unwrap()
+280        .flatten()
+281    }
+282
+283    /// UT-103 — the core of the bug: a favourite toggled while offline reaches
+284    /// the server on reconnect, and stops being pending.
+285    ///
+286    /// TRACES: UR-069 | DR-120 | UT-103
+287    #[tokio::test]
+288    async fn test_drain_pushes_pending_favorites_and_clears_the_flag() {
+289        let db = test_db();
+290        seed(
+291            &db,
+292            &[
+293                ("u1", "marked-offline", 1, 1),
+294                ("u1", "unmarked-offline", 0, 1),
+295                // Already synced — must not be pushed again.
+296                ("u1", "already-synced", 1, 0),
+297            ],
+298        )
+299        .await;
+300
+301        let sink = RecordingSink::new();
+302        let pushed = drain_pending_favorites(&db, &sink, "u1").await.unwrap();
+303
+304        assert_eq!(
+305            sink.calls(),
+306            vec![
+307                ("marked-offline".to_string(), true),
+308                ("unmarked-offline".to_string(), false),
+309            ],
+310            "both pending changes push, with their direction preserved"
+311        );
+312        assert_eq!(pushed.len(), 2);
+313        assert_eq!(pending_flag(&db, "marked-offline").await, Some(0));
+314        assert_eq!(pending_flag(&db, "unmarked-offline").await, Some(0));
+315    }
+316
+317    /// A push that fails keeps its row pending, so the change is retried rather
+318    /// than dropped on the floor.
+319    ///
+320    /// TRACES: UR-069 | DR-120 | UT-103
+321    #[tokio::test]
+322    async fn test_drain_leaves_failed_pushes_pending() {
+323        let db = test_db();
+324        seed(&db, &[("u1", "ok", 1, 1), ("u1", "boom", 1, 1)]).await;
+325
+326        let sink = RecordingSink::failing_for("boom");
+327        let pushed = drain_pending_favorites(&db, &sink, "u1").await.unwrap();
+328
+329        assert_eq!(pushed, vec!["ok".to_string()]);
+330        assert_eq!(pending_flag(&db, "ok").await, Some(0));
+331        assert_eq!(
+332            pending_flag(&db, "boom").await,
+333            Some(1),
+334            "a failed push must stay queued for the next reconnect"
+335        );
+336    }
+337
+338    /// Another user's queued changes are not pushed with this user's token.
+339    ///
+340    /// TRACES: UR-069 | DR-120 | UT-103
+341    #[tokio::test]
+342    async fn test_drain_only_touches_the_given_user() {
+343        let db = test_db();
+344        seed(&db, &[("u1", "mine", 1, 1), ("u2", "theirs", 1, 1)]).await;
+345
+346        let sink = RecordingSink::new();
+347        let pushed = drain_pending_favorites(&db, &sink, "u1").await.unwrap();
+348
+349        assert_eq!(pushed, vec!["mine".to_string()]);
+350        assert_eq!(pending_flag(&db, "theirs").await, Some(1));
+351    }
+352
+353    /// Nothing pending means no server calls at all — a reconnect must not
+354    /// generate traffic just because it happened.
+355    ///
+356    /// TRACES: UR-069 | DR-120 | UT-103
+357    #[tokio::test]
+358    async fn test_drain_is_a_noop_when_nothing_is_pending() {
+359        let db = test_db();
+360        seed(&db, &[("u1", "synced", 1, 0)]).await;
+361
+362        let sink = RecordingSink::new();
+363        let pushed = drain_pending_favorites(&db, &sink, "u1").await.unwrap();
+364
+365        assert!(pushed.is_empty());
+366        assert!(sink.calls().is_empty());
+367    }
+368}
+
\ No newline at end of file diff --git a/api/src/jellytau_lib/commands/library.rs.html b/api/src/jellytau_lib/commands/library.rs.html new file mode 100644 index 00000000..cb86dd55 --- /dev/null +++ b/api/src/jellytau_lib/commands/library.rs.html @@ -0,0 +1,327 @@ +library.rs - sourceSkip to main content

jellytau_lib/commands/
library.rs

1//! Library browsing preferences — currently, which folders are hidden.
+2//!
+3//! The setting replaces a hardcoded frontend filter that dropped any item
+4//! literally named "Podcasts", which was one user's folder layout keyed on an
+5//! English string and shipped to everyone. What is hidden is now a user choice
+6//! made of stable ids, applied in the repository layer
+7//! (`repository::exclusions`) so every query path agrees; the frontend only
+8//! renders a picker over the candidates this module serves.
+9//!
+10//! TRACES: UR-076 | DR-209
+11
+12use std::sync::Arc;
+13
+14use log::{debug, info, warn};
+15use tauri::{Manager, State};
+16
+17use crate::commands::repository::RepositoryManagerWrapper;
+18use crate::commands::storage::DatabaseWrapper;
+19use crate::repository::exclusions;
+20use crate::repository::types::{GetItemsOptions, SearchScope};
+21use crate::repository::MediaRepository;
+22use crate::settings::LibrarySettings;
+23use crate::storage::db_service::{DatabaseService, Query, QueryParam};
+24use crate::utils::lock::MutexSafe;
+25
+26/// `app_settings` key holding the persisted library preferences (JSON).
+27///
+28/// Persisted for the same reason the streaming cap is: a hidden folder that
+29/// silently comes back on the next launch is a setting the user has to keep
+30/// re-applying, and they would have no way to tell it had been forgotten.
+31const LIBRARY_SETTINGS_KEY: &str = "library_settings";
+32
+33/// How many immediate children of a library the picker will consider.
+34///
+35/// A music library's root listing is folders and (on some layouts) artists, not
+36/// the whole catalog, so this is generous. It exists to stop a pathological
+37/// library from turning the settings page into an unbounded fetch.
+38const CANDIDATE_SCAN_LIMIT: usize = 500;
+39
+40/// Something the user may choose to hide: a library, or a folder directly
+41/// inside one.
+42///
+43/// Which containers are *offerable* is a domain question (it depends on the
+44/// library's Jellyfin collection type and on what counts as a folder), so the
+45/// list is assembled here and the frontend renders it verbatim.
+46///
+47/// TRACES: UR-076 | DR-209
+48#[derive(specta::Type, Debug, Clone, serde::Serialize, serde::Deserialize)]
+49#[serde(rename_all = "camelCase")]
+50pub struct ExclusionCandidate {
+51    /// Stable Jellyfin item id — what gets stored when the user picks it.
+52    pub id: String,
+53    /// Display name of the folder (or of the library, for a whole-library entry).
+54    pub name: String,
+55    /// Library this candidate lives in, so the picker can group and disambiguate
+56    /// two folders that share a name.
+57    pub library_name: String,
+58    /// True when the candidate *is* a library rather than a folder inside one.
+59    pub is_library: bool,
+60}
+61
+62/// The library preferences currently in force.
+63///
+64/// Read from the in-memory exclusion set rather than the database: that set is
+65/// what queries actually consult, so reading it is the only answer that cannot
+66/// disagree with what the user is seeing.
+67///
+68/// TRACES: UR-076 | DR-209
+69#[tauri::command]
+70#[specta::specta]
+71pub async fn library_get_settings() -> Result<LibrarySettings, String> {
+72    Ok(LibrarySettings {
+73        excluded_item_ids: exclusions::excluded_item_ids(),
+74    })
+75}
+76
+77/// Replace the library preferences: apply them to every subsequent query and
+78/// persist them.
+79///
+80/// Returns the sanitised value actually applied, so the picker shows what was
+81/// stored rather than what it sent.
+82///
+83/// TRACES: UR-076 | DR-209
+84#[tauri::command]
+85#[specta::specta]
+86pub async fn library_set_settings(
+87    db: State<'_, DatabaseWrapper>,
+88    settings: LibrarySettings,
+89) -> Result<LibrarySettings, String> {
+90    let sanitised = settings.sanitised();
+91    exclusions::set_excluded_item_ids(&sanitised.excluded_item_ids);
+92    persist_library_settings(&db, &sanitised).await;
+93    info!(
+94        "[Library] {} folder(s) hidden from browsing",
+95        sanitised.excluded_item_ids.len()
+96    );
+97    Ok(sanitised)
+98}
+99
+100/// The folders the user may choose to hide.
+101///
+102/// Offers each music library and the folders directly inside it. Music is the
+103/// only scope offered because it is the one where a foreign folder — podcasts,
+104/// audiobooks, sound effects — routinely shares a library with the media the
+105/// user actually browses; the scope is decided here rather than in the UI so the
+106/// collection-type table stays out of the frontend
+107/// (see `SearchScope::for_collection_type`).
+108///
+109/// Reads through `HybridRepository::get_items_unfiltered` so folders that are
+110/// *already* hidden still appear — otherwise the setting could never be undone.
+111///
+112/// TRACES: UR-076 | DR-209
+113#[tauri::command]
+114#[specta::specta]
+115pub async fn library_get_exclusion_candidates(
+116    manager: State<'_, RepositoryManagerWrapper>,
+117    handle: String,
+118) -> Result<Vec<ExclusionCandidate>, String> {
+119    let repo = manager.0.get(&handle).ok_or("Repository not found")?;
+120
+121    let libraries = repo
+122        .as_ref()
+123        .get_libraries()
+124        .await
+125        .map_err(|e| format!("{:?}", e))?;
+126
+127    let mut candidates: Vec<ExclusionCandidate> = Vec::new();
+128
+129    for library in libraries {
+130        if SearchScope::for_collection_type(&library.collection_type) != Some(SearchScope::Music) {
+131            continue;
+132        }
+133
+134        candidates.push(ExclusionCandidate {
+135            id: library.id.clone(),
+136            name: library.name.clone(),
+137            library_name: library.name.clone(),
+138            is_library: true,
+139        });
+140
+141        let options = GetItemsOptions {
+142            recursive: Some(false),
+143            sort_by: Some("SortName".to_string()),
+144            sort_order: Some("Ascending".to_string()),
+145            limit: Some(CANDIDATE_SCAN_LIMIT),
+146            ..Default::default()
+147        };
+148
+149        match repo.get_items_unfiltered(&library.id, Some(options)).await {
+150            Ok(result) => {
+151                for item in result.items {
+152                    if !item.is_folder {
+153                        continue;
+154                    }
+155                    candidates.push(ExclusionCandidate {
+156                        id: item.id,
+157                        name: item.name,
+158                        library_name: library.name.clone(),
+159                        is_library: false,
+160                    });
+161                }
+162            }
+163            Err(e) => {
+164                // One unreachable library must not cost the user the picker for
+165                // the others — an empty section is recoverable, an error is not.
+166                warn!(
+167                    "[Library] Could not list folders in {}: {:?}",
+168                    library.name, e
+169                );
+170            }
+171        }
+172    }
+173
+174    debug!("[Library] {} exclusion candidate(s)", candidates.len());
+175    Ok(candidates)
+176}
+177
+178/// Write the preferences to `app_settings`.
+179///
+180/// Failure is logged, not returned: the setting has already been applied in
+181/// memory, and failing the whole call because the write failed would leave the
+182/// picker showing a state that *is* in force.
+183///
+184/// TRACES: UR-076 | DR-209
+185async fn persist_library_settings(db: &State<'_, DatabaseWrapper>, settings: &LibrarySettings) {
+186    let db_service = {
+187        let database = db.0.lock_safe();
+188        Arc::new(database.service())
+189    };
+190
+191    let encoded = match serde_json::to_string(settings) {
+192        Ok(value) => value,
+193        Err(e) => {
+194            warn!("[Library] Failed to encode library settings: {}", e);
+195            return;
+196        }
+197    };
+198
+199    let query = Query::with_params(
+200        "INSERT OR REPLACE INTO app_settings (key, value, updated_at)
+201         VALUES (?, ?, CURRENT_TIMESTAMP)",
+202        vec![
+203            QueryParam::String(LIBRARY_SETTINGS_KEY.to_string()),
+204            QueryParam::String(encoded),
+205        ],
+206    );
+207
+208    if let Err(e) = db_service.execute(query).await {
+209        warn!("[Library] Failed to persist library settings: {}", e);
+210    }
+211}
+212
+213/// Restore the persisted preferences at startup, into the exclusion set the
+214/// repository consults.
+215///
+216/// Called from the Tauri `setup` hook. A missing or unreadable row leaves the
+217/// default — nothing hidden — in place, so a database problem shows the user
+218/// more than they asked for rather than less.
+219///
+220/// TRACES: UR-076 | DR-209
+221pub async fn restore_library_settings(app: &tauri::AppHandle) {
+222    let db_service = {
+223        let Some(db) = app.try_state::<DatabaseWrapper>() else {
+224            warn!("[Library] No database available; nothing hidden from browsing");
+225            return;
+226        };
+227        let database = db.0.lock_safe();
+228        Arc::new(database.service())
+229    };
+230
+231    let query = Query::with_params(
+232        "SELECT value FROM app_settings WHERE key = ?",
+233        vec![QueryParam::String(LIBRARY_SETTINGS_KEY.to_string())],
+234    );
+235
+236    let stored: Option<String> = match db_service.query_optional(query, |row| row.get(0)).await {
+237        Ok(value) => value,
+238        Err(e) => {
+239            warn!("[Library] Failed to read library settings: {}", e);
+240            return;
+241        }
+242    };
+243
+244    let Some(stored) = stored else { return };
+245    let settings: LibrarySettings = match serde_json::from_str(&stored) {
+246        Ok(settings) => settings,
+247        Err(e) => {
+248            warn!(
+249                "[Library] Ignoring unreadable persisted library settings {:?}: {}",
+250                stored, e
+251            );
+252            return;
+253        }
+254    };
+255
+256    let settings = settings.sanitised();
+257    exclusions::set_excluded_item_ids(&settings.excluded_item_ids);
+258    if !settings.excluded_item_ids.is_empty() {
+259        info!(
+260            "[Library] Restored {} hidden folder(s)",
+261            settings.excluded_item_ids.len()
+262        );
+263    }
+264}
+265
+266#[cfg(test)]
+267mod tests {
+268    use super::*;
+269
+270    /// The persisted form must round-trip through the same camelCase JSON the
+271    /// IPC boundary uses — a rename here silently un-hides every folder the user
+272    /// chose, with no setting having been changed.
+273    ///
+274    /// TRACES: UR-076 | DR-209 | UT-203
+275    #[test]
+276    fn test_library_settings_round_trip_through_json() {
+277        let settings = LibrarySettings {
+278            excluded_item_ids: vec!["folder-1".to_string(), "folder-2".to_string()],
+279        };
+280
+281        let json = serde_json::to_string(&settings).expect("serialises");
+282        assert!(
+283            json.contains("\"excludedItemIds\""),
+284            "camelCase on the wire"
+285        );
+286
+287        let parsed: LibrarySettings = serde_json::from_str(&json).expect("parses back");
+288        assert_eq!(parsed, settings);
+289    }
+290
+291    /// Settings persisted before this feature existed — and a row with the key
+292    /// missing entirely — must load as "nothing hidden", never as an error the
+293    /// caller has to handle or a default that hides something.
+294    ///
+295    /// TRACES: UR-076 | DR-209 | UT-203
+296    #[test]
+297    fn test_library_settings_default_hides_nothing() {
+298        let parsed: LibrarySettings = serde_json::from_str("{}").expect("parses");
+299        assert!(parsed.excluded_item_ids.is_empty());
+300        assert!(LibrarySettings::default().excluded_item_ids.is_empty());
+301    }
+302
+303    /// Blank and duplicate ids are dropped on the way in, so a half-written or
+304    /// hand-edited value cannot grow the list without bound or store an id that
+305    /// matches nothing yet still shows as a selection.
+306    ///
+307    /// TRACES: UR-076 | DR-209 | UT-203
+308    #[test]
+309    fn test_library_settings_sanitised() {
+310        let settings = LibrarySettings {
+311            excluded_item_ids: vec![
+312                "  folder-1  ".to_string(),
+313                "".to_string(),
+314                "   ".to_string(),
+315                "folder-1".to_string(),
+316                "folder-2".to_string(),
+317            ],
+318        }
+319        .sanitised();
+320
+321        assert_eq!(
+322            settings.excluded_item_ids,
+323            vec!["folder-1".to_string(), "folder-2".to_string()]
+324        );
+325    }
+326}
+
\ No newline at end of file diff --git a/api/src/jellytau_lib/commands/mod.rs.html b/api/src/jellytau_lib/commands/mod.rs.html new file mode 100644 index 00000000..c056bb48 --- /dev/null +++ b/api/src/jellytau_lib/commands/mod.rs.html @@ -0,0 +1,43 @@ +mod.rs - sourceSkip to main content

jellytau_lib/commands/
mod.rs

1// Tauri commands exposed to frontend
+2// TRACES: UR-002, UR-003, UR-004, UR-005, UR-009, UR-011, UR-012, UR-017, UR-019, UR-025 |
+3//         DR-015, DR-017, DR-021, DR-028
+4pub mod auth;
+5pub mod catalog;
+6pub mod connectivity;
+7pub mod conversions;
+8pub mod device;
+9pub mod diagnostics;
+10pub mod download;
+11pub mod favorites;
+12pub mod library;
+13pub mod offline;
+14pub mod playback_mode;
+15pub mod playback_reporting;
+16pub mod player;
+17pub mod playlist;
+18pub mod repository;
+19pub mod sessions;
+20pub mod storage;
+21pub mod sync;
+22pub mod sync_drain;
+23
+24pub use auth::*;
+25pub use catalog::*;
+26pub use connectivity::*;
+27pub use conversions::*;
+28pub use device::*;
+29pub use diagnostics::*;
+30pub use download::*;
+31pub use library::*;
+32pub use offline::*;
+33pub use playback_mode::*;
+34#[allow(unused_imports)] // Used when playback_reporting is fully integrated
+35pub use playback_reporting::*;
+36pub use player::*;
+37pub use playlist::*;
+38pub use repository::{RepositoryManager, RepositoryManagerWrapper, *};
+39pub use sessions::*;
+40pub use storage::*;
+41pub use sync::*;
+42pub use sync_drain::*;
+
\ No newline at end of file diff --git a/api/src/jellytau_lib/commands/offline.rs.html b/api/src/jellytau_lib/commands/offline.rs.html new file mode 100644 index 00000000..71bc47ba --- /dev/null +++ b/api/src/jellytau_lib/commands/offline.rs.html @@ -0,0 +1,159 @@ +offline.rs - sourceSkip to main content

jellytau_lib/commands/
offline.rs

1//! Tauri commands for offline data access
+2
+3use serde::{Deserialize, Serialize};
+4use std::sync::Arc;
+5use tauri::State;
+6
+7use super::DatabaseWrapper;
+8use crate::storage::db_service::{DatabaseService, Query, QueryParam};
+9
+10#[derive(specta::Type, Debug, Serialize, Deserialize)]
+11#[serde(rename_all = "camelCase")]
+12pub struct OfflineItem {
+13    pub id: String,
+14    pub name: String,
+15    pub item_type: String,
+16    pub album_id: Option<String>,
+17    pub album_name: Option<String>,
+18    pub artists: Option<String>,
+19    pub runtime_ticks: Option<i64>,
+20    pub primary_image_tag: Option<String>,
+21}
+22
+23/// Check if an item is available offline
+24#[tauri::command]
+25#[specta::specta]
+26pub async fn offline_is_available(
+27    db: State<'_, DatabaseWrapper>,
+28    item_id: String,
+29) -> Result<bool, String> {
+30    let db_service = {
+31        let database = db.0.lock().map_err(|e| e.to_string())?;
+32        Arc::new(database.service())
+33    };
+34
+35    let query = Query::with_params(
+36        "SELECT COUNT(*) FROM downloads WHERE item_id = ? AND status = 'completed'",
+37        vec![QueryParam::String(item_id)],
+38    );
+39
+40    let count: i64 = db_service
+41        .query_one(query, |row| row.get(0))
+42        .await
+43        .map_err(|e| e.to_string())?;
+44
+45    Ok(count > 0)
+46}
+47
+48/// Get all offline items for a user
+49#[tauri::command]
+50#[specta::specta]
+51pub async fn offline_get_items(
+52    db: State<'_, DatabaseWrapper>,
+53    user_id: String,
+54) -> Result<Vec<OfflineItem>, String> {
+55    let db_service = {
+56        let database = db.0.lock().map_err(|e| e.to_string())?;
+57        Arc::new(database.service())
+58    };
+59
+60    let query = Query::with_params(
+61        "SELECT i.id, i.name, i.item_type, i.album_id, i.album_name, i.artists,
+62                i.runtime_ticks, i.primary_image_tag
+63         FROM items i
+64         INNER JOIN downloads d ON i.id = d.item_id
+65         WHERE d.user_id = ? AND d.status = 'completed'
+66         ORDER BY d.completed_at DESC",
+67        vec![QueryParam::String(user_id)],
+68    );
+69
+70    db_service
+71        .query_many(query, |row| {
+72            Ok(OfflineItem {
+73                id: row.get(0)?,
+74                name: row.get(1)?,
+75                item_type: row.get(2)?,
+76                album_id: row.get(3)?,
+77                album_name: row.get(4)?,
+78                artists: row.get(5)?,
+79                runtime_ticks: row.get(6)?,
+80                primary_image_tag: row.get(7)?,
+81            })
+82        })
+83        .await
+84        .map_err(|e| e.to_string())
+85}
+86
+87/// Search offline items
+88#[tauri::command]
+89#[specta::specta]
+90pub async fn offline_search(
+91    db: State<'_, DatabaseWrapper>,
+92    user_id: String,
+93    query: String,
+94) -> Result<Vec<OfflineItem>, String> {
+95    let db_service = {
+96        let database = db.0.lock().map_err(|e| e.to_string())?;
+97        Arc::new(database.service())
+98    };
+99
+100    let search_query = format!("%{}%", query.to_lowercase());
+101
+102    let db_query = Query::with_params(
+103        "SELECT i.id, i.name, i.item_type, i.album_id, i.album_name, i.artists,
+104                i.runtime_ticks, i.primary_image_tag
+105         FROM items i
+106         INNER JOIN downloads d ON i.id = d.item_id
+107         WHERE d.user_id = ? AND d.status = 'completed'
+108           AND (LOWER(i.name) LIKE ? OR LOWER(i.artists) LIKE ? OR LOWER(i.album_name) LIKE ?)
+109         ORDER BY i.name
+110         LIMIT 50",
+111        vec![
+112            QueryParam::String(user_id),
+113            QueryParam::String(search_query.clone()),
+114            QueryParam::String(search_query.clone()),
+115            QueryParam::String(search_query),
+116        ],
+117    );
+118
+119    db_service
+120        .query_many(db_query, |row| {
+121            Ok(OfflineItem {
+122                id: row.get(0)?,
+123                name: row.get(1)?,
+124                item_type: row.get(2)?,
+125                album_id: row.get(3)?,
+126                album_name: row.get(4)?,
+127                artists: row.get(5)?,
+128                runtime_ticks: row.get(6)?,
+129                primary_image_tag: row.get(7)?,
+130            })
+131        })
+132        .await
+133        .map_err(|e| e.to_string())
+134}
+135
+136// TRACES: UR-002, UR-011 | DR-017 | UT-044
+137#[cfg(test)]
+138mod tests {
+139    use super::*;
+140
+141    #[test]
+142    fn test_offline_item_serialization() {
+143        let item = OfflineItem {
+144            id: "123".to_string(),
+145            name: "Test Song".to_string(),
+146            item_type: "Audio".to_string(),
+147            album_id: Some("album1".to_string()),
+148            album_name: Some("Test Album".to_string()),
+149            artists: Some("Artist 1".to_string()),
+150            runtime_ticks: Some(180000000),
+151            primary_image_tag: Some("tag123".to_string()),
+152        };
+153
+154        let json = serde_json::to_string(&item).unwrap();
+155        assert!(json.contains("\"itemType\":\"Audio\""));
+156        assert!(json.contains("\"albumName\":\"Test Album\""));
+157    }
+158}
+
\ No newline at end of file diff --git a/api/src/jellytau_lib/commands/playback_mode.rs.html b/api/src/jellytau_lib/commands/playback_mode.rs.html new file mode 100644 index 00000000..2698b353 --- /dev/null +++ b/api/src/jellytau_lib/commands/playback_mode.rs.html @@ -0,0 +1,303 @@ +playback_mode.rs - sourceSkip to main content

jellytau_lib/commands/
playback_mode.rs

1//! Playback-mode transfer commands (local ↔ remote).
+2//!
+3//! TRACES: UR-010 | DR-059
+4
+5use std::sync::Arc;
+6use tauri::State;
+7
+8use crate::playback_mode::{PlaybackMode, PlaybackModeManager};
+9
+10/// Wrapper for PlaybackModeManager to manage in Tauri state
+11pub struct PlaybackModeManagerWrapper(pub Arc<PlaybackModeManager>);
+12
+13/// Get the current playback mode
+14#[tauri::command]
+15#[specta::specta]
+16pub fn playback_mode_get_current(
+17    manager: State<'_, PlaybackModeManagerWrapper>,
+18) -> Result<PlaybackMode, String> {
+19    Ok(manager.0.get_mode())
+20}
+21
+22/// Set the playback mode (internal/testing use)
+23#[tauri::command]
+24#[specta::specta]
+25pub fn playback_mode_set(
+26    manager: State<'_, PlaybackModeManagerWrapper>,
+27    mode: PlaybackMode,
+28) -> Result<(), String> {
+29    manager.0.set_mode(mode);
+30    Ok(())
+31}
+32
+33/// Check if currently transferring between playback modes
+34#[tauri::command]
+35#[specta::specta]
+36pub fn playback_mode_is_transferring(
+37    manager: State<'_, PlaybackModeManagerWrapper>,
+38) -> Result<bool, String> {
+39    Ok(manager.0.is_transferring())
+40}
+41
+42/// Transfer playback from local device to a remote Jellyfin session
+43#[tauri::command]
+44#[specta::specta]
+45pub async fn playback_mode_transfer_to_remote(
+46    manager: State<'_, PlaybackModeManagerWrapper>,
+47    session_id: String,
+48    position: Option<f64>,
+49) -> Result<(), String> {
+50    log::info!(
+51        "[PlaybackModeCommands] Transferring to remote session: {} (position override: {:?})",
+52        session_id,
+53        position
+54    );
+55    manager.0.transfer_to_remote(session_id, position).await
+56}
+57
+58/// Set the transferring flag on the playback mode manager.
+59///
+60/// Used by the frontend remote->local flow to mark the whole two-step sequence
+61/// as a transfer, so `player_play_tracks` starts LOCAL playback instead of
+62/// casting back to the remote session it's leaving. Always pair `true` with a
+63/// later `false` (including on error) so the flag can't stick.
+64#[tauri::command]
+65#[specta::specta]
+66pub async fn playback_mode_set_transferring(
+67    manager: State<'_, PlaybackModeManagerWrapper>,
+68    transferring: bool,
+69) -> Result<(), String> {
+70    manager.0.set_transferring(transferring);
+71    Ok(())
+72}
+73
+74/// Transfer playback from remote session back to local device
+75///
+76/// Parameters:
+77/// - current_item_id: The Jellyfin item ID currently playing on remote
+78/// - position_ticks: Current playback position in ticks (10,000 ticks = 1ms)
+79#[tauri::command]
+80#[specta::specta]
+81pub async fn playback_mode_transfer_to_local(
+82    manager: State<'_, PlaybackModeManagerWrapper>,
+83    current_item_id: String,
+84    position_ticks: i64,
+85) -> Result<(), String> {
+86    log::info!(
+87        "[PlaybackModeCommands] Transferring to local: item_id={}, position={}",
+88        current_item_id,
+89        position_ticks
+90    );
+91    manager
+92        .0
+93        .transfer_to_local(current_item_id, position_ticks)
+94        .await
+95}
+96
+97/// Get remote session status (for polling position/duration)
+98#[tauri::command]
+99#[specta::specta]
+100pub async fn playback_mode_get_remote_status(
+101    manager: State<'_, PlaybackModeManagerWrapper>,
+102    player: State<'_, crate::commands::PlayerStateWrapper>,
+103) -> Result<RemoteSessionStatus, String> {
+104    let mode = manager.0.get_mode();
+105
+106    if let crate::playback_mode::PlaybackMode::Remote { session_id } = mode {
+107        // Get Jellyfin client from player controller - clone before await
+108        let client = {
+109            let controller = player.0.lock().await;
+110            let client_arc = controller.jellyfin_client();
+111            let client_opt = client_arc.lock().map_err(|e| e.to_string())?;
+112            client_opt
+113                .as_ref()
+114                .ok_or("Jellyfin client not configured")?
+115                .clone()
+116        };
+117
+118        // Get session info
+119        match client.get_session(&session_id).await {
+120            Ok(Some(session)) => {
+121                let position_ticks = session
+122                    .play_state
+123                    .as_ref()
+124                    .and_then(|ps| ps.position_ticks)
+125                    .unwrap_or(0);
+126
+127                let duration_ticks = session
+128                    .now_playing_item
+129                    .as_ref()
+130                    .and_then(|item| item.run_time_ticks)
+131                    .unwrap_or(0);
+132
+133                let is_paused = session
+134                    .play_state
+135                    .as_ref()
+136                    .and_then(|ps| ps.is_paused)
+137                    .unwrap_or(true);
+138
+139                Ok(RemoteSessionStatus {
+140                    position: position_ticks as f64 / 10_000_000.0,
+141                    duration: if duration_ticks > 0 {
+142                        Some(duration_ticks as f64 / 10_000_000.0)
+143                    } else {
+144                        None
+145                    },
+146                    is_playing: !is_paused,
+147                    now_playing_item: session.now_playing_item.clone(),
+148                })
+149            }
+150            Ok(None) => Err("Remote session not found".to_string()),
+151            Err(e) => Err(format!("Failed to get session status: {}", e)),
+152        }
+153    } else {
+154        Err("Not in remote playback mode".to_string())
+155    }
+156}
+157
+158/// Remote session status for UI updates
+159#[derive(specta::Type, serde::Serialize)]
+160#[serde(rename_all = "camelCase")]
+161pub struct RemoteSessionStatus {
+162    pub position: f64,
+163    pub duration: Option<f64>,
+164    pub is_playing: bool,
+165    pub now_playing_item: Option<crate::jellyfin::NowPlayingItem>,
+166}
+167
+168#[cfg(test)]
+169mod tests {
+170    use super::*;
+171
+172    #[test]
+173    fn test_playback_mode_serialization() {
+174        // Test Local playback mode
+175        let local_mode = PlaybackMode::Local;
+176        let json = serde_json::to_string(&local_mode);
+177        assert!(json.is_ok());
+178
+179        // Test Remote playback mode
+180        let remote_mode = PlaybackMode::Remote {
+181            session_id: "session-123".to_string(),
+182        };
+183        let json = serde_json::to_string(&remote_mode);
+184        assert!(json.is_ok());
+185    }
+186
+187    #[test]
+188    fn test_remote_session_status_serialization() {
+189        let status = RemoteSessionStatus {
+190            position: 123.45,
+191            duration: Some(600.0),
+192            is_playing: true,
+193            now_playing_item: None,
+194        };
+195
+196        // Should serialize successfully
+197        let json = serde_json::to_string(&status);
+198        assert!(json.is_ok());
+199
+200        let serialized = json.unwrap();
+201        assert!(serialized.contains("123.45"));
+202        assert!(serialized.contains("600"));
+203        assert!(serialized.contains("true"));
+204    }
+205
+206    #[test]
+207    fn test_remote_session_status_with_no_duration() {
+208        let status = RemoteSessionStatus {
+209            position: 0.0,
+210            duration: None,
+211            is_playing: false,
+212            now_playing_item: None,
+213        };
+214
+215        let json = serde_json::to_string(&status).unwrap();
+216        assert!(json.contains("null") || json.contains("\"duration\":null"));
+217    }
+218
+219    #[test]
+220    fn test_remote_session_status_various_positions() {
+221        let positions = vec![0.0, 30.5, 100.0, 3600.0];
+222
+223        for pos in positions {
+224            let status = RemoteSessionStatus {
+225                position: pos,
+226                duration: Some(7200.0),
+227                is_playing: true,
+228                now_playing_item: None,
+229            };
+230
+231            let json = serde_json::to_string(&status).unwrap();
+232            assert!(json.contains(&pos.to_string()));
+233        }
+234    }
+235
+236    #[test]
+237    fn test_playback_mode_deserialization_from_frontend() {
+238        // Test what frontend sends for Idle mode
+239        let idle_json = r#"{"type":"idle"}"#;
+240        let mode: PlaybackMode =
+241            serde_json::from_str(idle_json).expect("Failed to deserialize idle");
+242        assert_eq!(mode, PlaybackMode::Idle);
+243
+244        // Test what frontend sends for Local mode
+245        let local_json = r#"{"type":"local"}"#;
+246        let mode: PlaybackMode =
+247            serde_json::from_str(local_json).expect("Failed to deserialize local");
+248        assert_eq!(mode, PlaybackMode::Local);
+249
+250        // Test what frontend sends for Remote mode
+251        let remote_json = r#"{"type":"remote","session_id":"session-123"}"#;
+252        let mode: PlaybackMode =
+253            serde_json::from_str(remote_json).expect("Failed to deserialize remote");
+254        match mode {
+255            PlaybackMode::Remote { session_id } => assert_eq!(session_id, "session-123"),
+256            _ => panic!("Expected Remote mode"),
+257        }
+258    }
+259
+260    #[test]
+261    fn test_play_tracks_context_deserialization() {
+262        use crate::commands::PlayTracksContext;
+263
+264        // Test Search context (the recently fixed issue)
+265        let search_json = r#"{"type":"search","searchQuery":"test query"}"#;
+266        let context: PlayTracksContext =
+267            serde_json::from_str(search_json).expect("Failed to deserialize search context");
+268        match context {
+269            PlayTracksContext::Search { search_query } => {
+270                assert_eq!(search_query, "test query");
+271            }
+272            _ => panic!("Expected Search context"),
+273        }
+274
+275        // Test Playlist context
+276        let playlist_json =
+277            r#"{"type":"playlist","playlistId":"pl-123","playlistName":"My Playlist"}"#;
+278        let context: PlayTracksContext =
+279            serde_json::from_str(playlist_json).expect("Failed to deserialize playlist context");
+280        match context {
+281            PlayTracksContext::Playlist {
+282                playlist_id,
+283                playlist_name,
+284            } => {
+285                assert_eq!(playlist_id, "pl-123");
+286                assert_eq!(playlist_name, "My Playlist");
+287            }
+288            _ => panic!("Expected Playlist context"),
+289        }
+290
+291        // Test Custom context
+292        let custom_json = r#"{"type":"custom","label":"Custom Queue"}"#;
+293        let context: PlayTracksContext =
+294            serde_json::from_str(custom_json).expect("Failed to deserialize custom context");
+295        match context {
+296            PlayTracksContext::Custom { label } => {
+297                assert_eq!(label, Some("Custom Queue".to_string()));
+298            }
+299            _ => panic!("Expected Custom context"),
+300        }
+301    }
+302}
+
\ No newline at end of file diff --git a/api/src/jellytau_lib/commands/playback_reporting.rs.html b/api/src/jellytau_lib/commands/playback_reporting.rs.html new file mode 100644 index 00000000..7f8d8217 --- /dev/null +++ b/api/src/jellytau_lib/commands/playback_reporting.rs.html @@ -0,0 +1,429 @@ +playback_reporting.rs - sourceSkip to main content

jellytau_lib/commands/
playback_reporting.rs

1//! Tauri commands for playback reporting operations
+2//!
+3//! TRACES: UR-025, UR-019 | IR-015, JA-010, JA-011, JA-012 | DR-028
+4//!
+5//! These commands provide frontend access to the Rust playback reporting system,
+6//! replacing the TypeScript implementation with native Rust reporting.
+7//!
+8//! Commands are registered but not yet called from the frontend.
+9//! Dead code warnings are suppressed until frontend migration is complete.
+10
+11#![allow(dead_code)]
+12
+13use std::sync::Arc;
+14use tauri::State;
+15use tokio::sync::Mutex as TokioMutex;
+16
+17use crate::commands::connectivity::ConnectivityMonitorWrapper;
+18use crate::commands::storage::DatabaseWrapper;
+19use crate::jellyfin::client::JellyfinClient;
+20use crate::jellyfin::JellyfinConfig;
+21use crate::playback_reporting::{PlaybackContext, PlaybackOperation, PlaybackReporter};
+22use crate::utils::conversions::seconds_to_ticks;
+23
+24/// Tauri state wrapper for PlaybackReporter
+25pub struct PlaybackReporterWrapper(pub Arc<TokioMutex<Option<PlaybackReporter>>>);
+26
+27/// Initialize playback reporter (called after login)
+28#[tauri::command]
+29#[specta::specta]
+30pub async fn playback_reporter_init(
+31    reporter_wrapper: State<'_, PlaybackReporterWrapper>,
+32    db: State<'_, DatabaseWrapper>,
+33    server_url: String,
+34    user_id: String,
+35    access_token: String,
+36    device_id: String,
+37) -> Result<(), String> {
+38    log::info!("[PlaybackReporter] Initializing for user: {}", user_id);
+39
+40    // Get database service
+41    let db_service = {
+42        let database = db.0.lock().map_err(|e| e.to_string())?;
+43        Arc::new(database.service())
+44    };
+45
+46    // Create JellyfinClient
+47    let jellyfin_config = JellyfinConfig {
+48        server_url,
+49        access_token,
+50        device_id,
+51    };
+52
+53    let jellyfin_client = JellyfinClient::new(jellyfin_config)
+54        .map_err(|e| format!("Failed to create JellyfinClient: {}", e))?;
+55
+56    // Create PlaybackReporter
+57    let reporter = PlaybackReporter::new(
+58        db_service,
+59        Arc::new(TokioMutex::new(Some(jellyfin_client))),
+60        user_id.clone(),
+61    );
+62
+63    // Store in wrapper
+64    *reporter_wrapper.0.lock().await = Some(reporter);
+65
+66    log::info!(
+67        "[PlaybackReporter] Initialized successfully for user: {}",
+68        user_id
+69    );
+70    Ok(())
+71}
+72
+73/// Destroy playback reporter (called on logout)
+74#[tauri::command]
+75#[specta::specta]
+76pub async fn playback_reporter_destroy(
+77    reporter_wrapper: State<'_, PlaybackReporterWrapper>,
+78) -> Result<(), String> {
+79    log::info!("[PlaybackReporter] Destroying reporter");
+80    *reporter_wrapper.0.lock().await = None;
+81    Ok(())
+82}
+83
+84/// Report playback start
+85#[tauri::command]
+86#[specta::specta]
+87pub async fn playback_report_start(
+88    reporter: State<'_, PlaybackReporterWrapper>,
+89    connectivity: State<'_, ConnectivityMonitorWrapper>,
+90    item_id: String,
+91    position_seconds: f64,
+92    context_type: Option<String>,
+93    context_id: Option<String>,
+94) -> Result<(), String> {
+95    let reporter_guard = reporter.0.lock().await;
+96    let reporter_instance = reporter_guard
+97        .as_ref()
+98        .ok_or("PlaybackReporter not initialized")?;
+99
+100    let position_ticks = seconds_to_ticks(position_seconds);
+101    let context = context_type.map(|ct| PlaybackContext {
+102        context_type: ct,
+103        context_id,
+104    });
+105
+106    let operation = PlaybackOperation::Start {
+107        item_id,
+108        position_ticks,
+109        context,
+110    };
+111
+112    let monitor = connectivity.0.lock().await;
+113    let is_online = monitor.get_status().await.is_server_reachable;
+114    drop(monitor);
+115
+116    reporter_instance.report(operation, is_online).await
+117}
+118
+119/// Report playback progress
+120#[tauri::command]
+121#[specta::specta]
+122pub async fn playback_report_progress(
+123    reporter: State<'_, PlaybackReporterWrapper>,
+124    connectivity: State<'_, ConnectivityMonitorWrapper>,
+125    item_id: String,
+126    position_seconds: f64,
+127    is_paused: bool,
+128) -> Result<(), String> {
+129    let reporter_guard = reporter.0.lock().await;
+130    let reporter_instance = reporter_guard
+131        .as_ref()
+132        .ok_or("PlaybackReporter not initialized")?;
+133
+134    let position_ticks = seconds_to_ticks(position_seconds);
+135    let operation = PlaybackOperation::Progress {
+136        item_id,
+137        position_ticks,
+138        is_paused,
+139    };
+140
+141    let monitor = connectivity.0.lock().await;
+142    let is_online = monitor.get_status().await.is_server_reachable;
+143    drop(monitor);
+144
+145    reporter_instance.report(operation, is_online).await
+146}
+147
+148/// Report playback stopped
+149#[tauri::command]
+150#[specta::specta]
+151pub async fn playback_report_stopped(
+152    reporter: State<'_, PlaybackReporterWrapper>,
+153    connectivity: State<'_, ConnectivityMonitorWrapper>,
+154    item_id: String,
+155    position_seconds: f64,
+156) -> Result<(), String> {
+157    let reporter_guard = reporter.0.lock().await;
+158    let reporter_instance = reporter_guard
+159        .as_ref()
+160        .ok_or("PlaybackReporter not initialized")?;
+161
+162    let position_ticks = seconds_to_ticks(position_seconds);
+163    let operation = PlaybackOperation::Stopped {
+164        item_id,
+165        position_ticks,
+166    };
+167
+168    let monitor = connectivity.0.lock().await;
+169    let is_online = monitor.get_status().await.is_server_reachable;
+170    drop(monitor);
+171
+172    reporter_instance.report(operation, is_online).await
+173}
+174
+175/// Mark item as played
+176#[tauri::command]
+177#[specta::specta]
+178pub async fn playback_mark_played(
+179    reporter: State<'_, PlaybackReporterWrapper>,
+180    connectivity: State<'_, ConnectivityMonitorWrapper>,
+181    item_id: String,
+182) -> Result<(), String> {
+183    let reporter_guard = reporter.0.lock().await;
+184    let reporter_instance = reporter_guard
+185        .as_ref()
+186        .ok_or("PlaybackReporter not initialized")?;
+187
+188    let operation = PlaybackOperation::MarkPlayed { item_id };
+189
+190    let monitor = connectivity.0.lock().await;
+191    let is_online = monitor.get_status().await.is_server_reachable;
+192    drop(monitor);
+193
+194    reporter_instance.report(operation, is_online).await
+195}
+196
+197#[cfg(test)]
+198mod tests {
+199    use super::*;
+200
+201    #[test]
+202    fn test_playback_operation_start_creation() {
+203        let operation = PlaybackOperation::Start {
+204            item_id: "item-123".to_string(),
+205            position_ticks: 15_000_000,
+206            context: Some(PlaybackContext {
+207                context_type: "series".to_string(),
+208                context_id: Some("series-456".to_string()),
+209            }),
+210        };
+211
+212        // Verify enum variant can be created and pattern matched
+213        if let PlaybackOperation::Start {
+214            item_id,
+215            position_ticks,
+216            context,
+217        } = operation
+218        {
+219            assert_eq!(item_id, "item-123");
+220            assert_eq!(position_ticks, 15_000_000);
+221            assert!(context.is_some());
+222            let ctx = context.unwrap();
+223            assert_eq!(ctx.context_type, "series");
+224            assert_eq!(ctx.context_id, Some("series-456".to_string()));
+225        } else {
+226            panic!("Expected Start variant");
+227        }
+228    }
+229
+230    #[test]
+231    fn test_playback_operation_start_without_context() {
+232        let operation = PlaybackOperation::Start {
+233            item_id: "item-789".to_string(),
+234            position_ticks: 5_000_000,
+235            context: None,
+236        };
+237
+238        if let PlaybackOperation::Start {
+239            item_id, context, ..
+240        } = operation
+241        {
+242            assert_eq!(item_id, "item-789");
+243            assert!(context.is_none());
+244        } else {
+245            panic!("Expected Start variant");
+246        }
+247    }
+248
+249    #[test]
+250    fn test_playback_operation_progress_creation() {
+251        let operation = PlaybackOperation::Progress {
+252            item_id: "item-999".to_string(),
+253            position_ticks: 30_000_000,
+254            is_paused: true,
+255        };
+256
+257        if let PlaybackOperation::Progress {
+258            item_id,
+259            position_ticks,
+260            is_paused,
+261        } = operation
+262        {
+263            assert_eq!(item_id, "item-999");
+264            assert_eq!(position_ticks, 30_000_000);
+265            assert!(is_paused);
+266        } else {
+267            panic!("Expected Progress variant");
+268        }
+269    }
+270
+271    #[test]
+272    fn test_playback_operation_progress_playing() {
+273        let operation = PlaybackOperation::Progress {
+274            item_id: "item-555".to_string(),
+275            position_ticks: 45_000_000,
+276            is_paused: false,
+277        };
+278
+279        if let PlaybackOperation::Progress { is_paused, .. } = operation {
+280            assert!(!is_paused);
+281        } else {
+282            panic!("Expected Progress variant");
+283        }
+284    }
+285
+286    #[test]
+287    fn test_playback_operation_stopped_creation() {
+288        let operation = PlaybackOperation::Stopped {
+289            item_id: "item-111".to_string(),
+290            position_ticks: 120_000_000,
+291        };
+292
+293        if let PlaybackOperation::Stopped {
+294            item_id,
+295            position_ticks,
+296        } = operation
+297        {
+298            assert_eq!(item_id, "item-111");
+299            assert_eq!(position_ticks, 120_000_000);
+300        } else {
+301            panic!("Expected Stopped variant");
+302        }
+303    }
+304
+305    #[test]
+306    fn test_playback_operation_mark_played_creation() {
+307        let operation = PlaybackOperation::MarkPlayed {
+308            item_id: "item-222".to_string(),
+309        };
+310
+311        if let PlaybackOperation::MarkPlayed { item_id } = operation {
+312            assert_eq!(item_id, "item-222");
+313        } else {
+314            panic!("Expected MarkPlayed variant");
+315        }
+316    }
+317
+318    #[test]
+319    fn test_playback_context_with_series() {
+320        let context = PlaybackContext {
+321            context_type: "series".to_string(),
+322            context_id: Some("series-789".to_string()),
+323        };
+324
+325        assert_eq!(context.context_type, "series");
+326        assert_eq!(context.context_id, Some("series-789".to_string()));
+327    }
+328
+329    #[test]
+330    fn test_playback_context_without_id() {
+331        let context = PlaybackContext {
+332            context_type: "folder".to_string(),
+333            context_id: None,
+334        };
+335
+336        assert_eq!(context.context_type, "folder");
+337        assert!(context.context_id.is_none());
+338    }
+339
+340    #[test]
+341    fn test_playback_context_clone() {
+342        let context = PlaybackContext {
+343            context_type: "container".to_string(),
+344            context_id: Some("container-123".to_string()),
+345        };
+346
+347        let cloned = context.clone();
+348        assert_eq!(cloned.context_type, "container");
+349        assert_eq!(cloned.context_id, Some("container-123".to_string()));
+350    }
+351
+352    #[test]
+353    fn test_seconds_to_ticks_conversion() {
+354        assert_eq!(seconds_to_ticks(0.0), 0);
+355        assert_eq!(seconds_to_ticks(1.0), 10_000_000);
+356        assert_eq!(seconds_to_ticks(1.5), 15_000_000);
+357        assert_eq!(seconds_to_ticks(120.0), 1_200_000_000);
+358    }
+359
+360    #[test]
+361    fn test_playback_reporter_wrapper_structure() {
+362        // Verify wrapper type can hold Arc<TokioMutex<Option<T>>>
+363        assert!(std::mem::size_of::<PlaybackReporterWrapper>() > 0);
+364    }
+365
+366    #[test]
+367    fn test_playback_operation_debug_trait() {
+368        // Verify Debug trait is implemented for operations
+369        let operation = PlaybackOperation::Start {
+370            item_id: "item-1".to_string(),
+371            position_ticks: 0,
+372            context: None,
+373        };
+374
+375        let debug_str = format!("{:?}", operation);
+376        assert!(debug_str.contains("Start"));
+377        assert!(debug_str.contains("item-1"));
+378    }
+379
+380    #[test]
+381    fn test_playback_operation_clone() {
+382        let operation = PlaybackOperation::Progress {
+383            item_id: "item-clone".to_string(),
+384            position_ticks: 50_000_000,
+385            is_paused: true,
+386        };
+387
+388        let cloned = operation.clone();
+389        if let PlaybackOperation::Progress {
+390            item_id, is_paused, ..
+391        } = cloned
+392        {
+393            assert_eq!(item_id, "item-clone");
+394            assert!(is_paused);
+395        } else {
+396            panic!("Clone failed to preserve variant");
+397        }
+398    }
+399
+400    #[test]
+401    fn test_playback_operation_all_variants() {
+402        // Test that all operation variants can be created and matched
+403        let start_op = PlaybackOperation::Start {
+404            item_id: "i1".to_string(),
+405            position_ticks: 0,
+406            context: None,
+407        };
+408        assert!(matches!(start_op, PlaybackOperation::Start { .. }));
+409
+410        let progress_op = PlaybackOperation::Progress {
+411            item_id: "i2".to_string(),
+412            position_ticks: 100,
+413            is_paused: false,
+414        };
+415        assert!(matches!(progress_op, PlaybackOperation::Progress { .. }));
+416
+417        let stopped_op = PlaybackOperation::Stopped {
+418            item_id: "i3".to_string(),
+419            position_ticks: 200,
+420        };
+421        assert!(matches!(stopped_op, PlaybackOperation::Stopped { .. }));
+422
+423        let played_op = PlaybackOperation::MarkPlayed {
+424            item_id: "i4".to_string(),
+425        };
+426        assert!(matches!(played_op, PlaybackOperation::MarkPlayed { .. }));
+427    }
+428}
+
\ No newline at end of file diff --git a/api/src/jellytau_lib/commands/player/mod.rs.html b/api/src/jellytau_lib/commands/player/mod.rs.html new file mode 100644 index 00000000..c45eae2f --- /dev/null +++ b/api/src/jellytau_lib/commands/player/mod.rs.html @@ -0,0 +1,3429 @@ +mod.rs - sourceSkip to main content

jellytau_lib/commands/player/
mod.rs

1//! TRACES: UR-003, UR-004, UR-005, UR-010, UR-020, UR-021 | JA-022, JA-023, JA-024, JA-025, JA-026 | DR-001
+2
+3// Cohesive command clusters live in their own submodules and are re-exported so
+4// the command names remain at `commands::player::*` (invoke_handler unchanged).
+5mod queue;
+6mod remote;
+7mod session;
+8mod settings;
+9mod timers;
+10pub use queue::*;
+11pub use remote::*;
+12pub use session::*;
+13pub use settings::*;
+14pub use timers::*;
+15
+16use crate::utils::lock::MutexSafe;
+17use log::{debug, error, info, warn};
+18use serde::{Deserialize, Serialize};
+19use std::path::PathBuf;
+20use std::sync::{Arc, Mutex};
+21use tauri::State;
+22use tokio::sync::Mutex as TokioMutex;
+23
+24use super::DatabaseWrapper;
+25use crate::download::cache::{CacheConfig, SmartCache};
+26use crate::jellyfin::{JellyfinClient, JellyfinConfig};
+27use crate::player::{
+28    determine_video_seek_strategy, MediaItem, MediaSessionManager, MediaSource, MediaType,
+29    PlayerController, PlayerState, PlayerStatusEvent, QueueContext, RepeatMode, VideoSeekStrategy,
+30};
+31use crate::repository::{
+32    types::{GetItemsOptions, ImageOptions, ImageType},
+33    MediaRepository,
+34};
+35use crate::settings::VideoSettings;
+36use crate::storage::db_service::{DatabaseService, Query, QueryParam};
+37
+38/// SmartCache wrapper for Tauri state management
+39pub struct SmartCacheWrapper(pub Mutex<SmartCache>);
+40
+41/// Player state wrapper for Tauri.
+42///
+43/// Uses Arc to allow sharing with the MediaSession handler on Android
+44/// for lockscreen control integration.
+45///
+46/// @req: UR-005 - Control media playback
+47/// @req: DR-001 - Player state machine
+48pub struct PlayerStateWrapper(pub Arc<TokioMutex<PlayerController>>);
+49
+50/// Media session manager wrapper for Tauri state management
+51///
+52/// @req: DR-009 - Audio player UI (mini player, full screen)
+53pub struct MediaSessionManagerWrapper(pub Mutex<MediaSessionManager>);
+54
+55/// Video settings state wrapper for Tauri
+56///
+57/// @req: DR-048 - Video settings (auto-play toggle, countdown duration)
+58pub struct VideoSettingsWrapper(pub Mutex<VideoSettings>);
+59
+60/// Response for player state queries
+61#[derive(specta::Type, Debug, Serialize)]
+62#[serde(rename_all = "camelCase")]
+63pub struct PlayerStatus {
+64    pub state: PlayerState,
+65    pub position: f64,
+66    pub duration: Option<f64>,
+67    pub volume: f32,
+68    pub muted: bool,
+69    pub shuffle: bool,
+70    pub repeat: RepeatMode,
+71    /// Backend being used (native = ExoPlayer/libmpv, html5 = fallback)
+72    pub backend: VideoBackend,
+73    /// Whether frontend should render HTML5 video element
+74    pub use_html5_element: bool,
+75
+76    // Merged fields (prefer remote session when available)
+77    /// Media item from either local queue or remote session
+78    pub merged_media: Option<MergedMediaItem>,
+79    /// Playing state from either local player or remote session
+80    pub merged_is_playing: bool,
+81    /// Volume from either local player or remote session (0-1 normalized)
+82    pub merged_volume: f32,
+83}
+84
+85/// Lightweight media item for merged playback state
+86/// Converts from both local MediaItem and remote NowPlayingItem
+87#[derive(specta::Type, Debug, Serialize, Clone)]
+88#[serde(rename_all = "camelCase")]
+89pub struct MergedMediaItem {
+90    pub id: String,
+91    pub title: String,
+92    pub artist: Option<String>,
+93    pub album: Option<String>,
+94    pub album_id: Option<String>,
+95    pub duration: Option<f64>,
+96    pub primary_image_tag: Option<String>,
+97    /// Neutral image identifier — replaces `primary_image_tag` (same value).
+98    pub image_id: Option<String>,
+99    pub media_type: String,
+100}
+101
+102// Convert from local MediaItem
+103impl From<&crate::player::MediaItem> for MergedMediaItem {
+104    fn from(item: &crate::player::MediaItem) -> Self {
+105        Self {
+106            id: item.id.clone(),
+107            title: item.title.clone(),
+108            artist: item.artist.clone(),
+109            album: item.album.clone(),
+110            album_id: item.album_id.clone(),
+111            duration: item.duration,
+112            primary_image_tag: item.primary_image_tag.clone(),
+113            image_id: item.primary_image_tag.clone(),
+114            media_type: match item.media_type {
+115                crate::player::MediaType::Audio => "audio".to_string(),
+116                crate::player::MediaType::Video => "video".to_string(),
+117            },
+118        }
+119    }
+120}
+121
+122// Convert from remote NowPlayingItem
+123impl From<&crate::jellyfin::client::NowPlayingItem> for MergedMediaItem {
+124    fn from(item: &crate::jellyfin::client::NowPlayingItem) -> Self {
+125        Self {
+126            id: item.id.clone().unwrap_or_default(),
+127            title: item.name.clone().unwrap_or_else(|| "Unknown".to_string()),
+128            artist: item
+129                .album_artist
+130                .clone()
+131                .or_else(|| item.artists.as_ref().and_then(|a| a.first().cloned())),
+132            album: item.album.clone(),
+133            album_id: item.album_id.clone(),
+134            duration: item.run_time_ticks.map(|ticks| ticks as f64 / 10_000_000.0),
+135            primary_image_tag: item.primary_image_tag.clone(),
+136            image_id: item.primary_image_tag.clone(),
+137            media_type: item
+138                .item_type
+139                .clone()
+140                .unwrap_or_else(|| "audio".to_string())
+141                .to_lowercase(),
+142        }
+143    }
+144}
+145
+146/// Response for queue queries
+147#[derive(specta::Type, Debug, Serialize)]
+148#[serde(rename_all = "camelCase")]
+149pub struct QueueStatus {
+150    pub items: Vec<MediaItem>,
+151    pub current_index: Option<usize>,
+152    pub shuffle: bool,
+153    pub repeat: RepeatMode,
+154    pub has_next: bool,
+155    pub has_previous: bool,
+156}
+157
+158/// Backend type for video playback
+159#[derive(specta::Type, Debug, Serialize)]
+160#[serde(rename_all = "lowercase")]
+161pub enum VideoBackend {
+162    /// Native backend (ExoPlayer on Android, libmpv on Linux)
+163    Native,
+164    /// HTML5 video element fallback
+165    Html5,
+166}
+167
+168/// Request to play a single video item
+169///
+170/// Simplified to video playback only. Audio playback uses player_play_tracks
+171/// to avoid Tauri Android serialization issues with complex objects.
+172#[derive(specta::Type, Debug, Deserialize)]
+173#[serde(rename_all = "camelCase")]
+174pub struct PlayItemRequest {
+175    pub id: String,
+176    pub title: String,
+177    pub stream_url: String,
+178    /// Video codec (e.g., "h264", "hevc") for video media
+179    pub video_codec: String,
+180    /// Whether the video requires server-side transcoding
+181    pub needs_transcoding: bool,
+182    /// Optional now-playing metadata. Used by the background-audio handoff so the
+183    /// lockscreen/miniplayer show the item (title/subtitle/artwork). Defaulted so
+184    /// existing video-only callers need not send them.
+185    #[serde(default)]
+186    pub artist: Option<String>,
+187    #[serde(default)]
+188    pub primary_image_tag: Option<String>,
+189    #[serde(default)]
+190    pub server_id: Option<String>,
+191    /// Total media duration (seconds). Threaded through the background-audio
+192    /// handoff so the lockscreen MediaSession advertises a real duration — a
+193    /// zero-duration session renders no scrubber, even with ACTION_SEEK_TO set.
+194    #[serde(default)]
+195    pub duration_seconds: Option<f64>,
+196    /// Item type (e.g. "Episode", "Movie", "Audio"). Carried through the
+197    /// background-audio handoff so an episode played as audio-only is still
+198    /// recognised as an episode by autoplay (UR-040) and advances to the next one.
+199    #[serde(default)]
+200    pub item_type: Option<String>,
+201    /// Series ID for TV episodes. Needed alongside `item_type` so the backend can
+202    /// look up the next episode when a background-audio track ends.
+203    #[serde(default)]
+204    pub series_id: Option<String>,
+205    /// Subtitle tracks to sideload, with URLs the frontend has already resolved.
+206    ///
+207    /// Only the native backends use these: on Android they become the
+208    /// `MediaItem.SubtitleConfiguration`s ExoPlayer renders. The HTML5 path
+209    /// builds its own `<track>` children instead and ignores this list.
+210    ///
+211    /// **Order is the contract.** `player_set_subtitle_track(n)` reaches
+212    /// `JellyTauPlayer.setSubtitleTrack(n)`, which indexes into ExoPlayer's
+213    /// *text track groups* — i.e. the position of the sideloaded configuration,
+214    /// not the Jellyfin stream index (which is kept on each entry for the UI's
+215    /// benefit). So `n` must be a position in this very array, and the array
+216    /// must not be reordered or filtered between building it and sending it.
+217    /// `nativeSubtitleArrayIndex()` on the frontend computes `n` from the same
+218    /// list that is sent here, for exactly this reason.
+219    ///
+220    /// Defaulted so the background-audio handoff and the autoplay/next-episode
+221    /// callers, which have no subtitles to offer, need not send the field.
+222    ///
+223    /// TRACES: UR-020 | IR-016, JA-008 | UT-145
+224    #[serde(default)]
+225    pub subtitles: Vec<crate::player::SubtitleTrack>,
+226}
+227
+228/// Queue context for remote transfer - what type of queue is this?
+229#[derive(specta::Type, Debug, Deserialize)]
+230#[serde(tag = "type", rename_all = "lowercase")]
+231pub enum PlayQueueContext {
+232    /// Playing from a specific album
+233    Album {
+234        #[serde(rename = "albumId")]
+235        album_id: String,
+236        #[serde(rename = "albumName")]
+237        album_name: String,
+238    },
+239    /// Playing from a specific playlist
+240    Playlist {
+241        #[serde(rename = "playlistId")]
+242        playlist_id: String,
+243        #[serde(rename = "playlistName")]
+244        playlist_name: String,
+245    },
+246    /// Custom queue (search results, manual queue, etc.)
+247    Custom,
+248}
+249
+250/// Request to play a queue of items
+251#[derive(specta::Type, Debug, Deserialize)]
+252#[serde(rename_all = "camelCase")]
+253pub struct PlayQueueRequest {
+254    pub items: Vec<PlayItemRequest>,
+255    pub start_index: usize,
+256    pub shuffle: bool,
+257    /// Optional context for the queue (album, playlist, or custom)
+258    /// Used for remote playback transfer
+259    #[serde(default)]
+260    pub context: Option<PlayQueueContext>,
+261}
+262
+263/// Request to play a track from an album (backend fetches all tracks)
+264#[derive(specta::Type, Debug, Deserialize)]
+265#[serde(rename_all = "camelCase")]
+266pub struct PlayAlbumTrackRequest {
+267    pub album_id: String,
+268    pub album_name: String,
+269    pub track_id: String,
+270    pub shuffle: bool,
+271}
+272
+273/// Request to play tracks by ID (backend fetches metadata)
+274#[derive(specta::Type, Debug, Deserialize)]
+275#[serde(rename_all = "camelCase")]
+276pub struct PlayTracksRequest {
+277    pub track_ids: Vec<String>,
+278    pub start_index: usize,
+279    pub shuffle: bool,
+280    pub context: PlayTracksContext,
+281    /// Position (seconds) to resume the starting track from. Used when taking
+282    /// over playback from a remote session so we don't restart from 0.
+283    #[serde(default)]
+284    pub start_position: Option<f64>,
+285}
+286
+287/// Context information for track playback
+288#[derive(specta::Type, Debug, Deserialize)]
+289#[serde(tag = "type", rename_all = "lowercase")]
+290pub enum PlayTracksContext {
+291    Playlist {
+292        #[serde(rename = "playlistId")]
+293        playlist_id: String,
+294        #[serde(rename = "playlistName")]
+295        playlist_name: String,
+296    },
+297    Search {
+298        #[serde(rename = "searchQuery")]
+299        #[allow(dead_code)] // Used for deserialization, may be used later for analytics
+300        search_query: String,
+301    },
+302    Custom {
+303        #[serde(rename = "label")]
+304        #[allow(dead_code)] // Used for deserialization, may be used later for UI display
+305        label: Option<String>,
+306    },
+307}
+308
+309/// Response for video seek operations
+310#[derive(specta::Type, Debug, Serialize)]
+311#[serde(tag = "strategy", rename_all = "camelCase")]
+312pub enum VideoSeekResponse {
+313    /// Use native seeking (HLS or direct stream)
+314    Native {
+315        /// Confirmed position after seek
+316        position: f64,
+317    },
+318    /// Reload stream from new position (transcoded non-HLS)
+319    ReloadStream {
+320        /// New stream URL starting at seek position
+321        new_url: String,
+322        /// Position offset to track (for display purposes)
+323        seek_offset: f64,
+324    },
+325}
+326
+327/// Response for audio track switching operations
+328#[derive(specta::Type, Debug, Serialize)]
+329#[serde(tag = "strategy", rename_all = "camelCase")]
+330pub enum AudioTrackSwitchResponse {
+331    /// Native backend handled it (Android ExoPlayer)
+332    Native {
+333        /// Confirmation message
+334        success: bool,
+335    },
+336    /// HTML5 needs to reload stream with new audio track
+337    ReloadStream {
+338        /// New stream URL with selected audio track
+339        new_url: String,
+340        /// Current position to resume from
+341        position: f64,
+342    },
+343}
+344
+345/// Response for a mid-playback streaming-quality change.
+346///
+347/// Mirrors [`AudioTrackSwitchResponse`]: the backend decides whether the caller
+348/// has to reload anything, so no strategy branch lives in the UI.
+349///
+350/// TRACES: UR-074 | DR-162
+351#[derive(specta::Type, Debug, Serialize)]
+352#[serde(tag = "strategy", rename_all = "camelCase")]
+353pub enum StreamQualityResponse {
+354    /// The native backend was reloaded here; nothing left for the frontend.
+355    Native {
+356        /// Position playback resumed at.
+357        position: f64,
+358    },
+359    /// HTML5 must reload its element with this URL.
+360    ReloadStream {
+361        /// New stream URL, already transcoded to the requested ceiling.
+362        new_url: String,
+363        /// Position to resume from.
+364        position: f64,
+365    },
+366}
+367
+368/// Helper function to create MediaItem from video request
+369///
+370/// PlayItemRequest is now video-only, so we create a video MediaItem.
+371/// Audio playback uses player_play_tracks which fetches full metadata from backend.
+372pub(super) async fn create_media_item(
+373    req: PlayItemRequest,
+374    db: Option<&DatabaseWrapper>,
+375) -> Result<MediaItem, String> {
+376    // For video-only requests, we use the item ID as the jellyfin ID
+377    let jellyfin_id = req.id.clone();
+378
+379    // Check if item is downloaded locally
+380    let local_path = if let Some(db_wrapper) = db {
+381        check_for_local_download(db_wrapper, &jellyfin_id).await?
+382    } else {
+383        None
+384    };
+385
+386    let source = if let Some(path) = local_path {
+387        MediaSource::Local {
+388            file_path: PathBuf::from(path),
+389            jellyfin_item_id: Some(jellyfin_id.clone()),
+390        }
+391    } else {
+392        MediaSource::Remote {
+393            stream_url: req.stream_url,
+394            jellyfin_item_id: jellyfin_id.clone(),
+395        }
+396    };
+397
+398    Ok(MediaItem {
+399        id: req.id.clone(),
+400        title: req.title.clone(),
+401        name: Some(req.title.clone()),
+402        artist: None,            // Not available from video-only request
+403        album: None,             // Not available from video-only request
+404        album_name: None,        // Not available from video-only request
+405        album_id: None,          // Not available from video-only request
+406        artist_items: None,      // Not available from video-only request
+407        artists: None,           // Not available from video-only request
+408        primary_image_tag: None, // Not available from video-only request
+409        image_id: None,
+410        item_type: None,   // Not available from video-only request
+411        playlist_id: None, // Not available from video-only request
+412        duration: None,    // Not available from video-only request
+413        artwork_url: None, // Not available from video-only request
+414        media_type: crate::player::MediaType::Video, // Video-only request
+415        source,
+416        video_codec: Some(req.video_codec),
+417        needs_transcoding: req.needs_transcoding,
+418        video_width: None,  // Not available from video-only request
+419        video_height: None, // Not available from video-only request
+420        // Sideloaded subtitles, in the order the frontend sent them — that order
+421        // is what `player_set_subtitle_track(n)` indexes into on Android.
+422        // TRACES: UR-020 | IR-016 | UT-145
+423        subtitles: req.subtitles,
+424        series_id: None, // Not available from video-only request
+425        server_id: None, // Not available from video-only request
+426    })
+427}
+428
+429/// Pick the source for an audio-only handoff.
+430///
+431/// A downloaded file wins over the audio-only stream URL. No transcode or audio
+432/// extraction is involved or wanted: the native backends already play a video
+433/// container without decoding its video — the Linux MPV backend is configured
+434/// with `video: no`, and ExoPlayer simply has no surface to render to when the
+435/// item is `MediaType::Audio`. Producing a separate audio-only file would cost
+436/// CPU and battery, need an encoder the project does not ship, and leave a
+437/// second artifact to keep in step with the first.
+438///
+439/// TRACES: UR-071 | DR-128 | UT-119
+440pub(super) fn background_audio_source(
+441    local_path: Option<String>,
+442    stream_url: String,
+443    item_id: &str,
+444) -> MediaSource {
+445    match local_path {
+446        Some(path) => MediaSource::Local {
+447            file_path: PathBuf::from(path),
+448            jellyfin_item_id: Some(item_id.to_string()),
+449        },
+450        None => MediaSource::Remote {
+451            stream_url,
+452            jellyfin_item_id: item_id.to_string(),
+453        },
+454    }
+455}
+456
+457/// How a background-audio handoff must start playback, given where its audio
+458/// actually begins.
+459///
+460/// TRACES: UR-040, UR-071 | DR-180 | UT-181
+461pub(super) struct BackgroundAudioPlan {
+462    /// The position the stream's own zero corresponds to, recorded as the
+463    /// handoff base so later readings can be shifted back to the episode's
+464    /// timeline.
+465    pub base_seconds: f64,
+466    /// Where to seek after loading, if the source does not already start there.
+467    pub seek_to: Option<f64>,
+468}
+469
+470/// Decide the base and the seek for a handoff at `position_seconds`.
+471///
+472/// The two sources start in different places. An audio-only **stream** is built
+473/// with `StartTimeTicks`, so the server makes the handoff point that stream's
+474/// zero: the base is the handoff position, and seeking would skip *past* the
+475/// content by that much again. A downloaded **file** has no such parameter and
+476/// begins at the episode's own zero, so it needs the opposite — no base, and a
+477/// real seek. Treating a file like a stream is why backgrounding a downloaded
+478/// episode restarted it from 0:00 while the lockscreen showed the right time.
+479///
+480/// TRACES: UR-040, UR-071 | DR-180 | UT-181
+481pub(super) fn background_audio_plan(
+482    is_local_file: bool,
+483    position_seconds: f64,
+484) -> BackgroundAudioPlan {
+485    let position = position_seconds.max(0.0);
+486
+487    if is_local_file {
+488        BackgroundAudioPlan {
+489            base_seconds: 0.0,
+490            seek_to: (position > 0.0).then_some(position),
+491        }
+492    } else {
+493        BackgroundAudioPlan {
+494            base_seconds: position,
+495            seek_to: None,
+496        }
+497    }
+498}
+499
+500/// Resolve the on-disk file backing a completed download, if there is one.
+501///
+502/// A `downloads` row is not proof of a file: it can outlive the bytes (manual
+503/// deletion, a cleared cache directory, a restored database). Every caller wants
+504/// "can I play this from disk right now", so existence is checked here rather
+505/// than trusted from the row.
+506///
+507/// Split out from [`check_for_local_download`] so the resolution is testable
+508/// without a `DatabaseWrapper`, and reusable by the video path.
+509///
+510/// TRACES: UR-071 | DR-123 | UT-116
+511pub(super) async fn resolve_local_media_path<S: DatabaseService>(
+512    db_service: &Arc<S>,
+513    item_id: &str,
+514) -> Result<Option<String>, String> {
+515    let query = Query::with_params(
+516        "SELECT file_path FROM downloads WHERE item_id = ? AND status = 'completed' LIMIT 1",
+517        vec![QueryParam::String(item_id.to_string())],
+518    );
+519
+520    let path: Option<String> = db_service
+521        .query_optional(query, |row| row.get(0))
+522        .await
+523        .map_err(|e| e.to_string())?;
+524
+525    match path {
+526        Some(ref file_path) if std::path::Path::new(file_path).exists() => Ok(path),
+527        Some(file_path) => {
+528            warn!(
+529                "[Player] Download entry exists in DB but file not found: {}",
+530                file_path
+531            );
+532            Ok(None)
+533        }
+534        None => Ok(None),
+535    }
+536}
+537
+538/// Check if an item has a completed download
+539pub(super) async fn check_for_local_download(
+540    db: &DatabaseWrapper,
+541    item_id: &str,
+542) -> Result<Option<String>, String> {
+543    let db_service = {
+544        let database = db.0.lock().map_err(|e| e.to_string())?;
+545        Arc::new(database.service())
+546    };
+547
+548    resolve_local_media_path(&db_service, item_id).await
+549}
+550
+551/// The on-disk path for a downloaded item, for playback surfaces that resolve
+552/// their own source rather than going through the queue.
+553///
+554/// The video player is the reason this exists: audio has preferred local files
+555/// since queue construction, but video asks the repository for a stream URL and
+556/// never consults `downloads`, so a downloaded film was still streamed — costing
+557/// bandwidth that had already been spent and failing outright when offline.
+558///
+559/// Returns `None` when nothing is downloaded *or* the file is missing, so the
+560/// caller falls back to streaming.
+561///
+562/// TRACES: UR-071 | DR-123 | UT-116
+563#[tauri::command]
+564#[specta::specta]
+565pub async fn player_local_media_path(
+566    db: State<'_, DatabaseWrapper>,
+567    item_id: String,
+568) -> Result<Option<String>, String> {
+569    let db_service = {
+570        let database = db.0.lock().map_err(|e| e.to_string())?;
+571        Arc::new(database.service())
+572    };
+573
+574    resolve_local_media_path(&db_service, &item_id).await
+575}
+576
+577/// Re-point queued streaming items at completed local downloads.
+578///
+579/// Sources are resolved once when the queue is built, so downloads that finish
+580/// while it plays (preloaded upcoming tracks) — or that existed before the
+581/// connection dropped — would otherwise keep streaming. Called before advancing
+582/// so the next track always prefers the on-disk copy.
+583///
+584/// Returns the number of items switched to a local source.
+585pub(super) async fn refresh_queue_local_sources(
+586    controller: &PlayerController,
+587    db: &DatabaseWrapper,
+588) -> Result<usize, String> {
+589    // Collect remote item IDs first; the queue lock must not be held across awaits.
+590    let remote_ids: Vec<String> = {
+591        let queue = controller.queue();
+592        let queue_lock = queue.lock().map_err(|e| e.to_string())?;
+593        queue_lock
+594            .items()
+595            .iter()
+596            .filter_map(|item| match &item.source {
+597                MediaSource::Remote {
+598                    jellyfin_item_id, ..
+599                } => Some(jellyfin_item_id.clone()),
+600                _ => None,
+601            })
+602            .collect()
+603    };
+604
+605    if remote_ids.is_empty() {
+606        return Ok(0);
+607    }
+608
+609    let mut local_paths: Vec<(String, String)> = Vec::new();
+610    for id in remote_ids {
+611        if let Some(path) = check_for_local_download(db, &id).await? {
+612            local_paths.push((id, path));
+613        }
+614    }
+615
+616    if local_paths.is_empty() {
+617        return Ok(0);
+618    }
+619
+620    let queue = controller.queue();
+621    let mut queue_lock = queue.lock().map_err(|e| e.to_string())?;
+622    let mut switched = 0;
+623    for item in queue_lock.items_mut() {
+624        if let MediaSource::Remote {
+625            jellyfin_item_id, ..
+626        } = &item.source
+627        {
+628            if let Some((id, path)) = local_paths.iter().find(|(id, _)| id == jellyfin_item_id) {
+629                info!(
+630                    "[Player] Switching queued track {} to local download: {}",
+631                    id, path
+632                );
+633                item.source = MediaSource::Local {
+634                    file_path: PathBuf::from(path),
+635                    jellyfin_item_id: Some(id.clone()),
+636                };
+637                switched += 1;
+638            }
+639        }
+640    }
+641    Ok(switched)
+642}
+643
+644/// Play a single media item (audio or video)
+645///
+646/// Accepts a PlayItemRequest with all optional fields properly defaulted.
+647/// This avoids Tauri's Android serialization issues with complex objects.
+648///
+649/// @req: UR-003 - Play videos
+650/// @req: UR-004 - Play audio uninterrupted
+651/// @req: UR-005 - Control media playback (play operation)
+652/// @req: DR-009 - Audio player UI
+653#[tauri::command]
+654#[specta::specta]
+655pub async fn player_play_item(
+656    player: State<'_, PlayerStateWrapper>,
+657    session: State<'_, MediaSessionManagerWrapper>,
+658    db: State<'_, DatabaseWrapper>,
+659    item: PlayItemRequest,
+660) -> Result<PlayerStatus, String> {
+661    info!(
+662        "player_play_item called: {} - {}",
+663        item.title, item.stream_url
+664    );
+665
+666    // Create media item, checking for local download first
+667    let media_item = create_media_item(item, Some(&db)).await?;
+668
+669    // Start appropriate session based on media type
+670    {
+671        let mut session_mgr = session.0.lock().map_err(|e| e.to_string())?;
+672        match media_item.media_type {
+673            MediaType::Audio => {
+674                session_mgr.start_audio_session(media_item.clone());
+675            }
+676            MediaType::Video => {
+677                // For single video items, treat as Movie (no series_id available here)
+678                session_mgr.start_movie_session(media_item.clone());
+679            }
+680        }
+681    }
+682
+683    let controller = player.0.lock().await;
+684    // On Linux, video plays in the WebKitGTK HTML5 <video> element (see
+685    // get_player_status -> use_html5_element). The MPV backend has no embedded
+686    // window, so loading the stream into it would only start a redundant decode
+687    // (and the frontend would immediately stop it). Only load into the native
+688    // backend on platforms that actually render video through it (e.g. Android).
+689    #[cfg(not(target_os = "linux"))]
+690    controller
+691        .play_item(media_item)
+692        .map_err(|e| e.to_string())?;
+693    #[cfg(target_os = "linux")]
+694    {
+695        // Keep the queue in sync for UI/remote-transfer without starting MPV.
+696        controller
+697            .set_current_item(media_item)
+698            .map_err(|e| e.to_string())?;
+699    }
+700
+701    // Emit queue changed event
+702    controller.emit_queue_changed();
+703
+704    // Emit session changed event
+705    if let Some(emitter) = controller.event_emitter() {
+706        let session_mgr = session.0.lock().map_err(|e| e.to_string())?;
+707        emitter.emit(PlayerStatusEvent::SessionChanged {
+708            session: session_mgr.current().clone(),
+709        });
+710    }
+711
+712    Ok(get_player_status(&controller))
+713}
+714
+715/// Enter background-audio mode: hand playback of the currently-watched video off
+716/// to the native ExoPlayer *audio* path so the audio keeps playing while the app
+717/// is backgrounded/locked, with no client-side video decode (UR-040).
+718///
+719/// `stream_url` MUST be an audio-only URL (see
+720/// `get_audio_only_stream_url_for_video`). The item is created as
+721/// `MediaType::Audio` so it starts an audio session and loads into the native
+722/// backend with `mediaType="audio"` — the WebView `<video>` is torn down on the
+723/// frontend side, so exactly one audio source is ever active.
+724///
+725/// This deliberately goes through the queue-based `play_item` path (NOT a
+726/// side-channel) so end-of-track lands in `on_playback_ended`, which already
+727/// honors the sleep timer (Time/Episodes/EndOfTrack) and drives autoplay-next.
+728/// The sleep-timer state is intentionally left untouched by the handoff.
+729///
+730/// TRACES: UR-040 | DR-052 | UT-061, IT-013
+731#[tauri::command]
+732#[specta::specta]
+733pub async fn player_enter_background_audio(
+734    player: State<'_, PlayerStateWrapper>,
+735    session: State<'_, MediaSessionManagerWrapper>,
+736    db: State<'_, DatabaseWrapper>,
+737    item: PlayItemRequest,
+738    position_seconds: f64,
+739) -> Result<PlayerStatus, String> {
+740    info!(
+741        "player_enter_background_audio: {} @ {:.1}s",
+742        item.title, position_seconds
+743    );
+744
+745    // Prefer the downloaded file over the audio-only stream URL the frontend
+746    // resolved. Handing the native backend a local video container yields
+747    // audio-only playback for free — no transcode, no second artifact.
+748    // TRACES: UR-071 | DR-128
+749    let local_path = check_for_local_download(&db, &item.id).await?;
+750    if local_path.is_some() {
+751        info!(
+752            "player_enter_background_audio: using downloaded file for {}",
+753            item.id
+754        );
+755    }
+756    // A downloaded file starts at the episode's zero; a stream starts at the
+757    // handoff point. Only one of them has a base, and only the other needs a seek.
+758    let plan = background_audio_plan(local_path.is_some(), position_seconds);
+759    let source = background_audio_source(local_path, item.stream_url, &item.id);
+760
+761    // Build an AUDIO media item pointing at the audio-only stream. We do not use
+762    // create_media_item() because that hardcodes MediaType::Video; background
+763    // audio must be Audio so no video decode is started.
+764    let media_item = MediaItem {
+765        id: item.id.clone(),
+766        title: item.title.clone(),
+767        name: Some(item.title.clone()),
+768        artist: item.artist.clone(),
+769        album: None,
+770        album_name: None,
+771        album_id: None,
+772        artist_items: None,
+773        artists: None,
+774        primary_image_tag: item.primary_image_tag.clone(),
+775        image_id: item.primary_image_tag.clone(),
+776        // Carry episode identity so autoplay can advance to the next episode when
+777        // this audio-only handoff ends while backgrounded (UR-040).
+778        item_type: item.item_type.clone(),
+779        playlist_id: None,
+780        // Carry the real duration so the lockscreen MediaSession can draw a scrubber.
+781        duration: item.duration_seconds,
+782        artwork_url: None,
+783        media_type: MediaType::Audio,
+784        source,
+785        video_codec: None,
+786        needs_transcoding: false,
+787        video_width: None,
+788        video_height: None,
+789        subtitles: vec![],
+790        series_id: item.series_id.clone(),
+791        server_id: item.server_id.clone(),
+792    };
+793
+794    {
+795        let mut session_mgr = session.0.lock().map_err(|e| e.to_string())?;
+796        session_mgr.start_audio_session(media_item.clone());
+797    }
+798
+799    // Same base offset drives the lockscreen scrubber: ExoPlayer reports position
+800    // relative to the stream's StartTimeTicks zero, but the metadata duration is
+801    // absolute, so shift the reported position back to absolute for the scrubber.
+802    let _ = crate::player::set_lockscreen_position_offset(plan.base_seconds);
+803
+804    let controller = player.0.lock().await;
+805    // Remember where the video was: for a stream the audio's zero == this
+806    // position (the URL was built with StartTimeTicks=position_seconds), so on
+807    // exit we add this base to the native player's relative position to get the
+808    // absolute one. The controller owns it so a backend-driven advance to the
+809    // next episode clears it along with the stream it described.
+810    controller.enter_background_audio(plan.base_seconds);
+811    controller
+812        .play_item(media_item)
+813        .map_err(|e| e.to_string())?;
+814    // Seek ONLY a local file. The audio-only URL already starts at the handoff
+815    // position via StartTimeTicks — its timeline begins at 0 == that point — so
+816    // seeking a stream would jump PAST the content by the handoff position again.
+817    if let Some(seek_to) = plan.seek_to {
+818        info!(
+819            "player_enter_background_audio: seeking the downloaded file to {:.1}s",
+820            seek_to
+821        );
+822        controller.seek(seek_to).map_err(|e| e.to_string())?;
+823    }
+824
+825    controller.emit_queue_changed();
+826    if let Some(emitter) = controller.event_emitter() {
+827        let session_mgr = session.0.lock().map_err(|e| e.to_string())?;
+828        emitter.emit(PlayerStatusEvent::SessionChanged {
+829            session: session_mgr.current().clone(),
+830        });
+831    }
+832
+833    Ok(get_player_status(&controller))
+834}
+835
+836/// Exit background-audio mode: stop the native audio player and return its final
+837/// position so the frontend can reload the WebView `<video>` there (UR-040).
+838///
+839/// Returns the position in seconds. The sleep timer is intentionally left
+840/// untouched — if it fired while backgrounded, playback is already stopped and
+841/// this simply reports the last position.
+842///
+843/// What playback should do now that the app is no longer visible.
+844///
+845/// The caller supplies only what it alone knows -- whether the per-player
+846/// toggle is armed, and whether Android put the window into picture-in-picture.
+847/// Everything else (what is playing, and therefore whether there is a picture to
+848/// lose) is read here, because it is domain state.
+849///
+850/// The rule itself is in `player::background_policy`; this command is the wire.
+851/// Returning `KeepPlaying` for an empty queue is deliberate: with nothing
+852/// playing there is nothing to pause, and an error would make the frontend
+853/// handle a case that is not a failure.
+854///
+855/// TRACES: UR-040, UR-041 | DR-224 | UT-211
+856#[tauri::command]
+857#[specta::specta]
+858pub async fn player_background_action(
+859    player: State<'_, PlayerStateWrapper>,
+860    background_audio_armed: bool,
+861    in_picture_in_picture: bool,
+862) -> Result<crate::player::background_policy::BackgroundAction, String> {
+863    use crate::player::background_policy::{background_action, is_video_media, BackgroundAction};
+864
+865    let is_video = {
+866        let controller = player.0.lock().await;
+867        let queue_arc = controller.queue();
+868        let queue = queue_arc.lock().map_err(|e| e.to_string())?;
+869        match queue.current() {
+870            Some(item) => is_video_media(item.media_type),
+871            None => return Ok(BackgroundAction::KeepPlaying),
+872        }
+873    };
+874
+875    let action = background_action(is_video, background_audio_armed, in_picture_in_picture);
+876    info!(
+877        "[player_background_action] video={} armed={} pip={} -> {:?}",
+878        is_video, background_audio_armed, in_picture_in_picture, action
+879    );
+880    Ok(action)
+881}
+882
+883/// TRACES: UR-040 | DR-052 | UT-061, IT-013
+884#[tauri::command]
+885#[specta::specta]
+886pub async fn player_exit_background_audio(
+887    player: State<'_, PlayerStateWrapper>,
+888) -> Result<f64, String> {
+889    let controller = player.0.lock().await;
+890
+891    // Read the position BEFORE clearing either base. The position tick applies the
+892    // base natively, so a tick landing between "base cleared" and "position read"
+893    // would hand back a relative position — the whole bug, reintroduced at the one
+894    // moment it matters most. Capturing into a `let` before stop() is also the
+895    // lock discipline from CLAUDE.md: never hold work across a re-entrant call.
+896    // (DR-159)
+897    //
+898    // `absolute_position` rather than `position`, because a tick that has not
+899    // landed *yet* is the same hazard from the other side: returning to the
+900    // foreground while the audio-only transcode is still opening read 0.0, and
+901    // the video reloaded at StartTimeTicks=0 — the episode restarting from the
+902    // beginning. Flooring at the handoff base cannot overshoot: the stream is
+903    // physically incapable of being behind its own starting point. (DR-178)
+904    let absolute = controller.absolute_position();
+905
+906    // Now safe to tear the handoff down, native side first.
+907    let _ = crate::player::set_lockscreen_position_offset(0.0);
+908    controller.exit_background_audio();
+909    controller.stop().map_err(|e| e.to_string())?;
+910    info!(
+911        "player_exit_background_audio: resuming the video at {:.1}s",
+912        absolute
+913    );
+914    Ok(absolute)
+915}
+916
+917/// Play a queue of media items
+918///
+919/// @req: UR-004 - Play audio uninterrupted
+920/// @req: UR-005 - Control media playback (queue playback)
+921/// @req: UR-015 - View and manage current audio queue
+922/// @req: DR-005 - Queue manager with shuffle, repeat, history
+923#[tauri::command]
+924#[specta::specta]
+925pub async fn player_play_queue(
+926    player: State<'_, PlayerStateWrapper>,
+927    session: State<'_, MediaSessionManagerWrapper>,
+928    db: State<'_, DatabaseWrapper>,
+929    request: PlayQueueRequest,
+930) -> Result<PlayerStatus, String> {
+931    info!(
+932        "player_play_queue called: {} items, start_index: {}, shuffle: {}",
+933        request.items.len(),
+934        request.start_index,
+935        request.shuffle
+936    );
+937
+938    // Handle shuffle first
+939    if request.shuffle {
+940        let controller = player.0.lock().await;
+941        controller.toggle_shuffle();
+942    }
+943
+944    // Convert request context to internal QueueContext
+945    let queue_context = match request.context {
+946        Some(PlayQueueContext::Album {
+947            album_id,
+948            album_name,
+949        }) => QueueContext::Album {
+950            album_id,
+951            album_name,
+952        },
+953        Some(PlayQueueContext::Playlist {
+954            playlist_id,
+955            playlist_name,
+956        }) => QueueContext::Playlist {
+957            playlist_id,
+958            playlist_name,
+959        },
+960        Some(PlayQueueContext::Custom) | None => QueueContext::Custom,
+961    };
+962
+963    // Create media items, checking for local downloads (must not hold locks during await)
+964    let mut items: Vec<MediaItem> = Vec::new();
+965    for req in request.items {
+966        items.push(create_media_item(req, Some(&db)).await?);
+967    }
+968
+969    // Start appropriate session based on first item's media type
+970    if let Some(first_item) = items.get(request.start_index) {
+971        let mut session_mgr = session.0.lock().map_err(|e| e.to_string())?;
+972        match first_item.media_type {
+973            MediaType::Audio => {
+974                session_mgr.start_audio_session(first_item.clone());
+975            }
+976            MediaType::Video => {
+977                // Queue of videos treated as movie session (TV episodes use different flow)
+978                session_mgr.start_movie_session(first_item.clone());
+979            }
+980        }
+981    }
+982
+983    // Now play the queue and get status
+984    let controller = player.0.lock().await;
+985    info!(
+986        "player_play_queue: Calling controller.play_queue with {} items at index {}",
+987        items.len(),
+988        request.start_index
+989    );
+990    controller
+991        .play_queue(items, request.start_index)
+992        .map_err(|e| e.to_string())?;
+993
+994    // Set the queue context for remote transfer
+995    {
+996        let queue_arc = controller.queue();
+997        let mut queue = queue_arc.lock().map_err(|e| e.to_string())?;
+998        queue.set_context(queue_context);
+999    }
+1000
+1001    // Emit queue changed event
+1002    controller.emit_queue_changed();
+1003
+1004    // Emit session changed event
+1005    if let Some(emitter) = controller.event_emitter() {
+1006        let session_mgr = session.0.lock().map_err(|e| e.to_string())?;
+1007        emitter.emit(PlayerStatusEvent::SessionChanged {
+1008            session: session_mgr.current().clone(),
+1009        });
+1010    }
+1011
+1012    Ok(get_player_status(&controller))
+1013}
+1014
+1015#[tauri::command]
+1016#[specta::specta]
+1017pub async fn player_play(
+1018    player: State<'_, PlayerStateWrapper>,
+1019    playback_mode: State<'_, super::playback_mode::PlaybackModeManagerWrapper>,
+1020) -> Result<PlayerStatus, String> {
+1021    // Check if we're in remote mode
+1022    let mode = playback_mode.0.get_mode();
+1023
+1024    if let crate::playback_mode::PlaybackMode::Remote { session_id } = mode {
+1025        // Send play command to remote session - clone client before await
+1026        let client = {
+1027            let controller = player.0.lock().await;
+1028            let client_arc = controller.jellyfin_client();
+1029            let client_opt = client_arc.lock().map_err(|e| e.to_string())?;
+1030            client_opt
+1031                .as_ref()
+1032                .ok_or("Jellyfin client not configured")?
+1033                .clone()
+1034        };
+1035        client.send_session_command(session_id, "Unpause").await?;
+1036    } else {
+1037        // Local playback
+1038        let controller = player.0.lock().await;
+1039        controller.play().map_err(|e| e.to_string())?;
+1040    }
+1041
+1042    let controller = player.0.lock().await;
+1043    Ok(get_player_status(&controller))
+1044}
+1045
+1046#[tauri::command]
+1047#[specta::specta]
+1048pub async fn player_pause(
+1049    player: State<'_, PlayerStateWrapper>,
+1050    playback_mode: State<'_, super::playback_mode::PlaybackModeManagerWrapper>,
+1051) -> Result<PlayerStatus, String> {
+1052    // Check if we're in remote mode
+1053    let mode = playback_mode.0.get_mode();
+1054
+1055    if let crate::playback_mode::PlaybackMode::Remote { session_id } = mode {
+1056        // Send pause command to remote session - clone client before await
+1057        let client = {
+1058            let controller = player.0.lock().await;
+1059            let client_arc = controller.jellyfin_client();
+1060            let client_opt = client_arc.lock().map_err(|e| e.to_string())?;
+1061            client_opt
+1062                .as_ref()
+1063                .ok_or("Jellyfin client not configured")?
+1064                .clone()
+1065        };
+1066        client.send_session_command(session_id, "Pause").await?;
+1067    } else {
+1068        // Local playback
+1069        let controller = player.0.lock().await;
+1070        controller.pause().map_err(|e| e.to_string())?;
+1071    }
+1072
+1073    let controller = player.0.lock().await;
+1074    Ok(get_player_status(&controller))
+1075}
+1076
+1077#[tauri::command]
+1078#[specta::specta]
+1079pub async fn player_toggle(
+1080    player: State<'_, PlayerStateWrapper>,
+1081    playback_mode: State<'_, super::playback_mode::PlaybackModeManagerWrapper>,
+1082) -> Result<PlayerStatus, String> {
+1083    // Check if we're in remote mode
+1084    let mode = playback_mode.0.get_mode();
+1085
+1086    if let crate::playback_mode::PlaybackMode::Remote { session_id } = mode {
+1087        // Send toggle command to remote session - clone client before await
+1088        let client = {
+1089            let controller = player.0.lock().await;
+1090            let client_arc = controller.jellyfin_client();
+1091            let client_opt = client_arc.lock().map_err(|e| e.to_string())?;
+1092            client_opt
+1093                .as_ref()
+1094                .ok_or("Jellyfin client not configured")?
+1095                .clone()
+1096        };
+1097        client.send_session_command(session_id, "PlayPause").await?;
+1098    } else {
+1099        // Local playback
+1100        let controller = player.0.lock().await;
+1101        controller.toggle_playback().map_err(|e| e.to_string())?;
+1102    }
+1103
+1104    let controller = player.0.lock().await;
+1105    Ok(get_player_status(&controller))
+1106}
+1107
+1108#[tauri::command]
+1109#[specta::specta]
+1110pub async fn player_stop(
+1111    player: State<'_, PlayerStateWrapper>,
+1112    session: State<'_, MediaSessionManagerWrapper>,
+1113    playback_mode: State<'_, super::playback_mode::PlaybackModeManagerWrapper>,
+1114) -> Result<PlayerStatus, String> {
+1115    // Check if we're in remote mode
+1116    let mode = playback_mode.0.get_mode();
+1117
+1118    if let crate::playback_mode::PlaybackMode::Remote { session_id } = mode {
+1119        // Send stop command to remote session - clone client before await
+1120        let client = {
+1121            let controller = player.0.lock().await;
+1122            let client_arc = controller.jellyfin_client();
+1123            let client_opt = client_arc.lock().map_err(|e| e.to_string())?;
+1124            client_opt
+1125                .as_ref()
+1126                .ok_or("Jellyfin client not configured")?
+1127                .clone()
+1128        };
+1129        client.send_session_command(session_id, "Stop").await?;
+1130
+1131        // Stopping the remote session ends the cast, so the manager returns to
+1132        // Idle — same as a local stop. This is also what hands OS volume control
+1133        // back to this device: set_mode releases the Android remote volume
+1134        // provider on any exit from remote mode. Without it the mode stayed
+1135        // Remote and the system volume slider remained stuck on the remote
+1136        // session with no way back to the local speaker.
+1137        playback_mode
+1138            .0
+1139            .set_mode(crate::playback_mode::PlaybackMode::Idle);
+1140    } else {
+1141        // Local playback
+1142        let controller = player.0.lock().await;
+1143        controller.stop().map_err(|e| e.to_string())?;
+1144
+1145        // A genuine local stop returns the manager to Idle so it no longer
+1146        // reports Local (or a stale Remote) — otherwise a later play/pause would
+1147        // route to the wrong device.
+1148        playback_mode
+1149            .0
+1150            .set_mode(crate::playback_mode::PlaybackMode::Idle);
+1151
+1152        // Handle session state based on type (local playback only)
+1153        {
+1154            let mut session_mgr = session.0.lock().map_err(|e| e.to_string())?;
+1155            let current_session = session_mgr.current().clone();
+1156
+1157            match current_session {
+1158                crate::player::MediaSessionType::Movie { .. } => {
+1159                    // Movies auto-dismiss when stopped
+1160                    session_mgr.movie_session_ended();
+1161                }
+1162                crate::player::MediaSessionType::Audio { .. } => {
+1163                    // Audio persists as inactive (user can resume later)
+1164                    session_mgr.audio_session_inactive();
+1165                }
+1166                crate::player::MediaSessionType::TvShow { .. } => {
+1167                    // TV shows mark episode ended (waiting for next or dismiss)
+1168                    session_mgr.tv_session_episode_ended();
+1169                }
+1170                crate::player::MediaSessionType::Idle => {
+1171                    // Already idle, no-op
+1172                }
+1173            }
+1174
+1175            // Emit session changed event
+1176            if let Some(emitter) = controller.event_emitter() {
+1177                emitter.emit(PlayerStatusEvent::SessionChanged {
+1178                    session: session_mgr.current().clone(),
+1179                });
+1180            }
+1181        }
+1182    }
+1183
+1184    let controller = player.0.lock().await;
+1185    Ok(get_player_status(&controller))
+1186}
+1187
+1188#[tauri::command]
+1189#[specta::specta]
+1190pub async fn player_next(
+1191    player: State<'_, PlayerStateWrapper>,
+1192    session: State<'_, MediaSessionManagerWrapper>,
+1193    playback_mode: State<'_, super::playback_mode::PlaybackModeManagerWrapper>,
+1194    db: State<'_, DatabaseWrapper>,
+1195) -> Result<PlayerStatus, String> {
+1196    debug!("[player_next] Command called from frontend");
+1197
+1198    // Check if we're in remote mode
+1199    let mode = playback_mode.0.get_mode();
+1200
+1201    if let crate::playback_mode::PlaybackMode::Remote { session_id } = mode {
+1202        // Send next track command to remote session - clone client before await
+1203        let client = {
+1204            let controller = player.0.lock().await;
+1205            let client_arc = controller.jellyfin_client();
+1206            let client_opt = client_arc.lock().map_err(|e| e.to_string())?;
+1207            client_opt
+1208                .as_ref()
+1209                .ok_or("Jellyfin client not configured")?
+1210                .clone()
+1211        };
+1212        client.send_session_command(session_id, "NextTrack").await?;
+1213    } else {
+1214        // Local playback
+1215        let controller = player.0.lock().await;
+1216        // Prefer downloads that completed since the queue was built
+1217        if let Err(e) = refresh_queue_local_sources(&controller, &db).await {
+1218            warn!("[player_next] Failed to refresh local sources: {}", e);
+1219        }
+1220        controller.next().map_err(|e| e.to_string())?;
+1221        controller.emit_queue_changed();
+1222
+1223        // Update audio session track if in audio session
+1224        let current_item = {
+1225            let queue = controller.queue();
+1226            let queue_lock = queue.lock().map_err(|e| e.to_string())?;
+1227            queue_lock.current().cloned()
+1228        };
+1229
+1230        if let Some(item) = current_item {
+1231            if item.media_type == MediaType::Audio {
+1232                let mut session_mgr = session.0.lock().map_err(|e| e.to_string())?;
+1233                session_mgr.update_audio_track(item);
+1234
+1235                // Emit session changed event
+1236                if let Some(emitter) = controller.event_emitter() {
+1237                    emitter.emit(PlayerStatusEvent::SessionChanged {
+1238                        session: session_mgr.current().clone(),
+1239                    });
+1240                }
+1241            }
+1242        }
+1243    }
+1244
+1245    let controller = player.0.lock().await;
+1246    Ok(get_player_status(&controller))
+1247}
+1248
+1249#[tauri::command]
+1250#[specta::specta]
+1251pub async fn player_previous(
+1252    player: State<'_, PlayerStateWrapper>,
+1253    session: State<'_, MediaSessionManagerWrapper>,
+1254    playback_mode: State<'_, super::playback_mode::PlaybackModeManagerWrapper>,
+1255    db: State<'_, DatabaseWrapper>,
+1256) -> Result<PlayerStatus, String> {
+1257    // Check if we're in remote mode
+1258    let mode = playback_mode.0.get_mode();
+1259
+1260    if let crate::playback_mode::PlaybackMode::Remote { session_id } = mode {
+1261        // Send previous track command to remote session - clone client before await
+1262        let client = {
+1263            let controller = player.0.lock().await;
+1264            let client_arc = controller.jellyfin_client();
+1265            let client_opt = client_arc.lock().map_err(|e| e.to_string())?;
+1266            client_opt
+1267                .as_ref()
+1268                .ok_or("Jellyfin client not configured")?
+1269                .clone()
+1270        };
+1271        client
+1272            .send_session_command(session_id, "PreviousTrack")
+1273            .await?;
+1274    } else {
+1275        // Local playback
+1276        let controller = player.0.lock().await;
+1277        // Prefer downloads that completed since the queue was built
+1278        if let Err(e) = refresh_queue_local_sources(&controller, &db).await {
+1279            warn!("[player_previous] Failed to refresh local sources: {}", e);
+1280        }
+1281        controller.previous().map_err(|e| e.to_string())?;
+1282        controller.emit_queue_changed();
+1283
+1284        // Update audio session track if in audio session
+1285        let current_item = {
+1286            let queue = controller.queue();
+1287            let queue_lock = queue.lock().map_err(|e| e.to_string())?;
+1288            queue_lock.current().cloned()
+1289        };
+1290
+1291        if let Some(item) = current_item {
+1292            if item.media_type == MediaType::Audio {
+1293                let mut session_mgr = session.0.lock().map_err(|e| e.to_string())?;
+1294                session_mgr.update_audio_track(item);
+1295
+1296                // Emit session changed event
+1297                if let Some(emitter) = controller.event_emitter() {
+1298                    emitter.emit(PlayerStatusEvent::SessionChanged {
+1299                        session: session_mgr.current().clone(),
+1300                    });
+1301                }
+1302            }
+1303        }
+1304    }
+1305
+1306    let controller = player.0.lock().await;
+1307    Ok(get_player_status(&controller))
+1308}
+1309
+1310#[tauri::command]
+1311#[specta::specta]
+1312pub async fn player_seek(
+1313    player: State<'_, PlayerStateWrapper>,
+1314    playback_mode: State<'_, super::playback_mode::PlaybackModeManagerWrapper>,
+1315    position: f64,
+1316) -> Result<PlayerStatus, String> {
+1317    // Check if we're in remote mode
+1318    let mode = playback_mode.0.get_mode();
+1319
+1320    if let crate::playback_mode::PlaybackMode::Remote { session_id } = mode {
+1321        // Send seek command to remote session - clone client before await
+1322        let client = {
+1323            let controller = player.0.lock().await;
+1324            let client_arc = controller.jellyfin_client();
+1325            let client_opt = client_arc.lock().map_err(|e| e.to_string())?;
+1326            client_opt
+1327                .as_ref()
+1328                .ok_or("Jellyfin client not configured")?
+1329                .clone()
+1330        };
+1331        let position_ticks = (position * 10_000_000.0) as i64;
+1332        client.session_seek(session_id, position_ticks).await?;
+1333    } else {
+1334        // Local playback. seek_absolute, not seek: the position came from the UI,
+1335        // which shows the whole item, so during a background-audio handoff it has
+1336        // to be resolved against the episode's timeline rather than the handoff
+1337        // stream's. (DR-159)
+1338        let controller = player.0.lock().await;
+1339        controller.seek_absolute(position).await?;
+1340    }
+1341
+1342    let controller = player.0.lock().await;
+1343    Ok(get_player_status(&controller))
+1344}
+1345
+1346/// Smart video seeking that decides between native and server-side seeking
+1347///
+1348/// This command analyzes the current video stream and automatically chooses
+1349/// the best seeking strategy:
+1350/// - HLS streams (.m3u8): Use native seeking
+1351/// - Direct play streams: Use native seeking
+1352/// - Transcoded non-HLS: Request new stream URL from server starting at seek position
+1353///
+1354/// For native (non-HTML5) backends, this command handles the entire stream reload
+1355/// internally. For HTML5 backends, it returns the new URL for the frontend to handle.
+1356#[tauri::command]
+1357#[specta::specta]
+1358pub async fn player_seek_video(
+1359    player: State<'_, PlayerStateWrapper>,
+1360    repository_manager: State<'_, super::repository::RepositoryManagerWrapper>,
+1361    repository_handle: String,
+1362    position: f64,
+1363    media_source_id: Option<String>,
+1364    audio_stream_index: Option<i32>,
+1365    use_html5: bool,
+1366) -> Result<VideoSeekResponse, String> {
+1367    info!(
+1368        "[player_seek_video] Seeking to {} seconds (use_html5: {})",
+1369        position, use_html5
+1370    );
+1371
+1372    // Get repository
+1373    let repository = repository_manager
+1374        .0
+1375        .get(&repository_handle)
+1376        .ok_or("Repository not found - user may need to log in")?;
+1377
+1378    // Get current playing item to analyze stream characteristics
+1379    // Clone what we need to avoid holding locks across await points
+1380    let (needs_transcoding, jellyfin_item_id, stream_url, is_local) = {
+1381        let controller = player.0.lock().await;
+1382        let queue_arc = controller.queue();
+1383        let queue = queue_arc.lock().map_err(|e| e.to_string())?;
+1384
+1385        let current_item = queue.current().ok_or("No item currently playing")?;
+1386
+1387        if current_item.media_type != MediaType::Video {
+1388            return Err("Current item is not a video".to_string());
+1389        }
+1390
+1391        let jellyfin_id = current_item
+1392            .jellyfin_id()
+1393            .ok_or("Current video has no Jellyfin ID")?
+1394            .to_string();
+1395
+1396        let (stream_url, is_local_file) = match &current_item.source {
+1397            MediaSource::Remote { stream_url, .. } => (stream_url.clone(), false),
+1398            MediaSource::Local { .. } => (String::new(), true),
+1399            MediaSource::DirectUrl { url } => (url.clone(), false),
+1400        };
+1401
+1402        let needs_trans = current_item.needs_transcoding;
+1403        (needs_trans, jellyfin_id, stream_url, is_local_file)
+1404    }; // Locks are dropped here
+1405
+1406    // Determine seek strategy using the testable helper function
+1407    let is_hls = stream_url.contains(".m3u8");
+1408    let strategy = determine_video_seek_strategy(is_local, is_hls, needs_transcoding, use_html5);
+1409
+1410    info!("[player_seek_video] Stream analysis: is_local={}, is_hls={}, needs_transcoding={}, use_html5={}, strategy={:?}",
+1411        is_local, is_hls, needs_transcoding, use_html5, strategy);
+1412
+1413    match strategy {
+1414        VideoSeekStrategy::LocalNativeSeek | VideoSeekStrategy::BackendNativeSeek => {
+1415            // Local files and native backend streams - call backend.seek()
+1416            info!("[player_seek_video] Using backend native seek");
+1417            let controller = player.0.lock().await;
+1418            controller.seek(position).map_err(|e| e.to_string())?;
+1419            Ok(VideoSeekResponse::Native { position })
+1420        }
+1421        VideoSeekStrategy::Html5NativeSeek => {
+1422            // HTML5 backend with HLS or direct play - frontend handles seeking
+1423            // We don't call backend.seek() because video is in HTML5 element, not in MPV
+1424            info!("[player_seek_video] HTML5 native seek - returning position for frontend");
+1425            Ok(VideoSeekResponse::Native { position })
+1426        }
+1427        VideoSeekStrategy::Html5ReloadStream => {
+1428            // Transcoded non-HLS with HTML5 - frontend handles stream reload
+1429            info!("[player_seek_video] HTML5 reload stream - requesting new stream URL");
+1430
+1431            let new_url = repository
+1432                .get_video_stream_url(
+1433                    &jellyfin_item_id,
+1434                    media_source_id.as_deref(),
+1435                    audio_stream_index,
+1436                )
+1437                .await
+1438                .map_err(|e| format!("Failed to get video stream URL: {:?}", e))?;
+1439
+1440            info!(
+1441                "[player_seek_video] Got new stream URL for position {}",
+1442                position
+1443            );
+1444
+1445            // `seek_offset` carries the position to RESUME AT, not a base to add
+1446            // to the element's clock. The reloaded stream starts at the item's
+1447            // zero — a position on an HLS playlist makes the server 400 every
+1448            // segment behind it (DR-181) — so the adapter reaches the position by
+1449            // seeking the element and leaves the transcode offset at zero. The
+1450            // field keeps its name only because renaming it means regenerating
+1451            // the specta bindings; `reloadSource` documents the contract.
+1452            Ok(VideoSeekResponse::ReloadStream {
+1453                new_url,
+1454                seek_offset: position,
+1455            })
+1456        }
+1457        VideoSeekStrategy::BackendReloadStream => {
+1458            // Transcoded non-HLS with native backend - backend handles stream reload
+1459            info!("[player_seek_video] Backend reload stream - requesting new stream URL");
+1460
+1461            let new_url = repository
+1462                .get_video_stream_url(
+1463                    &jellyfin_item_id,
+1464                    media_source_id.as_deref(),
+1465                    audio_stream_index,
+1466                )
+1467                .await
+1468                .map_err(|e| format!("Failed to get video stream URL: {:?}", e))?;
+1469
+1470            info!("[player_seek_video] Got new stream URL, handling reload internally");
+1471
+1472            // Stop current playback
+1473            {
+1474                let controller = player.0.lock().await;
+1475                controller.stop().map_err(|e| e.to_string())?;
+1476            }
+1477
+1478            // Update the stream URL in the queue
+1479            {
+1480                let controller = player.0.lock().await;
+1481                let queue_arc = controller.queue();
+1482                let mut queue = queue_arc.lock().map_err(|e| e.to_string())?;
+1483
+1484                if !queue.update_current_stream_url(new_url.clone()) {
+1485                    return Err("Failed to update stream URL in queue".to_string());
+1486                }
+1487            }
+1488
+1489            // Reload the player with the updated item from queue
+1490            {
+1491                let controller = player.0.lock().await;
+1492                let queue_arc = controller.queue();
+1493                let queue = queue_arc.lock().map_err(|e| e.to_string())?;
+1494
+1495                if let Some(updated_item) = queue.current() {
+1496                    controller
+1497                        .load_and_play(updated_item)
+1498                        .map_err(|e| e.to_string())?;
+1499                } else {
+1500                    return Err("No current item after URL update".to_string());
+1501                }
+1502
+1503                // The re-opened stream begins at zero — the position cannot ride
+1504                // along in the URL without 400ing every segment (DR-181) — so the
+1505                // seek that the reload was asked for happens here.
+1506                controller.seek(position).map_err(|e| e.to_string())?;
+1507            }
+1508
+1509            info!(
+1510                "[player_seek_video] Stream reloaded successfully at position {}",
+1511                position
+1512            );
+1513
+1514            Ok(VideoSeekResponse::Native { position })
+1515        }
+1516    }
+1517}
+1518
+1519/// Switch audio track - handles both HTML5 (stream reload) and native (direct switch)
+1520/// Note: Frontend should handle saving series preferences after this command succeeds
+1521///
+1522/// The split is the requirement: an HTML5 `<video>` element cannot be told to
+1523/// change audio track, so the stream is re-opened at the chosen
+1524/// `AudioStreamIndex` and the frontend seeks the reloaded element back to
+1525/// `position`; a native backend (ExoPlayer) switches in place by track-group
+1526/// index. libmpv implements neither — it is the audio-only backend here and
+1527/// leaves `PlayerBackend::set_audio_track` at its `not_implemented()` default,
+1528/// which is why IR-019 is met by these two paths rather than by MPV.
+1529///
+1530/// TRACES: UR-021 | IR-019, DR-024
+1531#[tauri::command]
+1532#[specta::specta]
+1533// Two of the eight arguments are Tauri `State<'_, _>` injections, not caller
+1534// input. Folding the rest into a struct would change the IPC contract and the
+1535// generated TypeScript for no readability gain.
+1536#[allow(clippy::too_many_arguments)]
+1537pub async fn player_switch_audio_track(
+1538    player: State<'_, PlayerStateWrapper>,
+1539    repository_manager: State<'_, super::repository::RepositoryManagerWrapper>,
+1540    repository_handle: String,
+1541    stream_index: i32,
+1542    array_index: i32,
+1543    use_html5: bool,
+1544    current_position: Option<f64>,
+1545    media_source_id: Option<String>,
+1546) -> Result<AudioTrackSwitchResponse, String> {
+1547    info!("[player_switch_audio_track] Switching to audio track - stream_index: {}, array_index: {}, use_html5: {}",
+1548        stream_index, array_index, use_html5);
+1549
+1550    if use_html5 {
+1551        // HTML5 backend needs stream reload
+1552        let repository = repository_manager
+1553            .0
+1554            .get(&repository_handle)
+1555            .ok_or("Repository not found - user may need to log in")?;
+1556
+1557        // Get current item to find Jellyfin ID
+1558        let jellyfin_item_id = {
+1559            let controller = player.0.lock().await;
+1560            let queue_arc = controller.queue();
+1561            let queue = queue_arc.lock().map_err(|e| e.to_string())?;
+1562
+1563            let current_item = queue.current().ok_or("No item currently playing")?;
+1564
+1565            current_item
+1566                .jellyfin_id()
+1567                .ok_or("Current item has no Jellyfin ID")?
+1568                .to_string()
+1569        };
+1570
+1571        // Get new stream URL with selected audio track. It starts at zero — an
+1572        // HLS playlist cannot carry a position (DR-181) — and `position` below
+1573        // tells the frontend where to seek the reloaded element back to.
+1574        let new_url = repository
+1575            .get_video_stream_url(
+1576                &jellyfin_item_id,
+1577                media_source_id.as_deref(),
+1578                Some(stream_index),
+1579            )
+1580            .await
+1581            .map_err(|e| format!("Failed to get video stream URL: {:?}", e))?;
+1582
+1583        Ok(AudioTrackSwitchResponse::ReloadStream {
+1584            new_url,
+1585            position: current_position.unwrap_or(0.0),
+1586        })
+1587    } else {
+1588        // Native backend (Android ExoPlayer) - use array index
+1589        let controller = player.0.lock().await;
+1590        controller
+1591            .set_audio_track(array_index)
+1592            .map_err(|e| e.to_string())?;
+1593
+1594        Ok(AudioTrackSwitchResponse::Native { success: true })
+1595    }
+1596}
+1597
+1598/// Change the bandwidth ceiling of the video that is playing *right now*.
+1599///
+1600/// A cap is a property of the stream the server is producing, so unlike a volume
+1601/// change it cannot be applied to a stream already in flight — the stream has to
+1602/// be re-opened at the new quality and resumed at the current position. That is
+1603/// the same reload the transcoded-seek and audio-track paths use, and the same
+1604/// two-sided split: HTML5 gets the URL back and reloads its own element, while a
+1605/// native backend is reloaded here.
+1606///
+1607/// The change applies to this playback *and* to everything started afterwards
+1608/// (it sets the process-wide ceiling), but it is deliberately **not** persisted:
+1609/// the in-player picker is a "this film, this connection" control, and the
+1610/// durable default belongs to Settings. `player_set_video_settings` is the one
+1611/// that writes to the database.
+1612///
+1613/// TRACES: UR-074 | DR-162
+1614#[tauri::command]
+1615#[specta::specta]
+1616// Three of the nine arguments are Tauri `State<'_, _>` injections, not caller
+1617// input. Folding the rest into a struct would change the IPC contract and the
+1618// generated TypeScript for no readability gain.
+1619#[allow(clippy::too_many_arguments)]
+1620pub async fn player_set_stream_quality(
+1621    player: State<'_, PlayerStateWrapper>,
+1622    repository_manager: State<'_, super::repository::RepositoryManagerWrapper>,
+1623    video_settings: State<'_, VideoSettingsWrapper>,
+1624    repository_handle: String,
+1625    quality: crate::settings::StreamingQuality,
+1626    use_html5: bool,
+1627    current_position: Option<f64>,
+1628    media_source_id: Option<String>,
+1629    audio_stream_index: Option<i32>,
+1630) -> Result<StreamQualityResponse, String> {
+1631    info!(
+1632        "[player_set_stream_quality] Switching to {} (use_html5: {}, position: {:?})",
+1633        quality.label(),
+1634        use_html5,
+1635        current_position
+1636    );
+1637
+1638    let repository = repository_manager
+1639        .0
+1640        .get(&repository_handle)
+1641        .ok_or("Repository not found - user may need to log in")?;
+1642
+1643    let jellyfin_item_id = {
+1644        let controller = player.0.lock().await;
+1645        let queue_arc = controller.queue();
+1646        let queue = queue_arc.lock().map_err(|e| e.to_string())?;
+1647
+1648        let current_item = queue.current().ok_or("No item currently playing")?;
+1649
+1650        if current_item.media_type != MediaType::Video {
+1651            return Err("Current item is not a video".to_string());
+1652        }
+1653
+1654        current_item
+1655            .jellyfin_id()
+1656            .ok_or("Current item has no Jellyfin ID")?
+1657            .to_string()
+1658    };
+1659
+1660    // Set the ceiling *before* building the URL — the builder reads it.
+1661    crate::repository::online::set_streaming_quality(quality);
+1662    {
+1663        let mut settings = video_settings.0.lock().map_err(|e| e.to_string())?;
+1664        settings.streaming_quality = quality;
+1665    }
+1666
+1667    let position = current_position.unwrap_or(0.0);
+1668    let new_url = repository
+1669        .get_video_stream_url(
+1670            &jellyfin_item_id,
+1671            media_source_id.as_deref(),
+1672            audio_stream_index,
+1673        )
+1674        .await
+1675        .map_err(|e| format!("Failed to get video stream URL: {:?}", e))?;
+1676
+1677    if use_html5 {
+1678        return Ok(StreamQualityResponse::ReloadStream { new_url, position });
+1679    }
+1680
+1681    // Native backend (Android/ExoPlayer): stop, repoint the queue entry at the
+1682    // new URL, and reload — mirroring `VideoSeekStrategy::BackendReloadStream`.
+1683    // The re-opened stream begins at zero (an HLS playlist cannot carry a start
+1684    // position without 400ing every segment — DR-181), so it is seeked back to
+1685    // where the picture was.
+1686    {
+1687        let controller = player.0.lock().await;
+1688        controller.stop().map_err(|e| e.to_string())?;
+1689
+1690        let queue_arc = controller.queue();
+1691        {
+1692            let mut queue = queue_arc.lock().map_err(|e| e.to_string())?;
+1693            if !queue.update_current_stream_url(new_url.clone()) {
+1694                return Err("Failed to update stream URL in queue".to_string());
+1695            }
+1696        }
+1697
+1698        let queue = queue_arc.lock().map_err(|e| e.to_string())?;
+1699        let updated_item = queue.current().ok_or("No current item after URL update")?;
+1700        controller
+1701            .load_and_play(updated_item)
+1702            .map_err(|e| e.to_string())?;
+1703        if position > 0.0 {
+1704            controller.seek(position).map_err(|e| e.to_string())?;
+1705        }
+1706    }
+1707
+1708    Ok(StreamQualityResponse::Native { position })
+1709}
+1710
+1711/// Set the active audio track on a native backend directly.
+1712///
+1713/// TRACES: UR-021 | IR-019, DR-024
+1714#[tauri::command]
+1715#[specta::specta]
+1716pub async fn player_set_audio_track(
+1717    player: State<'_, PlayerStateWrapper>,
+1718    stream_index: i32,
+1719) -> Result<PlayerStatus, String> {
+1720    let controller = player.0.lock().await;
+1721    controller
+1722        .set_audio_track(stream_index)
+1723        .map_err(|e| e.to_string())?;
+1724    Ok(get_player_status(&controller))
+1725}
+1726
+1727/// Set (or clear, with `None`) the active subtitle track on a native backend.
+1728///
+1729/// On Android this indexes ExoPlayer's *text track groups* — i.e. the position
+1730/// of the sideloaded `MediaItem.SubtitleConfiguration`, not the Jellyfin stream
+1731/// index. The HTML5 path never reaches here; it toggles its own `<track>`
+1732/// children. libmpv implements neither, leaving the trait default in place.
+1733///
+1734/// TRACES: UR-020 | IR-018, DR-023
+1735#[tauri::command]
+1736#[specta::specta]
+1737pub async fn player_set_subtitle_track(
+1738    player: State<'_, PlayerStateWrapper>,
+1739    stream_index: Option<i32>,
+1740) -> Result<PlayerStatus, String> {
+1741    let controller = player.0.lock().await;
+1742    controller
+1743        .set_subtitle_track(stream_index)
+1744        .map_err(|e| e.to_string())?;
+1745    Ok(get_player_status(&controller))
+1746}
+1747
+1748/// Normalise a volume arriving over IPC to the 0.0..=1.0 range every backend
+1749/// works in.
+1750///
+1751/// NaN is handled before the clamp rather than by it: `f32::clamp` returns NaN
+1752/// for a NaN input (it only panics on NaN *bounds*), and NaN then survives every
+1753/// comparison downstream, so a backend clamp cannot catch it either. It is
+1754/// treated as "no volume asked for" and floored to 0.0.
+1755///
+1756/// TRACES: DR-212 | UT-206
+1757fn normalize_volume(volume: f32) -> f32 {
+1758    if volume.is_nan() {
+1759        0.0
+1760    } else {
+1761        volume.clamp(0.0, 1.0)
+1762    }
+1763}
+1764
+1765#[tauri::command]
+1766#[specta::specta]
+1767pub async fn player_set_volume(
+1768    player: State<'_, PlayerStateWrapper>,
+1769    playback_mode: State<'_, super::playback_mode::PlaybackModeManagerWrapper>,
+1770    volume: f32,
+1771) -> Result<PlayerStatus, String> {
+1772    // Clamp at the boundary as well as in each backend: the remote branch below
+1773    // never reaches a backend clamp, and `(f32::INFINITY * 100.0) as i32` would
+1774    // hand the server i32::MAX as a volume percentage.
+1775    // TRACES: DR-212 | UT-206
+1776    let volume = normalize_volume(volume);
+1777
+1778    // Check if we're in remote mode
+1779    let mode = playback_mode.0.get_mode();
+1780
+1781    if let crate::playback_mode::PlaybackMode::Remote { session_id } = mode {
+1782        // Send volume command to remote session - clone client before await
+1783        let client = {
+1784            let controller = player.0.lock().await;
+1785            let client_arc = controller.jellyfin_client();
+1786            let client_opt = client_arc.lock().map_err(|e| e.to_string())?;
+1787            client_opt
+1788                .as_ref()
+1789                .ok_or("Jellyfin client not configured")?
+1790                .clone()
+1791        };
+1792        // Convert 0-1 range to 0-100 for Jellyfin API
+1793        let volume_percent = (volume * 100.0) as i32;
+1794        client
+1795            .session_set_volume(session_id, volume_percent)
+1796            .await?;
+1797    } else {
+1798        // Local playback
+1799        let controller = player.0.lock().await;
+1800        controller.set_volume(volume).map_err(|e| e.to_string())?;
+1801    }
+1802
+1803    let controller = player.0.lock().await;
+1804    Ok(get_player_status(&controller))
+1805}
+1806
+1807#[tauri::command]
+1808#[specta::specta]
+1809pub async fn player_toggle_mute(
+1810    player: State<'_, PlayerStateWrapper>,
+1811    playback_mode: State<'_, super::playback_mode::PlaybackModeManagerWrapper>,
+1812) -> Result<PlayerStatus, String> {
+1813    // Check if we're in remote mode
+1814    let mode = playback_mode.0.get_mode();
+1815
+1816    if let crate::playback_mode::PlaybackMode::Remote { session_id } = mode {
+1817        // Send toggle mute command to remote session - clone client before await
+1818        let client = {
+1819            let controller = player.0.lock().await;
+1820            let client_arc = controller.jellyfin_client();
+1821            let client_opt = client_arc.lock().map_err(|e| e.to_string())?;
+1822            client_opt
+1823                .as_ref()
+1824                .ok_or("Jellyfin client not configured")?
+1825                .clone()
+1826        };
+1827        client
+1828            .send_session_command(session_id, "ToggleMute")
+1829            .await?;
+1830    } else {
+1831        // Local playback
+1832        // TODO: Implement toggle_mute in PlayerController
+1833        // let controller = player.0.lock().await;
+1834        // controller.toggle_mute().map_err(|e| e.to_string())?;
+1835    }
+1836
+1837    let controller = player.0.lock().await;
+1838    Ok(get_player_status(&controller))
+1839}
+1840
+1841#[tauri::command]
+1842#[specta::specta]
+1843pub async fn player_toggle_shuffle(
+1844    player: State<'_, PlayerStateWrapper>,
+1845) -> Result<QueueStatus, String> {
+1846    let controller = player.0.lock().await;
+1847    controller.toggle_shuffle();
+1848    controller.emit_queue_changed();
+1849    Ok(get_queue_status(&controller))
+1850}
+1851
+1852#[tauri::command]
+1853#[specta::specta]
+1854pub async fn player_cycle_repeat(
+1855    player: State<'_, PlayerStateWrapper>,
+1856) -> Result<QueueStatus, String> {
+1857    let controller = player.0.lock().await;
+1858    controller.cycle_repeat();
+1859    controller.emit_queue_changed();
+1860    Ok(get_queue_status(&controller))
+1861}
+1862
+1863#[tauri::command]
+1864#[specta::specta]
+1865pub async fn player_get_status(
+1866    player: State<'_, PlayerStateWrapper>,
+1867    playback_mode: State<'_, super::playback_mode::PlaybackModeManagerWrapper>,
+1868) -> Result<PlayerStatus, String> {
+1869    let mode = playback_mode.0.get_mode();
+1870
+1871    // Get base local status
+1872    let controller = player.0.lock().await;
+1873    let mut status = get_player_status(&controller);
+1874
+1875    // Get local media from queue
+1876    let local_media = {
+1877        let queue_arc = controller.queue();
+1878        let queue = queue_arc.lock().map_err(|e| e.to_string())?;
+1879        queue.current().map(MergedMediaItem::from)
+1880    };
+1881
+1882    let local_is_playing = status.state.is_playing();
+1883    let local_volume = status.volume;
+1884
+1885    // If in remote mode, fetch session and merge state
+1886    if let crate::playback_mode::PlaybackMode::Remote { session_id } = mode {
+1887        let client = {
+1888            let client_arc = controller.jellyfin_client();
+1889            let client_opt = client_arc.lock().map_err(|e| e.to_string())?;
+1890            client_opt
+1891                .as_ref()
+1892                .ok_or("Jellyfin client not configured")?
+1893                .clone()
+1894        };
+1895
+1896        drop(controller); // Release lock before async call
+1897
+1898        match client.get_session(&session_id).await {
+1899            Ok(Some(session)) => {
+1900                log::info!("[PlayerCommands] Merging remote session state");
+1901
+1902                // Merge media item
+1903                status.merged_media = session.now_playing_item.as_ref().map(MergedMediaItem::from);
+1904
+1905                // Merge isPlaying (NOT isPaused!)
+1906                status.merged_is_playing = session
+1907                    .play_state
+1908                    .as_ref()
+1909                    .map(|ps| !ps.is_paused.unwrap_or(true))
+1910                    .unwrap_or(false);
+1911
+1912                // Merge position (convert ticks to seconds)
+1913                status.position = session
+1914                    .play_state
+1915                    .as_ref()
+1916                    .and_then(|ps| ps.position_ticks)
+1917                    .map(|ticks| ticks as f64 / 10_000_000.0)
+1918                    .unwrap_or(0.0);
+1919
+1920                // Merge duration (convert ticks to seconds)
+1921                status.duration = session
+1922                    .now_playing_item
+1923                    .as_ref()
+1924                    .and_then(|item| item.run_time_ticks)
+1925                    .map(|ticks| ticks as f64 / 10_000_000.0);
+1926
+1927                // Merge volume (convert 0-100 → 0-1)
+1928                status.merged_volume = session
+1929                    .play_state
+1930                    .as_ref()
+1931                    .and_then(|ps| ps.volume_level)
+1932                    .map(|vol| (vol.clamp(0, 100) as f32) / 100.0)
+1933                    .unwrap_or(1.0);
+1934
+1935                return Ok(status);
+1936            }
+1937            Ok(None) => {
+1938                log::warn!("[PlayerCommands] Remote session not found, using local state");
+1939            }
+1940            Err(e) => {
+1941                log::warn!("[PlayerCommands] Failed to fetch remote session: {}", e);
+1942            }
+1943        }
+1944    }
+1945
+1946    // Local playback or fallback
+1947    status.merged_media = local_media;
+1948    status.merged_is_playing = local_is_playing;
+1949    status.merged_volume = local_volume;
+1950
+1951    Ok(status)
+1952}
+1953
+1954#[tauri::command]
+1955#[specta::specta]
+1956pub async fn player_get_queue(
+1957    player: State<'_, PlayerStateWrapper>,
+1958) -> Result<QueueStatus, String> {
+1959    let controller = player.0.lock().await;
+1960    Ok(get_queue_status(&controller))
+1961}
+1962
+1963/// What playback facilities this platform's backend actually provides.
+1964///
+1965/// The frontend is presentation-only and must not re-derive backend facts from
+1966/// `navigator.userAgent` — that sniffing was a second copy of the same platform
+1967/// decision Rust already makes with `cfg!`, and it drifted. These flags are the
+1968/// single source of truth; the frontend consumes them.
+1969///
+1970/// TRACES: UR-003, UR-005 | DR-004, DR-023, DR-024
+1971#[derive(specta::Type, Debug, Serialize)]
+1972#[serde(rename_all = "camelCase")]
+1973pub struct PlaybackCapabilities {
+1974    /// True when audio is rendered by a webview `<audio>` element rather than a
+1975    /// native backend. Native audio exists on Linux (mpv) and Android
+1976    /// (ExoPlayer); everything else (Windows, future desktops) uses the webview.
+1977    pub uses_webview_audio: bool,
+1978    /// True when video can be rendered by a native surface composited *behind*
+1979    /// a transparent webview. Android only: ExoPlayer draws into a SurfaceView
+1980    /// beneath the WebView. Linux cannot do this (WebKitGTK/Wayland
+1981    /// compositing), so it stays on the HTML5 element.
+1982    pub supports_native_video: bool,
+1983}
+1984
+1985/// Report this platform's playback capabilities to the frontend.
+1986///
+1987/// TRACES: UR-003, UR-005 | DR-004, DR-023, DR-024
+1988#[tauri::command]
+1989#[specta::specta]
+1990pub async fn player_get_capabilities() -> Result<PlaybackCapabilities, String> {
+1991    // Mirrors the cfg gates the backends themselves are built under.
+1992    let native_audio = cfg!(any(target_os = "android", target_os = "linux"));
+1993
+1994    Ok(PlaybackCapabilities {
+1995        uses_webview_audio: !native_audio,
+1996        supports_native_video: cfg!(target_os = "android"),
+1997    })
+1998}
+1999
+2000pub(super) fn get_player_status(controller: &PlayerController) -> PlayerStatus {
+2001    // Determine backend at compile time based on platform
+2002    let (backend, use_html5_element) = if cfg!(target_os = "android") {
+2003        // Android uses ExoPlayer native backend
+2004        (VideoBackend::Native, false)
+2005    } else {
+2006        // Linux and other platforms use HTML5 video element in frontend
+2007        (VideoBackend::Html5, true)
+2008    };
+2009
+2010    PlayerStatus {
+2011        state: controller.state(),
+2012        // The position on the item's timeline, whichever of the three paths is
+2013        // rendering it — the native backend answers for only one of them, and
+2014        // reads 0 for webview video and for a handoff that has not ticked yet.
+2015        // TRACES: UR-005 | DR-178
+2016        position: controller.absolute_position(),
+2017        duration: controller.duration(),
+2018        volume: controller.volume(),
+2019        muted: controller.muted(),
+2020        shuffle: controller.is_shuffle(),
+2021        repeat: controller.repeat_mode(),
+2022        backend,
+2023        use_html5_element,
+2024
+2025        // Merged fields initialized to defaults (will be set by player_get_status)
+2026        merged_media: None,
+2027        merged_is_playing: false,
+2028        merged_volume: controller.volume(),
+2029    }
+2030}
+2031
+2032pub(super) fn get_queue_status(controller: &PlayerController) -> QueueStatus {
+2033    let queue = controller.queue();
+2034    let queue_lock = queue.lock_safe();
+2035
+2036    QueueStatus {
+2037        items: queue_lock.items().to_vec(),
+2038        current_index: queue_lock.current_index(),
+2039        shuffle: queue_lock.is_shuffle(),
+2040        repeat: queue_lock.repeat_mode(),
+2041        has_next: queue_lock.has_next(),
+2042        has_previous: queue_lock.has_previous(),
+2043    }
+2044}
+2045
+2046/// Start a freshly-built queue on the active remote session.
+2047///
+2048/// Used by the "play tracks"/"play album track" commands when we're in remote
+2049/// mode: instead of starting local MPV playback, we cast the selected tracks to
+2050/// the remote device. Mirrors PlaybackModeManager::transfer_to_remote's
+2051/// play_on_session call, but for a brand-new selection (so there's no resume
+2052/// position - playback starts from the chosen track's beginning).
+2053///
+2054/// Local-only items (no Jellyfin ID) can't be cast, so they're filtered out and
+2055/// the start index is adjusted to the remaining Jellyfin items. Returns an error
+2056/// if the selected track itself has no Jellyfin ID.
+2057async fn play_selection_on_remote(
+2058    controller: &PlayerController,
+2059    session_id: &str,
+2060    media_items: &[MediaItem],
+2061    start_index: usize,
+2062) -> Result<(), String> {
+2063    // Collect Jellyfin IDs, tracking where the selected track lands after any
+2064    // local-only items are dropped.
+2065    let mut jellyfin_ids: Vec<String> = Vec::new();
+2066    let mut adjusted_index: Option<usize> = None;
+2067    for (i, item) in media_items.iter().enumerate() {
+2068        if let Some(id) = item.jellyfin_id() {
+2069            if i == start_index {
+2070                adjusted_index = Some(jellyfin_ids.len());
+2071            }
+2072            jellyfin_ids.push(id.to_string());
+2073        }
+2074    }
+2075
+2076    let start_index =
+2077        adjusted_index.ok_or("Cannot play on remote: selected track is not from Jellyfin")?;
+2078
+2079    if jellyfin_ids.is_empty() {
+2080        return Err("Cannot play on remote: no Jellyfin tracks in selection".to_string());
+2081    }
+2082
+2083    let client = {
+2084        let client_arc = controller.jellyfin_client();
+2085        let client_opt = client_arc.lock().map_err(|e| e.to_string())?;
+2086        client_opt
+2087            .as_ref()
+2088            .ok_or("Jellyfin client not configured")?
+2089            .clone()
+2090    };
+2091
+2092    // Fresh selection: start from the beginning of the chosen track.
+2093    client
+2094        .play_on_session(session_id.to_string(), jellyfin_ids, start_index, None)
+2095        .await
+2096        .map_err(|e| format!("Failed to start playback on remote session: {}", e))
+2097}
+2098
+2099/// Play a track from an album - backend fetches all album tracks and builds queue
+2100#[tauri::command]
+2101#[specta::specta]
+2102pub async fn player_play_album_track(
+2103    player: State<'_, PlayerStateWrapper>,
+2104    session: State<'_, MediaSessionManagerWrapper>,
+2105    db: State<'_, DatabaseWrapper>,
+2106    repository_manager: State<'_, super::repository::RepositoryManagerWrapper>,
+2107    playback_mode: State<'_, super::playback_mode::PlaybackModeManagerWrapper>,
+2108    repository_handle: String,
+2109    request: PlayAlbumTrackRequest,
+2110) -> Result<PlayerStatus, String> {
+2111    info!(
+2112        "player_play_album_track called: album_id={}, track_id={}, shuffle={}",
+2113        request.album_id, request.track_id, request.shuffle
+2114    );
+2115
+2116    // Get repository (hybrid - supports offline/online)
+2117    let repository = repository_manager
+2118        .0
+2119        .get(&repository_handle)
+2120        .ok_or("Repository not found - user may need to log in")?;
+2121
+2122    // Fetch all tracks from the album via hybrid repository
+2123    info!(
+2124        "Fetching tracks for album {} via repository",
+2125        request.album_id
+2126    );
+2127    let album_items = repository
+2128        .get_items(
+2129            &request.album_id,
+2130            Some(GetItemsOptions {
+2131                limit: Some(1000),
+2132                fields: Some(vec![
+2133                    "PrimaryImageAspectRatio".to_string(),
+2134                    "Overview".to_string(),
+2135                    "MediaStreams".to_string(),
+2136                ]),
+2137                ..Default::default()
+2138            }),
+2139        )
+2140        .await
+2141        .map_err(|e| format!("Failed to fetch album tracks: {}", e))?;
+2142
+2143    info!("Found {} items in album", album_items.items.len());
+2144
+2145    // Filter to only Audio items and sort by index
+2146    let mut tracks: Vec<_> = album_items
+2147        .items
+2148        .into_iter()
+2149        .filter(|item| item.item_type == "Audio")
+2150        .collect();
+2151    tracks.sort_by_key(|t| t.index_number.unwrap_or(0));
+2152
+2153    info!("Found {} audio tracks in album", tracks.len());
+2154
+2155    if tracks.is_empty() {
+2156        return Err("No audio tracks found in album".to_string());
+2157    }
+2158
+2159    // Debug: Log all track IDs and their indices
+2160    info!("Album has {} tracks after sorting:", tracks.len());
+2161    for (idx, track) in tracks.iter().enumerate() {
+2162        info!("  [{}] {} (ID: {})", idx, track.name, track.id);
+2163    }
+2164
+2165    // Validate the requested track exists in the album (its position in the
+2166    // final queue is computed after building, since offline tracks are skipped).
+2167    info!("Looking for track_id: {}", request.track_id);
+2168    let album_index = tracks
+2169        .iter()
+2170        .position(|t| t.id == request.track_id)
+2171        .ok_or_else(|| format!("Track {} not found in album", request.track_id))?;
+2172
+2173    info!(
+2174        "Track {} is at index {} in album",
+2175        request.track_id, album_index
+2176    );
+2177
+2178    // Convert tracks to MediaItems
+2179    let mut media_items = Vec::new();
+2180    for track in tracks {
+2181        // Check for local download first
+2182        let jellyfin_id = &track.id;
+2183        let local_path = check_for_local_download(&db, jellyfin_id).await?;
+2184
+2185        let source = if let Some(path) = local_path {
+2186            MediaSource::Local {
+2187                file_path: PathBuf::from(path),
+2188                jellyfin_item_id: Some(jellyfin_id.clone()),
+2189            }
+2190        } else {
+2191            // Non-downloaded track: needs a stream URL from the server. When the
+2192            // server is unreachable (offline), skip this track rather than failing
+2193            // the whole album — downloaded tracks must still be playable.
+2194            match repository.get_audio_stream_url(&track.id).await {
+2195                Ok(stream_url) => MediaSource::Remote {
+2196                    stream_url,
+2197                    jellyfin_item_id: jellyfin_id.clone(),
+2198                },
+2199                Err(e) => {
+2200                    warn!(
+2201                        "[Player] Skipping track {} ({}) — no local download and stream URL unavailable: {}",
+2202                        track.name, track.id, e
+2203                    );
+2204                    continue;
+2205                }
+2206            }
+2207        };
+2208
+2209        let primary_image_tag_for_url = track.primary_image_tag.clone();
+2210        let media_item = MediaItem {
+2211            id: track.id.clone(),
+2212            title: track.name.clone(),
+2213            name: Some(track.name.clone()), // Frontend compatibility
+2214            artist: track
+2215                .album_artist
+2216                .clone()
+2217                .or_else(|| track.artists.as_ref().and_then(|a| a.first().cloned())),
+2218            album: Some(request.album_name.clone()),
+2219            album_name: Some(request.album_name.clone()), // Frontend compatibility
+2220            album_id: Some(request.album_id.clone()),
+2221            artist_items: track.artist_items.clone(), // For clickable artist links
+2222            artists: track.artists.clone(),           // Fallback artist info
+2223            primary_image_tag: track.primary_image_tag.clone(), // For frontend image display
+2224            image_id: track.primary_image_tag.clone(),
+2225            item_type: Some(track.item_type.clone()), // Frontend compatibility
+2226            playlist_id: None,
+2227            duration: track.runtime_ticks.map(|t| t as f64 / 10_000_000.0),
+2228            artwork_url: primary_image_tag_for_url.map(|tag| {
+2229                repository.get_image_url(
+2230                    &request.album_id,
+2231                    ImageType::Primary,
+2232                    Some(ImageOptions {
+2233                        max_width: Some(300),
+2234                        tag: Some(tag),
+2235                        ..Default::default()
+2236                    }),
+2237                )
+2238            }),
+2239            media_type: MediaType::Audio,
+2240            source,
+2241            video_codec: None,
+2242            needs_transcoding: false,
+2243            video_width: None,
+2244            video_height: None,
+2245            subtitles: vec![],
+2246            series_id: None,
+2247            server_id: None,
+2248        };
+2249
+2250        media_items.push(media_item);
+2251    }
+2252
+2253    if media_items.is_empty() {
+2254        return Err("No playable tracks available (offline and nothing downloaded)".to_string());
+2255    }
+2256
+2257    // Tracks with no local download and no reachable server were skipped above,
+2258    // so positions shifted. Re-locate the requested track in the built queue.
+2259    // If the tapped track itself was skipped, fall back to the first item.
+2260    let start_index = media_items
+2261        .iter()
+2262        .position(|item| item.id == request.track_id)
+2263        .unwrap_or(0);
+2264
+2265    info!(
+2266        "Built queue with {} media items, starting at index {}",
+2267        media_items.len(),
+2268        start_index
+2269    );
+2270
+2271    // Handle shuffle before setting queue
+2272    if request.shuffle {
+2273        let controller = player.0.lock().await;
+2274        controller.toggle_shuffle();
+2275    }
+2276
+2277    // Start audio session with the first item
+2278    if let Some(first_item) = media_items.get(start_index) {
+2279        let mut session_mgr = session.0.lock().map_err(|e| e.to_string())?;
+2280        session_mgr.start_audio_session(first_item.clone());
+2281    }
+2282
+2283    let controller = player.0.lock().await;
+2284
+2285    // When controlling a remote session, cast the selection there instead of
+2286    // starting local MPV playback. We still load the queue locally (below) so
+2287    // the queue/context stay in sync for the UI and for transferring back.
+2288    let remote_session = match playback_mode.0.get_mode() {
+2289        crate::playback_mode::PlaybackMode::Remote { session_id } => Some(session_id),
+2290        _ => None,
+2291    };
+2292
+2293    if let Some(session_id) = &remote_session {
+2294        play_selection_on_remote(&controller, session_id, &media_items, start_index).await?;
+2295        controller
+2296            .set_queue(media_items, start_index)
+2297            .map_err(|e| e.to_string())?;
+2298    } else {
+2299        // Local playback is now authoritative (see player_play_tracks); set it
+2300        // before starting so the mode-changed event precedes the state events.
+2301        playback_mode
+2302            .0
+2303            .set_mode(crate::playback_mode::PlaybackMode::Local);
+2304        controller
+2305            .play_queue(media_items, start_index)
+2306            .map_err(|e| e.to_string())?;
+2307    }
+2308
+2309    // Set the queue context for remote transfer
+2310    {
+2311        let queue_arc = controller.queue();
+2312        let mut queue = queue_arc.lock().map_err(|e| e.to_string())?;
+2313        queue.set_context(QueueContext::Album {
+2314            album_id: request.album_id.clone(),
+2315            album_name: request.album_name.clone(),
+2316        });
+2317    }
+2318
+2319    // Emit queue changed event
+2320    controller.emit_queue_changed();
+2321
+2322    // Log final queue state
+2323    {
+2324        let queue_arc = controller.queue();
+2325        let queue_lock = queue_arc.lock().map_err(|e| e.to_string())?;
+2326        info!(
+2327            "player_play_album_track: Queue now has {} items, current_index: {:?}",
+2328            queue_lock.items().len(),
+2329            queue_lock.current_index()
+2330        );
+2331    }
+2332
+2333    // Emit session changed event
+2334    if let Some(emitter) = controller.event_emitter() {
+2335        let session_mgr = session.0.lock().map_err(|e| e.to_string())?;
+2336        emitter.emit(PlayerStatusEvent::SessionChanged {
+2337            session: session_mgr.current().clone(),
+2338        });
+2339    }
+2340
+2341    Ok(get_player_status(&controller))
+2342}
+2343
+2344/// Play tracks by ID - backend fetches all metadata
+2345#[tauri::command]
+2346#[specta::specta]
+2347pub async fn player_play_tracks(
+2348    player: State<'_, PlayerStateWrapper>,
+2349    session: State<'_, MediaSessionManagerWrapper>,
+2350    db: State<'_, DatabaseWrapper>,
+2351    repository_manager: State<'_, super::repository::RepositoryManagerWrapper>,
+2352    playback_mode: State<'_, super::playback_mode::PlaybackModeManagerWrapper>,
+2353    repository_handle: String,
+2354    request: PlayTracksRequest,
+2355) -> Result<PlayerStatus, String> {
+2356    info!(
+2357        "player_play_tracks called: {} tracks, start_index={}, shuffle={}",
+2358        request.track_ids.len(),
+2359        request.start_index,
+2360        request.shuffle
+2361    );
+2362
+2363    // Validate input
+2364    if request.track_ids.is_empty() {
+2365        return Err("No tracks provided".to_string());
+2366    }
+2367
+2368    // Get repository
+2369    let repository = repository_manager
+2370        .0
+2371        .get(&repository_handle)
+2372        .ok_or("Repository not found - user may need to log in")?;
+2373
+2374    // Fetch metadata for all tracks
+2375    let mut media_items = Vec::new();
+2376    for track_id in &request.track_ids {
+2377        // Fetch track metadata from repository
+2378        let track = repository
+2379            .get_item(track_id)
+2380            .await
+2381            .map_err(|e| format!("Failed to fetch track {}: {}", track_id, e))?;
+2382
+2383        // Check for local download
+2384        let local_path = check_for_local_download(&db, track_id).await?;
+2385
+2386        // Build MediaSource
+2387        let source = if let Some(path) = local_path {
+2388            MediaSource::Local {
+2389                file_path: PathBuf::from(path),
+2390                jellyfin_item_id: Some(track.id.clone()),
+2391            }
+2392        } else {
+2393            let stream_url = repository
+2394                .get_audio_stream_url(track_id)
+2395                .await
+2396                .map_err(|e| format!("Failed to get stream URL: {}", e))?;
+2397
+2398            MediaSource::Remote {
+2399                stream_url,
+2400                jellyfin_item_id: track.id.clone(),
+2401            }
+2402        };
+2403
+2404        // Transform to MediaItem with frontend-compatible fields
+2405        let primary_image_tag_for_url = track.primary_image_tag.clone();
+2406        let media_item = MediaItem {
+2407            id: track.id.clone(),
+2408            title: track.name.clone(),
+2409            name: Some(track.name.clone()), // Frontend compatibility
+2410            artist: track
+2411                .album_artist
+2412                .clone()
+2413                .or_else(|| track.artists.as_ref().and_then(|a| a.first().cloned())),
+2414            album: track.album_name.clone(),
+2415            album_name: track.album_name.clone(), // Frontend compatibility
+2416            album_id: track.album_id.clone(),
+2417            artist_items: track.artist_items.clone(), // For clickable artist links
+2418            artists: track.artists.clone(),           // Fallback artist info
+2419            primary_image_tag: track.primary_image_tag.clone(), // For frontend image display
+2420            image_id: track.primary_image_tag.clone(),
+2421            item_type: Some(track.item_type.clone()), // Frontend compatibility
+2422            playlist_id: None,                        // Set based on context below
+2423            duration: track.runtime_ticks.map(|t| t as f64 / 10_000_000.0),
+2424            artwork_url: primary_image_tag_for_url.and_then(|tag| {
+2425                track.album_id.as_ref().map(|album_id| {
+2426                    repository.get_image_url(
+2427                        album_id,
+2428                        ImageType::Primary,
+2429                        Some(ImageOptions {
+2430                            max_width: Some(300),
+2431                            tag: Some(tag),
+2432                            ..Default::default()
+2433                        }),
+2434                    )
+2435                })
+2436            }),
+2437            media_type: MediaType::Audio,
+2438            source,
+2439            video_codec: None,
+2440            needs_transcoding: false,
+2441            video_width: None,
+2442            video_height: None,
+2443            subtitles: vec![],
+2444            series_id: None,
+2445            server_id: None,
+2446        };
+2447
+2448        media_items.push(media_item);
+2449    }
+2450
+2451    info!("Built queue with {} media items", media_items.len());
+2452
+2453    // Map context and set playlist_id
+2454    let queue_context = match request.context {
+2455        PlayTracksContext::Playlist {
+2456            playlist_id,
+2457            playlist_name,
+2458        } => {
+2459            for item in &mut media_items {
+2460                item.playlist_id = Some(playlist_id.clone());
+2461            }
+2462            QueueContext::Playlist {
+2463                playlist_id,
+2464                playlist_name,
+2465            }
+2466        }
+2467        PlayTracksContext::Search { .. } | PlayTracksContext::Custom { .. } => QueueContext::Custom,
+2468    };
+2469
+2470    // Handle shuffle
+2471    if request.shuffle {
+2472        let controller = player.0.lock().await;
+2473        controller.toggle_shuffle();
+2474    }
+2475
+2476    // Start session
+2477    if let Some(first_item) = media_items.get(request.start_index) {
+2478        let mut session_mgr = session.0.lock().map_err(|e| e.to_string())?;
+2479        session_mgr.start_audio_session(first_item.clone());
+2480    }
+2481
+2482    let controller = player.0.lock().await;
+2483
+2484    // When controlling a remote session, cast the selection there instead of
+2485    // starting local MPV playback. Skip this while a transfer is in flight: the
+2486    // transfer-to-local path calls this command to load the queue locally and
+2487    // the mode is still Remote until the transfer completes - routing it back to
+2488    // the remote would undo the transfer.
+2489    let remote_session = match playback_mode.0.get_mode() {
+2490        crate::playback_mode::PlaybackMode::Remote { session_id }
+2491            if !playback_mode.0.is_transferring() =>
+2492        {
+2493            Some(session_id)
+2494        }
+2495        _ => None,
+2496    };
+2497
+2498    if let Some(session_id) = &remote_session {
+2499        play_selection_on_remote(&controller, session_id, &media_items, request.start_index)
+2500            .await?;
+2501        controller
+2502            .set_queue(media_items, request.start_index)
+2503            .map_err(|e| e.to_string())?;
+2504    } else {
+2505        // Starting local playback makes Local the authoritative mode. Without
+2506        // this, a prior Remote mode lingers in the manager and later play/pause
+2507        // commands route back to the (stopped) remote session. Set it BEFORE
+2508        // starting playback so the PlaybackModeChanged event reaches the frontend
+2509        // ahead of the state_changed events it will emit — otherwise the frontend
+2510        // (still thinking it's remote) filters those state events out. Skip during
+2511        // a transfer: transfer_to_local drives the mode itself once complete.
+2512        if !playback_mode.0.is_transferring() {
+2513            playback_mode
+2514                .0
+2515                .set_mode(crate::playback_mode::PlaybackMode::Local);
+2516        }
+2517        controller
+2518            .play_queue_from(media_items, request.start_index, request.start_position)
+2519            .map_err(|e| e.to_string())?;
+2520    }
+2521
+2522    // Set queue context
+2523    {
+2524        let queue_arc = controller.queue();
+2525        let mut queue = queue_arc.lock().map_err(|e| e.to_string())?;
+2526        queue.set_context(queue_context);
+2527    }
+2528
+2529    // Emit events
+2530    controller.emit_queue_changed();
+2531    if let Some(emitter) = controller.event_emitter() {
+2532        let session_mgr = session.0.lock().map_err(|e| e.to_string())?;
+2533        emitter.emit(PlayerStatusEvent::SessionChanged {
+2534            session: session_mgr.current().clone(),
+2535        });
+2536    }
+2537
+2538    info!("player_play_tracks completed successfully");
+2539    Ok(get_player_status(&controller))
+2540}
+2541
+2542/// Response for preload operation
+2543#[derive(specta::Type, Debug, Serialize)]
+2544#[serde(rename_all = "camelCase")]
+2545pub struct PreloadResult {
+2546    /// Number of tracks queued for preload
+2547    pub queued_count: usize,
+2548    /// Number of tracks already downloaded
+2549    pub already_downloaded: usize,
+2550    /// Number of tracks skipped (no jellyfin ID or other reasons)
+2551    pub skipped: usize,
+2552}
+2553
+2554/// Preload upcoming tracks from the queue
+2555/// This queues background downloads for the next N tracks that aren't already downloaded
+2556#[tauri::command]
+2557#[specta::specta]
+2558pub async fn player_preload_upcoming(
+2559    player: State<'_, PlayerStateWrapper>,
+2560    db: State<'_, DatabaseWrapper>,
+2561    smart_cache: State<'_, SmartCacheWrapper>,
+2562    download_manager: State<'_, crate::commands::download::DownloadManagerWrapper>,
+2563    app: tauri::AppHandle,
+2564    user_id: String,
+2565    _download_base_path: String,
+2566) -> Result<PreloadResult, String> {
+2567    // The pump only starts rows that carry both a stream URL and a target dir,
+2568    // so resolve the same storage root the user-initiated download paths use
+2569    // (storage_get_path = the database's parent directory).
+2570    let (db_service, target_dir) = {
+2571        let database = db.0.lock().map_err(|e| e.to_string())?;
+2572        let target_dir = database
+2573            .path()
+2574            .parent()
+2575            .ok_or_else(|| "Database path has no parent directory".to_string())?
+2576            .to_string_lossy()
+2577            .to_string();
+2578        (Arc::new(database.service()), target_dir)
+2579    };
+2580
+2581    // Get cache settings
+2582    let precache_count = {
+2583        let cache = smart_cache.0.lock().map_err(|e| e.to_string())?;
+2584        if !cache.should_precache_queue() {
+2585            return Ok(PreloadResult {
+2586                queued_count: 0,
+2587                already_downloaded: 0,
+2588                skipped: 0,
+2589            });
+2590        }
+2591        cache.queue_precache_count()
+2592    };
+2593
+2594    // Get upcoming items from queue
+2595    let upcoming_items: Vec<MediaItem> = {
+2596        let controller = player.0.lock().await;
+2597        let queue = controller.queue();
+2598        let queue_lock = queue.lock().map_err(|e| e.to_string())?;
+2599        queue_lock
+2600            .get_upcoming(precache_count)
+2601            .into_iter()
+2602            .cloned()
+2603            .collect()
+2604    };
+2605
+2606    if upcoming_items.is_empty() {
+2607        return Ok(PreloadResult {
+2608            queued_count: 0,
+2609            already_downloaded: 0,
+2610            skipped: 0,
+2611        });
+2612    }
+2613
+2614    let mut queued_count = 0;
+2615    let mut already_downloaded = 0;
+2616    let mut skipped = 0;
+2617
+2618    // Process each upcoming item
+2619    for item in upcoming_items {
+2620        // Only process items with Remote source (not already local). The
+2621        // source already carries the resolved stream URL — reuse it so the
+2622        // pump can start the download without any extra resolution step.
+2623        let (jellyfin_id, stream_url) = match &item.source {
+2624            MediaSource::Remote {
+2625                jellyfin_item_id,
+2626                stream_url,
+2627            } => (jellyfin_item_id.clone(), stream_url.clone()),
+2628            MediaSource::Local { .. } => {
+2629                already_downloaded += 1;
+2630                continue;
+2631            }
+2632            MediaSource::DirectUrl { .. } => {
+2633                skipped += 1;
+2634                continue;
+2635            }
+2636        };
+2637
+2638        // Check if already downloaded or actively in flight. Stale pending rows
+2639        // without a stream URL are NOT skipped here — the upsert below heals
+2640        // them so the pump can finally start them.
+2641        let query = Query::with_params(
+2642            "SELECT file_path FROM downloads WHERE item_id = ? AND user_id = ?
+2643             AND (status IN ('completed', 'downloading')
+2644                  OR (status = 'pending' AND stream_url IS NOT NULL)) LIMIT 1",
+2645            vec![
+2646                QueryParam::String(jellyfin_id.clone()),
+2647                QueryParam::String(user_id.clone()),
+2648            ],
+2649        );
+2650
+2651        let is_downloaded: Option<String> = db_service
+2652            .query_optional(query, |row| row.get(0))
+2653            .await
+2654            .map_err(|e| e.to_string())?;
+2655
+2656        if is_downloaded.is_some() {
+2657            already_downloaded += 1;
+2658            continue;
+2659        }
+2660
+2661        // Queue for download with low priority (preload priority = -100) so
+2662        // user-initiated downloads always win a pump slot first.
+2663        let album_dir = item
+2664            .album
+2665            .as_deref()
+2666            .filter(|a| !a.is_empty())
+2667            .unwrap_or("Unknown Album");
+2668        let file_path = format!(
+2669            "downloads/{}/{}.mp3",
+2670            sanitize_filename(album_dir),
+2671            sanitize_filename(&item.title)
+2672        );
+2673
+2674        // Insert with the stream URL + target dir the pump needs to start it.
+2675        // On conflict, heal pre-existing rows that were queued without a URL
+2676        // (they could never start) instead of leaving them stuck.
+2677        let insert_query = Query::with_params(
+2678            "INSERT INTO downloads (item_id, user_id, file_path, status, priority, queued_at, item_name, artist_name, album_name, download_source, media_type, stream_url, target_dir)
+2679             VALUES (?, ?, ?, 'pending', -100, CURRENT_TIMESTAMP, ?, ?, ?, 'auto', 'audio', ?, ?)
+2680             ON CONFLICT(item_id, user_id) DO UPDATE SET
+2681                 stream_url = excluded.stream_url,
+2682                 target_dir = excluded.target_dir
+2683             WHERE downloads.status = 'pending' AND downloads.stream_url IS NULL",
+2684            vec![
+2685                QueryParam::String(jellyfin_id),
+2686                QueryParam::String(user_id.clone()),
+2687                QueryParam::String(file_path),
+2688                QueryParam::String(item.title.clone()),
+2689                item.artist.clone().map(QueryParam::String).unwrap_or(QueryParam::Null),
+2690                item.album.clone().map(QueryParam::String).unwrap_or(QueryParam::Null),
+2691                QueryParam::String(stream_url),
+2692                QueryParam::String(target_dir.clone()),
+2693            ],
+2694        );
+2695
+2696        match db_service.execute(insert_query).await {
+2697            Ok(rows) if rows > 0 => {
+2698                info!(
+2699                    "[Preload] Queued download for: {} - {}",
+2700                    item.artist.as_deref().unwrap_or("Unknown"),
+2701                    item.title
+2702                );
+2703                queued_count += 1;
+2704            }
+2705            Ok(_) => {
+2706                // Row already exists (conflict), count as already queued
+2707                already_downloaded += 1;
+2708            }
+2709            Err(e) => {
+2710                error!("[Preload] Failed to queue {}: {}", item.title, e);
+2711                skipped += 1;
+2712            }
+2713        }
+2714    }
+2715
+2716    // Kick the pump so the queued preloads actually start; without this they'd
+2717    // only begin once some other download activity pumps the queue.
+2718    if queued_count > 0 {
+2719        let active_downloads = {
+2720            let manager = download_manager.0.lock().map_err(|e| e.to_string())?;
+2721            manager.get_active_downloads()
+2722        };
+2723        crate::commands::download::pump_download_queue(app, db_service, active_downloads).await;
+2724    }
+2725
+2726    info!(
+2727        "[Preload] Result: queued={}, already_downloaded={}, skipped={}",
+2728        queued_count, already_downloaded, skipped
+2729    );
+2730
+2731    Ok(PreloadResult {
+2732        queued_count,
+2733        already_downloaded,
+2734        skipped,
+2735    })
+2736}
+2737
+2738/// Sanitize filename by removing invalid characters
+2739fn sanitize_filename(name: &str) -> String {
+2740    name.chars()
+2741        .map(|c| match c {
+2742            '/' | '\\' | ':' | '*' | '?' | '"' | '<' | '>' | '|' => '_',
+2743            _ => c,
+2744        })
+2745        .collect()
+2746}
+2747
+2748/// Update SmartCache configuration
+2749#[tauri::command]
+2750#[specta::specta]
+2751pub async fn player_set_cache_config(
+2752    smart_cache: State<'_, SmartCacheWrapper>,
+2753    config: CacheConfig,
+2754) -> Result<(), String> {
+2755    let cache = smart_cache.0.lock().map_err(|e| e.to_string())?;
+2756    cache.update_config(config);
+2757    Ok(())
+2758}
+2759
+2760/// Get current SmartCache configuration
+2761#[tauri::command]
+2762#[specta::specta]
+2763pub async fn player_get_cache_config(
+2764    smart_cache: State<'_, SmartCacheWrapper>,
+2765) -> Result<CacheConfig, String> {
+2766    let cache = smart_cache.0.lock().map_err(|e| e.to_string())?;
+2767    Ok(cache.get_config().unwrap_or_default())
+2768}
+2769
+2770/// Configure Jellyfin API client for automatic playback reporting
+2771#[tauri::command]
+2772#[specta::specta]
+2773pub async fn player_configure_jellyfin(
+2774    player: State<'_, PlayerStateWrapper>,
+2775    db: State<'_, DatabaseWrapper>,
+2776    server_url: String,
+2777    access_token: String,
+2778    user_id: String,
+2779    device_id: String,
+2780) -> Result<(), String> {
+2781    log::info!("[PlayerCommand] Configuring Jellyfin client for playback reporting");
+2782
+2783    let config = JellyfinConfig {
+2784        server_url,
+2785        access_token,
+2786        device_id,
+2787    };
+2788
+2789    // Legacy client (used for remote session control / casting).
+2790    let client = JellyfinClient::new(config.clone())?;
+2791
+2792    // Build the PlaybackReporter the player and backends (MPV + ExoPlayer)
+2793    // actually report through. Without this, Start/Progress/Stopped never reach
+2794    // Jellyfin, so playback position never syncs and you can't resume on another
+2795    // device. The reporter shares the player controller's Arc, so populating it
+2796    // here lights up reporting on both desktop and Android, on every auth path
+2797    // that configures the player (login / restore / reauth).
+2798    let db_service = {
+2799        let database = db.0.lock().map_err(|e| e.to_string())?;
+2800        Arc::new(database.service())
+2801    };
+2802    let reporter_client = JellyfinClient::new(config)?;
+2803    let reporter = crate::playback_reporting::PlaybackReporter::new(
+2804        db_service,
+2805        Arc::new(TokioMutex::new(Some(reporter_client))),
+2806        user_id,
+2807    );
+2808
+2809    let controller = player.0.lock().await;
+2810    controller.set_jellyfin_client(Some(client));
+2811    controller.set_playback_reporter(Some(reporter)).await;
+2812
+2813    log::info!("[PlayerCommand] Jellyfin client and playback reporter configured successfully");
+2814    Ok(())
+2815}
+2816
+2817/// Disable Jellyfin automatic playback reporting
+2818#[tauri::command]
+2819#[specta::specta]
+2820pub async fn player_disable_jellyfin(player: State<'_, PlayerStateWrapper>) -> Result<(), String> {
+2821    log::info!("[PlayerCommand] Disabling Jellyfin client");
+2822
+2823    let controller = player.0.lock().await;
+2824    controller.set_jellyfin_client(None);
+2825    controller.set_playback_reporter(None).await;
+2826
+2827    log::info!("[PlayerCommand] Jellyfin client and playback reporter disabled");
+2828    Ok(())
+2829}
+2830
+2831#[cfg(test)]
+2832mod tests {
+2833    use crate::utils::lock::MutexSafe;
+2834
+2835    /// UT-206 — the volume the command hands on is always a real number in
+2836    /// 0.0..=1.0.
+2837    ///
+2838    /// Every backend clamps for itself, but the remote branch of
+2839    /// `player_set_volume` reaches no backend at all: it does
+2840    /// `(volume * 100.0) as i32`, which turns infinity into `i32::MAX` and NaN
+2841    /// into 0. NaN also survives `f32::clamp` unchanged, so clamping alone is
+2842    /// not enough — it has to be tested for.
+2843    ///
+2844    /// TRACES: DR-212 | UT-206
+2845    #[test]
+2846    fn test_normalize_volume_clamps_and_rejects_nan() {
+2847        use super::normalize_volume;
+2848
+2849        // In-range values pass through untouched.
+2850        assert_eq!(normalize_volume(0.0), 0.0);
+2851        assert_eq!(normalize_volume(0.5), 0.5);
+2852        assert_eq!(normalize_volume(1.0), 1.0);
+2853
+2854        // Out of range clamps to the same 0.0..=1.0 the backends use.
+2855        assert_eq!(normalize_volume(-0.5), 0.0);
+2856        assert_eq!(normalize_volume(42.0), 1.0);
+2857        assert_eq!(normalize_volume(f32::INFINITY), 1.0);
+2858        assert_eq!(normalize_volume(f32::NEG_INFINITY), 0.0);
+2859
+2860        // NaN is not a volume; it must not reach the Jellyfin percentage
+2861        // conversion or a backend.
+2862        let from_nan = normalize_volume(f32::NAN);
+2863        assert!(!from_nan.is_nan(), "NaN must not pass through the boundary");
+2864        assert_eq!(from_nan, 0.0);
+2865
+2866        // Whatever comes out survives the remote branch's percentage cast.
+2867        for input in [-1.0, 0.25, 9.0, f32::INFINITY, f32::NAN] {
+2868            let percent = (normalize_volume(input) * 100.0) as i32;
+2869            assert!((0..=100).contains(&percent), "input {input} gave {percent}");
+2870        }
+2871    }
+2872
+2873    /// The subtitle list the frontend resolved must survive the IPC hop and end
+2874    /// up on the `MediaItem` the native backend loads.
+2875    ///
+2876    /// The bug: `VideoPlayer.svelte` built a fully-resolved subtitle array and
+2877    /// then dropped it on the floor — `PlayItemRequest` had no field to put it
+2878    /// in — so `create_media_item` always produced `subtitles: vec![]`,
+2879    /// `android/mod.rs` serialized `[]` across JNI, and ExoPlayer was handed a
+2880    /// `MediaItem` with zero `SubtitleConfiguration`s. Every later
+2881    /// `setSubtitleTrack(n)` then found no text track groups and logged
+2882    /// "Invalid subtitle track index".
+2883    ///
+2884    /// The payload below is exactly what the frontend sends: camelCase for the
+2885    /// top-level command params (Tauri v2 converts them), and the subtitle
+2886    /// entries in the casing of `SubtitleTrack` itself — note `mime_type`.
+2887    ///
+2888    /// TRACES: UR-020 | IR-016 | UT-145
+2889    #[tokio::test]
+2890    async fn test_play_item_request_carries_subtitles_into_media_item() {
+2891        use super::{create_media_item, PlayItemRequest};
+2892
+2893        let payload = serde_json::json!({
+2894            "id": "ep-1",
+2895            "title": "Pilot",
+2896            "streamUrl": "https://jelly.example/Videos/ep-1/master.m3u8",
+2897            "videoCodec": "h264",
+2898            "needsTranscoding": false,
+2899            "subtitles": [
+2900                {
+2901                    "index": 2,
+2902                    "url": "https://jelly.example/Videos/ep-1/2/Subtitles/subtitles.vtt",
+2903                    "language": "eng",
+2904                    "label": "English (SRT)",
+2905                    "mime_type": "text/vtt"
+2906                },
+2907                {
+2908                    "index": 3,
+2909                    "url": "https://jelly.example/Videos/ep-1/3/Subtitles/subtitles.vtt",
+2910                    "language": null,
+2911                    "label": null,
+2912                    "mime_type": "text/vtt"
+2913                }
+2914            ]
+2915        });
+2916
+2917        let req: PlayItemRequest =
+2918            serde_json::from_value(payload).expect("frontend payload must deserialize");
+2919        assert_eq!(
+2920            req.subtitles.len(),
+2921            2,
+2922            "PlayItemRequest must carry the subtitle tracks, not silently ignore them"
+2923        );
+2924
+2925        let media = create_media_item(req, None).await.unwrap();
+2926        assert_eq!(
+2927            media.subtitles.len(),
+2928            2,
+2929            "create_media_item must thread the tracks onto the MediaItem the backend loads"
+2930        );
+2931        assert_eq!(media.subtitles[0].index, 2);
+2932        assert_eq!(media.subtitles[0].language.as_deref(), Some("eng"));
+2933        assert_eq!(media.subtitles[0].label.as_deref(), Some("English (SRT)"));
+2934        assert_eq!(media.subtitles[0].mime_type, "text/vtt");
+2935        // Order is the contract: `player_set_subtitle_track(n)` is a position in
+2936        // this list (see the note on `PlayItemRequest::subtitles`).
+2937        assert_eq!(media.subtitles[1].index, 3);
+2938        assert!(media.subtitles[1].language.is_none());
+2939    }
+2940
+2941    /// A request without subtitles must still deserialize — the field is
+2942    /// defaulted so the background-audio handoff and the autoplay/next-episode
+2943    /// callers keep compiling and sending what they always sent.
+2944    ///
+2945    /// TRACES: UR-020 | IR-016 | UT-145
+2946    #[tokio::test]
+2947    async fn test_play_item_request_without_subtitles_defaults_to_empty() {
+2948        use super::{create_media_item, PlayItemRequest};
+2949
+2950        let req: PlayItemRequest = serde_json::from_value(serde_json::json!({
+2951            "id": "movie-1",
+2952            "title": "Movie",
+2953            "streamUrl": "https://jelly.example/Videos/movie-1/stream.mp4",
+2954            "videoCodec": "h264",
+2955            "needsTranscoding": false
+2956        }))
+2957        .expect("a subtitle-less payload must still deserialize");
+2958
+2959        assert!(req.subtitles.is_empty());
+2960        assert!(create_media_item(req, None)
+2961            .await
+2962            .unwrap()
+2963            .subtitles
+2964            .is_empty());
+2965    }
+2966
+2967    /// The JSON handed to Kotlin over JNI must use the keys
+2968    /// `JellyTauPlayer.load()` actually reads.
+2969    ///
+2970    /// `MediaItem` is `rename_all = "camelCase"`, and the instinct (and the
+2971    /// house IPC rule) is to camelCase nested structs too — but
+2972    /// `JellyTauPlayer.kt` reads `subtitle.optString("mime_type", …)`. Renaming
+2973    /// the field would not fail to compile or fail the IPC; it would silently
+2974    /// fall back to the default MIME type for every track, so this is asserted
+2975    /// on the exact bytes `android/mod.rs` sends.
+2976    ///
+2977    /// TRACES: UR-020 | IR-016, JA-008 | UT-146
+2978    #[test]
+2979    fn test_subtitle_json_for_jni_uses_the_keys_kotlin_reads() {
+2980        use crate::player::media::SubtitleTrack;
+2981
+2982        let subtitles = vec![SubtitleTrack {
+2983            index: 2,
+2984            url: "https://jelly.example/subs.vtt".to_string(),
+2985            language: Some("eng".to_string()),
+2986            label: Some("English".to_string()),
+2987            mime_type: "text/vtt".to_string(),
+2988        }];
+2989
+2990        // Exactly what player/android/mod.rs passes to loadWithMetadata.
+2991        let json = serde_json::to_string(&subtitles).unwrap();
+2992        let parsed: Vec<serde_json::Value> = serde_json::from_str(&json).unwrap();
+2993        let obj = parsed[0].as_object().unwrap();
+2994
+2995        for key in ["url", "language", "label", "mime_type"] {
+2996            assert!(
+2997                obj.contains_key(key),
+2998                "JellyTauPlayer.load() reads `{key}`; serialized keys were {:?}",
+2999                obj.keys().collect::<Vec<_>>()
+3000            );
+3001        }
+3002        assert!(
+3003            !obj.contains_key("mimeType"),
+3004            "camelCasing mime_type silently drops every track's MIME type on Android"
+3005        );
+3006    }
+3007
+3008    /// The audio-only handoff must play a downloaded file when there is one,
+3009    /// rather than fetching an audio-only stream for media already on disk.
+3010    ///
+3011    /// TRACES: UR-071 | DR-128 | UT-119
+3012    #[test]
+3013    fn test_background_audio_source_prefers_local_file() {
+3014        use super::background_audio_source;
+3015        use crate::player::MediaSource;
+3016        use std::path::PathBuf;
+3017
+3018        let local = background_audio_source(
+3019            Some("/downloads/ep1.mkv".to_string()),
+3020            "https://server/audio-only".to_string(),
+3021            "ep-1",
+3022        );
+3023        match local {
+3024            MediaSource::Local {
+3025                file_path,
+3026                jellyfin_item_id,
+3027            } => {
+3028                assert_eq!(file_path, PathBuf::from("/downloads/ep1.mkv"));
+3029                // The Jellyfin id must survive so progress still syncs back.
+3030                assert_eq!(jellyfin_item_id.as_deref(), Some("ep-1"));
+3031            }
+3032            other => panic!("expected a local source, got {:?}", other),
+3033        }
+3034
+3035        let remote = background_audio_source(None, "https://server/audio-only".to_string(), "ep-1");
+3036        match remote {
+3037            MediaSource::Remote {
+3038                stream_url,
+3039                jellyfin_item_id,
+3040            } => {
+3041                assert_eq!(stream_url, "https://server/audio-only");
+3042                assert_eq!(jellyfin_item_id, "ep-1");
+3043            }
+3044            other => panic!("expected a remote source, got {:?}", other),
+3045        }
+3046    }
+3047
+3048    /// The two sources start in different places, so the handoff cannot treat
+3049    /// them alike.
+3050    ///
+3051    /// An audio-only *stream* is built with `StartTimeTicks`, so the server makes
+3052    /// the handoff point that stream's zero: the base is the handoff position and
+3053    /// seeking would jump past the content. A *downloaded file* has no such
+3054    /// parameter — it starts at the episode's own zero — so basing it at the
+3055    /// handoff position claims 18 minutes of audio that is about to play from the
+3056    /// beginning. That is the downloaded-episode version of "it restarts when the
+3057    /// screen sleeps", and it needs the opposite treatment: no base, and a seek.
+3058    ///
+3059    /// TRACES: UR-040, UR-071 | DR-180 | UT-181
+3060    #[test]
+3061    fn test_background_audio_plan_seeks_a_file_and_bases_a_stream() {
+3062        use super::background_audio_plan;
+3063
+3064        let local = background_audio_plan(true, 1104.0);
+3065        assert_eq!(local.base_seconds, 0.0);
+3066        assert_eq!(local.seek_to, Some(1104.0));
+3067
+3068        let streamed = background_audio_plan(false, 1104.0);
+3069        assert_eq!(streamed.base_seconds, 1104.0);
+3070        assert_eq!(
+3071            streamed.seek_to, None,
+3072            "the URL already starts at the handoff point; seeking again skips past it"
+3073        );
+3074    }
+3075
+3076    /// Handing off at the very start has nothing to seek to and nothing to base:
+3077    /// both sources are already where they need to be.
+3078    ///
+3079    /// TRACES: UR-040, UR-071 | DR-180 | UT-181
+3080    #[test]
+3081    fn test_background_audio_plan_at_the_start_neither_seeks_nor_bases() {
+3082        use super::background_audio_plan;
+3083
+3084        for local in [true, false] {
+3085            let plan = background_audio_plan(local, 0.0);
+3086            assert_eq!(plan.base_seconds, 0.0);
+3087            assert_eq!(plan.seek_to, None);
+3088        }
+3089    }
+3090
+3091    /// A downloaded item must resolve to its file, and a `downloads` row whose
+3092    /// file has gone must resolve to `None` so the caller falls back to
+3093    /// streaming instead of handing the player a path that cannot be opened.
+3094    ///
+3095    /// TRACES: UR-071 | DR-123 | UT-116
+3096    #[tokio::test]
+3097    async fn test_resolve_local_media_path() {
+3098        use super::resolve_local_media_path;
+3099        use crate::storage::db_service::{DatabaseService, Query, RusqliteService};
+3100        use rusqlite::Connection;
+3101        use std::sync::{Arc, Mutex};
+3102
+3103        let conn = Connection::open_in_memory().unwrap();
+3104        conn.execute(
+3105            "CREATE TABLE downloads (id INTEGER PRIMARY KEY, item_id TEXT, status TEXT, file_path TEXT)",
+3106            [],
+3107        )
+3108        .unwrap();
+3109        let db_service = Arc::new(RusqliteService::new(Arc::new(Mutex::new(conn))));
+3110
+3111        // A real file on disk, so the existence check passes.
+3112        let present = std::env::temp_dir().join("jellytau-resolve-local-test.mp4");
+3113        std::fs::write(&present, b"x").unwrap();
+3114        let present_str = present.to_string_lossy().to_string();
+3115
+3116        for (item, status, path) in [
+3117            ("downloaded", "completed", present_str.as_str()),
+3118            ("still-going", "downloading", present_str.as_str()),
+3119            (
+3120                "file-gone",
+3121                "completed",
+3122                "/nonexistent/jellytau/missing.mp4",
+3123            ),
+3124        ] {
+3125            db_service
+3126                .execute(Query::with_params(
+3127                    "INSERT INTO downloads (item_id, status, file_path) VALUES (?, ?, ?)",
+3128                    vec![
+3129                        crate::storage::db_service::QueryParam::String(item.to_string()),
+3130                        crate::storage::db_service::QueryParam::String(status.to_string()),
+3131                        crate::storage::db_service::QueryParam::String(path.to_string()),
+3132                    ],
+3133                ))
+3134                .await
+3135                .unwrap();
+3136        }
+3137
+3138        assert_eq!(
+3139            resolve_local_media_path(&db_service, "downloaded")
+3140                .await
+3141                .unwrap()
+3142                .as_deref(),
+3143            Some(present_str.as_str()),
+3144            "a completed download with its file present must resolve"
+3145        );
+3146
+3147        assert_eq!(
+3148            resolve_local_media_path(&db_service, "still-going")
+3149                .await
+3150                .unwrap(),
+3151            None,
+3152            "an in-progress download is not playable from disk"
+3153        );
+3154
+3155        assert_eq!(
+3156            resolve_local_media_path(&db_service, "file-gone")
+3157                .await
+3158                .unwrap(),
+3159            None,
+3160            "a row whose file has gone must fall back to streaming, not hand over a dead path"
+3161        );
+3162
+3163        assert_eq!(
+3164            resolve_local_media_path(&db_service, "never-heard-of-it")
+3165                .await
+3166                .unwrap(),
+3167            None
+3168        );
+3169
+3170        let _ = std::fs::remove_file(&present);
+3171    }
+3172
+3173    /// Queue items enqueued as Remote must flip to Local once a completed
+3174    /// download exists on disk — this is what makes preloaded tracks (and
+3175    /// offline playback after a connection drop) actually use the cache.
+3176    #[tokio::test]
+3177    async fn test_refresh_queue_local_sources_switches_completed_downloads() {
+3178        use super::{refresh_queue_local_sources, DatabaseWrapper};
+3179        use crate::player::{MediaItem, MediaSource, MediaType, PlayerController};
+3180        use crate::storage::Database;
+3181        use std::sync::Mutex;
+3182
+3183        // A real file on disk for the completed download; a missing file for
+3184        // the second entry to prove nonexistent files are not switched.
+3185        let dir = std::env::temp_dir().join("jellytau-test-refresh-sources");
+3186        std::fs::create_dir_all(&dir).unwrap();
+3187        let existing = dir.join("track-a.mp3");
+3188        std::fs::write(&existing, b"audio").unwrap();
+3189        let missing = dir.join("track-b-missing.mp3");
+3190        let _ = std::fs::remove_file(&missing);
+3191
+3192        let database = Database::open_in_memory().unwrap();
+3193        {
+3194            let conn = database.connection();
+3195            let conn = conn.lock_safe();
+3196            conn.execute_batch(&format!(
+3197                r#"
+3198                INSERT INTO servers (id, name, url) VALUES ('srv', 'Test', 'http://test');
+3199                INSERT INTO users (id, server_id, username) VALUES ('user1', 'srv', 'tester');
+3200                INSERT INTO downloads (item_id, user_id, file_path, status)
+3201                    VALUES ('track-a', 'user1', '{}', 'completed');
+3202                INSERT INTO downloads (item_id, user_id, file_path, status)
+3203                    VALUES ('track-b', 'user1', '{}', 'completed');
+3204                "#,
+3205                existing.display(),
+3206                missing.display()
+3207            ))
+3208            .unwrap();
+3209        }
+3210        let db = DatabaseWrapper(Mutex::new(database));
+3211
+3212        let make_item = |id: &str| MediaItem {
+3213            id: id.to_string(),
+3214            title: id.to_string(),
+3215            name: None,
+3216            artist: None,
+3217            album: None,
+3218            album_name: None,
+3219            album_id: None,
+3220            artist_items: None,
+3221            artists: None,
+3222            primary_image_tag: None,
+3223            image_id: None,
+3224            item_type: None,
+3225            playlist_id: None,
+3226            duration: None,
+3227            artwork_url: None,
+3228            media_type: MediaType::Audio,
+3229            source: MediaSource::Remote {
+3230                stream_url: format!("http://test/Audio/{}/stream", id),
+3231                jellyfin_item_id: id.to_string(),
+3232            },
+3233            video_codec: None,
+3234            needs_transcoding: false,
+3235            video_width: None,
+3236            video_height: None,
+3237            subtitles: vec![],
+3238            series_id: None,
+3239            server_id: None,
+3240        };
+3241
+3242        let controller = PlayerController::default();
+3243        controller
+3244            .set_queue(vec![make_item("track-a"), make_item("track-b")], 0)
+3245            .unwrap();
+3246
+3247        let switched = refresh_queue_local_sources(&controller, &db).await.unwrap();
+3248        assert_eq!(switched, 1, "only the download whose file exists switches");
+3249
+3250        let queue = controller.queue();
+3251        let queue_lock = queue.lock_safe();
+3252        match &queue_lock.items()[0].source {
+3253            MediaSource::Local {
+3254                file_path,
+3255                jellyfin_item_id,
+3256            } => {
+3257                assert_eq!(file_path, &existing);
+3258                assert_eq!(jellyfin_item_id.as_deref(), Some("track-a"));
+3259            }
+3260            other => panic!("track-a should be local, got {:?}", other),
+3261        }
+3262        assert!(
+3263            matches!(queue_lock.items()[1].source, MediaSource::Remote { .. }),
+3264            "track-b's file is missing, it must stay remote"
+3265        );
+3266    }
+3267
+3268    /// Test track index finding in album
+3269    /// This reproduces the bug where clicking songs 1-5 always played song 13
+3270    #[test]
+3271    fn test_find_track_index_in_album() {
+3272        // Create mock album tracks
+3273        #[derive(Clone)]
+3274        struct MockTrack {
+3275            id: String,
+3276            name: String,
+3277            index_number: Option<i32>,
+3278        }
+3279
+3280        let mut tracks = [
+3281            MockTrack {
+3282                id: "track1".to_string(),
+3283                name: "Song 1".to_string(),
+3284                index_number: Some(1),
+3285            },
+3286            MockTrack {
+3287                id: "track2".to_string(),
+3288                name: "Song 2".to_string(),
+3289                index_number: Some(2),
+3290            },
+3291            MockTrack {
+3292                id: "track3".to_string(),
+3293                name: "Song 3".to_string(),
+3294                index_number: Some(3),
+3295            },
+3296            MockTrack {
+3297                id: "track4".to_string(),
+3298                name: "Song 4".to_string(),
+3299                index_number: Some(4),
+3300            },
+3301            MockTrack {
+3302                id: "track5".to_string(),
+3303                name: "Song 5".to_string(),
+3304                index_number: Some(5),
+3305            },
+3306        ];
+3307
+3308        // Sort by index (same as the real code does)
+3309        tracks.sort_by_key(|t| t.index_number.unwrap_or(0));
+3310
+3311        // Test finding track 1 (should be index 0)
+3312        let index1 = tracks.iter().position(|t| t.id == "track1");
+3313        assert_eq!(index1, Some(0), "Track 1 should be at index 0");
+3314        assert_eq!(
+3315            tracks[0].name, "Song 1",
+3316            "Track at index 0 should carry its name"
+3317        );
+3318
+3319        // Test finding track 3 (should be index 2)
+3320        let index3 = tracks.iter().position(|t| t.id == "track3");
+3321        assert_eq!(index3, Some(2), "Track 3 should be at index 2");
+3322
+3323        // Test finding track 5 (should be index 4)
+3324        let index5 = tracks.iter().position(|t| t.id == "track5");
+3325        assert_eq!(index5, Some(4), "Track 5 should be at index 4");
+3326
+3327        // Test finding non-existent track
+3328        let index_none = tracks.iter().position(|t| t.id == "nonexistent");
+3329        assert_eq!(index_none, None, "Non-existent track should return None");
+3330    }
+3331
+3332    /// Test that track order is preserved when iterating
+3333    #[test]
+3334    fn test_track_iteration_order() {
+3335        // Simulate the loop that builds MediaItems
+3336        let track_ids = vec!["id1", "id2", "id3", "id4", "id5"];
+3337
+3338        // Find where "id3" is in the original list
+3339        let target_index = track_ids.iter().position(|&id| id == "id3");
+3340        assert_eq!(
+3341            target_index,
+3342            Some(2),
+3343            "id3 should be at index 2 in original list"
+3344        );
+3345
+3346        // Simulate building the MediaItems vector
+3347        let mut media_items = Vec::new();
+3348        for id in &track_ids {
+3349            media_items.push(id.to_string());
+3350        }
+3351
+3352        // Verify the order is preserved
+3353        assert_eq!(media_items.len(), 5);
+3354        assert_eq!(media_items[0], "id1");
+3355        assert_eq!(media_items[2], "id3");
+3356        assert_eq!(media_items[4], "id5");
+3357
+3358        // The start_index found earlier should still be valid
+3359        assert_eq!(media_items[target_index.unwrap()], "id3");
+3360    }
+3361
+3362    /// Test album track sorting behavior
+3363    #[test]
+3364    fn test_album_track_sorting() {
+3365        #[derive(Clone, Debug)]
+3366        struct MockTrack {
+3367            id: String,
+3368            name: String,
+3369            index_number: Option<i32>,
+3370        }
+3371
+3372        // Create tracks in random order (not sorted)
+3373        let mut tracks = [
+3374            MockTrack {
+3375                id: "id5".to_string(),
+3376                name: "Track 5".to_string(),
+3377                index_number: Some(5),
+3378            },
+3379            MockTrack {
+3380                id: "id1".to_string(),
+3381                name: "Track 1".to_string(),
+3382                index_number: Some(1),
+3383            },
+3384            MockTrack {
+3385                id: "id3".to_string(),
+3386                name: "Track 3".to_string(),
+3387                index_number: Some(3),
+3388            },
+3389            MockTrack {
+3390                id: "id2".to_string(),
+3391                name: "Track 2".to_string(),
+3392                index_number: Some(2),
+3393            },
+3394            MockTrack {
+3395                id: "id4".to_string(),
+3396                name: "Track 4".to_string(),
+3397                index_number: Some(4),
+3398            },
+3399        ];
+3400
+3401        // User clicks track "id1" before sorting - what index is it?
+3402        let requested_track_id = "id1";
+3403
+3404        // Sort the tracks (same as real code)
+3405        tracks.sort_by_key(|t| t.index_number.unwrap_or(0));
+3406
+3407        // Now find the index AFTER sorting
+3408        let start_index = tracks.iter().position(|t| t.id == requested_track_id);
+3409
+3410        // Track "id1" should be at index 0 after sorting
+3411        assert_eq!(
+3412            start_index,
+3413            Some(0),
+3414            "After sorting, track id1 should be at index 0"
+3415        );
+3416
+3417        // Verify all tracks are in correct order
+3418        assert_eq!(tracks[0].id, "id1");
+3419        assert_eq!(
+3420            tracks[0].name, "Track 1",
+3421            "Sorted track should retain its name"
+3422        );
+3423        assert_eq!(tracks[1].id, "id2");
+3424        assert_eq!(tracks[2].id, "id3");
+3425        assert_eq!(tracks[3].id, "id4");
+3426        assert_eq!(tracks[4].id, "id5");
+3427    }
+3428}
+
\ No newline at end of file diff --git a/api/src/jellytau_lib/commands/player/queue.rs.html b/api/src/jellytau_lib/commands/player/queue.rs.html new file mode 100644 index 00000000..9b6f4074 --- /dev/null +++ b/api/src/jellytau_lib/commands/player/queue.rs.html @@ -0,0 +1,416 @@ +queue.rs - sourceSkip to main content

jellytau_lib/commands/player/
queue.rs

1//! Queue manipulation commands (add / remove / move / skip).
+2//!
+3//! TRACES: UR-015 | DR-005, DR-020
+4
+5use std::path::PathBuf;
+6
+7use log::info;
+8use serde::Deserialize;
+9use tauri::State;
+10
+11use super::{
+12    check_for_local_download, create_media_item, get_player_status, get_queue_status,
+13    DatabaseWrapper, PlayItemRequest, PlayerStateWrapper, PlayerStatus, QueueStatus,
+14};
+15use crate::commands::repository::RepositoryManagerWrapper;
+16use crate::player::{MediaItem, MediaSource, MediaType};
+17use crate::repository::types::{ImageOptions, ImageType};
+18use crate::repository::MediaRepository;
+19
+20/// Request to add items to queue
+21#[derive(specta::Type, Debug, Deserialize)]
+22#[serde(rename_all = "camelCase")]
+23pub struct AddToQueueRequest {
+24    pub items: Vec<PlayItemRequest>,
+25    pub position: String, // "next" or "end"
+26}
+27
+28/// Request to add a track by ID - backend fetches metadata
+29#[derive(specta::Type, Debug, Deserialize)]
+30#[serde(rename_all = "camelCase")]
+31pub struct AddTrackByIdRequest {
+32    pub track_id: String,
+33    pub position: String, // "next" or "end"
+34}
+35
+36/// Request to add multiple tracks by IDs - backend fetches metadata
+37#[derive(specta::Type, Debug, Deserialize)]
+38#[serde(rename_all = "camelCase")]
+39pub struct AddTracksByIdsRequest {
+40    pub track_ids: Vec<String>,
+41    pub position: String, // "next" or "end"
+42}
+43
+44#[tauri::command]
+45#[specta::specta]
+46pub async fn player_add_to_queue(
+47    player: State<'_, PlayerStateWrapper>,
+48    db: State<'_, DatabaseWrapper>,
+49    request: AddToQueueRequest,
+50) -> Result<QueueStatus, String> {
+51    use crate::player::queue::AddPosition;
+52
+53    let position = match request.position.as_str() {
+54        "next" => AddPosition::Next,
+55        _ => AddPosition::End,
+56    };
+57
+58    // Create media items first (without holding any locks during await)
+59    let mut items: Vec<MediaItem> = Vec::new();
+60    for req in request.items {
+61        items.push(create_media_item(req, Some(&db)).await?);
+62    }
+63
+64    // Now add to queue
+65    let controller = player.0.lock().await;
+66    let queue = controller.queue();
+67    let mut queue_lock = queue.lock().map_err(|e| e.to_string())?;
+68
+69    queue_lock.add(items, position);
+70
+71    let result = QueueStatus {
+72        items: queue_lock.items().to_vec(),
+73        current_index: queue_lock.current_index(),
+74        shuffle: queue_lock.is_shuffle(),
+75        repeat: queue_lock.repeat_mode(),
+76        has_next: queue_lock.has_next(),
+77        has_previous: queue_lock.has_previous(),
+78    };
+79
+80    drop(queue_lock);
+81    controller.emit_queue_changed();
+82
+83    Ok(result)
+84}
+85
+86#[tauri::command]
+87#[specta::specta]
+88pub async fn player_remove_from_queue(
+89    player: State<'_, PlayerStateWrapper>,
+90    index: usize,
+91) -> Result<QueueStatus, String> {
+92    let controller = player.0.lock().await;
+93    let queue = controller.queue();
+94    let mut queue_lock = queue.lock().map_err(|e| e.to_string())?;
+95
+96    queue_lock.remove(index);
+97
+98    let result = QueueStatus {
+99        items: queue_lock.items().to_vec(),
+100        current_index: queue_lock.current_index(),
+101        shuffle: queue_lock.is_shuffle(),
+102        repeat: queue_lock.repeat_mode(),
+103        has_next: queue_lock.has_next(),
+104        has_previous: queue_lock.has_previous(),
+105    };
+106
+107    drop(queue_lock);
+108    controller.emit_queue_changed();
+109
+110    Ok(result)
+111}
+112
+113#[tauri::command]
+114#[specta::specta]
+115pub async fn player_move_in_queue(
+116    player: State<'_, PlayerStateWrapper>,
+117    from_index: usize,
+118    to_index: usize,
+119) -> Result<QueueStatus, String> {
+120    let controller = player.0.lock().await;
+121    let queue = controller.queue();
+122    let mut queue_lock = queue.lock().map_err(|e| e.to_string())?;
+123
+124    if !queue_lock.move_item(from_index, to_index) {
+125        return Err("Invalid indices for move operation".to_string());
+126    }
+127
+128    let result = QueueStatus {
+129        items: queue_lock.items().to_vec(),
+130        current_index: queue_lock.current_index(),
+131        shuffle: queue_lock.is_shuffle(),
+132        repeat: queue_lock.repeat_mode(),
+133        has_next: queue_lock.has_next(),
+134        has_previous: queue_lock.has_previous(),
+135    };
+136
+137    drop(queue_lock);
+138    controller.emit_queue_changed();
+139
+140    Ok(result)
+141}
+142
+143/// Add a track to queue by ID - backend fetches metadata and constructs URLs
+144#[tauri::command]
+145#[specta::specta]
+146pub async fn player_add_track_by_id(
+147    player: State<'_, PlayerStateWrapper>,
+148    db: State<'_, DatabaseWrapper>,
+149    repository_manager: State<'_, RepositoryManagerWrapper>,
+150    repository_handle: String,
+151    request: AddTrackByIdRequest,
+152) -> Result<QueueStatus, String> {
+153    use crate::player::queue::AddPosition;
+154
+155    info!(
+156        "player_add_track_by_id called: track_id={}, position={}",
+157        request.track_id, request.position
+158    );
+159
+160    // Get repository (hybrid - supports offline/online)
+161    let repository = repository_manager
+162        .0
+163        .get(&repository_handle)
+164        .ok_or("Repository not found - user may need to log in")?;
+165
+166    // Fetch track metadata via repository
+167    info!(
+168        "Fetching metadata for track {} via repository",
+169        request.track_id
+170    );
+171    let track = repository
+172        .get_item(&request.track_id)
+173        .await
+174        .map_err(|e| format!("Failed to fetch track metadata: {}", e))?;
+175
+176    // Check for local download first
+177    let local_path = check_for_local_download(&db, &request.track_id).await?;
+178
+179    let source = if let Some(path) = local_path {
+180        info!("Using local download for track {}", request.track_id);
+181        MediaSource::Local {
+182            file_path: PathBuf::from(path),
+183            jellyfin_item_id: Some(track.id.clone()),
+184        }
+185    } else {
+186        // Get stream URL from repository (works online/offline)
+187        let stream_url = repository
+188            .get_audio_stream_url(&track.id)
+189            .await
+190            .map_err(|e| format!("Failed to get stream URL for {}: {}", track.name, e))?;
+191
+192        MediaSource::Remote {
+193            stream_url,
+194            jellyfin_item_id: track.id.clone(),
+195        }
+196    };
+197
+198    // Build MediaItem with artwork URL from repository and frontend-compatible fields
+199    let primary_image_tag_for_url = track.primary_image_tag.clone();
+200    let media_item = MediaItem {
+201        id: track.id.clone(),
+202        title: track.name.clone(),
+203        name: Some(track.name.clone()), // Frontend compatibility
+204        artist: track
+205            .album_artist
+206            .clone()
+207            .or_else(|| track.artists.as_ref().and_then(|a| a.first().cloned())),
+208        album: track.album_name.clone(),
+209        album_name: track.album_name.clone(), // Frontend compatibility
+210        album_id: track.album_id.clone(),
+211        artist_items: track.artist_items.clone(), // For clickable artist links
+212        artists: track.artists.clone(),           // Fallback artist info
+213        primary_image_tag: track.primary_image_tag.clone(), // For frontend image display
+214        image_id: track.primary_image_tag.clone(),
+215        item_type: Some(track.item_type.clone()), // Frontend compatibility
+216        playlist_id: None,
+217        duration: track.runtime_ticks.map(|t| t as f64 / 10_000_000.0),
+218        artwork_url: primary_image_tag_for_url.and_then(|tag| {
+219            track.album_id.as_ref().map(|album_id| {
+220                repository.get_image_url(
+221                    album_id,
+222                    ImageType::Primary,
+223                    Some(ImageOptions {
+224                        max_width: Some(300),
+225                        tag: Some(tag),
+226                        ..Default::default()
+227                    }),
+228                )
+229            })
+230        }),
+231        media_type: MediaType::Audio,
+232        source,
+233        video_codec: None,
+234        needs_transcoding: false,
+235        video_width: None,
+236        video_height: None,
+237        subtitles: vec![],
+238        series_id: None,
+239        server_id: None,
+240    };
+241
+242    // Add to queue at specified position
+243    let position = match request.position.as_str() {
+244        "next" => AddPosition::Next,
+245        _ => AddPosition::End,
+246    };
+247
+248    let controller = player.0.lock().await;
+249    let queue = controller.queue();
+250    let mut queue_lock = queue.lock().map_err(|e| e.to_string())?;
+251
+252    queue_lock.add(vec![media_item], position);
+253
+254    let result = get_queue_status(&controller);
+255    drop(queue_lock);
+256    controller.emit_queue_changed();
+257
+258    info!("Successfully added track {} to queue", request.track_id);
+259    Ok(result)
+260}
+261
+262/// Add multiple tracks to queue by IDs - backend fetches metadata and constructs URLs
+263#[tauri::command]
+264#[specta::specta]
+265pub async fn player_add_tracks_by_ids(
+266    player: State<'_, PlayerStateWrapper>,
+267    db: State<'_, DatabaseWrapper>,
+268    repository_manager: State<'_, RepositoryManagerWrapper>,
+269    repository_handle: String,
+270    request: AddTracksByIdsRequest,
+271) -> Result<QueueStatus, String> {
+272    use crate::player::queue::AddPosition;
+273
+274    info!(
+275        "player_add_tracks_by_ids called: {} tracks, position={}",
+276        request.track_ids.len(),
+277        request.position
+278    );
+279
+280    // Get repository (hybrid - supports offline/online)
+281    let repository = repository_manager
+282        .0
+283        .get(&repository_handle)
+284        .ok_or("Repository not found - user may need to log in")?;
+285
+286    // Fetch metadata and build MediaItems for all tracks
+287    let mut media_items = Vec::new();
+288    for track_id in &request.track_ids {
+289        info!("Fetching metadata for track {} via repository", track_id);
+290        let track = repository
+291            .get_item(track_id)
+292            .await
+293            .map_err(|e| format!("Failed to fetch track metadata for {}: {}", track_id, e))?;
+294
+295        // Check for local download first
+296        let local_path = check_for_local_download(&db, track_id).await?;
+297
+298        let source = if let Some(path) = local_path {
+299            info!("Using local download for track {}", track_id);
+300            MediaSource::Local {
+301                file_path: PathBuf::from(path),
+302                jellyfin_item_id: Some(track.id.clone()),
+303            }
+304        } else {
+305            // Get stream URL from repository (works online/offline)
+306            let stream_url = repository
+307                .get_audio_stream_url(&track.id)
+308                .await
+309                .map_err(|e| format!("Failed to get stream URL for {}: {}", track.name, e))?;
+310
+311            MediaSource::Remote {
+312                stream_url,
+313                jellyfin_item_id: track.id.clone(),
+314            }
+315        };
+316
+317        // Build MediaItem with artwork URL from repository and frontend-compatible fields
+318        let primary_image_tag_for_url = track.primary_image_tag.clone();
+319        let media_item = MediaItem {
+320            id: track.id.clone(),
+321            title: track.name.clone(),
+322            name: Some(track.name.clone()), // Frontend compatibility
+323            artist: track
+324                .album_artist
+325                .clone()
+326                .or_else(|| track.artists.as_ref().and_then(|a| a.first().cloned())),
+327            album: track.album_name.clone(),
+328            album_name: track.album_name.clone(), // Frontend compatibility
+329            album_id: track.album_id.clone(),
+330            artist_items: track.artist_items.clone(), // For clickable artist links
+331            artists: track.artists.clone(),           // Fallback artist info
+332            primary_image_tag: track.primary_image_tag.clone(), // For frontend image display
+333            image_id: track.primary_image_tag.clone(),
+334            item_type: Some(track.item_type.clone()), // Frontend compatibility
+335            playlist_id: None,
+336            duration: track.runtime_ticks.map(|t| t as f64 / 10_000_000.0),
+337            artwork_url: primary_image_tag_for_url.and_then(|tag| {
+338                track.album_id.as_ref().map(|album_id| {
+339                    repository.get_image_url(
+340                        album_id,
+341                        ImageType::Primary,
+342                        Some(ImageOptions {
+343                            max_width: Some(300),
+344                            tag: Some(tag),
+345                            ..Default::default()
+346                        }),
+347                    )
+348                })
+349            }),
+350            media_type: MediaType::Audio,
+351            source,
+352            video_codec: None,
+353            needs_transcoding: false,
+354            video_width: None,
+355            video_height: None,
+356            subtitles: vec![],
+357            series_id: None,
+358            server_id: None,
+359        };
+360
+361        media_items.push(media_item);
+362    }
+363
+364    // Add to queue at specified position
+365    let position = match request.position.as_str() {
+366        "next" => AddPosition::Next,
+367        _ => AddPosition::End,
+368    };
+369
+370    let controller = player.0.lock().await;
+371    let queue = controller.queue();
+372    let mut queue_lock = queue.lock().map_err(|e| e.to_string())?;
+373
+374    queue_lock.add(media_items, position);
+375
+376    let result = get_queue_status(&controller);
+377    drop(queue_lock);
+378    controller.emit_queue_changed();
+379
+380    info!(
+381        "Successfully added {} tracks to queue",
+382        request.track_ids.len()
+383    );
+384    Ok(result)
+385}
+386
+387#[tauri::command]
+388#[specta::specta]
+389pub async fn player_skip_to(
+390    player: State<'_, PlayerStateWrapper>,
+391    db: State<'_, DatabaseWrapper>,
+392    index: usize,
+393) -> Result<PlayerStatus, String> {
+394    let controller = player.0.lock().await;
+395
+396    // Prefer downloads that completed since the queue was built
+397    if let Err(e) = super::refresh_queue_local_sources(&controller, &db).await {
+398        log::warn!("[player_skip_to] Failed to refresh local sources: {}", e);
+399    }
+400
+401    // Skip to the index and get the item to play
+402    let item = {
+403        let queue = controller.queue();
+404        let mut queue_lock = queue.lock().map_err(|e| e.to_string())?;
+405        queue_lock.skip_to(index).cloned().ok_or("Invalid index")?
+406    };
+407
+408    // Play the item without modifying the queue
+409    controller.load_and_play(&item).map_err(|e| e.to_string())?;
+410
+411    // Emit queue changed event
+412    controller.emit_queue_changed();
+413
+414    Ok(get_player_status(&controller))
+415}
+
\ No newline at end of file diff --git a/api/src/jellytau_lib/commands/player/remote.rs.html b/api/src/jellytau_lib/commands/player/remote.rs.html new file mode 100644 index 00000000..0e2fede8 --- /dev/null +++ b/api/src/jellytau_lib/commands/player/remote.rs.html @@ -0,0 +1,286 @@ +remote.rs - sourceSkip to main content

jellytau_lib/commands/player/
remote.rs

1//! Remote Jellyfin session control commands (casting to another device).
+2//!
+3//! TRACES: UR-010, UR-046 | IR-012, IR-028, JA-022, JA-023, JA-025, JA-026 | DR-037, DR-058
+4//!
+5//! These thin command adapters forward control actions to the active Jellyfin
+6//! session via the player's configured `JellyfinClient`.
+7
+8use tauri::State;
+9
+10use super::PlayerStateWrapper;
+11use crate::jellyfin::client::LmsSyncGroup;
+12
+13/// Play items on a remote Jellyfin session (casting)
+14#[tauri::command]
+15#[specta::specta]
+16pub async fn remote_play_on_session(
+17    player: State<'_, PlayerStateWrapper>,
+18    session_id: String,
+19    item_ids: Vec<String>,
+20    start_index: usize,
+21) -> Result<(), String> {
+22    log::info!(
+23        "[RemoteSession] Playing {} items on session {} (start index: {})",
+24        item_ids.len(),
+25        session_id,
+26        start_index
+27    );
+28    log::info!("[RemoteSession] Item IDs: {:?}", item_ids);
+29
+30    let client_opt = {
+31        let controller = player.0.lock().await;
+32        controller
+33            .jellyfin_client()
+34            .lock()
+35            .map_err(|e| e.to_string())?
+36            .clone()
+37    };
+38
+39    if let Some(client) = client_opt {
+40        log::info!("[RemoteSession] Jellyfin client IS configured, calling play_on_session");
+41        client
+42            .play_on_session(session_id, item_ids, start_index, None)
+43            .await?;
+44        log::info!("[RemoteSession] Successfully started playback on remote session");
+45        Ok(())
+46    } else {
+47        log::error!("[RemoteSession] Jellyfin client is NOT configured! User needs to log out/in or restart app");
+48        Err(
+49            "Jellyfin client not configured - please restart the app or log out and log back in"
+50                .to_string(),
+51        )
+52    }
+53}
+54
+55/// Send a playback command to a remote session
+56#[tauri::command]
+57#[specta::specta]
+58pub async fn remote_send_command(
+59    player: State<'_, PlayerStateWrapper>,
+60    session_id: String,
+61    command: String,
+62) -> Result<(), String> {
+63    log::info!(
+64        "[RemoteSession] Sending command '{}' to session {}",
+65        command,
+66        session_id
+67    );
+68
+69    let client_opt = {
+70        let controller = player.0.lock().await;
+71        controller
+72            .jellyfin_client()
+73            .lock()
+74            .map_err(|e| e.to_string())?
+75            .clone()
+76    };
+77
+78    if let Some(client) = client_opt {
+79        client.send_session_command(session_id, &command).await?;
+80        log::info!("[RemoteSession] Command sent successfully");
+81        Ok(())
+82    } else {
+83        Err("Jellyfin client not configured".to_string())
+84    }
+85}
+86
+87/// Seek on a remote session
+88#[tauri::command]
+89#[specta::specta]
+90pub async fn remote_session_seek(
+91    player: State<'_, PlayerStateWrapper>,
+92    session_id: String,
+93    position_ticks: i64,
+94) -> Result<(), String> {
+95    log::info!(
+96        "[RemoteSession] Seeking to {} ticks on session {}",
+97        position_ticks,
+98        session_id
+99    );
+100
+101    let client_opt = {
+102        let controller = player.0.lock().await;
+103        controller
+104            .jellyfin_client()
+105            .lock()
+106            .map_err(|e| e.to_string())?
+107            .clone()
+108    };
+109
+110    if let Some(client) = client_opt {
+111        client.session_seek(session_id, position_ticks).await?;
+112        log::info!("[RemoteSession] Seek successful");
+113        Ok(())
+114    } else {
+115        Err("Jellyfin client not configured".to_string())
+116    }
+117}
+118
+119/// Set volume on a remote session
+120#[tauri::command]
+121#[specta::specta]
+122pub async fn remote_session_set_volume(
+123    player: State<'_, PlayerStateWrapper>,
+124    session_id: String,
+125    volume: i32,
+126) -> Result<(), String> {
+127    log::info!(
+128        "[RemoteSession] Setting volume to {} on session {}",
+129        volume,
+130        session_id
+131    );
+132
+133    let client_opt = {
+134        let controller = player.0.lock().await;
+135        controller
+136            .jellyfin_client()
+137            .lock()
+138            .map_err(|e| e.to_string())?
+139            .clone()
+140    };
+141
+142    if let Some(client) = client_opt {
+143        client.session_set_volume(session_id, volume).await?;
+144        log::info!("[RemoteSession] Volume set successfully");
+145        Ok(())
+146    } else {
+147        Err("Jellyfin client not configured".to_string())
+148    }
+149}
+150
+151/// Toggle mute on a remote session
+152#[tauri::command]
+153#[specta::specta]
+154pub async fn remote_session_toggle_mute(
+155    player: State<'_, PlayerStateWrapper>,
+156    session_id: String,
+157) -> Result<(), String> {
+158    log::info!("[RemoteSession] Toggling mute on session {}", session_id);
+159
+160    let client_opt = {
+161        let controller = player.0.lock().await;
+162        controller
+163            .jellyfin_client()
+164            .lock()
+165            .map_err(|e| e.to_string())?
+166            .clone()
+167    };
+168
+169    if let Some(client) = client_opt {
+170        client.session_toggle_mute(session_id).await?;
+171        log::info!("[RemoteSession] Mute toggled successfully");
+172        Ok(())
+173    } else {
+174        Err("Jellyfin client not configured".to_string())
+175    }
+176}
+177
+178// --- JellyLMS multi-room sync groups (fuse / unfuse LMS zones) --------------
+179//
+180// The frontend addresses LMS players by MAC address, which it derives from a
+181// session's device id (`lms-{mac}`). These commands forward to the JellyLMS
+182// plugin REST API via the configured JellyfinClient.
+183
+184/// List current LMS sync groups.
+185#[tauri::command]
+186#[specta::specta]
+187pub async fn lms_get_sync_groups(
+188    player: State<'_, PlayerStateWrapper>,
+189) -> Result<Vec<LmsSyncGroup>, String> {
+190    let client_opt = {
+191        let controller = player.0.lock().await;
+192        controller
+193            .jellyfin_client()
+194            .lock()
+195            .map_err(|e| e.to_string())?
+196            .clone()
+197    };
+198
+199    if let Some(client) = client_opt {
+200        client.lms_get_sync_groups().await
+201    } else {
+202        Err("Jellyfin client not configured".to_string())
+203    }
+204}
+205
+206/// Fuse LMS zones into a sync group. `master_mac` keeps playing and the
+207/// `slave_macs` zones join it in sync.
+208#[tauri::command]
+209#[specta::specta]
+210pub async fn lms_create_sync_group(
+211    player: State<'_, PlayerStateWrapper>,
+212    master_mac: String,
+213    slave_macs: Vec<String>,
+214) -> Result<(), String> {
+215    log::info!(
+216        "[LmsSync] Fusing zones: master={}, slaves={:?}",
+217        master_mac,
+218        slave_macs
+219    );
+220
+221    let client_opt = {
+222        let controller = player.0.lock().await;
+223        controller
+224            .jellyfin_client()
+225            .lock()
+226            .map_err(|e| e.to_string())?
+227            .clone()
+228    };
+229
+230    if let Some(client) = client_opt {
+231        client.lms_create_sync_group(&master_mac, slave_macs).await
+232    } else {
+233        Err("Jellyfin client not configured".to_string())
+234    }
+235}
+236
+237/// Remove a single LMS zone from its sync group (decouple one player).
+238#[tauri::command]
+239#[specta::specta]
+240pub async fn lms_unsync_player(
+241    player: State<'_, PlayerStateWrapper>,
+242    mac: String,
+243) -> Result<(), String> {
+244    log::info!("[LmsSync] Decoupling zone {}", mac);
+245
+246    let client_opt = {
+247        let controller = player.0.lock().await;
+248        controller
+249            .jellyfin_client()
+250            .lock()
+251            .map_err(|e| e.to_string())?
+252            .clone()
+253    };
+254
+255    if let Some(client) = client_opt {
+256        client.lms_unsync_player(&mac).await
+257    } else {
+258        Err("Jellyfin client not configured".to_string())
+259    }
+260}
+261
+262/// Dissolve an entire LMS sync group, identified by its master's MAC.
+263#[tauri::command]
+264#[specta::specta]
+265pub async fn lms_dissolve_sync_group(
+266    player: State<'_, PlayerStateWrapper>,
+267    master_mac: String,
+268) -> Result<(), String> {
+269    log::info!("[LmsSync] Dissolving group with master {}", master_mac);
+270
+271    let client_opt = {
+272        let controller = player.0.lock().await;
+273        controller
+274            .jellyfin_client()
+275            .lock()
+276            .map_err(|e| e.to_string())?
+277            .clone()
+278    };
+279
+280    if let Some(client) = client_opt {
+281        client.lms_dissolve_sync_group(&master_mac).await
+282    } else {
+283        Err("Jellyfin client not configured".to_string())
+284    }
+285}
+
\ No newline at end of file diff --git a/api/src/jellytau_lib/commands/player/session.rs.html b/api/src/jellytau_lib/commands/player/session.rs.html new file mode 100644 index 00000000..f53e5758 --- /dev/null +++ b/api/src/jellytau_lib/commands/player/session.rs.html @@ -0,0 +1,48 @@ +session.rs - sourceSkip to main content

jellytau_lib/commands/player/
session.rs

1//! Media session state commands.
+2//!
+3//! TRACES: UR-005 | DR-009
+4//!
+5//! Read and dismiss the current media session (the Now Playing surface backing
+6//! lockscreen/notification controls).
+7
+8use tauri::State;
+9
+10use super::{MediaSessionManagerWrapper, PlayerStateWrapper};
+11use crate::player::PlayerStatusEvent;
+12
+13/// Get the current media session state
+14#[tauri::command]
+15#[specta::specta]
+16pub async fn player_get_session(
+17    session: State<'_, MediaSessionManagerWrapper>,
+18) -> Result<crate::player::session::MediaSessionType, String> {
+19    let manager = session.0.lock().map_err(|e| e.to_string())?;
+20    Ok(manager.current().clone())
+21}
+22
+23/// Dismiss the current media session (returns to Idle)
+24#[tauri::command]
+25#[specta::specta]
+26pub async fn player_dismiss_session(
+27    session: State<'_, MediaSessionManagerWrapper>,
+28    player: State<'_, PlayerStateWrapper>,
+29) -> Result<(), String> {
+30    log::info!("[Session] Dismissing current media session");
+31
+32    // Dismiss the session
+33    {
+34        let mut manager = session.0.lock().map_err(|e| e.to_string())?;
+35        manager.dismiss();
+36    }
+37
+38    // Emit session changed event
+39    if let Some(emitter) = player.0.lock().await.event_emitter() {
+40        let manager = session.0.lock().map_err(|e| e.to_string())?;
+41        emitter.emit(PlayerStatusEvent::SessionChanged {
+42            session: manager.current().clone(),
+43        });
+44    }
+45
+46    Ok(())
+47}
+
\ No newline at end of file diff --git a/api/src/jellytau_lib/commands/player/settings.rs.html b/api/src/jellytau_lib/commands/player/settings.rs.html new file mode 100644 index 00000000..58cb8c49 --- /dev/null +++ b/api/src/jellytau_lib/commands/player/settings.rs.html @@ -0,0 +1,212 @@ +settings.rs - sourceSkip to main content

jellytau_lib/commands/player/
settings.rs

1//! Audio and video playback settings commands.
+2//!
+3//! TRACES: UR-022, UR-027, UR-031, UR-032, UR-033, UR-074 | DR-025, DR-030, DR-034, DR-035, DR-036, DR-162, IR-020
+4
+5use std::sync::Arc;
+6
+7use log::{info, warn};
+8use tauri::{Manager, State};
+9
+10use super::{PlayerStateWrapper, VideoSettingsWrapper};
+11use crate::commands::storage::DatabaseWrapper;
+12use crate::player::AutoplaySettings;
+13use crate::settings::{AudioSettings, EqPreset, StreamingQuality, VideoSettings};
+14use crate::storage::db_service::{DatabaseService, Query, QueryParam};
+15use crate::utils::lock::MutexSafe;
+16
+17/// `app_settings` key holding the persisted streaming bandwidth ceiling.
+18///
+19/// The cap is persisted (unlike the rest of `VideoSettings`, which is
+20/// process-lifetime state) because forgetting it is the one failure that costs
+21/// the user something real: a limit set for a metered connection that silently
+22/// reverts to uncapped on the next launch spends their data allowance without
+23/// ever showing them a changed setting.
+24///
+25/// TRACES: UR-074 | DR-162
+26const STREAMING_QUALITY_KEY: &str = "streaming_quality";
+27
+28#[tauri::command]
+29#[specta::specta]
+30pub async fn player_set_audio_settings(
+31    player: State<'_, PlayerStateWrapper>,
+32    settings: AudioSettings,
+33) -> Result<AudioSettings, String> {
+34    // Validate/normalise domain values before applying: clamp crossfade to its
+35    // range and normalise the equalizer band vector (length + gain clamps).
+36    let validated = settings
+37        .with_crossfade_clamped()
+38        .with_equalizer_normalised();
+39    let mut controller = player.0.lock().await;
+40    controller
+41        .set_audio_settings(&validated)
+42        .map_err(|e| e.to_string())?;
+43    Ok(controller.audio_settings())
+44}
+45
+46/// The built-in equalizer presets and their per-band gain curves (dB), for the
+47/// settings UI. The curve numbers are domain data defined by the band layout,
+48/// so the frontend reads them here rather than encoding them.
+49///
+50/// TRACES: UR-027 | DR-030
+51#[tauri::command]
+52#[specta::specta]
+53pub async fn player_get_eq_presets() -> Result<Vec<(EqPreset, Vec<f32>)>, String> {
+54    Ok(EqPreset::ALL
+55        .iter()
+56        .map(|p| (*p, p.gains().to_vec()))
+57        .collect())
+58}
+59
+60#[tauri::command]
+61#[specta::specta]
+62pub async fn player_get_audio_settings(
+63    player: State<'_, PlayerStateWrapper>,
+64) -> Result<AudioSettings, String> {
+65    let controller = player.0.lock().await;
+66    Ok(controller.audio_settings())
+67}
+68
+69#[tauri::command]
+70#[specta::specta]
+71pub async fn player_set_video_settings(
+72    video_settings: State<'_, VideoSettingsWrapper>,
+73    player: State<'_, PlayerStateWrapper>,
+74    db: State<'_, DatabaseWrapper>,
+75    settings: VideoSettings,
+76) -> Result<VideoSettings, String> {
+77    let validated = settings.with_countdown_clamped();
+78    {
+79        let mut current = video_settings.0.lock().map_err(|e| e.to_string())?;
+80        *current = validated.clone();
+81    } // Drop MutexGuard before await
+82
+83    // The bandwidth ceiling is read by the repository's URL builders and by the
+84    // PlaybackInfo negotiation, neither of which can see this wrapper.
+85    // TRACES: UR-074 | DR-162
+86    crate::repository::online::set_streaming_quality(validated.streaming_quality);
+87    persist_streaming_quality(&db, validated.streaming_quality).await;
+88
+89    // Sync to PlayerController's autoplay settings so on_playback_ended() uses current values
+90    let controller = player.0.lock().await;
+91    controller.set_autoplay_settings(AutoplaySettings {
+92        enabled: validated.auto_play_next_episode,
+93        countdown_seconds: validated.auto_play_countdown_seconds,
+94        max_episodes: validated.auto_play_max_episodes,
+95    });
+96
+97    Ok(validated)
+98}
+99
+100/// The bandwidth ceilings the quality picker may offer, each with the label and
+101/// one-line detail to show for it, highest first.
+102///
+103/// The ladder and its numbers are Jellyfin encoding domain vocabulary, so the
+104/// frontend reads them here rather than encoding them — the same arrangement as
+105/// [`player_get_eq_presets`].
+106///
+107/// TRACES: UR-074 | DR-162
+108#[tauri::command]
+109#[specta::specta]
+110pub async fn player_get_streaming_qualities(
+111) -> Result<Vec<(StreamingQuality, String, String)>, String> {
+112    Ok(StreamingQuality::ALL
+113        .iter()
+114        .map(|q| (*q, q.label().to_string(), q.detail().to_string()))
+115        .collect())
+116}
+117
+118/// Write the ceiling to `app_settings`. Failure is logged, not returned: the
+119/// setting has already been applied in memory, and refusing the whole call
+120/// because the write failed would leave the UI showing a cap that *is* active.
+121///
+122/// TRACES: UR-074 | DR-162
+123async fn persist_streaming_quality(db: &State<'_, DatabaseWrapper>, quality: StreamingQuality) {
+124    let db_service = {
+125        let database = db.0.lock_safe();
+126        Arc::new(database.service())
+127    };
+128
+129    let encoded = match serde_json::to_string(&quality) {
+130        Ok(value) => value,
+131        Err(e) => {
+132            warn!("[VideoSettings] Failed to encode streaming quality: {}", e);
+133            return;
+134        }
+135    };
+136
+137    let query = Query::with_params(
+138        "INSERT OR REPLACE INTO app_settings (key, value, updated_at)
+139         VALUES (?, ?, CURRENT_TIMESTAMP)",
+140        vec![
+141            QueryParam::String(STREAMING_QUALITY_KEY.to_string()),
+142            QueryParam::String(encoded),
+143        ],
+144    );
+145
+146    if let Err(e) = db_service.execute(query).await {
+147        warn!("[VideoSettings] Failed to persist streaming quality: {}", e);
+148    }
+149}
+150
+151/// Restore the persisted bandwidth ceiling at startup, into both the repository
+152/// (which enforces it) and `VideoSettings` (which the settings UI reads).
+153///
+154/// Called from the Tauri `setup` hook. A missing or unreadable row leaves the
+155/// default — uncapped — in place, so a database problem degrades to the old
+156/// behaviour rather than to an arbitrary limit.
+157///
+158/// TRACES: UR-074 | DR-162
+159pub async fn restore_streaming_quality(app: &tauri::AppHandle) {
+160    let db_service = {
+161        let Some(db) = app.try_state::<DatabaseWrapper>() else {
+162            warn!("[VideoSettings] No database available; streaming quality stays uncapped");
+163            return;
+164        };
+165        let database = db.0.lock_safe();
+166        Arc::new(database.service())
+167    };
+168
+169    let query = Query::with_params(
+170        "SELECT value FROM app_settings WHERE key = ?",
+171        vec![QueryParam::String(STREAMING_QUALITY_KEY.to_string())],
+172    );
+173
+174    let stored: Option<String> = match db_service.query_optional(query, |row| row.get(0)).await {
+175        Ok(value) => value,
+176        Err(e) => {
+177            warn!("[VideoSettings] Failed to read streaming quality: {}", e);
+178            return;
+179        }
+180    };
+181
+182    let Some(stored) = stored else { return };
+183    let quality: StreamingQuality = match serde_json::from_str(&stored) {
+184        Ok(quality) => quality,
+185        Err(e) => {
+186            warn!(
+187                "[VideoSettings] Ignoring unrecognised persisted streaming quality {:?}: {}",
+188                stored, e
+189            );
+190            return;
+191        }
+192    };
+193
+194    crate::repository::online::set_streaming_quality(quality);
+195    if let Some(video_settings) = app.try_state::<VideoSettingsWrapper>() {
+196        video_settings.0.lock_safe().streaming_quality = quality;
+197    }
+198    info!(
+199        "[VideoSettings] Restored streaming quality cap: {}",
+200        quality.label()
+201    );
+202}
+203
+204#[tauri::command]
+205#[specta::specta]
+206pub async fn player_get_video_settings(
+207    video_settings: State<'_, VideoSettingsWrapper>,
+208) -> Result<VideoSettings, String> {
+209    let current = video_settings.0.lock().map_err(|e| e.to_string())?;
+210    Ok(current.clone())
+211}
+
\ No newline at end of file diff --git a/api/src/jellytau_lib/commands/player/timers.rs.html b/api/src/jellytau_lib/commands/player/timers.rs.html new file mode 100644 index 00000000..f9d0d582 --- /dev/null +++ b/api/src/jellytau_lib/commands/player/timers.rs.html @@ -0,0 +1,376 @@ +timers.rs - sourceSkip to main content

jellytau_lib/commands/player/
timers.rs

1//! Sleep-timer and autoplay commands.
+2//!
+3//! TRACES: UR-026, UR-023 | DR-029, DR-047, DR-049
+4//!
+5//! Thin command adapters over `PlayerController`'s sleep-timer and autoplay
+6//! logic, plus persistence of autoplay settings to the database.
+7
+8use std::sync::Arc;
+9use tauri::State;
+10
+11use super::{
+12    create_media_item, get_player_status, DatabaseWrapper, PlayItemRequest, PlayerStateWrapper,
+13    PlayerStatus,
+14};
+15use crate::player::{AutoplaySettings, SleepTimerMode, SleepTimerState};
+16use crate::storage::db_service::{DatabaseService, Query, QueryParam};
+17
+18// ===== Sleep Timer Commands =====
+19
+20/// Set sleep timer mode
+21#[tauri::command]
+22#[specta::specta]
+23pub async fn player_set_sleep_timer(
+24    player: State<'_, PlayerStateWrapper>,
+25    mode: SleepTimerMode,
+26) -> Result<SleepTimerState, String> {
+27    let controller = player.0.lock().await;
+28    controller.set_sleep_timer(mode);
+29    Ok(controller.sleep_timer_state())
+30}
+31
+32/// Cancel sleep timer
+33#[tauri::command]
+34#[specta::specta]
+35pub async fn player_cancel_sleep_timer(
+36    player: State<'_, PlayerStateWrapper>,
+37) -> Result<SleepTimerState, String> {
+38    let controller = player.0.lock().await;
+39    controller.cancel_sleep_timer();
+40    Ok(controller.sleep_timer_state())
+41}
+42
+43/// Get current sleep timer state
+44#[tauri::command]
+45#[specta::specta]
+46pub async fn player_get_sleep_timer(
+47    player: State<'_, PlayerStateWrapper>,
+48) -> Result<SleepTimerState, String> {
+49    let controller = player.0.lock().await;
+50    Ok(controller.sleep_timer_state())
+51}
+52
+53// ===== Autoplay Commands =====
+54
+55/// Get autoplay settings
+56#[tauri::command]
+57#[specta::specta]
+58pub async fn player_get_autoplay_settings(
+59    player: State<'_, PlayerStateWrapper>,
+60) -> Result<AutoplaySettings, String> {
+61    let controller = player.0.lock().await;
+62    Ok(controller.autoplay_settings())
+63}
+64
+65/// Set autoplay settings and persist to database
+66#[tauri::command]
+67#[specta::specta]
+68pub async fn player_set_autoplay_settings(
+69    player: State<'_, PlayerStateWrapper>,
+70    db: State<'_, DatabaseWrapper>,
+71    user_id: String,
+72    settings: AutoplaySettings,
+73) -> Result<AutoplaySettings, String> {
+74    let validated = settings.with_validated_countdown();
+75
+76    // Set in controller
+77    {
+78        let controller = player.0.lock().await;
+79        controller.set_autoplay_settings(validated.clone());
+80    }
+81
+82    // Persist to database
+83    let db_service = {
+84        let database = db.0.lock().map_err(|e| e.to_string())?;
+85        Arc::new(database.service())
+86    };
+87
+88    let query = Query::with_params(
+89        "INSERT INTO user_player_settings (user_id, autoplay_next_episode, autoplay_countdown_seconds, autoplay_max_episodes, updated_at)
+90         VALUES (?, ?, ?, ?, CURRENT_TIMESTAMP)
+91         ON CONFLICT(user_id) DO UPDATE SET
+92            autoplay_next_episode = excluded.autoplay_next_episode,
+93            autoplay_countdown_seconds = excluded.autoplay_countdown_seconds,
+94            autoplay_max_episodes = excluded.autoplay_max_episodes,
+95            updated_at = CURRENT_TIMESTAMP",
+96        vec![
+97            QueryParam::String(user_id),
+98            QueryParam::Int(if validated.enabled { 1 } else { 0 }),
+99            QueryParam::Int(validated.countdown_seconds as i32),
+100            QueryParam::Int(validated.max_episodes as i32),
+101        ],
+102    );
+103
+104    db_service.execute(query).await.map_err(|e| e.to_string())?;
+105
+106    Ok(validated)
+107}
+108
+109/// Cancel active autoplay countdown
+110#[tauri::command]
+111#[specta::specta]
+112pub async fn player_cancel_autoplay_countdown(
+113    player: State<'_, PlayerStateWrapper>,
+114) -> Result<(), String> {
+115    let controller = player.0.lock().await;
+116    controller.cancel_autoplay_countdown();
+117    Ok(())
+118}
+119
+120/// Play next episode (user confirmed from popup)
+121#[tauri::command]
+122#[specta::specta]
+123pub async fn player_play_next_episode(
+124    player: State<'_, PlayerStateWrapper>,
+125    db: State<'_, DatabaseWrapper>,
+126    item: PlayItemRequest,
+127) -> Result<PlayerStatus, String> {
+128    // Convert request to MediaItem
+129    let media_item = create_media_item(item, Some(&db)).await?;
+130
+131    let controller = player.0.lock().await;
+132    controller
+133        .play_item(media_item)
+134        .map_err(|e| e.to_string())?;
+135
+136    Ok(get_player_status(&controller))
+137}
+138
+139/// Handle playback ended event - triggers autoplay decision logic
+140/// This is called from:
+141/// - Frontend when HTML5 video ends (Linux/desktop) - passes itemId + repositoryHandle for the video
+142/// - Frontend when audio track ends via backend event - no itemId/repositoryHandle needed
+143/// - Android JNI callback also triggers this logic directly
+144///
+145/// TRACES: UR-023, UR-026, UR-040 | DR-047, DR-052, DR-129
+146#[tauri::command]
+147#[specta::specta]
+148pub async fn player_on_playback_ended(
+149    player: State<'_, PlayerStateWrapper>,
+150    repository_manager: State<'_, crate::commands::repository::RepositoryManagerWrapper>,
+151    db: State<'_, DatabaseWrapper>,
+152    item_id: Option<String>,
+153    repository_handle: Option<String>,
+154) -> Result<(), String> {
+155    use crate::player::autoplay::AutoplayDecision;
+156    use crate::player::PlayerStatusEvent;
+157
+158    let controller_arc = player.0.clone();
+159
+160    // Run autoplay decision logic
+161    // If item_id is provided (HTML5 video case), use the video-specific path
+162    // that bypasses the backend queue and stale end_reason
+163    let decision = {
+164        let controller = controller_arc.lock().await;
+165        if let Some(ref id) = item_id {
+166            // Video path: need repository to look up episode info
+167            let repo = repository_handle
+168                .as_ref()
+169                .and_then(|handle| repository_manager.0.get(handle));
+170            if let Some(repo) = repo {
+171                controller.on_video_playback_ended(id, repo).await?
+172            } else {
+173                log::warn!(
+174                    "[Autoplay] No repository available for video autoplay (itemId: {})",
+175                    id
+176                );
+177                AutoplayDecision::Stop
+178            }
+179        } else {
+180            controller.on_playback_ended().await?
+181        }
+182    };
+183
+184    // Handle the decision
+185    match decision {
+186        AutoplayDecision::Stop => {
+187            log::info!("[Autoplay] Decision: Stop playback");
+188            let controller = controller_arc.lock().await;
+189            // Clear the queue so the frontend's currentQueueItem becomes null and
+190            // the mini player hides. Without this, the queue still holds the last
+191            // track and the bar would linger (the frontend keeps the bar visible
+192            // through transient idle blips as long as a queue item exists).
+193            controller.clear_queue();
+194            controller.emit_queue_changed();
+195            if let Some(emitter) = controller.event_emitter() {
+196                // Emit StateChanged to idle to clear the current media from mini player
+197                // Note: Do NOT emit PlaybackEnded here - it would cause an infinite loop
+198                // (frontend receives PlaybackEnded → calls player_on_playback_ended → Stop → PlaybackEnded → ...)
+199                emitter.emit(PlayerStatusEvent::StateChanged {
+200                    state: "idle".to_string(),
+201                    media_id: None,
+202                });
+203            }
+204        }
+205        AutoplayDecision::AdvanceToNext => {
+206            log::info!("[Autoplay] Decision: Advance to next track");
+207            // Advance to next track in queue
+208            let controller = controller_arc.lock().await;
+209            // Prefer downloads that completed since the queue was built (e.g.
+210            // preloaded upcoming tracks) over continuing to stream.
+211            if let Err(e) = super::refresh_queue_local_sources(&controller, &db).await {
+212                log::warn!("[Autoplay] Failed to refresh local sources: {}", e);
+213            }
+214            if let Err(e) = controller.next() {
+215                log::error!("[Autoplay] Failed to advance to next track: {}", e);
+216                // Emit PlaybackEnded event on error
+217                if let Some(emitter) = controller.event_emitter() {
+218                    emitter.emit(PlayerStatusEvent::PlaybackEnded);
+219                }
+220            } else {
+221                // Emit queue changed event so frontend updates UI with new current track
+222                controller.emit_queue_changed();
+223            }
+224        }
+225        AutoplayDecision::ShowNextEpisodePopup {
+226            current_episode,
+227            next_episode,
+228            countdown_seconds,
+229            auto_advance,
+230        } => {
+231            log::info!(
+232                "[Autoplay] Decision: Show next episode popup (countdown: {}s, auto: {})",
+233                countdown_seconds,
+234                auto_advance
+235            );
+236
+237            // Emit popup event to frontend
+238            if let Some(emitter) = controller_arc.lock().await.event_emitter() {
+239                emitter.emit(PlayerStatusEvent::ShowNextEpisodePopup {
+240                    current_episode: current_episode.clone(),
+241                    next_episode: next_episode.clone(),
+242                    countdown_seconds,
+243                    auto_advance,
+244                });
+245            }
+246
+247            // Advance if auto_advance is enabled. This is the path that actually
+248            // runs on Android: the JNI callback's own decision is swallowed by the
+249            // NewTrackLoaded end reason set at load, so it returns Stop, emits
+250            // PlaybackEnded, and the frontend echoes it back into this command —
+251            // which is where the real decision lands.
+252            if auto_advance {
+253                controller_arc
+254                    .lock()
+255                    .await
+256                    .auto_advance_to_next_episode(next_episode, countdown_seconds)
+257                    .await;
+258            }
+259        }
+260        AutoplayDecision::ResumeStream { position } => {
+261            // The stream was cut short by the network, not by the media ending.
+262            // Re-open it where it died — no queue clearing, no PlaybackEnded, and
+263            // above all no leaving the player parked in ExoPlayer's STATE_ENDED,
+264            // where the next play intent restarts the item from 0:00.
+265            log::info!(
+266                "[Autoplay] Decision: Resume truncated stream at {:.1}s",
+267                position
+268            );
+269            let controller = controller_arc.lock().await;
+270            if let Err(e) = controller.resume_stream_at(position).await {
+271                log::error!("[Autoplay] Failed to resume truncated stream: {}", e);
+272                if let Some(emitter) = controller.event_emitter() {
+273                    emitter.emit(PlayerStatusEvent::PlaybackEnded);
+274                }
+275            }
+276        }
+277    }
+278
+279    Ok(())
+280}
+281
+282/// Try to recover playback after a **recoverable** player error, reporting
+283/// whether it was handled.
+284///
+285/// The frontend's error handler stops the player, which is right for a real
+286/// failure and wrong for a network blip — it turned every hiccup into "playback
+287/// died". This is the echo path for backends that cannot decide in-process:
+288/// MpvBackend is constructed before `PlayerController` exists ([`lib.rs`]), so
+289/// its event thread has no controller to ask. It emits the error, the frontend
+290/// echoes it here, and the decision stays in Rust — the same shape as
+291/// `PlaybackEnded` → `player_on_playback_ended`.
+292///
+293/// Returns `true` when the stream was re-opened and the caller must NOT stop the
+294/// player; `false` when the error is real and should be surfaced as before.
+295/// Android decides inside its JNI callback and only emits errors it has already
+296/// declined to recover, so this reports `false` for those without a second
+297/// opinion — the shared attempt budget is spent by then either way.
+298///
+299/// TRACES: UR-004, UR-040 | DR-130 | UT-117
+300#[tauri::command]
+301#[specta::specta]
+302pub async fn player_recover_stream(player: State<'_, PlayerStateWrapper>) -> Result<bool, String> {
+303    let (position, delay_secs) = {
+304        let controller = player.0.lock().await;
+305        match controller.recoverable_error_resume() {
+306            Some(resume) => resume,
+307            None => return Ok(false),
+308        }
+309    };
+310
+311    log::warn!(
+312        "[Recovery] Stream failed — re-opening at {:.1}s in {}s",
+313        position,
+314        delay_secs
+315    );
+316    // Give a brief outage time to clear; retrying instantly just burns the budget.
+317    tokio::time::sleep(std::time::Duration::from_secs(delay_secs)).await;
+318
+319    let controller = player.0.lock().await;
+320    match controller.resume_stream_at(position).await {
+321        Ok(()) => Ok(true),
+322        Err(e) => {
+323            log::error!("[Recovery] Failed to re-open stream: {}", e);
+324            Ok(false)
+325        }
+326    }
+327}
+328
+329// ===== HTML5 video state-report commands =====
+330//
+331// On platforms where video renders in the webview (Linux WebKitGTK HTML5
+332// <video>), the real player lives outside the native backend, so the frontend
+333// HTML5 adapter reports DOM events back through these commands. The controller
+334// re-emits them through the same PlayerStatusEvent pipeline the native backends
+335// use, keeping the Rust controller the single source of truth and the frontend
+336// player store fed from one place (playerEvents.ts) in both modes.
+337
+338/// Report an HTML5 <video> state change (playing/paused/loading/stopped/idle).
+339#[tauri::command]
+340#[specta::specta]
+341pub async fn player_report_state(
+342    player: State<'_, PlayerStateWrapper>,
+343    state: String,
+344    media_id: Option<String>,
+345) -> Result<(), String> {
+346    let controller = player.0.lock().await;
+347    controller.report_html5_state(state, media_id);
+348    Ok(())
+349}
+350
+351/// Report an HTML5 <video> position tick (seconds). The adapter should throttle
+352/// these to roughly match the native backends' ~250ms cadence.
+353#[tauri::command]
+354#[specta::specta]
+355pub async fn player_report_position(
+356    player: State<'_, PlayerStateWrapper>,
+357    position: f64,
+358    duration: f64,
+359) -> Result<(), String> {
+360    let controller = player.0.lock().await;
+361    controller.report_html5_position(position, duration);
+362    Ok(())
+363}
+364
+365/// Report that the HTML5 <video> finished loading and knows its duration.
+366#[tauri::command]
+367#[specta::specta]
+368pub async fn player_report_media_loaded(
+369    player: State<'_, PlayerStateWrapper>,
+370    duration: f64,
+371) -> Result<(), String> {
+372    let controller = player.0.lock().await;
+373    controller.report_html5_media_loaded(duration);
+374    Ok(())
+375}
+
\ No newline at end of file diff --git a/api/src/jellytau_lib/commands/playlist.rs.html b/api/src/jellytau_lib/commands/playlist.rs.html new file mode 100644 index 00000000..acfb4db2 --- /dev/null +++ b/api/src/jellytau_lib/commands/playlist.rs.html @@ -0,0 +1,144 @@ +playlist.rs - sourceSkip to main content

jellytau_lib/commands/
playlist.rs

1//! Tauri commands for playlist management
+2//! Uses handle-based system: UUID -> Arc<HybridRepository>
+3//!
+4//! TRACES: UR-014 | JA-019, JA-020
+5
+6use log::debug;
+7use tauri::State;
+8
+9use super::repository::RepositoryManagerWrapper;
+10use crate::repository::{types::*, MediaRepository};
+11
+12/// Create a new playlist
+13#[tauri::command]
+14#[specta::specta]
+15pub async fn playlist_create(
+16    manager: State<'_, RepositoryManagerWrapper>,
+17    handle: String,
+18    name: String,
+19    item_ids: Option<Vec<String>>,
+20) -> Result<PlaylistCreatedResult, String> {
+21    debug!("[PLAYLIST] create called: name={}", name);
+22    let repo = manager.0.get(&handle).ok_or("Repository not found")?;
+23    let ids = item_ids.unwrap_or_default();
+24    repo.as_ref()
+25        .create_playlist(&name, &ids)
+26        .await
+27        .map_err(|e| format!("{:?}", e))
+28}
+29
+30/// Delete a playlist
+31#[tauri::command]
+32#[specta::specta]
+33pub async fn playlist_delete(
+34    manager: State<'_, RepositoryManagerWrapper>,
+35    handle: String,
+36    playlist_id: String,
+37) -> Result<(), String> {
+38    debug!("[PLAYLIST] delete called: id={}", playlist_id);
+39    let repo = manager.0.get(&handle).ok_or("Repository not found")?;
+40    repo.as_ref()
+41        .delete_playlist(&playlist_id)
+42        .await
+43        .map_err(|e| format!("{:?}", e))
+44}
+45
+46/// Rename a playlist
+47#[tauri::command]
+48#[specta::specta]
+49pub async fn playlist_rename(
+50    manager: State<'_, RepositoryManagerWrapper>,
+51    handle: String,
+52    playlist_id: String,
+53    name: String,
+54) -> Result<(), String> {
+55    debug!(
+56        "[PLAYLIST] rename called: id={}, name={}",
+57        playlist_id, name
+58    );
+59    let repo = manager.0.get(&handle).ok_or("Repository not found")?;
+60    repo.as_ref()
+61        .rename_playlist(&playlist_id, &name)
+62        .await
+63        .map_err(|e| format!("{:?}", e))
+64}
+65
+66/// Get playlist items with PlaylistItemId
+67#[tauri::command]
+68#[specta::specta]
+69pub async fn playlist_get_items(
+70    manager: State<'_, RepositoryManagerWrapper>,
+71    handle: String,
+72    playlist_id: String,
+73) -> Result<Vec<PlaylistEntry>, String> {
+74    debug!("[PLAYLIST] get_items called: id={}", playlist_id);
+75    let repo = manager.0.get(&handle).ok_or("Repository not found")?;
+76    repo.as_ref()
+77        .get_playlist_items(&playlist_id)
+78        .await
+79        .map_err(|e| format!("{:?}", e))
+80}
+81
+82/// Add items to a playlist
+83#[tauri::command]
+84#[specta::specta]
+85pub async fn playlist_add_items(
+86    manager: State<'_, RepositoryManagerWrapper>,
+87    handle: String,
+88    playlist_id: String,
+89    item_ids: Vec<String>,
+90) -> Result<(), String> {
+91    debug!(
+92        "[PLAYLIST] add_items called: id={}, count={}",
+93        playlist_id,
+94        item_ids.len()
+95    );
+96    let repo = manager.0.get(&handle).ok_or("Repository not found")?;
+97    repo.as_ref()
+98        .add_to_playlist(&playlist_id, &item_ids)
+99        .await
+100        .map_err(|e| format!("{:?}", e))
+101}
+102
+103/// Remove items from a playlist (uses PlaylistItemId entry IDs, NOT media item IDs)
+104#[tauri::command]
+105#[specta::specta]
+106pub async fn playlist_remove_items(
+107    manager: State<'_, RepositoryManagerWrapper>,
+108    handle: String,
+109    playlist_id: String,
+110    entry_ids: Vec<String>,
+111) -> Result<(), String> {
+112    debug!(
+113        "[PLAYLIST] remove_items called: id={}, count={}",
+114        playlist_id,
+115        entry_ids.len()
+116    );
+117    let repo = manager.0.get(&handle).ok_or("Repository not found")?;
+118    repo.as_ref()
+119        .remove_from_playlist(&playlist_id, &entry_ids)
+120        .await
+121        .map_err(|e| format!("{:?}", e))
+122}
+123
+124/// Move a playlist item to a new position
+125#[tauri::command]
+126#[specta::specta]
+127pub async fn playlist_move_item(
+128    manager: State<'_, RepositoryManagerWrapper>,
+129    handle: String,
+130    playlist_id: String,
+131    item_id: String,
+132    new_index: u32,
+133) -> Result<(), String> {
+134    debug!(
+135        "[PLAYLIST] move_item called: playlist={}, item={}, index={}",
+136        playlist_id, item_id, new_index
+137    );
+138    let repo = manager.0.get(&handle).ok_or("Repository not found")?;
+139    repo.as_ref()
+140        .move_playlist_item(&playlist_id, &item_id, new_index)
+141        .await
+142        .map_err(|e| format!("{:?}", e))
+143}
+
\ No newline at end of file diff --git a/api/src/jellytau_lib/commands/repository.rs.html b/api/src/jellytau_lib/commands/repository.rs.html new file mode 100644 index 00000000..7fa264c2 --- /dev/null +++ b/api/src/jellytau_lib/commands/repository.rs.html @@ -0,0 +1,1190 @@ +repository.rs - sourceSkip to main content

jellytau_lib/commands/
repository.rs

1//! Tauri commands for repository access
+2//! Uses handle-based system: UUID -> Arc<HybridRepository>
+3//!
+4//! TRACES: UR-007, UR-008, UR-023, UR-034, UR-035, UR-036 | IR-022, IR-024, JA-004, JA-005, JA-006, JA-029, JA-030, JA-031
+5
+6use crate::utils::lock::MutexSafe;
+7use std::collections::HashMap;
+8use std::sync::{Arc, Mutex};
+9
+10use log::{debug, error, info, warn};
+11use serde::{Deserialize, Serialize};
+12use tauri::{AppHandle, Emitter, State};
+13use uuid::Uuid;
+14
+15use crate::domain::rank_search_results;
+16use crate::jellyfin::HttpClient;
+17use crate::repository::{
+18    series_progress, types::*, HybridRepository, MediaRepository, OfflineRepository,
+19    OnlineRepository,
+20};
+21
+22/// Repository handle manager
+23pub struct RepositoryManager {
+24    repositories: Arc<Mutex<HashMap<String, Arc<HybridRepository>>>>,
+25}
+26
+27impl RepositoryManager {
+28    pub fn new() -> Self {
+29        Self {
+30            repositories: Arc::new(Mutex::new(HashMap::new())),
+31        }
+32    }
+33
+34    pub fn create(&self, handle: String, repository: HybridRepository) {
+35        let mut repos = self.repositories.lock_safe();
+36        repos.insert(handle, Arc::new(repository));
+37    }
+38
+39    pub fn get(&self, handle: &str) -> Option<Arc<HybridRepository>> {
+40        let repos = self.repositories.lock_safe();
+41        repos.get(handle).cloned()
+42    }
+43
+44    /// Handles of every live repository.
+45    ///
+46    /// The background catalog indexer (DR-109) runs outside any command, so it
+47    /// has no handle passed in and needs to discover one. In practice there is a
+48    /// single signed-in repository; returning all of them avoids inventing an
+49    /// "active" concept the rest of the code does not have.
+50    ///
+51    /// TRACES: UR-065 | DR-109
+52    pub fn handles(&self) -> Vec<String> {
+53        let repos = self.repositories.lock_safe();
+54        repos.keys().cloned().collect()
+55    }
+56
+57    pub fn destroy(&self, handle: &str) {
+58        let mut repos = self.repositories.lock_safe();
+59        repos.remove(handle);
+60    }
+61}
+62
+63/// Wrapper for Tauri state
+64pub struct RepositoryManagerWrapper(pub RepositoryManager);
+65
+66/// Create a new repository instance
+67/// Returns a handle (UUID) for accessing the repository
+68#[tauri::command]
+69#[specta::specta]
+70// Four of the eight arguments are Tauri `State<'_, _>` injections, not caller
+71// input. Folding the remaining four into a struct would change the IPC contract
+72// and the generated TypeScript for no readability gain.
+73#[allow(clippy::too_many_arguments)]
+74pub async fn repository_create(
+75    manager: State<'_, RepositoryManagerWrapper>,
+76    player: State<'_, crate::commands::player::PlayerStateWrapper>,
+77    db: State<'_, crate::commands::storage::DatabaseWrapper>,
+78    connectivity: State<'_, crate::commands::connectivity::ConnectivityMonitorWrapper>,
+79    server_url: String,
+80    user_id: String,
+81    access_token: String,
+82    server_id: String,
+83) -> Result<String, String> {
+84    info!("[REPO] repository_create called for user: {}", user_id);
+85
+86    // Create HTTP client for online repository
+87    debug!("[REPO] Creating HTTP client...");
+88    let http_config = crate::jellyfin::HttpConfig::default();
+89    let http_client = HttpClient::new(http_config).map_err(|e| {
+90        error!("[REPO] HTTP client creation failed: {}", e);
+91        e.to_string()
+92    })?;
+93    debug!("[REPO] HTTP client created successfully");
+94
+95    // Grab a connectivity reporter so the online repository's server outcomes
+96    // drive the reachability state the UI observes (source of truth for the
+97    // offline/online banner). See docs/architecture/07-connectivity.md.
+98    let connectivity_reporter = {
+99        let monitor = connectivity.0.lock().await;
+100        monitor.reporter()
+101    };
+102
+103    // Create online repository wired to connectivity reporting
+104    debug!("[REPO] Creating online repository...");
+105    let online = OnlineRepository::new(
+106        Arc::new(http_client),
+107        server_url,
+108        user_id.clone(),
+109        access_token,
+110    )
+111    .with_connectivity(connectivity_reporter);
+112    debug!("[REPO] Online repository created");
+113
+114    // Create offline repository with async-safe database service
+115    debug!("[REPO] Creating database service...");
+116    let db_service = {
+117        let database = db.0.lock().map_err(|e| {
+118            error!("[REPO] Database lock failed: {}", e);
+119            e.to_string()
+120        })?;
+121        debug!("[REPO] Database lock acquired, getting service...");
+122        Arc::new(database.service())
+123    }; // Lock is released here
+124    debug!("[REPO] Database service created");
+125
+126    debug!("[REPO] Creating offline repository...");
+127    let offline = OfflineRepository::new(db_service, server_id, user_id);
+128    debug!("[REPO] Offline repository created");
+129
+130    // Create hybrid repository
+131    debug!("[REPO] Creating hybrid repository...");
+132    let hybrid = HybridRepository::new(online, offline);
+133    debug!("[REPO] Hybrid repository created");
+134
+135    // Generate handle and store repository
+136    let uuid = Uuid::new_v4();
+137    let handle = format!("{}", uuid);
+138    info!("[REPO] Generated handle: {}", handle);
+139
+140    // Store repository synchronously
+141    debug!("[REPO] Storing repository...");
+142    manager.0.create(handle.clone(), hybrid);
+143    info!("[REPO] Repository stored successfully");
+144
+145    // Give the player controller a repository for next-episode lookups. The
+146    // Android playback-ended callback has no repository handle, so without
+147    // this the episode autoplay countdown never triggers there.
+148    if let Some(repo) = manager.0.get(&handle) {
+149        let controller = player.0.lock().await;
+150        controller.set_repository(repo);
+151    }
+152
+153    Ok(handle)
+154}
+155
+156/// Destroy a repository instance
+157#[tauri::command]
+158#[specta::specta]
+159pub async fn repository_destroy(
+160    manager: State<'_, RepositoryManagerWrapper>,
+161    handle: String,
+162) -> Result<(), String> {
+163    manager.0.destroy(&handle);
+164    Ok(())
+165}
+166
+167/// Get libraries
+168#[tauri::command]
+169#[specta::specta]
+170pub async fn repository_get_libraries(
+171    manager: State<'_, RepositoryManagerWrapper>,
+172    handle: String,
+173) -> Result<Vec<Library>, String> {
+174    debug!("[REPO] get_libraries called with handle: {}", handle);
+175    let repo = manager.0.get(&handle).ok_or_else(|| {
+176        error!("[REPO] Repository not found for handle: {}", handle);
+177        "Repository not found".to_string()
+178    })?;
+179    debug!("[REPO] Repository found, fetching libraries...");
+180    repo.as_ref().get_libraries().await.map_err(|e| {
+181        error!("[REPO] Error fetching libraries: {:?}", e);
+182        format!("{:?}", e)
+183    })
+184}
+185
+186/// Get items in a container (library, folder, album, etc.)
+187#[tauri::command]
+188#[specta::specta]
+189pub async fn repository_get_items(
+190    manager: State<'_, RepositoryManagerWrapper>,
+191    handle: String,
+192    parent_id: String,
+193    options: Option<GetItemsOptions>,
+194) -> Result<SearchResult, String> {
+195    let repo = manager.0.get(&handle).ok_or("Repository not found")?;
+196    repo.as_ref()
+197        .get_items(&parent_id, options)
+198        .await
+199        .map_err(|e| format!("{:?}", e))
+200}
+201
+202/// Get a single item by ID
+203#[tauri::command]
+204#[specta::specta]
+205pub async fn repository_get_item(
+206    manager: State<'_, RepositoryManagerWrapper>,
+207    handle: String,
+208    item_id: String,
+209) -> Result<MediaItem, String> {
+210    let repo = manager.0.get(&handle).ok_or("Repository not found")?;
+211    repo.as_ref()
+212        .get_item(&item_id)
+213        .await
+214        .map_err(|e| format!("{:?}", e))
+215}
+216
+217/// Downloaded-only browse: libraries that contain downloaded content.
+218///
+219/// Backs the Downloads "Downloaded" surface. Never merges server results and is
+220/// authoritative — an empty list means nothing is downloaded.
+221///
+222/// TRACES: UR-055 | DR-082
+223#[tauri::command]
+224#[specta::specta]
+225pub async fn repository_get_downloaded_libraries(
+226    manager: State<'_, RepositoryManagerWrapper>,
+227    handle: String,
+228) -> Result<Vec<Library>, String> {
+229    let repo = manager.0.get(&handle).ok_or("Repository not found")?;
+230    repo.get_downloaded_libraries()
+231        .await
+232        .map_err(|e| format!("{:?}", e))
+233}
+234
+235/// Downloaded-only browse: items under a container that are on the device.
+236///
+237/// TRACES: UR-055 | DR-082, DR-083
+238#[tauri::command]
+239#[specta::specta]
+240pub async fn repository_get_downloaded_items(
+241    manager: State<'_, RepositoryManagerWrapper>,
+242    handle: String,
+243    parent_id: String,
+244    options: Option<GetItemsOptions>,
+245) -> Result<SearchResult, String> {
+246    let repo = manager.0.get(&handle).ok_or("Repository not found")?;
+247    repo.get_downloaded_items(&parent_id, options)
+248        .await
+249        .map_err(|e| format!("{:?}", e))
+250}
+251
+252/// On-disk usage of downloaded content (device total, per-item/container bytes).
+253///
+254/// TRACES: UR-056 | DR-085
+255#[tauri::command]
+256#[specta::specta]
+257pub async fn repository_get_download_disk_usage(
+258    manager: State<'_, RepositoryManagerWrapper>,
+259    handle: String,
+260) -> Result<DownloadDiskUsage, String> {
+261    let repo = manager.0.get(&handle).ok_or("Repository not found")?;
+262    repo.get_download_disk_usage()
+263        .await
+264        .map_err(|e| format!("{:?}", e))
+265}
+266
+267/// Query the optional JRay plugin for the actors on screen at time `t`
+268/// (seconds) in an item. Returns an empty list when JRay isn't installed or
+269/// has no data for the item, so the caller can render nothing without error.
+270#[tauri::command]
+271#[specta::specta]
+272pub async fn repository_jray_actors_at(
+273    manager: State<'_, RepositoryManagerWrapper>,
+274    handle: String,
+275    item_id: String,
+276    t: f64,
+277) -> Result<Vec<crate::repository::JRayActor>, String> {
+278    let repo = manager.0.get(&handle).ok_or("Repository not found")?;
+279    repo.as_ref()
+280        .get_jray_actors(&item_id, t)
+281        .await
+282        .map_err(|e| format!("{:?}", e))
+283}
+284
+285/// Get latest items in a library
+286#[tauri::command]
+287#[specta::specta]
+288pub async fn repository_get_latest_items(
+289    manager: State<'_, RepositoryManagerWrapper>,
+290    handle: String,
+291    parent_id: String,
+292    limit: Option<usize>,
+293) -> Result<Vec<MediaItem>, String> {
+294    let repo = manager.0.get(&handle).ok_or("Repository not found")?;
+295    repo.as_ref()
+296        .get_latest_items(&parent_id, limit)
+297        .await
+298        .map_err(|e| format!("{:?}", e))
+299}
+300
+301/// Get resume items (continue watching/listening).
+302///
+303/// The home screen's Continue Watching row and every library's "pick up where
+304/// you left off" hero come through here; each item carries its own resume
+305/// position in `UserData`.
+306///
+307/// TRACES: UR-019, UR-023, UR-034 | IR-024, JA-013, JA-015 | DR-026, DR-038
+308#[tauri::command]
+309#[specta::specta]
+310pub async fn repository_get_resume_items(
+311    manager: State<'_, RepositoryManagerWrapper>,
+312    handle: String,
+313    parent_id: Option<String>,
+314    limit: Option<usize>,
+315) -> Result<Vec<MediaItem>, String> {
+316    debug!("[REPO] get_resume_items called with handle: {}", handle);
+317    let repo = manager.0.get(&handle).ok_or_else(|| {
+318        error!("[REPO] Repository not found for handle: {}", handle);
+319        "Repository not found".to_string()
+320    })?;
+321    debug!("[REPO] Repository found, fetching resume items...");
+322    repo.as_ref()
+323        .get_resume_items(parent_id.as_deref(), limit)
+324        .await
+325        .map_err(|e| {
+326            error!("[REPO] Error fetching resume items: {:?}", e);
+327            format!("{:?}", e)
+328        })
+329}
+330
+331/// Get next up episodes.
+332///
+333/// TRACES: UR-023, UR-034 | IR-024, JA-014 | DR-026
+334#[tauri::command]
+335#[specta::specta]
+336pub async fn repository_get_next_up_episodes(
+337    manager: State<'_, RepositoryManagerWrapper>,
+338    handle: String,
+339    series_id: Option<String>,
+340    limit: Option<usize>,
+341) -> Result<Vec<MediaItem>, String> {
+342    let repo = manager.0.get(&handle).ok_or("Repository not found")?;
+343    repo.as_ref()
+344        .get_next_up_episodes(series_id.as_deref(), limit)
+345        .await
+346        .map_err(|e| format!("{:?}", e))
+347}
+348
+349/// Every episode of a series, across all seasons, in series order.
+350///
+351/// Jellyfin hangs episodes off season folders — except for "flat" series whose
+352/// children are episodes directly. Both shapes are provider vocabulary, so the
+353/// fan-out and its fallback live in Rust rather than being reimplemented in the
+354/// frontend (which is what it used to do).
+355///
+356/// TRACES: UR-062 | DR-101
+357#[tauri::command]
+358#[specta::specta]
+359pub async fn repository_get_series_episodes(
+360    manager: State<'_, RepositoryManagerWrapper>,
+361    handle: String,
+362    series_id: String,
+363) -> Result<Vec<MediaItem>, String> {
+364    let repo = manager.0.get(&handle).ok_or("Repository not found")?;
+365    series_progress::fetch_series_episodes(repo.as_ref(), &series_id)
+366        .await
+367        .map_err(|e| format!("{:?}", e))
+368}
+369
+370/// The episode a viewer should land on when they open a series.
+371///
+372/// "Current" is domain policy, not layout: an episode in progress, else the
+373/// server's Next Up for the series, else the first unwatched episode, else the
+374/// first. The third rung is what makes this work offline, where Next Up is
+375/// always empty. Returns `None` only when the series has no episodes at all.
+376///
+377/// TRACES: UR-062 | DR-101
+378#[tauri::command]
+379#[specta::specta]
+380pub async fn repository_get_series_current_episode(
+381    manager: State<'_, RepositoryManagerWrapper>,
+382    handle: String,
+383    series_id: String,
+384) -> Result<Option<MediaItem>, String> {
+385    let repo = manager.0.get(&handle).ok_or("Repository not found")?;
+386    series_progress::resolve_current_episode(repo.as_ref(), &series_id)
+387        .await
+388        .map_err(|e| format!("{:?}", e))
+389}
+390
+391/// Erase the viewer's watch history for an item.
+392///
+393/// Clears the played flag and the resume position; on a series or season the
+394/// server applies it to everything inside. A series cleared this way is "never
+395/// watched" again, so `repository_get_series_current_episode` returns its
+396/// premiere. Requires the server — offline this fails rather than diverging
+397/// local state the next sync would overwrite.
+398///
+399/// TRACES: UR-064 | DR-106
+400#[tauri::command]
+401#[specta::specta]
+402pub async fn repository_clear_watch_history(
+403    manager: State<'_, RepositoryManagerWrapper>,
+404    handle: String,
+405    item_id: String,
+406) -> Result<(), String> {
+407    let repo = manager.0.get(&handle).ok_or("Repository not found")?;
+408    repo.as_ref()
+409        .clear_watch_history(&item_id)
+410        .await
+411        .map_err(|e| format!("{:?}", e))
+412}
+413
+414/// Get recently played audio
+415#[tauri::command]
+416#[specta::specta]
+417pub async fn repository_get_recently_played_audio(
+418    manager: State<'_, RepositoryManagerWrapper>,
+419    handle: String,
+420    limit: Option<usize>,
+421) -> Result<Vec<MediaItem>, String> {
+422    let repo = manager.0.get(&handle).ok_or("Repository not found")?;
+423    repo.as_ref()
+424        .get_recently_played_audio(limit)
+425        .await
+426        .map_err(|e| format!("{:?}", e))
+427}
+428
+429/// Get resume movies
+430#[tauri::command]
+431#[specta::specta]
+432pub async fn repository_get_resume_movies(
+433    manager: State<'_, RepositoryManagerWrapper>,
+434    handle: String,
+435    limit: Option<usize>,
+436) -> Result<Vec<MediaItem>, String> {
+437    let repo = manager.0.get(&handle).ok_or("Repository not found")?;
+438    repo.as_ref()
+439        .get_resume_movies(limit)
+440        .await
+441        .map_err(|e| format!("{:?}", e))
+442}
+443
+444/// Get albums the user hasn't listened to recently ("rediscover")
+445#[tauri::command]
+446#[specta::specta]
+447pub async fn repository_get_rediscover_albums(
+448    manager: State<'_, RepositoryManagerWrapper>,
+449    handle: String,
+450    parent_id: Option<String>,
+451    limit: Option<usize>,
+452) -> Result<Vec<MediaItem>, String> {
+453    let repo = manager.0.get(&handle).ok_or("Repository not found")?;
+454    repo.as_ref()
+455        .get_rediscover_albums(parent_id.as_deref(), limit)
+456        .await
+457        .map_err(|e| format!("{:?}", e))
+458}
+459
+460/// Get genres for a library
+461#[tauri::command]
+462#[specta::specta]
+463pub async fn repository_get_genres(
+464    manager: State<'_, RepositoryManagerWrapper>,
+465    handle: String,
+466    parent_id: Option<String>,
+467) -> Result<Vec<Genre>, String> {
+468    let repo = manager.0.get(&handle).ok_or("Repository not found")?;
+469    repo.as_ref()
+470        .get_genres(parent_id.as_deref())
+471        .await
+472        .map_err(|e| format!("{:?}", e))
+473}
+474
+475/// Tauri event name carrying the merged (cache + server) search results.
+476pub const SEARCH_EVENT_NAME: &str = "search-event";
+477
+478/// Payload for the deferred, merged search results pushed to the frontend.
+479///
+480/// `request_id` matches the value the frontend passed to `repository_search`,
+481/// letting it discard updates from queries that have since been superseded.
+482#[derive(specta::Type, Debug, Clone, Serialize, Deserialize)]
+483#[serde(rename_all = "camelCase")]
+484pub struct SearchUpdateEvent {
+485    pub request_id: u32,
+486    pub result: SearchResult,
+487}
+488
+489/// Search for items.
+490///
+491/// Resolves `SearchOptions::scope` into concrete Jellyfin item types before
+492/// dispatching, so scope taxonomy stays in Rust.
+493///
+494/// TRACES: UR-049, UR-050 | DR-063
+495#[tauri::command]
+496#[specta::specta]
+497pub async fn repository_search(
+498    app: AppHandle,
+499    manager: State<'_, RepositoryManagerWrapper>,
+500    handle: String,
+501    query: String,
+502    options: Option<SearchOptions>,
+503    request_id: u32,
+504) -> Result<SearchResult, String> {
+505    let repo = manager.0.get(&handle).ok_or("Repository not found")?;
+506
+507    // Expand the opaque scope into item types HERE — once, before the cache and
+508    // server paths diverge — so both phases filter identically. Doing it later
+509    // (or in only one path) makes offline results disagree with online ones.
+510    // The frontend sends `scope` and never names a Jellyfin item type for
+511    // search; see docs/specs/scoped-search-boundary.md.
+512    let options = options.map(|mut o| {
+513        o.resolve_scope();
+514        o
+515    });
+516
+517    // Phase 1: instant local results from the cache (downloaded content) so the
+518    // UI can render immediately while the server is still being queried.
+519    let mut cache_result = repo
+520        .search_cache_only(&query, options.clone())
+521        .await
+522        .unwrap_or_else(|e| {
+523            debug!("[Search] Cache search miss/timeout: {:?}", e);
+524            SearchResult {
+525                items: Vec::new(),
+526                total_record_count: 0,
+527            }
+528        });
+529
+530    // Neither backend orders by *where* the query matched, so a mid-word hit
+531    // ("Sparks" for "parks") can outrank a prefix hit ("Parks and Recreation").
+532    // Both phases are ranked with the same rules so the list does not reshuffle
+533    // when the server results land.
+534    rank_search_results(&mut cache_result.items, &query);
+535
+536    // Phase 2: query the live server in the background, merge with the cache,
+537    // and push the union to the frontend via a `search-event`. Tagged with
+538    // `request_id` so the frontend can discard results from superseded queries.
+539    let repo_bg = repo.clone();
+540    let cache_for_merge = cache_result.clone();
+541    tauri::async_runtime::spawn(async move {
+542        match repo_bg.search_server_only(&query, options).await {
+543            Ok(server_result) => {
+544                let mut merged =
+545                    HybridRepository::merge_search_results(cache_for_merge, server_result);
+546                // Rank the union, not each half: a server-only prefix match must
+547                // be able to outrank a cached mid-word one.
+548                rank_search_results(&mut merged.items, &query);
+549                let event = SearchUpdateEvent {
+550                    request_id,
+551                    result: merged,
+552                };
+553                if let Err(e) = app.emit(SEARCH_EVENT_NAME, &event) {
+554                    error!("[Search] Failed to emit search update: {}", e);
+555                }
+556            }
+557            Err(e) => {
+558                // Server failed — the cache results are already on screen, so
+559                // just log. (Offline / unreachable server falls here.)
+560                warn!(
+561                    "[Search] Server search failed, keeping cache results: {:?}",
+562                    e
+563                );
+564            }
+565        }
+566    });
+567
+568    Ok(cache_result)
+569}
+570
+571/// Get playback info for an item
+572#[tauri::command]
+573#[specta::specta]
+574pub async fn repository_get_playback_info(
+575    manager: State<'_, RepositoryManagerWrapper>,
+576    handle: String,
+577    item_id: String,
+578) -> Result<PlaybackInfo, String> {
+579    let repo = manager.0.get(&handle).ok_or("Repository not found")?;
+580    repo.as_ref()
+581        .get_playback_info(&item_id)
+582        .await
+583        .map_err(|e| format!("{:?}", e))
+584}
+585
+586/// Get a video stream URL.
+587///
+588/// There is no start-position parameter on purpose: the URL is an HLS playlist
+589/// covering the whole item, and a position on it makes the server reject every
+590/// segment with `400` (DR-181). Callers resume by seeking after load.
+591///
+592/// TRACES: UR-004 | DR-181 | UT-182
+593#[tauri::command]
+594#[specta::specta]
+595pub async fn repository_get_video_stream_url(
+596    manager: State<'_, RepositoryManagerWrapper>,
+597    handle: String,
+598    item_id: String,
+599    media_source_id: Option<String>,
+600    audio_stream_index: Option<i32>,
+601) -> Result<String, String> {
+602    let repo = manager.0.get(&handle).ok_or("Repository not found")?;
+603    repo.as_ref()
+604        .get_video_stream_url(&item_id, media_source_id.as_deref(), audio_stream_index)
+605        .await
+606        .map_err(|e| format!("{:?}", e))
+607}
+608
+609/// Get an audio-only stream URL for a *video* item (background-audio handoff).
+610///
+611/// TRACES: UR-040 | JA-032 | UT-061
+612#[tauri::command]
+613#[specta::specta]
+614pub async fn repository_get_audio_only_stream_url_for_video(
+615    manager: State<'_, RepositoryManagerWrapper>,
+616    handle: String,
+617    item_id: String,
+618    media_source_id: Option<String>,
+619    start_time_seconds: Option<f64>,
+620    audio_stream_index: Option<i32>,
+621) -> Result<String, String> {
+622    let repo = manager.0.get(&handle).ok_or("Repository not found")?;
+623    repo.as_ref()
+624        .get_audio_only_stream_url_for_video(
+625            &item_id,
+626            media_source_id.as_deref(),
+627            start_time_seconds,
+628            audio_stream_index,
+629        )
+630        .await
+631        .map_err(|e| format!("{:?}", e))
+632}
+633
+634/// Get audio stream URL for a track
+635#[tauri::command]
+636#[specta::specta]
+637pub async fn repository_get_audio_stream_url(
+638    manager: State<'_, RepositoryManagerWrapper>,
+639    handle: String,
+640    item_id: String,
+641) -> Result<String, String> {
+642    let repo = manager.0.get(&handle).ok_or("Repository not found")?;
+643    repo.as_ref()
+644        .get_audio_stream_url(&item_id)
+645        .await
+646        .map_err(|e| format!("{:?}", e))
+647}
+648
+649/// Get Live TV channels (broadcast / IPTV) for browsing
+650#[tauri::command]
+651#[specta::specta]
+652pub async fn repository_get_live_tv_channels(
+653    manager: State<'_, RepositoryManagerWrapper>,
+654    handle: String,
+655) -> Result<Vec<MediaItem>, String> {
+656    let repo = manager.0.get(&handle).ok_or("Repository not found")?;
+657    repo.as_ref()
+658        .get_live_tv_channels()
+659        .await
+660        .map_err(|e| format!("{:?}", e))
+661}
+662
+663/// Get the root list of plugin "Channels"
+664#[tauri::command]
+665#[specta::specta]
+666pub async fn repository_get_channels(
+667    manager: State<'_, RepositoryManagerWrapper>,
+668    handle: String,
+669) -> Result<SearchResult, String> {
+670    let repo = manager.0.get(&handle).ok_or("Repository not found")?;
+671    repo.as_ref()
+672        .get_channels()
+673        .await
+674        .map_err(|e| format!("{:?}", e))
+675}
+676
+677/// Open a live stream for a Live TV channel / live item
+678#[tauri::command]
+679#[specta::specta]
+680pub async fn repository_open_live_stream(
+681    manager: State<'_, RepositoryManagerWrapper>,
+682    handle: String,
+683    item_id: String,
+684) -> Result<LiveStreamInfo, String> {
+685    let repo = manager.0.get(&handle).ok_or("Repository not found")?;
+686    repo.as_ref()
+687        .open_live_stream(&item_id)
+688        .await
+689        .map_err(|e| format!("{:?}", e))
+690}
+691
+692/// Report playback start
+693#[tauri::command]
+694#[specta::specta]
+695pub async fn repository_report_playback_start(
+696    manager: State<'_, RepositoryManagerWrapper>,
+697    handle: String,
+698    item_id: String,
+699    position_ms: i64,
+700) -> Result<(), String> {
+701    let position_ticks = position_ms * 10_000;
+702    let repo = manager.0.get(&handle).ok_or("Repository not found")?;
+703    repo.as_ref()
+704        .report_playback_start(&item_id, position_ticks)
+705        .await
+706        .map_err(|e| format!("{:?}", e))
+707}
+708
+709/// Report playback progress
+710#[tauri::command]
+711#[specta::specta]
+712pub async fn repository_report_playback_progress(
+713    manager: State<'_, RepositoryManagerWrapper>,
+714    handle: String,
+715    item_id: String,
+716    position_ms: i64,
+717) -> Result<(), String> {
+718    let position_ticks = position_ms * 10_000;
+719    let repo = manager.0.get(&handle).ok_or("Repository not found")?;
+720    repo.as_ref()
+721        .report_playback_progress(&item_id, position_ticks)
+722        .await
+723        .map_err(|e| format!("{:?}", e))
+724}
+725
+726/// Report playback stopped
+727///
+728/// A stop-report that cannot reach the server is queued rather than dropped:
+729/// this is the position the resume point is built from, and losing it is
+730/// exactly the "it forgot where I was" the sync queue exists to prevent. The
+731/// drain (DR-131) pushes it on the next reconnect. Queueing is best-effort —
+732/// failing the command because the *queue* write failed would tell the caller
+733/// the report was lost when the local position was already saved.
+734///
+735/// TRACES: UR-025 | DR-154 | UT-151
+736#[tauri::command]
+737#[specta::specta]
+738pub async fn repository_report_playback_stopped(
+739    db: State<'_, crate::commands::storage::DatabaseWrapper>,
+740    manager: State<'_, RepositoryManagerWrapper>,
+741    handle: String,
+742    item_id: String,
+743    position_ms: i64,
+744) -> Result<(), String> {
+745    // Milliseconds across the boundary; the Jellyfin API wants ticks.
+746    let position_ticks = position_ms * 10_000;
+747    let repo = manager.0.get(&handle).ok_or("Repository not found")?;
+748
+749    let result = repo
+750        .as_ref()
+751        .report_playback_stopped(&item_id, position_ticks)
+752        .await;
+753
+754    if let Err(e) = &result {
+755        let db_service = {
+756            let database = db.0.lock().map_err(|err| err.to_string())?;
+757            Arc::new(database.service())
+758        };
+759        let user_id = repo.user_id().to_string();
+760        if let Err(queue_err) = crate::commands::sync_drain::enqueue_playback_stopped(
+761            &db_service,
+762            &user_id,
+763            &item_id,
+764            position_ticks,
+765        )
+766        .await
+767        {
+768            warn!(
+769                "[Repository] Stop-report for {} failed ({:?}) and could not be queued: {}",
+770                item_id, e, queue_err
+771            );
+772        } else {
+773            debug!(
+774                "[Repository] Stop-report for {} failed ({:?}); queued for the next reconnect",
+775                item_id, e
+776            );
+777        }
+778    }
+779
+780    result.map_err(|e| format!("{:?}", e))
+781}
+782
+783/// Get image URL for an item
+784#[tauri::command]
+785#[specta::specta]
+786pub fn repository_get_image_url(
+787    manager: State<'_, RepositoryManagerWrapper>,
+788    handle: String,
+789    item_id: String,
+790    image_type: ImageType,
+791    options: Option<ImageOptions>,
+792) -> Result<String, String> {
+793    let repo = manager.0.get(&handle).ok_or("Repository not found")?;
+794    Ok(repo.as_ref().get_image_url(&item_id, image_type, options))
+795}
+796
+797/// Get subtitle URL for a media item
+798#[tauri::command]
+799#[specta::specta]
+800#[allow(dead_code)]
+801pub fn repository_get_subtitle_url(
+802    manager: State<'_, RepositoryManagerWrapper>,
+803    handle: String,
+804    item_id: String,
+805    media_source_id: String,
+806    stream_index: i32,
+807    format: String,
+808) -> Result<String, String> {
+809    let repo = manager.0.get(&handle).ok_or("Repository not found")?;
+810    Ok(repo
+811        .as_ref()
+812        .get_subtitle_url(&item_id, &media_source_id, stream_index, &format))
+813}
+814
+815/// Get video download URL with quality preset
+816#[tauri::command]
+817#[specta::specta]
+818#[allow(dead_code)]
+819pub async fn repository_get_video_download_url(
+820    manager: State<'_, RepositoryManagerWrapper>,
+821    handle: String,
+822    item_id: String,
+823    quality: String,
+824    media_source_id: Option<String>,
+825) -> Result<String, String> {
+826    let repo = manager.0.get(&handle).ok_or("Repository not found")?;
+827    // Async because the audio-codec policy has to know what the source's audio
+828    // is before it can decide whether the file may be copied verbatim (DR-171).
+829    // The frontend calls this exactly as before — the decision stays in Rust.
+830    Ok(crate::repository::resolve_video_download_url(
+831        repo.as_ref(),
+832        &item_id,
+833        &quality,
+834        media_source_id.as_deref(),
+835    )
+836    .await)
+837}
+838
+839/// Mark an item as favorite
+840#[tauri::command]
+841#[specta::specta]
+842pub async fn repository_mark_favorite(
+843    manager: State<'_, RepositoryManagerWrapper>,
+844    handle: String,
+845    item_id: String,
+846) -> Result<(), String> {
+847    let repo = manager.0.get(&handle).ok_or("Repository not found")?;
+848    repo.as_ref()
+849        .mark_favorite(&item_id)
+850        .await
+851        .map_err(|e| format!("{:?}", e))
+852}
+853
+854/// Tauri event announcing that favourite state changed behind the UI's back —
+855/// either because the server disagreed with the cache on a background refresh,
+856/// or because pending offline toggles were pushed on reconnect.
+857///
+858/// TRACES: UR-069 | DR-120
+859pub const FAVORITES_CHANGED_EVENT: &str = "favorites-changed";
+860
+861/// Payload for [`FAVORITES_CHANGED_EVENT`] — the ids whose favourite state
+862/// actually flipped, so the frontend refreshes those rather than everything.
+863///
+864/// TRACES: UR-069 | DR-120 | UT-107
+865#[derive(specta::Type, Debug, Clone, Serialize, Deserialize)]
+866#[serde(rename_all = "camelCase")]
+867pub struct FavoritesChangedEvent {
+868    pub item_ids: Vec<String>,
+869}
+870
+871/// Ids whose favourite state differs between what we showed and what the server
+872/// has — favourited elsewhere since the cache was written, or un-favourited
+873/// elsewhere.
+874///
+875/// Pulled out of the command so the "emit nothing when nothing changed" rule is
+876/// testable: an unchanged set must leave a quiet page quiet rather than
+877/// triggering a refetch on every visit.
+878///
+879/// TRACES: UR-069 | DR-120 | UT-107
+880fn changed_favorite_ids(
+881    cached: &std::collections::HashSet<String>,
+882    server: &std::collections::HashSet<String>,
+883) -> Vec<String> {
+884    let mut changed: Vec<String> = server.symmetric_difference(cached).cloned().collect();
+885    // Deterministic order so the event payload does not depend on hash seeding.
+886    changed.sort();
+887    changed
+888}
+889
+890/// Everything the viewer has favourited, across libraries, narrowed by scope.
+891///
+892/// Two-phase like `repository_search`: the local answer returns immediately and
+893/// a background server pass emits `favorites-changed` when the server's set
+894/// differs. Without the second phase a favourite marked in another client shows
+895/// up only on the *second* visit to the page, since the cache-first read hands
+896/// back local rows and the refresh is invisible to the frontend.
+897///
+898/// TRACES: UR-067 | DR-115, DR-120, JA-033 | UT-107
+899#[tauri::command]
+900#[specta::specta]
+901pub async fn repository_get_favorites(
+902    app: AppHandle,
+903    manager: State<'_, RepositoryManagerWrapper>,
+904    handle: String,
+905    scope: SearchScope,
+906    options: Option<GetItemsOptions>,
+907) -> Result<SearchResult, String> {
+908    let repo = manager.0.get(&handle).ok_or("Repository not found")?;
+909
+910    let cache_result = repo
+911        .get_favorites_cache_only(scope, options.clone())
+912        .await
+913        .unwrap_or_else(|e| {
+914            debug!("[Favorites] Cache miss/timeout: {:?}", e);
+915            SearchResult {
+916                items: Vec::new(),
+917                total_record_count: 0,
+918            }
+919        });
+920
+921    // With "Show all server media" off the local answer is authoritative
+922    // (DR-080) — don't go behind the user's back to the server.
+923    if !crate::repository::offline::include_catalog_browse() {
+924        return Ok(cache_result);
+925    }
+926
+927    // Nothing cached yet — a fresh install, or a viewer whose favourites were
+928    // all marked on another client. Returning the empty result here paints
+929    // "Nothing favourited yet — tap the heart on anything you like", which is a
+930    // *wrong* answer, corrected a server round trip later when the background
+931    // refresh fires `favorites-changed`. Ask the repository for a real answer
+932    // instead: its `get_favorites` is exactly this read — cache first, server on
+933    // a miss, saving through — and it applies the same DR-080 gate.
+934    //
+935    // TRACES: UR-067 | DR-115
+936    if !cache_result.has_content() {
+937        debug!("[Favorites] Nothing cached; answering from the server");
+938        return repo
+939            .get_favorites(scope, options)
+940            .await
+941            .map_err(|e| format!("{:?}", e));
+942    }
+943
+944    let repo_bg = repo.clone();
+945    let cached_ids: std::collections::HashSet<String> =
+946        cache_result.items.iter().map(|i| i.id.clone()).collect();
+947    tauri::async_runtime::spawn(async move {
+948        match repo_bg.get_favorites_server_only(scope, options).await {
+949            Ok(server_result) => {
+950                let server_ids: std::collections::HashSet<String> =
+951                    server_result.items.iter().map(|i| i.id.clone()).collect();
+952                let changed = changed_favorite_ids(&cached_ids, &server_ids);
+953
+954                if !changed.is_empty() {
+955                    let event = FavoritesChangedEvent { item_ids: changed };
+956                    if let Err(e) = app.emit(FAVORITES_CHANGED_EVENT, &event) {
+957                        error!("[Favorites] Failed to emit change event: {}", e);
+958                    }
+959                }
+960            }
+961            Err(e) => {
+962                warn!(
+963                    "[Favorites] Server refresh failed, keeping cached favourites: {:?}",
+964                    e
+965                );
+966            }
+967        }
+968    });
+969
+970    Ok(cache_result)
+971}
+972
+973/// Unmark an item as favorite
+974#[tauri::command]
+975#[specta::specta]
+976pub async fn repository_unmark_favorite(
+977    manager: State<'_, RepositoryManagerWrapper>,
+978    handle: String,
+979    item_id: String,
+980) -> Result<(), String> {
+981    let repo = manager.0.get(&handle).ok_or("Repository not found")?;
+982    repo.as_ref()
+983        .unmark_favorite(&item_id)
+984        .await
+985        .map_err(|e| format!("{:?}", e))
+986}
+987
+988/// Get person details
+989#[tauri::command]
+990#[specta::specta]
+991pub async fn repository_get_person(
+992    manager: State<'_, RepositoryManagerWrapper>,
+993    handle: String,
+994    person_id: String,
+995) -> Result<MediaItem, String> {
+996    let repo = manager.0.get(&handle).ok_or("Repository not found")?;
+997    repo.as_ref()
+998        .get_person(&person_id)
+999        .await
+1000        .map_err(|e| format!("{:?}", e))
+1001}
+1002
+1003/// Get items by person (actor, director, etc.)
+1004#[tauri::command]
+1005#[specta::specta]
+1006pub async fn repository_get_items_by_person(
+1007    manager: State<'_, RepositoryManagerWrapper>,
+1008    handle: String,
+1009    person_id: String,
+1010    options: Option<GetItemsOptions>,
+1011) -> Result<SearchResult, String> {
+1012    let repo = manager.0.get(&handle).ok_or("Repository not found")?;
+1013    repo.as_ref()
+1014        .get_items_by_person(&person_id, options)
+1015        .await
+1016        .map_err(|e| format!("{:?}", e))
+1017}
+1018
+1019/// Get similar/related items for a media item
+1020#[tauri::command]
+1021#[specta::specta]
+1022pub async fn repository_get_similar_items(
+1023    manager: State<'_, RepositoryManagerWrapper>,
+1024    handle: String,
+1025    item_id: String,
+1026    limit: Option<usize>,
+1027) -> Result<SearchResult, String> {
+1028    let repo = manager.0.get(&handle).ok_or("Repository not found")?;
+1029    repo.as_ref()
+1030        .get_similar_items(&item_id, limit)
+1031        .await
+1032        .map_err(|e| format!("{:?}", e))
+1033}
+1034
+1035#[cfg(test)]
+1036mod tests {
+1037    use super::*;
+1038
+1039    #[test]
+1040    fn test_repository_manager_creation() {
+1041        let manager = RepositoryManager::new();
+1042        // A freshly created manager holds no repositories
+1043        assert!(manager.get("any-handle").is_none());
+1044    }
+1045
+1046    fn ids(values: &[&str]) -> std::collections::HashSet<String> {
+1047        values.iter().map(|v| v.to_string()).collect()
+1048    }
+1049
+1050    /// UT-107 — the background refresh reports only what actually changed.
+1051    ///
+1052    /// TRACES: UR-069 | DR-120 | UT-107
+1053    #[test]
+1054    fn test_changed_favorite_ids_reports_both_directions() {
+1055        // Favourited in another client since we cached.
+1056        assert_eq!(
+1057            changed_favorite_ids(&ids(&["a"]), &ids(&["a", "b"])),
+1058            vec!["b".to_string()]
+1059        );
+1060
+1061        // Un-favourited in another client.
+1062        assert_eq!(
+1063            changed_favorite_ids(&ids(&["a", "b"]), &ids(&["a"])),
+1064            vec!["b".to_string()]
+1065        );
+1066
+1067        // Both at once, in a stable order.
+1068        assert_eq!(
+1069            changed_favorite_ids(&ids(&["a", "b"]), &ids(&["b", "c"])),
+1070            vec!["a".to_string(), "c".to_string()]
+1071        );
+1072    }
+1073
+1074    /// An unchanged set emits nothing — otherwise every visit to the page would
+1075    /// fire an event and trigger a pointless refetch.
+1076    ///
+1077    /// TRACES: UR-069 | DR-120 | UT-107
+1078    #[test]
+1079    fn test_changed_favorite_ids_is_empty_when_nothing_moved() {
+1080        assert!(changed_favorite_ids(&ids(&["a", "b"]), &ids(&["b", "a"])).is_empty());
+1081        assert!(changed_favorite_ids(&ids(&[]), &ids(&[])).is_empty());
+1082    }
+1083
+1084    #[test]
+1085    fn test_repository_manager_wrapper_structure() {
+1086        let manager = RepositoryManager::new();
+1087        let wrapper = RepositoryManagerWrapper(manager);
+1088        // The wrapper exposes the underlying manager, which starts empty
+1089        assert!(wrapper.0.get("any-handle").is_none());
+1090    }
+1091
+1092    #[test]
+1093    fn test_repository_manager_get_nonexistent() {
+1094        let manager = RepositoryManager::new();
+1095        // Getting a non-existent repository should return None
+1096        let result = manager.get("nonexistent-handle");
+1097        assert!(result.is_none());
+1098    }
+1099
+1100    #[test]
+1101    fn test_uuid_handle_generation() {
+1102        let uuid = Uuid::new_v4();
+1103        let handle = format!("{}", uuid);
+1104        // UUID should convert to a non-empty string
+1105        assert!(!handle.is_empty());
+1106    }
+1107
+1108    #[test]
+1109    fn test_uuid_handles_are_unique() {
+1110        let handle1 = format!("{}", Uuid::new_v4());
+1111        let handle2 = format!("{}", Uuid::new_v4());
+1112        // Two generated UUIDs should be different
+1113        assert_ne!(handle1, handle2);
+1114    }
+1115
+1116    #[test]
+1117    fn test_uuid_handle_format() {
+1118        let uuid = Uuid::new_v4();
+1119        let handle = format!("{}", uuid);
+1120        // UUID should have standard format with hyphens
+1121        let parts: Vec<&str> = handle.split('-').collect();
+1122        assert_eq!(parts.len(), 5);
+1123    }
+1124
+1125    #[test]
+1126    fn test_repository_manager_destroy_nonexistent() {
+1127        let manager = RepositoryManager::new();
+1128        // Destroying a non-existent repository should not panic
+1129        manager.destroy("nonexistent-handle");
+1130    }
+1131
+1132    #[test]
+1133    fn test_repository_manager_is_send_sync() {
+1134        // Verify RepositoryManager can be used in async contexts
+1135        fn is_send_sync<T: Send + Sync>() {}
+1136        is_send_sync::<RepositoryManager>();
+1137    }
+1138
+1139    #[test]
+1140    fn test_repository_manager_wrapper_is_send_sync() {
+1141        // Verify RepositoryManagerWrapper is Send + Sync
+1142        fn is_send_sync<T: Send + Sync>() {}
+1143        is_send_sync::<RepositoryManagerWrapper>();
+1144    }
+1145
+1146    #[test]
+1147    fn test_multiple_manager_instances() {
+1148        let manager1 = RepositoryManager::new();
+1149        let manager2 = RepositoryManager::new();
+1150
+1151        // Multiple manager instances should be independent
+1152        let handle1_nonexistent = manager1.get("test");
+1153        let handle2_nonexistent = manager2.get("test");
+1154
+1155        assert!(handle1_nonexistent.is_none());
+1156        assert!(handle2_nonexistent.is_none());
+1157    }
+1158
+1159    #[test]
+1160    fn test_handle_string_properties() {
+1161        let uuid = Uuid::new_v4();
+1162        let handle = format!("{}", uuid);
+1163
+1164        // Handle should be alphanumeric with hyphens
+1165        for c in handle.chars() {
+1166            assert!(c.is_alphanumeric() || c == '-');
+1167        }
+1168    }
+1169
+1170    #[test]
+1171    fn test_repository_manager_concurrent_access() {
+1172        let manager = Arc::new(RepositoryManager::new());
+1173        let mut handles = vec![];
+1174
+1175        // Verify manager can be wrapped in Arc for concurrent access
+1176        for _ in 0..3 {
+1177            let mgr = Arc::clone(&manager);
+1178            let handle = std::thread::spawn(move || {
+1179                let result = mgr.get("test");
+1180                assert!(result.is_none());
+1181            });
+1182            handles.push(handle);
+1183        }
+1184
+1185        for h in handles {
+1186            h.join().unwrap();
+1187        }
+1188    }
+1189}
+
\ No newline at end of file diff --git a/api/src/jellytau_lib/commands/sessions.rs.html b/api/src/jellytau_lib/commands/sessions.rs.html new file mode 100644 index 00000000..8bf37187 --- /dev/null +++ b/api/src/jellytau_lib/commands/sessions.rs.html @@ -0,0 +1,103 @@ +sessions.rs - sourceSkip to main content

jellytau_lib/commands/
sessions.rs

1//! TRACES: UR-010 | JA-021 | DR-037
+2
+3use crate::jellyfin::client::SessionInfo;
+4use crate::session_poller::{PollingHint, SessionPollerManager};
+5use std::sync::Arc;
+6use tauri::State;
+7
+8/// Tauri state wrapper for SessionPollerManager
+9pub struct SessionPollerWrapper(pub Arc<SessionPollerManager>);
+10
+11/// Set polling frequency hint based on UI state
+12#[tauri::command]
+13#[specta::specta]
+14pub fn sessions_set_polling_hint(
+15    poller: State<'_, SessionPollerWrapper>,
+16    hint: String,
+17) -> Result<(), String> {
+18    let parsed_hint = match hint.as_str() {
+19        "cast_active" => PollingHint::CastActive,
+20        "cast_discovery" => PollingHint::CastDiscovery,
+21        "normal" => PollingHint::Normal,
+22        _ => return Err(format!("Invalid polling hint: {}", hint)),
+23    };
+24
+25    poller.0.set_polling_hint(parsed_hint);
+26    Ok(())
+27}
+28
+29/// Manually trigger a session poll (for refresh button)
+30#[tauri::command]
+31#[specta::specta]
+32pub async fn sessions_poll_now(
+33    poller: State<'_, SessionPollerWrapper>,
+34) -> Result<Vec<SessionInfo>, String> {
+35    poller.0.poll_now().await
+36}
+37
+38#[cfg(test)]
+39mod tests {
+40    use super::*;
+41
+42    #[test]
+43    fn test_polling_hint_parsing() {
+44        // Test valid hints
+45        assert_eq!(
+46            match "cast_active" {
+47                "cast_active" => Some(PollingHint::CastActive),
+48                "cast_discovery" => Some(PollingHint::CastDiscovery),
+49                "normal" => Some(PollingHint::Normal),
+50                _ => None,
+51            },
+52            Some(PollingHint::CastActive)
+53        );
+54
+55        assert_eq!(
+56            match "cast_discovery" {
+57                "cast_active" => Some(PollingHint::CastActive),
+58                "cast_discovery" => Some(PollingHint::CastDiscovery),
+59                "normal" => Some(PollingHint::Normal),
+60                _ => None,
+61            },
+62            Some(PollingHint::CastDiscovery)
+63        );
+64
+65        assert_eq!(
+66            match "normal" {
+67                "cast_active" => Some(PollingHint::CastActive),
+68                "cast_discovery" => Some(PollingHint::CastDiscovery),
+69                "normal" => Some(PollingHint::Normal),
+70                _ => None,
+71            },
+72            Some(PollingHint::Normal)
+73        );
+74    }
+75
+76    #[test]
+77    fn test_invalid_polling_hint() {
+78        // Test invalid hint
+79        let result = match "invalid" {
+80            "cast_active" => Ok(PollingHint::CastActive),
+81            "cast_discovery" => Ok(PollingHint::CastDiscovery),
+82            "normal" => Ok(PollingHint::Normal),
+83            _ => Err("Invalid polling hint"),
+84        };
+85
+86        assert!(result.is_err());
+87    }
+88
+89    #[test]
+90    fn test_session_poller_wrapper_structure() {
+91        // Test that wrapper type structure is correct
+92        assert!(std::mem::size_of::<SessionPollerWrapper>() > 0);
+93    }
+94
+95    #[test]
+96    fn test_polling_hints_exist() {
+97        // Verify polling hint variants exist
+98        let _ = PollingHint::CastActive;
+99        let _ = PollingHint::CastDiscovery;
+100        let _ = PollingHint::Normal;
+101    }
+102}
+
\ No newline at end of file diff --git a/api/src/jellytau_lib/commands/storage/mod.rs.html b/api/src/jellytau_lib/commands/storage/mod.rs.html new file mode 100644 index 00000000..f178a4ba --- /dev/null +++ b/api/src/jellytau_lib/commands/storage/mod.rs.html @@ -0,0 +1,1730 @@ +mod.rs - sourceSkip to main content

jellytau_lib/commands/storage/
mod.rs

1//! Tauri commands for database/storage operations
+2//!
+3//! TRACES: UR-002, UR-011, UR-012, UR-017, UR-019, UR-025, UR-047 | IR-013 | DR-012, DR-013, DR-022, DR-060
+4
+5use std::sync::{Arc, Mutex};
+6
+7use log::{debug, error, info, warn};
+8use serde::{Deserialize, Serialize};
+9use tauri::State;
+10
+11use crate::credentials::CredentialStore;
+12use crate::storage::db_service::{DatabaseService, Query, QueryParam};
+13use crate::storage::Database;
+14use crate::thumbnail::ThumbnailCache;
+15
+16use super::SmartCacheWrapper;
+17
+18// Cohesive command clusters in their own submodules, re-exported so the command
+19// names remain at `commands::storage::*` (invoke_handler unchanged).
+20mod people;
+21mod series_prefs;
+22mod thumbnails;
+23pub use people::*;
+24pub use series_prefs::*;
+25pub use thumbnails::*;
+26
+27/// Wrapper for thread-safe database access
+28pub struct DatabaseWrapper(pub Mutex<Database>);
+29
+30/// Wrapper for thread-safe credential store access
+31pub struct CredentialStoreWrapper(pub Mutex<CredentialStore>);
+32
+33/// Wrapper for thread-safe thumbnail cache access
+34pub struct ThumbnailCacheWrapper(pub Arc<ThumbnailCache>);
+35
+36/// Server info returned to frontend
+37#[derive(specta::Type, Debug, Clone, Serialize, Deserialize)]
+38pub struct ServerInfo {
+39    pub id: String,
+40    pub name: String,
+41    pub url: String,
+42    pub version: Option<String>,
+43}
+44
+45/// User info returned to frontend
+46#[derive(specta::Type, Debug, Clone, Serialize, Deserialize)]
+47#[serde(rename_all = "camelCase")]
+48pub struct UserInfo {
+49    pub id: String,
+50    pub server_id: String,
+51    pub username: String,
+52    pub is_active: bool,
+53}
+54
+55/// Active session info (for session restoration)
+56#[derive(specta::Type, Debug, Clone, Serialize, Deserialize)]
+57#[serde(rename_all = "camelCase")]
+58pub struct ActiveSession {
+59    pub user_id: String,
+60    pub username: String,
+61    pub server_id: String,
+62    pub server_url: String,
+63    pub server_name: String,
+64    pub access_token: String,
+65}
+66
+67/// Security status info
+68#[derive(specta::Type, Debug, Clone, Serialize, Deserialize)]
+69#[serde(rename_all = "camelCase")]
+70pub struct SecurityStatus {
+71    pub using_keyring: bool,
+72    pub storage_type: String,
+73}
+74
+75/// Initialize the database and run migrations
+76#[tauri::command]
+77#[specta::specta]
+78pub fn storage_init(db: State<DatabaseWrapper>) -> Result<String, String> {
+79    let database = db.0.lock().map_err(|e| e.to_string())?;
+80    Ok(database.path().to_string_lossy().to_string())
+81}
+82
+83/// A playable URL for a downloaded file on disk.
+84///
+85/// Local media is served over a loopback HTTP server rather than handed to the
+86/// webview as a `file://`/asset URL, because the asset protocol cannot stream a
+87/// large file — it answers a range-less request with the whole thing, which
+88/// Chromium abandons. See `media_server` for why real HTTP is used.
+89///
+90/// The returned URL carries the server's per-session token, so it is only valid
+91/// for this run of the app and must not be persisted.
+92///
+93/// TRACES: UR-071 | DR-137
+94#[tauri::command]
+95#[specta::specta]
+96pub fn media_local_url(
+97    server: State<crate::media_server::MediaServerWrapper>,
+98    path: String,
+99) -> Result<String, String> {
+100    server
+101        .0
+102        .as_ref()
+103        .map(|s| s.url_for(&path))
+104        .ok_or_else(|| "Local media server is not running".to_string())
+105}
+106
+107/// Get storage directory path (parent directory of the database file)
+108#[tauri::command]
+109#[specta::specta]
+110pub fn storage_get_path(db: State<DatabaseWrapper>) -> Result<String, String> {
+111    let database = db.0.lock().map_err(|e| e.to_string())?;
+112    let db_path = database.path();
+113
+114    // Return the parent directory instead of the database file path
+115    let storage_dir = db_path
+116        .parent()
+117        .ok_or_else(|| "Database path has no parent directory".to_string())?;
+118
+119    Ok(storage_dir.to_string_lossy().to_string())
+120}
+121
+122/// Get database file size in bytes
+123#[tauri::command]
+124#[specta::specta]
+125pub fn storage_get_size(db: State<DatabaseWrapper>) -> Result<Option<u64>, String> {
+126    let database = db.0.lock().map_err(|e| e.to_string())?;
+127    Ok(database.file_size())
+128}
+129
+130/// Get security status (keyring vs encrypted file fallback)
+131#[tauri::command]
+132#[specta::specta]
+133pub fn storage_get_security_status(
+134    creds: State<CredentialStoreWrapper>,
+135) -> Result<SecurityStatus, String> {
+136    let store = creds.0.lock().map_err(|e| e.to_string())?;
+137    let using_keyring = store.is_using_keyring();
+138    Ok(SecurityStatus {
+139        using_keyring,
+140        storage_type: if using_keyring {
+141            "system_keyring".to_string()
+142        } else {
+143            "encrypted_file".to_string()
+144        },
+145    })
+146}
+147
+148/// Save a server connection
+149/// Uses INSERT ... ON CONFLICT to avoid triggering CASCADE DELETE on users
+150#[tauri::command]
+151#[specta::specta]
+152pub async fn storage_save_server(
+153    db: State<'_, DatabaseWrapper>,
+154    id: String,
+155    name: String,
+156    url: String,
+157    version: Option<String>,
+158) -> Result<(), String> {
+159    let db_service = {
+160        let database = db.0.lock().map_err(|e| e.to_string())?;
+161        Arc::new(database.service())
+162    };
+163
+164    // IMPORTANT: Use ON CONFLICT ... DO UPDATE instead of INSERT OR REPLACE
+165    // INSERT OR REPLACE triggers DELETE + INSERT, which cascades to delete all users!
+166    let query = Query::with_params(
+167        "INSERT INTO servers (id, name, url, version, last_connected_at)
+168         VALUES (?, ?, ?, ?, CURRENT_TIMESTAMP)
+169         ON CONFLICT(id) DO UPDATE SET
+170            name = excluded.name,
+171            url = excluded.url,
+172            version = excluded.version,
+173            last_connected_at = CURRENT_TIMESTAMP",
+174        vec![
+175            QueryParam::String(id),
+176            QueryParam::String(name),
+177            QueryParam::String(url),
+178            version.map(QueryParam::String).unwrap_or(QueryParam::Null),
+179        ],
+180    );
+181
+182    db_service.execute(query).await.map_err(|e| e.to_string())?;
+183
+184    Ok(())
+185}
+186
+187/// Get all saved servers
+188#[tauri::command]
+189#[specta::specta]
+190pub async fn storage_get_servers(
+191    db: State<'_, DatabaseWrapper>,
+192) -> Result<Vec<ServerInfo>, String> {
+193    let db_service = {
+194        let database = db.0.lock().map_err(|e| e.to_string())?;
+195        Arc::new(database.service())
+196    };
+197
+198    let query =
+199        Query::new("SELECT id, name, url, version FROM servers ORDER BY last_connected_at DESC");
+200
+201    let servers = db_service
+202        .query_many(query, |row| {
+203            Ok(ServerInfo {
+204                id: row.get(0)?,
+205                name: row.get(1)?,
+206                url: row.get(2)?,
+207                version: row.get(3)?,
+208            })
+209        })
+210        .await
+211        .map_err(|e| e.to_string())?;
+212
+213    Ok(servers)
+214}
+215
+216/// Delete a server and all associated data
+217#[tauri::command]
+218#[specta::specta]
+219pub async fn storage_delete_server(
+220    db: State<'_, DatabaseWrapper>,
+221    creds: State<'_, CredentialStoreWrapper>,
+222    server_id: String,
+223) -> Result<(), String> {
+224    let db_service = {
+225        let database = db.0.lock().map_err(|e| e.to_string())?;
+226        Arc::new(database.service())
+227    };
+228
+229    // Get all user IDs for this server to delete their tokens
+230    let user_query = Query::with_params(
+231        "SELECT id FROM users WHERE server_id = ?",
+232        vec![QueryParam::String(server_id.clone())],
+233    );
+234
+235    let user_ids: Vec<String> = db_service
+236        .query_many(user_query, |row| row.get(0))
+237        .await
+238        .map_err(|e| e.to_string())?;
+239
+240    // Delete tokens from secure storage
+241    {
+242        let store = creds.0.lock().map_err(|e| e.to_string())?;
+243        for user_id in user_ids {
+244            let _ = store.delete_token(&user_id); // Ignore errors, user might not have token
+245        }
+246    } // Drop store lock here
+247
+248    // Delete server (cascades to users via foreign key)
+249    let delete_query = Query::with_params(
+250        "DELETE FROM servers WHERE id = ?",
+251        vec![QueryParam::String(server_id)],
+252    );
+253
+254    db_service
+255        .execute(delete_query)
+256        .await
+257        .map_err(|e| e.to_string())?;
+258
+259    Ok(())
+260}
+261
+262/// Save a user account (token stored in secure storage, not database)
+263#[tauri::command]
+264#[specta::specta]
+265pub async fn storage_save_user(
+266    db: State<'_, DatabaseWrapper>,
+267    creds: State<'_, CredentialStoreWrapper>,
+268    id: String,
+269    server_id: String,
+270    username: String,
+271    access_token: Option<String>,
+272) -> Result<bool, String> {
+273    info!(
+274        "storage_save_user called: id={}, server_id={}, username={}",
+275        id, server_id, username
+276    );
+277
+278    let (db_service, db_path) = {
+279        let database = db.0.lock().map_err(|e| {
+280            error!("Failed to lock database: {}", e);
+281            e.to_string()
+282        })?;
+283        let path = database.path().to_path_buf();
+284        (Arc::new(database.service()), path)
+285    };
+286
+287    // Save user metadata to database (without token)
+288    // IMPORTANT: Use ON CONFLICT ... DO UPDATE instead of INSERT OR REPLACE
+289    // INSERT OR REPLACE triggers DELETE + INSERT, which cascades to delete user_data!
+290    debug!("Executing INSERT INTO users with ON CONFLICT...");
+291    let insert_query = Query::with_params(
+292        "INSERT INTO users (id, server_id, username, last_login_at)
+293         VALUES (?, ?, ?, CURRENT_TIMESTAMP)
+294         ON CONFLICT(id) DO UPDATE SET
+295            server_id = excluded.server_id,
+296            username = excluded.username,
+297            last_login_at = CURRENT_TIMESTAMP",
+298        vec![
+299            QueryParam::String(id.clone()),
+300            QueryParam::String(server_id),
+301            QueryParam::String(username),
+302        ],
+303    );
+304
+305    db_service.execute(insert_query).await.map_err(|e| {
+306        error!("Failed to save user: {}", e);
+307        e.to_string()
+308    })?;
+309    info!("User saved to database successfully");
+310
+311    // Verify the user was actually saved
+312    let verify_query = Query::with_params(
+313        "SELECT COUNT(*) FROM users WHERE id = ?",
+314        vec![QueryParam::String(id.clone())],
+315    );
+316    let verify_count: i32 = db_service
+317        .query_one(verify_query, |row| row.get(0))
+318        .await
+319        .unwrap_or(-1);
+320    debug!("VERIFY: {} users with id={} after insert", verify_count, id);
+321    debug!("Database path: {:?}", db_path);
+322
+323    // Force WAL checkpoint to ensure user data is persisted to disk
+324    let checkpoint_query = Query::new("PRAGMA wal_checkpoint(PASSIVE)");
+325    match db_service.execute(checkpoint_query).await {
+326        Ok(_) => debug!("WAL checkpoint completed after save_user"),
+327        Err(e) => warn!("WAL checkpoint failed: {}", e),
+328    }
+329
+330    // Save token to secure storage if provided
+331    let using_keyring = if let Some(token) = access_token {
+332        let store = creds.0.lock().map_err(|e| e.to_string())?;
+333        let result = store.save_token(&id, &token).map_err(|e| e.to_string())?;
+334        let is_keyring = matches!(result, crate::credentials::CredentialResult::Keyring);
+335        is_keyring
+336    } else {
+337        // No token provided, check current status
+338        let store = creds.0.lock().map_err(|e| e.to_string())?;
+339        store.is_using_keyring()
+340    };
+341
+342    // Return whether we're using the secure keyring
+343    Ok(using_keyring)
+344}
+345
+346/// Get users for a server
+347#[tauri::command]
+348#[specta::specta]
+349pub async fn storage_get_users(
+350    db: State<'_, DatabaseWrapper>,
+351    server_id: String,
+352) -> Result<Vec<UserInfo>, String> {
+353    let db_service = {
+354        let database = db.0.lock().map_err(|e| e.to_string())?;
+355        Arc::new(database.service())
+356    };
+357
+358    let query = Query::with_params(
+359        "SELECT id, server_id, username, is_active FROM users
+360         WHERE server_id = ? ORDER BY last_login_at DESC",
+361        vec![QueryParam::String(server_id)],
+362    );
+363
+364    let users = db_service
+365        .query_many(query, |row| {
+366            Ok(UserInfo {
+367                id: row.get(0)?,
+368                server_id: row.get(1)?,
+369                username: row.get(2)?,
+370                is_active: row.get::<_, i32>(3)? != 0,
+371            })
+372        })
+373        .await
+374        .map_err(|e| e.to_string())?;
+375
+376    Ok(users)
+377}
+378
+379/// Set a user as active (and deactivate all other users globally)
+380#[tauri::command]
+381#[specta::specta]
+382pub async fn storage_set_active_user(
+383    db: State<'_, DatabaseWrapper>,
+384    user_id: String,
+385    _server_id: String,
+386) -> Result<(), String> {
+387    info!("storage_set_active_user called: user_id={}", user_id);
+388
+389    let (db_service, db_path) = {
+390        let database = db.0.lock().map_err(|e| e.to_string())?;
+391        let path = database.path().to_path_buf();
+392        (Arc::new(database.service()), path)
+393    };
+394
+395    // Deactivate ALL users globally (since we only connect to one server at a time)
+396    let deactivate_query = Query::new("UPDATE users SET is_active = 0");
+397    db_service
+398        .execute(deactivate_query)
+399        .await
+400        .map_err(|e| e.to_string())?;
+401
+402    // Activate the specified user and update last_login_at
+403    let activate_query = Query::with_params(
+404        "UPDATE users SET is_active = 1, last_login_at = CURRENT_TIMESTAMP WHERE id = ?",
+405        vec![QueryParam::String(user_id.clone())],
+406    );
+407    let rows_affected = db_service
+408        .execute(activate_query)
+409        .await
+410        .map_err(|e| e.to_string())?;
+411
+412    debug!("storage_set_active_user: {} rows affected", rows_affected);
+413
+414    if rows_affected == 0 {
+415        warn!("No user found with id={} to set as active!", user_id);
+416    }
+417
+418    // Verify the user is now active
+419    let verify_query = Query::new("SELECT COUNT(*) FROM users WHERE is_active = 1");
+420    let verify_count: i32 = db_service
+421        .query_one(verify_query, |row| row.get(0))
+422        .await
+423        .unwrap_or(-1);
+424    debug!("VERIFY: {} active users after set_active", verify_count);
+425    debug!("Database path: {:?}", db_path);
+426
+427    // Force WAL checkpoint to ensure data is persisted to disk
+428    let checkpoint_query = Query::new("PRAGMA wal_checkpoint(PASSIVE)");
+429    match db_service.execute(checkpoint_query).await {
+430        Ok(_) => debug!("WAL checkpoint completed"),
+431        Err(e) => warn!("WAL checkpoint failed: {}", e),
+432    }
+433
+434    Ok(())
+435}
+436
+437/// Get the active user for a server
+438#[tauri::command]
+439#[specta::specta]
+440pub async fn storage_get_active_user(
+441    db: State<'_, DatabaseWrapper>,
+442    server_id: String,
+443) -> Result<Option<UserInfo>, String> {
+444    let db_service = {
+445        let database = db.0.lock().map_err(|e| e.to_string())?;
+446        Arc::new(database.service())
+447    };
+448
+449    let query = Query::with_params(
+450        "SELECT id, server_id, username, is_active FROM users
+451         WHERE server_id = ? AND is_active = 1",
+452        vec![QueryParam::String(server_id)],
+453    );
+454
+455    db_service
+456        .query_optional(query, |row| {
+457            Ok(UserInfo {
+458                id: row.get(0)?,
+459                server_id: row.get(1)?,
+460                username: row.get(2)?,
+461                is_active: true,
+462            })
+463        })
+464        .await
+465        .map_err(|e| e.to_string())
+466}
+467
+468/// Get the active session (user + server + token) for session restoration
+469#[tauri::command]
+470#[specta::specta]
+471pub async fn storage_get_active_session(
+472    db: State<'_, DatabaseWrapper>,
+473    creds: State<'_, CredentialStoreWrapper>,
+474) -> Result<Option<ActiveSession>, String> {
+475    info!("storage_get_active_session called");
+476
+477    let (db_service, db_path) = {
+478        let database = db.0.lock().map_err(|e| e.to_string())?;
+479        let path = database.path().to_path_buf();
+480        (Arc::new(database.service()), path)
+481    };
+482
+483    // Debug: count total users and active users
+484    let total_query = Query::new("SELECT COUNT(*) FROM users");
+485    let total_users: i32 = db_service
+486        .query_one(total_query, |row| row.get(0))
+487        .await
+488        .unwrap_or(-1);
+489
+490    let active_query = Query::new("SELECT COUNT(*) FROM users WHERE is_active = 1");
+491    let active_users: i32 = db_service
+492        .query_one(active_query, |row| row.get(0))
+493        .await
+494        .unwrap_or(-1);
+495
+496    debug!(
+497        "Database state: {} total users, {} active users",
+498        total_users, active_users
+499    );
+500    debug!("Database path: {:?}", db_path);
+501
+502    // Find active user with their server info, ordered by most recently logged in
+503    let session_query = Query::new(
+504        "SELECT u.id, u.username, u.server_id, s.url, s.name
+505         FROM users u
+506         JOIN servers s ON u.server_id = s.id
+507         WHERE u.is_active = 1
+508         ORDER BY u.last_login_at DESC
+509         LIMIT 1",
+510    );
+511
+512    let result = db_service
+513        .query_optional(session_query, |row| {
+514            Ok((
+515                row.get::<_, String>(0)?,
+516                row.get::<_, String>(1)?,
+517                row.get::<_, String>(2)?,
+518                row.get::<_, String>(3)?,
+519                row.get::<_, String>(4)?,
+520            ))
+521        })
+522        .await
+523        .map_err(|e| e.to_string())?;
+524
+525    match result {
+526        Some((user_id, username, server_id, server_url, server_name)) => {
+527            info!("Found active user: {} ({})", username, user_id);
+528            // Get token from secure storage
+529            let store = creds.0.lock().map_err(|e| e.to_string())?;
+530            match store.get_token(&user_id) {
+531                Ok(access_token) => {
+532                    debug!("Successfully retrieved token from secure storage");
+533                    Ok(Some(ActiveSession {
+534                        user_id,
+535                        username,
+536                        server_id,
+537                        server_url,
+538                        server_name,
+539                        access_token,
+540                    }))
+541                }
+542                Err(e) => {
+543                    // Token not found or error - session is invalid
+544                    warn!("Failed to get token from secure storage: {:?}", e);
+545                    Ok(None)
+546                }
+547            }
+548        }
+549        None => {
+550            info!("No active user found in database");
+551            Ok(None)
+552        }
+553    }
+554}
+555
+556/// Get user's access token from secure storage
+557#[tauri::command]
+558#[specta::specta]
+559pub fn storage_get_access_token(
+560    creds: State<CredentialStoreWrapper>,
+561    user_id: String,
+562) -> Result<Option<String>, String> {
+563    let store = creds.0.lock().map_err(|e| e.to_string())?;
+564    match store.get_token(&user_id) {
+565        Ok(token) => Ok(Some(token)),
+566        Err(crate::credentials::CredentialError::NotFound) => Ok(None),
+567        Err(e) => Err(e.to_string()),
+568    }
+569}
+570
+571/// Delete a user account and their token from secure storage
+572#[tauri::command]
+573#[specta::specta]
+574pub async fn storage_delete_user(
+575    db: State<'_, DatabaseWrapper>,
+576    creds: State<'_, CredentialStoreWrapper>,
+577    user_id: String,
+578) -> Result<(), String> {
+579    info!("storage_delete_user called: user_id={}", user_id);
+580    debug!("STACK TRACE: This is where the user is being deleted!");
+581
+582    // Delete token from secure storage first
+583    {
+584        let store = creds.0.lock().map_err(|e| e.to_string())?;
+585        let _ = store.delete_token(&user_id); // Ignore errors, token might not exist
+586    }
+587
+588    // Delete user from database
+589    let db_service = {
+590        let database = db.0.lock().map_err(|e| e.to_string())?;
+591        Arc::new(database.service())
+592    };
+593
+594    let query = Query::with_params(
+595        "DELETE FROM users WHERE id = ?",
+596        vec![QueryParam::String(user_id)],
+597    );
+598
+599    db_service.execute(query).await.map_err(|e| e.to_string())?;
+600
+601    info!("User deleted successfully");
+602    Ok(())
+603}
+604
+605/// Playback progress info
+606#[derive(specta::Type, Debug, Clone, Serialize, Deserialize)]
+607#[serde(rename_all = "camelCase")]
+608pub struct PlaybackProgress {
+609    pub item_id: String,
+610    /// Resume position in milliseconds. Stored as Jellyfin ticks in the DB;
+611    /// converted here so the frontend never sees ticks.
+612    pub position_ms: i64,
+613    pub is_played: bool,
+614    pub is_favorite: bool,
+615    pub play_count: i32,
+616}
+617
+618/// Update playback progress in local database
+619/// This stores the progress locally for offline access and "continue watching"
+620#[tauri::command]
+621#[specta::specta]
+622pub async fn storage_update_playback_progress(
+623    db: State<'_, DatabaseWrapper>,
+624    user_id: String,
+625    item_id: String,
+626    position_ms: i64,
+627) -> Result<(), String> {
+628    // The frontend speaks milliseconds; ticks are a Jellyfin storage detail that
+629    // stays on this side of the boundary. 10_000 ticks = 1 ms.
+630    let position_ticks = position_ms * 10_000;
+631    let db_service = {
+632        let database = db.0.lock().map_err(|e| e.to_string())?;
+633        Arc::new(database.service())
+634    };
+635
+636    // Note: user_id and item_id are Jellyfin IDs (strings)
+637    // The user_data table uses them directly as strings, not foreign keys to integer IDs
+638    // Insert or update playback position
+639    let query = Query::with_params(
+640        "INSERT INTO user_data (user_id, item_id, playback_position_ticks, last_played_at, pending_sync)
+641         VALUES (?, ?, ?, CURRENT_TIMESTAMP, 1)
+642         ON CONFLICT(user_id, item_id) DO UPDATE SET
+643            playback_position_ticks = excluded.playback_position_ticks,
+644            last_played_at = excluded.last_played_at,
+645            pending_sync = 1",
+646        vec![
+647            QueryParam::String(user_id),
+648            QueryParam::String(item_id.clone()),
+649            QueryParam::Int64(position_ticks),
+650        ],
+651    );
+652
+653    match db_service.execute(query).await {
+654        Ok(_) => Ok(()),
+655        Err(e) if e.contains("constraint") || e.contains("UNIQUE") => {
+656            // Foreign key constraint failed - this can happen if:
+657            // 1. The item isn't synced locally yet (item_id not in items table)
+658            // 2. Database migration 003 hasn't been applied (old database)
+659            //
+660            // In either case, we silently succeed - playback progress will still
+661            // be reported to the server, and local progress will be tracked
+662            // once the item is synced.
+663            debug!(
+664                "Skipping local playback progress for item {} (not cached locally)",
+665                item_id
+666            );
+667            Ok(())
+668        }
+669        Err(e) => Err(format!("Failed to update playback progress: {}", e)),
+670    }
+671}
+672
+673/// Update playback progress with context in local database
+674/// This stores the progress along with playback context (container vs single)
+675#[tauri::command]
+676#[specta::specta]
+677pub async fn storage_update_playback_context(
+678    db: State<'_, DatabaseWrapper>,
+679    user_id: String,
+680    item_id: String,
+681    position_ms: i64,
+682    context_type: Option<String>,
+683    context_id: Option<String>,
+684) -> Result<(), String> {
+685    use crate::storage::db_service::{Query, QueryParam};
+686
+687    // Milliseconds in, Jellyfin ticks stored. 10_000 ticks = 1 ms.
+688    let position_ticks = position_ms * 10_000;
+689    let db_service = {
+690        let database = db.0.lock().map_err(|e| e.to_string())?;
+691        Arc::new(database.service())
+692    };
+693
+694    let query = Query::with_params(
+695        "INSERT INTO user_data (user_id, item_id, playback_position_ticks, last_played_at,
+696                                playback_context_type, playback_context_id, pending_sync)
+697         VALUES (?, ?, ?, CURRENT_TIMESTAMP, ?, ?, 1)
+698         ON CONFLICT(user_id, item_id) DO UPDATE SET
+699            playback_position_ticks = excluded.playback_position_ticks,
+700            last_played_at = excluded.last_played_at,
+701            playback_context_type = excluded.playback_context_type,
+702            playback_context_id = excluded.playback_context_id,
+703            pending_sync = 1",
+704        vec![
+705            QueryParam::String(user_id.clone()),
+706            QueryParam::String(item_id.clone()),
+707            QueryParam::Int64(position_ticks),
+708            context_type
+709                .map(QueryParam::String)
+710                .unwrap_or(QueryParam::Null),
+711            context_id
+712                .map(QueryParam::String)
+713                .unwrap_or(QueryParam::Null),
+714        ],
+715    );
+716
+717    match db_service.execute(query).await {
+718        Ok(_) => Ok(()),
+719        Err(e) if e.contains("constraint") || e.contains("UNIQUE") => {
+720            debug!(
+721                "Skipping local playback context for item {} (not cached locally)",
+722                item_id
+723            );
+724            Ok(())
+725        }
+726        Err(e) => Err(format!("Failed to update playback context: {}", e)),
+727    }
+728}
+729
+730/// Mark item as played in local database
+731#[tauri::command]
+732#[specta::specta]
+733pub async fn storage_mark_played(
+734    db: State<'_, DatabaseWrapper>,
+735    smart_cache: State<'_, SmartCacheWrapper>,
+736    user_id: String,
+737    item_id: String,
+738) -> Result<(), String> {
+739    let db_service = {
+740        let database = db.0.lock().map_err(|e| e.to_string())?;
+741        Arc::new(database.service())
+742    };
+743
+744    // Get album_id for this item (if it's an audio track)
+745    let album_query = Query::with_params(
+746        "SELECT album_id, item_type FROM items WHERE id = ?",
+747        vec![QueryParam::String(item_id.clone())],
+748    );
+749
+750    let album_info: Option<(Option<String>, String)> = db_service
+751        .query_optional(album_query, |row| Ok((row.get(0)?, row.get(1)?)))
+752        .await
+753        .unwrap_or(None);
+754
+755    // Track play in SmartCache (for audio tracks with albums)
+756    if let Some((Some(album_id), item_type)) = album_info.clone() {
+757        if item_type == "Audio" {
+758            // Clone cache to avoid holding lock across async operations
+759            let should_cache = {
+760                let cache = smart_cache.0.lock().map_err(|e| e.to_string())?;
+761                cache.track_play(&item_id, Some(&album_id));
+762                cache.should_cache_album(&album_id)
+763            };
+764
+765            // Check if we should cache this album
+766            if let Some(true) = should_cache {
+767                info!("Album affinity threshold reached for album {}!", album_id);
+768
+769                // Auto-queue remaining album tracks for download
+770                // Get tracks from this album that aren't already downloaded
+771                let tracks_query = Query::with_params(
+772                    "SELECT i.id, i.name, i.artists, i.album_name
+773                     FROM items i
+774                     LEFT JOIN downloads d ON d.item_id = i.id AND d.user_id = ?
+775                     WHERE i.album_id = ? AND i.item_type = 'Audio'
+776                       AND (d.id IS NULL OR d.status NOT IN ('completed', 'downloading', 'pending'))
+777                     ORDER BY i.index_number",
+778                    vec![
+779                        QueryParam::String(user_id.clone()),
+780                        QueryParam::String(album_id.clone()),
+781                    ],
+782                );
+783
+784                let tracks: Vec<(String, String, Option<String>, Option<String>)> = db_service
+785                    .query_many(tracks_query, |row| {
+786                        Ok((row.get(0)?, row.get(1)?, row.get(2)?, row.get(3)?))
+787                    })
+788                    .await
+789                    .unwrap_or_else(|e| {
+790                        warn!("Failed to query album tracks: {}", e);
+791                        Vec::new()
+792                    });
+793
+794                if !tracks.is_empty() {
+795                    info!(
+796                        "Auto-queueing {} tracks from album for download",
+797                        tracks.len()
+798                    );
+799
+800                    // Queue each track with high priority (50) and mark as auto-downloaded
+801                    for (track_id, track_name, artist_name, album_name) in tracks {
+802                        // Generate a sanitized file path (simplified version)
+803                        let sanitized_name = track_name
+804                            .chars()
+805                            .map(|c| {
+806                                if c.is_alphanumeric() || c == ' ' || c == '-' || c == '_' {
+807                                    c
+808                                } else {
+809                                    '_'
+810                                }
+811                            })
+812                            .collect::<String>();
+813                        let file_path = format!("downloads/{}/{}.mp3", album_id, sanitized_name);
+814
+815                        let insert_query = Query::with_params(
+816                            "INSERT INTO downloads (item_id, user_id, file_path, status, priority, queued_at, item_name, artist_name, album_name, download_source)
+817                             VALUES (?, ?, ?, 'pending', 50, CURRENT_TIMESTAMP, ?, ?, ?, 'auto')
+818                             ON CONFLICT(item_id, user_id) DO UPDATE SET
+819                               priority = 50,
+820                               status = 'pending',
+821                               download_source = 'auto'",
+822                            vec![
+823                                QueryParam::String(track_id.clone()),
+824                                QueryParam::String(user_id.clone()),
+825                                QueryParam::String(file_path),
+826                                QueryParam::String(track_name),
+827                                artist_name.map(QueryParam::String).unwrap_or(QueryParam::Null),
+828                                album_name.map(QueryParam::String).unwrap_or(QueryParam::Null),
+829                            ],
+830                        );
+831
+832                        if let Err(e) = db_service.execute(insert_query).await {
+833                            warn!("Failed to queue track {}: {}", track_id, e);
+834                        }
+835                    }
+836
+837                    info!("Album tracks queued for automatic download");
+838                } else {
+839                    info!("All album tracks already downloaded or queued");
+840                }
+841            }
+842        }
+843    }
+844
+845    let query = Query::with_params(
+846        "INSERT INTO user_data (user_id, item_id, is_played, play_count, last_played_at, pending_sync)
+847         VALUES (?, ?, 1, 1, CURRENT_TIMESTAMP, 1)
+848         ON CONFLICT(user_id, item_id) DO UPDATE SET
+849            is_played = 1,
+850            play_count = play_count + 1,
+851            last_played_at = CURRENT_TIMESTAMP,
+852            pending_sync = 1",
+853        vec![QueryParam::String(user_id), QueryParam::String(item_id.clone())],
+854    );
+855
+856    match db_service.execute(query).await {
+857        Ok(_) => Ok(()),
+858        Err(e) if e.contains("constraint") => {
+859            // Foreign key constraint failed - item not cached locally
+860            debug!(
+861                "Skipping local mark_played for item {} (not cached locally)",
+862                item_id
+863            );
+864            Ok(())
+865        }
+866        Err(e) => Err(e.to_string()),
+867    }
+868}
+869
+870/// Set the watched flag locally for an item **and everything inside it**.
+871///
+872/// This backs the watched toggle, and is deliberately separate from
+873/// [`storage_mark_played`] — which reports a single track/episode finishing and
+874/// increments `play_count` — because the toggle has two directions and applies
+875/// to containers.
+876///
+877/// The recursion is what makes the toggle honest offline. Jellyfin applies
+878/// `POST`/`DELETE /PlayedItems/{id}` recursively over a season or series, so
+879/// online the server fixes up the children on the next read; with no server to
+880/// ask, marking a season watched would otherwise tick the season and leave every
+881/// episode inside it unwatched. Targets are drawn from `items` by the same link
+882/// columns the rest of the offline layer uses, so an id that is not cached
+883/// selects nothing and the statement is a no-op rather than a foreign-key error.
+884///
+885/// Un-marking clears the resume position too, matching the server, so an item
+886/// un-marked offline does not come back offering to resume from a position it is
+887/// no longer meant to have.
+888///
+889/// `pending_sync = 1` hands the rows to the sync drain.
+890///
+891/// TRACES: UR-073 | DR-158
+892#[tauri::command]
+893#[specta::specta]
+894pub async fn storage_set_watched(
+895    db: State<'_, DatabaseWrapper>,
+896    user_id: String,
+897    item_id: String,
+898    watched: bool,
+899) -> Result<(), String> {
+900    let db_service = {
+901        let database = db.0.lock().map_err(|e| e.to_string())?;
+902        Arc::new(database.service())
+903    };
+904
+905    // The item itself plus its descendants: a season's episodes reach it by
+906    // season_id, a series' by series_id, its seasons by parent_id, an album's
+907    // tracks by album_id.
+908    let targets = "SELECT id FROM items
+909                   WHERE id = ? OR parent_id = ? OR album_id = ?
+910                      OR season_id = ? OR series_id = ?";
+911
+912    let sql = if watched {
+913        format!(
+914            "INSERT INTO user_data (user_id, item_id, is_played, play_count, last_played_at, pending_sync)
+915             SELECT ?, id, 1, 1, CURRENT_TIMESTAMP, 1 FROM ({targets})
+916             ON CONFLICT(user_id, item_id) DO UPDATE SET
+917                is_played = 1,
+918                play_count = MAX(user_data.play_count, 1),
+919                last_played_at = CURRENT_TIMESTAMP,
+920                pending_sync = 1"
+921        )
+922    } else {
+923        format!(
+924            "INSERT INTO user_data (user_id, item_id, is_played, play_count, playback_position_ticks, pending_sync)
+925             SELECT ?, id, 0, 0, 0, 1 FROM ({targets})
+926             ON CONFLICT(user_id, item_id) DO UPDATE SET
+927                is_played = 0,
+928                play_count = 0,
+929                playback_position_ticks = 0,
+930                pending_sync = 1"
+931        )
+932    };
+933
+934    let query = Query::with_params(
+935        sql,
+936        vec![
+937            QueryParam::String(user_id),
+938            QueryParam::String(item_id.clone()),
+939            QueryParam::String(item_id.clone()),
+940            QueryParam::String(item_id.clone()),
+941            QueryParam::String(item_id.clone()),
+942            QueryParam::String(item_id.clone()),
+943        ],
+944    );
+945
+946    db_service.execute(query).await.map_err(|e| e.to_string())?;
+947    Ok(())
+948}
+949
+950/// Get playback progress for an item
+951#[tauri::command]
+952#[specta::specta]
+953pub async fn storage_get_playback_progress(
+954    db: State<'_, DatabaseWrapper>,
+955    user_id: String,
+956    item_id: String,
+957) -> Result<Option<PlaybackProgress>, String> {
+958    let db_service = {
+959        let database = db.0.lock().map_err(|e| e.to_string())?;
+960        Arc::new(database.service())
+961    };
+962
+963    let query = Query::with_params(
+964        "SELECT item_id, playback_position_ticks, is_played, is_favorite, play_count
+965         FROM user_data WHERE user_id = ? AND item_id = ?",
+966        vec![QueryParam::String(user_id), QueryParam::String(item_id)],
+967    );
+968
+969    db_service
+970        .query_optional(query, |row| {
+971            let position_ticks: i64 = row.get(1)?;
+972            Ok(PlaybackProgress {
+973                item_id: row.get(0)?,
+974                position_ms: position_ticks / 10_000,
+975                is_played: row.get::<_, i32>(2)? != 0,
+976                is_favorite: row.get::<_, i32>(3)? != 0,
+977                play_count: row.get(4)?,
+978            })
+979        })
+980        .await
+981        .map_err(|e| e.to_string())
+982}
+983
+984/// Mark pending sync as completed for an item
+985#[tauri::command]
+986#[specta::specta]
+987pub async fn storage_mark_synced(
+988    db: State<'_, DatabaseWrapper>,
+989    user_id: String,
+990    item_id: String,
+991) -> Result<(), String> {
+992    let db_service = {
+993        let database = db.0.lock().map_err(|e| e.to_string())?;
+994        Arc::new(database.service())
+995    };
+996
+997    let query = Query::with_params(
+998        "UPDATE user_data SET pending_sync = 0, synced_at = CURRENT_TIMESTAMP
+999         WHERE user_id = ? AND item_id = ?",
+1000        vec![QueryParam::String(user_id), QueryParam::String(item_id)],
+1001    );
+1002
+1003    db_service.execute(query).await.map_err(|e| e.to_string())?;
+1004
+1005    Ok(())
+1006}
+1007
+1008/// Toggle favorite status for an item in local database
+1009/// This updates the is_favorite field and marks it for sync to Jellyfin
+1010#[tauri::command]
+1011#[specta::specta]
+1012pub async fn storage_toggle_favorite(
+1013    db: State<'_, DatabaseWrapper>,
+1014    user_id: String,
+1015    item_id: String,
+1016    is_favorite: bool,
+1017) -> Result<bool, String> {
+1018    let db_service = {
+1019        let database = db.0.lock().map_err(|e| e.to_string())?;
+1020        Arc::new(database.service())
+1021    };
+1022
+1023    // Insert or update favorite status
+1024    let query = Query::with_params(
+1025        "INSERT INTO user_data (user_id, item_id, is_favorite, pending_sync)
+1026         VALUES (?, ?, ?, 1)
+1027         ON CONFLICT(user_id, item_id) DO UPDATE SET
+1028            is_favorite = excluded.is_favorite,
+1029            pending_sync = 1",
+1030        vec![
+1031            QueryParam::String(user_id),
+1032            QueryParam::String(item_id.clone()),
+1033            QueryParam::Int(is_favorite as i32),
+1034        ],
+1035    );
+1036
+1037    match db_service.execute(query).await {
+1038        Ok(_) => Ok(is_favorite),
+1039        Err(e) if e.contains("constraint") => {
+1040            // Foreign key constraint failed - item not cached locally
+1041            // Still return the requested state, server update will work separately
+1042            debug!(
+1043                "Skipping local favorite toggle for item {} (not cached locally)",
+1044                item_id
+1045            );
+1046            Ok(is_favorite)
+1047        }
+1048        Err(e) => Err(format!("Failed to toggle favorite: {}", e)),
+1049    }
+1050}
+1051
+1052// =============================================================================
+1053// Offline Data Queries
+1054// =============================================================================
+1055
+1056/// Cached library info returned to frontend
+1057#[derive(specta::Type, Debug, Clone, Serialize, Deserialize)]
+1058#[serde(rename_all = "camelCase")]
+1059pub struct CachedLibrary {
+1060    pub id: String,
+1061    pub server_id: String,
+1062    pub name: String,
+1063    pub collection_type: Option<String>,
+1064    pub image_tag: Option<String>,
+1065}
+1066
+1067/// Cached media item returned to frontend
+1068#[derive(specta::Type, Debug, Clone, Serialize, Deserialize)]
+1069#[serde(rename_all = "camelCase")]
+1070pub struct CachedItem {
+1071    pub id: String,
+1072    pub name: String,
+1073    pub item_type: String,
+1074    pub parent_id: Option<String>,
+1075    pub library_id: Option<String>,
+1076    pub overview: Option<String>,
+1077    pub genres: Option<String>,
+1078    pub runtime_ticks: Option<i64>,
+1079    pub production_year: Option<i32>,
+1080    pub community_rating: Option<f64>,
+1081    pub official_rating: Option<String>,
+1082    pub primary_image_tag: Option<String>,
+1083    // Music-specific
+1084    pub album_id: Option<String>,
+1085    pub album_name: Option<String>,
+1086    pub album_artist: Option<String>,
+1087    pub artists: Option<String>,
+1088    pub index_number: Option<i32>,
+1089    // TV-specific
+1090    pub series_id: Option<String>,
+1091    pub series_name: Option<String>,
+1092    pub season_id: Option<String>,
+1093    pub season_name: Option<String>,
+1094    pub parent_index_number: Option<i32>,
+1095}
+1096
+1097/// Get cached libraries for a server
+1098#[tauri::command]
+1099#[specta::specta]
+1100pub async fn storage_get_libraries(
+1101    db: State<'_, DatabaseWrapper>,
+1102    server_id: String,
+1103) -> Result<Vec<CachedLibrary>, String> {
+1104    let db_service = {
+1105        let database = db.0.lock().map_err(|e| e.to_string())?;
+1106        Arc::new(database.service())
+1107    };
+1108
+1109    let query = Query::with_params(
+1110        "SELECT id, server_id, name, collection_type, image_tag
+1111         FROM libraries
+1112         WHERE server_id = ?
+1113         ORDER BY sort_order ASC, name ASC",
+1114        vec![QueryParam::String(server_id)],
+1115    );
+1116
+1117    let libraries = db_service
+1118        .query_many(query, |row| {
+1119            Ok(CachedLibrary {
+1120                id: row.get(0)?,
+1121                server_id: row.get(1)?,
+1122                name: row.get(2)?,
+1123                collection_type: row.get(3)?,
+1124                image_tag: row.get(4)?,
+1125            })
+1126        })
+1127        .await
+1128        .map_err(|e| e.to_string())?;
+1129
+1130    Ok(libraries)
+1131}
+1132
+1133fn row_to_cached_item(row: &rusqlite::Row) -> rusqlite::Result<CachedItem> {
+1134    Ok(CachedItem {
+1135        id: row.get(0)?,
+1136        name: row.get(1)?,
+1137        item_type: row.get(2)?,
+1138        parent_id: row.get(3)?,
+1139        library_id: row.get(4)?,
+1140        overview: row.get(5)?,
+1141        genres: row.get(6)?,
+1142        runtime_ticks: row.get(7)?,
+1143        production_year: row.get(8)?,
+1144        community_rating: row.get(9)?,
+1145        official_rating: row.get(10)?,
+1146        primary_image_tag: row.get(11)?,
+1147        album_id: row.get(12)?,
+1148        album_name: row.get(13)?,
+1149        album_artist: row.get(14)?,
+1150        artists: row.get(15)?,
+1151        index_number: row.get(16)?,
+1152        series_id: row.get(17)?,
+1153        series_name: row.get(18)?,
+1154        season_id: row.get(19)?,
+1155        season_name: row.get(20)?,
+1156        parent_index_number: row.get(21)?,
+1157    })
+1158}
+1159
+1160/// Get cached items with optional filtering
+1161#[tauri::command]
+1162#[specta::specta]
+1163pub async fn storage_get_items(
+1164    db: State<'_, DatabaseWrapper>,
+1165    server_id: String,
+1166    parent_id: Option<String>,
+1167    library_id: Option<String>,
+1168    item_type: Option<String>,
+1169    limit: Option<i32>,
+1170    offset: Option<i32>,
+1171) -> Result<Vec<CachedItem>, String> {
+1172    let db_service = {
+1173        let database = db.0.lock().map_err(|e| e.to_string())?;
+1174        Arc::new(database.service())
+1175    };
+1176
+1177    // Build base query
+1178    let base_select = "SELECT id, name, item_type, parent_id, library_id, overview, genres,
+1179                runtime_ticks, production_year, community_rating, official_rating,
+1180                primary_image_tag, album_id, album_name, album_artist, artists,
+1181                index_number, series_id, series_name, season_id, season_name,
+1182                parent_index_number
+1183         FROM items
+1184         WHERE server_id = ?";
+1185
+1186    let mut conditions = Vec::new();
+1187    let mut params = vec![QueryParam::String(server_id)];
+1188
+1189    if let Some(pid) = parent_id {
+1190        conditions.push("parent_id = ?");
+1191        params.push(QueryParam::String(pid));
+1192    }
+1193
+1194    if let Some(lid) = library_id {
+1195        conditions.push("library_id = ?");
+1196        params.push(QueryParam::String(lid));
+1197    }
+1198
+1199    if let Some(itype) = item_type {
+1200        conditions.push("item_type = ?");
+1201        params.push(QueryParam::String(itype));
+1202    }
+1203
+1204    let mut sql = base_select.to_string();
+1205    for cond in &conditions {
+1206        sql.push_str(&format!(" AND {}", cond));
+1207    }
+1208    sql.push_str(" ORDER BY sort_name ASC, name ASC");
+1209
+1210    if let Some(lim) = limit {
+1211        sql.push_str(&format!(" LIMIT {}", lim));
+1212    }
+1213    if let Some(off) = offset {
+1214        sql.push_str(&format!(" OFFSET {}", off));
+1215    }
+1216
+1217    let query = Query::with_params(sql, params);
+1218
+1219    let items = db_service
+1220        .query_many(query, row_to_cached_item)
+1221        .await
+1222        .map_err(|e| e.to_string())?;
+1223
+1224    Ok(items)
+1225}
+1226
+1227/// Get a single cached item by ID
+1228#[tauri::command]
+1229#[specta::specta]
+1230pub async fn storage_get_item(
+1231    db: State<'_, DatabaseWrapper>,
+1232    item_id: String,
+1233) -> Result<Option<CachedItem>, String> {
+1234    let db_service = {
+1235        let database = db.0.lock().map_err(|e| e.to_string())?;
+1236        Arc::new(database.service())
+1237    };
+1238
+1239    let query = Query::with_params(
+1240        "SELECT id, name, item_type, parent_id, library_id, overview, genres,
+1241                runtime_ticks, production_year, community_rating, official_rating,
+1242                primary_image_tag, album_id, album_name, album_artist, artists,
+1243                index_number, series_id, series_name, season_id, season_name,
+1244                parent_index_number
+1245         FROM items WHERE id = ?",
+1246        vec![QueryParam::String(item_id)],
+1247    );
+1248
+1249    db_service
+1250        .query_optional(query, row_to_cached_item)
+1251        .await
+1252        .map_err(|e| e.to_string())
+1253}
+1254
+1255/// Search cached items using FTS
+1256#[tauri::command]
+1257#[specta::specta]
+1258pub async fn storage_search_items(
+1259    db: State<'_, DatabaseWrapper>,
+1260    server_id: String,
+1261    query: String,
+1262    limit: Option<i32>,
+1263) -> Result<Vec<CachedItem>, String> {
+1264    let db_service = {
+1265        let database = db.0.lock().map_err(|e| e.to_string())?;
+1266        Arc::new(database.service())
+1267    };
+1268
+1269    // Escape FTS special characters and add prefix matching
+1270    let fts_query = format!("{}*", query.replace('"', "\"\""));
+1271
+1272    let limit_clause = limit.map(|l| format!(" LIMIT {}", l)).unwrap_or_default();
+1273
+1274    let sql = format!(
+1275        "SELECT i.id, i.name, i.item_type, i.parent_id, i.library_id, i.overview, i.genres,
+1276                i.runtime_ticks, i.production_year, i.community_rating, i.official_rating,
+1277                i.primary_image_tag, i.album_id, i.album_name, i.album_artist, i.artists,
+1278                i.index_number, i.series_id, i.series_name, i.season_id, i.season_name,
+1279                i.parent_index_number
+1280         FROM items i
+1281         JOIN items_fts fts ON fts.rowid = i.rowid
+1282         WHERE i.server_id = ? AND items_fts MATCH ?
+1283         ORDER BY rank{}",
+1284        limit_clause
+1285    );
+1286
+1287    let query_obj = Query::with_params(
+1288        sql,
+1289        vec![QueryParam::String(server_id), QueryParam::String(fts_query)],
+1290    );
+1291
+1292    let items = db_service
+1293        .query_many(query_obj, row_to_cached_item)
+1294        .await
+1295        .map_err(|e| e.to_string())?;
+1296
+1297    Ok(items)
+1298}
+1299
+1300/// Save a library to the cache
+1301#[tauri::command]
+1302#[specta::specta]
+1303pub async fn storage_save_library(
+1304    db: State<'_, DatabaseWrapper>,
+1305    id: String,
+1306    server_id: String,
+1307    name: String,
+1308    collection_type: Option<String>,
+1309    image_tag: Option<String>,
+1310    sort_order: Option<i32>,
+1311) -> Result<(), String> {
+1312    let db_service = {
+1313        let database = db.0.lock().map_err(|e| e.to_string())?;
+1314        Arc::new(database.service())
+1315    };
+1316
+1317    let query = Query::with_params(
+1318        "INSERT OR REPLACE INTO libraries (id, server_id, name, collection_type, image_tag, sort_order, synced_at)
+1319         VALUES (?, ?, ?, ?, ?, ?, CURRENT_TIMESTAMP)",
+1320        vec![
+1321            QueryParam::String(id),
+1322            QueryParam::String(server_id),
+1323            QueryParam::String(name),
+1324            collection_type.map(QueryParam::String).unwrap_or(QueryParam::Null),
+1325            image_tag.map(QueryParam::String).unwrap_or(QueryParam::Null),
+1326            QueryParam::Int(sort_order.unwrap_or(0)),
+1327        ],
+1328    );
+1329
+1330    db_service.execute(query).await.map_err(|e| e.to_string())?;
+1331    Ok(())
+1332}
+1333
+1334/// Save an item to the cache
+1335#[tauri::command]
+1336#[specta::specta]
+1337pub async fn storage_save_item(
+1338    db: State<'_, DatabaseWrapper>,
+1339    item: CachedItem,
+1340    server_id: String,
+1341) -> Result<(), String> {
+1342    let db_service = {
+1343        let database = db.0.lock().map_err(|e| e.to_string())?;
+1344        Arc::new(database.service())
+1345    };
+1346
+1347    // Generate sort_name from name (remove leading "The ", "A ", etc.)
+1348    let sort_name = item
+1349        .name
+1350        .strip_prefix("The ")
+1351        .or_else(|| item.name.strip_prefix("A "))
+1352        .or_else(|| item.name.strip_prefix("An "))
+1353        .unwrap_or(&item.name)
+1354        .to_string();
+1355
+1356    let query = Query::with_params(
+1357        "INSERT OR REPLACE INTO items (
+1358            id, server_id, library_id, parent_id, name, sort_name, item_type,
+1359            overview, genres, runtime_ticks, production_year, community_rating,
+1360            official_rating, primary_image_tag, album_id, album_name, album_artist,
+1361            artists, index_number, series_id, series_name, season_id, season_name,
+1362            parent_index_number, synced_at
+1363         ) VALUES (
+1364            ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?,
+1365            ?, ?, ?, ?, ?, ?, ?, ?, CURRENT_TIMESTAMP
+1366         )",
+1367        vec![
+1368            QueryParam::String(item.id),
+1369            QueryParam::String(server_id),
+1370            item.library_id
+1371                .map(QueryParam::String)
+1372                .unwrap_or(QueryParam::Null),
+1373            item.parent_id
+1374                .map(QueryParam::String)
+1375                .unwrap_or(QueryParam::Null),
+1376            QueryParam::String(item.name),
+1377            QueryParam::String(sort_name),
+1378            QueryParam::String(item.item_type),
+1379            item.overview
+1380                .map(QueryParam::String)
+1381                .unwrap_or(QueryParam::Null),
+1382            item.genres
+1383                .map(QueryParam::String)
+1384                .unwrap_or(QueryParam::Null),
+1385            item.runtime_ticks
+1386                .map(QueryParam::Int64)
+1387                .unwrap_or(QueryParam::Null),
+1388            item.production_year
+1389                .map(QueryParam::Int)
+1390                .unwrap_or(QueryParam::Null),
+1391            item.community_rating
+1392                .map(QueryParam::Float)
+1393                .unwrap_or(QueryParam::Null),
+1394            item.official_rating
+1395                .map(QueryParam::String)
+1396                .unwrap_or(QueryParam::Null),
+1397            item.primary_image_tag
+1398                .map(QueryParam::String)
+1399                .unwrap_or(QueryParam::Null),
+1400            item.album_id
+1401                .map(QueryParam::String)
+1402                .unwrap_or(QueryParam::Null),
+1403            item.album_name
+1404                .map(QueryParam::String)
+1405                .unwrap_or(QueryParam::Null),
+1406            item.album_artist
+1407                .map(QueryParam::String)
+1408                .unwrap_or(QueryParam::Null),
+1409            item.artists
+1410                .map(QueryParam::String)
+1411                .unwrap_or(QueryParam::Null),
+1412            item.index_number
+1413                .map(QueryParam::Int)
+1414                .unwrap_or(QueryParam::Null),
+1415            item.series_id
+1416                .map(QueryParam::String)
+1417                .unwrap_or(QueryParam::Null),
+1418            item.series_name
+1419                .map(QueryParam::String)
+1420                .unwrap_or(QueryParam::Null),
+1421            item.season_id
+1422                .map(QueryParam::String)
+1423                .unwrap_or(QueryParam::Null),
+1424            item.season_name
+1425                .map(QueryParam::String)
+1426                .unwrap_or(QueryParam::Null),
+1427            item.parent_index_number
+1428                .map(QueryParam::Int)
+1429                .unwrap_or(QueryParam::Null),
+1430        ],
+1431    );
+1432
+1433    db_service.execute(query).await.map_err(|e| e.to_string())?;
+1434    Ok(())
+1435}
+1436
+1437/// Get count of pending sync operations for a user
+1438#[tauri::command]
+1439#[specta::specta]
+1440pub async fn storage_get_pending_sync_count(
+1441    db: State<'_, DatabaseWrapper>,
+1442    user_id: String,
+1443) -> Result<i32, String> {
+1444    let db_service = {
+1445        let database = db.0.lock().map_err(|e| e.to_string())?;
+1446        Arc::new(database.service())
+1447    };
+1448
+1449    let query = Query::with_params(
+1450        "SELECT COUNT(*) FROM user_data WHERE user_id = ? AND pending_sync = 1",
+1451        vec![QueryParam::String(user_id)],
+1452    );
+1453
+1454    let count: i32 = db_service
+1455        .query_one(query, |row| row.get(0))
+1456        .await
+1457        .map_err(|e| e.to_string())?;
+1458
+1459    Ok(count)
+1460}
+1461
+1462#[cfg(test)]
+1463mod tests {
+1464    use super::*;
+1465
+1466    #[test]
+1467    fn test_server_info_serialization() {
+1468        let server = ServerInfo {
+1469            id: "server-123".to_string(),
+1470            name: "My Server".to_string(),
+1471            url: "https://jellyfin.example.com".to_string(),
+1472            version: Some("10.8.0".to_string()),
+1473        };
+1474
+1475        let json = serde_json::to_string(&server);
+1476        assert!(json.is_ok());
+1477        let serialized = json.unwrap();
+1478        assert!(serialized.contains("server-123"));
+1479        assert!(serialized.contains("My Server"));
+1480    }
+1481
+1482    #[test]
+1483    fn test_server_info_without_version() {
+1484        let server = ServerInfo {
+1485            id: "server-456".to_string(),
+1486            name: "Test Server".to_string(),
+1487            url: "https://test.local".to_string(),
+1488            version: None,
+1489        };
+1490
+1491        let json = serde_json::to_string(&server).unwrap();
+1492        assert!(json.contains("null") || json.contains("\"version\":null"));
+1493    }
+1494
+1495    #[test]
+1496    fn test_server_info_roundtrip() {
+1497        let original = ServerInfo {
+1498            id: "srv-999".to_string(),
+1499            name: "Production".to_string(),
+1500            url: "https://prod.jellyfin.example.com:8096".to_string(),
+1501            version: Some("10.9.0".to_string()),
+1502        };
+1503
+1504        let json = serde_json::to_string(&original).unwrap();
+1505        let deserialized: ServerInfo = serde_json::from_str(&json).unwrap();
+1506
+1507        assert_eq!(original.id, deserialized.id);
+1508        assert_eq!(original.name, deserialized.name);
+1509        assert_eq!(original.url, deserialized.url);
+1510        assert_eq!(original.version, deserialized.version);
+1511    }
+1512
+1513    #[test]
+1514    fn test_user_info_serialization() {
+1515        let user = UserInfo {
+1516            id: "user-123".to_string(),
+1517            server_id: "server-456".to_string(),
+1518            username: "john_doe".to_string(),
+1519            is_active: true,
+1520        };
+1521
+1522        let json = serde_json::to_string(&user);
+1523        assert!(json.is_ok());
+1524        let serialized = json.unwrap();
+1525        assert!(serialized.contains("user-123"));
+1526        assert!(serialized.contains("john_doe"));
+1527    }
+1528
+1529    #[test]
+1530    fn test_user_info_inactive() {
+1531        let user = UserInfo {
+1532            id: "user-inactive".to_string(),
+1533            server_id: "server-789".to_string(),
+1534            username: "jane_doe".to_string(),
+1535            is_active: false,
+1536        };
+1537
+1538        let json = serde_json::to_string(&user).unwrap();
+1539        assert!(json.contains("false"));
+1540
+1541        let deserialized: UserInfo = serde_json::from_str(&json).unwrap();
+1542        assert!(!deserialized.is_active);
+1543    }
+1544
+1545    #[test]
+1546    fn test_active_session_serialization() {
+1547        let session = ActiveSession {
+1548            user_id: "user-001".to_string(),
+1549            username: "alice".to_string(),
+1550            server_id: "server-001".to_string(),
+1551            server_url: "https://jellyfin.example.com".to_string(),
+1552            server_name: "Home Jellyfin".to_string(),
+1553            access_token: "very-long-token-string-abc123".to_string(),
+1554        };
+1555
+1556        let json = serde_json::to_string(&session);
+1557        assert!(json.is_ok());
+1558        let serialized = json.unwrap();
+1559        assert!(serialized.contains("alice"));
+1560        assert!(serialized.contains("Home Jellyfin"));
+1561    }
+1562
+1563    #[test]
+1564    fn test_active_session_roundtrip() {
+1565        let original = ActiveSession {
+1566            user_id: "u999".to_string(),
+1567            username: "testuser".to_string(),
+1568            server_id: "s999".to_string(),
+1569            server_url: "https://test.example.com:8096".to_string(),
+1570            server_name: "Test Server".to_string(),
+1571            access_token: "token-xyz".to_string(),
+1572        };
+1573
+1574        let json = serde_json::to_string(&original).unwrap();
+1575        let deserialized: ActiveSession = serde_json::from_str(&json).unwrap();
+1576
+1577        assert_eq!(original.user_id, deserialized.user_id);
+1578        assert_eq!(original.username, deserialized.username);
+1579        assert_eq!(original.access_token, deserialized.access_token);
+1580    }
+1581
+1582    #[test]
+1583    fn test_security_status_with_keyring() {
+1584        let status = SecurityStatus {
+1585            using_keyring: true,
+1586            storage_type: "system_keyring".to_string(),
+1587        };
+1588
+1589        let json = serde_json::to_string(&status).unwrap();
+1590        assert!(json.contains("true"));
+1591        assert!(json.contains("system_keyring"));
+1592    }
+1593
+1594    #[test]
+1595    fn test_security_status_with_encrypted_file() {
+1596        let status = SecurityStatus {
+1597            using_keyring: false,
+1598            storage_type: "encrypted_file".to_string(),
+1599        };
+1600
+1601        let json = serde_json::to_string(&status).unwrap();
+1602        assert!(json.contains("false"));
+1603        assert!(json.contains("encrypted_file"));
+1604    }
+1605
+1606    #[test]
+1607    fn test_playback_progress_serialization() {
+1608        let progress = PlaybackProgress {
+1609            item_id: "item-123".to_string(),
+1610            position_ms: 150_000_000,
+1611            is_played: true,
+1612            is_favorite: false,
+1613            play_count: 3,
+1614        };
+1615
+1616        let json = serde_json::to_string(&progress);
+1617        assert!(json.is_ok());
+1618        let serialized = json.unwrap();
+1619        assert!(serialized.contains("item-123"));
+1620        assert!(serialized.contains("150000000"));
+1621    }
+1622
+1623    #[test]
+1624    fn test_playback_progress_played_status() {
+1625        let progress = PlaybackProgress {
+1626            item_id: "item-456".to_string(),
+1627            position_ms: 0,
+1628            is_played: true,
+1629            is_favorite: true,
+1630            play_count: 1,
+1631        };
+1632
+1633        let json = serde_json::to_string(&progress).unwrap();
+1634        let deserialized: PlaybackProgress = serde_json::from_str(&json).unwrap();
+1635
+1636        assert!(deserialized.is_played);
+1637        assert!(deserialized.is_favorite);
+1638        assert_eq!(deserialized.play_count, 1);
+1639    }
+1640
+1641    #[test]
+1642    fn test_playback_progress_not_played() {
+1643        let progress = PlaybackProgress {
+1644            item_id: "item-789".to_string(),
+1645            position_ms: 30_000_000,
+1646            is_played: false,
+1647            is_favorite: false,
+1648            play_count: 0,
+1649        };
+1650
+1651        let json = serde_json::to_string(&progress).unwrap();
+1652        let deserialized: PlaybackProgress = serde_json::from_str(&json).unwrap();
+1653
+1654        assert!(!deserialized.is_played);
+1655        assert_eq!(deserialized.play_count, 0);
+1656    }
+1657
+1658    #[test]
+1659    fn test_database_wrapper_structure() {
+1660        // Verify DatabaseWrapper can be created and holds Mutex<Database>
+1661        assert!(std::mem::size_of::<DatabaseWrapper>() > 0);
+1662    }
+1663
+1664    #[test]
+1665    fn test_credential_store_wrapper_structure() {
+1666        // Verify CredentialStoreWrapper can be created
+1667        assert!(std::mem::size_of::<CredentialStoreWrapper>() > 0);
+1668    }
+1669
+1670    #[test]
+1671    fn test_thumbnail_cache_wrapper_structure() {
+1672        // Verify ThumbnailCacheWrapper holds Arc<ThumbnailCache>
+1673        assert!(std::mem::size_of::<ThumbnailCacheWrapper>() > 0);
+1674    }
+1675
+1676    #[test]
+1677    fn test_user_info_camel_case() {
+1678        let user = UserInfo {
+1679            id: "u1".to_string(),
+1680            server_id: "s1".to_string(),
+1681            username: "user1".to_string(),
+1682            is_active: true,
+1683        };
+1684
+1685        let json = serde_json::to_string(&user).unwrap();
+1686        // Verify camelCase serialization
+1687        assert!(json.contains("serverId"));
+1688        assert!(json.contains("isActive"));
+1689    }
+1690
+1691    #[test]
+1692    fn test_active_session_camel_case() {
+1693        let session = ActiveSession {
+1694            user_id: "u1".to_string(),
+1695            username: "user1".to_string(),
+1696            server_id: "s1".to_string(),
+1697            server_url: "url1".to_string(),
+1698            server_name: "name1".to_string(),
+1699            access_token: "token1".to_string(),
+1700        };
+1701
+1702        let json = serde_json::to_string(&session).unwrap();
+1703        // Verify camelCase serialization
+1704        assert!(json.contains("userId"));
+1705        assert!(json.contains("serverId"));
+1706        assert!(json.contains("serverUrl"));
+1707        assert!(json.contains("serverName"));
+1708        assert!(json.contains("accessToken"));
+1709    }
+1710
+1711    #[test]
+1712    fn test_playback_progress_camel_case() {
+1713        let progress = PlaybackProgress {
+1714            item_id: "i1".to_string(),
+1715            position_ms: 100,
+1716            is_played: true,
+1717            is_favorite: false,
+1718            play_count: 1,
+1719        };
+1720
+1721        let json = serde_json::to_string(&progress).unwrap();
+1722        // Verify camelCase serialization
+1723        assert!(json.contains("itemId"));
+1724        assert!(json.contains("positionMs"));
+1725        assert!(json.contains("isPlayed"));
+1726        assert!(json.contains("isFavorite"));
+1727        assert!(json.contains("playCount"));
+1728    }
+1729}
+
\ No newline at end of file diff --git a/api/src/jellytau_lib/commands/storage/people.rs.html b/api/src/jellytau_lib/commands/storage/people.rs.html new file mode 100644 index 00000000..afef96ba --- /dev/null +++ b/api/src/jellytau_lib/commands/storage/people.rs.html @@ -0,0 +1,214 @@ +people.rs - sourceSkip to main content

jellytau_lib/commands/storage/
people.rs

1//! Person/cast metadata cache commands.
+2//!
+3//! TRACES: UR-035, UR-036 | IR-023 | DR-040, DR-041
+4
+5use serde::{Deserialize, Serialize};
+6use std::sync::Arc;
+7use tauri::State;
+8
+9use super::DatabaseWrapper;
+10use crate::storage::db_service::{DatabaseService, Query, QueryParam};
+11
+12/// Cached person info returned to frontend
+13#[derive(specta::Type, Debug, Clone, Serialize, Deserialize)]
+14#[serde(rename_all = "camelCase")]
+15pub struct CachedPerson {
+16    pub id: String,
+17    pub server_id: String,
+18    pub name: String,
+19    pub overview: Option<String>,
+20    pub primary_image_tag: Option<String>,
+21    pub premiere_date: Option<String>,
+22    pub end_date: Option<String>,
+23}
+24
+25/// Item-person association for caching
+26#[derive(specta::Type, Debug, Clone, Serialize, Deserialize)]
+27#[serde(rename_all = "camelCase")]
+28pub struct CachedItemPerson {
+29    pub item_id: String,
+30    pub person_id: String,
+31    pub server_id: String,
+32    pub person_type: String,
+33    pub role: Option<String>,
+34    pub sort_order: i32,
+35}
+36
+37/// Save a person to the cache
+38#[tauri::command]
+39#[specta::specta]
+40pub async fn storage_save_person(
+41    db: State<'_, DatabaseWrapper>,
+42    person: CachedPerson,
+43) -> Result<(), String> {
+44    let db_service = {
+45        let database = db.0.lock().map_err(|e| e.to_string())?;
+46        Arc::new(database.service())
+47    };
+48
+49    let query = Query::with_params(
+50        // A real UPSERT, not INSERT OR REPLACE — `people` is now backed by the
+51        // `people_fts` index (migration 022), and REPLACE would orphan an index
+52        // entry on every re-cache: it fires no AFTER DELETE trigger without
+53        // `recursive_triggers`, and reassigns the rowid that `content_rowid`
+54        // refers to. Same defect as DR-110 fixed for `items`.
+55        //
+56        // TRACES: UR-065 | DR-110, DR-111
+57        "INSERT INTO people (
+58            id, server_id, name, overview, primary_image_tag,
+59            premiere_date, end_date, synced_at
+60        ) VALUES (?, ?, ?, ?, ?, ?, ?, CURRENT_TIMESTAMP)
+61        ON CONFLICT(id) DO UPDATE SET
+62            server_id = excluded.server_id,
+63            name = excluded.name,
+64            overview = excluded.overview,
+65            primary_image_tag = excluded.primary_image_tag,
+66            premiere_date = excluded.premiere_date,
+67            end_date = excluded.end_date,
+68            synced_at = CURRENT_TIMESTAMP",
+69        vec![
+70            QueryParam::String(person.id),
+71            QueryParam::String(person.server_id),
+72            QueryParam::String(person.name),
+73            person
+74                .overview
+75                .map(QueryParam::String)
+76                .unwrap_or(QueryParam::Null),
+77            person
+78                .primary_image_tag
+79                .map(QueryParam::String)
+80                .unwrap_or(QueryParam::Null),
+81            person
+82                .premiere_date
+83                .map(QueryParam::String)
+84                .unwrap_or(QueryParam::Null),
+85            person
+86                .end_date
+87                .map(QueryParam::String)
+88                .unwrap_or(QueryParam::Null),
+89        ],
+90    );
+91
+92    db_service.execute(query).await.map_err(|e| e.to_string())?;
+93    Ok(())
+94}
+95
+96/// Get a cached person by ID
+97#[tauri::command]
+98#[specta::specta]
+99pub async fn storage_get_person(
+100    db: State<'_, DatabaseWrapper>,
+101    person_id: String,
+102) -> Result<Option<CachedPerson>, String> {
+103    let db_service = {
+104        let database = db.0.lock().map_err(|e| e.to_string())?;
+105        Arc::new(database.service())
+106    };
+107
+108    let query = Query::with_params(
+109        "SELECT id, server_id, name, overview, primary_image_tag, premiere_date, end_date
+110         FROM people WHERE id = ?",
+111        vec![QueryParam::String(person_id)],
+112    );
+113
+114    let result = db_service
+115        .query_optional(query, |row| {
+116            Ok(CachedPerson {
+117                id: row.get(0)?,
+118                server_id: row.get(1)?,
+119                name: row.get(2)?,
+120                overview: row.get(3)?,
+121                primary_image_tag: row.get(4)?,
+122                premiere_date: row.get(5)?,
+123                end_date: row.get(6)?,
+124            })
+125        })
+126        .await
+127        .map_err(|e| e.to_string())?;
+128
+129    Ok(result)
+130}
+131
+132/// Save item-person associations (batch)
+133#[tauri::command]
+134#[specta::specta]
+135pub async fn storage_save_item_people(
+136    db: State<'_, DatabaseWrapper>,
+137    associations: Vec<CachedItemPerson>,
+138) -> Result<(), String> {
+139    let db_service = {
+140        let database = db.0.lock().map_err(|e| e.to_string())?;
+141        Arc::new(database.service())
+142    };
+143
+144    // Clone associations for the closure
+145    let associations_clone = associations.clone();
+146
+147    // Use transaction for batch insert
+148    db_service
+149        .transaction(move |tx| {
+150            for assoc in &associations_clone {
+151                let query = Query::with_params(
+152                    "INSERT OR REPLACE INTO item_people (
+153                    item_id, person_id, server_id, person_type, role, sort_order, synced_at
+154                ) VALUES (?, ?, ?, ?, ?, ?, CURRENT_TIMESTAMP)",
+155                    vec![
+156                        QueryParam::String(assoc.item_id.clone()),
+157                        QueryParam::String(assoc.person_id.clone()),
+158                        QueryParam::String(assoc.server_id.clone()),
+159                        QueryParam::String(assoc.person_type.clone()),
+160                        assoc
+161                            .role
+162                            .clone()
+163                            .map(QueryParam::String)
+164                            .unwrap_or(QueryParam::Null),
+165                        QueryParam::Int(assoc.sort_order),
+166                    ],
+167                );
+168                tx.execute(query)?;
+169            }
+170            Ok(())
+171        })
+172        .await
+173        .map_err(|e| e.to_string())?;
+174
+175    Ok(())
+176}
+177
+178/// Get people for an item (with person details joined)
+179#[tauri::command]
+180#[specta::specta]
+181pub async fn storage_get_item_people(
+182    db: State<'_, DatabaseWrapper>,
+183    item_id: String,
+184) -> Result<Vec<CachedItemPerson>, String> {
+185    let db_service = {
+186        let database = db.0.lock().map_err(|e| e.to_string())?;
+187        Arc::new(database.service())
+188    };
+189
+190    let query = Query::with_params(
+191        "SELECT ip.item_id, ip.person_id, ip.server_id, ip.person_type, ip.role, ip.sort_order
+192         FROM item_people ip
+193         WHERE ip.item_id = ?
+194         ORDER BY ip.sort_order ASC",
+195        vec![QueryParam::String(item_id)],
+196    );
+197
+198    let people = db_service
+199        .query_many(query, |row| {
+200            Ok(CachedItemPerson {
+201                item_id: row.get(0)?,
+202                person_id: row.get(1)?,
+203                server_id: row.get(2)?,
+204                person_type: row.get(3)?,
+205                role: row.get(4)?,
+206                sort_order: row.get(5)?,
+207            })
+208        })
+209        .await
+210        .map_err(|e| e.to_string())?;
+211
+212    Ok(people)
+213}
+
\ No newline at end of file diff --git a/api/src/jellytau_lib/commands/storage/series_prefs.rs.html b/api/src/jellytau_lib/commands/storage/series_prefs.rs.html new file mode 100644 index 00000000..aba55ca6 --- /dev/null +++ b/api/src/jellytau_lib/commands/storage/series_prefs.rs.html @@ -0,0 +1,96 @@ +series_prefs.rs - sourceSkip to main content

jellytau_lib/commands/storage/
series_prefs.rs

1//! Per-series preferred audio track commands.
+2//!
+3//! TRACES: UR-021 | DR-024
+4
+5use serde::{Deserialize, Serialize};
+6use std::sync::Arc;
+7use tauri::State;
+8
+9use super::DatabaseWrapper;
+10use crate::storage::db_service::{DatabaseService, Query, QueryParam};
+11
+12/// Audio track preference for a series
+13#[derive(specta::Type, Debug, Clone, Serialize, Deserialize)]
+14#[serde(rename_all = "camelCase")]
+15pub struct SeriesAudioPreference {
+16    pub series_id: String,
+17    pub audio_track_display_title: Option<String>,
+18    pub audio_track_language: Option<String>,
+19    pub audio_track_index: Option<i32>,
+20}
+21
+22/// Save user's preferred audio track for a series
+23#[tauri::command]
+24#[specta::specta]
+25pub async fn storage_save_series_audio_preference(
+26    db: State<'_, DatabaseWrapper>,
+27    user_id: String,
+28    series_id: String,
+29    server_id: String,
+30    audio_track_display_title: Option<String>,
+31    audio_track_language: Option<String>,
+32    audio_track_index: Option<i32>,
+33) -> Result<(), String> {
+34    let db_service = {
+35        let database = db.0.lock().map_err(|e| e.to_string())?;
+36        Arc::new(database.service())
+37    };
+38
+39    let query = Query::with_params(
+40        "INSERT INTO series_audio_preferences
+41            (user_id, series_id, server_id, audio_track_display_title, audio_track_language, audio_track_index, updated_at)
+42         VALUES (?, ?, ?, ?, ?, ?, datetime('now'))
+43         ON CONFLICT (user_id, series_id, server_id) DO UPDATE SET
+44            audio_track_display_title = excluded.audio_track_display_title,
+45            audio_track_language = excluded.audio_track_language,
+46            audio_track_index = excluded.audio_track_index,
+47            updated_at = datetime('now')",
+48        vec![
+49            QueryParam::String(user_id),
+50            QueryParam::String(series_id),
+51            QueryParam::String(server_id),
+52            audio_track_display_title.map(QueryParam::String).unwrap_or(QueryParam::Null),
+53            audio_track_language.map(QueryParam::String).unwrap_or(QueryParam::Null),
+54            audio_track_index.map(|i| QueryParam::Int64(i as i64)).unwrap_or(QueryParam::Null),
+55        ],
+56    );
+57
+58    db_service.execute(query).await.map_err(|e| e.to_string())?;
+59    Ok(())
+60}
+61
+62/// Get user's preferred audio track for a series
+63#[tauri::command]
+64#[specta::specta]
+65pub async fn storage_get_series_audio_preference(
+66    db: State<'_, DatabaseWrapper>,
+67    user_id: String,
+68    series_id: String,
+69) -> Result<Option<SeriesAudioPreference>, String> {
+70    let db_service = {
+71        let database = db.0.lock().map_err(|e| e.to_string())?;
+72        Arc::new(database.service())
+73    };
+74
+75    let query = Query::with_params(
+76        "SELECT series_id, audio_track_display_title, audio_track_language, audio_track_index
+77         FROM series_audio_preferences
+78         WHERE user_id = ? AND series_id = ?",
+79        vec![QueryParam::String(user_id), QueryParam::String(series_id)],
+80    );
+81
+82    let preference = db_service
+83        .query_optional(query, |row| {
+84            Ok(SeriesAudioPreference {
+85                series_id: row.get(0)?,
+86                audio_track_display_title: row.get(1)?,
+87                audio_track_language: row.get(2)?,
+88                audio_track_index: row.get(3)?,
+89            })
+90        })
+91        .await
+92        .map_err(|e| e.to_string())?;
+93
+94    Ok(preference)
+95}
+
\ No newline at end of file diff --git a/api/src/jellytau_lib/commands/storage/thumbnails.rs.html b/api/src/jellytau_lib/commands/storage/thumbnails.rs.html new file mode 100644 index 00000000..df270efd --- /dev/null +++ b/api/src/jellytau_lib/commands/storage/thumbnails.rs.html @@ -0,0 +1,267 @@ +thumbnails.rs - sourceSkip to main content

jellytau_lib/commands/storage/
thumbnails.rs

1//! Thumbnail cache and image-URL commands.
+2//!
+3//! TRACES: UR-007 | JA-028 | DR-016
+4
+5use serde::Deserialize;
+6use std::sync::{Arc, OnceLock};
+7use tauri::State;
+8use tokio::sync::Semaphore;
+9
+10use super::{DatabaseWrapper, ThumbnailCacheWrapper};
+11use crate::commands::repository::RepositoryManagerWrapper;
+12use crate::repository::types::{ImageOptions, ImageType};
+13use crate::repository::MediaRepository;
+14use crate::thumbnail::{ThumbnailCacheStats, ThumbnailWorker};
+15
+16/// Get cached thumbnail path, returns None if not cached
+17/// Also updates last_accessed timestamp for LRU tracking
+18#[tauri::command]
+19#[specta::specta]
+20pub async fn thumbnail_get_cached(
+21    db: State<'_, DatabaseWrapper>,
+22    thumbnail_cache: State<'_, ThumbnailCacheWrapper>,
+23    item_id: String,
+24    image_type: String,
+25    tag: String,
+26) -> Result<Option<String>, String> {
+27    let db_service = {
+28        let database = db.0.lock().map_err(|e| e.to_string())?;
+29        Arc::new(database.service())
+30    };
+31
+32    let result = thumbnail_cache
+33        .0
+34        .get_cached_path(db_service, &item_id, &image_type, &tag)
+35        .await
+36        .map(|p| p.to_string_lossy().to_string());
+37
+38    Ok(result)
+39}
+40
+41/// Download and save a thumbnail to cache
+42/// Returns the local file path on success
+43#[tauri::command]
+44#[specta::specta]
+45pub async fn thumbnail_save(
+46    db: State<'_, DatabaseWrapper>,
+47    thumbnail_cache: State<'_, ThumbnailCacheWrapper>,
+48    item_id: String,
+49    image_type: String,
+50    tag: String,
+51    url: String,
+52) -> Result<String, String> {
+53    // Download the image
+54    let worker = ThumbnailWorker::new();
+55    let data = worker
+56        .download_with_retry(&url, 2)
+57        .await
+58        .map_err(|e| e.to_string())?;
+59
+60    // Save to cache
+61    let db_service = {
+62        let database = db.0.lock().map_err(|e| e.to_string())?;
+63        Arc::new(database.service())
+64    };
+65
+66    let path = thumbnail_cache
+67        .0
+68        .save_thumbnail(db_service, &item_id, &image_type, &tag, &data, None, None)
+69        .await?;
+70    Ok(path.to_string_lossy().to_string())
+71}
+72
+73/// Get thumbnail cache statistics
+74#[tauri::command]
+75#[specta::specta]
+76pub async fn thumbnail_get_stats(
+77    db: State<'_, DatabaseWrapper>,
+78    thumbnail_cache: State<'_, ThumbnailCacheWrapper>,
+79) -> Result<ThumbnailCacheStats, String> {
+80    let db_service = {
+81        let database = db.0.lock().map_err(|e| e.to_string())?;
+82        Arc::new(database.service())
+83    };
+84
+85    let total_size_bytes = thumbnail_cache.0.get_cache_size(db_service.clone()).await;
+86    let item_count = thumbnail_cache.0.get_item_count(db_service.clone()).await;
+87    let limit_bytes = thumbnail_cache.0.get_limit(db_service).await;
+88
+89    Ok(ThumbnailCacheStats {
+90        total_size_bytes,
+91        item_count,
+92        limit_bytes,
+93    })
+94}
+95
+96/// Set thumbnail cache storage limit in bytes
+97#[tauri::command]
+98#[specta::specta]
+99pub async fn thumbnail_set_limit(
+100    db: State<'_, DatabaseWrapper>,
+101    thumbnail_cache: State<'_, ThumbnailCacheWrapper>,
+102    limit_bytes: u64,
+103) -> Result<(), String> {
+104    let db_service = {
+105        let database = db.0.lock().map_err(|e| e.to_string())?;
+106        Arc::new(database.service())
+107    };
+108
+109    thumbnail_cache.0.set_limit(db_service, limit_bytes).await
+110}
+111
+112/// Clear all cached thumbnails
+113#[tauri::command]
+114#[specta::specta]
+115pub async fn thumbnail_clear_cache(
+116    db: State<'_, DatabaseWrapper>,
+117    thumbnail_cache: State<'_, ThumbnailCacheWrapper>,
+118) -> Result<(), String> {
+119    let db_service = {
+120        let database = db.0.lock().map_err(|e| e.to_string())?;
+121        Arc::new(database.service())
+122    };
+123
+124    thumbnail_cache.0.clear_cache(db_service).await
+125}
+126
+127/// Delete cached thumbnails for a specific item
+128#[tauri::command]
+129#[specta::specta]
+130pub async fn thumbnail_delete_item(
+131    db: State<'_, DatabaseWrapper>,
+132    thumbnail_cache: State<'_, ThumbnailCacheWrapper>,
+133    item_id: String,
+134) -> Result<(), String> {
+135    let db_service = {
+136        let database = db.0.lock().map_err(|e| e.to_string())?;
+137        Arc::new(database.service())
+138    };
+139
+140    thumbnail_cache.0.delete_item(db_service, &item_id).await
+141}
+142
+143fn mime_from_ext(ext: Option<&str>) -> &'static str {
+144    match ext {
+145        Some("jpg") | Some("jpeg") => "image/jpeg",
+146        Some("png") => "image/png",
+147        Some("gif") => "image/gif",
+148        Some("webp") => "image/webp",
+149        _ => "image/jpeg",
+150    }
+151}
+152
+153/// Limit concurrent image downloads to avoid saturating the connection pool.
+154/// Without this, rendering a page with hundreds of album cards fires hundreds of
+155/// concurrent HTTP requests, starving API calls and causing timeouts.
+156static IMAGE_DOWNLOAD_SEMAPHORE: OnceLock<Semaphore> = OnceLock::new();
+157
+158fn image_semaphore() -> &'static Semaphore {
+159    IMAGE_DOWNLOAD_SEMAPHORE.get_or_init(|| Semaphore::new(6))
+160}
+161
+162/// Request to get an image URL (with caching)
+163#[derive(specta::Type, Debug, Deserialize)]
+164#[serde(rename_all = "camelCase")]
+165pub struct GetImageRequest {
+166    pub item_id: String,
+167    pub image_type: String,
+168    #[serde(skip_serializing_if = "Option::is_none")]
+169    pub max_width: Option<u32>,
+170    #[serde(skip_serializing_if = "Option::is_none")]
+171    pub max_height: Option<u32>,
+172    #[serde(skip_serializing_if = "Option::is_none")]
+173    pub tag: Option<String>,
+174}
+175
+176/// Get image as base64 data URL, caching if not already cached
+177/// This extends the thumbnail system to serve all images through Rust with automatic caching
+178#[tauri::command]
+179#[specta::specta]
+180pub async fn image_get_url(
+181    repository_manager: State<'_, RepositoryManagerWrapper>,
+182    thumbnail_cache: State<'_, ThumbnailCacheWrapper>,
+183    db: State<'_, DatabaseWrapper>,
+184    repository_handle: String,
+185    request: GetImageRequest,
+186) -> Result<String, String> {
+187    use base64::{engine::general_purpose::STANDARD as BASE64, Engine as _};
+188    use std::fs;
+189
+190    let tag = request.tag.as_deref().unwrap_or("default");
+191
+192    // Get database service
+193    let db_service = {
+194        let database = db.0.lock().map_err(|e| e.to_string())?;
+195        Arc::new(database.service())
+196    };
+197
+198    // Check cache first
+199    if let Some(cached_path) = thumbnail_cache
+200        .0
+201        .get_cached_path(
+202            db_service.clone(),
+203            &request.item_id,
+204            &request.image_type,
+205            tag,
+206        )
+207        .await
+208    {
+209        let image_data =
+210            fs::read(&cached_path).map_err(|e| format!("Failed to read cached image: {}", e))?;
+211        let base64_data = BASE64.encode(&image_data);
+212        let mime_type = mime_from_ext(cached_path.extension().and_then(|s| s.to_str()));
+213        return Ok(format!("data:{};base64,{}", mime_type, base64_data));
+214    }
+215
+216    // Not cached — fetch from server and cache.
+217    // Acquire semaphore to limit concurrent downloads (prevents connection pool starvation).
+218    let _permit = image_semaphore()
+219        .acquire()
+220        .await
+221        .map_err(|_| "Image download semaphore closed".to_string())?;
+222
+223    let repository = repository_manager
+224        .0
+225        .get(&repository_handle)
+226        .ok_or_else(|| "Repository not found - user may need to log in".to_string())?;
+227
+228    let image_type_enum = match request.image_type.as_str() {
+229        "Primary" => ImageType::Primary,
+230        "Backdrop" => ImageType::Backdrop,
+231        "Banner" => ImageType::Banner,
+232        "Thumb" => ImageType::Thumb,
+233        "Logo" => ImageType::Logo,
+234        _ => ImageType::Primary,
+235    };
+236
+237    let options = ImageOptions {
+238        max_width: request.max_width,
+239        max_height: request.max_height,
+240        quality: Some(90),
+241        tag: request.tag.clone(),
+242    };
+243
+244    let server_url = repository.get_image_url(&request.item_id, image_type_enum, Some(options));
+245    let image_data = repository
+246        .download_bytes(&server_url)
+247        .await
+248        .map_err(|e| format!("Failed to download image: {}", e))?;
+249
+250    let cached_path = thumbnail_cache
+251        .0
+252        .save_thumbnail(
+253            db_service,
+254            &request.item_id,
+255            &request.image_type,
+256            tag,
+257            &image_data,
+258            request.max_width.map(|w| w as i32),
+259            request.max_height.map(|h| h as i32),
+260        )
+261        .await?;
+262
+263    let base64_data = BASE64.encode(&image_data);
+264    let mime_type = mime_from_ext(cached_path.extension().and_then(|s| s.to_str()));
+265    Ok(format!("data:{};base64,{}", mime_type, base64_data))
+266}
+
\ No newline at end of file diff --git a/api/src/jellytau_lib/commands/sync.rs.html b/api/src/jellytau_lib/commands/sync.rs.html new file mode 100644 index 00000000..619b104f --- /dev/null +++ b/api/src/jellytau_lib/commands/sync.rs.html @@ -0,0 +1,391 @@ +sync.rs - sourceSkip to main content

jellytau_lib/commands/
sync.rs

1//! Tauri commands for sync queue operations
+2//!
+3//! The sync queue stores mutations (favorites, playback progress, etc.)
+4//! that need to be synced to the Jellyfin server when connectivity is restored.
+5//! Draining it lives in `sync_drain` (DR-131); this module is the storage and
+6//! read side the UI lists from (DR-132).
+7//! TRACES: UR-002, UR-017, UR-025 | DR-014, DR-131, DR-132
+8
+9use serde::{Deserialize, Serialize};
+10use std::sync::Arc;
+11use tauri::State;
+12
+13use super::storage::DatabaseWrapper;
+14use crate::storage::db_service::{DatabaseService, Query, QueryParam};
+15
+16/// Sync queue item returned to frontend
+17#[derive(specta::Type, Debug, Clone, Serialize, Deserialize)]
+18#[serde(rename_all = "camelCase")]
+19pub struct SyncQueueItem {
+20    pub id: i64,
+21    pub user_id: String,
+22    pub operation: String,
+23    pub item_id: Option<String>,
+24    pub payload: Option<String>,
+25    pub status: String,
+26    pub retry_count: i32,
+27    pub created_at: Option<String>,
+28    pub error_message: Option<String>,
+29    /// Cached title of the item the operation is about, when the catalog knows
+30    /// it. Resolved here rather than by a per-row frontend fetch — the queue
+31    /// list is otherwise a wall of opaque ids.
+32    ///
+33    /// TRACES: UR-025 | DR-132
+34    pub item_name: Option<String>,
+35}
+36
+37/// Queue a mutation for sync to server
+38#[tauri::command]
+39#[specta::specta]
+40pub async fn sync_queue_mutation(
+41    db: State<'_, DatabaseWrapper>,
+42    user_id: String,
+43    operation: String,
+44    item_id: Option<String>,
+45    payload: Option<String>,
+46) -> Result<i64, String> {
+47    let db_service = {
+48        let database = db.0.lock().map_err(|e| e.to_string())?;
+49        Arc::new(database.service())
+50    };
+51
+52    let query = Query::with_params(
+53        "INSERT INTO sync_queue (user_id, operation, item_id, payload, status, created_at)
+54         VALUES (?, ?, ?, ?, 'pending', CURRENT_TIMESTAMP)",
+55        vec![
+56            QueryParam::String(user_id),
+57            QueryParam::String(operation),
+58            item_id.map(QueryParam::String).unwrap_or(QueryParam::Null),
+59            payload.map(QueryParam::String).unwrap_or(QueryParam::Null),
+60        ],
+61    );
+62
+63    db_service.execute(query).await.map_err(|e| e.to_string())?;
+64    let id = db_service
+65        .last_insert_rowid()
+66        .await
+67        .map_err(|e| e.to_string())?;
+68
+69    Ok(id)
+70}
+71
+72/// Get all pending sync operations for a user
+73#[tauri::command]
+74#[specta::specta]
+75pub async fn sync_get_pending(
+76    db: State<'_, DatabaseWrapper>,
+77    user_id: String,
+78    limit: Option<i32>,
+79) -> Result<Vec<SyncQueueItem>, String> {
+80    let db_service = {
+81        let database = db.0.lock().map_err(|e| e.to_string())?;
+82        Arc::new(database.service())
+83    };
+84
+85    // The `items` join names the queued item where the catalog has it; a row for
+86    // an item that was never cached still lists, with a null name.
+87    // `abandoned` rows (DR-131 gave up on them) are excluded here for the same
+88    // reason they are excluded from the count — they are no longer waiting.
+89    const SELECT: &str = "SELECT q.id, q.user_id, q.operation, q.item_id, q.payload, q.status,
+90                                 COALESCE(q.retry_count, 0), q.created_at, q.error_message, i.name
+91                          FROM sync_queue q
+92                          LEFT JOIN items i ON i.id = q.item_id
+93                          WHERE q.user_id = ? AND q.status IN ('pending', 'failed')
+94                          ORDER BY q.created_at ASC, q.id ASC";
+95
+96    let sql = match limit {
+97        Some(l) => format!("{} LIMIT {}", SELECT, l),
+98        None => SELECT.to_string(),
+99    };
+100
+101    let query = Query::with_params(sql, vec![QueryParam::String(user_id)]);
+102
+103    db_service
+104        .query_many(query, |row| {
+105            Ok(SyncQueueItem {
+106                id: row.get(0)?,
+107                user_id: row.get(1)?,
+108                operation: row.get(2)?,
+109                item_id: row.get(3)?,
+110                payload: row.get(4)?,
+111                status: row.get(5)?,
+112                retry_count: row.get(6)?,
+113                created_at: row.get(7)?,
+114                error_message: row.get(8)?,
+115                item_name: row.get(9)?,
+116            })
+117        })
+118        .await
+119        .map_err(|e| e.to_string())
+120}
+121
+122/// Mark a sync operation as in progress
+123#[tauri::command]
+124#[specta::specta]
+125pub async fn sync_mark_processing(db: State<'_, DatabaseWrapper>, id: i64) -> Result<(), String> {
+126    let db_service = {
+127        let database = db.0.lock().map_err(|e| e.to_string())?;
+128        Arc::new(database.service())
+129    };
+130
+131    let query = Query::with_params(
+132        "UPDATE sync_queue SET status = 'processing' WHERE id = ?",
+133        vec![QueryParam::Int64(id)],
+134    );
+135
+136    db_service.execute(query).await.map_err(|e| e.to_string())?;
+137    Ok(())
+138}
+139
+140/// Mark a sync operation as completed
+141#[tauri::command]
+142#[specta::specta]
+143pub async fn sync_mark_completed(db: State<'_, DatabaseWrapper>, id: i64) -> Result<(), String> {
+144    let db_service = {
+145        let database = db.0.lock().map_err(|e| e.to_string())?;
+146        Arc::new(database.service())
+147    };
+148
+149    let query = Query::with_params(
+150        "UPDATE sync_queue SET status = 'completed', processed_at = CURRENT_TIMESTAMP WHERE id = ?",
+151        vec![QueryParam::Int64(id)],
+152    );
+153
+154    db_service.execute(query).await.map_err(|e| e.to_string())?;
+155    Ok(())
+156}
+157
+158/// Mark a sync operation as failed with error message
+159#[tauri::command]
+160#[specta::specta]
+161pub async fn sync_mark_failed(
+162    db: State<'_, DatabaseWrapper>,
+163    id: i64,
+164    error: String,
+165) -> Result<(), String> {
+166    let db_service = {
+167        let database = db.0.lock().map_err(|e| e.to_string())?;
+168        Arc::new(database.service())
+169    };
+170
+171    let query = Query::with_params(
+172        "UPDATE sync_queue
+173         SET status = 'failed',
+174             retry_count = retry_count + 1,
+175             error_message = ?,
+176             processed_at = CURRENT_TIMESTAMP
+177         WHERE id = ?",
+178        vec![QueryParam::String(error), QueryParam::Int64(id)],
+179    );
+180
+181    db_service.execute(query).await.map_err(|e| e.to_string())?;
+182    Ok(())
+183}
+184
+185/// Get count of pending sync operations for a user
+186#[tauri::command]
+187#[specta::specta]
+188pub async fn sync_get_pending_count(
+189    db: State<'_, DatabaseWrapper>,
+190    user_id: String,
+191) -> Result<i32, String> {
+192    let db_service = {
+193        let database = db.0.lock().map_err(|e| e.to_string())?;
+194        Arc::new(database.service())
+195    };
+196
+197    let query = Query::with_params(
+198        "SELECT COUNT(*) FROM sync_queue WHERE user_id = ? AND status IN ('pending', 'failed')",
+199        vec![QueryParam::String(user_id)],
+200    );
+201
+202    db_service
+203        .query_one(query, |row| row.get(0))
+204        .await
+205        .map_err(|e| e.to_string())
+206}
+207
+208/// Delete completed sync operations older than specified days
+209#[tauri::command]
+210#[specta::specta]
+211pub async fn sync_cleanup_completed(
+212    db: State<'_, DatabaseWrapper>,
+213    days_old: i32,
+214) -> Result<i32, String> {
+215    let db_service = {
+216        let database = db.0.lock().map_err(|e| e.to_string())?;
+217        Arc::new(database.service())
+218    };
+219
+220    let query = Query::with_params(
+221        "DELETE FROM sync_queue
+222         WHERE status = 'completed'
+223         AND processed_at < datetime('now', ?)",
+224        vec![QueryParam::String(format!("-{} days", days_old))],
+225    );
+226
+227    let deleted = db_service.execute(query).await.map_err(|e| e.to_string())?;
+228    Ok(deleted as i32)
+229}
+230
+231/// Delete all sync operations for a user (used during logout)
+232#[tauri::command]
+233#[specta::specta]
+234pub async fn sync_clear_user(
+235    db: State<'_, DatabaseWrapper>,
+236    user_id: String,
+237) -> Result<(), String> {
+238    let db_service = {
+239        let database = db.0.lock().map_err(|e| e.to_string())?;
+240        Arc::new(database.service())
+241    };
+242
+243    let query = Query::with_params(
+244        "DELETE FROM sync_queue WHERE user_id = ?",
+245        vec![QueryParam::String(user_id)],
+246    );
+247
+248    db_service.execute(query).await.map_err(|e| e.to_string())?;
+249    Ok(())
+250}
+251
+252#[cfg(test)]
+253mod tests {
+254    use super::*;
+255
+256    #[test]
+257    fn test_sync_queue_item_serialization() {
+258        let item = SyncQueueItem {
+259            id: 1,
+260            user_id: "user-123".to_string(),
+261            operation: "favorite".to_string(),
+262            item_id: Some("item-456".to_string()),
+263            payload: Some(r#"{"isFavorite": true}"#.to_string()),
+264            status: "pending".to_string(),
+265            retry_count: 0,
+266            created_at: Some("2024-02-14T08:00:00Z".to_string()),
+267            error_message: None,
+268            item_name: None,
+269        };
+270
+271        // Should serialize successfully
+272        let json = serde_json::to_string(&item);
+273        assert!(json.is_ok());
+274
+275        let serialized = json.unwrap();
+276        assert!(serialized.contains("user-123"));
+277        assert!(serialized.contains("favorite"));
+278        assert!(serialized.contains("pending"));
+279    }
+280
+281    #[test]
+282    fn test_sync_queue_item_with_error() {
+283        let item = SyncQueueItem {
+284            id: 2,
+285            user_id: "user-789".to_string(),
+286            operation: "update_progress".to_string(),
+287            item_id: Some("item-999".to_string()),
+288            payload: None,
+289            status: "failed".to_string(),
+290            retry_count: 3,
+291            created_at: Some("2024-02-14T07:00:00Z".to_string()),
+292            error_message: Some("Connection timeout".to_string()),
+293            item_name: None,
+294        };
+295
+296        let json = serde_json::to_string(&item).unwrap();
+297        assert!(json.contains("failed"));
+298        assert!(json.contains("Connection timeout"));
+299        assert!(json.contains("3")); // retry_count
+300    }
+301
+302    #[test]
+303    fn test_sync_queue_item_without_optional_fields() {
+304        let item = SyncQueueItem {
+305            id: 3,
+306            user_id: "user-000".to_string(),
+307            operation: "clear_progress".to_string(),
+308            item_id: None,
+309            payload: None,
+310            status: "completed".to_string(),
+311            retry_count: 0,
+312            created_at: None,
+313            error_message: None,
+314            item_name: None,
+315        };
+316
+317        let json = serde_json::to_string(&item).unwrap();
+318        assert!(json.contains("completed"));
+319        assert!(json.contains("null") || json.contains("\"itemId\":null"));
+320    }
+321
+322    #[test]
+323    fn test_sync_status_values() {
+324        // Verify all expected status values
+325        let valid_statuses = vec!["pending", "processing", "completed", "failed"];
+326
+327        for status in valid_statuses {
+328            let item = SyncQueueItem {
+329                id: 1,
+330                user_id: "test".to_string(),
+331                operation: "test".to_string(),
+332                item_id: None,
+333                payload: None,
+334                status: status.to_string(),
+335                retry_count: 0,
+336                created_at: None,
+337                error_message: None,
+338                item_name: None,
+339            };
+340
+341            let json = serde_json::to_string(&item).unwrap();
+342            assert!(json.contains(status));
+343        }
+344    }
+345
+346    #[test]
+347    fn test_query_param_generation() {
+348        // Test QueryParam generation for sync operations
+349        let user_id = "user-123".to_string();
+350        let operation = "favorite".to_string();
+351
+352        let params: Vec<QueryParam> = vec![
+353            QueryParam::String(user_id.clone()),
+354            QueryParam::String(operation.clone()),
+355            QueryParam::Null,
+356            QueryParam::Null,
+357        ];
+358
+359        assert_eq!(params.len(), 4);
+360        assert!(matches!(params[0], QueryParam::String(_)));
+361        assert!(matches!(params[1], QueryParam::String(_)));
+362        assert!(matches!(params[2], QueryParam::Null));
+363        assert!(matches!(params[3], QueryParam::Null));
+364    }
+365
+366    #[test]
+367    fn test_retry_count_increment() {
+368        // Verify retry count management
+369        let mut item = SyncQueueItem {
+370            id: 1,
+371            user_id: "user-123".to_string(),
+372            operation: "favorite".to_string(),
+373            item_id: None,
+374            payload: None,
+375            status: "pending".to_string(),
+376            retry_count: 0,
+377            created_at: None,
+378            error_message: None,
+379            item_name: None,
+380        };
+381
+382        // Simulate retries
+383        for i in 1..=5 {
+384            item.retry_count = i;
+385            item.status = if i < 3 { "pending" } else { "failed" }.to_string();
+386
+387            assert!(item.retry_count == i);
+388        }
+389    }
+390}
+
\ No newline at end of file diff --git a/api/src/jellytau_lib/commands/sync_drain.rs.html b/api/src/jellytau_lib/commands/sync_drain.rs.html new file mode 100644 index 00000000..a8e5c2d3 --- /dev/null +++ b/api/src/jellytau_lib/commands/sync_drain.rs.html @@ -0,0 +1,1095 @@ +sync_drain.rs - sourceSkip to main content

jellytau_lib/commands/
sync_drain.rs

1//! Draining the offline mutation queue (`sync_queue`) to the server.
+2//!
+3//! `sync_queue` had producers but no consumer: `PlaybackReporter::queue_for_sync`
+4//! inserts a row whenever a start/stop/mark-played cannot reach the server, and
+5//! nothing ever pushed one. `sync_mark_processing`/`_completed`/`_failed` were
+6//! registered commands with no callers, so the queue only grew — the offline
+7//! banner's "N pending" climbed forever and the watch positions those rows stood
+8//! for never reached Jellyfin.
+9//!
+10//! Same shape as the favourites drain (DR-120), and for the same reason: a drain
+11//! started by a component dies with it, so it lives in Rust and hangs off the
+12//! `connectivity:reconnected` transition the `ConnectivityMonitor` already emits.
+13//!
+14//! TRACES: UR-025, UR-002 | DR-131 | UT-122
+15
+16use std::sync::Arc;
+17
+18use async_trait::async_trait;
+19use log::{debug, info, warn};
+20use tauri::{Emitter, Listener, Manager};
+21
+22use crate::repository::types::RepoError;
+23use crate::repository::MediaRepository;
+24use crate::storage::db_service::{DatabaseService, Query, QueryParam, RusqliteService};
+25
+26/// How many times a row may fail before it stops being retried.
+27///
+28/// A row that can never succeed (a deleted item, an operation this build does
+29/// not know how to push) must eventually leave the queue, or it re-creates the
+30/// bug this module fixes: a count that only ever goes up.
+31pub const MAX_SYNC_ATTEMPTS: i32 = 5;
+32
+33/// Emitted after a drain so open views can re-read the queue instead of waiting
+34/// for the frontend's 10s poll.
+35pub const SYNC_QUEUE_CHANGED_EVENT: &str = "sync-queue-changed";
+36
+37/// A queued mutation, resolved from its stored `operation` + JSON `payload`.
+38#[derive(Debug, Clone, PartialEq, Eq)]
+39pub enum QueuedOp {
+40    PlaybackStart {
+41        item_id: String,
+42        position_ticks: i64,
+43    },
+44    /// Also where `update_progress` lands: replaying a mid-playback progress
+45    /// report long after the fact would tell the server we are still playing.
+46    /// What the row actually carries is a resume position, and "stopped at N"
+47    /// is how that reaches Jellyfin's `UserData`.
+48    PlaybackStopped {
+49        item_id: String,
+50        position_ticks: i64,
+51    },
+52    MarkPlayed {
+53        item_id: String,
+54    },
+55    /// The inverse, queued by the watched toggle. Pushes as `clear_watch_history`
+56    /// (Jellyfin's mark-unplayed), which also zeroes the resume position — so an
+57    /// item un-marked offline does not come back carrying a stale position.
+58    MarkUnplayed {
+59        item_id: String,
+60    },
+61    /// Legacy rows only — live favourite toggles drain via `user_data.pending_sync`
+62    /// (DR-120). Supported so a row written by an older build still lands.
+63    Favorite {
+64        item_id: String,
+65        is_favorite: bool,
+66    },
+67}
+68
+69/// Turn a stored row into something pushable.
+70///
+71/// Payload keys differ by producer: the Rust reporter writes `position_ticks`,
+72/// while `syncService.queuePlaybackProgress` writes camelCase `positionMs`.
+73/// Both are accepted rather than normalised at the producer, because rows
+74/// already in users' databases were written by both.
+75///
+76/// TRACES: UR-025 | DR-131 | UT-122
+77pub fn parse_queued_op(
+78    operation: &str,
+79    item_id: Option<&str>,
+80    payload: Option<&str>,
+81) -> Result<QueuedOp, String> {
+82    let json: serde_json::Value = match payload {
+83        Some(raw) if !raw.trim().is_empty() => {
+84            serde_json::from_str(raw).map_err(|e| format!("Unreadable payload: {}", e))?
+85        }
+86        _ => serde_json::Value::Null,
+87    };
+88
+89    let item_id = item_id
+90        .filter(|id| !id.is_empty())
+91        .ok_or_else(|| format!("Operation '{}' has no item id", operation))?
+92        .to_string();
+93
+94    let ticks = || -> i64 {
+95        if let Some(t) = json.get("position_ticks").and_then(|v| v.as_i64()) {
+96            return t;
+97        }
+98        if let Some(ms) = json.get("positionMs").and_then(|v| v.as_i64()) {
+99            return ms * 10_000; // ms → Jellyfin ticks (100ns)
+100        }
+101        0
+102    };
+103
+104    match operation {
+105        "report_playback_start" => Ok(QueuedOp::PlaybackStart {
+106            item_id,
+107            position_ticks: ticks(),
+108        }),
+109        "report_playback_stopped" | "update_progress" => Ok(QueuedOp::PlaybackStopped {
+110            item_id,
+111            position_ticks: ticks(),
+112        }),
+113        "mark_played" => Ok(QueuedOp::MarkPlayed { item_id }),
+114        "mark_unplayed" => Ok(QueuedOp::MarkUnplayed { item_id }),
+115        "mark_favorite" => Ok(QueuedOp::Favorite {
+116            item_id,
+117            is_favorite: true,
+118        }),
+119        "unmark_favorite" => Ok(QueuedOp::Favorite {
+120            item_id,
+121            is_favorite: false,
+122        }),
+123        other => Err(format!("Unsupported operation '{}'", other)),
+124    }
+125}
+126
+127/// The slice of the repository the drain needs — narrow so it can be doubled in
+128/// a test without forty `unimplemented!()` methods.
+129#[async_trait]
+130pub trait SyncSink: Send + Sync {
+131    async fn push(&self, op: &QueuedOp) -> Result<(), RepoError>;
+132}
+133
+134#[async_trait]
+135impl<T: MediaRepository + ?Sized> SyncSink for T {
+136    async fn push(&self, op: &QueuedOp) -> Result<(), RepoError> {
+137        match op {
+138            QueuedOp::PlaybackStart {
+139                item_id,
+140                position_ticks,
+141            } => self.report_playback_start(item_id, *position_ticks).await,
+142            QueuedOp::PlaybackStopped {
+143                item_id,
+144                position_ticks,
+145            } => self.report_playback_stopped(item_id, *position_ticks).await,
+146            QueuedOp::MarkPlayed { item_id } => self.mark_played(item_id).await,
+147            QueuedOp::MarkUnplayed { item_id } => self.clear_watch_history(item_id).await,
+148            QueuedOp::Favorite {
+149                item_id,
+150                is_favorite,
+151            } => {
+152                if *is_favorite {
+153                    self.mark_favorite(item_id).await
+154                } else {
+155                    self.unmark_favorite(item_id).await
+156                }
+157            }
+158        }
+159    }
+160}
+161
+162/// What a drain did, for logging and for the frontend's "Sync now" button.
+163#[derive(
+164    Debug, Clone, Default, PartialEq, Eq, serde::Serialize, serde::Deserialize, specta::Type,
+165)]
+166#[serde(rename_all = "camelCase")]
+167pub struct DrainReport {
+168    /// Rows that reached the server and are now `completed`.
+169    pub pushed: i32,
+170    /// Rows that failed and will be retried on the next reconnect.
+171    pub deferred: i32,
+172    /// Rows that exhausted `MAX_SYNC_ATTEMPTS` and were given up on.
+173    pub abandoned: i32,
+174    /// Rows still waiting afterwards (what the badge counts).
+175    pub remaining: i32,
+176}
+177
+178/// Why a push failed, and whether the row should be charged an attempt for it.
+179struct PushFailure {
+180    reason: String,
+181    /// The server could not be reached at all — retry later, free of charge.
+182    transient: bool,
+183}
+184
+185#[derive(Debug, Clone)]
+186struct QueuedRow {
+187    id: i64,
+188    operation: String,
+189    item_id: Option<String>,
+190    payload: Option<String>,
+191    retry_count: i32,
+192}
+193
+194async fn read_queue(db: &Arc<RusqliteService>, user_id: &str) -> Result<Vec<QueuedRow>, String> {
+195    db.query_many(
+196        Query::with_params(
+197            "SELECT id, operation, item_id, payload, COALESCE(retry_count, 0) \
+198             FROM sync_queue \
+199             WHERE user_id = ? AND status IN ('pending', 'failed') \
+200             ORDER BY created_at ASC, id ASC",
+201            vec![QueryParam::String(user_id.to_string())],
+202        ),
+203        |row| {
+204            Ok(QueuedRow {
+205                id: row.get(0)?,
+206                operation: row.get(1)?,
+207                item_id: row.get(2)?,
+208                payload: row.get(3)?,
+209                retry_count: row.get(4)?,
+210            })
+211        },
+212    )
+213    .await
+214}
+215
+216async fn remaining_count(db: &Arc<RusqliteService>, user_id: &str) -> Result<i32, String> {
+217    db.query_one(
+218        Query::with_params(
+219            "SELECT COUNT(*) FROM sync_queue WHERE user_id = ? AND status IN ('pending', 'failed')",
+220            vec![QueryParam::String(user_id.to_string())],
+221        ),
+222        |row| row.get(0),
+223    )
+224    .await
+225}
+226
+227/// Push every queued mutation for this user, oldest first.
+228///
+229/// Chronological order matters: a stale start replayed after a later stop would
+230/// otherwise move the server's resume position backwards.
+231///
+232/// A row that fails keeps its place and is retried on the next reconnect, until
+233/// `MAX_SYNC_ATTEMPTS` — after which it is abandoned, because a row nothing can
+234/// ever push is exactly what turned this queue into a counter that only grew.
+235///
+236/// TRACES: UR-025, UR-002 | DR-131 | UT-122
+237pub async fn drain_sync_queue(
+238    db: &Arc<RusqliteService>,
+239    sink: &dyn SyncSink,
+240    user_id: &str,
+241) -> Result<DrainReport, String> {
+242    let queued = read_queue(db, user_id).await?;
+243    if queued.is_empty() {
+244        return Ok(DrainReport::default());
+245    }
+246
+247    info!(
+248        "[SyncQueue] Pushing {} operation(s) queued while offline",
+249        queued.len()
+250    );
+251
+252    let mut report = DrainReport::default();
+253
+254    for row in queued {
+255        let outcome = match parse_queued_op(
+256            &row.operation,
+257            row.item_id.as_deref(),
+258            row.payload.as_deref(),
+259        ) {
+260            Ok(op) => sink.push(&op).await.map_err(|e| PushFailure {
+261                // An unreachable server is not the row's fault: burning its
+262                // budget would abandon perfectly good rows just because the app
+263                // was opened offline a few times.
+264                transient: matches!(e, RepoError::Offline | RepoError::Network { .. }),
+265                reason: e.to_string(),
+266            }),
+267            // An unreadable or unsupported row can never succeed, so it does
+268            // burn attempts rather than being deleted outright — the panel shows
+269            // the reason until it is abandoned.
+270            Err(reason) => Err(PushFailure {
+271                reason,
+272                transient: false,
+273            }),
+274        };
+275
+276        match outcome {
+277            Ok(()) => {
+278                mark_completed(db, row.id).await?;
+279                report.pushed += 1;
+280            }
+281            Err(failure) if failure.transient => {
+282                mark_deferred(db, row.id, &failure.reason).await?;
+283                debug!(
+284                    "[SyncQueue] Server unreachable, {} stays queued: {}",
+285                    row.operation, failure.reason
+286                );
+287                report.deferred += 1;
+288            }
+289            Err(failure) => {
+290                let attempts = row.retry_count + 1;
+291                let give_up = attempts >= MAX_SYNC_ATTEMPTS;
+292                mark_failed(db, row.id, attempts, give_up, &failure.reason).await?;
+293                if give_up {
+294                    warn!(
+295                        "[SyncQueue] Giving up on {} after {} attempts: {}",
+296                        row.operation, attempts, failure.reason
+297                    );
+298                    report.abandoned += 1;
+299                } else {
+300                    debug!(
+301                        "[SyncQueue] Deferring {} (attempt {}): {}",
+302                        row.operation, attempts, failure.reason
+303                    );
+304                    report.deferred += 1;
+305                }
+306            }
+307        }
+308    }
+309
+310    report.remaining = remaining_count(db, user_id).await?;
+311    info!(
+312        "[SyncQueue] Drain finished: {} pushed, {} deferred, {} abandoned, {} remaining",
+313        report.pushed, report.deferred, report.abandoned, report.remaining
+314    );
+315
+316    Ok(report)
+317}
+318
+319async fn mark_completed(db: &Arc<RusqliteService>, id: i64) -> Result<(), String> {
+320    db.execute(Query::with_params(
+321        "UPDATE sync_queue \
+322         SET status = 'completed', processed_at = CURRENT_TIMESTAMP, error_message = NULL \
+323         WHERE id = ?",
+324        vec![QueryParam::Int64(id)],
+325    ))
+326    .await?;
+327    Ok(())
+328}
+329
+330/// Put a row back in the queue untouched apart from its error note — used when
+331/// the server was simply unreachable.
+332async fn mark_deferred(db: &Arc<RusqliteService>, id: i64, reason: &str) -> Result<(), String> {
+333    db.execute(Query::with_params(
+334        "UPDATE sync_queue SET status = 'pending', error_message = ? WHERE id = ?",
+335        vec![
+336            QueryParam::String(reason.to_string()),
+337            QueryParam::Int64(id),
+338        ],
+339    ))
+340    .await?;
+341    Ok(())
+342}
+343
+344async fn mark_failed(
+345    db: &Arc<RusqliteService>,
+346    id: i64,
+347    attempts: i32,
+348    give_up: bool,
+349    reason: &str,
+350) -> Result<(), String> {
+351    db.execute(Query::with_params(
+352        "UPDATE sync_queue \
+353         SET status = ?, retry_count = ?, error_message = ?, processed_at = CURRENT_TIMESTAMP \
+354         WHERE id = ?",
+355        vec![
+356            QueryParam::String(if give_up { "abandoned" } else { "failed" }.to_string()),
+357            QueryParam::Int(attempts),
+358            QueryParam::String(reason.to_string()),
+359            QueryParam::Int64(id),
+360        ],
+361    ))
+362    .await?;
+363    Ok(())
+364}
+365
+366/// Queue a watch position that could not be reported to the server.
+367///
+368/// The stop-report path pushed straight to the server and, on failure, logged
+369/// and dropped the position — so closing a video while the server was
+370/// unreachable lost the resume point outright, even though the queue and its
+371/// drain (DR-131) were built and running. This is the missing producer.
+372///
+373/// The pending row for an item is *replaced* rather than appended to. Progress
+374/// is reported every 10s, so a server that stays down would otherwise grow one
+375/// row per tick, all of them superseded by the newest — the unbounded queue
+376/// DR-131 exists to prevent. Only `pending`/`failed` rows are superseded:
+377/// an `abandoned` row has been given up on and must not be revived, and a
+378/// `completed` one is history.
+379///
+380/// TRACES: UR-025 | DR-154 | UT-151
+381pub async fn enqueue_playback_stopped(
+382    db: &Arc<RusqliteService>,
+383    user_id: &str,
+384    item_id: &str,
+385    position_ticks: i64,
+386) -> Result<(), String> {
+387    let payload = format!(r#"{{"position_ticks": {}}}"#, position_ticks);
+388
+389    // Supersede an already-queued position for this item, keeping its place in
+390    // the queue order (created_at) so a later item cannot overtake it.
+391    let updated = db
+392        .execute(Query::with_params(
+393            "UPDATE sync_queue \
+394             SET payload = ?, status = 'pending', error_message = NULL \
+395             WHERE user_id = ? AND item_id = ? AND operation = 'report_playback_stopped' \
+396               AND status IN ('pending', 'failed')",
+397            vec![
+398                QueryParam::String(payload.clone()),
+399                QueryParam::String(user_id.to_string()),
+400                QueryParam::String(item_id.to_string()),
+401            ],
+402        ))
+403        .await?;
+404
+405    if updated == 0 {
+406        db.execute(Query::with_params(
+407            "INSERT INTO sync_queue (user_id, operation, item_id, payload, status, created_at) \
+408             VALUES (?, 'report_playback_stopped', ?, ?, 'pending', CURRENT_TIMESTAMP)",
+409            vec![
+410                QueryParam::String(user_id.to_string()),
+411                QueryParam::String(item_id.to_string()),
+412                QueryParam::String(payload),
+413            ],
+414        ))
+415        .await?;
+416    }
+417
+418    debug!(
+419        "[SyncQueue] Queued unreported stop for {} at {} ticks",
+420        item_id, position_ticks
+421    );
+422    Ok(())
+423}
+424
+425/// Drain on every offline→online transition.
+426///
+427/// TRACES: UR-025 | DR-131
+428pub fn spawn_sync_queue_drain(app: tauri::AppHandle) {
+429    let handle = app.clone();
+430    app.listen("connectivity:reconnected", move |_event| {
+431        let app = handle.clone();
+432        tauri::async_runtime::spawn(async move {
+433            if let Err(e) = run_drain(&app).await {
+434                warn!("[SyncQueue] Drain skipped: {}", e);
+435            }
+436        });
+437    });
+438}
+439
+440/// Resolve app state and drain. Shared by the reconnect hook and the manual
+441/// "Sync now" command.
+442pub async fn run_drain(app: &tauri::AppHandle) -> Result<DrainReport, String> {
+443    let db_service: Arc<RusqliteService> = {
+444        let db = app.state::<crate::commands::storage::DatabaseWrapper>();
+445        let database = db.0.lock().map_err(|e| e.to_string())?;
+446        Arc::new(database.service())
+447    };
+448
+449    let (repo, user_id) = {
+450        let manager = app.state::<crate::commands::repository::RepositoryManagerWrapper>();
+451        let handles = manager.0.handles();
+452        let Some(handle) = handles.first() else {
+453            // Not signed in — nothing to push on behalf of.
+454            return Ok(DrainReport::default());
+455        };
+456        let repo = manager.0.get(handle).ok_or("Repository not found")?;
+457        let user_id = repo.user_id().to_string();
+458        (repo, user_id)
+459    };
+460
+461    let report = drain_sync_queue(&db_service, repo.as_ref(), &user_id).await?;
+462
+463    if report.pushed > 0 || report.abandoned > 0 {
+464        if let Err(e) = app.emit(SYNC_QUEUE_CHANGED_EVENT, &report) {
+465            warn!("[SyncQueue] Failed to emit change event: {}", e);
+466        }
+467    }
+468
+469    Ok(report)
+470}
+471
+472/// Push the queue now, on the user's say-so, instead of waiting for a reconnect.
+473///
+474/// TRACES: UR-025 | DR-132
+475#[tauri::command]
+476#[specta::specta]
+477pub async fn sync_process_pending(app: tauri::AppHandle) -> Result<DrainReport, String> {
+478    run_drain(&app).await
+479}
+480
+481#[cfg(test)]
+482mod tests {
+483    use super::*;
+484    use crate::utils::lock::MutexSafe;
+485    use rusqlite::Connection;
+486    use std::sync::Mutex;
+487
+488    /// Records what the server was asked to do, and can be told to fail.
+489    struct RecordingSink {
+490        calls: Mutex<Vec<QueuedOp>>,
+491        fail_with: Option<RepoError>,
+492    }
+493
+494    impl RecordingSink {
+495        fn new() -> Self {
+496            Self {
+497                calls: Mutex::new(Vec::new()),
+498                fail_with: None,
+499            }
+500        }
+501
+502        /// The server is there and refuses the operation — the row's own fault.
+503        fn always_rejecting() -> Self {
+504            Self {
+505                calls: Mutex::new(Vec::new()),
+506                fail_with: Some(RepoError::Server {
+507                    message: "HTTP 400".to_string(),
+508                }),
+509            }
+510        }
+511
+512        /// The server cannot be reached at all — nothing to do with the row.
+513        fn unreachable() -> Self {
+514            Self {
+515                calls: Mutex::new(Vec::new()),
+516                fail_with: Some(RepoError::Offline),
+517            }
+518        }
+519
+520        fn calls(&self) -> Vec<QueuedOp> {
+521            self.calls.lock_safe().clone()
+522        }
+523    }
+524
+525    #[async_trait]
+526    impl SyncSink for RecordingSink {
+527        async fn push(&self, op: &QueuedOp) -> Result<(), RepoError> {
+528            if let Some(err) = &self.fail_with {
+529                return Err(err.clone());
+530            }
+531            self.calls.lock_safe().push(op.clone());
+532            Ok(())
+533        }
+534    }
+535
+536    fn test_db() -> Arc<RusqliteService> {
+537        let conn = Connection::open_in_memory().unwrap();
+538        conn.execute_batch(
+539            r#"
+540            CREATE TABLE sync_queue (
+541                id INTEGER PRIMARY KEY AUTOINCREMENT,
+542                user_id TEXT NOT NULL,
+543                operation TEXT NOT NULL,
+544                item_id TEXT,
+545                payload TEXT,
+546                status TEXT DEFAULT 'pending',
+547                retry_count INTEGER DEFAULT 0,
+548                created_at TEXT DEFAULT CURRENT_TIMESTAMP,
+549                processed_at TEXT,
+550                error_message TEXT
+551            );
+552            "#,
+553        )
+554        .unwrap();
+555        Arc::new(RusqliteService::new(Arc::new(Mutex::new(conn))))
+556    }
+557
+558    /// (user, operation, item_id, payload, status, retry_count, created_at)
+559    type Seed<'a> = (
+560        &'a str,
+561        &'a str,
+562        &'a str,
+563        Option<&'a str>,
+564        &'a str,
+565        i32,
+566        &'a str,
+567    );
+568
+569    async fn seed(db: &Arc<RusqliteService>, rows: &[Seed<'_>]) {
+570        for (user, op, item, payload, status, retries, created) in rows {
+571            db.execute(Query::with_params(
+572                "INSERT INTO sync_queue (user_id, operation, item_id, payload, status, retry_count, created_at) \
+573                 VALUES (?, ?, ?, ?, ?, ?, ?)",
+574                vec![
+575                    QueryParam::String(user.to_string()),
+576                    QueryParam::String(op.to_string()),
+577                    QueryParam::String(item.to_string()),
+578                    payload
+579                        .map(|p| QueryParam::String(p.to_string()))
+580                        .unwrap_or(QueryParam::Null),
+581                    QueryParam::String(status.to_string()),
+582                    QueryParam::Int(*retries),
+583                    QueryParam::String(created.to_string()),
+584                ],
+585            ))
+586            .await
+587            .unwrap();
+588        }
+589    }
+590
+591    async fn row_state(db: &Arc<RusqliteService>, item_id: &str) -> (String, i32) {
+592        db.query_one(
+593            Query::with_params(
+594                "SELECT status, COALESCE(retry_count, 0) FROM sync_queue WHERE item_id = ?",
+595                vec![QueryParam::String(item_id.to_string())],
+596            ),
+597            |row| Ok((row.get::<_, String>(0)?, row.get::<_, i32>(1)?)),
+598        )
+599        .await
+600        .unwrap()
+601    }
+602
+603    /// UT-122 — the bug itself: rows queued while offline reach the server on
+604    /// reconnect and stop counting towards the offline banner's badge.
+605    ///
+606    /// TRACES: UR-025 | DR-131 | UT-122
+607    #[tokio::test]
+608    async fn test_drain_pushes_queued_operations_and_clears_the_queue() {
+609        let db = test_db();
+610        seed(
+611            &db,
+612            &[
+613                (
+614                    "u1",
+615                    "report_playback_start",
+616                    "ep1",
+617                    Some(r#"{"position_ticks": 100}"#),
+618                    "pending",
+619                    0,
+620                    "2026-08-01T10:00:00Z",
+621                ),
+622                (
+623                    "u1",
+624                    "report_playback_stopped",
+625                    "ep2",
+626                    Some(r#"{"position_ticks": 5000}"#),
+627                    "pending",
+628                    0,
+629                    "2026-08-01T10:01:00Z",
+630                ),
+631                (
+632                    "u1",
+633                    "mark_played",
+634                    "ep3",
+635                    None,
+636                    "pending",
+637                    0,
+638                    "2026-08-01T10:02:00Z",
+639                ),
+640            ],
+641        )
+642        .await;
+643
+644        let sink = RecordingSink::new();
+645        let report = drain_sync_queue(&db, &sink, "u1").await.unwrap();
+646
+647        assert_eq!(
+648            sink.calls(),
+649            vec![
+650                QueuedOp::PlaybackStart {
+651                    item_id: "ep1".to_string(),
+652                    position_ticks: 100
+653                },
+654                QueuedOp::PlaybackStopped {
+655                    item_id: "ep2".to_string(),
+656                    position_ticks: 5000
+657                },
+658                QueuedOp::MarkPlayed {
+659                    item_id: "ep3".to_string()
+660                },
+661            ],
+662            "every queued operation pushes, oldest first"
+663        );
+664        assert_eq!(report.pushed, 3);
+665        assert_eq!(report.remaining, 0, "the badge must reach zero");
+666        assert_eq!(row_state(&db, "ep1").await.0, "completed");
+667    }
+668
+669    /// A push that fails stays queued for the next reconnect rather than being
+670    /// dropped.
+671    ///
+672    /// TRACES: UR-025 | DR-131 | UT-122
+673    #[tokio::test]
+674    async fn test_drain_defers_failed_pushes() {
+675        let db = test_db();
+676        seed(
+677            &db,
+678            &[(
+679                "u1",
+680                "report_playback_stopped",
+681                "ep1",
+682                Some(r#"{"position_ticks": 42}"#),
+683                "pending",
+684                0,
+685                "2026-08-01T10:00:00Z",
+686            )],
+687        )
+688        .await;
+689
+690        let report = drain_sync_queue(&db, &RecordingSink::always_rejecting(), "u1")
+691            .await
+692            .unwrap();
+693
+694        assert_eq!(report.deferred, 1);
+695        assert_eq!(report.remaining, 1);
+696        assert_eq!(row_state(&db, "ep1").await, ("failed".to_string(), 1));
+697    }
+698
+699    /// An unreachable server does not charge the row an attempt — otherwise
+700    /// opening the app offline a few times abandons perfectly good rows.
+701    ///
+702    /// TRACES: UR-025 | DR-131 | UT-122
+703    #[tokio::test]
+704    async fn test_unreachable_server_does_not_burn_the_retry_budget() {
+705        let db = test_db();
+706        seed(
+707            &db,
+708            &[(
+709                "u1",
+710                "mark_played",
+711                "ep1",
+712                None,
+713                "pending",
+714                MAX_SYNC_ATTEMPTS - 1,
+715                "2026-08-01T10:00:00Z",
+716            )],
+717        )
+718        .await;
+719
+720        let report = drain_sync_queue(&db, &RecordingSink::unreachable(), "u1")
+721            .await
+722            .unwrap();
+723
+724        assert_eq!(report.deferred, 1);
+725        assert_eq!(report.abandoned, 0);
+726        assert_eq!(
+727            row_state(&db, "ep1").await,
+728            ("pending".to_string(), MAX_SYNC_ATTEMPTS - 1),
+729            "still queued, with its budget intact"
+730        );
+731    }
+732
+733    /// A row that can never succeed must eventually leave the queue, or the
+734    /// count climbs forever — which is the bug this module exists to fix.
+735    ///
+736    /// TRACES: UR-025 | DR-131 | UT-122
+737    #[tokio::test]
+738    async fn test_drain_abandons_a_row_after_max_attempts() {
+739        let db = test_db();
+740        seed(
+741            &db,
+742            &[(
+743                "u1",
+744                "report_playback_stopped",
+745                "doomed",
+746                Some(r#"{"position_ticks": 1}"#),
+747                "failed",
+748                MAX_SYNC_ATTEMPTS - 1,
+749                "2026-08-01T10:00:00Z",
+750            )],
+751        )
+752        .await;
+753
+754        let report = drain_sync_queue(&db, &RecordingSink::always_rejecting(), "u1")
+755            .await
+756            .unwrap();
+757
+758        assert_eq!(report.abandoned, 1);
+759        assert_eq!(report.remaining, 0, "an abandoned row stops being counted");
+760        assert_eq!(row_state(&db, "doomed").await.0, "abandoned");
+761    }
+762
+763    /// An operation this build cannot push does not wedge the queue behind it.
+764    ///
+765    /// TRACES: UR-025 | DR-131 | UT-122
+766    #[tokio::test]
+767    async fn test_unsupported_operation_records_a_reason_and_lets_others_through() {
+768        let db = test_db();
+769        seed(
+770            &db,
+771            &[
+772                (
+773                    "u1",
+774                    "playlist_reorder_item",
+775                    "pl1",
+776                    None,
+777                    "pending",
+778                    0,
+779                    "2026-08-01T10:00:00Z",
+780                ),
+781                (
+782                    "u1",
+783                    "mark_played",
+784                    "ep1",
+785                    None,
+786                    "pending",
+787                    0,
+788                    "2026-08-01T10:01:00Z",
+789                ),
+790            ],
+791        )
+792        .await;
+793
+794        let sink = RecordingSink::new();
+795        let report = drain_sync_queue(&db, &sink, "u1").await.unwrap();
+796
+797        assert_eq!(
+798            sink.calls(),
+799            vec![QueuedOp::MarkPlayed {
+800                item_id: "ep1".to_string()
+801            }],
+802            "the unsupported row must not block the ones behind it"
+803        );
+804        assert_eq!(report.pushed, 1);
+805        assert_eq!(report.deferred, 1);
+806        assert_eq!(row_state(&db, "pl1").await, ("failed".to_string(), 1));
+807    }
+808
+809    /// Another user's queued changes are not pushed with this user's token.
+810    ///
+811    /// TRACES: UR-025 | DR-131 | UT-122
+812    #[tokio::test]
+813    async fn test_drain_only_touches_the_given_user() {
+814        let db = test_db();
+815        seed(
+816            &db,
+817            &[
+818                (
+819                    "u1",
+820                    "mark_played",
+821                    "mine",
+822                    None,
+823                    "pending",
+824                    0,
+825                    "2026-08-01T10:00:00Z",
+826                ),
+827                (
+828                    "u2",
+829                    "mark_played",
+830                    "theirs",
+831                    None,
+832                    "pending",
+833                    0,
+834                    "2026-08-01T10:00:00Z",
+835                ),
+836            ],
+837        )
+838        .await;
+839
+840        let sink = RecordingSink::new();
+841        drain_sync_queue(&db, &sink, "u1").await.unwrap();
+842
+843        assert_eq!(
+844            sink.calls(),
+845            vec![QueuedOp::MarkPlayed {
+846                item_id: "mine".to_string()
+847            }]
+848        );
+849        assert_eq!(row_state(&db, "theirs").await.0, "pending");
+850    }
+851
+852    /// The bug DR-154 fixes: a stop-report that could not reach the server was
+853    /// logged and dropped, so the watch position was lost outright. It must
+854    /// land in the queue the drain already knows how to push.
+855    ///
+856    /// TRACES: UR-025 | DR-154 | UT-151
+857    #[tokio::test]
+858    async fn test_failed_stop_report_is_queued_rather_than_dropped() {
+859        let db = test_db();
+860
+861        enqueue_playback_stopped(&db, "u1", "ep1", 5_000_000_000)
+862            .await
+863            .unwrap();
+864
+865        // The very drain that already exists must be able to push it.
+866        let sink = RecordingSink::new();
+867        let report = drain_sync_queue(&db, &sink, "u1").await.unwrap();
+868
+869        assert_eq!(
+870            sink.calls(),
+871            vec![QueuedOp::PlaybackStopped {
+872                item_id: "ep1".to_string(),
+873                position_ticks: 5_000_000_000,
+874            }]
+875        );
+876        assert_eq!(report.pushed, 1);
+877        assert_eq!(report.remaining, 0);
+878    }
+879
+880    /// Progress is reported every 10s, and a server that stays unreachable
+881    /// would otherwise add a row per tick — an unbounded queue of positions
+882    /// that are all superseded by the newest one. The pending row for an item
+883    /// is replaced in place, so the queue holds the latest position only.
+884    ///
+885    /// TRACES: UR-025 | DR-154 | UT-151
+886    #[tokio::test]
+887    async fn test_requeueing_the_same_item_supersedes_the_earlier_position() {
+888        let db = test_db();
+889
+890        enqueue_playback_stopped(&db, "u1", "ep1", 1_000)
+891            .await
+892            .unwrap();
+893        enqueue_playback_stopped(&db, "u1", "ep1", 2_000)
+894            .await
+895            .unwrap();
+896        enqueue_playback_stopped(&db, "u1", "ep1", 3_000)
+897            .await
+898            .unwrap();
+899
+900        let sink = RecordingSink::new();
+901        drain_sync_queue(&db, &sink, "u1").await.unwrap();
+902
+903        // One row, carrying the newest position — not three.
+904        assert_eq!(
+905            sink.calls(),
+906            vec![QueuedOp::PlaybackStopped {
+907                item_id: "ep1".to_string(),
+908                position_ticks: 3_000,
+909            }]
+910        );
+911    }
+912
+913    /// Distinct items must not collide — superseding is per item, not global.
+914    ///
+915    /// TRACES: UR-025 | DR-154 | UT-151
+916    #[tokio::test]
+917    async fn test_requeueing_keeps_positions_for_different_items_apart() {
+918        let db = test_db();
+919
+920        enqueue_playback_stopped(&db, "u1", "ep1", 1_000)
+921            .await
+922            .unwrap();
+923        enqueue_playback_stopped(&db, "u1", "ep2", 2_000)
+924            .await
+925            .unwrap();
+926
+927        let sink = RecordingSink::new();
+928        drain_sync_queue(&db, &sink, "u1").await.unwrap();
+929
+930        let mut calls = sink.calls();
+931        calls.sort_by_key(|op| match op {
+932            QueuedOp::PlaybackStopped { item_id, .. } => item_id.clone(),
+933            _ => String::new(),
+934        });
+935        assert_eq!(
+936            calls,
+937            vec![
+938                QueuedOp::PlaybackStopped {
+939                    item_id: "ep1".to_string(),
+940                    position_ticks: 1_000,
+941                },
+942                QueuedOp::PlaybackStopped {
+943                    item_id: "ep2".to_string(),
+944                    position_ticks: 2_000,
+945                },
+946            ]
+947        );
+948    }
+949
+950    /// A row already abandoned (DR-131 gave up on it) must not be resurrected
+951    /// by a later report — that would restore the queue-that-only-grows this
+952    /// whole area exists to prevent. The new report is queued as its own row.
+953    ///
+954    /// TRACES: UR-025 | DR-154 | UT-151
+955    #[tokio::test]
+956    async fn test_requeueing_does_not_revive_an_abandoned_row() {
+957        let db = test_db();
+958        seed(
+959            &db,
+960            &[(
+961                "u1",
+962                "report_playback_stopped",
+963                "ep1",
+964                Some(r#"{"position_ticks": 111}"#),
+965                "abandoned",
+966                MAX_SYNC_ATTEMPTS,
+967                "2026-08-01T10:00:00Z",
+968            )],
+969        )
+970        .await;
+971
+972        enqueue_playback_stopped(&db, "u1", "ep1", 999)
+973            .await
+974            .unwrap();
+975
+976        let sink = RecordingSink::new();
+977        drain_sync_queue(&db, &sink, "u1").await.unwrap();
+978
+979        // Only the fresh row is pushed; the abandoned one stays abandoned.
+980        assert_eq!(
+981            sink.calls(),
+982            vec![QueuedOp::PlaybackStopped {
+983                item_id: "ep1".to_string(),
+984                position_ticks: 999,
+985            }]
+986        );
+987    }
+988
+989    /// Nothing queued means no server calls at all — a reconnect must not
+990    /// generate traffic just because it happened.
+991    ///
+992    /// TRACES: UR-025 | DR-131 | UT-122
+993    #[tokio::test]
+994    async fn test_drain_is_a_noop_when_the_queue_is_empty() {
+995        let db = test_db();
+996        let sink = RecordingSink::new();
+997
+998        let report = drain_sync_queue(&db, &sink, "u1").await.unwrap();
+999
+1000        assert_eq!(report, DrainReport::default());
+1001        assert!(sink.calls().is_empty());
+1002    }
+1003
+1004    /// Both payload dialects parse: `position_ticks` from the Rust reporter and
+1005    /// camelCase `positionMs` from the frontend's queue helper.
+1006    ///
+1007    /// TRACES: UR-025 | DR-131 | UT-122
+1008    #[test]
+1009    fn test_parse_accepts_both_payload_dialects() {
+1010        assert_eq!(
+1011            parse_queued_op(
+1012                "report_playback_stopped",
+1013                Some("ep1"),
+1014                Some(r#"{"position_ticks": 1234}"#)
+1015            )
+1016            .unwrap(),
+1017            QueuedOp::PlaybackStopped {
+1018                item_id: "ep1".to_string(),
+1019                position_ticks: 1234
+1020            }
+1021        );
+1022
+1023        assert_eq!(
+1024            parse_queued_op("update_progress", Some("ep1"), Some(r#"{"positionMs": 5}"#)).unwrap(),
+1025            QueuedOp::PlaybackStopped {
+1026                item_id: "ep1".to_string(),
+1027                position_ticks: 50_000
+1028            },
+1029            "milliseconds convert to ticks"
+1030        );
+1031
+1032        assert_eq!(
+1033            parse_queued_op("mark_played", Some("ep1"), None).unwrap(),
+1034            QueuedOp::MarkPlayed {
+1035                item_id: "ep1".to_string()
+1036            },
+1037            "a payload-less operation is not an error"
+1038        );
+1039
+1040        assert!(parse_queued_op("mark_played", None, None).is_err());
+1041        assert!(parse_queued_op("teleport", Some("ep1"), None).is_err());
+1042    }
+1043
+1044    /// Un-marking watched queues like marking watched does, so the toggle works
+1045    /// in both directions while the server is unreachable rather than only one.
+1046    ///
+1047    /// TRACES: UR-073 | DR-158 | UT-154
+1048    #[test]
+1049    fn test_parse_accepts_mark_unplayed() {
+1050        assert_eq!(
+1051            parse_queued_op("mark_unplayed", Some("ep1"), None).unwrap(),
+1052            QueuedOp::MarkUnplayed {
+1053                item_id: "ep1".to_string()
+1054            },
+1055        );
+1056
+1057        assert!(parse_queued_op("mark_unplayed", None, None).is_err());
+1058    }
+1059
+1060    /// The queued un-mark reaches the server as `clear_watch_history` — Jellyfin's
+1061    /// mark-unplayed, which also zeroes the resume position, so a series returns
+1062    /// to "never watched" rather than keeping a stale position.
+1063    ///
+1064    /// TRACES: UR-073 | DR-158 | UT-154
+1065    #[tokio::test]
+1066    async fn test_drain_pushes_mark_unplayed() {
+1067        let db = test_db();
+1068        seed(
+1069            &db,
+1070            &[(
+1071                "u1",
+1072                "mark_unplayed",
+1073                "ep9",
+1074                None,
+1075                "pending",
+1076                0,
+1077                "2026-08-01T10:00:00Z",
+1078            )],
+1079        )
+1080        .await;
+1081
+1082        let sink = RecordingSink::new();
+1083        let report = drain_sync_queue(&db, &sink, "u1").await.unwrap();
+1084
+1085        assert_eq!(
+1086            sink.calls(),
+1087            vec![QueuedOp::MarkUnplayed {
+1088                item_id: "ep9".to_string()
+1089            }],
+1090        );
+1091        assert_eq!(report.pushed, 1);
+1092        assert_eq!(report.remaining, 0);
+1093    }
+1094}
+
\ No newline at end of file diff --git a/api/src/jellytau_lib/connectivity/mod.rs.html b/api/src/jellytau_lib/connectivity/mod.rs.html new file mode 100644 index 00000000..9cfd8e59 --- /dev/null +++ b/api/src/jellytau_lib/connectivity/mod.rs.html @@ -0,0 +1,522 @@ +mod.rs - sourceSkip to main content

jellytau_lib/connectivity/
mod.rs

1use serde::{Deserialize, Serialize};
+2use std::sync::atomic::{AtomicBool, Ordering};
+3use std::sync::Arc;
+4use std::time::{Duration, Instant};
+5use tauri::{AppHandle, Emitter};
+6use tokio::sync::RwLock;
+7
+8use crate::jellyfin::http_client::HttpClient;
+9
+10// Offline recovery probe interval.
+11// Reachability while online is driven by real repository traffic, so there is
+12// no online polling. While offline we probe quickly to detect the server
+13// returning even when no user traffic is flowing.
+14const RETRY_CHECK_INTERVAL_MS: u64 = 5000; // 5 seconds when offline
+15
+16// Time-window debounce for declaring the server offline.
+17// A single dropped request must not trip the banner: we only flip to offline
+18// once network failures have persisted continuously for this window with no
+19// intervening success. Recovery (online) is instant on the first success.
+20const OFFLINE_CONFIRM_WINDOW: Duration = Duration::from_secs(5);
+21
+22/// Connectivity status
+23#[derive(specta::Type, Debug, Clone, Serialize, Deserialize)]
+24#[serde(rename_all = "camelCase")]
+25pub struct ConnectivityStatus {
+26    /// Whether the Jellyfin server is reachable
+27    pub is_server_reachable: bool,
+28    /// Last time we checked server reachability (ISO 8601 string)
+29    pub last_checked: Option<String>,
+30    /// Error message from last connectivity check
+31    pub connection_error: Option<String>,
+32    /// Whether we're currently checking connectivity
+33    pub is_checking: bool,
+34}
+35
+36impl Default for ConnectivityStatus {
+37    fn default() -> Self {
+38        Self {
+39            // Start optimistic - assume online until proven otherwise
+40            // This prevents the app from appearing offline on startup
+41            is_server_reachable: true,
+42            last_checked: None,
+43            connection_error: None,
+44            is_checking: false,
+45        }
+46    }
+47}
+48
+49/// Connectivity change event emitted to frontend
+50#[derive(specta::Type, Debug, Clone, Serialize)]
+51#[serde(rename_all = "camelCase")]
+52struct ConnectivityChangeEvent {
+53    is_reachable: bool,
+54}
+55
+56/// Shared reachability state and transition logic.
+57///
+58/// This is the single place that mutates reachability and emits events. It is
+59/// cheap to clone (all fields are `Arc`/`Option`) and is shared by:
+60/// - the `ConnectivityMonitor` (commands, offline recovery probe), and
+61/// - `OnlineRepository`, which reports the outcome of every server request.
+62///
+63/// Reachability is therefore driven by real traffic; the probe only fills the
+64/// gap while offline.
+65#[derive(Clone)]
+66pub struct ConnectivityReporter {
+67    status: Arc<RwLock<ConnectivityStatus>>,
+68    /// Timestamp of the first network failure in the current failure streak.
+69    /// Used to debounce the transition to offline (see `OFFLINE_CONFIRM_WINDOW`).
+70    first_failure_at: Arc<RwLock<Option<Instant>>>,
+71    app_handle: Option<AppHandle>,
+72}
+73
+74impl ConnectivityReporter {
+75    fn new(status: Arc<RwLock<ConnectivityStatus>>, app_handle: Option<AppHandle>) -> Self {
+76        Self {
+77            status,
+78            first_failure_at: Arc::new(RwLock::new(None)),
+79            app_handle,
+80        }
+81    }
+82
+83    /// Current reachability as seen by this reporter (shared with the monitor
+84    /// and the UI). Useful for callers that want to branch on connectivity.
+85    #[allow(dead_code)] // public API; currently only exercised by cross-module tests
+86    pub async fn is_reachable(&self) -> bool {
+87        self.status.read().await.is_server_reachable
+88    }
+89
+90    /// Test-only: force the reporter into the offline state without going through
+91    /// the debounce, so other modules' tests can set up an "offline" precondition.
+92    #[cfg(test)]
+93    pub async fn mark_unreachable_for_test(&self) {
+94        self.apply_probe_result(false, Some("forced offline (test)".to_string()))
+95            .await;
+96    }
+97
+98    /// Report that a real server request succeeded (or that the server answered
+99    /// at all, e.g. with 401/404/5xx). The server is up — recover instantly.
+100    pub async fn report_success(&self) {
+101        *self.first_failure_at.write().await = None;
+102        self.set_reachable(true, None).await;
+103    }
+104
+105    /// Report that a real server request failed with a network-level error
+106    /// (connection refused, timeout, DNS). Subject to the time-window debounce:
+107    /// we only flip to offline once failures have persisted for
+108    /// `OFFLINE_CONFIRM_WINDOW` with no intervening success.
+109    pub async fn report_network_failure(&self, error: Option<String>) {
+110        // If already offline, nothing to debounce.
+111        if !self.status.read().await.is_server_reachable {
+112            return;
+113        }
+114
+115        let now = Instant::now();
+116        let streak_start = {
+117            let mut first = self.first_failure_at.write().await;
+118            *first.get_or_insert(now)
+119        };
+120
+121        if now.duration_since(streak_start) >= OFFLINE_CONFIRM_WINDOW {
+122            log::warn!(
+123                "[ConnectivityMonitor] Network failures sustained for {:?}; declaring offline",
+124                OFFLINE_CONFIRM_WINDOW
+125            );
+126            self.set_reachable(false, error).await;
+127        } else {
+128            log::debug!(
+129                "[ConnectivityMonitor] Network failure within debounce window; not yet offline"
+130            );
+131        }
+132    }
+133
+134    /// Apply a deliberate reachability probe result (offline recovery probe or a
+135    /// manual check). Unlike `report_network_failure`, a probe is an explicit
+136    /// reachability test, so its result is applied immediately without debounce.
+137    async fn apply_probe_result(&self, is_reachable: bool, error: Option<String>) {
+138        if is_reachable {
+139            *self.first_failure_at.write().await = None;
+140        }
+141        self.set_reachable(is_reachable, error).await;
+142    }
+143
+144    /// Core transition: update status and emit events only on an actual change.
+145    async fn set_reachable(&self, is_reachable: bool, error: Option<String>) {
+146        let was_reachable = {
+147            let mut status = self.status.write().await;
+148            let was = status.is_server_reachable;
+149            status.is_server_reachable = is_reachable;
+150            status.last_checked = Some(chrono::Utc::now().to_rfc3339());
+151            status.connection_error = if is_reachable {
+152                None
+153            } else {
+154                Some(error.unwrap_or_else(|| "Server unreachable".to_string()))
+155            };
+156            status.is_checking = false;
+157            was
+158        };
+159
+160        if is_reachable != was_reachable {
+161            self.emit_connectivity_change(is_reachable).await;
+162            if is_reachable {
+163                self.emit_server_reconnected().await;
+164            }
+165        }
+166    }
+167
+168    /// Emit connectivity change event to frontend
+169    async fn emit_connectivity_change(&self, is_reachable: bool) {
+170        if let Some(app_handle) = &self.app_handle {
+171            let event = ConnectivityChangeEvent { is_reachable };
+172            if let Err(e) = app_handle.emit("connectivity:changed", event) {
+173                log::error!(
+174                    "[ConnectivityMonitor] Failed to emit connectivity change event: {}",
+175                    e
+176                );
+177            } else {
+178                log::info!(
+179                    "[ConnectivityMonitor] Emitted connectivity change: {}",
+180                    is_reachable
+181                );
+182            }
+183        }
+184    }
+185
+186    /// Emit server reconnected event to frontend
+187    async fn emit_server_reconnected(&self) {
+188        if let Some(app_handle) = &self.app_handle {
+189            if let Err(e) = app_handle.emit("connectivity:reconnected", ()) {
+190                log::error!(
+191                    "[ConnectivityMonitor] Failed to emit reconnection event: {}",
+192                    e
+193                );
+194            } else {
+195                log::info!("[ConnectivityMonitor] Emitted server reconnected event");
+196            }
+197        }
+198    }
+199}
+200
+201/// Connectivity monitor for tracking server reachability.
+202///
+203/// Reachability is driven primarily by real repository traffic via the shared
+204/// [`ConnectivityReporter`]. The monitor itself only runs an offline recovery
+205/// probe (see `start_monitoring`) and serves the connectivity Tauri commands.
+206pub struct ConnectivityMonitor {
+207    server_url: Arc<RwLock<Option<String>>>,
+208    http_client: Arc<HttpClient>,
+209    reporter: ConnectivityReporter,
+210    is_monitoring: Arc<AtomicBool>,
+211}
+212
+213impl ConnectivityMonitor {
+214    /// Create a new connectivity monitor
+215    pub fn new(http_client: HttpClient) -> Self {
+216        let status = Arc::new(RwLock::new(ConnectivityStatus::default()));
+217        Self {
+218            server_url: Arc::new(RwLock::new(None)),
+219            http_client: Arc::new(http_client),
+220            reporter: ConnectivityReporter::new(status, None),
+221            is_monitoring: Arc::new(AtomicBool::new(false)),
+222        }
+223    }
+224
+225    /// Set the Tauri app handle for event emission.
+226    /// Must be called before the reporter is shared with the repository.
+227    pub fn set_app_handle(&mut self, app_handle: AppHandle) {
+228        self.reporter.app_handle = Some(app_handle);
+229    }
+230
+231    /// Get a cheap, cloneable reporter so the repository can feed server
+232    /// outcomes into the same reachability state the UI observes.
+233    pub fn reporter(&self) -> ConnectivityReporter {
+234        self.reporter.clone()
+235    }
+236
+237    /// Update the server URL
+238    pub async fn set_server_url(&self, url: String) {
+239        log::info!("[ConnectivityMonitor] Setting server URL: {}", url);
+240        *self.server_url.write().await = Some(url);
+241
+242        // Check new server immediately
+243        log::info!("[ConnectivityMonitor] Checking reachability of new server...");
+244        let is_reachable = self.check_reachability().await;
+245        log::info!(
+246            "[ConnectivityMonitor] New server is {}",
+247            if is_reachable {
+248                "REACHABLE"
+249            } else {
+250                "UNREACHABLE"
+251            }
+252        );
+253    }
+254
+255    /// Get current connectivity status
+256    pub async fn get_status(&self) -> ConnectivityStatus {
+257        self.reporter.status.read().await.clone()
+258    }
+259
+260    /// Deliberately probe the server's reachability (manual check / recovery probe).
+261    /// The result is applied immediately (no debounce) since this is an explicit test.
+262    pub async fn check_reachability(&self) -> bool {
+263        {
+264            let mut status = self.reporter.status.write().await;
+265            status.is_checking = true;
+266        }
+267
+268        let server_url = self.server_url.read().await.clone();
+269
+270        let Some(url) = server_url else {
+271            log::warn!("[ConnectivityMonitor] Cannot check reachability: No server URL configured");
+272            self.reporter
+273                .apply_probe_result(false, Some("No server URL configured".to_string()))
+274                .await;
+275            return false;
+276        };
+277
+278        let ping_url = format!("{}/System/Info/Public", url);
+279        log::debug!("[ConnectivityMonitor] Pinging server: {}", ping_url);
+280
+281        let is_reachable = self.http_client.ping(&ping_url).await;
+282        log::debug!(
+283            "[ConnectivityMonitor] Ping result: {}",
+284            if is_reachable { "SUCCESS" } else { "FAILED" }
+285        );
+286
+287        self.reporter.apply_probe_result(is_reachable, None).await;
+288        is_reachable
+289    }
+290
+291    /// Mark server as reachable (called after successful API call / login)
+292    pub async fn mark_reachable(&self) {
+293        self.reporter.report_success().await;
+294    }
+295
+296    /// Mark server as unreachable directly.
+297    ///
+298    /// Used by deliberate signals (e.g. a failed login/connect) where the caller
+299    /// knows the server is unreachable now. Repository traffic should prefer
+300    /// `reporter().report_network_failure()` so the debounce applies.
+301    pub async fn mark_unreachable(&self, error: Option<String>) {
+302        self.reporter.apply_probe_result(false, error).await;
+303    }
+304
+305    /// Start the offline recovery probe.
+306    ///
+307    /// While **online**, reachability is kept fresh by real traffic, so the probe
+308    /// idles. While **offline**, it polls `/System/Info/Public` every
+309    /// `RETRY_CHECK_INTERVAL_MS` to detect the server returning even when no user
+310    /// traffic is flowing.
+311    pub async fn start_monitoring(&self) {
+312        if self.is_monitoring.swap(true, Ordering::SeqCst) {
+313            log::info!("[ConnectivityMonitor] Already monitoring");
+314            return;
+315        }
+316
+317        log::info!(
+318            "[ConnectivityMonitor] Starting connectivity monitoring (offline recovery probe)"
+319        );
+320
+321        // Perform an immediate check so startup reflects reality quickly.
+322        let is_reachable = self.check_reachability().await;
+323        log::info!(
+324            "[ConnectivityMonitor] Initial connectivity check: {}",
+325            if is_reachable { "ONLINE" } else { "OFFLINE" }
+326        );
+327
+328        let is_monitoring = Arc::clone(&self.is_monitoring);
+329        let server_url = Arc::clone(&self.server_url);
+330        let http_client = Arc::clone(&self.http_client);
+331        let reporter = self.reporter.clone();
+332
+333        tokio::spawn(async move {
+334            while is_monitoring.load(Ordering::SeqCst) {
+335                tokio::time::sleep(Duration::from_millis(RETRY_CHECK_INTERVAL_MS)).await;
+336
+337                if !is_monitoring.load(Ordering::SeqCst) {
+338                    break;
+339                }
+340
+341                // Only probe while offline — real traffic is the signal when online.
+342                if reporter.status.read().await.is_server_reachable {
+343                    continue;
+344                }
+345
+346                let Some(url) = server_url.read().await.clone() else {
+347                    continue;
+348                };
+349                let ping_url = format!("{}/System/Info/Public", url);
+350                let is_reachable = http_client.ping(&ping_url).await;
+351
+352                // Probe only ever recovers us to online; a failed probe leaves us
+353                // offline without re-emitting (no change).
+354                if is_reachable {
+355                    reporter.apply_probe_result(true, None).await;
+356                }
+357            }
+358
+359            log::info!("[ConnectivityMonitor] Stopped monitoring");
+360        });
+361    }
+362
+363    /// Stop monitoring connectivity
+364    pub fn stop_monitoring(&self) {
+365        log::info!("[ConnectivityMonitor] Stopping connectivity monitoring");
+366        self.is_monitoring.store(false, Ordering::SeqCst);
+367    }
+368}
+369
+370#[cfg(test)]
+371mod tests {
+372    use super::*;
+373
+374    /// Build a reporter backed by a fresh (optimistic) status, with no app handle.
+375    /// Event emission is a no-op without a handle, which is exactly what we want
+376    /// for unit-testing the reachability state transitions.
+377    fn test_reporter() -> ConnectivityReporter {
+378        ConnectivityReporter::new(Arc::new(RwLock::new(ConnectivityStatus::default())), None)
+379    }
+380
+381    async fn is_reachable(reporter: &ConnectivityReporter) -> bool {
+382        reporter.status.read().await.is_server_reachable
+383    }
+384
+385    #[test]
+386    fn test_intervals() {
+387        // Offline recovery probe interval (online has no polling).
+388        assert_eq!(RETRY_CHECK_INTERVAL_MS, 5000);
+389        assert_eq!(OFFLINE_CONFIRM_WINDOW, Duration::from_secs(5));
+390    }
+391
+392    #[tokio::test]
+393    async fn test_default_status() {
+394        let status = ConnectivityStatus::default();
+395        // Default is now optimistic (assume online until proven otherwise)
+396        assert!(status.is_server_reachable);
+397        assert!(status.last_checked.is_none());
+398        assert!(status.connection_error.is_none());
+399        assert!(!status.is_checking);
+400    }
+401
+402    /// A single (or brief) network failure must NOT flip the app offline:
+403    /// the time-window debounce keeps us online until the failure persists.
+404    ///
+405    /// @req-test: UR-002 - Access media when online or offline
+406    #[tokio::test]
+407    async fn test_single_network_failure_does_not_go_offline() {
+408        let reporter = test_reporter();
+409        assert!(is_reachable(&reporter).await, "starts online");
+410
+411        reporter
+412            .report_network_failure(Some("timeout".to_string()))
+413            .await;
+414
+415        assert!(
+416            is_reachable(&reporter).await,
+417            "one network failure within the debounce window stays online"
+418        );
+419        // But the failure streak is now being tracked.
+420        assert!(reporter.first_failure_at.read().await.is_some());
+421    }
+422
+423    /// Once failures persist past OFFLINE_CONFIRM_WINDOW, we flip offline.
+424    /// We simulate elapsed time by backdating the streak start.
+425    ///
+426    /// @req-test: UR-002 - Access media when online or offline
+427    #[tokio::test]
+428    async fn test_sustained_network_failure_goes_offline() {
+429        let reporter = test_reporter();
+430
+431        // First failure starts the streak.
+432        reporter.report_network_failure(None).await;
+433        assert!(is_reachable(&reporter).await);
+434
+435        // Backdate the streak start to before the window.
+436        {
+437            let mut first = reporter.first_failure_at.write().await;
+438            *first = Some(Instant::now() - OFFLINE_CONFIRM_WINDOW - Duration::from_secs(1));
+439        }
+440
+441        // Next failure now exceeds the window → offline.
+442        reporter
+443            .report_network_failure(Some("connection refused".to_string()))
+444            .await;
+445        assert!(
+446            !is_reachable(&reporter).await,
+447            "sustained network failure flips to offline"
+448        );
+449    }
+450
+451    /// A success during a failure streak clears the streak and keeps us online —
+452    /// recovery is instant and never trips the banner.
+453    #[tokio::test]
+454    async fn test_success_clears_failure_streak() {
+455        let reporter = test_reporter();
+456
+457        reporter.report_network_failure(None).await;
+458        assert!(reporter.first_failure_at.read().await.is_some());
+459
+460        reporter.report_success().await;
+461
+462        assert!(is_reachable(&reporter).await);
+463        assert!(
+464            reporter.first_failure_at.read().await.is_none(),
+465            "success resets the debounce streak"
+466        );
+467    }
+468
+469    /// First success after being offline recovers instantly (no debounce on the
+470    /// way back up).
+471    #[tokio::test]
+472    async fn test_recovery_is_instant() {
+473        let reporter = test_reporter();
+474
+475        // Force offline.
+476        reporter
+477            .apply_probe_result(false, Some("down".to_string()))
+478            .await;
+479        assert!(!is_reachable(&reporter).await);
+480
+481        // A single success brings us straight back online.
+482        reporter.report_success().await;
+483        assert!(is_reachable(&reporter).await);
+484        let status = reporter.status.read().await;
+485        assert!(status.connection_error.is_none());
+486    }
+487
+488    /// Server-answered errors (401/404/5xx) are reported via report_success
+489    /// by the repository, because the server is demonstrably reachable. This
+490    /// test documents that contract: report_success means "server is up".
+491    #[tokio::test]
+492    async fn test_server_answered_error_counts_as_reachable() {
+493        let reporter = test_reporter();
+494
+495        // Simulate being offline, then the server answers (even with an error).
+496        reporter.apply_probe_result(false, None).await;
+497        assert!(!is_reachable(&reporter).await);
+498
+499        // Repository maps Authentication/NotFound/Server errors to report_success.
+500        reporter.report_success().await;
+501        assert!(
+502            is_reachable(&reporter).await,
+503            "a server that answers (even with 4xx/5xx) is reachable"
+504        );
+505    }
+506
+507    /// report_network_failure is a no-op once already offline (nothing to debounce,
+508    /// no duplicate events).
+509    #[tokio::test]
+510    async fn test_network_failure_noop_when_already_offline() {
+511        let reporter = test_reporter();
+512        reporter.apply_probe_result(false, None).await;
+513        assert!(!is_reachable(&reporter).await);
+514
+515        // Should not panic or change state.
+516        reporter
+517            .report_network_failure(Some("still down".to_string()))
+518            .await;
+519        assert!(!is_reachable(&reporter).await);
+520    }
+521}
+
\ No newline at end of file diff --git a/api/src/jellytau_lib/credentials.rs.html b/api/src/jellytau_lib/credentials.rs.html new file mode 100644 index 00000000..2007e4a9 --- /dev/null +++ b/api/src/jellytau_lib/credentials.rs.html @@ -0,0 +1,974 @@ +credentials.rs - sourceSkip to main content

jellytau_lib/
credentials.rs

1//! Secure credential storage module
+2//!
+3//! Provides secure storage for access tokens using:
+4//! - Primary: System keyring (Secret Service on Linux, Keychain on macOS)
+5//! - Fallback: AES-256-GCM encrypted file when keyring unavailable
+6//!
+7//! The fallback is less secure as the encryption key is derived from machine
+8//! identifiers, but provides functionality on headless systems.
+9//!
+10//! TRACES: UR-012 | IR-014
+11
+12use aes_gcm::{
+13    aead::{Aead, KeyInit},
+14    Aes256Gcm, Nonce,
+15};
+16use base64::{engine::general_purpose::STANDARD as BASE64, Engine};
+17use directories::ProjectDirs;
+18use log::{info, warn};
+19use sha2::{Digest, Sha256};
+20use std::fs;
+21use std::path::PathBuf;
+22
+23#[cfg(not(target_os = "android"))]
+24const SERVICE_NAME: &str = "com.dtourolle.jellytau";
+25
+26const CREDENTIALS_FILENAME: &str = "credentials.enc";
+27
+28/// Result of a credential storage operation
+29#[derive(Debug)]
+30pub enum CredentialResult {
+31    /// Operation succeeded using the system keyring
+32    Keyring,
+33    /// Operation succeeded using encrypted file fallback
+34    EncryptedFile,
+35}
+36
+37/// Error types for credential operations
+38#[derive(Debug)]
+39pub enum CredentialError {
+40    /// Keyring operation failed
+41    Keyring(String),
+42    /// Encryption/decryption failed
+43    Encryption(String),
+44    /// File I/O failed
+45    Io(String),
+46    /// Credential not found
+47    NotFound,
+48}
+49
+50impl std::fmt::Display for CredentialError {
+51    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
+52        match self {
+53            Self::Keyring(msg) => write!(f, "Keyring error: {}", msg),
+54            Self::Encryption(msg) => write!(f, "Encryption error: {}", msg),
+55            Self::Io(msg) => write!(f, "I/O error: {}", msg),
+56            Self::NotFound => write!(f, "Credential not found"),
+57        }
+58    }
+59}
+60
+61impl std::error::Error for CredentialError {}
+62
+63/// Credential storage manager
+64pub struct CredentialStore {
+65    /// Whether we're using keyring (true) or encrypted file (false)
+66    using_keyring: bool,
+67    /// Path to the encrypted credentials file (fallback)
+68    credentials_path: PathBuf,
+69    /// Encryption key for file fallback (derived from machine ID)
+70    encryption_key: [u8; 32],
+71}
+72
+73impl CredentialStore {
+74    /// Create a new credential store, detecting the best available backend
+75    pub fn new() -> Self {
+76        let credentials_path = Self::get_credentials_path();
+77        let encryption_key = Self::derive_encryption_key();
+78
+79        // Test if keyring is available by trying a dummy operation
+80        let using_keyring = Self::test_keyring_available();
+81
+82        if !using_keyring {
+83            warn!(
+84                "[INIT] System keyring unavailable, using encrypted file fallback at {:?}. \
+85                 This is less secure than system keyring storage.",
+86                credentials_path
+87            );
+88        } else {
+89            info!("[INIT] Using system keyring for credential storage");
+90        }
+91
+92        Self {
+93            using_keyring,
+94            credentials_path,
+95            encryption_key,
+96        }
+97    }
+98
+99    /// Check if we're using the secure keyring backend
+100    pub fn is_using_keyring(&self) -> bool {
+101        self.using_keyring
+102    }
+103
+104    /// Save an access token for a user
+105    pub fn save_token(
+106        &self,
+107        user_id: &str,
+108        token: &str,
+109    ) -> Result<CredentialResult, CredentialError> {
+110        if self.using_keyring {
+111            log::debug!("Saving token for user {} to keyring", user_id);
+112            self.save_to_keyring(user_id, token)?;
+113            Ok(CredentialResult::Keyring)
+114        } else {
+115            log::debug!(
+116                "Saving token for user {} to encrypted file at {:?}",
+117                user_id,
+118                self.credentials_path
+119            );
+120            self.save_to_file(user_id, token)?;
+121            log::debug!("Successfully saved token to encrypted file");
+122            Ok(CredentialResult::EncryptedFile)
+123        }
+124    }
+125
+126    /// Get an access token for a user
+127    pub fn get_token(&self, user_id: &str) -> Result<String, CredentialError> {
+128        if self.using_keyring {
+129            log::debug!("Getting token for user {} from keyring", user_id);
+130            self.get_from_keyring(user_id)
+131        } else {
+132            log::debug!(
+133                "Getting token for user {} from encrypted file at {:?}",
+134                user_id,
+135                self.credentials_path
+136            );
+137            let result = self.get_from_file(user_id);
+138            if result.is_ok() {
+139                log::debug!("Successfully retrieved token from encrypted file");
+140            } else {
+141                log::warn!("Failed to retrieve token from encrypted file: {:?}", result);
+142            }
+143            result
+144        }
+145    }
+146
+147    /// Delete an access token for a user
+148    pub fn delete_token(&self, user_id: &str) -> Result<(), CredentialError> {
+149        if self.using_keyring {
+150            self.delete_from_keyring(user_id)
+151        } else {
+152            self.delete_from_file(user_id)
+153        }
+154    }
+155
+156    // --- Keyring backend ---
+157
+158    fn test_keyring_available() -> bool {
+159        // On Android, use Android Keystore via JNI
+160        #[cfg(target_os = "android")]
+161        {
+162            android_test_keystore_available()
+163        }
+164
+165        // On Linux, the keyring test can block indefinitely if Secret Service
+166        // (gnome-keyring/kwallet) is unresponsive. Use a timeout to prevent hanging.
+167        #[cfg(target_os = "linux")]
+168        {
+169            use std::sync::mpsc;
+170            use std::thread;
+171            use std::time::Duration;
+172
+173            let (tx, rx) = mpsc::channel();
+174
+175            thread::spawn(move || {
+176                let result = Self::test_keyring_inner();
+177                let _ = tx.send(result);
+178            });
+179
+180            // Wait up to 2 seconds for keyring response
+181            match rx.recv_timeout(Duration::from_secs(2)) {
+182                Ok(result) => result,
+183                Err(_) => {
+184                    log::warn!("Keyring availability check timed out after 2 seconds");
+185                    false
+186                }
+187            }
+188        }
+189
+190        #[cfg(all(not(target_os = "linux"), not(target_os = "android")))]
+191        {
+192            Self::test_keyring_inner()
+193        }
+194    }
+195
+196    #[cfg(target_os = "linux")]
+197    fn test_keyring_inner() -> bool {
+198        // On Linux, test if secret-tool is available
+199        use std::process::Command;
+200
+201        // secret-tool doesn't support --version, so we test with a search command
+202        // that will succeed even if no items are found
+203        Command::new("secret-tool")
+204            .arg("search")
+205            .arg("service")
+206            .arg("__nonexistent_test__")
+207            .output()
+208            .is_ok()
+209    }
+210
+211    #[cfg(all(not(target_os = "android"), not(target_os = "linux")))]
+212    fn test_keyring_inner() -> bool {
+213        // On macOS/Windows, test using the keyring-rs library
+214        let entry = keyring::Entry::new(SERVICE_NAME, "__test__");
+215        match entry {
+216            Ok(e) => {
+217                // Try to get (will fail with NotFound, which is fine)
+218                // If it fails with a different error, keyring is not available
+219                match e.get_password() {
+220                    Ok(_) => true,
+221                    Err(keyring::Error::NoEntry) => true,
+222                    Err(keyring::Error::NoStorageAccess(_)) => false,
+223                    Err(_) => false,
+224                }
+225            }
+226            Err(_) => false,
+227        }
+228    }
+229
+230    fn save_to_keyring(&self, user_id: &str, token: &str) -> Result<(), CredentialError> {
+231        // On Android, use Android Keystore via JNI
+232        #[cfg(target_os = "android")]
+233        {
+234            android_keystore::save_token(user_id, token)
+235        }
+236
+237        #[cfg(target_os = "linux")]
+238        {
+239            // Use secret-tool directly on Linux as a workaround for keyring-rs library issues
+240            // See Technical Debt section in README.md for details
+241            use std::io::Write;
+242            use std::process::{Command, Stdio};
+243
+244            let key = format!("access_token:{}", user_id);
+245            let mut child = Command::new("secret-tool")
+246                .arg("store")
+247                .arg("--label")
+248                .arg(format!("{}@{}", key, SERVICE_NAME))
+249                .arg("service")
+250                .arg(SERVICE_NAME)
+251                .arg("username")
+252                .arg(&key)
+253                .stdin(Stdio::piped())
+254                .stdout(Stdio::null())
+255                .stderr(Stdio::null())
+256                .spawn()
+257                .map_err(|e| {
+258                    CredentialError::Keyring(format!("Failed to spawn secret-tool: {}", e))
+259                })?;
+260
+261            if let Some(mut stdin) = child.stdin.take() {
+262                stdin.write_all(token.as_bytes()).map_err(|e| {
+263                    CredentialError::Keyring(format!("Failed to write to secret-tool: {}", e))
+264                })?;
+265            }
+266
+267            let status = child.wait().map_err(|e| {
+268                CredentialError::Keyring(format!("Failed to wait for secret-tool: {}", e))
+269            })?;
+270
+271            if status.success() {
+272                Ok(())
+273            } else {
+274                Err(CredentialError::Keyring(format!(
+275                    "secret-tool failed with status: {}",
+276                    status
+277                )))
+278            }
+279        }
+280
+281        #[cfg(all(not(target_os = "linux"), not(target_os = "android")))]
+282        {
+283            let key = format!("access_token:{}", user_id);
+284            let entry = keyring::Entry::new(SERVICE_NAME, &key)
+285                .map_err(|e| CredentialError::Keyring(e.to_string()))?;
+286            entry
+287                .set_password(token)
+288                .map_err(|e| CredentialError::Keyring(e.to_string()))
+289        }
+290    }
+291
+292    fn get_from_keyring(&self, user_id: &str) -> Result<String, CredentialError> {
+293        // On Android, use Android Keystore via JNI
+294        #[cfg(target_os = "android")]
+295        {
+296            android_keystore::get_token(user_id)
+297        }
+298
+299        #[cfg(target_os = "linux")]
+300        {
+301            // Use secret-tool directly on Linux as a workaround for keyring-rs library issues
+302            // See Technical Debt section in README.md for details
+303            use std::process::Command;
+304
+305            let key = format!("access_token:{}", user_id);
+306            log::debug!(
+307                "Looking up token with service={}, username={}",
+308                SERVICE_NAME,
+309                key
+310            );
+311
+312            let output = Command::new("secret-tool")
+313                .arg("lookup")
+314                .arg("service")
+315                .arg(SERVICE_NAME)
+316                .arg("username")
+317                .arg(&key)
+318                .output()
+319                .map_err(|e| {
+320                    CredentialError::Keyring(format!("Failed to run secret-tool: {}", e))
+321                })?;
+322
+323            if output.status.success() {
+324                log::debug!(
+325                    "secret-tool lookup succeeded, token length: {}",
+326                    output.stdout.len()
+327                );
+328                let token = String::from_utf8(output.stdout)
+329                    .map_err(|e| {
+330                        CredentialError::Keyring(format!("Invalid UTF-8 in token: {}", e))
+331                    })?
+332                    .trim()
+333                    .to_string();
+334                Ok(token)
+335            } else {
+336                let stderr = String::from_utf8_lossy(&output.stderr);
+337                log::warn!(
+338                    "secret-tool lookup failed with status: {} stderr: {}",
+339                    output.status,
+340                    stderr
+341                );
+342                Err(CredentialError::NotFound)
+343            }
+344        }
+345
+346        #[cfg(all(not(target_os = "linux"), not(target_os = "android")))]
+347        {
+348            let key = format!("access_token:{}", user_id);
+349            let entry = keyring::Entry::new(SERVICE_NAME, &key)
+350                .map_err(|e| CredentialError::Keyring(e.to_string()))?;
+351            entry.get_password().map_err(|e| match e {
+352                keyring::Error::NoEntry => CredentialError::NotFound,
+353                _ => CredentialError::Keyring(e.to_string()),
+354            })
+355        }
+356    }
+357
+358    fn delete_from_keyring(&self, user_id: &str) -> Result<(), CredentialError> {
+359        // On Android, use Android Keystore via JNI
+360        #[cfg(target_os = "android")]
+361        {
+362            android_keystore::delete_token(user_id)
+363        }
+364
+365        #[cfg(target_os = "linux")]
+366        {
+367            // Use secret-tool directly on Linux as a workaround for keyring-rs library issues
+368            // See Technical Debt section in README.md for details
+369            use std::process::Command;
+370
+371            let key = format!("access_token:{}", user_id);
+372            let status = Command::new("secret-tool")
+373                .arg("clear")
+374                .arg("service")
+375                .arg(SERVICE_NAME)
+376                .arg("username")
+377                .arg(&key)
+378                .status()
+379                .map_err(|e| {
+380                    CredentialError::Keyring(format!("Failed to run secret-tool: {}", e))
+381                })?;
+382
+383            // secret-tool clear returns success even if entry doesn't exist
+384            if status.success() {
+385                Ok(())
+386            } else {
+387                Err(CredentialError::Keyring(format!(
+388                    "secret-tool clear failed with status: {}",
+389                    status
+390                )))
+391            }
+392        }
+393
+394        #[cfg(all(not(target_os = "linux"), not(target_os = "android")))]
+395        {
+396            let key = format!("access_token:{}", user_id);
+397            let entry = keyring::Entry::new(SERVICE_NAME, &key)
+398                .map_err(|e| CredentialError::Keyring(e.to_string()))?;
+399            match entry.delete_credential() {
+400                Ok(_) => Ok(()),
+401                Err(keyring::Error::NoEntry) => Ok(()), // Already deleted
+402                Err(e) => Err(CredentialError::Keyring(e.to_string())),
+403            }
+404        }
+405    }
+406
+407    // --- Encrypted file backend ---
+408
+409    fn get_credentials_path() -> PathBuf {
+410        if let Some(proj_dirs) = ProjectDirs::from("com", "dtourolle", "jellytau") {
+411            proj_dirs.data_dir().join(CREDENTIALS_FILENAME)
+412        } else {
+413            PathBuf::from(CREDENTIALS_FILENAME)
+414        }
+415    }
+416
+417    fn derive_encryption_key() -> [u8; 32] {
+418        // Derive a key from machine-specific identifiers
+419        // This is less secure than a true keyring but provides some protection
+420        let mut hasher = Sha256::new();
+421
+422        // Use hostname on Linux (where it's available and stable)
+423        #[cfg(target_os = "linux")]
+424        {
+425            if let Ok(hostname) = hostname::get() {
+426                hasher.update(hostname.to_string_lossy().as_bytes());
+427            }
+428        }
+429
+430        // On Android, read device properties from the filesystem
+431        #[cfg(target_os = "android")]
+432        {
+433            // Try to read Android build properties from /system/build.prop
+434            let build_prop_paths = ["/system/build.prop", "/vendor/build.prop"];
+435
+436            for path in &build_prop_paths {
+437                if let Ok(content) = fs::read_to_string(path) {
+438                    // Extract key properties for device fingerprint
+439                    for line in content.lines() {
+440                        if line.starts_with("ro.build.fingerprint=")
+441                            || line.starts_with("ro.serialno=")
+442                            || line.starts_with("ro.build.id=")
+443                            || line.starts_with("ro.product.model=")
+444                        {
+445                            hasher.update(line.as_bytes());
+446                        }
+447                    }
+448                }
+449            }
+450
+451            // Also use the app data directory path as it's device/install-specific
+452            if let Some(proj_dirs) = ProjectDirs::from("com", "dtourolle", "jellytau") {
+453                hasher.update(proj_dirs.data_dir().to_string_lossy().as_bytes());
+454            }
+455        }
+456
+457        // Use a static salt (app-specific)
+458        hasher.update(b"jellytau-credential-encryption-v1");
+459
+460        // Add username for additional entropy (if available)
+461        if let Ok(user) = std::env::var("USER").or_else(|_| std::env::var("USERNAME")) {
+462            hasher.update(user.as_bytes());
+463        }
+464
+465        hasher.finalize().into()
+466    }
+467
+468    /// Load and decrypt the credential map.
+469    ///
+470    /// A file that is present but **undecryptable** is deliberately reported as
+471    /// an *empty* credential set rather than as an error. The key never leaves
+472    /// the device it was derived on (Android Keystore keys are never backed up,
+473    /// and the file fallback's key is derived from machine identifiers), so a
+474    /// restored/transferred install gets ciphertext with no key and every read
+475    /// would fail *permanently*. Surfacing that as an error made session restore
+476    /// throw instead of falling back to the login screen: an unrecoverable app
+477    /// rather than a clean logged-out one. The next successful login re-encrypts
+478    /// the file with the current key, so the state self-heals.
+479    ///
+480    /// TRACES: UR-012 | IR-014
+481    fn load_credentials_file(&self) -> Result<serde_json::Value, CredentialError> {
+482        if !self.credentials_path.exists() {
+483            return Ok(serde_json::json!({}));
+484        }
+485
+486        let encrypted_data = fs::read_to_string(&self.credentials_path)
+487            .map_err(|e| CredentialError::Io(e.to_string()))?;
+488
+489        if encrypted_data.is_empty() {
+490            return Ok(serde_json::json!({}));
+491        }
+492
+493        let decrypted = match self.decrypt(&encrypted_data) {
+494            Ok(decrypted) => decrypted,
+495            Err(e) => {
+496                warn!(
+497                    "Credentials file at {:?} exists but cannot be decrypted ({}); \
+498                     treating as no stored credentials. This is expected after a \
+499                     backup restore or device transfer - the encryption key does \
+500                     not travel with the data. Signing in again will rewrite it.",
+501                    self.credentials_path, e
+502                );
+503                return Ok(serde_json::json!({}));
+504            }
+505        };
+506
+507        match serde_json::from_str(&decrypted) {
+508            Ok(value) => Ok(value),
+509            Err(e) => {
+510                warn!(
+511                    "Credentials file at {:?} decrypted to invalid JSON ({}); \
+512                     treating as no stored credentials.",
+513                    self.credentials_path, e
+514                );
+515                Ok(serde_json::json!({}))
+516            }
+517        }
+518    }
+519
+520    fn save_credentials_file(&self, data: &serde_json::Value) -> Result<(), CredentialError> {
+521        // Ensure parent directory exists
+522        if let Some(parent) = self.credentials_path.parent() {
+523            fs::create_dir_all(parent).map_err(|e| CredentialError::Io(e.to_string()))?;
+524        }
+525
+526        let json =
+527            serde_json::to_string(data).map_err(|e| CredentialError::Encryption(e.to_string()))?;
+528        let encrypted = self.encrypt(&json)?;
+529
+530        fs::write(&self.credentials_path, encrypted).map_err(|e| CredentialError::Io(e.to_string()))
+531    }
+532
+533    fn encrypt(&self, plaintext: &str) -> Result<String, CredentialError> {
+534        let cipher = Aes256Gcm::new_from_slice(&self.encryption_key)
+535            .map_err(|e| CredentialError::Encryption(e.to_string()))?;
+536
+537        // Generate a random nonce
+538        let mut nonce_bytes = [0u8; 12];
+539        getrandom::getrandom(&mut nonce_bytes)
+540            .map_err(|e| CredentialError::Encryption(e.to_string()))?;
+541        let nonce = Nonce::from_slice(&nonce_bytes);
+542
+543        let ciphertext = cipher
+544            .encrypt(nonce, plaintext.as_bytes())
+545            .map_err(|e| CredentialError::Encryption(e.to_string()))?;
+546
+547        // Prepend nonce to ciphertext and encode as base64
+548        let mut combined = nonce_bytes.to_vec();
+549        combined.extend(ciphertext);
+550
+551        Ok(BASE64.encode(&combined))
+552    }
+553
+554    fn decrypt(&self, encrypted: &str) -> Result<String, CredentialError> {
+555        let combined = BASE64
+556            .decode(encrypted)
+557            .map_err(|e| CredentialError::Encryption(e.to_string()))?;
+558
+559        if combined.len() < 12 {
+560            return Err(CredentialError::Encryption(
+561                "Invalid encrypted data".to_string(),
+562            ));
+563        }
+564
+565        let (nonce_bytes, ciphertext) = combined.split_at(12);
+566        let nonce = Nonce::from_slice(nonce_bytes);
+567
+568        let cipher = Aes256Gcm::new_from_slice(&self.encryption_key)
+569            .map_err(|e| CredentialError::Encryption(e.to_string()))?;
+570
+571        let plaintext = cipher
+572            .decrypt(nonce, ciphertext)
+573            .map_err(|e| CredentialError::Encryption(e.to_string()))?;
+574
+575        String::from_utf8(plaintext).map_err(|e| CredentialError::Encryption(e.to_string()))
+576    }
+577
+578    fn save_to_file(&self, user_id: &str, token: &str) -> Result<(), CredentialError> {
+579        let mut data = self.load_credentials_file()?;
+580        data[user_id] = serde_json::json!(token);
+581        self.save_credentials_file(&data)
+582    }
+583
+584    fn get_from_file(&self, user_id: &str) -> Result<String, CredentialError> {
+585        let data = self.load_credentials_file()?;
+586        data.get(user_id)
+587            .and_then(|v| v.as_str())
+588            .map(|s| s.to_string())
+589            .ok_or(CredentialError::NotFound)
+590    }
+591
+592    fn delete_from_file(&self, user_id: &str) -> Result<(), CredentialError> {
+593        let mut data = self.load_credentials_file()?;
+594        if let Some(obj) = data.as_object_mut() {
+595            obj.remove(user_id);
+596        }
+597        self.save_credentials_file(&data)
+598    }
+599}
+600
+601impl Default for CredentialStore {
+602    fn default() -> Self {
+603        Self::new()
+604    }
+605}
+606
+607// --- Android Keystore integration via JNI ---
+608
+609#[cfg(target_os = "android")]
+610mod android_keystore {
+611    use super::*;
+612    use jni::objects::{JClass, JObject, JString, JValue};
+613    use jni::JNIEnv;
+614    use std::sync::OnceLock;
+615
+616    /// Cached reference to the SecureStorage class
+617    static SECURE_STORAGE_CLASS: OnceLock<String> = OnceLock::new();
+618
+619    const SECURE_STORAGE_CLASS_NAME: &str = "com/dtourolle/jellytau/security/SecureStorage";
+620
+621    /// Initialize the SecureStorage singleton from Android context
+622    pub fn initialize_secure_storage(env: &mut JNIEnv, context: &JObject) -> Result<(), String> {
+623        log::info!("Initializing Android SecureStorage...");
+624
+625        // Get the ClassLoader from the Context
+626        let class_loader = env
+627            .call_method(context, "getClassLoader", "()Ljava/lang/ClassLoader;", &[])
+628            .map_err(|e| format!("Failed to get ClassLoader: {}", e))?
+629            .l()
+630            .map_err(|e| format!("Failed to convert ClassLoader: {}", e))?;
+631
+632        // Load the SecureStorage class
+633        let class_name = env
+634            .new_string(SECURE_STORAGE_CLASS_NAME.replace('/', "."))
+635            .map_err(|e| format!("Failed to create class name string: {}", e))?;
+636
+637        let storage_class_obj = env
+638            .call_method(
+639                &class_loader,
+640                "loadClass",
+641                "(Ljava/lang/String;)Ljava/lang/Class;",
+642                &[JValue::Object(&class_name.into())],
+643            )
+644            .map_err(|e| format!("Failed to load SecureStorage class: {}", e))?
+645            .l()
+646            .map_err(|e| format!("Failed to convert to Class: {}", e))?;
+647
+648        let storage_class = JClass::from(storage_class_obj);
+649
+650        // Call SecureStorage.initialize(context)
+651        env.call_static_method(
+652            &storage_class,
+653            "initialize",
+654            "(Landroid/content/Context;)V",
+655            &[JValue::Object(context)],
+656        )
+657        .map_err(|e| format!("Failed to initialize SecureStorage: {}", e))?;
+658
+659        // Cache the class name for future use
+660        let _ = SECURE_STORAGE_CLASS.set(SECURE_STORAGE_CLASS_NAME.to_string());
+661
+662        log::info!("Android SecureStorage initialized successfully");
+663        Ok(())
+664    }
+665
+666    /// Test if Android Keystore is available
+667    pub fn test_keystore_available() -> bool {
+668        // Get JNI environment
+669        let ctx = ndk_context::android_context();
+670        let vm = unsafe { jni::JavaVM::from_raw(ctx.vm().cast()) };
+671
+672        let vm = match vm {
+673            Ok(vm) => vm,
+674            Err(e) => {
+675                log::warn!("Failed to get JavaVM for keystore test: {}", e);
+676                return false;
+677            }
+678        };
+679
+680        let mut env = match vm.attach_current_thread() {
+681            Ok(env) => env,
+682            Err(e) => {
+683                log::warn!("Failed to attach thread for keystore test: {}", e);
+684                return false;
+685            }
+686        };
+687
+688        // Try to get the SecureStorage instance
+689        match get_secure_storage_instance(&mut env) {
+690            Ok(_) => {
+691                log::info!("Android Keystore available via SecureStorage");
+692                true
+693            }
+694            Err(e) => {
+695                log::warn!("Android Keystore not available: {}", e);
+696                false
+697            }
+698        }
+699    }
+700
+701    /// Get the SecureStorage singleton instance
+702    fn get_secure_storage_instance<'a>(env: &mut JNIEnv<'a>) -> Result<JObject<'a>, String> {
+703        let class_name = SECURE_STORAGE_CLASS
+704            .get()
+705            .ok_or_else(|| "SecureStorage not initialized".to_string())?;
+706
+707        // Get the Android context
+708        let ctx = ndk_context::android_context();
+709        let context = unsafe { JObject::from_raw(ctx.context().cast()) };
+710
+711        // Get the ClassLoader from the Context
+712        let class_loader = env
+713            .call_method(&context, "getClassLoader", "()Ljava/lang/ClassLoader;", &[])
+714            .map_err(|e| format!("Failed to get ClassLoader: {}", e))?
+715            .l()
+716            .map_err(|e| format!("Failed to convert ClassLoader: {}", e))?;
+717
+718        // Load the SecureStorage class using the app's classloader
+719        let class_name_jstring = env
+720            .new_string(class_name.replace('/', "."))
+721            .map_err(|e| format!("Failed to create class name string: {}", e))?;
+722
+723        let storage_class_obj = env
+724            .call_method(
+725                &class_loader,
+726                "loadClass",
+727                "(Ljava/lang/String;)Ljava/lang/Class;",
+728                &[JValue::Object(&class_name_jstring.into())],
+729            )
+730            .map_err(|e| format!("Failed to load SecureStorage class: {}", e))?
+731            .l()
+732            .map_err(|e| format!("Failed to convert to Class: {}", e))?;
+733
+734        let storage_class = JClass::from(storage_class_obj);
+735
+736        let instance = env
+737            .call_static_method(
+738                &storage_class,
+739                "getInstance",
+740                "()Lcom/dtourolle/jellytau/security/SecureStorage;",
+741                &[],
+742            )
+743            .map_err(|e| format!("Failed to get SecureStorage instance: {}", e))?
+744            .l()
+745            .map_err(|e| format!("Failed to convert to object: {}", e))?;
+746
+747        Ok(instance)
+748    }
+749
+750    /// Save a token using Android Keystore
+751    pub fn save_token(user_id: &str, token: &str) -> Result<(), CredentialError> {
+752        let ctx = ndk_context::android_context();
+753        let vm = unsafe { jni::JavaVM::from_raw(ctx.vm().cast()) }
+754            .map_err(|e| CredentialError::Keyring(format!("Failed to get JavaVM: {}", e)))?;
+755
+756        let mut env = vm
+757            .attach_current_thread()
+758            .map_err(|e| CredentialError::Keyring(format!("Failed to attach thread: {}", e)))?;
+759
+760        let instance =
+761            get_secure_storage_instance(&mut env).map_err(|e| CredentialError::Keyring(e))?;
+762
+763        let key = format!("access_token:{}", user_id);
+764        let key_jstring = env
+765            .new_string(&key)
+766            .map_err(|e| CredentialError::Keyring(format!("Failed to create key string: {}", e)))?;
+767        let token_jstring = env.new_string(token).map_err(|e| {
+768            CredentialError::Keyring(format!("Failed to create token string: {}", e))
+769        })?;
+770
+771        let result = env
+772            .call_method(
+773                instance,
+774                "saveToken",
+775                "(Ljava/lang/String;Ljava/lang/String;)Z",
+776                &[
+777                    JValue::Object(&key_jstring.into()),
+778                    JValue::Object(&token_jstring.into()),
+779                ],
+780            )
+781            .map_err(|e| CredentialError::Keyring(format!("Failed to call saveToken: {}", e)))?
+782            .z()
+783            .map_err(|e| {
+784                CredentialError::Keyring(format!("Failed to get boolean result: {}", e))
+785            })?;
+786
+787        if result {
+788            Ok(())
+789        } else {
+790            Err(CredentialError::Keyring(
+791                "saveToken returned false".to_string(),
+792            ))
+793        }
+794    }
+795
+796    /// Get a token from Android Keystore
+797    pub fn get_token(user_id: &str) -> Result<String, CredentialError> {
+798        let ctx = ndk_context::android_context();
+799        let vm = unsafe { jni::JavaVM::from_raw(ctx.vm().cast()) }
+800            .map_err(|e| CredentialError::Keyring(format!("Failed to get JavaVM: {}", e)))?;
+801
+802        let mut env = vm
+803            .attach_current_thread()
+804            .map_err(|e| CredentialError::Keyring(format!("Failed to attach thread: {}", e)))?;
+805
+806        let instance =
+807            get_secure_storage_instance(&mut env).map_err(|e| CredentialError::Keyring(e))?;
+808
+809        let key = format!("access_token:{}", user_id);
+810        let key_jstring = env
+811            .new_string(&key)
+812            .map_err(|e| CredentialError::Keyring(format!("Failed to create key string: {}", e)))?;
+813
+814        let result = env
+815            .call_method(
+816                instance,
+817                "getToken",
+818                "(Ljava/lang/String;)Ljava/lang/String;",
+819                &[JValue::Object(&key_jstring.into())],
+820            )
+821            .map_err(|e| CredentialError::Keyring(format!("Failed to call getToken: {}", e)))?
+822            .l()
+823            .map_err(|e| CredentialError::Keyring(format!("Failed to get object result: {}", e)))?;
+824
+825        if result.is_null() {
+826            return Err(CredentialError::NotFound);
+827        }
+828
+829        let token_jstring = JString::from(result);
+830        let token: String = env
+831            .get_string(&token_jstring)
+832            .map_err(|e| CredentialError::Keyring(format!("Failed to get string: {}", e)))?
+833            .into();
+834
+835        Ok(token)
+836    }
+837
+838    /// Delete a token from Android Keystore
+839    pub fn delete_token(user_id: &str) -> Result<(), CredentialError> {
+840        let ctx = ndk_context::android_context();
+841        let vm = unsafe { jni::JavaVM::from_raw(ctx.vm().cast()) }
+842            .map_err(|e| CredentialError::Keyring(format!("Failed to get JavaVM: {}", e)))?;
+843
+844        let mut env = vm
+845            .attach_current_thread()
+846            .map_err(|e| CredentialError::Keyring(format!("Failed to attach thread: {}", e)))?;
+847
+848        let instance =
+849            get_secure_storage_instance(&mut env).map_err(|e| CredentialError::Keyring(e))?;
+850
+851        let key = format!("access_token:{}", user_id);
+852        let key_jstring = env
+853            .new_string(&key)
+854            .map_err(|e| CredentialError::Keyring(format!("Failed to create key string: {}", e)))?;
+855
+856        let result = env
+857            .call_method(
+858                instance,
+859                "deleteToken",
+860                "(Ljava/lang/String;)Z",
+861                &[JValue::Object(&key_jstring.into())],
+862            )
+863            .map_err(|e| CredentialError::Keyring(format!("Failed to call deleteToken: {}", e)))?
+864            .z()
+865            .map_err(|e| {
+866                CredentialError::Keyring(format!("Failed to get boolean result: {}", e))
+867            })?;
+868
+869        if result {
+870            Ok(())
+871        } else {
+872            Err(CredentialError::Keyring(
+873                "deleteToken returned false".to_string(),
+874            ))
+875        }
+876    }
+877}
+878
+879// Export Android keystore functions at the module level for easier access
+880#[cfg(target_os = "android")]
+881pub use android_keystore::{
+882    initialize_secure_storage, test_keystore_available as android_test_keystore_available,
+883};
+884
+885#[cfg(test)]
+886mod tests {
+887    use super::*;
+888
+889    /// Build a store pinned to the encrypted-file backend with an explicit key,
+890    /// so a test can simulate "same file, different machine key" (which is what
+891    /// a restored backup looks like).
+892    fn file_backed_store(credentials_path: PathBuf, encryption_key: [u8; 32]) -> CredentialStore {
+893        CredentialStore {
+894            using_keyring: false,
+895            credentials_path,
+896            encryption_key,
+897        }
+898    }
+899
+900    /// A credentials file we cannot decrypt must read as *no credentials stored*,
+901    /// not as a hard error. This is the restored-backup case: the ciphertext comes
+902    /// back but the key that encrypted it (Android Keystore / the machine-derived
+903    /// key) does not, so every read fails forever.
+904    ///
+905    /// TRACES: UR-012 | IR-014
+906    #[test]
+907    fn undecryptable_credentials_file_reads_as_not_found() {
+908        let dir = tempfile::tempdir().unwrap();
+909        let path = dir.path().join(CREDENTIALS_FILENAME);
+910
+911        let original = file_backed_store(path.clone(), [1u8; 32]);
+912        original.save_to_file("user-1", "token-abc").unwrap();
+913
+914        // Restored onto a device whose derived key differs: same bytes, no key.
+915        let restored = file_backed_store(path.clone(), [2u8; 32]);
+916        match restored.get_token("user-1") {
+917            Err(CredentialError::NotFound) => {}
+918            other => panic!("expected NotFound for undecryptable ciphertext, got {other:?}"),
+919        }
+920    }
+921
+922    /// Garbage in the file (truncation, partial restore) is the same story.
+923    ///
+924    /// TRACES: UR-012 | IR-014
+925    #[test]
+926    fn corrupt_credentials_file_reads_as_not_found() {
+927        let dir = tempfile::tempdir().unwrap();
+928        let path = dir.path().join(CREDENTIALS_FILENAME);
+929        fs::write(&path, "not base64 at all !!!").unwrap();
+930
+931        let store = file_backed_store(path, [3u8; 32]);
+932        match store.get_token("user-1") {
+933            Err(CredentialError::NotFound) => {}
+934            other => panic!("expected NotFound for corrupt file, got {other:?}"),
+935        }
+936    }
+937
+938    /// …and the logged-out state must be recoverable: signing in again has to be
+939    /// able to write over the unreadable file rather than failing on load.
+940    ///
+941    /// TRACES: UR-012 | IR-014
+942    #[test]
+943    fn login_after_undecryptable_file_rewrites_it() {
+944        let dir = tempfile::tempdir().unwrap();
+945        let path = dir.path().join(CREDENTIALS_FILENAME);
+946
+947        let original = file_backed_store(path.clone(), [1u8; 32]);
+948        original.save_to_file("user-1", "token-abc").unwrap();
+949
+950        let restored = file_backed_store(path.clone(), [2u8; 32]);
+951        restored.save_to_file("user-1", "token-fresh").unwrap();
+952
+953        assert_eq!(restored.get_from_file("user-1").unwrap(), "token-fresh");
+954    }
+955
+956    #[test]
+957    fn test_encryption_roundtrip() {
+958        let store = CredentialStore::new();
+959        let plaintext = "test-access-token-12345";
+960
+961        let encrypted = store.encrypt(plaintext).unwrap();
+962        let decrypted = store.decrypt(&encrypted).unwrap();
+963
+964        assert_eq!(plaintext, decrypted);
+965    }
+966
+967    #[test]
+968    fn test_derive_encryption_key_is_deterministic() {
+969        let key1 = CredentialStore::derive_encryption_key();
+970        let key2 = CredentialStore::derive_encryption_key();
+971        assert_eq!(key1, key2);
+972    }
+973}
+
\ No newline at end of file diff --git a/api/src/jellytau_lib/domain/from_jellyfin.rs.html b/api/src/jellytau_lib/domain/from_jellyfin.rs.html new file mode 100644 index 00000000..43a93af6 --- /dev/null +++ b/api/src/jellytau_lib/domain/from_jellyfin.rs.html @@ -0,0 +1,181 @@ +from_jellyfin.rs - sourceSkip to main content

jellytau_lib/domain/
from_jellyfin.rs

1//! Jellyfin → domain translation.
+2//!
+3//! The ONLY place Jellyfin's vocabulary touches the domain model. Adding a
+4//! second provider later means a sibling `from_<provider>.rs`; the domain types
+5//! and every consumer stay untouched.
+6//!
+7//! Spec: docs/specs/frontend-domain-model.md
+8
+9use super::media::{MediaKind, StreamKind};
+10
+11/// Classify a Jellyfin media-stream `Type` string into a neutral [`StreamKind`].
+12/// Total and panic-free.
+13pub fn stream_kind_from_jellyfin(stream_type: &str) -> StreamKind {
+14    match stream_type {
+15        "Audio" => StreamKind::Audio,
+16        "Video" => StreamKind::Video,
+17        "Subtitle" => StreamKind::Subtitle,
+18        _ => StreamKind::Other,
+19    }
+20}
+21
+22/// Jellyfin ticks per second (10 million). A tick is 100 ns.
+23/// The frontend must never see ticks — this is where they die.
+24const TICKS_PER_MILLISECOND: i64 = 10_000;
+25
+26/// Convert a Jellyfin `RunTimeTicks` value to milliseconds.
+27///
+28/// Domain durations are milliseconds; ticks are a Jellyfin unit and stop here.
+29pub fn ticks_to_ms(ticks: i64) -> i64 {
+30    ticks / TICKS_PER_MILLISECOND
+31}
+32
+33/// Classify a Jellyfin `Type` string into a neutral [`MediaKind`].
+34///
+35/// **Total and panic-free**: any unrecognised string maps to [`MediaKind::Other`]
+36/// rather than failing. `is_folder` disambiguates the one Jellyfin type
+37/// (`ChannelFolderItem`) whose kind depends on whether it is a container.
+38///
+39/// The recognised set is every `item_type` the frontend audit found in use
+40/// (docs/specs/frontend-domain-model.md), plus the common cast/crew person
+41/// subtypes Jellyfin returns in `People[].Type`.
+42pub fn kind_from_jellyfin(item_type: &str, is_folder: bool) -> MediaKind {
+43    match item_type {
+44        // Music
+45        "Audio" | "MusicVideo" => MediaKind::Track,
+46        "MusicAlbum" => MediaKind::Album,
+47        "MusicArtist" | "AlbumArtist" => MediaKind::Artist,
+48        "Playlist" => MediaKind::Playlist,
+49
+50        // Video
+51        "Movie" => MediaKind::Movie,
+52        "Series" => MediaKind::Series,
+53        "Season" => MediaKind::Season,
+54        "Episode" => MediaKind::Episode,
+55        // A bare video leaf with no richer classification.
+56        "Video" => MediaKind::Movie,
+57
+58        // Cast / crew — Jellyfin uses both a "Person" item type and role-typed
+59        // people (Actor/Director/Writer/Composer/…) in People[].Type.
+60        "Person" | "Actor" | "Director" | "Writer" | "Composer" | "GuestStar" | "Producer" => {
+61            MediaKind::Person
+62        }
+63
+64        // A live TV channel: playable, but a non-seekable live stream.
+65        "TvChannel" | "LiveTvChannel" => MediaKind::LiveChannel,
+66        // A bare channel is a container the user drills into.
+67        "Channel" => MediaKind::Channel,
+68
+69        // Containers
+70        "Folder" | "CollectionFolder" | "UserView" | "BoxSet" => MediaKind::Folder,
+71        // ChannelFolderItem is a container when it is a folder, else a playable
+72        // channel leaf (distinct kind so the UI can route it to playback).
+73        "ChannelFolderItem" => {
+74            if is_folder {
+75                MediaKind::Folder
+76            } else {
+77                MediaKind::ChannelItem
+78            }
+79        }
+80
+81        // Unknown → safe sink. Never panics.
+82        _ => {
+83            if is_folder {
+84                MediaKind::Folder
+85            } else {
+86                MediaKind::Other
+87            }
+88        }
+89    }
+90}
+91
+92#[cfg(test)]
+93mod tests {
+94    use super::*;
+95
+96    #[test]
+97    fn ticks_convert_to_milliseconds() {
+98        // 1 second = 10,000,000 ticks = 1000 ms
+99        assert_eq!(ticks_to_ms(10_000_000), 1000);
+100        // 90.5 s
+101        assert_eq!(ticks_to_ms(905_000_000), 90_500);
+102        assert_eq!(ticks_to_ms(0), 0);
+103        // Sub-millisecond truncates toward zero, not panics.
+104        assert_eq!(ticks_to_ms(9_999), 0);
+105    }
+106
+107    #[test]
+108    fn music_types_map() {
+109        assert_eq!(kind_from_jellyfin("Audio", false), MediaKind::Track);
+110        assert_eq!(kind_from_jellyfin("MusicAlbum", true), MediaKind::Album);
+111        assert_eq!(kind_from_jellyfin("MusicArtist", true), MediaKind::Artist);
+112        assert_eq!(kind_from_jellyfin("Playlist", true), MediaKind::Playlist);
+113    }
+114
+115    #[test]
+116    fn video_types_map() {
+117        assert_eq!(kind_from_jellyfin("Movie", false), MediaKind::Movie);
+118        assert_eq!(kind_from_jellyfin("Series", true), MediaKind::Series);
+119        assert_eq!(kind_from_jellyfin("Season", true), MediaKind::Season);
+120        assert_eq!(kind_from_jellyfin("Episode", false), MediaKind::Episode);
+121        assert_eq!(kind_from_jellyfin("Video", false), MediaKind::Movie);
+122    }
+123
+124    #[test]
+125    fn person_and_role_types_map_to_person() {
+126        for t in ["Person", "Actor", "Director", "Writer", "Composer"] {
+127            assert_eq!(kind_from_jellyfin(t, false), MediaKind::Person, "{t}");
+128        }
+129    }
+130
+131    #[test]
+132    fn channel_and_container_types_map() {
+133        assert_eq!(
+134            kind_from_jellyfin("TvChannel", false),
+135            MediaKind::LiveChannel
+136        );
+137        assert_eq!(kind_from_jellyfin("Channel", false), MediaKind::Channel);
+138        assert_eq!(
+139            kind_from_jellyfin("CollectionFolder", true),
+140            MediaKind::Folder
+141        );
+142        assert_eq!(kind_from_jellyfin("BoxSet", true), MediaKind::Folder);
+143    }
+144
+145    #[test]
+146    fn channel_folder_item_disambiguates_on_is_folder() {
+147        assert_eq!(
+148            kind_from_jellyfin("ChannelFolderItem", true),
+149            MediaKind::Folder
+150        );
+151        assert_eq!(
+152            kind_from_jellyfin("ChannelFolderItem", false),
+153            MediaKind::ChannelItem
+154        );
+155    }
+156
+157    #[test]
+158    fn stream_kinds_map() {
+159        assert_eq!(stream_kind_from_jellyfin("Audio"), StreamKind::Audio);
+160        assert_eq!(stream_kind_from_jellyfin("Video"), StreamKind::Video);
+161        assert_eq!(stream_kind_from_jellyfin("Subtitle"), StreamKind::Subtitle);
+162        assert_eq!(
+163            stream_kind_from_jellyfin("EmbeddedImage"),
+164            StreamKind::Other
+165        );
+166        assert_eq!(stream_kind_from_jellyfin(""), StreamKind::Other);
+167    }
+168
+169    #[test]
+170    fn unknown_type_never_panics_and_falls_back() {
+171        // The whole point: garbage in, safe kind out, no panic.
+172        assert_eq!(kind_from_jellyfin("Epis0de", false), MediaKind::Other);
+173        assert_eq!(kind_from_jellyfin("", false), MediaKind::Other);
+174        assert_eq!(
+175            kind_from_jellyfin("SomeFutureType", true),
+176            MediaKind::Folder
+177        );
+178        assert_eq!(kind_from_jellyfin("🎵unicode", false), MediaKind::Other);
+179    }
+180}
+
\ No newline at end of file diff --git a/api/src/jellytau_lib/domain/media.rs.html b/api/src/jellytau_lib/domain/media.rs.html new file mode 100644 index 00000000..b1d03f5e --- /dev/null +++ b/api/src/jellytau_lib/domain/media.rs.html @@ -0,0 +1,92 @@ +media.rs - sourceSkip to main content

jellytau_lib/domain/
media.rs

1//! Canonical, provider-neutral media domain model.
+2//!
+3//! This is the *single source of truth* for what a media item is across the
+4//! whole app. Rust (repositories, player, downloads) uses these types directly;
+5//! the frontend consumes the tauri-specta-generated projection in
+6//! `src/lib/api/bindings.ts`. There is no second hand-written copy in either
+7//! language, so the model cannot drift.
+8//!
+9//! No provider (Jellyfin) vocabulary belongs in this file. Translation from a
+10//! provider's wire shape lives beside it in `from_jellyfin.rs` and is the only
+11//! place provider terms touch the domain type.
+12//!
+13//! Spec: docs/specs/frontend-domain-model.md
+14
+15use serde::{Deserialize, Serialize};
+16
+17/// The kind of a media item — provider-neutral classification.
+18///
+19/// Replaces the stringly-typed `item_type` that carried Jellyfin's vocabulary
+20/// (`"Audio"`, `"MusicAlbum"`, …) across the boundary. A closed enum means a
+21/// typo or an unhandled kind is a compile error on the frontend, not a silent
+22/// runtime miss across ~127 comparison sites.
+23#[derive(specta::Type, Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
+24#[serde(rename_all = "camelCase")]
+25pub enum MediaKind {
+26    // Music
+27    Track,
+28    Album,
+29    Artist,
+30    Playlist,
+31    // Video
+32    Movie,
+33    Series,
+34    Season,
+35    Episode,
+36    // Cast/crew
+37    Person,
+38    // Containers / live TV
+39    /// A channel *container* the user drills into (Jellyfin `Channel`).
+40    Channel,
+41    Folder,
+42    /// A live TV channel — playable, but a live stream with no seekable
+43    /// timeline (no resume/seek). Jellyfin `TvChannel`/`LiveTvChannel`.
+44    LiveChannel,
+45    /// A playable leaf inside a channel (Jellyfin `ChannelFolderItem` that is
+46    /// not itself a folder) — e.g. a plugin-channel VOD item that has no
+47    /// dedicated item type but carries its own media streams. Playable and
+48    /// seekable, unlike `LiveChannel`. Distinct from `Channel` (the container)
+49    /// and from `Other` so the UI can route it to playback.
+50    ChannelItem,
+51    /// A kind we do not model explicitly. Reached only for provider item types
+52    /// that map to nothing meaningful; consumers treat it like an opaque
+53    /// container. The mapping must be *total* — it never panics — so this is the
+54    /// safe sink for unknown strings. Also the `Default`, so a defaulted
+55    /// `MediaItem` (see the dual-carry migration) is inert rather than a lie.
+56    #[default]
+57    Other,
+58}
+59
+60/// The kind of a media stream within an item (audio track, video track,
+61/// subtitle, …) — provider-neutral, replacing the stringly Jellyfin stream type.
+62#[derive(specta::Type, Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
+63#[serde(rename_all = "camelCase")]
+64pub enum StreamKind {
+65    Audio,
+66    Video,
+67    Subtitle,
+68    /// Any stream kind we do not model explicitly (e.g. embedded image, data).
+69    #[default]
+70    Other,
+71}
+72
+73impl MediaKind {
+74    /// True for kinds that are containers/collections rather than playable leaves.
+75    /// Presentation-neutral helper the backend can use for e.g. drill-vs-play.
+76    // Consumed by later migration phases (drill-vs-play routing); kept now so the
+77    // domain surface is complete alongside the type it describes.
+78    #[allow(dead_code)]
+79    pub fn is_container(self) -> bool {
+80        matches!(
+81            self,
+82            MediaKind::Album
+83                | MediaKind::Artist
+84                | MediaKind::Series
+85                | MediaKind::Season
+86                | MediaKind::Playlist
+87                | MediaKind::Channel
+88                | MediaKind::Folder
+89        )
+90    }
+91}
+
\ No newline at end of file diff --git a/api/src/jellytau_lib/domain/mod.rs.html b/api/src/jellytau_lib/domain/mod.rs.html new file mode 100644 index 00000000..209655b0 --- /dev/null +++ b/api/src/jellytau_lib/domain/mod.rs.html @@ -0,0 +1,13 @@ +mod.rs - sourceSkip to main content

jellytau_lib/domain/
mod.rs

1//! Canonical, provider-neutral domain model — the single source of truth for
+2//! the app's core data shapes, shared with the frontend via generated bindings.
+3//!
+4//! Spec: docs/specs/frontend-domain-model.md
+5
+6pub mod from_jellyfin;
+7pub mod media;
+8pub mod search_rank;
+9
+10pub use from_jellyfin::{kind_from_jellyfin, stream_kind_from_jellyfin, ticks_to_ms};
+11pub use media::{MediaKind, StreamKind};
+12pub use search_rank::rank_search_results;
+
\ No newline at end of file diff --git a/api/src/jellytau_lib/domain/search_rank.rs.html b/api/src/jellytau_lib/domain/search_rank.rs.html new file mode 100644 index 00000000..0ab8c236 --- /dev/null +++ b/api/src/jellytau_lib/domain/search_rank.rs.html @@ -0,0 +1,315 @@ +search_rank.rs - sourceSkip to main content

jellytau_lib/domain/
search_rank.rs

1//! Relevance ranking for search results.
+2//!
+3//! Both search paths (the SQLite FTS cache and the Jellyfin server) return items
+4//! in an order that ignores *where* in the name the query matched: a server
+5//! substring hit like "Sparks of Love" can outrank "Parks and Recreation" for
+6//! the query "parks". Neither backend is going to change, so the app imposes its
+7//! own ordering on the union.
+8//!
+9//! Ranking is domain logic, not presentation: it encodes what a "better match"
+10//! means and which media kinds outrank which. The frontend only renders the
+11//! order it is given.
+12//!
+13//! Two rules, in priority order:
+14//!
+15//! 1. **Match position** — a prefix match beats a word-start match, which beats
+16//!    a mid-word substring match. This is what makes "parks" find
+17//!    "Parks and Recreation" before "Sparks of Love".
+18//! 2. **Kind** — containers before their contents at equal match quality, so a
+19//!    series outranks its own episodes.
+20//!
+21//! Ties fall back to the input order, so a backend's own relevance signal (FTS
+22//! `rank`) still breaks ties it was never overruled on.
+23
+24use crate::domain::MediaKind;
+25use crate::repository::types::MediaItem;
+26
+27/// How well a query matched an item's name — better matches sort first.
+28///
+29/// Ordered by discriminant: `Prefix` is the strongest. Derived `Ord` gives the
+30/// comparison for free, so adding a tier in the right position is all it takes.
+31#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
+32pub enum MatchQuality {
+33    /// The name starts with the query — "parks" in "Parks and Recreation".
+34    Prefix,
+35    /// Some later *word* starts with the query — "recreation" in "Parks and
+36    /// Recreation". Still a deliberate hit: users type whole words.
+37    WordStart,
+38    /// The query appears mid-word — "parks" in "Sparks of Love". Weakest hit
+39    /// that still counts as a match.
+40    Substring,
+41    /// No match on the name at all. The backend returned it for some other
+42    /// reason (overview, artist, album), so it is kept but sorted last.
+43    None,
+44}
+45
+46/// Rank of a media kind when match quality ties — lower sorts first.
+47///
+48/// Containers outrank the items they contain: searching a show's name should
+49/// surface the show, not an arbitrary episode of it. Within a tier the order is
+50/// arbitrary but stable, and equal ranks fall through to input order.
+51fn kind_rank(kind: MediaKind) -> u8 {
+52    match kind {
+53        // Top-level containers a user is most likely to be looking for.
+54        MediaKind::Series | MediaKind::Movie | MediaKind::Album | MediaKind::Artist => 0,
+55        // Sub-containers and standalone collections.
+56        MediaKind::Season | MediaKind::Playlist | MediaKind::Channel | MediaKind::Folder => 1,
+57        // Leaves — an episode/track is a match *inside* something bigger.
+58        MediaKind::Episode | MediaKind::Track | MediaKind::LiveChannel | MediaKind::ChannelItem => {
+59            2
+60        }
+61        // Peripheral matches.
+62        MediaKind::Person | MediaKind::Other => 3,
+63    }
+64}
+65
+66/// Classify how `query` matches `name`, case-insensitively.
+67///
+68/// Both sides are trimmed and lowercased; an empty query matches everything
+69/// equally (`Prefix`), which leaves the input order untouched.
+70pub fn match_quality(name: &str, query: &str) -> MatchQuality {
+71    let query = query.trim().to_lowercase();
+72    if query.is_empty() {
+73        return MatchQuality::Prefix;
+74    }
+75    let name = name.trim().to_lowercase();
+76
+77    let Some(index) = name.find(&query) else {
+78        return MatchQuality::None;
+79    };
+80
+81    if index == 0 {
+82        return MatchQuality::Prefix;
+83    }
+84
+85    // A word start is any match preceded by a non-alphanumeric character, so
+86    // "the-office" and "The Office" behave the same. Indexing back one char is
+87    // safe on the byte index `find` returned only via `char_indices`, since a
+88    // multi-byte char would panic on a raw slice.
+89    let preceded_by_boundary = name[..index]
+90        .chars()
+91        .next_back()
+92        .is_some_and(|c| !c.is_alphanumeric());
+93
+94    if preceded_by_boundary {
+95        MatchQuality::WordStart
+96    } else {
+97        MatchQuality::Substring
+98    }
+99}
+100
+101/// Sort search results by relevance to `query`, in place.
+102///
+103/// Stable, so items the rules rank equally keep the order the backend supplied
+104/// (FTS `rank` for cache hits, Jellyfin's own ordering for server hits).
+105///
+106/// TRACES: UR-060 | DR-090
+107pub fn rank_search_results(items: &mut [MediaItem], query: &str) {
+108    // An empty query carries no relevance signal, so there is nothing to rank
+109    // by — reordering on kind alone would shuffle the backend's own ordering
+110    // for no reason.
+111    if query.trim().is_empty() {
+112        return;
+113    }
+114    items.sort_by_key(|item| (match_quality(&item.name, query), kind_rank(item.kind)));
+115}
+116
+117#[cfg(test)]
+118mod tests {
+119    use super::*;
+120
+121    fn item(name: &str, kind: MediaKind) -> MediaItem {
+122        MediaItem {
+123            id: format!("id-{}-{:?}", name, kind),
+124            name: name.to_string(),
+125            kind,
+126            ..MediaItem::default()
+127        }
+128    }
+129
+130    fn names(items: &[MediaItem]) -> Vec<&str> {
+131        items.iter().map(|i| i.name.as_str()).collect()
+132    }
+133
+134    /// UT-085: a prefix match outranks a mid-word substring match.
+135    #[test]
+136    fn prefix_match_beats_midword_substring() {
+137        assert_eq!(
+138            match_quality("Parks and Recreation", "parks"),
+139            MatchQuality::Prefix
+140        );
+141        assert_eq!(
+142            match_quality("Sparks of Love", "parks"),
+143            MatchQuality::Substring
+144        );
+145        assert!(MatchQuality::Prefix < MatchQuality::Substring);
+146    }
+147
+148    /// UT-085: the reported bug — "parks" must find the show, not "Sparks".
+149    #[test]
+150    fn ranks_prefix_match_before_substring_match() {
+151        let mut items = vec![
+152            item("Sparks of Love", MediaKind::Series),
+153            item("Parks and Recreation", MediaKind::Series),
+154        ];
+155
+156        rank_search_results(&mut items, "parks");
+157
+158        assert_eq!(
+159            names(&items),
+160            vec!["Parks and Recreation", "Sparks of Love"]
+161        );
+162    }
+163
+164    /// A match at a later word start beats a mid-word one but loses to a prefix.
+165    #[test]
+166    fn word_start_ranks_between_prefix_and_substring() {
+167        assert_eq!(
+168            match_quality("The Office", "office"),
+169            MatchQuality::WordStart
+170        );
+171        assert_eq!(match_quality("Bofficer", "office"), MatchQuality::Substring);
+172
+173        let mut items = vec![
+174            item("Bofficer", MediaKind::Series),
+175            item("The Office", MediaKind::Series),
+176            item("Office Space", MediaKind::Movie),
+177        ];
+178
+179        rank_search_results(&mut items, "office");
+180
+181        assert_eq!(
+182            names(&items),
+183            vec!["Office Space", "The Office", "Bofficer"]
+184        );
+185    }
+186
+187    /// UT-086: at equal match quality a series outranks an episode.
+188    #[test]
+189    fn series_ranks_before_episode_at_equal_match_quality() {
+190        let mut items = vec![
+191            item("Parks and Recreation S01E01", MediaKind::Episode),
+192            item("Parks and Recreation", MediaKind::Series),
+193        ];
+194
+195        rank_search_results(&mut items, "parks");
+196
+197        assert_eq!(
+198            names(&items),
+199            vec!["Parks and Recreation", "Parks and Recreation S01E01"]
+200        );
+201    }
+202
+203    /// Albums outrank their tracks for the same reason series outrank episodes.
+204    #[test]
+205    fn album_ranks_before_track_at_equal_match_quality() {
+206        let mut items = vec![
+207            item("Rumours", MediaKind::Track),
+208            item("Rumours", MediaKind::Album),
+209        ];
+210
+211        rank_search_results(&mut items, "rumours");
+212
+213        assert_eq!(items[0].kind, MediaKind::Album);
+214    }
+215
+216    /// Match quality dominates kind: a better-matching episode beats a
+217    /// worse-matching series, so kind never drags an irrelevant show to the top.
+218    #[test]
+219    fn match_quality_outranks_kind() {
+220        let mut items = vec![
+221            item("Sparks of Love", MediaKind::Series),
+222            item("Parks Cleanup", MediaKind::Episode),
+223        ];
+224
+225        rank_search_results(&mut items, "parks");
+226
+227        assert_eq!(names(&items), vec!["Parks Cleanup", "Sparks of Love"]);
+228    }
+229
+230    /// Items the backend returned for a non-name reason (overview, artist) are
+231    /// kept, but sort below everything that actually matched the name.
+232    #[test]
+233    fn non_matching_names_sort_last_without_being_dropped() {
+234        let mut items = vec![
+235            item("Unrelated Documentary", MediaKind::Movie),
+236            item("Parks and Recreation", MediaKind::Series),
+237        ];
+238
+239        rank_search_results(&mut items, "parks");
+240
+241        assert_eq!(
+242            names(&items),
+243            vec!["Parks and Recreation", "Unrelated Documentary"]
+244        );
+245    }
+246
+247    /// Ranking is stable: equally-ranked items keep the backend's order, so the
+248    /// FTS/server relevance signal still breaks ties.
+249    #[test]
+250    fn equal_rank_preserves_input_order() {
+251        let mut items = vec![
+252            item("Parks A", MediaKind::Series),
+253            item("Parks B", MediaKind::Series),
+254            item("Parks C", MediaKind::Series),
+255        ];
+256
+257        rank_search_results(&mut items, "parks");
+258
+259        assert_eq!(names(&items), vec!["Parks A", "Parks B", "Parks C"]);
+260    }
+261
+262    /// Case and surrounding whitespace never change the tier.
+263    #[test]
+264    fn matching_is_case_and_whitespace_insensitive() {
+265        assert_eq!(
+266            match_quality("PARKS AND RECREATION", "  parks "),
+267            MatchQuality::Prefix
+268        );
+269        assert_eq!(
+270            match_quality("Parks and Recreation", "PARKS"),
+271            MatchQuality::Prefix
+272        );
+273    }
+274
+275    /// An empty query leaves the order alone rather than reshuffling on kind.
+276    #[test]
+277    fn empty_query_preserves_input_order() {
+278        let mut items = vec![
+279            item("Zebra", MediaKind::Episode),
+280            item("Apple", MediaKind::Series),
+281        ];
+282
+283        rank_search_results(&mut items, "");
+284
+285        assert_eq!(names(&items), vec!["Zebra", "Apple"]);
+286    }
+287
+288    /// A multi-byte name must not panic when the match is mid-string — the
+289    /// boundary check walks chars rather than slicing raw bytes.
+290    #[test]
+291    fn handles_multibyte_names_without_panicking() {
+292        assert_eq!(
+293            match_quality("Pokémon Journeys", "journeys"),
+294            MatchQuality::WordStart
+295        );
+296        assert_eq!(
+297            match_quality("Café Parks", "parks"),
+298            MatchQuality::WordStart
+299        );
+300    }
+301
+302    /// Punctuation counts as a word boundary, so "office" hits "The-Office".
+303    #[test]
+304    fn punctuation_counts_as_a_word_boundary() {
+305        assert_eq!(
+306            match_quality("The-Office", "office"),
+307            MatchQuality::WordStart
+308        );
+309        assert_eq!(
+310            match_quality("Show: Parks", "parks"),
+311            MatchQuality::WordStart
+312        );
+313    }
+314}
+
\ No newline at end of file diff --git a/api/src/jellytau_lib/download/cache.rs.html b/api/src/jellytau_lib/download/cache.rs.html new file mode 100644 index 00000000..24ef6173 --- /dev/null +++ b/api/src/jellytau_lib/download/cache.rs.html @@ -0,0 +1,670 @@ +cache.rs - sourceSkip to main content

jellytau_lib/download/
cache.rs

1//! Smart caching engine for predictive downloads
+2
+3#[cfg(test)]
+4use crate::utils::lock::MutexSafe;
+5use log::{debug, info};
+6use std::collections::HashMap;
+7use std::sync::{Arc, Mutex};
+8
+9use serde::{Deserialize, Serialize};
+10
+11use crate::storage::db_service::{DatabaseService, Query, QueryParam};
+12
+13/// Smart caching configuration
+14#[derive(specta::Type, Debug, Clone, Serialize, Deserialize)]
+15#[serde(rename_all = "camelCase")]
+16pub struct CacheConfig {
+17    /// Enable queue pre-caching
+18    pub queue_precache_enabled: bool,
+19    /// Number of tracks to pre-cache from queue
+20    pub queue_precache_count: usize,
+21    /// Enable album affinity detection
+22    pub album_affinity_enabled: bool,
+23    /// Threshold for album affinity (tracks played before caching)
+24    pub album_affinity_threshold: usize,
+25    /// Storage limit in bytes (0 = unlimited)
+26    pub storage_limit: u64,
+27    /// Only cache on WiFi
+28    pub wifi_only: bool,
+29    /// How long a temporary (`download_source = 'auto'`) download lives before
+30    /// it is reclaimed, in hours. 0 disables expiry, leaving space pressure as
+31    /// the only reclaim trigger.
+32    ///
+33    /// TRACES: UR-071 | DR-127
+34    pub temporary_ttl_hours: u64,
+35}
+36
+37impl Default for CacheConfig {
+38    fn default() -> Self {
+39        Self {
+40            queue_precache_enabled: true,
+41            queue_precache_count: 3, // Preload next 3 tracks by default
+42            album_affinity_enabled: true,
+43            album_affinity_threshold: 3,
+44            storage_limit: 10 * 1024 * 1024 * 1024, // 10GB
+45            wifi_only: false,                       // Allow preloading on any connection by default
+46            // A week: long enough that re-watching over a weekend still hits
+47            // disk, short enough that a one-off play does not hold space
+48            // indefinitely.
+49            temporary_ttl_hours: 24 * 7,
+50        }
+51    }
+52}
+53
+54/// Smart caching engine
+55#[derive(Clone)]
+56pub struct SmartCache {
+57    config: Arc<Mutex<CacheConfig>>,
+58    /// Track recently played items per album
+59    album_play_history: Arc<Mutex<HashMap<String, Vec<String>>>>,
+60}
+61
+62impl SmartCache {
+63    pub fn new(config: CacheConfig) -> Self {
+64        Self {
+65            config: Arc::new(Mutex::new(config)),
+66            album_play_history: Arc::new(Mutex::new(HashMap::new())),
+67        }
+68    }
+69
+70    /// Update configuration
+71    pub fn update_config(&self, config: CacheConfig) {
+72        if let Ok(mut cfg) = self.config.lock() {
+73            *cfg = config;
+74        }
+75    }
+76
+77    /// Check if should pre-cache queue items.
+78    ///
+79    /// Note this deliberately does NOT consult `wifi_only`. It used to return
+80    /// `queue_precache_enabled && !wifi_only`, which disabled precaching
+81    /// outright whenever the user enabled WiFi-only — regardless of the network
+82    /// actually in use. The network check now lives in the download queue pump
+83    /// (`downloads_allowed_on_current_network`), which is the single gate for
+84    /// all download traffic, so this only answers "is precaching enabled?".
+85    ///
+86    /// TRACES: UR-053 | DR-074
+87    pub fn should_precache_queue(&self) -> bool {
+88        self.config
+89            .lock()
+90            .map(|cfg| cfg.queue_precache_enabled)
+91            .unwrap_or(false)
+92    }
+93
+94    /// Get number of queue items to pre-cache
+95    pub fn queue_precache_count(&self) -> usize {
+96        self.config
+97            .lock()
+98            .map(|cfg| cfg.queue_precache_count)
+99            .unwrap_or(5)
+100    }
+101
+102    /// Track that an item was played
+103    pub fn track_play(&self, item_id: &str, album_id: Option<&str>) {
+104        if let Some(album) = album_id {
+105            if let Ok(mut history) = self.album_play_history.lock() {
+106                let plays = history.entry(album.to_string()).or_insert_with(Vec::new);
+107                if !plays.contains(&item_id.to_string()) {
+108                    plays.push(item_id.to_string());
+109                }
+110            }
+111        }
+112    }
+113
+114    /// Check if album affinity threshold reached for caching
+115    pub fn should_cache_album(&self, album_id: &str) -> Option<bool> {
+116        let config = self.config.lock().ok()?;
+117        if !config.album_affinity_enabled {
+118            return Some(false);
+119        }
+120
+121        let history = self.album_play_history.lock().ok()?;
+122        let play_count = history.get(album_id).map(|v| v.len()).unwrap_or(0);
+123
+124        Some(play_count >= config.album_affinity_threshold)
+125    }
+126
+127    /// Get configuration
+128    pub fn get_config(&self) -> Option<CacheConfig> {
+129        self.config.lock().ok().map(|cfg| cfg.clone())
+130    }
+131
+132    /// Get all tracked albums with their play counts
+133    /// Returns Vec<(album_id, unique_tracks_played)>
+134    pub fn get_album_play_history(&self) -> Vec<(String, usize)> {
+135        self.album_play_history
+136            .lock()
+137            .ok()
+138            .map(|history| {
+139                history
+140                    .iter()
+141                    .map(|(album_id, tracks)| (album_id.clone(), tracks.len()))
+142                    .collect()
+143            })
+144            .unwrap_or_default()
+145    }
+146
+147    // ============= Async versions for DatabaseService =============
+148
+149    /// Get total download size for a user (async version)
+150    pub async fn get_total_download_size_async<S: DatabaseService>(
+151        &self,
+152        db_service: &Arc<S>,
+153        user_id: &str,
+154    ) -> Result<u64, String> {
+155        let query = Query::with_params(
+156            "SELECT COALESCE(SUM(file_size), 0) FROM downloads
+157             WHERE user_id = ? AND status = 'completed'",
+158            vec![QueryParam::String(user_id.to_string())],
+159        );
+160
+161        let size: i64 = db_service
+162            .query_one(query, |row| row.get(0))
+163            .await
+164            .map_err(|e| e.to_string())?;
+165
+166        Ok(size as u64)
+167    }
+168
+169    /// Check if storage limit allows download (async version)
+170    pub async fn can_download_async<S: DatabaseService>(
+171        &self,
+172        db_service: &Arc<S>,
+173        user_id: &str,
+174        new_size: u64,
+175    ) -> bool {
+176        // Clone config to avoid holding lock across await
+177        let storage_limit = {
+178            match self.config.lock() {
+179                Ok(cfg) => cfg.storage_limit,
+180                Err(_) => return true,
+181            }
+182        };
+183
+184        if storage_limit == 0 {
+185            return true; // Unlimited
+186        }
+187
+188        let current_size = self
+189            .get_total_download_size_async(db_service, user_id)
+190            .await
+191            .unwrap_or(0);
+192
+193        current_size + new_size <= storage_limit
+194    }
+195
+196    /// Reclaim temporary downloads whose life limit has passed.
+197    ///
+198    /// The time-based half of the temporary tier (DR-127); [`evict_lru_async`]
+199    /// is the space-pressure half. A row is reclaimed by whichever fires first.
+200    ///
+201    /// Scoped to `download_source = 'auto'` for the same reason eviction is: a
+202    /// `'user'` row is someone's own download and has no expiry. `COALESCE`
+203    /// guards rows predating migration 012, whose source is NULL and whose
+204    /// provenance must therefore be treated as the user's.
+205    ///
+206    /// Expiry is normally *derived* — `completed_at` plus the configured TTL —
+207    /// rather than stamped at completion. That means a TTL change applies to
+208    /// entries already on disk instead of only to future ones, and entries
+209    /// predating the column expire without a backfill. `expires_at` is honoured
+210    /// as a per-row override when something sets it.
+211    ///
+212    /// `now` is passed in rather than read from the clock so the policy is
+213    /// testable without sleeping. Both sides go through SQLite's `datetime()`
+214    /// because `completed_at` is written as `CURRENT_TIMESTAMP`
+215    /// (`YYYY-MM-DD HH:MM:SS`) while callers pass RFC-3339 (`…T…+00:00`) — a raw
+216    /// string comparison between the two formats is wrong, since `' ' < 'T'`.
+217    ///
+218    /// Returns the number of entries reclaimed.
+219    ///
+220    /// TRACES: UR-071 | DR-127 | UT-120
+221    pub async fn reclaim_expired_async<S: DatabaseService>(
+222        &self,
+223        db_service: &Arc<S>,
+224        user_id: &str,
+225        now: &str,
+226    ) -> Result<usize, String> {
+227        let ttl_hours = {
+228            let config = self.config.lock().map_err(|e| e.to_string())?;
+229            config.temporary_ttl_hours
+230        };
+231        // 0 disables time-based reclaim; space pressure remains the only trigger.
+232        if ttl_hours == 0 {
+233            return Ok(0);
+234        }
+235
+236        let expired: Vec<(i64, String)> = db_service
+237            .query_many(
+238                Query::with_params(
+239                    "SELECT id, file_path FROM downloads
+240                      WHERE user_id = ?
+241                        AND COALESCE(download_source, 'user') = 'auto'
+242                        AND status = 'completed'
+243                        AND datetime(
+244                              COALESCE(expires_at, datetime(completed_at, '+' || ? || ' hours'))
+245                            ) < datetime(?)",
+246                    vec![
+247                        QueryParam::String(user_id.to_string()),
+248                        QueryParam::String(ttl_hours.to_string()),
+249                        QueryParam::String(now.to_string()),
+250                    ],
+251                ),
+252                |row| Ok((row.get(0)?, row.get(1)?)),
+253            )
+254            .await
+255            .map_err(|e| e.to_string())?;
+256
+257        let mut reclaimed = 0usize;
+258        for (id, file_path) in expired {
+259            // Best-effort on the file: a missing one still needs its row gone,
+260            // or the sweep retries it forever.
+261            let _ = std::fs::remove_file(&file_path);
+262            db_service
+263                .execute(Query::with_params(
+264                    "DELETE FROM downloads WHERE id = ?",
+265                    vec![QueryParam::Int64(id)],
+266                ))
+267                .await
+268                .map_err(|e| e.to_string())?;
+269            reclaimed += 1;
+270        }
+271
+272        if reclaimed > 0 {
+273            info!("[SmartCache] Reclaimed {} expired entries", reclaimed);
+274        }
+275        Ok(reclaimed)
+276    }
+277
+278    /// Evict least recently used items to make space (async version).
+279    ///
+280    /// The space-pressure half of the temporary tier; [`reclaim_expired_async`]
+281    /// is the time-based half.
+282    pub async fn evict_lru_async<S: DatabaseService>(
+283        &self,
+284        db_service: &Arc<S>,
+285        user_id: &str,
+286        space_needed: u64,
+287    ) -> Result<u64, String> {
+288        let current_size = self
+289            .get_total_download_size_async(db_service, user_id)
+290            .await?;
+291
+292        // Get limit without holding lock across await
+293        let limit = {
+294            let config = self.config.lock().map_err(|e| e.to_string())?;
+295            config.storage_limit
+296        };
+297
+298        if limit == 0 || current_size + space_needed <= limit {
+299            return Ok(0); // No eviction needed
+300        }
+301
+302        let to_free = (current_size + space_needed) - limit;
+303        let mut freed: u64 = 0;
+304
+305        // Only the *temporary* tier is evictable. `download_source = 'auto'` is
+306        // precache — the cache put it there, the cache may reclaim it. A 'user'
+307        // row is a download someone explicitly asked for; deleting it to make
+308        // room for a predictive fetch is data loss, and because the old query
+309        // ordered purely by `completed_at ASC` it took the oldest — typically
+310        // exactly the film saved for a flight.
+311        //
+312        // COALESCE, not `= 'auto'` alone: migration 012 added the column with a
+313        // 'user' default, but rows predating it can be NULL, and an unknown
+314        // provenance must be treated as the user's, never as disposable.
+315        //
+316        // Freeing less than requested is the correct outcome when only user
+317        // downloads remain — the caller surfaces "unable to free enough space"
+318        // rather than silently deleting them.
+319        //
+320        // TRACES: UR-071 | DR-126 | UT-108
+321        let query = Query::with_params(
+322            "SELECT id, file_size, file_path FROM downloads
+323             WHERE user_id = ? AND status = 'completed'
+324               AND COALESCE(download_source, 'user') = 'auto'
+325             ORDER BY completed_at ASC",
+326            vec![QueryParam::String(user_id.to_string())],
+327        );
+328
+329        let downloads: Vec<(i64, i64, String)> = db_service
+330            .query_many(query, |row| Ok((row.get(0)?, row.get(1)?, row.get(2)?)))
+331            .await
+332            .map_err(|e| e.to_string())?;
+333
+334        for (id, size, file_path) in downloads {
+335            if freed >= to_free {
+336                break;
+337            }
+338
+339            // Delete file
+340            let _ = std::fs::remove_file(&file_path);
+341            debug!("[SmartCache] Evicted: {} ({} bytes)", file_path, size);
+342
+343            // Delete from database
+344            let delete_query = Query::with_params(
+345                "DELETE FROM downloads WHERE id = ?",
+346                vec![QueryParam::Int64(id)],
+347            );
+348            db_service
+349                .execute(delete_query)
+350                .await
+351                .map_err(|e| e.to_string())?;
+352
+353            freed += size as u64;
+354        }
+355
+356        info!("[SmartCache] Freed {} bytes ({} needed)", freed, to_free);
+357        Ok(freed)
+358    }
+359}
+360
+361#[cfg(test)]
+362mod tests {
+363    use super::*;
+364
+365    #[test]
+366    fn test_default_config() {
+367        let config = CacheConfig::default();
+368        assert_eq!(config.queue_precache_count, 3);
+369        assert_eq!(config.album_affinity_threshold, 3);
+370        assert!(!config.wifi_only); // wifi_only is false by default for easier preloading
+371    }
+372
+373    #[test]
+374    fn test_album_affinity_tracking() {
+375        let cache = SmartCache::new(CacheConfig::default());
+376
+377        // Track plays from same album
+378        cache.track_play("track1", Some("album1"));
+379        cache.track_play("track2", Some("album1"));
+380
+381        // Below threshold
+382        assert!(!cache.should_cache_album("album1").unwrap_or(false));
+383
+384        cache.track_play("track3", Some("album1"));
+385
+386        // At threshold - should cache
+387        assert!(cache.should_cache_album("album1").unwrap_or(false));
+388    }
+389
+390    #[test]
+391    fn test_queue_precache_config() {
+392        let config = CacheConfig {
+393            queue_precache_enabled: false,
+394            ..CacheConfig::default()
+395        };
+396
+397        let cache = SmartCache::new(config);
+398        assert!(!cache.should_precache_queue());
+399
+400        let new_config = CacheConfig {
+401            wifi_only: false,
+402            ..CacheConfig::default()
+403        };
+404        cache.update_config(new_config);
+405
+406        assert!(cache.should_precache_queue());
+407    }
+408
+409    #[test]
+410    fn test_wifi_only_does_not_disable_precaching() {
+411        // wifi_only must not short-circuit precaching: the network gate lives in
+412        // the download pump, which checks the *actual* transport. Enabling
+413        // WiFi-only while on WiFi should still precache.
+414        let config = CacheConfig {
+415            queue_precache_enabled: true,
+416            wifi_only: true,
+417            ..CacheConfig::default()
+418        };
+419
+420        let cache = SmartCache::new(config);
+421        assert!(cache.should_precache_queue());
+422    }
+423
+424    /// Expiry reclaims only temporary entries that are actually past their life
+425    /// limit — never a user's download (which has no expiry), and never a
+426    /// temporary entry still within its life.
+427    ///
+428    /// TRACES: UR-071 | DR-127 | UT-120
+429    #[tokio::test]
+430    async fn test_reclaim_expired_only_takes_expired_temporary_entries() {
+431        use crate::storage::db_service::RusqliteService;
+432        use rusqlite::Connection;
+433        use std::sync::{Arc, Mutex};
+434
+435        let conn = Connection::open_in_memory().unwrap();
+436        conn.execute(
+437            "CREATE TABLE downloads (
+438                id INTEGER PRIMARY KEY,
+439                user_id TEXT,
+440                status TEXT,
+441                file_size INTEGER,
+442                file_path TEXT,
+443                completed_at TEXT,
+444                download_source TEXT DEFAULT 'user',
+445                expires_at TEXT
+446            )",
+447            [],
+448        )
+449        .unwrap();
+450
+451        // `completed_at` is in SQLite's CURRENT_TIMESTAMP format (space, not
+452        // 'T'), deliberately: the query has to compare it against an RFC-3339
+453        // "now" and must not do so as raw strings.
+454        for (path, source, completed, expires) in [
+455            // Completed long ago, no override => derived expiry has passed.
+456            ("/tmp/jt-expired.mp4", "auto", "2026-01-01 00:00:00", None),
+457            // Completed yesterday => still inside the 7-day default TTL.
+458            ("/tmp/jt-fresh.mp4", "auto", "2026-05-31 00:00:00", None),
+459            // Old, but an explicit override keeps it alive.
+460            (
+461                "/tmp/jt-override.mp4",
+462                "auto",
+463                "2026-01-01 00:00:00",
+464                Some("2026-12-01T00:00:00+00:00"),
+465            ),
+466            // A user download must never carry an expiry, but assert the sweep
+467            // ignores it even if one were somehow set.
+468            (
+469                "/tmp/jt-user.mp4",
+470                "user",
+471                "2026-01-01 00:00:00",
+472                Some("2026-01-01T00:00:00+00:00"),
+473            ),
+474            (
+475                "/tmp/jt-user-noexp.mp4",
+476                "user",
+477                "2026-01-01 00:00:00",
+478                None,
+479            ),
+480        ] {
+481            conn.execute(
+482                "INSERT INTO downloads (user_id, status, file_size, file_path, download_source, completed_at, expires_at)
+483                 VALUES ('user1', 'completed', 10, ?1, ?2, ?3, ?4)",
+484                rusqlite::params![path, source, completed, expires],
+485            )
+486            .unwrap();
+487        }
+488
+489        let conn_arc = Arc::new(Mutex::new(conn));
+490        let db_service = Arc::new(RusqliteService::new(conn_arc.clone()));
+491        let cache = SmartCache::new(CacheConfig::default());
+492
+493        let reclaimed = cache
+494            .reclaim_expired_async(&db_service, "user1", "2026-06-01T00:00:00+00:00")
+495            .await
+496            .unwrap();
+497        assert_eq!(
+498            reclaimed, 1,
+499            "only the expired temporary entry is reclaimed"
+500        );
+501
+502        let surviving: Vec<String> = {
+503            let guard = conn_arc.lock_safe();
+504            let mut stmt = guard
+505                .prepare("SELECT file_path FROM downloads ORDER BY id")
+506                .unwrap();
+507            let rows = stmt
+508                .query_map([], |row| row.get::<_, String>(0))
+509                .unwrap()
+510                .map(|r| r.unwrap())
+511                .collect();
+512            rows
+513        };
+514        assert_eq!(
+515            surviving,
+516            vec![
+517                "/tmp/jt-fresh.mp4".to_string(),
+518                "/tmp/jt-override.mp4".to_string(),
+519                "/tmp/jt-user.mp4".to_string(),
+520                "/tmp/jt-user-noexp.mp4".to_string(),
+521            ],
+522            "entries within their life, those with a later override, and every user download must survive"
+523        );
+524
+525        // TTL of 0 disables time-based reclaim entirely.
+526        let cache_no_ttl = SmartCache::new(CacheConfig {
+527            temporary_ttl_hours: 0,
+528            ..Default::default()
+529        });
+530        assert_eq!(
+531            cache_no_ttl
+532                .reclaim_expired_async(&db_service, "user1", "2027-01-01T00:00:00+00:00")
+533                .await
+534                .unwrap(),
+535            0,
+536            "a zero TTL leaves space pressure as the only reclaim trigger"
+537        );
+538    }
+539
+540    /// Eviction must only reclaim *temporary* (`download_source = 'auto'`)
+541    /// downloads — the precache tier. A download the user explicitly asked for
+542    /// is their file: it may be deleted by them, never by the cache making room
+543    /// for a predictive fetch.
+544    ///
+545    /// Before the fix, `evict_lru_async` selected every completed row ordered by
+546    /// `completed_at ASC` with no source filter, so hitting the storage limit
+547    /// deleted the *oldest* download — typically the film someone downloaded for
+548    /// a flight — in favour of a newer auto-precached track.
+549    ///
+550    /// TRACES: UR-071 | DR-126 | UT-108
+551    #[tokio::test]
+552    async fn test_evict_lru_never_deletes_user_downloads() {
+553        use crate::storage::db_service::RusqliteService;
+554        use rusqlite::Connection;
+555        use std::sync::{Arc, Mutex};
+556
+557        let conn = Connection::open_in_memory().unwrap();
+558        conn.execute(
+559            "CREATE TABLE downloads (
+560                id INTEGER PRIMARY KEY,
+561                user_id TEXT,
+562                status TEXT,
+563                file_size INTEGER,
+564                file_path TEXT,
+565                completed_at TEXT,
+566                download_source TEXT DEFAULT 'user'
+567            )",
+568            [],
+569        )
+570        .unwrap();
+571
+572        // The user's own download is the OLDEST, so a purely time-ordered
+573        // eviction would take it first.
+574        conn.execute(
+575            "INSERT INTO downloads (user_id, status, file_size, file_path, completed_at, download_source)
+576             VALUES ('user1', 'completed', 600, '/tmp/jellytau-test-user.mp4', '2026-01-01', 'user')",
+577            [],
+578        )
+579        .unwrap();
+580        // A newer, auto-precached item.
+581        conn.execute(
+582            "INSERT INTO downloads (user_id, status, file_size, file_path, completed_at, download_source)
+583             VALUES ('user1', 'completed', 600, '/tmp/jellytau-test-auto.mp4', '2026-06-01', 'auto')",
+584            [],
+585        )
+586        .unwrap();
+587
+588        let conn_arc = Arc::new(Mutex::new(conn));
+589        let db_service = Arc::new(RusqliteService::new(conn_arc.clone()));
+590
+591        let cache = SmartCache::new(CacheConfig {
+592            storage_limit: 1000,
+593            ..Default::default()
+594        });
+595
+596        // 1200 bytes held against a 1000 limit: eviction must free something.
+597        let freed = cache
+598            .evict_lru_async(&db_service, "user1", 0)
+599            .await
+600            .unwrap();
+601        assert!(freed > 0, "eviction should have reclaimed the auto entry");
+602
+603        let surviving: Vec<String> = {
+604            let guard = conn_arc.lock_safe();
+605            let mut stmt = guard
+606                .prepare("SELECT download_source FROM downloads ORDER BY id")
+607                .unwrap();
+608            let rows = stmt
+609                .query_map([], |row| row.get::<_, String>(0))
+610                .unwrap()
+611                .map(|r| r.unwrap())
+612                .collect();
+613            rows
+614        };
+615
+616        assert_eq!(
+617            surviving,
+618            vec!["user".to_string()],
+619            "the user's own download must survive; only the 'auto' entry is evictable"
+620        );
+621    }
+622
+623    #[tokio::test]
+624    async fn test_storage_limit_check() {
+625        use crate::storage::db_service::RusqliteService;
+626        use rusqlite::Connection;
+627        use std::sync::{Arc, Mutex};
+628
+629        let conn = Connection::open_in_memory().unwrap();
+630        conn.execute(
+631            "CREATE TABLE downloads (
+632                id INTEGER PRIMARY KEY,
+633                user_id TEXT,
+634                status TEXT,
+635                file_size INTEGER
+636            )",
+637            [],
+638        )
+639        .unwrap();
+640
+641        let conn_arc = Arc::new(Mutex::new(conn));
+642        let db_service = Arc::new(RusqliteService::new(conn_arc.clone()));
+643
+644        let config = CacheConfig {
+645            storage_limit: 1000,
+646            ..Default::default()
+647        };
+648        let cache = SmartCache::new(config);
+649
+650        // Empty - can download
+651        assert!(cache.can_download_async(&db_service, "user1", 500).await);
+652
+653        // Add some downloads
+654        {
+655            let conn_guard = conn_arc.lock_safe();
+656            conn_guard.execute(
+657                "INSERT INTO downloads (user_id, status, file_size) VALUES ('user1', 'completed', 600)",
+658                [],
+659            )
+660            .unwrap();
+661        }
+662
+663        // Total would be 1100 > 1000
+664        assert!(!cache.can_download_async(&db_service, "user1", 500).await);
+665
+666        // Smaller size fits
+667        assert!(cache.can_download_async(&db_service, "user1", 300).await);
+668    }
+669}
+
\ No newline at end of file diff --git a/api/src/jellytau_lib/download/events.rs.html b/api/src/jellytau_lib/download/events.rs.html new file mode 100644 index 00000000..631d70a3 --- /dev/null +++ b/api/src/jellytau_lib/download/events.rs.html @@ -0,0 +1,141 @@ +events.rs - sourceSkip to main content

jellytau_lib/download/
events.rs

1//! Download events for progress tracking and status updates
+2
+3use serde::{Deserialize, Serialize};
+4
+5/// Events emitted during download operations
+6#[derive(Debug, Clone, Serialize, Deserialize)]
+7#[serde(tag = "type", rename_all = "camelCase")]
+8pub enum DownloadEvent {
+9    /// Download has been queued
+10    #[serde(rename_all = "camelCase")]
+11    Queued { download_id: i64, item_id: String },
+12    /// Download has started
+13    #[serde(rename_all = "camelCase")]
+14    Started { download_id: i64, item_id: String },
+15    /// Download progress update
+16    #[serde(rename_all = "camelCase")]
+17    Progress {
+18        download_id: i64,
+19        item_id: String,
+20        bytes_downloaded: i64,
+21        total_bytes: Option<i64>,
+22        progress: f64, // 0.0 to 1.0
+23    },
+24    /// Download completed successfully
+25    #[serde(rename_all = "camelCase")]
+26    Completed {
+27        download_id: i64,
+28        item_id: String,
+29        file_path: String,
+30    },
+31    /// Download failed with error
+32    #[serde(rename_all = "camelCase")]
+33    Failed {
+34        download_id: i64,
+35        item_id: String,
+36        error: String,
+37    },
+38    /// Download paused
+39    #[serde(rename_all = "camelCase")]
+40    Paused { download_id: i64, item_id: String },
+41    /// Download cancelled
+42    #[serde(rename_all = "camelCase")]
+43    Cancelled { download_id: i64, item_id: String },
+44    /// The queue is holding: WiFi-only is enabled and the current network is
+45    /// metered/cellular. Pending rows stay pending and resume on network change.
+46    ///
+47    /// TRACES: UR-053 | DR-074
+48    WaitingForNetwork,
+49}
+50
+51#[cfg(test)]
+52mod tests {
+53    use super::*;
+54
+55    #[test]
+56    fn test_download_event_serialization_roundtrip() {
+57        let event = DownloadEvent::Progress {
+58            download_id: 1,
+59            item_id: "test123".to_string(),
+60            bytes_downloaded: 1024,
+61            total_bytes: Some(2048),
+62            progress: 0.5,
+63        };
+64
+65        let json = serde_json::to_string(&event).unwrap();
+66        let deserialized: DownloadEvent = serde_json::from_str(&json).unwrap();
+67
+68        match deserialized {
+69            DownloadEvent::Progress {
+70                download_id,
+71                item_id,
+72                progress,
+73                ..
+74            } => {
+75                assert_eq!(download_id, 1);
+76                assert_eq!(item_id, "test123");
+77                assert_eq!(progress, 0.5);
+78            }
+79            _ => panic!("Wrong variant"),
+80        }
+81    }
+82
+83    #[test]
+84    fn test_download_event_completed() {
+85        let event = DownloadEvent::Completed {
+86            download_id: 42,
+87            item_id: "song456".to_string(),
+88            file_path: "/path/to/file.mp3".to_string(),
+89        };
+90
+91        let json = serde_json::to_string(&event).unwrap();
+92        assert!(json.contains("\"type\":\"completed\""));
+93        // Verify camelCase field names
+94        assert!(
+95            json.contains("\"downloadId\":42"),
+96            "Expected downloadId (camelCase), got: {}",
+97            json
+98        );
+99        assert!(
+100            json.contains("\"itemId\":\"song456\""),
+101            "Expected itemId (camelCase), got: {}",
+102            json
+103        );
+104        assert!(
+105            json.contains("\"filePath\":"),
+106            "Expected filePath (camelCase), got: {}",
+107            json
+108        );
+109
+110        // Verify roundtrip
+111        let deserialized: DownloadEvent = serde_json::from_str(&json).unwrap();
+112        match deserialized {
+113            DownloadEvent::Completed { file_path, .. } => {
+114                assert_eq!(file_path, "/path/to/file.mp3");
+115            }
+116            _ => panic!("Wrong variant"),
+117        }
+118    }
+119
+120    #[test]
+121    fn test_download_event_failed() {
+122        let event = DownloadEvent::Failed {
+123            download_id: 10,
+124            item_id: "failed_item".to_string(),
+125            error: "Network timeout".to_string(),
+126        };
+127
+128        let json = serde_json::to_string(&event).unwrap();
+129        assert!(json.contains("\"type\":\"failed\""));
+130
+131        // Verify roundtrip
+132        let deserialized: DownloadEvent = serde_json::from_str(&json).unwrap();
+133        match deserialized {
+134            DownloadEvent::Failed { error, .. } => {
+135                assert_eq!(error, "Network timeout");
+136            }
+137            _ => panic!("Wrong variant"),
+138        }
+139    }
+140}
+
\ No newline at end of file diff --git a/api/src/jellytau_lib/download/mod.rs.html b/api/src/jellytau_lib/download/mod.rs.html new file mode 100644 index 00000000..23b4964e --- /dev/null +++ b/api/src/jellytau_lib/download/mod.rs.html @@ -0,0 +1,213 @@ +mod.rs - sourceSkip to main content

jellytau_lib/download/
mod.rs

1//! Download manager for offline media support
+2//!
+3//! This module handles downloading media from Jellyfin servers with:
+4//! - Priority-based queue management
+5//! - Progress tracking and event emission
+6//! - Retry logic with exponential backoff
+7//! - Resume support via HTTP Range requests
+8
+9pub mod cache;
+10pub mod events;
+11pub mod network;
+12pub mod stop;
+13pub mod worker;
+14
+15use crate::utils::lock::MutexSafe;
+16use std::collections::HashSet;
+17use std::path::PathBuf;
+18use std::sync::{Arc, Mutex};
+19
+20pub use worker::DownloadWorker;
+21
+22/// Download manager coordinating downloads across workers
+23pub struct DownloadManager {
+24    /// Maximum concurrent downloads
+25    max_concurrent: usize,
+26    /// Currently active download IDs
+27    active_downloads: Arc<Mutex<HashSet<i64>>>,
+28}
+29
+30impl DownloadManager {
+31    /// Create a new download manager
+32    pub fn new(_media_dir: PathBuf) -> Self {
+33        Self {
+34            max_concurrent: 3,
+35            active_downloads: Arc::new(Mutex::new(HashSet::new())),
+36        }
+37    }
+38
+39    /// Get the maximum concurrent downloads
+40    pub fn max_concurrent(&self) -> usize {
+41        self.max_concurrent
+42    }
+43
+44    /// Set the maximum concurrent downloads
+45    pub fn set_max_concurrent(&mut self, max: usize) {
+46        self.max_concurrent = max.max(1); // At least 1
+47    }
+48
+49    /// Check if a new download can be started based on concurrent limit
+50    pub fn can_start_download(&self) -> bool {
+51        let active = self.active_downloads.lock_safe();
+52        active.len() < self.max_concurrent
+53    }
+54
+55    /// Get the number of currently active downloads
+56    pub fn active_count(&self) -> usize {
+57        self.active_downloads.lock_safe().len()
+58    }
+59
+60    /// Register a download as active
+61    pub fn register_download(&self, download_id: i64) -> bool {
+62        let mut active = self.active_downloads.lock_safe();
+63        if active.len() >= self.max_concurrent {
+64            return false;
+65        }
+66        active.insert(download_id)
+67    }
+68
+69    /// Unregister a download when it completes or fails
+70    pub fn unregister_download(&self, download_id: i64) {
+71        let mut active = self.active_downloads.lock_safe();
+72        active.remove(&download_id);
+73    }
+74
+75    /// Get a clone of the active downloads set (for internal use)
+76    pub fn get_active_downloads(&self) -> Arc<Mutex<HashSet<i64>>> {
+77        self.active_downloads.clone()
+78    }
+79}
+80
+81/// Information about a download
+82#[derive(specta::Type, Debug, Clone, serde::Serialize, serde::Deserialize)]
+83#[serde(rename_all = "camelCase")]
+84pub struct DownloadInfo {
+85    pub id: i64,
+86    pub item_id: String,
+87    pub user_id: String,
+88    pub file_path: String,
+89    pub file_size: Option<i64>,
+90    pub mime_type: Option<String>,
+91    pub status: String,
+92    pub progress: f64,
+93    pub bytes_downloaded: i64,
+94    pub queued_at: String,
+95    pub started_at: Option<String>,
+96    pub completed_at: Option<String>,
+97    pub error_message: Option<String>,
+98    pub retry_count: i32,
+99    pub priority: i32,
+100    // Item metadata for display (audio)
+101    pub item_name: Option<String>,
+102    pub artist_name: Option<String>,
+103    pub album_name: Option<String>,
+104    // Video-specific metadata
+105    pub series_name: Option<String>,
+106    pub season_name: Option<String>,
+107    pub episode_number: Option<i32>,
+108    pub season_number: Option<i32>,
+109    pub quality_preset: Option<String>,
+110    pub media_type: String,
+111    // Download source tracking
+112    pub download_source: String, // 'user' or 'auto'
+113}
+114
+115/// Download task for workers
+116#[derive(Debug, Clone)]
+117pub struct DownloadTask {
+118    pub url: String,
+119    pub target_path: PathBuf,
+120}
+121
+122#[cfg(test)]
+123mod tests {
+124    use super::*;
+125
+126    #[test]
+127    fn test_download_manager_set_max_concurrent() {
+128        let media_dir = PathBuf::from("/tmp/jellytau/media");
+129        let mut manager = DownloadManager::new(media_dir);
+130
+131        manager.set_max_concurrent(5);
+132        assert_eq!(manager.max_concurrent(), 5);
+133
+134        // Should clamp to minimum of 1
+135        manager.set_max_concurrent(0);
+136        assert_eq!(manager.max_concurrent(), 1);
+137    }
+138
+139    #[test]
+140    fn test_download_info_serialization() {
+141        let info = DownloadInfo {
+142            id: 1,
+143            item_id: "test123".to_string(),
+144            user_id: "user1".to_string(),
+145            file_path: "/path/to/file.mp3".to_string(),
+146            file_size: Some(1024000),
+147            mime_type: Some("audio/mpeg".to_string()),
+148            status: "downloading".to_string(),
+149            progress: 0.5,
+150            bytes_downloaded: 512000,
+151            queued_at: "2024-01-01T00:00:00Z".to_string(),
+152            started_at: Some("2024-01-01T00:01:00Z".to_string()),
+153            completed_at: None,
+154            error_message: None,
+155            retry_count: 0,
+156            priority: 0,
+157            item_name: Some("Test Song".to_string()),
+158            artist_name: Some("Test Artist".to_string()),
+159            album_name: Some("Test Album".to_string()),
+160            series_name: None,
+161            season_name: None,
+162            episode_number: None,
+163            season_number: None,
+164            quality_preset: Some("original".to_string()),
+165            media_type: "audio".to_string(),
+166            download_source: "user".to_string(),
+167        };
+168
+169        let json = serde_json::to_string(&info).unwrap();
+170        assert!(json.contains("\"status\":\"downloading\""));
+171        assert!(json.contains("\"progress\":0.5"));
+172        assert!(json.contains("\"itemName\":\"Test Song\""));
+173        assert!(json.contains("\"mediaType\":\"audio\""));
+174    }
+175
+176    #[test]
+177    fn test_video_download_info_serialization() {
+178        let info = DownloadInfo {
+179            id: 2,
+180            item_id: "episode123".to_string(),
+181            user_id: "user1".to_string(),
+182            file_path: "/path/to/ShowName/S01E01_Title.mp4".to_string(),
+183            file_size: Some(1024000000),
+184            mime_type: Some("video/mp4".to_string()),
+185            status: "completed".to_string(),
+186            progress: 1.0,
+187            bytes_downloaded: 1024000000,
+188            queued_at: "2024-01-01T00:00:00Z".to_string(),
+189            started_at: Some("2024-01-01T00:01:00Z".to_string()),
+190            completed_at: Some("2024-01-01T01:00:00Z".to_string()),
+191            error_message: None,
+192            retry_count: 0,
+193            priority: 100,
+194            item_name: Some("Episode Title".to_string()),
+195            artist_name: None,
+196            album_name: None,
+197            series_name: Some("Show Name".to_string()),
+198            season_name: Some("Season 1".to_string()),
+199            episode_number: Some(1),
+200            season_number: Some(1),
+201            quality_preset: Some("high".to_string()),
+202            media_type: "video".to_string(),
+203            download_source: "auto".to_string(),
+204        };
+205
+206        let json = serde_json::to_string(&info).unwrap();
+207        assert!(json.contains("\"mediaType\":\"video\""));
+208        assert!(json.contains("\"seriesName\":\"Show Name\""));
+209        assert!(json.contains("\"episodeNumber\":1"));
+210        assert!(json.contains("\"qualityPreset\":\"high\""));
+211    }
+212}
+
\ No newline at end of file diff --git a/api/src/jellytau_lib/download/network.rs.html b/api/src/jellytau_lib/download/network.rs.html new file mode 100644 index 00000000..a9e3b3e0 --- /dev/null +++ b/api/src/jellytau_lib/download/network.rs.html @@ -0,0 +1,197 @@ +network.rs - sourceSkip to main content

jellytau_lib/download/
network.rs

1//! Network transport classification for the WiFi-only download gate.
+2//!
+3//! This answers "what kind of connection are we on?", which is orthogonal to
+4//! the `ConnectivityMonitor`'s "is the server reachable?". The download queue
+5//! pump consults this before starting pending rows when the user has enabled
+6//! WiFi-only downloads.
+7//!
+8//! On Android the real transport is read from `NetworkCapabilities` in
+9//! `NetworkTypeMonitor.kt` and pushed in from the frontend. On desktop there is
+10//! no metered-connection concept worth enforcing, so we report `Ethernet`,
+11//! which is always acceptable — gating desktop downloads would be a regression.
+12
+13use serde::{Deserialize, Serialize};
+14use std::sync::Arc;
+15use tokio::sync::RwLock;
+16
+17/// Kind of network transport currently active.
+18///
+19/// Mirrors the string constants in `NetworkTypeMonitor.kt`; the two must stay
+20/// in sync (the serde rename below is what the frontend sends).
+21///
+22/// TRACES: UR-053 | DR-074
+23#[derive(specta::Type, Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
+24#[serde(rename_all = "lowercase")]
+25pub enum NetworkType {
+26    /// No active network.
+27    None,
+28    /// WiFi (may still be metered — check `unmetered`).
+29    Wifi,
+30    /// Wired ethernet, typical on Android TV and desktop.
+31    Ethernet,
+32    /// Mobile data — never acceptable when wifi-only is enabled.
+33    Cellular,
+34    /// Some other transport (VPN over unknown carrier, Bluetooth tethering, …).
+35    Other,
+36    /// Could not determine the transport.
+37    Unknown,
+38}
+39
+40/// Current network transport plus whether it is metered.
+41///
+42/// TRACES: UR-053 | DR-074
+43#[derive(specta::Type, Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
+44#[serde(rename_all = "camelCase")]
+45pub struct NetworkState {
+46    pub network_type: NetworkType,
+47    /// Whether the active network is unmetered (Android `NET_CAPABILITY_NOT_METERED`).
+48    pub unmetered: bool,
+49}
+50
+51impl Default for NetworkState {
+52    fn default() -> Self {
+53        // Desktop default: wired and unmetered, so the gate never blocks there.
+54        // Android overwrites this as soon as the frontend reports the real state.
+55        Self {
+56            network_type: NetworkType::Ethernet,
+57            unmetered: true,
+58        }
+59    }
+60}
+61
+62impl NetworkState {
+63    /// Whether downloads may run right now given the wifi-only preference.
+64    ///
+65    /// Ethernet counts as acceptable — it is unmetered in practice and is what
+66    /// Android TV devices use. Cellular never does. `None`/`Unknown` fail
+67    /// closed: if we cannot tell what we are on, we do not spend the user's
+68    /// mobile data to find out.
+69    ///
+70    /// TRACES: UR-053 | DR-074
+71    pub fn allows_download(&self, wifi_only: bool) -> bool {
+72        if !wifi_only {
+73            return true;
+74        }
+75        match self.network_type {
+76            NetworkType::Cellular | NetworkType::None | NetworkType::Unknown => false,
+77            // Require unmetered so metered WiFi hotspots (backed by the very
+78            // cellular data this setting protects) are excluded too.
+79            NetworkType::Wifi | NetworkType::Ethernet | NetworkType::Other => self.unmetered,
+80        }
+81    }
+82}
+83
+84/// Shared, mutable view of the current network transport.
+85///
+86/// Cheap to clone; the frontend updates it via `set_network_state` whenever
+87/// Android reports a network change.
+88#[derive(Clone, Default)]
+89pub struct NetworkStateHandle {
+90    state: Arc<RwLock<NetworkState>>,
+91}
+92
+93impl NetworkStateHandle {
+94    pub fn new() -> Self {
+95        Self {
+96            state: Arc::new(RwLock::new(NetworkState::default())),
+97        }
+98    }
+99
+100    pub async fn get(&self) -> NetworkState {
+101        *self.state.read().await
+102    }
+103
+104    pub async fn set(&self, new_state: NetworkState) {
+105        *self.state.write().await = new_state;
+106    }
+107
+108    /// Whether downloads may run right now given the wifi-only preference.
+109    pub async fn allows_download(&self, wifi_only: bool) -> bool {
+110        self.state.read().await.allows_download(wifi_only)
+111    }
+112}
+113
+114#[cfg(test)]
+115mod tests {
+116    use super::*;
+117
+118    fn state(network_type: NetworkType, unmetered: bool) -> NetworkState {
+119        NetworkState {
+120            network_type,
+121            unmetered,
+122        }
+123    }
+124
+125    #[test]
+126    fn wifi_only_off_allows_every_transport() {
+127        for t in [
+128            NetworkType::None,
+129            NetworkType::Wifi,
+130            NetworkType::Ethernet,
+131            NetworkType::Cellular,
+132            NetworkType::Other,
+133            NetworkType::Unknown,
+134        ] {
+135            assert!(
+136                state(t, false).allows_download(false),
+137                "{t:?} should be allowed when wifi_only is off"
+138            );
+139        }
+140    }
+141
+142    #[test]
+143    fn cellular_is_blocked_when_wifi_only() {
+144        // Even if somehow flagged unmetered, cellular is never acceptable.
+145        assert!(!state(NetworkType::Cellular, true).allows_download(true));
+146        assert!(!state(NetworkType::Cellular, false).allows_download(true));
+147    }
+148
+149    #[test]
+150    fn unmetered_wifi_and_ethernet_are_allowed() {
+151        assert!(state(NetworkType::Wifi, true).allows_download(true));
+152        assert!(state(NetworkType::Ethernet, true).allows_download(true));
+153    }
+154
+155    #[test]
+156    fn metered_wifi_is_blocked() {
+157        // A phone hotspot reports as WiFi but is metered — blocking it is the
+158        // whole point of checking NOT_METERED rather than the transport alone.
+159        assert!(!state(NetworkType::Wifi, false).allows_download(true));
+160    }
+161
+162    #[test]
+163    fn unknown_and_none_fail_closed() {
+164        assert!(!state(NetworkType::Unknown, true).allows_download(true));
+165        assert!(!state(NetworkType::None, true).allows_download(true));
+166    }
+167
+168    #[test]
+169    fn desktop_default_is_never_gated() {
+170        assert!(NetworkState::default().allows_download(true));
+171    }
+172
+173    #[tokio::test]
+174    async fn handle_roundtrips_state() {
+175        let handle = NetworkStateHandle::new();
+176        assert!(handle.allows_download(true).await);
+177
+178        handle.set(state(NetworkType::Cellular, false)).await;
+179        assert!(!handle.allows_download(true).await);
+180        assert!(handle.allows_download(false).await);
+181        assert_eq!(handle.get().await.network_type, NetworkType::Cellular);
+182    }
+183
+184    #[test]
+185    fn network_type_serializes_lowercase() {
+186        // Must match the string constants in NetworkTypeMonitor.kt.
+187        assert_eq!(
+188            serde_json::to_string(&NetworkType::Wifi).unwrap(),
+189            "\"wifi\""
+190        );
+191        assert_eq!(
+192            serde_json::to_string(&NetworkType::Cellular).unwrap(),
+193            "\"cellular\""
+194        );
+195    }
+196}
+
\ No newline at end of file diff --git a/api/src/jellytau_lib/download/stop.rs.html b/api/src/jellytau_lib/download/stop.rs.html new file mode 100644 index 00000000..e9a7f7c2 --- /dev/null +++ b/api/src/jellytau_lib/download/stop.rs.html @@ -0,0 +1,151 @@ +stop.rs - sourceSkip to main content

jellytau_lib/download/
stop.rs

1//! Stop signalling for in-flight downloads.
+2//!
+3//! TRACES: UR-055 | DR-168
+4//!
+5//! Pausing and cancelling used to be database-only: `pause_download` wrote
+6//! `status = 'paused'` and nothing else. No cancellation existed anywhere in the
+7//! download stack — no token, no flag, no abort — so the streaming task kept
+8//! running, kept writing bytes, and on finishing overwrote the row with
+9//! `completed` or `failed`. The row flicked to "paused" and then undid itself,
+10//! which is precisely the reported "pause does not work".
+11//!
+12//! This is the missing half: a flag per in-flight download that the worker reads
+13//! between chunks. Setting it makes the worker return [`Stopped`] promptly and
+14//! leave the `.part` file **intact**, which is what lets a resume pick up from
+15//! where it stopped via the existing HTTP Range request.
+16//!
+17//! Kept as a module-level registry rather than on `DownloadManager` because the
+18//! two sides never meet: the command handler holds the manager's lock, while the
+19//! worker runs detached inside `tauri::async_runtime::spawn` with no access to
+20//! Tauri state. A registry both can reach is the smallest thing that works.
+21//!
+22//! [`Stopped`]: crate::download::worker::DownloadError::Stopped
+23
+24use std::collections::HashMap;
+25use std::sync::atomic::{AtomicBool, Ordering};
+26use std::sync::{Arc, Mutex, OnceLock};
+27
+28use crate::utils::lock::MutexSafe;
+29
+30/// download id → its stop flag, for downloads currently in flight.
+31fn registry() -> &'static Mutex<HashMap<i64, Arc<AtomicBool>>> {
+32    static REGISTRY: OnceLock<Mutex<HashMap<i64, Arc<AtomicBool>>>> = OnceLock::new();
+33    REGISTRY.get_or_init(|| Mutex::new(HashMap::new()))
+34}
+35
+36/// Register `download_id` as in-flight and hand back its stop flag.
+37///
+38/// Called by the worker as it starts. A previous flag for the same id is
+39/// replaced, so a download that is paused and later resumed does not inherit the
+40/// set flag from its last run and stop immediately.
+41pub fn register(download_id: i64) -> Arc<AtomicBool> {
+42    let flag = Arc::new(AtomicBool::new(false));
+43    registry().lock_safe().insert(download_id, flag.clone());
+44    flag
+45}
+46
+47/// Ask an in-flight download to stop.
+48///
+49/// Returns whether one was actually in flight — the caller uses this to tell a
+50/// running download (which will stop shortly) from a merely queued one (which
+51/// the database update alone has already handled).
+52pub fn signal(download_id: i64) -> bool {
+53    match registry().lock_safe().get(&download_id) {
+54        Some(flag) => {
+55            flag.store(true, Ordering::SeqCst);
+56            true
+57        }
+58        None => false,
+59    }
+60}
+61
+62/// Forget a download's flag. Called when its task finishes, however it ended.
+63pub fn clear(download_id: i64) {
+64    registry().lock_safe().remove(&download_id);
+65}
+66
+67/// Whether a stop has been requested for `download_id`.
+68///
+69/// The worker reads its own `Arc<AtomicBool>` directly rather than looking the id
+70/// up, so this exists for the tests that assert the registry's behaviour.
+71#[cfg(test)]
+72pub fn is_stopping(download_id: i64) -> bool {
+73    registry()
+74        .lock_safe()
+75        .get(&download_id)
+76        .map(|f| f.load(Ordering::SeqCst))
+77        .unwrap_or(false)
+78}
+79
+80#[cfg(test)]
+81mod tests {
+82    use super::*;
+83
+84    /// Ids are per-test so the shared registry cannot leak between them.
+85    fn unique_id(seed: i64) -> i64 {
+86        900_000 + seed
+87    }
+88
+89    #[test]
+90    fn test_a_registered_download_starts_unflagged() {
+91        let id = unique_id(1);
+92        let flag = register(id);
+93        assert!(!flag.load(Ordering::SeqCst));
+94        assert!(!is_stopping(id));
+95        clear(id);
+96    }
+97
+98    #[test]
+99    fn test_signal_sets_the_flag_the_worker_reads() {
+100        let id = unique_id(2);
+101        let flag = register(id);
+102
+103        assert!(signal(id), "a registered download reports as in flight");
+104        assert!(
+105            flag.load(Ordering::SeqCst),
+106            "the worker's own handle sees it"
+107        );
+108        assert!(is_stopping(id));
+109
+110        clear(id);
+111    }
+112
+113    /// The pump only needs to abort a task that exists; a queued row is handled
+114    /// by its database status alone.
+115    #[test]
+116    fn test_signalling_an_unregistered_download_reports_not_in_flight() {
+117        assert!(!signal(unique_id(3)));
+118    }
+119
+120    #[test]
+121    fn test_clear_forgets_the_download() {
+122        let id = unique_id(4);
+123        register(id);
+124        signal(id);
+125        clear(id);
+126
+127        assert!(!is_stopping(id));
+128        assert!(!signal(id), "a cleared download is no longer in flight");
+129    }
+130
+131    /// The bug this guards: pause sets the flag, and resume re-runs the same
+132    /// download id. If registering reused the old flag, the resumed run would see
+133    /// a set flag and stop instantly — a download that could never be resumed.
+134    #[test]
+135    fn test_reregistering_clears_a_previous_stop() {
+136        let id = unique_id(5);
+137        register(id);
+138        signal(id);
+139        assert!(is_stopping(id));
+140
+141        let fresh = register(id);
+142        assert!(!fresh.load(Ordering::SeqCst));
+143        assert!(
+144            !is_stopping(id),
+145            "a resumed download must not inherit the pause"
+146        );
+147
+148        clear(id);
+149    }
+150}
+
\ No newline at end of file diff --git a/api/src/jellytau_lib/download/worker.rs.html b/api/src/jellytau_lib/download/worker.rs.html new file mode 100644 index 00000000..38e7f993 --- /dev/null +++ b/api/src/jellytau_lib/download/worker.rs.html @@ -0,0 +1,395 @@ +worker.rs - sourceSkip to main content

jellytau_lib/download/
worker.rs

1//! Download worker for HTTP streaming with progress tracking and retry logic
+2
+3use log::warn;
+4use std::sync::atomic::{AtomicBool, Ordering};
+5use std::time::Duration;
+6
+7use futures_util::StreamExt;
+8use tokio::fs;
+9use tokio::io::AsyncWriteExt;
+10
+11use super::DownloadTask;
+12
+13/// Download worker that handles individual download tasks
+14pub struct DownloadWorker {
+15    /// HTTP client for downloads
+16    client: reqwest::Client,
+17    /// Maximum retry attempts
+18    max_retries: u32,
+19}
+20
+21impl DownloadWorker {
+22    pub fn new() -> Self {
+23        let client = reqwest::Client::builder()
+24            .timeout(Duration::from_secs(300)) // 5 minute timeout
+25            .https_only(true)
+26            .build()
+27            .expect("Failed to create HTTP client");
+28
+29        Self {
+30            client,
+31            max_retries: 3,
+32        }
+33    }
+34
+35    /// Download a file with retry logic and progress tracking.
+36    ///
+37    /// `stop` is the pause/cancel flag (see [`crate::download::stop`]). It is
+38    /// checked between chunks and again between retries, so a paused download
+39    /// stops promptly rather than after its next backoff — up to 45 seconds
+40    /// away, which reads as the pause having done nothing.
+41    ///
+42    /// TRACES: UR-055 | DR-168
+43    pub async fn download<F>(
+44        &self,
+45        task: &DownloadTask,
+46        stop: &AtomicBool,
+47        on_progress: F,
+48    ) -> Result<DownloadResult, DownloadError>
+49    where
+50        F: Fn(u64, Option<u64>) + Send + Sync,
+51    {
+52        let mut retries = 0;
+53
+54        loop {
+55            if stop.load(Ordering::SeqCst) {
+56                return Err(DownloadError::Stopped);
+57            }
+58            match self.try_download(task, stop, &on_progress).await {
+59                Ok(result) => return Ok(result),
+60                Err(e) if retries < self.max_retries && e.is_retryable() => {
+61                    retries += 1;
+62                    let delay = Self::exponential_backoff(retries);
+63                    warn!(
+64                        "Download failed (attempt {}/{}), retrying in {:?}: {}",
+65                        retries, self.max_retries, delay, e
+66                    );
+67                    tokio::time::sleep(delay).await;
+68                }
+69                Err(e) => return Err(e),
+70            }
+71        }
+72    }
+73
+74    /// Attempt a single download
+75    async fn try_download<F>(
+76        &self,
+77        task: &DownloadTask,
+78        stop: &AtomicBool,
+79        on_progress: &F,
+80    ) -> Result<DownloadResult, DownloadError>
+81    where
+82        F: Fn(u64, Option<u64>) + Send + Sync,
+83    {
+84        // Create parent directories
+85        if let Some(parent) = task.target_path.parent() {
+86            fs::create_dir_all(parent)
+87                .await
+88                .map_err(|e| DownloadError::FileSystem(e.to_string()))?;
+89        }
+90
+91        // Check for partial download
+92        let temp_path = partial_path(&task.target_path);
+93        let existing_bytes = if temp_path.exists() {
+94            fs::metadata(&temp_path).await.map(|m| m.len()).unwrap_or(0)
+95        } else {
+96            0
+97        };
+98
+99        // Build HTTP request with Range header for resume support
+100        let mut request = self.client.get(&task.url);
+101        if existing_bytes > 0 {
+102            request = request.header("Range", format!("bytes={}-", existing_bytes));
+103        }
+104
+105        // Send request
+106        let response = request
+107            .send()
+108            .await
+109            .map_err(|e| DownloadError::Network(e.to_string()))?;
+110
+111        // Check status
+112        if !response.status().is_success() && response.status().as_u16() != 206 {
+113            return Err(DownloadError::Http(response.status().as_u16()));
+114        }
+115
+116        // Did the server actually honour the Range? A transcode does not, and
+117        // answers 200 with the whole stream — appending that would duplicate what
+118        // we already hold. (DR-170)
+119        let resume_from = resume_offset(existing_bytes, response.status().as_u16());
+120        if existing_bytes > 0 && resume_from == 0 {
+121            warn!(
+122                "Server ignored the Range request (HTTP {}) — restarting {} from the beginning \
+123                 instead of appending to {} existing bytes",
+124                response.status().as_u16(),
+125                task.target_path.display(),
+126                existing_bytes
+127            );
+128        }
+129
+130        // Get content length. Absent on a chunked transcode, which is why progress
+131        // for a non-`original` preset has no percentage to show.
+132        let _total_bytes = response
+133            .headers()
+134            .get(reqwest::header::CONTENT_LENGTH)
+135            .and_then(|v| v.to_str().ok())
+136            .and_then(|v| v.parse::<u64>().ok())
+137            .map(|len| len + resume_from);
+138
+139        // Append only when resuming a range the server agreed to; otherwise
+140        // create/truncate so the restarted stream replaces the stale bytes.
+141        let mut file = if resume_from > 0 {
+142            fs::OpenOptions::new().append(true).open(&temp_path).await
+143        } else {
+144            fs::File::create(&temp_path).await
+145        }
+146        .map_err(|e| DownloadError::FileSystem(e.to_string()))?;
+147
+148        // Stream download with progress tracking
+149        let mut downloaded = resume_from;
+150        let mut stream = response.bytes_stream();
+151        let mut last_progress_emit = std::time::Instant::now();
+152
+153        while let Some(chunk) = stream.next().await {
+154            // Checked before writing, so a paused download stops on a byte
+155            // boundary the `.part` file already accounts for — the Range request
+156            // on resume then asks for exactly what is missing. Flushing what we
+157            // have and leaving the file in place is the whole mechanism behind
+158            // "resume", so this must never delete it. (DR-168)
+159            if stop.load(Ordering::SeqCst) {
+160                let _ = file.flush().await;
+161                let _ = file.sync_all().await;
+162                return Err(DownloadError::Stopped);
+163            }
+164
+165            let chunk = chunk.map_err(|e| DownloadError::Network(e.to_string()))?;
+166
+167            file.write_all(&chunk)
+168                .await
+169                .map_err(|e| DownloadError::FileSystem(e.to_string()))?;
+170
+171            downloaded += chunk.len() as u64;
+172
+173            // Emit progress every 500ms or every MB
+174            if last_progress_emit.elapsed() > Duration::from_millis(500)
+175                || downloaded.is_multiple_of(1024 * 1024)
+176            {
+177                last_progress_emit = std::time::Instant::now();
+178                on_progress(downloaded, _total_bytes);
+179            }
+180        }
+181
+182        file.sync_all()
+183            .await
+184            .map_err(|e| DownloadError::FileSystem(e.to_string()))?;
+185
+186        // Move from .part to final location
+187        fs::rename(&temp_path, &task.target_path)
+188            .await
+189            .map_err(|e| DownloadError::FileSystem(e.to_string()))?;
+190
+191        Ok(DownloadResult {
+192            bytes_downloaded: downloaded,
+193        })
+194    }
+195
+196    /// Calculate exponential backoff delay
+197    fn exponential_backoff(retry_count: u32) -> Duration {
+198        let base_delay = 5; // 5 seconds
+199        let delay_secs = base_delay * 3u64.pow(retry_count - 1); // 5s, 15s, 45s
+200        Duration::from_secs(delay_secs)
+201    }
+202}
+203
+204/// Where to resume writing a partial download, given how the server answered.
+205///
+206/// A byte offset of 0 means "start the file again"; anything else means "append
+207/// from here".
+208///
+209/// This is what makes non-`original` downloads survive. Those presets ask
+210/// Jellyfin to **transcode**, and a live transcode is chunked with no
+211/// `Content-Length` and cannot be byte-seeked: the server ignores `Range` and
+212/// answers `200` with the whole stream from the beginning, not `206` with the
+213/// requested tail. The worker sent the header and appended the body regardless,
+214/// so every retry — and every resume — concatenated a fresh copy of the whole
+215/// transcode onto the bytes already on disk. The file grew past its real size
+216/// and would not play. Only a `206` actually promises the tail; a `200` means we
+217/// must discard what we have and take the stream from the top.
+218///
+219/// TRACES: UR-071 | DR-170
+220pub fn resume_offset(existing_bytes: u64, status: u16) -> u64 {
+221    if existing_bytes == 0 {
+222        return 0;
+223    }
+224    // 206 Partial Content is the only answer that honours the Range request.
+225    if status == 206 {
+226        existing_bytes
+227    } else {
+228        0
+229    }
+230}
+231
+232/// The partial-download sidecar for `target`.
+233///
+234/// **Appends** `.part` rather than replacing the extension. The worker used
+235/// `Path::with_extension("part")`, which replaces: `movie.mp4` became
+236/// `movie.part`. Every cleanup path meanwhile deleted `"{file_path}.part"` —
+237/// `movie.mp4.part` — so nothing ever matched and the partial file of every
+238/// cancelled or failed download was left on disk forever, invisible to the
+239/// disk-usage totals because no `downloads` row pointed at it. That is the
+240/// reported "failure is not cleaned".
+241///
+242/// Appending also removes a collision the old form had: `movie.mp4` and
+243/// `movie.mkv` both mapped to `movie.part` and would have fought over one file.
+244///
+245/// One function so the writer and the cleaners cannot disagree again.
+246///
+247/// TRACES: UR-055 | DR-169
+248pub fn partial_path(target: &std::path::Path) -> std::path::PathBuf {
+249    let mut s = target.as_os_str().to_os_string();
+250    s.push(".part");
+251    std::path::PathBuf::from(s)
+252}
+253
+254/// Result of a successful download
+255#[derive(Debug)]
+256pub struct DownloadResult {
+257    pub bytes_downloaded: u64,
+258}
+259
+260/// Download error types
+261#[derive(Debug)]
+262pub enum DownloadError {
+263    Network(String),
+264    Http(u16),
+265    FileSystem(String),
+266    /// The download was asked to stop (paused or cancelled). Not a failure: the
+267    /// row's status already says what happened, and the partial file is kept so a
+268    /// resume can continue from it.
+269    Stopped,
+270}
+271
+272impl DownloadError {
+273    /// Check if this error is retryable
+274    fn is_retryable(&self) -> bool {
+275        match self {
+276            DownloadError::Network(_) => true,
+277            DownloadError::Http(status) => *status >= 500, // Retry server errors
+278            DownloadError::FileSystem(_) => false,
+279            // Retrying would restart the very download the user just paused.
+280            DownloadError::Stopped => false,
+281        }
+282    }
+283
+284    /// Whether this outcome means "the user stopped it", rather than a failure to
+285    /// record and report.
+286    pub fn is_stopped(&self) -> bool {
+287        matches!(self, DownloadError::Stopped)
+288    }
+289}
+290
+291impl std::fmt::Display for DownloadError {
+292    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
+293        match self {
+294            DownloadError::Network(msg) => write!(f, "Network error: {}", msg),
+295            DownloadError::Http(status) => write!(f, "HTTP error {}", status),
+296            DownloadError::FileSystem(msg) => write!(f, "File system error: {}", msg),
+297            DownloadError::Stopped => write!(f, "Download stopped by request"),
+298        }
+299    }
+300}
+301
+302impl std::error::Error for DownloadError {}
+303
+304#[cfg(test)]
+305mod tests {
+306    use super::*;
+307
+308    /// The bitrate-download corruption: a transcode ignores `Range` and answers
+309    /// `200` with the whole stream. Appending that to the bytes already on disk
+310    /// duplicated them, so every retry grew the file past its real size and left
+311    /// it unplayable. Only `206` promises the requested tail.
+312    ///
+313    /// TRACES: UR-071 | DR-170 | UT-164
+314    #[test]
+315    fn test_resume_offset_only_appends_when_the_server_honoured_the_range() {
+316        // Nothing on disk: start at the beginning either way.
+317        assert_eq!(resume_offset(0, 200), 0);
+318        assert_eq!(resume_offset(0, 206), 0);
+319
+320        // The server agreed to the range — append to what we have.
+321        assert_eq!(resume_offset(5_000, 206), 5_000);
+322
+323        // The server ignored it and is sending the whole file (a transcode).
+324        // Restart, or the bytes are duplicated.
+325        assert_eq!(
+326            resume_offset(5_000, 200),
+327            0,
+328            "a 200 carries the whole stream; appending it corrupts the file"
+329        );
+330    }
+331
+332    /// The regression: `with_extension` replaced the extension, so the worker
+333    /// wrote `movie.part` while every cleanup path deleted `movie.mp4.part`.
+334    /// Nothing matched, and partial files accumulated forever.
+335    ///
+336    /// TRACES: UR-055 | DR-169 | UT-163
+337    #[test]
+338    fn test_partial_path_appends_rather_than_replacing_the_extension() {
+339        use std::path::Path;
+340
+341        assert_eq!(
+342            partial_path(Path::new("/media/movie.mp4")),
+343            Path::new("/media/movie.mp4.part"),
+344            "the cleanup paths delete \"{{file_path}}.part\"; this must produce it"
+345        );
+346
+347        // Two sources for one title must not fight over a single partial file.
+348        assert_ne!(
+349            partial_path(Path::new("/media/movie.mp4")),
+350            partial_path(Path::new("/media/movie.mkv")),
+351        );
+352
+353        // Extension-less targets still get a sidecar rather than being clobbered.
+354        assert_eq!(
+355            partial_path(Path::new("/media/track")),
+356            Path::new("/media/track.part"),
+357        );
+358
+359        // A dotted name keeps every part of its own name.
+360        assert_eq!(
+361            partial_path(Path::new("/media/S01.E02.episode.mkv")),
+362            Path::new("/media/S01.E02.episode.mkv.part"),
+363        );
+364    }
+365
+366    #[test]
+367    fn test_exponential_backoff() {
+368        assert_eq!(
+369            DownloadWorker::exponential_backoff(1),
+370            Duration::from_secs(5)
+371        );
+372        assert_eq!(
+373            DownloadWorker::exponential_backoff(2),
+374            Duration::from_secs(15)
+375        );
+376        assert_eq!(
+377            DownloadWorker::exponential_backoff(3),
+378            Duration::from_secs(45)
+379        );
+380    }
+381
+382    #[test]
+383    fn test_error_retryable() {
+384        assert!(DownloadError::Network("timeout".to_string()).is_retryable());
+385        assert!(DownloadError::Http(500).is_retryable());
+386        assert!(DownloadError::Http(503).is_retryable());
+387        assert!(!DownloadError::Http(404).is_retryable());
+388        assert!(!DownloadError::FileSystem("disk full".to_string()).is_retryable());
+389        // Retrying a paused download would restart what the user just stopped.
+390        assert!(!DownloadError::Stopped.is_retryable());
+391        assert!(DownloadError::Stopped.is_stopped());
+392        assert!(!DownloadError::Network("timeout".to_string()).is_stopped());
+393    }
+394}
+
\ No newline at end of file diff --git a/api/src/jellytau_lib/jellyfin/client.rs.html b/api/src/jellytau_lib/jellyfin/client.rs.html new file mode 100644 index 00000000..9b458b3f --- /dev/null +++ b/api/src/jellytau_lib/jellyfin/client.rs.html @@ -0,0 +1,672 @@ +client.rs - sourceSkip to main content

jellytau_lib/jellyfin/
client.rs

1//! TRACES: UR-009 | JA-001, JA-002, JA-003, JA-004, JA-007, JA-010, JA-011, JA-012, JA-017, JA-021 | IR-009, IR-010, IR-011
+2
+3use log::{debug, error, info};
+4use reqwest::Client;
+5use serde::Deserialize;
+6use std::sync::Arc;
+7
+8use super::types::*;
+9
+10const APP_NAME: &str = "JellyTau";
+11const APP_VERSION: &str = "0.1.0";
+12
+13/// Jellyfin API client for playback reporting
+14#[derive(Clone)]
+15pub struct JellyfinClient {
+16    config: Arc<JellyfinConfig>,
+17    http_client: Client,
+18}
+19
+20impl JellyfinClient {
+21    /// Create a new Jellyfin API client
+22    pub fn new(config: JellyfinConfig) -> Result<Self, String> {
+23        let http_client = Client::builder()
+24            .timeout(std::time::Duration::from_secs(10))
+25            .https_only(true)
+26            .build()
+27            .map_err(|e| format!("Failed to create HTTP client: {}", e))?;
+28
+29        Ok(Self {
+30            config: Arc::new(config),
+31            http_client,
+32        })
+33    }
+34
+35    /// Get device name based on platform
+36    fn get_device_name() -> &'static str {
+37        #[cfg(target_os = "android")]
+38        return "Android";
+39        #[cfg(target_os = "linux")]
+40        return "Linux";
+41        #[cfg(target_os = "windows")]
+42        return "Windows";
+43        #[cfg(target_os = "macos")]
+44        return "macOS";
+45        #[cfg(target_os = "ios")]
+46        return "iOS";
+47        #[cfg(not(any(
+48            target_os = "android",
+49            target_os = "linux",
+50            target_os = "windows",
+51            target_os = "macos",
+52            target_os = "ios"
+53        )))]
+54        return "Unknown";
+55    }
+56
+57    /// Build the X-Emby-Authorization header value
+58    fn get_auth_header(&self) -> String {
+59        format!(
+60            "MediaBrowser Client=\"{}\", Version=\"{}\", Device=\"{}\", DeviceId=\"{}\", Token=\"{}\"",
+61            APP_NAME,
+62            APP_VERSION,
+63            Self::get_device_name(),
+64            self.config.device_id,
+65            self.config.access_token
+66        )
+67    }
+68
+69    /// Make a GET request to the Jellyfin API
+70    async fn get<T: for<'de> Deserialize<'de>>(&self, endpoint: &str) -> Result<T, String> {
+71        let url = format!("{}{}", self.config.server_url, endpoint);
+72
+73        log::debug!("[JellyfinClient] GET {}", endpoint);
+74
+75        let response = self
+76            .http_client
+77            .get(&url)
+78            .header("X-Emby-Authorization", self.get_auth_header())
+79            .send()
+80            .await
+81            .map_err(|e| {
+82                log::error!("[JellyfinClient] Request failed for {}: {}", endpoint, e);
+83                format!("Network request failed: {}", e)
+84            })?;
+85
+86        let status = response.status();
+87        log::debug!(
+88            "[JellyfinClient] Response status for {}: {}",
+89            endpoint,
+90            status
+91        );
+92
+93        if !status.is_success() {
+94            let error_text = response
+95                .text()
+96                .await
+97                .unwrap_or_else(|_| "Unknown error".to_string());
+98            log::error!("[JellyfinClient] Request failed: {} {}", status, endpoint);
+99            log::error!("[JellyfinClient] Response: {}", error_text);
+100            return Err(format!(
+101                "Jellyfin API error {}: {}",
+102                status.as_u16(),
+103                error_text
+104            ));
+105        }
+106
+107        // Get the response text first so we can log it
+108        let response_text = response.text().await.map_err(|e| {
+109            log::error!("[JellyfinClient] Failed to read response body: {}", e);
+110            format!("Failed to read response: {}", e)
+111        })?;
+112
+113        // Log the raw response for sessions endpoint to help debug
+114        if endpoint.contains("/Sessions") {
+115            debug!(
+116                "[JellyfinClient] Raw response for {}: {}",
+117                endpoint,
+118                if response_text.len() > 500 {
+119                    format!(
+120                        "{}... (truncated, {} bytes total)",
+121                        &response_text[..500],
+122                        response_text.len()
+123                    )
+124                } else {
+125                    response_text.clone()
+126                }
+127            );
+128        }
+129
+130        // Parse the response text as JSON
+131        let data = serde_json::from_str::<T>(&response_text).map_err(|e| {
+132            log::error!("[JellyfinClient] Failed to parse response: {}", e);
+133            log::error!(
+134                "[JellyfinClient] Response was: {}",
+135                if response_text.len() > 200 {
+136                    format!("{}...", &response_text[..200])
+137                } else {
+138                    response_text.clone()
+139                }
+140            );
+141            format!("Failed to parse response: {}", e)
+142        })?;
+143
+144        log::debug!("[JellyfinClient] Request successful for {}", endpoint);
+145        Ok(data)
+146    }
+147
+148    /// Make a POST request to the Jellyfin API
+149    async fn post<T: serde::Serialize>(&self, endpoint: &str, body: &T) -> Result<(), String> {
+150        let url = format!("{}{}", self.config.server_url, endpoint);
+151
+152        log::debug!("[JellyfinClient] POST {} to {}", endpoint, url);
+153
+154        let response: reqwest::Response = self
+155            .http_client
+156            .post(&url)
+157            .header("Content-Type", "application/json")
+158            .header("X-Emby-Authorization", self.get_auth_header())
+159            .json(body)
+160            .send()
+161            .await
+162            .map_err(|e| {
+163                log::error!("[JellyfinClient] Request failed for {}: {}", endpoint, e);
+164                format!("Network request failed: {}", e)
+165            })?;
+166
+167        let status = response.status();
+168        log::debug!(
+169            "[JellyfinClient] Response status for {}: {}",
+170            endpoint,
+171            status
+172        );
+173
+174        if !status.is_success() {
+175            let error_text: String = response
+176                .text()
+177                .await
+178                .unwrap_or_else(|_| "Unknown error".to_string());
+179            log::error!("[JellyfinClient] Request failed: {} {}", status, endpoint);
+180            log::error!("[JellyfinClient] Response: {}", error_text);
+181            return Err(format!(
+182                "Jellyfin API error {}: {}",
+183                status.as_u16(),
+184                error_text
+185            ));
+186        }
+187
+188        log::debug!("[JellyfinClient] Request successful for {}", endpoint);
+189        Ok(())
+190    }
+191
+192    /// Report playback start to Jellyfin
+193    pub async fn report_playback_start(
+194        &self,
+195        item_id: String,
+196        position_ticks: i64,
+197        play_session_id: Option<String>,
+198    ) -> Result<(), String> {
+199        let request = PlaybackStartRequest {
+200            item_id,
+201            position_ticks,
+202            play_session_id,
+203            play_command: "PlayNow".to_string(),
+204            is_paused: false,
+205        };
+206
+207        self.post("/Sessions/Playing", &request).await
+208    }
+209
+210    /// Report playback stopped to Jellyfin
+211    pub async fn report_playback_stopped(
+212        &self,
+213        item_id: String,
+214        position_ticks: i64,
+215        play_session_id: Option<String>,
+216    ) -> Result<(), String> {
+217        let request = PlaybackStoppedRequest {
+218            item_id,
+219            position_ticks,
+220            play_session_id,
+221        };
+222
+223        self.post("/Sessions/Playing/Stopped", &request).await
+224    }
+225
+226    /// Report playback progress to Jellyfin
+227    #[allow(dead_code)] // Will be used when playback_reporting is integrated
+228    pub async fn report_playback_progress(
+229        &self,
+230        item_id: String,
+231        position_ticks: i64,
+232        is_paused: bool,
+233        play_session_id: Option<String>,
+234    ) -> Result<(), String> {
+235        let request = PlaybackProgressRequest {
+236            item_id,
+237            position_ticks,
+238            is_paused,
+239            play_session_id,
+240        };
+241
+242        self.post("/Sessions/Playing/Progress", &request).await
+243    }
+244
+245    /// Play items on a remote session (casting)
+246    pub async fn play_on_session(
+247        &self,
+248        session_id: String,
+249        item_ids: Vec<String>,
+250        start_index: usize,
+251        start_position_ticks: Option<i64>,
+252    ) -> Result<(), String> {
+253        log::info!("[JellyfinClient] Playing on session: {}", session_id);
+254        log::info!(
+255            "[JellyfinClient] Item IDs: {:?}, Start index: {}",
+256            item_ids,
+257            start_index
+258        );
+259        debug!(
+260            "[JellyfinClient] play_on_session called: session={}, {} items, start_index={}",
+261            session_id,
+262            item_ids.len(),
+263            start_index
+264        );
+265
+266        // Build URL with query parameters (Jellyfin expects PascalCase query params)
+267        let mut url = format!(
+268            "{}/Sessions/{}/Playing?PlayCommand=PlayNow&StartIndex={}",
+269            self.config.server_url, session_id, start_index
+270        );
+271
+272        // Add item IDs as repeated query parameters
+273        for item_id in &item_ids {
+274            url.push_str(&format!("&ItemIds={}", item_id));
+275        }
+276
+277        // Add start position if provided
+278        if let Some(ticks) = start_position_ticks {
+279            url.push_str(&format!("&StartPositionTicks={}", ticks));
+280            log::info!("[JellyfinClient] Starting at position: {} ticks", ticks);
+281        }
+282
+283        log::info!("[JellyfinClient] POST {}", url);
+284        debug!("[JellyfinClient] Full URL length: {} chars", url.len());
+285        // Don't log full URL as it may contain sensitive tokens, just log the endpoint
+286        debug!(
+287            "[JellyfinClient] POST to Sessions/{}/Playing with {} itemIds",
+288            session_id,
+289            item_ids.len()
+290        );
+291
+292        debug!("[JellyfinClient] Sending HTTP POST request...");
+293        let response = self
+294            .http_client
+295            .post(&url)
+296            .header("X-Emby-Authorization", self.get_auth_header())
+297            .send()
+298            .await
+299            .map_err(|e| {
+300                log::error!("[JellyfinClient] Request failed: {}", e);
+301                error!("[JellyfinClient] HTTP request failed: {}", e);
+302                format!("Network request failed: {}", e)
+303            })?;
+304
+305        let status = response.status();
+306        log::debug!("[JellyfinClient] Response status: {}", status);
+307        debug!("[JellyfinClient] Response status: {}", status);
+308
+309        if !status.is_success() {
+310            let error_text = response
+311                .text()
+312                .await
+313                .unwrap_or_else(|_| "Unknown error".to_string());
+314            log::error!("[JellyfinClient] Request failed: {}", error_text);
+315            error!(
+316                "[JellyfinClient] API error {}: {}",
+317                status.as_u16(),
+318                error_text
+319            );
+320            return Err(format!(
+321                "Jellyfin API error {}: {}",
+322                status.as_u16(),
+323                error_text
+324            ));
+325        }
+326
+327        log::info!("[JellyfinClient] Successfully sent play command to remote session");
+328        info!("[JellyfinClient] Play command sent to remote session");
+329        Ok(())
+330    }
+331
+332    /// Send a playback command to a remote session
+333    pub async fn send_session_command(
+334        &self,
+335        session_id: String,
+336        command: &str,
+337    ) -> Result<(), String> {
+338        self.post(
+339            &format!("/Sessions/{}/Playing/{}", session_id, command),
+340            &serde_json::json!({}),
+341        )
+342        .await
+343    }
+344
+345    /// Seek on a remote session
+346    ///
+347    /// Jellyfin's `/Sessions/{id}/Playing/Seek` endpoint takes the target as the
+348    /// `SeekPositionTicks` *query parameter*, not a JSON body. Sending it in the
+349    /// body (as we used to) is silently ignored and the remote never seeks.
+350    pub async fn session_seek(
+351        &self,
+352        session_id: String,
+353        position_ticks: i64,
+354    ) -> Result<(), String> {
+355        let url = format!(
+356            "{}/Sessions/{}/Playing/Seek?SeekPositionTicks={}",
+357            self.config.server_url, session_id, position_ticks
+358        );
+359
+360        let response = self
+361            .http_client
+362            .post(&url)
+363            .header("X-Emby-Authorization", self.get_auth_header())
+364            .send()
+365            .await
+366            .map_err(|e| format!("Network request failed: {}", e))?;
+367
+368        let status = response.status();
+369        if !status.is_success() {
+370            let error_text = response
+371                .text()
+372                .await
+373                .unwrap_or_else(|_| "Unknown error".to_string());
+374            return Err(format!(
+375                "Jellyfin API error {}: {}",
+376                status.as_u16(),
+377                error_text
+378            ));
+379        }
+380
+381        log::info!(
+382            "[JellyfinClient] Seek to {} ticks on session {}",
+383            position_ticks,
+384            session_id
+385        );
+386        Ok(())
+387    }
+388
+389    /// Send a full GeneralCommand to a remote session.
+390    /// Uses POST /Sessions/{id}/Command with a body containing Name and Arguments.
+391    /// This is required for commands that need arguments (e.g. SetVolume).
+392    async fn send_general_command(
+393        &self,
+394        session_id: &str,
+395        command_name: &str,
+396        arguments: Option<serde_json::Value>,
+397    ) -> Result<(), String> {
+398        let mut payload = serde_json::json!({
+399            "Name": command_name,
+400        });
+401
+402        if let Some(args) = arguments {
+403            payload["Arguments"] = args;
+404        }
+405
+406        log::info!(
+407            "[JellyfinClient] Sending GeneralCommand '{}' to session {} with payload: {}",
+408            command_name,
+409            session_id,
+410            serde_json::to_string(&payload).unwrap_or_default()
+411        );
+412
+413        self.post(&format!("/Sessions/{}/Command", session_id), &payload)
+414            .await
+415    }
+416
+417    /// Set volume on a remote session
+418    pub async fn session_set_volume(&self, session_id: String, volume: i32) -> Result<(), String> {
+419        self.send_general_command(
+420            &session_id,
+421            "SetVolume",
+422            Some(serde_json::json!({ "Volume": volume.to_string() })),
+423        )
+424        .await
+425    }
+426
+427    /// Toggle mute on a remote session
+428    pub async fn session_toggle_mute(&self, session_id: String) -> Result<(), String> {
+429        log::info!("[JellyfinClient] Toggling mute on session {}", session_id);
+430
+431        self.send_general_command(&session_id, "ToggleMute", None)
+432            .await
+433    }
+434
+435    /// Get all active sessions
+436    pub async fn get_sessions(&self) -> Result<Vec<SessionInfo>, String> {
+437        let sessions: Vec<SessionInfo> = self.get("/Sessions").await?;
+438        info!(
+439            "[JellyfinClient] Fetched {} sessions from API",
+440            sessions.len()
+441        );
+442        for session in &sessions {
+443            debug!("[JellyfinClient] Session: id={:?}, device={:?}, client={:?}, supportsRemoteControl={}",
+444                session.id, session.device_name, session.client, session.supports_remote_control);
+445        }
+446        Ok(sessions)
+447    }
+448
+449    /// Get a specific session by ID
+450    pub async fn get_session(&self, session_id: &str) -> Result<Option<SessionInfo>, String> {
+451        let sessions = self.get_sessions().await?;
+452        Ok(sessions
+453            .into_iter()
+454            .find(|s| s.id.as_deref() == Some(session_id)))
+455    }
+456
+457    // --- JellyLMS multi-room sync groups -----------------------------------
+458    //
+459    // The JellyLMS plugin exposes a REST API under `/JellyLms` for grouping LMS
+460    // players ("zones") into synchronized multi-room sync groups. Players are
+461    // addressed by MAC address; JellyTau maps a Jellyfin session to a MAC by
+462    // stripping the `lms-` prefix off the session's device id (see
+463    // LmsDeviceDiscoveryService in the jellyLMS repo, which registers each player
+464    // with deviceId = "lms-{MacAddress}").
+465
+466    /// List current LMS sync groups.
+467    pub async fn lms_get_sync_groups(&self) -> Result<Vec<LmsSyncGroup>, String> {
+468        self.get("/JellyLms/SyncGroups").await
+469    }
+470
+471    /// Fuse LMS zones: create a sync group with `master_mac` as the sync master
+472    /// and `slave_macs` joining it. The master keeps playing; slaves follow.
+473    pub async fn lms_create_sync_group(
+474        &self,
+475        master_mac: &str,
+476        slave_macs: Vec<String>,
+477    ) -> Result<(), String> {
+478        let payload = serde_json::json!({
+479            "MasterMac": master_mac,
+480            "SlaveMacs": slave_macs,
+481        });
+482        self.post("/JellyLms/SyncGroups", &payload).await
+483    }
+484
+485    /// Remove a single LMS player from whatever sync group it's in.
+486    pub async fn lms_unsync_player(&self, mac: &str) -> Result<(), String> {
+487        self.delete(&format!("/JellyLms/SyncGroups/Players/{}", mac))
+488            .await
+489    }
+490
+491    /// Dissolve an entire LMS sync group, identified by its master's MAC.
+492    pub async fn lms_dissolve_sync_group(&self, master_mac: &str) -> Result<(), String> {
+493        self.delete(&format!("/JellyLms/SyncGroups/{}", master_mac))
+494            .await
+495    }
+496
+497    /// Make a DELETE request to the Jellyfin API (used by the JellyLMS endpoints).
+498    async fn delete(&self, endpoint: &str) -> Result<(), String> {
+499        let url = format!("{}{}", self.config.server_url, endpoint);
+500
+501        log::debug!("[JellyfinClient] DELETE {}", endpoint);
+502
+503        let response = self
+504            .http_client
+505            .delete(&url)
+506            .header("X-Emby-Authorization", self.get_auth_header())
+507            .send()
+508            .await
+509            .map_err(|e| format!("Network request failed: {}", e))?;
+510
+511        let status = response.status();
+512        if !status.is_success() {
+513            let error_text = response
+514                .text()
+515                .await
+516                .unwrap_or_else(|_| "Unknown error".to_string());
+517            return Err(format!(
+518                "Jellyfin API error {}: {}",
+519                status.as_u16(),
+520                error_text
+521            ));
+522        }
+523        Ok(())
+524    }
+525}
+526
+527/// An LMS multi-room sync group, as returned by JellyLMS `/JellyLms/SyncGroups`.
+528///
+529/// Mirrors the plugin's `SyncGroup` model. The master is the sync source; slaves
+530/// follow it in lockstep.
+531#[derive(specta::Type, Debug, Clone, Deserialize, serde::Serialize)]
+532#[serde(rename_all = "camelCase")]
+533pub struct LmsSyncGroup {
+534    #[serde(alias = "MasterMac")]
+535    pub master_mac: String,
+536    #[serde(default, alias = "MasterName")]
+537    pub master_name: String,
+538    #[serde(default, alias = "SlaveMacs")]
+539    pub slave_macs: Vec<String>,
+540    #[serde(default, alias = "SlaveNames")]
+541    pub slave_names: Vec<String>,
+542}
+543
+544/// Default value for supports_remote_control when missing from API
+545/// We default to true to show all sessions. If a session explicitly doesn't
+546/// support remote control, the Jellyfin API will set this field to false.
+547fn default_true() -> bool {
+548    true
+549}
+550
+551/// Session information from Jellyfin
+552#[derive(specta::Type, Debug, Clone, Deserialize, serde::Serialize)]
+553#[serde(rename_all = "camelCase")]
+554pub struct SessionInfo {
+555    #[serde(default)]
+556    #[serde(alias = "Id")]
+557    pub id: Option<String>,
+558    #[serde(default)]
+559    #[serde(alias = "UserId")]
+560    pub user_id: Option<String>,
+561    #[serde(default)]
+562    #[serde(alias = "UserName")]
+563    pub user_name: Option<String>,
+564    #[serde(default)]
+565    #[serde(alias = "Client")]
+566    pub client: Option<String>,
+567    #[serde(default)]
+568    #[serde(alias = "DeviceName")]
+569    pub device_name: Option<String>,
+570    #[serde(default)]
+571    #[serde(alias = "DeviceId")]
+572    pub device_id: Option<String>,
+573    #[serde(default)]
+574    #[serde(alias = "ApplicationVersion")]
+575    pub application_version: Option<String>,
+576    #[serde(default)]
+577    #[serde(alias = "IsActive")]
+578    pub is_active: Option<bool>,
+579    #[serde(default)]
+580    #[serde(alias = "SupportsMediaControl")]
+581    pub supports_media_control: Option<bool>,
+582    #[serde(default = "default_true")]
+583    #[serde(alias = "SupportsRemoteControl")]
+584    pub supports_remote_control: bool,
+585    #[serde(default)]
+586    #[serde(alias = "NowPlayingItem")]
+587    pub now_playing_item: Option<NowPlayingItem>,
+588    #[serde(default)]
+589    #[serde(alias = "PlayState")]
+590    pub play_state: Option<PlayState>,
+591    #[serde(default)]
+592    #[serde(alias = "PlayableMediaTypes")]
+593    pub playable_media_types: Option<Vec<String>>,
+594    #[serde(default)]
+595    #[serde(alias = "SupportedCommands")]
+596    pub supported_commands: Option<Vec<String>>,
+597}
+598
+599#[derive(specta::Type, Debug, Clone, Deserialize, serde::Serialize)]
+600#[serde(rename_all = "camelCase")]
+601pub struct NowPlayingItem {
+602    #[serde(alias = "Id")]
+603    pub id: Option<String>,
+604    #[serde(alias = "Name")]
+605    pub name: Option<String>,
+606    #[serde(alias = "RunTimeTicks")]
+607    pub run_time_ticks: Option<i64>,
+608    #[serde(alias = "Album")]
+609    pub album: Option<String>,
+610    #[serde(alias = "AlbumId")]
+611    pub album_id: Option<String>,
+612    #[serde(alias = "AlbumArtist")]
+613    pub album_artist: Option<String>,
+614    #[serde(alias = "Artists")]
+615    pub artists: Option<Vec<String>>,
+616    #[serde(alias = "ImageTags")]
+617    pub image_tags: Option<std::collections::HashMap<String, String>>,
+618    #[serde(alias = "PrimaryImageTag")]
+619    pub primary_image_tag: Option<String>,
+620    #[serde(alias = "AlbumPrimaryImageTag")]
+621    pub album_primary_image_tag: Option<String>,
+622    #[serde(rename = "Type")]
+623    pub item_type: Option<String>,
+624}
+625
+626#[derive(specta::Type, Debug, Clone, Deserialize, serde::Serialize)]
+627#[serde(rename_all = "camelCase")]
+628pub struct PlayState {
+629    #[serde(default)]
+630    #[serde(alias = "PositionTicks")]
+631    pub position_ticks: Option<i64>,
+632    #[serde(default)]
+633    #[serde(alias = "CanSeek")]
+634    pub can_seek: Option<bool>,
+635    #[serde(default)]
+636    #[serde(alias = "IsPaused")]
+637    pub is_paused: Option<bool>,
+638    #[serde(default)]
+639    #[serde(alias = "IsMuted")]
+640    pub is_muted: Option<bool>,
+641    #[serde(default)]
+642    #[serde(alias = "VolumeLevel")]
+643    pub volume_level: Option<i32>,
+644    #[serde(default)]
+645    #[serde(alias = "RepeatMode")]
+646    pub repeat_mode: Option<String>,
+647    #[serde(default)]
+648    #[serde(alias = "ShuffleMode")]
+649    pub shuffle_mode: Option<String>,
+650}
+651
+652#[cfg(test)]
+653mod tests {
+654    use super::*;
+655
+656    #[test]
+657    fn test_auth_header_format() {
+658        let config = JellyfinConfig {
+659            server_url: "http://localhost:8096".to_string(),
+660            access_token: "test_token".to_string(),
+661            device_id: "device456".to_string(),
+662        };
+663
+664        let client = JellyfinClient::new(config).unwrap();
+665        let header = client.get_auth_header();
+666
+667        assert!(header.contains("MediaBrowser Client=\"JellyTau\""));
+668        assert!(header.contains("Token=\"test_token\""));
+669        assert!(header.contains("DeviceId=\"device456\""));
+670    }
+671}
+
\ No newline at end of file diff --git a/api/src/jellytau_lib/jellyfin/http_client.rs.html b/api/src/jellytau_lib/jellyfin/http_client.rs.html new file mode 100644 index 00000000..a86ef12a --- /dev/null +++ b/api/src/jellytau_lib/jellyfin/http_client.rs.html @@ -0,0 +1,275 @@ +http_client.rs - sourceSkip to main content

jellytau_lib/jellyfin/
http_client.rs

1use reqwest::{Client, Request, Response, StatusCode};
+2use serde::de::DeserializeOwned;
+3use std::time::Duration;
+4
+5const APP_NAME: &str = "JellyTau";
+6const APP_VERSION: &str = "0.1.0";
+7
+8// Default timeout for requests (30 seconds - large library queries can be slow)
+9const DEFAULT_TIMEOUT_MS: u64 = 30000;
+10
+11// Retry configuration - matches TypeScript exactly
+12const DEFAULT_MAX_RETRIES: u32 = 3;
+13const RETRY_DELAYS_MS: [u64; 3] = [1000, 2000, 4000]; // Exponential backoff
+14
+15/// HTTP client configuration
+16#[derive(Clone, Debug)]
+17pub struct HttpConfig {
+18    pub timeout: Duration,
+19    pub max_retries: u32,
+20}
+21
+22impl Default for HttpConfig {
+23    fn default() -> Self {
+24        Self {
+25            timeout: Duration::from_millis(DEFAULT_TIMEOUT_MS),
+26            max_retries: DEFAULT_MAX_RETRIES,
+27        }
+28    }
+29}
+30
+31/// Error classification for retry logic
+32#[derive(Debug, Clone, PartialEq)]
+33pub enum ErrorKind {
+34    Network,
+35    Authentication,
+36    Server,
+37    Client,
+38}
+39
+40/// Enhanced HTTP client with retry logic and error classification
+41#[derive(Clone)]
+42pub struct HttpClient {
+43    pub(crate) client: Client, // Make accessible within crate for custom requests
+44    config: HttpConfig,
+45}
+46
+47impl HttpClient {
+48    /// Create a new HTTP client with default configuration
+49    pub fn new(config: HttpConfig) -> Result<Self, String> {
+50        let client = Client::builder()
+51            .timeout(config.timeout)
+52            .https_only(true)
+53            .build()
+54            .map_err(|e| format!("Failed to create HTTP client: {}", e))?;
+55
+56        Ok(Self { client, config })
+57    }
+58
+59    /// Get device name based on platform
+60    fn get_device_name() -> &'static str {
+61        #[cfg(target_os = "android")]
+62        return "Android";
+63        #[cfg(target_os = "linux")]
+64        return "Linux";
+65        #[cfg(target_os = "windows")]
+66        return "Windows";
+67        #[cfg(target_os = "macos")]
+68        return "macOS";
+69        #[cfg(target_os = "ios")]
+70        return "iOS";
+71        #[cfg(not(any(
+72            target_os = "android",
+73            target_os = "linux",
+74            target_os = "windows",
+75            target_os = "macos",
+76            target_os = "ios"
+77        )))]
+78        return "Unknown";
+79    }
+80
+81    /// Build the X-Emby-Authorization header value
+82    pub fn build_auth_header(access_token: Option<&str>, device_id: &str) -> String {
+83        let mut parts = vec![
+84            format!("MediaBrowser Client=\"{}\"", APP_NAME),
+85            format!("Version=\"{}\"", APP_VERSION),
+86            format!("Device=\"{}\"", Self::get_device_name()),
+87            format!("DeviceId=\"{}\"", device_id),
+88        ];
+89
+90        if let Some(token) = access_token {
+91            parts.push(format!("Token=\"{}\"", token));
+92        }
+93
+94        parts.join(", ")
+95    }
+96
+97    /// Classify an error for retry logic
+98    pub fn classify_error(error: &reqwest::Error) -> ErrorKind {
+99        // Network errors (connection failures, timeouts, DNS failures)
+100        if error.is_timeout() || error.is_connect() {
+101            return ErrorKind::Network;
+102        }
+103
+104        // Check status code if available
+105        if let Some(status) = error.status() {
+106            if status == StatusCode::UNAUTHORIZED || status == StatusCode::FORBIDDEN {
+107                return ErrorKind::Authentication;
+108            } else if status.is_server_error() {
+109                return ErrorKind::Server;
+110            } else if status.is_client_error() {
+111                return ErrorKind::Client;
+112            }
+113        }
+114
+115        // If no status code, check error message for network-related keywords
+116        let error_msg = error.to_string().to_lowercase();
+117        if error_msg.contains("network")
+118            || error_msg.contains("connection")
+119            || error_msg.contains("timeout")
+120            || error_msg.contains("dns")
+121            || error_msg.contains("refused")
+122            || error_msg.contains("reset")
+123        {
+124            return ErrorKind::Network;
+125        }
+126
+127        // Default to client error
+128        ErrorKind::Client
+129    }
+130
+131    /// Check if a request should be retried based on the error
+132    pub fn should_retry(error: &reqwest::Error) -> bool {
+133        match Self::classify_error(error) {
+134            ErrorKind::Network => true,         // Retry network errors
+135            ErrorKind::Server => true,          // Retry 5xx server errors
+136            ErrorKind::Authentication => false, // Don't retry 401/403
+137            ErrorKind::Client => false,         // Don't retry other 4xx errors
+138        }
+139    }
+140
+141    /// Make a request with automatic retry on network errors
+142    pub async fn request_with_retry(&self, request: Request) -> Result<Response, reqwest::Error> {
+143        let max_retries = self.config.max_retries;
+144        let mut last_error: Option<reqwest::Error> = None;
+145
+146        for attempt in 0..=max_retries {
+147            // Clone the request for retry attempts
+148            // If request cannot be cloned (e.g., streaming body), we cannot retry
+149            let Some(req) = request.try_clone() else {
+150                log::warn!("[HttpClient] Request body cannot be cloned, retries not possible");
+151                return self.client.execute(request).await;
+152            };
+153
+154            match self.client.execute(req).await {
+155                Ok(response) => return Ok(response),
+156                Err(error) => {
+157                    last_error = Some(error);
+158                    let err = last_error.as_ref().unwrap();
+159
+160                    // Don't retry if it's not a retryable error
+161                    if !Self::should_retry(err) {
+162                        return Err(last_error.unwrap());
+163                    }
+164
+165                    // Don't retry on last attempt
+166                    if attempt == max_retries {
+167                        break;
+168                    }
+169
+170                    // Wait before retrying (exponential backoff)
+171                    let delay_ms = RETRY_DELAYS_MS
+172                        .get(attempt as usize)
+173                        .copied()
+174                        .unwrap_or(*RETRY_DELAYS_MS.last().unwrap());
+175
+176                    log::info!(
+177                        "[HttpClient] Retry {}/{} after {}ms (error: {})",
+178                        attempt + 1,
+179                        max_retries,
+180                        delay_ms,
+181                        err
+182                    );
+183
+184                    tokio::time::sleep(Duration::from_millis(delay_ms)).await;
+185                }
+186            }
+187        }
+188
+189        Err(last_error.unwrap())
+190    }
+191
+192    /// Make a GET request and deserialize JSON with a short timeout and no retries.
+193    ///
+194    /// Intended for the initial "connect to server" probe on the login screen:
+195    /// a wrong/unreachable URL must fail fast instead of burning through the
+196    /// default 30s-per-attempt timeout and exponential backoff retries.
+197    pub async fn get_json_fast<T: DeserializeOwned>(&self, url: &str) -> Result<T, String> {
+198        // Short timeout so an unreachable host fails quickly.
+199        const FAST_TIMEOUT: Duration = Duration::from_secs(10);
+200
+201        let request = self
+202            .client
+203            .get(url)
+204            .timeout(FAST_TIMEOUT)
+205            .build()
+206            .map_err(|e| format!("Failed to build request: {}", e))?;
+207
+208        // No retry: connection failures on a wrong URL won't succeed on retry,
+209        // they'd only multiply the wait the user sees before an error.
+210        let response = self
+211            .client
+212            .execute(request)
+213            .await
+214            .map_err(|e| format!("Request failed: {}", e))?;
+215
+216        if !response.status().is_success() {
+217            let status = response.status();
+218            let error_text = response
+219                .text()
+220                .await
+221                .unwrap_or_else(|_| "Unknown error".to_string());
+222            return Err(format!("HTTP {}: {}", status, error_text));
+223        }
+224
+225        response
+226            .json::<T>()
+227            .await
+228            .map_err(|e| format!("Failed to parse JSON: {}", e))
+229    }
+230
+231    /// Quick ping to check if a server is reachable (no retry)
+232    pub async fn ping(&self, url: &str) -> bool {
+233        let request = self
+234            .client
+235            .get(url)
+236            .timeout(Duration::from_secs(5)) // Shorter timeout for ping
+237            .build();
+238
+239        match request {
+240            Ok(req) => match self.client.execute(req).await {
+241                Ok(response) => response.status().is_success(),
+242                Err(_) => false,
+243            },
+244            Err(_) => false,
+245        }
+246    }
+247}
+248
+249#[cfg(test)]
+250mod tests {
+251    use super::*;
+252
+253    #[test]
+254    fn test_auth_header_format() {
+255        let header = HttpClient::build_auth_header(Some("test_token"), "device456");
+256        assert!(header.contains("MediaBrowser Client=\"JellyTau\""));
+257        assert!(header.contains("Token=\"test_token\""));
+258        assert!(header.contains("DeviceId=\"device456\""));
+259    }
+260
+261    #[test]
+262    fn test_auth_header_without_token() {
+263        let header = HttpClient::build_auth_header(None, "device456");
+264        assert!(header.contains("MediaBrowser Client=\"JellyTau\""));
+265        assert!(!header.contains("Token="));
+266        assert!(header.contains("DeviceId=\"device456\""));
+267    }
+268
+269    #[test]
+270    fn test_retry_delays() {
+271        // Verify retry delays match TypeScript
+272        assert_eq!(RETRY_DELAYS_MS, [1000, 2000, 4000]);
+273    }
+274}
+
\ No newline at end of file diff --git a/api/src/jellytau_lib/jellyfin/mod.rs.html b/api/src/jellytau_lib/jellyfin/mod.rs.html new file mode 100644 index 00000000..5157cd29 --- /dev/null +++ b/api/src/jellytau_lib/jellyfin/mod.rs.html @@ -0,0 +1,8 @@ +mod.rs - sourceSkip to main content

jellytau_lib/jellyfin/
mod.rs

1pub mod client;
+2pub mod http_client;
+3pub mod types;
+4
+5pub use client::{JellyfinClient, NowPlayingItem};
+6pub use http_client::{HttpClient, HttpConfig};
+7pub use types::*;
+
\ No newline at end of file diff --git a/api/src/jellytau_lib/jellyfin/types.rs.html b/api/src/jellytau_lib/jellyfin/types.rs.html new file mode 100644 index 00000000..9bf3a812 --- /dev/null +++ b/api/src/jellytau_lib/jellyfin/types.rs.html @@ -0,0 +1,278 @@ +types.rs - sourceSkip to main content

jellytau_lib/jellyfin/
types.rs

1use serde::Serialize;
+2
+3/// Configuration for Jellyfin API client
+4#[derive(Debug, Clone)]
+5pub struct JellyfinConfig {
+6    pub server_url: String,
+7    pub access_token: String,
+8    pub device_id: String,
+9}
+10
+11/// Request body for reporting playback start
+12#[derive(Debug, Serialize)]
+13#[serde(rename_all = "PascalCase")]
+14pub struct PlaybackStartRequest {
+15    pub item_id: String,
+16    pub position_ticks: i64,
+17    #[serde(skip_serializing_if = "Option::is_none")]
+18    pub play_session_id: Option<String>,
+19    pub play_command: String,
+20    pub is_paused: bool,
+21}
+22
+23/// Request body for reporting playback stopped
+24#[derive(Debug, Serialize)]
+25#[serde(rename_all = "PascalCase")]
+26pub struct PlaybackStoppedRequest {
+27    pub item_id: String,
+28    pub position_ticks: i64,
+29    #[serde(skip_serializing_if = "Option::is_none")]
+30    pub play_session_id: Option<String>,
+31}
+32
+33/// Request body for reporting playback progress
+34#[derive(Debug, Serialize)]
+35#[serde(rename_all = "PascalCase")]
+36#[allow(dead_code)] // Will be used when playback_reporting is integrated
+37pub struct PlaybackProgressRequest {
+38    pub item_id: String,
+39    pub position_ticks: i64,
+40    pub is_paused: bool,
+41    #[serde(skip_serializing_if = "Option::is_none")]
+42    pub play_session_id: Option<String>,
+43}
+44
+45#[cfg(test)]
+46mod tests {
+47    use super::*;
+48
+49    #[test]
+50    fn test_jellyfin_config_creation() {
+51        let config = JellyfinConfig {
+52            server_url: "https://jellyfin.example.com".to_string(),
+53            access_token: "token-123".to_string(),
+54            device_id: "device-456".to_string(),
+55        };
+56
+57        assert_eq!(config.server_url, "https://jellyfin.example.com");
+58        assert_eq!(config.access_token, "token-123");
+59        assert_eq!(config.device_id, "device-456");
+60    }
+61
+62    #[test]
+63    fn test_jellyfin_config_clone() {
+64        let config = JellyfinConfig {
+65            server_url: "https://server.local".to_string(),
+66            access_token: "token-abc".to_string(),
+67            device_id: "device-xyz".to_string(),
+68        };
+69
+70        let cloned = config.clone();
+71        assert_eq!(config.server_url, cloned.server_url);
+72        assert_eq!(config.access_token, cloned.access_token);
+73        assert_eq!(config.device_id, cloned.device_id);
+74    }
+75
+76    #[test]
+77    fn test_playback_start_request_serialization() {
+78        let request = PlaybackStartRequest {
+79            item_id: "item-123".to_string(),
+80            position_ticks: 0,
+81            play_session_id: Some("session-456".to_string()),
+82            play_command: "PlayNow".to_string(),
+83            is_paused: false,
+84        };
+85
+86        let json = serde_json::to_string(&request);
+87        assert!(json.is_ok());
+88        let serialized = json.unwrap();
+89        assert!(serialized.contains("item-123"));
+90        assert!(serialized.contains("PlayNow"));
+91    }
+92
+93    #[test]
+94    fn test_playback_start_request_without_session() {
+95        let request = PlaybackStartRequest {
+96            item_id: "item-789".to_string(),
+97            position_ticks: 1_000_000,
+98            play_session_id: None,
+99            play_command: "Resume".to_string(),
+100            is_paused: true,
+101        };
+102
+103        let json = serde_json::to_string(&request).unwrap();
+104        assert!(json.contains("true"));
+105        assert!(!json.contains("playSessionId"));
+106    }
+107
+108    #[test]
+109    fn test_playback_start_request_pascal_case() {
+110        let request = PlaybackStartRequest {
+111            item_id: "item-1".to_string(),
+112            position_ticks: 100,
+113            play_session_id: Some("session-1".to_string()),
+114            play_command: "Play".to_string(),
+115            is_paused: false,
+116        };
+117
+118        let json = serde_json::to_string(&request).unwrap();
+119        // Verify PascalCase serialization
+120        assert!(json.contains("ItemId"));
+121        assert!(json.contains("PositionTicks"));
+122        assert!(json.contains("PlaySessionId"));
+123        assert!(json.contains("PlayCommand"));
+124        assert!(json.contains("IsPaused"));
+125    }
+126
+127    #[test]
+128    fn test_playback_stopped_request_serialization() {
+129        let request = PlaybackStoppedRequest {
+130            item_id: "item-555".to_string(),
+131            position_ticks: 5_000_000,
+132            play_session_id: Some("session-789".to_string()),
+133        };
+134
+135        let json = serde_json::to_string(&request);
+136        assert!(json.is_ok());
+137        let serialized = json.unwrap();
+138        assert!(serialized.contains("item-555"));
+139        assert!(serialized.contains("5000000"));
+140    }
+141
+142    #[test]
+143    fn test_playback_stopped_request_without_session() {
+144        let request = PlaybackStoppedRequest {
+145            item_id: "item-000".to_string(),
+146            position_ticks: 0,
+147            play_session_id: None,
+148        };
+149
+150        let json = serde_json::to_string(&request).unwrap();
+151        assert!(json.contains("item-000"));
+152        assert!(!json.contains("playSessionId"));
+153    }
+154
+155    #[test]
+156    fn test_playback_stopped_request_pascal_case() {
+157        let request = PlaybackStoppedRequest {
+158            item_id: "i1".to_string(),
+159            position_ticks: 500,
+160            play_session_id: Some("s1".to_string()),
+161        };
+162
+163        let json = serde_json::to_string(&request).unwrap();
+164        assert!(json.contains("ItemId"));
+165        assert!(json.contains("PositionTicks"));
+166        assert!(json.contains("PlaySessionId"));
+167    }
+168
+169    #[test]
+170    fn test_playback_progress_request_serialization() {
+171        let request = PlaybackProgressRequest {
+172            item_id: "item-999".to_string(),
+173            position_ticks: 150_000_000,
+174            is_paused: false,
+175            play_session_id: Some("session-111".to_string()),
+176        };
+177
+178        let json = serde_json::to_string(&request);
+179        assert!(json.is_ok());
+180        let serialized = json.unwrap();
+181        assert!(serialized.contains("item-999"));
+182        assert!(serialized.contains("150000000"));
+183        assert!(serialized.contains("false"));
+184    }
+185
+186    #[test]
+187    fn test_playback_progress_request_paused() {
+188        let request = PlaybackProgressRequest {
+189            item_id: "item-paused".to_string(),
+190            position_ticks: 75_000_000,
+191            is_paused: true,
+192            play_session_id: None,
+193        };
+194
+195        let json = serde_json::to_string(&request).unwrap();
+196        assert!(json.contains("true"));
+197        assert!(json.contains("item-paused"));
+198    }
+199
+200    #[test]
+201    fn test_playback_progress_request_pascal_case() {
+202        let request = PlaybackProgressRequest {
+203            item_id: "i2".to_string(),
+204            position_ticks: 200,
+205            is_paused: true,
+206            play_session_id: Some("s2".to_string()),
+207        };
+208
+209        let json = serde_json::to_string(&request).unwrap();
+210        assert!(json.contains("ItemId"));
+211        assert!(json.contains("PositionTicks"));
+212        assert!(json.contains("IsPaused"));
+213        assert!(json.contains("PlaySessionId"));
+214    }
+215
+216    #[test]
+217    fn test_playback_start_request_various_position_values() {
+218        let positions = vec![0, 1_000_000, 10_000_000, 100_000_000, 1_000_000_000];
+219
+220        for pos in positions {
+221            let request = PlaybackStartRequest {
+222                item_id: "item-test".to_string(),
+223                position_ticks: pos,
+224                play_session_id: None,
+225                play_command: "Play".to_string(),
+226                is_paused: false,
+227            };
+228
+229            let json = serde_json::to_string(&request).unwrap();
+230            assert!(json.contains("item-test"));
+231            assert!(json.contains(&pos.to_string()));
+232        }
+233    }
+234
+235    #[test]
+236    fn test_jellyfin_config_debug() {
+237        let config = JellyfinConfig {
+238            server_url: "https://debug.example.com".to_string(),
+239            access_token: "token-debug".to_string(),
+240            device_id: "device-debug".to_string(),
+241        };
+242
+243        let debug_str = format!("{:?}", config);
+244        assert!(debug_str.contains("JellyfinConfig"));
+245        assert!(debug_str.contains("https://debug.example.com"));
+246    }
+247
+248    #[test]
+249    fn test_playback_requests_creation_patterns() {
+250        // Test various creation patterns
+251        let start = PlaybackStartRequest {
+252            item_id: "i1".to_string(),
+253            position_ticks: 0,
+254            play_session_id: None,
+255            play_command: "PlayNow".to_string(),
+256            is_paused: false,
+257        };
+258
+259        let stopped = PlaybackStoppedRequest {
+260            item_id: "i1".to_string(),
+261            position_ticks: 1_000_000,
+262            play_session_id: None,
+263        };
+264
+265        let progress = PlaybackProgressRequest {
+266            item_id: "i1".to_string(),
+267            position_ticks: 500_000,
+268            is_paused: false,
+269            play_session_id: None,
+270        };
+271
+272        // All should serialize successfully
+273        assert!(serde_json::to_string(&start).is_ok());
+274        assert!(serde_json::to_string(&stopped).is_ok());
+275        assert!(serde_json::to_string(&progress).is_ok());
+276    }
+277}
+
\ No newline at end of file diff --git a/api/src/jellytau_lib/lib.rs.html b/api/src/jellytau_lib/lib.rs.html new file mode 100644 index 00000000..69583c75 --- /dev/null +++ b/api/src/jellytau_lib/lib.rs.html @@ -0,0 +1,1603 @@ +lib.rs - sourceSkip to main content

jellytau_lib/
lib.rs

1#[cfg(target_os = "android")]
+2mod android_context;
+3mod auth;
+4mod commands;
+5mod connectivity;
+6mod credentials;
+7mod domain;
+8mod download;
+9mod jellyfin;
+10mod media_server;
+11mod playback_mode;
+12mod playback_reporting;
+13mod player;
+14mod repository;
+15mod session_poller;
+16pub mod settings;
+17mod storage;
+18mod thumbnail;
+19pub mod utils;
+20
+21#[cfg(target_os = "android")]
+22use log::warn;
+23use log::{error, info};
+24use std::sync::{Arc, Mutex};
+25use tauri::{Emitter, Manager};
+26use tauri_specta::Builder;
+27use tokio::sync::Mutex as TokioMutex;
+28
+29use auth::AuthManager;
+30use commands::{
+31    auth_connect_to_server,
+32    auth_get_session,
+33    // Auth commands
+34    auth_initialize,
+35    auth_login,
+36    auth_logout,
+37    auth_reauthenticate,
+38    auth_set_session,
+39    auth_start_verification,
+40    auth_stop_verification,
+41    auth_verify_session,
+42    calc_progress,
+43    cancel_download,
+44    catalog_sync_status,
+45    clear_stale_downloads,
+46    // Connectivity commands
+47    connectivity_check_server,
+48    connectivity_get_status,
+49    connectivity_mark_reachable,
+50    connectivity_mark_unreachable,
+51    connectivity_set_server_url,
+52    connectivity_start_monitoring,
+53    connectivity_stop_monitoring,
+54    convert_percent_to_volume,
+55    convert_ticks_to_seconds,
+56    delete_album_downloads,
+57    delete_all_downloads,
+58    delete_download,
+59    delete_downloads_under,
+60    // Device commands
+61    device_get_id,
+62    device_set_id,
+63    // Diagnostics commands
+64    diagnostics_export,
+65    diagnostics_get_info,
+66    diagnostics_set_level,
+67    download_album,
+68    download_item,
+69    download_item_and_start,
+70    download_season,
+71    download_series,
+72    download_video,
+73    enqueue_download,
+74    enqueue_video_downloads,
+75    // Conversion commands
+76    format_time_seconds,
+77    format_time_seconds_long,
+78    get_album_affinity_status,
+79    get_album_recommendations,
+80    get_download_manager_stats,
+81    get_download_storage_stats,
+82    get_downloads,
+83    get_downloads_allowed,
+84    get_smart_cache_config,
+85    get_smart_cache_stats,
+86    image_get_url,
+87    is_item_pinned,
+88    // Library browsing preferences (hidden folders)
+89    library_get_exclusion_candidates,
+90    library_get_settings,
+91    library_set_settings,
+92    lms_create_sync_group,
+93    lms_dissolve_sync_group,
+94    // LMS multi-room sync group commands
+95    lms_get_sync_groups,
+96    lms_unsync_player,
+97    mark_download_completed,
+98    mark_download_failed,
+99    media_local_url,
+100    offline_get_items,
+101    offline_is_available,
+102    offline_search,
+103    pause_download,
+104    pin_item,
+105    playback_mark_played,
+106    // Playback mode commands
+107    playback_mode_get_current,
+108    playback_mode_get_remote_status,
+109    playback_mode_is_transferring,
+110    playback_mode_set,
+111    playback_mode_set_transferring,
+112    playback_mode_transfer_to_local,
+113    playback_mode_transfer_to_remote,
+114    playback_report_progress,
+115    playback_report_start,
+116    playback_report_stopped,
+117    playback_reporter_destroy,
+118    // Playback reporting commands
+119    playback_reporter_init,
+120    // Queue manipulation commands
+121    player_add_to_queue,
+122    player_add_track_by_id,
+123    player_add_tracks_by_ids,
+124    player_background_action,
+125    player_cancel_autoplay_countdown,
+126    player_cancel_sleep_timer,
+127    // Jellyfin reporting commands
+128    player_configure_jellyfin,
+129    player_cycle_repeat,
+130    player_disable_jellyfin,
+131    player_dismiss_session,
+132    player_enter_background_audio,
+133    player_exit_background_audio,
+134    player_get_audio_settings,
+135    player_get_autoplay_settings,
+136    player_get_cache_config,
+137    player_get_capabilities,
+138    player_get_eq_presets,
+139    player_get_queue,
+140    // Session management commands
+141    player_get_session,
+142    player_get_sleep_timer,
+143    player_get_status,
+144    player_get_streaming_qualities,
+145    player_get_video_settings,
+146    // Preload commands
+147    player_local_media_path,
+148    player_move_in_queue,
+149    player_next,
+150    player_on_playback_ended,
+151    player_pause,
+152    player_play,
+153    player_play_album_track,
+154    player_play_item,
+155    player_play_next_episode,
+156    player_play_queue,
+157    player_play_tracks,
+158    player_preload_upcoming,
+159    player_previous,
+160    player_recover_stream,
+161    player_remove_from_queue,
+162    player_report_media_loaded,
+163    player_report_position,
+164    // HTML5 video state-report commands
+165    player_report_state,
+166    player_seek,
+167    player_seek_video,
+168    player_set_audio_settings,
+169    player_set_audio_track,
+170    player_set_autoplay_settings,
+171    player_set_cache_config,
+172    // Sleep timer and autoplay commands
+173    player_set_sleep_timer,
+174    player_set_stream_quality,
+175    player_set_subtitle_track,
+176    player_set_video_settings,
+177    player_set_volume,
+178    player_skip_to,
+179    player_stop,
+180    player_switch_audio_track,
+181    player_toggle,
+182    player_toggle_mute,
+183    player_toggle_shuffle,
+184    playlist_add_items,
+185    // Playlist commands
+186    playlist_create,
+187    playlist_delete,
+188    playlist_get_items,
+189    playlist_move_item,
+190    playlist_remove_items,
+191    playlist_rename,
+192    // Remote session control commands
+193    remote_play_on_session,
+194    remote_send_command,
+195    remote_session_seek,
+196    remote_session_set_volume,
+197    remote_session_toggle_mute,
+198    // Repository commands
+199    repository_clear_watch_history,
+200    repository_create,
+201    repository_destroy,
+202    repository_get_audio_only_stream_url_for_video,
+203    repository_get_audio_stream_url,
+204    repository_get_channels,
+205    repository_get_download_disk_usage,
+206    repository_get_downloaded_items,
+207    repository_get_downloaded_libraries,
+208    repository_get_favorites,
+209    repository_get_genres,
+210    repository_get_image_url,
+211    repository_get_item,
+212    repository_get_items,
+213    repository_get_items_by_person,
+214    repository_get_latest_items,
+215    repository_get_libraries,
+216    repository_get_live_tv_channels,
+217    repository_get_next_up_episodes,
+218    repository_get_person,
+219    repository_get_playback_info,
+220    repository_get_recently_played_audio,
+221    repository_get_rediscover_albums,
+222    repository_get_resume_items,
+223    repository_get_resume_movies,
+224    repository_get_series_current_episode,
+225    repository_get_series_episodes,
+226    repository_get_similar_items,
+227    repository_get_subtitle_url,
+228    repository_get_video_download_url,
+229    repository_get_video_stream_url,
+230    repository_jray_actors_at,
+231    repository_mark_favorite,
+232    repository_open_live_stream,
+233    repository_report_playback_progress,
+234    repository_report_playback_start,
+235    repository_report_playback_stopped,
+236    repository_search,
+237    repository_unmark_favorite,
+238    resume_download,
+239    resume_queued_downloads,
+240    sessions_poll_now,
+241    // Session polling commands
+242    sessions_set_polling_hint,
+243    set_max_concurrent_downloads,
+244    set_network_state,
+245    set_show_server_catalog,
+246    start_download,
+247    // Storage commands
+248    storage_delete_server,
+249    storage_delete_user,
+250    storage_get_access_token,
+251    storage_get_active_session,
+252    storage_get_active_user,
+253    storage_get_item,
+254    storage_get_item_people,
+255    storage_get_items,
+256    // Offline cache commands
+257    storage_get_libraries,
+258    storage_get_path,
+259    storage_get_pending_sync_count,
+260    storage_get_person,
+261    storage_get_playback_progress,
+262    storage_get_security_status,
+263    storage_get_series_audio_preference,
+264    storage_get_servers,
+265    storage_get_size,
+266    storage_get_users,
+267    storage_init,
+268    storage_mark_played,
+269    storage_mark_synced,
+270    storage_save_item,
+271    storage_save_item_people,
+272    storage_save_library,
+273    // People cache commands
+274    storage_save_person,
+275    // Series audio preferences
+276    storage_save_series_audio_preference,
+277    storage_save_server,
+278    storage_save_user,
+279    storage_search_items,
+280    storage_set_active_user,
+281    storage_set_watched,
+282    storage_toggle_favorite,
+283    storage_update_playback_context,
+284    storage_update_playback_progress,
+285    sync_cleanup_completed,
+286    sync_clear_user,
+287    sync_full_catalog,
+288    sync_get_pending,
+289    sync_get_pending_count,
+290    sync_mark_completed,
+291    sync_mark_failed,
+292    sync_mark_processing,
+293    sync_process_pending,
+294    // Sync queue commands
+295    sync_queue_mutation,
+296    thumbnail_clear_cache,
+297    thumbnail_delete_item,
+298    // Thumbnail cache and image commands
+299    thumbnail_get_cached,
+300    thumbnail_get_stats,
+301    thumbnail_save,
+302    thumbnail_set_limit,
+303    unpin_item,
+304    update_smart_cache_config,
+305    AuthManagerWrapper,
+306    ConnectivityMonitorWrapper,
+307    CredentialStoreWrapper,
+308    DatabaseWrapper,
+309    DownloadManagerWrapper,
+310    MediaSessionManagerWrapper,
+311    PlaybackModeManagerWrapper,
+312    PlaybackReporterWrapper,
+313    PlayerStateWrapper,
+314    RepositoryManagerWrapper,
+315    SessionPollerWrapper,
+316    SessionVerifierWrapper,
+317    SmartCacheWrapper,
+318    ThumbnailCacheWrapper,
+319    VideoSettingsWrapper,
+320};
+321use connectivity::ConnectivityMonitor;
+322use credentials::CredentialStore;
+323use download::cache::{CacheConfig as SmartCacheConfig, SmartCache};
+324use download::DownloadManager;
+325use jellyfin::{HttpClient, HttpConfig};
+326#[cfg(target_os = "android")]
+327use playback_mode::PlaybackModeManager;
+328// Only the Android MediaSessionHandler resolves lockscreen skips; on other
+329// targets this would be an unused import.
+330#[cfg(target_os = "android")]
+331use player::seek::{resolve_skip_action, SkipAction};
+332use player::{MediaSessionManager, PlayerBackend, PlayerController, TauriEventEmitter};
+333// NullBackend is used both for platforms without a native backend AND as a graceful
+334// fallback when a native backend (MPV/ExoPlayer) fails to initialize, so the app can
+335// still launch (browse library, manage downloads, see an error) instead of crashing.
+336use player::NullBackend;
+337
+338#[cfg(target_os = "linux")]
+339use player::MpvBackend;
+340use settings::VideoSettings;
+341use storage::Database;
+342use thumbnail::{CacheConfig as ThumbnailCacheConfig, ThumbnailCache};
+343
+344#[cfg(target_os = "android")]
+345use credentials::initialize_secure_storage;
+346
+347#[cfg(target_os = "android")]
+348use player::ExoPlayerBackend;
+349
+350#[cfg(target_os = "android")]
+351use player::{
+352    set_media_command_handler, set_remote_volume_handler, MediaCommandHandler, RemoteVolumeHandler,
+353};
+354
+355/// Handler for media commands from Android MediaSession (lockscreen/notification controls).
+356///
+357/// Routes commands from the system media controls to the right place depending on
+358/// playback mode: in local mode it drives the local `PlayerController`; in remote
+359/// (cast) mode it forwards transport commands to the remote Jellyfin session so
+360/// the lockscreen can control whatever is casting. Stop while casting requests a
+361/// disconnect back to local playback.
+362#[cfg(target_os = "android")]
+363struct MediaSessionHandler {
+364    player: Arc<TokioMutex<PlayerController>>,
+365    playback_mode: Arc<PlaybackModeManager>,
+366    event_emitter: Arc<TauriEventEmitter>,
+367}
+368
+369#[cfg(target_os = "android")]
+370impl MediaSessionHandler {
+371    /// Forward a transport command to the active remote Jellyfin session.
+372    ///
+373    /// Runs async on the Tauri runtime because JNI callbacks arrive on arbitrary
+374    /// threads without a Tokio context.
+375    fn handle_remote_command(&self, command: &str, session_id: String) {
+376        use crate::player::{PlayerEventEmitter, PlayerStatusEvent};
+377
+378        // Stop while casting means "disconnect and resume locally". The frontend
+379        // owns the remote->local transfer (it reloads the item locally), so we
+380        // just signal intent.
+381        if command == "stop" {
+382            self.event_emitter
+383                .emit(PlayerStatusEvent::RemoteDisconnectRequested);
+384            return;
+385        }
+386
+387        let jellyfin_client = {
+388            let player = self.player.blocking_lock();
+389            player.jellyfin_client()
+390        };
+391        let command = command.to_string();
+392
+393        tauri::async_runtime::spawn(async move {
+394            let client = {
+395                let guard = match jellyfin_client.lock() {
+396                    Ok(g) => g,
+397                    Err(e) => {
+398                        error!("[MediaSession] Failed to lock Jellyfin client: {}", e);
+399                        return;
+400                    }
+401                };
+402                match guard.as_ref() {
+403                    Some(c) => c.clone(),
+404                    None => {
+405                        warn!("[MediaSession] No Jellyfin client for remote command");
+406                        return;
+407                    }
+408                }
+409            };
+410
+411            // Map lockscreen transport commands onto Jellyfin session commands.
+412            let result = match command.as_str() {
+413                "play" => client.send_session_command(session_id, "Unpause").await,
+414                "pause" => client.send_session_command(session_id, "Pause").await,
+415                "next" => client.send_session_command(session_id, "NextTrack").await,
+416                "previous" => {
+417                    client
+418                        .send_session_command(session_id, "PreviousTrack")
+419                        .await
+420                }
+421                cmd if cmd.starts_with("seek:") => match cmd[5..].parse::<f64>() {
+422                    Ok(seconds) => {
+423                        let ticks = (seconds * 10_000_000.0) as i64;
+424                        client.session_seek(session_id, ticks).await
+425                    }
+426                    Err(_) => {
+427                        warn!("[MediaSession] Bad seek command: {}", command);
+428                        Ok(())
+429                    }
+430                },
+431                _ => {
+432                    warn!("[MediaSession] Unknown remote command: {}", command);
+433                    Ok(())
+434                }
+435            };
+436
+437            if let Err(e) = result {
+438                error!("[MediaSession] Remote command '{}' failed: {}", command, e);
+439            }
+440        });
+441    }
+442
+443    /// Drive the local player for a transport command.
+444    fn handle_local_command(&self, command: &str) {
+445        // A lockscreen scrub is an ABSOLUTE position — the scrubber shows the
+446        // whole episode — and resolving it during a background-audio handoff means
+447        // re-opening the stream, which is async. So it runs on the runtime and,
+448        // critically, is handled *before* the blocking lock below: taking that
+449        // guard and then spawning a task that waits for the same mutex would
+450        // deadlock the media session. (DR-159)
+451        if let Some(raw) = command.strip_prefix("seek:") {
+452            match raw.parse::<f64>() {
+453                Ok(position) => {
+454                    let player = self.player.clone();
+455                    tokio::spawn(async move {
+456                        let controller = player.lock().await;
+457                        if let Err(e) = controller.seek_absolute(position).await {
+458                            error!("[MediaSession] Seek to {:.1}s failed: {}", position, e);
+459                        }
+460                    });
+461                }
+462                Err(_) => warn!("[MediaSession] Bad seek command: {}", command),
+463            }
+464            return;
+465        }
+466
+467        // Skip means different things depending on what is actually playing, so
+468        // the decision belongs here rather than in the Kotlin that drew the
+469        // button: music advances the queue, while a video whose audio is running
+470        // through a background-audio handoff scrubs instead (UR-040). Routed
+471        // through the same spawn-and-seek path as "seek:" above, because
+472        // `seek_absolute` rebuilds the stream during a handoff and must not run
+473        // under the blocking lock (DR-159).
+474        //
+475        // TRACES: UR-040, UR-006 | DR-201
+476        if command == "next" || command == "previous" {
+477            let is_next = command == "next";
+478            let player = self.player.clone();
+479            tokio::spawn(async move {
+480                let controller = player.lock().await;
+481                let action = resolve_skip_action(
+482                    is_next,
+483                    controller.is_background_audio_active(),
+484                    controller.position(),
+485                    controller.duration(),
+486                );
+487                let label = if is_next { "next" } else { "previous" };
+488                let result: Result<(), String> = match action {
+489                    SkipAction::Advance => if is_next {
+490                        controller.next()
+491                    } else {
+492                        controller.previous()
+493                    }
+494                    .map_err(|e| e.to_string()),
+495                    SkipAction::SeekTo(position) => {
+496                        info!(
+497                            "[MediaSession] Background audio: '{}' scrubs to {:.1}s",
+498                            label, position
+499                        );
+500                        controller.seek_absolute(position).await
+501                    }
+502                };
+503                if let Err(e) = result {
+504                    error!("[MediaSession] Skip '{}' failed: {}", label, e);
+505                }
+506            });
+507            return;
+508        }
+509
+510        // Use blocking_lock since this is called from a non-async JNI callback
+511        let controller = self.player.blocking_lock();
+512
+513        let result = match command {
+514            "play" => controller.play(),
+515            "pause" => controller.pause(),
+516            "stop" => controller.stop(),
+517            _ => {
+518                warn!("[MediaSession] Unknown command: {}", command);
+519                Ok(())
+520            }
+521        };
+522
+523        if let Err(e) = result {
+524            error!("[MediaSession] Command '{}' failed: {}", command, e);
+525        }
+526    }
+527}
+528
+529#[cfg(target_os = "android")]
+530impl MediaCommandHandler for MediaSessionHandler {
+531    fn on_command(&self, command: &str) {
+532        match self.playback_mode.get_mode() {
+533            playback_mode::PlaybackMode::Remote { session_id } => {
+534                self.handle_remote_command(command, session_id);
+535            }
+536            _ => self.handle_local_command(command),
+537        }
+538    }
+539}
+540
+541/// Handler for remote volume changes from Android volume buttons when in remote playback mode.
+542///
+543/// Routes volume commands to the Jellyfin session via the playback mode manager.
+544#[cfg(target_os = "android")]
+545struct RemoteVolumeSessionHandler {
+546    playback_mode: Arc<PlaybackModeManager>,
+547}
+548
+549#[cfg(target_os = "android")]
+550impl RemoteVolumeHandler for RemoteVolumeSessionHandler {
+551    fn on_remote_volume_change(&self, command: &str, volume: i32) {
+552        log::info!("[RemoteVolume] Command: {}, Volume: {}", command, volume);
+553
+554        // Send the volume command to the remote session asynchronously
+555        let playback_mode = Arc::clone(&self.playback_mode);
+556        let command_str = command.to_string();
+557
+558        // Use tauri::async_runtime::spawn instead of tokio::spawn
+559        // JNI callbacks happen on arbitrary threads without a Tokio runtime
+560        log::info!("[RemoteVolume] Spawning async task to send volume command...");
+561        tauri::async_runtime::spawn(async move {
+562            log::info!("[RemoteVolume] Async task started, calling send_remote_volume_command...");
+563            match playback_mode
+564                .send_remote_volume_command(&command_str, volume)
+565                .await
+566            {
+567                Ok(_) => log::info!("[RemoteVolume] Volume command completed successfully"),
+568                Err(e) => log::error!("[RemoteVolume] Failed to send volume command: {}", e),
+569            }
+570            log::info!("[RemoteVolume] Async task completed");
+571        });
+572        log::info!("[RemoteVolume] Async task spawned, returning from JNI callback");
+573    }
+574}
+575
+576/// Payload emitted to the frontend when a native player backend fails to
+577/// initialize and the app falls back to a no-op backend.
+578#[derive(Clone, serde::Serialize)]
+579struct BackendInitError {
+580    platform: &'static str,
+581    backend: &'static str,
+582    message: String,
+583}
+584
+585/// Log a backend-initialization failure and notify the frontend, so the UI can
+586/// surface "playback unavailable" instead of the app hard-crashing.
+587fn emit_backend_init_failed(app_handle: &tauri::AppHandle, backend: &'static str, message: String) {
+588    error!(
+589        "[INIT] Player backend '{}' failed to initialize: {}. Falling back to NullBackend (playback disabled).",
+590        backend, message
+591    );
+592    let _ = app_handle.emit(
+593        "backend-init-failed",
+594        BackendInitError {
+595            platform: std::env::consts::OS,
+596            backend,
+597            message,
+598        },
+599    );
+600}
+601
+602/// Create the appropriate player backend for the current platform.
+603// playback_reporter/position_throttler are consumed only by the native audio
+604// backends (mpv/exo); on platforms using the webview audio backend they're unused.
+605#[cfg_attr(
+606    not(any(target_os = "linux", target_os = "android")),
+607    allow(unused_variables)
+608)]
+609fn create_player_backend(
+610    app_handle: tauri::AppHandle,
+611    playback_reporter: Arc<tokio::sync::Mutex<Option<playback_reporting::PlaybackReporter>>>,
+612    position_throttler: Arc<playback_reporting::EventThrottler>,
+613) -> Box<dyn PlayerBackend> {
+614    let _event_emitter = Arc::new(TauriEventEmitter::new(app_handle.clone()));
+615
+616    #[cfg(target_os = "android")]
+617    {
+618        info!("Android platform detected - initializing ExoPlayer backend");
+619
+620        // Same precondition as the credential path: ndk_context must be
+621        // populated before it is read, and nothing outside this crate populates
+622        // it any more. Idempotent, so it does not matter which of the two runs
+623        // first. TRACES: UR-012 | DR-223
+624        if let Err(e) = crate::android_context::ensure_initialized() {
+625            log::error!("[INIT] Android context unavailable for the player: {e}");
+626        }
+627
+628        // Get the Android context via ndk-context
+629        let ctx = ndk_context::android_context();
+630
+631        // Get JavaVM and create JNI environment
+632        let vm = unsafe { jni::JavaVM::from_raw(ctx.vm().cast()) };
+633
+634        match vm {
+635            Ok(java_vm) => {
+636                match java_vm.attach_current_thread() {
+637                    Ok(mut env) => {
+638                        let context_obj =
+639                            unsafe { jni::objects::JObject::from_raw(ctx.context().cast()) };
+640
+641                        match ExoPlayerBackend::new(
+642                            &mut env,
+643                            &context_obj,
+644                            _event_emitter.clone(),
+645                            playback_reporter.clone(),
+646                            position_throttler.clone(),
+647                        ) {
+648                            Ok(backend) => {
+649                                info!("Successfully initialized ExoPlayer backend for Android");
+650                                return Box::new(backend);
+651                            }
+652                            Err(e) => {
+653                                // Degrade gracefully instead of crashing the app.
+654                                emit_backend_init_failed(&app_handle, "exoplayer", e.to_string());
+655                                return Box::new(NullBackend::new());
+656                            }
+657                        }
+658                    }
+659                    Err(e) => {
+660                        emit_backend_init_failed(
+661                            &app_handle,
+662                            "exoplayer",
+663                            format!("attach JNI thread failed: {}", e),
+664                        );
+665                        return Box::new(NullBackend::new());
+666                    }
+667                }
+668            }
+669            Err(e) => {
+670                emit_backend_init_failed(
+671                    &app_handle,
+672                    "exoplayer",
+673                    format!("create JavaVM failed: {}", e),
+674                );
+675                return Box::new(NullBackend::new());
+676            }
+677        }
+678    }
+679
+680    // For Linux, use MPV backend for audio playback
+681    #[cfg(target_os = "linux")]
+682    {
+683        info!("Linux platform detected - initializing MPV backend for audio");
+684        match MpvBackend::new(Some(_event_emitter), playback_reporter, position_throttler) {
+685            Ok(backend) => {
+686                info!("Successfully initialized MPV backend for Linux");
+687                Box::new(backend)
+688            }
+689            Err(e) => {
+690                error!("\n========================================");
+691                error!("FATAL ERROR: Failed to initialize MPV backend");
+692                error!("========================================");
+693                error!("Error: {}", e);
+694                error!("\nCommon causes:");
+695                error!("  1. MPV is not installed");
+696                error!("     Solution: Install MPV using your package manager");
+697                error!("     - Arch/CachyOS: sudo pacman -S mpv");
+698                error!("     - Ubuntu/Debian: sudo apt install mpv libmpv-dev");
+699                error!("     - Fedora: sudo dnf install mpv mpv-libs-devel");
+700                error!("\n  2. MPV version mismatch (app was built with different libmpv version)");
+701                error!("     Solution: Rebuild the application");
+702                error!("     - cd src-tauri && cargo clean && cargo build --release");
+703                error!("\n  3. Audio system not working");
+704                error!("     Solution: Verify audio works with: pactl info");
+705                error!("\nAudio playback will NOT work until this is fixed.");
+706                error!("========================================\n");
+707
+708                // Degrade gracefully: launch with a no-op backend so the user can
+709                // still browse the library and manage downloads, and the frontend
+710                // can show a "playback unavailable" notice via this event.
+711                emit_backend_init_failed(&app_handle, "mpv", e.to_string());
+712                Box::new(NullBackend::new())
+713            }
+714        }
+715    }
+716
+717    // Platforms with no native audio backend (e.g. Windows): render audio-only
+718    // playback through a webview <audio> element (all video already renders in
+719    // the webview). Falls back to NullBackend only if the backend can't init.
+720    #[cfg(not(any(target_os = "linux", target_os = "android")))]
+721    {
+722        info!("No native audio backend for this platform - using webview <audio> backend");
+723        match player::WebviewAudioBackend::new(_event_emitter) {
+724            Ok(backend) => Box::new(backend),
+725            Err(e) => {
+726                emit_backend_init_failed(&app_handle, "webview-audio", e.to_string());
+727                Box::new(NullBackend::new())
+728            }
+729        }
+730    }
+731}
+732
+733/// Construct the tauri-specta command builder. Shared by `run()` and the
+734/// bindings-export test so the TypeScript bindings always match the handler.
+735fn specta_builder() -> Builder<tauri::Wry> {
+736    Builder::<tauri::Wry>::new()
+737        // Throw on error so generated `commands.*` return Promise<T> and throw,
+738        // matching the existing frontend's invoke() try/catch convention.
+739        .error_handling(tauri_specta::ErrorHandlingMode::Throw)
+740        .events(tauri_specta::collect_events![
+741            crate::player::events::PlayerStatusEvent
+742        ])
+743        .commands(tauri_specta::collect_commands![
+744            // Player commands
+745            player_play_item,
+746            player_background_action,
+747            player_enter_background_audio,
+748            player_exit_background_audio,
+749            player_play_queue,
+750            player_play_album_track,
+751            player_play_tracks,
+752            player_play,
+753            player_pause,
+754            player_toggle,
+755            player_stop,
+756            player_next,
+757            player_previous,
+758            player_seek,
+759            player_seek_video,
+760            player_set_volume,
+761            player_toggle_mute,
+762            player_set_audio_track,
+763            player_switch_audio_track,
+764            player_set_subtitle_track,
+765            player_toggle_shuffle,
+766            player_cycle_repeat,
+767            player_get_status,
+768            player_get_queue,
+769            player_get_capabilities,
+770            player_add_to_queue,
+771            player_add_track_by_id,
+772            player_add_tracks_by_ids,
+773            player_remove_from_queue,
+774            player_move_in_queue,
+775            player_skip_to,
+776            player_set_audio_settings,
+777            player_get_audio_settings,
+778            player_get_eq_presets,
+779            player_set_video_settings,
+780            player_get_video_settings,
+781            player_get_streaming_qualities,
+782            player_set_stream_quality,
+783            // Sleep timer and autoplay commands
+784            player_set_sleep_timer,
+785            player_cancel_sleep_timer,
+786            player_get_sleep_timer,
+787            player_get_autoplay_settings,
+788            player_set_autoplay_settings,
+789            player_cancel_autoplay_countdown,
+790            player_play_next_episode,
+791            player_on_playback_ended,
+792            player_recover_stream,
+793            player_report_state,
+794            player_report_position,
+795            player_report_media_loaded,
+796            // Preload commands
+797            player_local_media_path,
+798            player_preload_upcoming,
+799            player_set_cache_config,
+800            player_get_cache_config,
+801            // Jellyfin reporting commands
+802            player_configure_jellyfin,
+803            player_disable_jellyfin,
+804            // Session management commands
+805            player_get_session,
+806            player_dismiss_session,
+807            // Remote session control commands
+808            remote_play_on_session,
+809            remote_send_command,
+810            remote_session_seek,
+811            remote_session_set_volume,
+812            remote_session_toggle_mute,
+813            // LMS multi-room sync group commands
+814            lms_get_sync_groups,
+815            lms_create_sync_group,
+816            lms_unsync_player,
+817            lms_dissolve_sync_group,
+818            // Session polling commands
+819            sessions_set_polling_hint,
+820            sessions_poll_now,
+821            // Playback mode commands
+822            playback_mode_get_current,
+823            playback_mode_set,
+824            playback_mode_is_transferring,
+825            playback_mode_transfer_to_remote,
+826            playback_mode_get_remote_status,
+827            playback_mode_transfer_to_local,
+828            playback_mode_set_transferring,
+829            // Playback reporting commands
+830            playback_reporter_init,
+831            playback_reporter_destroy,
+832            playback_report_start,
+833            playback_report_progress,
+834            playback_report_stopped,
+835            playback_mark_played,
+836            // Auth commands
+837            auth_initialize,
+838            auth_connect_to_server,
+839            auth_login,
+840            auth_verify_session,
+841            auth_logout,
+842            auth_get_session,
+843            auth_set_session,
+844            auth_start_verification,
+845            auth_stop_verification,
+846            auth_reauthenticate,
+847            // Device commands
+848            device_get_id,
+849            device_set_id,
+850            // Connectivity commands
+851            connectivity_check_server,
+852            connectivity_set_server_url,
+853            connectivity_get_status,
+854            connectivity_start_monitoring,
+855            connectivity_stop_monitoring,
+856            connectivity_mark_reachable,
+857            connectivity_mark_unreachable,
+858            // Storage commands
+859            storage_init,
+860            storage_get_path,
+861            storage_get_size,
+862            storage_get_security_status,
+863            storage_save_server,
+864            storage_get_servers,
+865            storage_delete_server,
+866            storage_save_user,
+867            storage_get_users,
+868            storage_set_active_user,
+869            storage_get_active_user,
+870            storage_get_active_session,
+871            storage_get_access_token,
+872            storage_delete_user,
+873            // Playback progress commands
+874            storage_update_playback_progress,
+875            storage_update_playback_context,
+876            storage_mark_played,
+877            storage_set_watched,
+878            storage_get_playback_progress,
+879            storage_mark_synced,
+880            storage_toggle_favorite,
+881            // Download commands
+882            download_item,
+883            download_item_and_start,
+884            download_album,
+885            download_video,
+886            download_series,
+887            download_season,
+888            get_downloads,
+889            pause_download,
+890            resume_download,
+891            cancel_download,
+892            delete_download,
+893            delete_all_downloads,
+894            delete_album_downloads,
+895            delete_downloads_under,
+896            clear_stale_downloads,
+897            get_download_storage_stats,
+898            mark_download_completed,
+899            mark_download_failed,
+900            media_local_url,
+901            start_download,
+902            enqueue_download,
+903            enqueue_video_downloads,
+904            sync_full_catalog,
+905            catalog_sync_status,
+906            set_show_server_catalog,
+907            // Library browsing preferences (UR-076 / DR-209)
+908            library_get_settings,
+909            library_set_settings,
+910            library_get_exclusion_candidates,
+911            resume_queued_downloads,
+912            get_download_manager_stats,
+913            set_max_concurrent_downloads,
+914            get_smart_cache_stats,
+915            update_smart_cache_config,
+916            get_smart_cache_config,
+917            // WiFi-only download gate (UR-053)
+918            set_network_state,
+919            get_downloads_allowed,
+920            get_album_recommendations,
+921            get_album_affinity_status,
+922            // Pinning commands
+923            pin_item,
+924            unpin_item,
+925            is_item_pinned,
+926            // Offline commands
+927            offline_is_available,
+928            offline_get_items,
+929            offline_search,
+930            // Offline cache commands
+931            storage_get_libraries,
+932            storage_get_items,
+933            storage_get_item,
+934            storage_search_items,
+935            storage_save_library,
+936            storage_save_item,
+937            storage_get_pending_sync_count,
+938            // Sync queue commands
+939            sync_queue_mutation,
+940            sync_get_pending,
+941            sync_mark_processing,
+942            sync_mark_completed,
+943            sync_mark_failed,
+944            sync_get_pending_count,
+945            sync_process_pending,
+946            sync_cleanup_completed,
+947            sync_clear_user,
+948            // Thumbnail cache and image commands
+949            thumbnail_get_cached,
+950            thumbnail_save,
+951            thumbnail_get_stats,
+952            thumbnail_set_limit,
+953            thumbnail_clear_cache,
+954            thumbnail_delete_item,
+955            image_get_url,
+956            // People cache commands
+957            storage_save_person,
+958            storage_get_person,
+959            storage_save_item_people,
+960            storage_get_item_people,
+961            // Series audio preferences
+962            storage_save_series_audio_preference,
+963            storage_get_series_audio_preference,
+964            // Repository commands
+965            repository_create,
+966            repository_destroy,
+967            repository_get_libraries,
+968            repository_get_items,
+969            repository_get_item,
+970            repository_get_downloaded_libraries,
+971            repository_get_downloaded_items,
+972            repository_get_download_disk_usage,
+973            repository_jray_actors_at,
+974            repository_get_latest_items,
+975            repository_get_resume_items,
+976            repository_get_next_up_episodes,
+977            repository_get_series_episodes,
+978            repository_get_series_current_episode,
+979            repository_clear_watch_history,
+980            repository_get_recently_played_audio,
+981            repository_get_resume_movies,
+982            repository_get_rediscover_albums,
+983            repository_get_genres,
+984            repository_search,
+985            repository_get_playback_info,
+986            repository_get_video_stream_url,
+987            repository_get_audio_stream_url,
+988            repository_get_audio_only_stream_url_for_video,
+989            repository_get_live_tv_channels,
+990            repository_get_channels,
+991            repository_open_live_stream,
+992            repository_report_playback_start,
+993            repository_report_playback_progress,
+994            repository_report_playback_stopped,
+995            repository_get_image_url,
+996            repository_mark_favorite,
+997            repository_unmark_favorite,
+998            repository_get_favorites,
+999            repository_get_person,
+1000            repository_get_items_by_person,
+1001            repository_get_similar_items,
+1002            repository_get_subtitle_url,
+1003            repository_get_video_download_url,
+1004            // Playlist commands
+1005            playlist_create,
+1006            playlist_delete,
+1007            playlist_rename,
+1008            playlist_get_items,
+1009            playlist_add_items,
+1010            playlist_remove_items,
+1011            playlist_move_item,
+1012            // Diagnostics commands
+1013            // TRACES: UR-078 | DR-218
+1014            diagnostics_get_info,
+1015            diagnostics_set_level,
+1016            diagnostics_export,
+1017            // Conversion commands
+1018            format_time_seconds,
+1019            format_time_seconds_long,
+1020            convert_ticks_to_seconds,
+1021            calc_progress,
+1022            convert_percent_to_volume,
+1023        ])
+1024}
+1025
+1026/// Configure GStreamer (the media backend behind WebKitGTK's HTML5 `<video>`
+1027/// element on Linux) to prefer hardware-accelerated VAAPI decoding when the
+1028/// host provides it, falling back to software decoding otherwise.
+1029///
+1030/// All variables are only set if the user has not already exported them, so an
+1031/// explicit override (e.g. forcing software decode for debugging) is respected.
+1032/// They must be applied before WebKitGTK builds its GStreamer pipeline, hence the
+1033/// call at the very top of `run()`.
+1034#[cfg(target_os = "linux")]
+1035fn enable_linux_hardware_video_decoding() {
+1036    // Boost the rank of the modern stateless VAAPI decoders (gst-plugins-bad
+1037    // `va` plugin) so GStreamer selects them ahead of the software decoders. The
+1038    // `MAX` rank wins decoder autoplugging when the hardware/driver supports the
+1039    // codec; unsupported codecs simply fall through to software.
+1040    let rank_overrides = "vah264dec:MAX,vah265dec:MAX,vavp9dec:MAX,vaav1dec:MAX,\
+1041                          vampeg2dec:MAX,vavp8dec:MAX";
+1042
+1043    set_env_if_unset("GST_PLUGIN_FEATURE_RANK", rank_overrides);
+1044
+1045    // Ensure WebKit keeps GStreamer's hardware/DMABUF video path enabled. Setting
+1046    // this to "0" would force software decoding, so only default it to "1".
+1047    set_env_if_unset("WEBKIT_GST_ENABLE_HW_VIDEO_DECODER", "1");
+1048
+1049    info!("[INIT] Linux hardware video decoding (VAAPI) enabled where supported");
+1050
+1051    log_available_vaapi_decoders();
+1052}
+1053
+1054/// Probe (via `gst-inspect-1.0`, which ships with GStreamer) which VAAPI hardware
+1055/// video decoders GStreamer can actually load on this host, and log the result so
+1056/// it is clear at startup whether hardware decoding is genuinely available or
+1057/// whether playback will fall back to software.
+1058#[cfg(target_os = "linux")]
+1059fn log_available_vaapi_decoders() {
+1060    const HW_DECODERS: &[&str] = &[
+1061        "vah264dec",
+1062        "vah265dec",
+1063        "vavp9dec",
+1064        "vaav1dec",
+1065        "vampeg2dec",
+1066        "vavp8dec",
+1067    ];
+1068
+1069    let available: Vec<&str> = HW_DECODERS
+1070        .iter()
+1071        .copied()
+1072        .filter(|name| {
+1073            std::process::Command::new("gst-inspect-1.0")
+1074                .arg(name)
+1075                .stdout(std::process::Stdio::null())
+1076                .stderr(std::process::Stdio::null())
+1077                .status()
+1078                .map(|s| s.success())
+1079                .unwrap_or(false)
+1080        })
+1081        .collect();
+1082
+1083    if available.is_empty() {
+1084        log::warn!(
+1085            "[INIT] No VAAPI hardware video decoders found via gst-inspect-1.0; \
+1086             video will use software decoding. Install the GStreamer 'va' plugin \
+1087             (gst-plugins-bad) and a VAAPI driver to enable hardware decoding."
+1088        );
+1089    } else {
+1090        info!(
+1091            "[INIT] VAAPI hardware video decoders available to GStreamer: {}",
+1092            available.join(", ")
+1093        );
+1094    }
+1095}
+1096
+1097#[cfg(target_os = "linux")]
+1098fn set_env_if_unset(key: &str, value: &str) {
+1099    if std::env::var_os(key).is_none() {
+1100        // SAFETY: called once at startup before any threads that read the
+1101        // environment (WebKitGTK/GStreamer) are spawned.
+1102        std::env::set_var(key, value);
+1103    }
+1104}
+1105
+1106/// Cached thumbnails are handed to the webview as asset-protocol URLs by
+1107/// `convertFileSrc` (`asset://localhost/…` on Linux/macOS,
+1108/// `http://asset.localhost/…` on Windows/Android). Tauri only answers that
+1109/// origin when the `protocol-asset` cargo feature is compiled in *and*
+1110/// `app.security.assetProtocol.enable` is set in `tauri.conf.json`. Both are
+1111/// required together: with either missing the URL resolves to nothing and the
+1112/// webview reports `NETWORK_NO_SOURCE`, which is how offline video came to fail
+1113/// silently.
+1114///
+1115/// The scope is `$APPDATA/thumbnails/**`, not the storage root: downloaded media
+1116/// moved to the loopback media server in DR-137, so `imageCache` is the only
+1117/// remaining `convertFileSrc` caller and the database and the encrypted-token
+1118/// fallback file — which share that root — never need to be readable by the
+1119/// webview. Widen it only if something other than thumbnails starts resolving
+1120/// through `convertFileSrc` again.
+1121///
+1122/// TRACES: UR-012, UR-071 | DR-134, DR-137, DR-198
+1123/// Build the logging plugin.
+1124///
+1125/// Replaces the previous `env_logger` init, which wrote to **stdout only**. That
+1126/// was invisible to anyone who launched from a desktop icon, and worse than
+1127/// useless on Android: stdout is not logcat, so the Rust backend produced no
+1128/// visible output at all on the platform carrying the hardest bugs in this
+1129/// project's history (the autoplay deadlock, the truncated-stream restart, the
+1130/// background-audio stall). tauri-plugin-log routes to logcat there for free.
+1131///
+1132/// Three decisions worth keeping:
+1133///
+1134/// * **Every line goes through `redact` first.** A credential must never reach
+1135///   disk, not merely be stripped later when a bundle is exported — a file on
+1136///   the device is already the disclosure.
+1137/// * **The size cap is deliberate.** `RotationStrategy::KeepAll` would let a
+1138///   long-running session fill a phone. One rotation keeps yesterday's evidence
+1139///   without unbounded growth.
+1140/// * **The level is read from disk.** Someone reproducing a bug needs debug
+1141///   logging to survive the restart that reproduces it.
+1142///
+1143/// TRACES: UR-078 | DR-218
+1144fn build_log_plugin() -> tauri::plugin::TauriPlugin<tauri::Wry> {
+1145    use tauri_plugin_log::{Target, TargetKind};
+1146
+1147    let mut targets = vec![
+1148        Target::new(TargetKind::Stdout),
+1149        Target::new(TargetKind::LogDir {
+1150            file_name: Some("jellytau".to_string()),
+1151        }),
+1152    ];
+1153
+1154    // Rust lines in the webview console, so a developer sees both halves of the
+1155    // app in one place. Dev only -- in a release build this would ship backend
+1156    // logging into a console the user can open.
+1157    if cfg!(debug_assertions) {
+1158        targets.push(Target::new(TargetKind::Webview));
+1159    }
+1160
+1161    tauri_plugin_log::Builder::new()
+1162        .targets(targets)
+1163        .level(log::LevelFilter::Info)
+1164        .max_file_size(5 * 1024 * 1024)
+1165        .rotation_strategy(tauri_plugin_log::RotationStrategy::KeepOne)
+1166        .format(|out, message, record| {
+1167            out.finish(format_args!(
+1168                "[{}][{}] {}",
+1169                record.level(),
+1170                record.target(),
+1171                crate::utils::diagnostics::redact(&message.to_string())
+1172            ))
+1173        })
+1174        .build()
+1175}
+1176
+1177#[cfg_attr(mobile, tauri::mobile_entry_point)]
+1178pub fn run() {
+1179    // Crash capture before anything else, so a panic during startup is recorded
+1180    // rather than vanishing with the process.
+1181    //
+1182    // TRACES: UR-078 | DR-218
+1183    crate::utils::diagnostics::install_panic_hook();
+1184
+1185    // On Linux, video plays through WebKitGTK's HTML5 <video> element, which uses
+1186    // GStreamer as its media backend. Enable hardware-accelerated (VAAPI) decoding
+1187    // when available so video transcoding/decoding does not fall back to the CPU.
+1188    // These must be set before WebKitGTK initializes its GStreamer pipeline.
+1189    #[cfg(target_os = "linux")]
+1190    enable_linux_hardware_video_decoding();
+1191
+1192    // NOTE: TypeScript bindings are generated by the `export_typescript_bindings`
+1193    // test (`cargo test export_typescript_bindings`), NOT at runtime. Calling
+1194    // `.export()` here would try to write `../src/lib/api/bindings.ts` at app
+1195    // startup, which panics on devices (e.g. Android) where that path doesn't exist.
+1196    let builder = specta_builder();
+1197    let invoke_handler = builder.invoke_handler();
+1198
+1199    tauri::Builder::default()
+1200        .plugin(build_log_plugin())
+1201        .plugin(tauri_plugin_opener::init())
+1202        .plugin(tauri_plugin_os::init())
+1203        .invoke_handler(invoke_handler)
+1204        .setup(move |app| {
+1205            // Mount tauri-specta events so PlayerStatusEvent can be emitted to and
+1206            // listened for on the frontend via the generated bindings.
+1207            builder.mount_events(app);
+1208
+1209            // In-app update, desktop only.
+1210            //
+1211            // Registered here rather than in the builder chain above because a
+1212            // `#[cfg]` attribute cannot be attached to one link of a method
+1213            // chain -- this block is the shape Tauri's own docs use.
+1214            //
+1215            // Android is excluded on purpose: tauri-plugin-updater cannot
+1216            // replace an installed APK, and the frontend offers the releases
+1217            // page there instead.
+1218            //
+1219            // Re-apply the log level the user last chose. Without this the
+1220            // picker would only affect the running session -- and the whole
+1221            // point of a persisted level is that somebody reproducing a bug
+1222            // keeps debug logging across the restart that reproduces it.
+1223            //
+1224            // TRACES: UR-078 | DR-218
+1225            if let Ok(config_dir) = app.path().app_config_dir() {
+1226                if let Some(level) = crate::commands::diagnostics::stored_level(&config_dir) {
+1227                    log::set_max_level(level);
+1228                    log::info!("[DIAG] restored log level from settings: {level}");
+1229                }
+1230            }
+1231
+1232            // TRACES: UR-077 | DR-217
+1233            #[cfg(desktop)]
+1234            {
+1235                app.handle()
+1236                    .plugin(tauri_plugin_updater::Builder::new().build())?;
+1237                app.handle().plugin(tauri_plugin_process::init())?;
+1238            }
+1239
+1240            // Initialize database with proper app data directory
+1241            // Check for test mode environment variable first
+1242            let db_path = if let Ok(test_data_dir) = std::env::var("JELLYTAU_DATA_DIR") {
+1243                let test_path = std::path::PathBuf::from(test_data_dir);
+1244                info!("[INIT] Using test data directory: {:?}", test_path);
+1245                test_path.join("jellytau.db")
+1246            } else {
+1247                app
+1248                    .path()
+1249                    .app_data_dir()
+1250                    .expect("Failed to get app data directory")
+1251                    .join("jellytau.db")
+1252            };
+1253
+1254            info!("[INIT] Initializing database at: {:?}", db_path);
+1255
+1256            // Create the directory if it doesn't exist
+1257            if let Some(parent) = db_path.parent() {
+1258                info!("[INIT] Creating database directory: {:?}", parent);
+1259                match std::fs::create_dir_all(parent) {
+1260                    Ok(_) => info!("[INIT] Database directory ready"),
+1261                    Err(e) => {
+1262                        error!("[INIT ERROR] Failed to create database directory: {}", e);
+1263                        panic!("Failed to create database directory: {}", e);
+1264                    }
+1265                }
+1266            }
+1267
+1268            info!("[INIT] Opening database...");
+1269            let database = match Database::open(&db_path) {
+1270                Ok(db) => {
+1271                    info!("[INIT] Database initialized successfully");
+1272                    db
+1273                }
+1274                Err(e) => {
+1275                    error!("[INIT ERROR] Failed to initialize database: {}", e);
+1276                    panic!("Failed to initialize database: {}", e);
+1277                }
+1278            };
+1279            let db_wrapper = DatabaseWrapper(Mutex::new(database));
+1280            app.manage(db_wrapper);
+1281
+1282            // On Android, initialize SecureStorage BEFORE creating CredentialStore
+1283            #[cfg(target_os = "android")]
+1284            {
+1285                // Publish the JavaVM and Application into ndk_context first.
+1286                //
+1287                // Everything below reads that global. tao used to populate it
+1288                // and stopped doing so in 0.35 (Tauri 2.11), at which point the
+1289                // first read here aborted the process on launch. See
+1290                // android_context.rs. A failure is logged rather than fatal:
+1291                // credentials then fall back to the encrypted-file path, which
+1292                // is a supported degraded mode -- unlike aborting.
+1293                //
+1294                // TRACES: UR-012 | DR-223
+1295                if let Err(e) = crate::android_context::ensure_initialized() {
+1296                    log::error!("[INIT] Android context unavailable: {e}");
+1297                    log::error!("[INIT] Secure credential storage will fall back to the encrypted file.");
+1298                }
+1299
+1300                info!("[INIT] Initializing Android SecureStorage for credentials...");
+1301                let ctx = ndk_context::android_context();
+1302                let vm = unsafe { jni::JavaVM::from_raw(ctx.vm().cast()) };
+1303
+1304                match vm {
+1305                    Ok(java_vm) => {
+1306                        match java_vm.attach_current_thread() {
+1307                            Ok(mut env) => {
+1308                                let context_obj = unsafe { jni::objects::JObject::from_raw(ctx.context().cast()) };
+1309
+1310                                match initialize_secure_storage(&mut env, &context_obj) {
+1311                                    Ok(()) => {
+1312                                        info!("[INIT] Android SecureStorage initialized successfully");
+1313                                    }
+1314                                    Err(e) => {
+1315                                        warn!("[INIT WARNING] Failed to initialize SecureStorage: {}. Credentials will use encrypted file fallback.", e);
+1316                                    }
+1317                                }
+1318                            }
+1319                            Err(e) => {
+1320                                warn!("[INIT WARNING] Failed to attach JNI thread: {}. Credentials will use encrypted file fallback.", e);
+1321                            }
+1322                        }
+1323                    }
+1324                    Err(e) => {
+1325                        warn!("[INIT WARNING] Failed to create JavaVM: {}. Credentials will use encrypted file fallback.", e);
+1326                    }
+1327                }
+1328            }
+1329
+1330            // Initialize credential store (keyring with encrypted file fallback)
+1331            info!("[INIT] Initializing credential store...");
+1332            let credential_store = CredentialStore::new();
+1333            let creds_wrapper = CredentialStoreWrapper(Mutex::new(credential_store));
+1334            app.manage(creds_wrapper);
+1335
+1336            // Create shared reporter and throttler Arc wrappers before backend/controller
+1337            info!("[INIT] Creating shared playback reporting infrastructure...");
+1338            let playback_reporter = Arc::new(tokio::sync::Mutex::new(None));
+1339            let position_throttler = Arc::new(playback_reporting::EventThrottler::new());
+1340
+1341            // Create player backend with access to AppHandle for event emission
+1342            info!("[INIT] Creating player backend...");
+1343            let backend = create_player_backend(
+1344                app.handle().clone(),
+1345                playback_reporter.clone(),
+1346                position_throttler.clone(),
+1347            );
+1348            let player_controller = PlayerController::new(
+1349                backend,
+1350                playback_reporter.clone(),
+1351                position_throttler.clone(),
+1352            );
+1353
+1354            // Wire up event emitter for sleep timer and autoplay notifications
+1355            let event_emitter = Arc::new(TauriEventEmitter::new(app.handle().clone()));
+1356            player_controller.set_event_emitter(event_emitter.clone());
+1357
+1358            let player_arc = Arc::new(TokioMutex::new(player_controller));
+1359
+1360            // On Android, register the player controller for autoplay decisions.
+1361            // The MediaSession (lockscreen) handler is set up later, once the
+1362            // playback mode manager exists, so it can route to remote sessions.
+1363            #[cfg(target_os = "android")]
+1364            {
+1365                player::android::set_player_controller(player_arc.clone());
+1366            }
+1367
+1368            let player_state = PlayerStateWrapper(player_arc.clone());
+1369            app.manage(player_state);
+1370
+1371            // Initialize media session manager
+1372            info!("[INIT] Initializing media session manager...");
+1373            let session_manager = MediaSessionManager::new();
+1374            let session_wrapper = MediaSessionManagerWrapper(Mutex::new(session_manager));
+1375            app.manage(session_wrapper);
+1376
+1377            // Initialize playback mode manager
+1378            info!("[INIT] Initializing playback mode manager...");
+1379            let jellyfin_client = {
+1380                let player = player_arc.blocking_lock();
+1381                player.jellyfin_client()
+1382            };
+1383            let playback_mode_manager = playback_mode::PlaybackModeManager::new(
+1384                jellyfin_client.clone(),
+1385                player_arc.clone(),
+1386            );
+1387            let playback_mode_arc = Arc::new(playback_mode_manager);
+1388            // Broadcast mode changes so the frontend's mirror store reconciles to
+1389            // this authoritative one (prevents remote/local control desync).
+1390            playback_mode_arc.set_event_emitter(event_emitter.clone());
+1391            let playback_mode_wrapper = PlaybackModeManagerWrapper(playback_mode_arc.clone());
+1392            app.manage(playback_mode_wrapper);
+1393
+1394            // Initialize session poller manager for remote session polling
+1395            info!("[INIT] Initializing session poller manager...");
+1396            let session_poller = session_poller::SessionPollerManager::new(
+1397                jellyfin_client,
+1398                playback_mode_arc.clone(),
+1399            );
+1400            session_poller.set_event_emitter(event_emitter.clone());
+1401            // Note: start() is deferred until after the connectivity monitor is
+1402            // created below, so the poller can report reachability from its first
+1403            // poll (it drives offline detection + recovery while the user is idle).
+1404            let session_poller_arc = Arc::new(session_poller);
+1405            let session_poller_wrapper = SessionPollerWrapper(session_poller_arc.clone());
+1406            app.manage(session_poller_wrapper);
+1407
+1408            // On Android, set up the MediaSession (lockscreen) handler and the
+1409            // remote volume handler. Both need the playback mode manager so they
+1410            // can route to the active remote session while casting.
+1411            #[cfg(target_os = "android")]
+1412            {
+1413                info!("[INIT] Setting up MediaSession handler for lockscreen controls...");
+1414                let media_handler = Arc::new(MediaSessionHandler {
+1415                    player: player_arc.clone(),
+1416                    playback_mode: playback_mode_arc.clone(),
+1417                    event_emitter: event_emitter.clone(),
+1418                });
+1419                set_media_command_handler(media_handler);
+1420
+1421                info!("[INIT] Setting up remote volume handler for Android...");
+1422                let handler = Arc::new(RemoteVolumeSessionHandler {
+1423                    playback_mode: playback_mode_arc.clone(),
+1424                });
+1425                set_remote_volume_handler(handler);
+1426            }
+1427
+1428            // Initialize video settings with defaults
+1429            let video_settings = VideoSettingsWrapper(Mutex::new(VideoSettings::default()));
+1430            app.manage(video_settings);
+1431
+1432            // Restore the persisted streaming bandwidth ceiling. Deferred to the
+1433            // async runtime because the read is async, and ordered after the
+1434            // wrapper above because it writes into it. Until it lands, streams
+1435            // are uncapped — the pre-existing behaviour — and no playback can
+1436            // have started this early anyway (login happens after setup).
+1437            //
+1438            // TRACES: UR-074 | DR-162
+1439            {
+1440                let handle = app.handle().clone();
+1441                tauri::async_runtime::spawn(async move {
+1442                    crate::commands::restore_streaming_quality(&handle).await;
+1443                });
+1444            }
+1445
+1446            // Restore the folders the user hid from browsing, for the same
+1447            // reason and in the same way. Until it lands nothing is hidden —
+1448            // the pre-existing behaviour — and no query can have run this early.
+1449            //
+1450            // TRACES: UR-076 | DR-209
+1451            {
+1452                let handle = app.handle().clone();
+1453                tauri::async_runtime::spawn(async move {
+1454                    crate::commands::restore_library_settings(&handle).await;
+1455                });
+1456            }
+1457
+1458            // Initialize thumbnail cache
+1459            info!("[INIT] Initializing thumbnail cache...");
+1460            let app_data_dir = if let Ok(test_data_dir) = std::env::var("JELLYTAU_DATA_DIR") {
+1461                std::path::PathBuf::from(test_data_dir)
+1462            } else {
+1463                app
+1464                    .path()
+1465                    .app_data_dir()
+1466                    .expect("Failed to get app data directory")
+1467            };
+1468            let thumbnail_cache = ThumbnailCache::new(app_data_dir.clone(), ThumbnailCacheConfig::default());
+1469            let thumbnail_wrapper = ThumbnailCacheWrapper(Arc::new(thumbnail_cache));
+1470            app.manage(thumbnail_wrapper);
+1471
+1472            // Initialize smart cache for preloading
+1473            info!("[INIT] Initializing smart cache...");
+1474            let smart_cache = SmartCache::new(SmartCacheConfig::default());
+1475            let smart_cache_wrapper = SmartCacheWrapper(Mutex::new(smart_cache));
+1476            app.manage(smart_cache_wrapper);
+1477
+1478            // Serve downloaded media over loopback HTTP. The webview cannot
+1479            // stream a large file through the asset protocol (see media_server),
+1480            // so local playback resolves its URL from here instead.
+1481            // TRACES: UR-071 | DR-137
+1482            info!("[INIT] Starting local media server...");
+1483            let media_server = match media_server::start(app_data_dir.clone()) {
+1484                Ok(s) => Some(s),
+1485                Err(e) => {
+1486                    // Not fatal: streaming still works, and the command reports
+1487                    // a clear error if local playback is attempted.
+1488                    error!("[INIT ERROR] Local media server failed to start: {}", e);
+1489                    None
+1490                }
+1491            };
+1492            app.manage(media_server::MediaServerWrapper(media_server));
+1493
+1494            // Initialize download manager
+1495            info!("[INIT] Initializing download manager...");
+1496            let download_dir = app_data_dir.join("downloads");
+1497            let download_manager = DownloadManager::new(download_dir);
+1498            let download_manager_wrapper = DownloadManagerWrapper(Mutex::new(download_manager));
+1499            app.manage(download_manager_wrapper);
+1500
+1501            // Current network transport, for the WiFi-only download gate (UR-053).
+1502            // Defaults to unmetered ethernet so desktop is never gated; Android
+1503            // overwrites it via set_network_state as soon as the UI starts.
+1504            app.manage(commands::download::NetworkStateWrapper(
+1505                download::network::NetworkStateHandle::new(),
+1506            ));
+1507
+1508            // Initialize connectivity monitor
+1509            info!("[INIT] Initializing connectivity monitor...");
+1510            let http_config = HttpConfig::default();
+1511            let http_client = HttpClient::new(http_config)
+1512                .expect("Failed to create HTTP client");
+1513            let mut connectivity_monitor = ConnectivityMonitor::new(http_client);
+1514            connectivity_monitor.set_app_handle(app.handle().clone());
+1515
+1516            // Wire the connectivity reporter into the session poller so its
+1517            // continuous background polls drive reachability (offline detection
+1518            // + recovery) even when the user isn't browsing, then start it.
+1519            session_poller_arc.set_connectivity_reporter(connectivity_monitor.reporter());
+1520            session_poller_arc.start();
+1521
+1522            // Wrap in Arc for sharing with AuthManager
+1523            let connectivity_arc = Arc::new(tokio::sync::Mutex::new(connectivity_monitor));
+1524            let connectivity_wrapper = ConnectivityMonitorWrapper(connectivity_arc.clone());
+1525            app.manage(connectivity_wrapper);
+1526
+1527            // Initialize auth manager
+1528            info!("[INIT] Initializing auth manager...");
+1529            let auth_http_config = HttpConfig::default();
+1530            let auth_http_client = HttpClient::new(auth_http_config)
+1531                .expect("Failed to create HTTP client for auth");
+1532            let mut auth_manager = AuthManager::new(auth_http_client);
+1533
+1534            // Give auth manager a reference to connectivity monitor
+1535            auth_manager.set_connectivity_monitor(connectivity_arc.clone());
+1536
+1537            let auth_manager_wrapper = AuthManagerWrapper(Arc::new(auth_manager));
+1538            app.manage(auth_manager_wrapper);
+1539
+1540            // Initialize session verifier wrapper (initially empty)
+1541            info!("[INIT] Initializing session verifier wrapper...");
+1542            let session_verifier_wrapper = SessionVerifierWrapper(Arc::new(tokio::sync::Mutex::new(None)));
+1543            app.manage(session_verifier_wrapper);
+1544
+1545            // Initialize repository manager
+1546            info!("[INIT] Initializing repository manager...");
+1547            let repository_manager = commands::RepositoryManager::new();
+1548            let repository_manager_wrapper = RepositoryManagerWrapper(repository_manager);
+1549            app.manage(repository_manager_wrapper);
+1550
+1551            // Initialize playback reporter wrapper. This MUST share the same Arc
+1552            // the player controller and MPV progress loop report through (created
+1553            // above at `playback_reporter`), otherwise `playback_reporter_init`
+1554            // would populate a dead, parallel Option and no Start/Progress/Stopped
+1555            // would ever reach Jellyfin.
+1556            info!("[INIT] Initializing playback reporter wrapper...");
+1557            let playback_reporter_wrapper = PlaybackReporterWrapper(playback_reporter.clone());
+1558            app.manage(playback_reporter_wrapper);
+1559
+1560            // Keep the local search index fresh. Ownership of *when* to re-index
+1561            // sits here rather than in the frontend: it is sync policy over
+1562            // domain data, and a startup-only trigger left a long session
+1563            // searching a stale catalog.
+1564            // TRACES: UR-065 | DR-109, IR-030
+1565            info!("[INIT] Starting background catalog indexer...");
+1566            commands::catalog::spawn_catalog_indexer(app.handle().clone());
+1567
+1568            // Push favourite toggles made while the server was unreachable, on
+1569            // every reconnect. In Rust rather than the frontend so it runs
+1570            // whether or not the screen that made the change is still mounted.
+1571            // TRACES: UR-069 | DR-120
+1572            info!("[INIT] Starting favourites drain...");
+1573            commands::favorites::spawn_favorites_drain(app.handle().clone());
+1574
+1575            // Push playback reports queued while the server was unreachable.
+1576            // Without this the `sync_queue` rows the reporter writes offline
+1577            // are never sent and the offline banner's count only grows.
+1578            // TRACES: UR-025, UR-002 | DR-131
+1579            info!("[INIT] Starting sync-queue drain...");
+1580            commands::sync_drain::spawn_sync_queue_drain(app.handle().clone());
+1581
+1582            info!("[INIT] Application setup completed successfully");
+1583            Ok(())
+1584        })
+1585        .run(tauri::generate_context!())
+1586        .expect("error while running tauri application");
+1587}
+1588
+1589#[cfg(test)]
+1590mod specta_bindings {
+1591    /// Generates `src/lib/api/bindings.ts`. Run with `cargo test export_typescript_bindings`.
+1592    #[test]
+1593    fn export_typescript_bindings() {
+1594        super::specta_builder()
+1595            .export(
+1596                specta_typescript::Typescript::default()
+1597                    .bigint(specta_typescript::BigIntExportBehavior::Number),
+1598                "../src/lib/api/bindings.ts",
+1599            )
+1600            .expect("failed to export typescript bindings");
+1601    }
+1602}
+
\ No newline at end of file diff --git a/api/src/jellytau_lib/media_server.rs.html b/api/src/jellytau_lib/media_server.rs.html new file mode 100644 index 00000000..479b7ab5 --- /dev/null +++ b/api/src/jellytau_lib/media_server.rs.html @@ -0,0 +1,589 @@ +media_server.rs - sourceSkip to main content

jellytau_lib/
media_server.rs

1//! A loopback HTTP server for locally downloaded media.
+2//!
+3//! Tauri's built-in `asset` protocol cannot serve a downloaded film to the
+4//! webview. Its response to a request *without* a `Range` header reads the whole
+5//! file into a `Vec<u8>`, and it only advertises `Accept-Ranges: bytes` from
+6//! inside the range branch — so the first request never learns ranges are
+7//! available and a multi-gigabyte body is attempted instead. Chromium abandoned
+8//! it with `PIPELINE_ERROR_READ` after ~31s, which reached the user as
+9//! "downloaded video does not play offline".
+10//!
+11//! Serving over real HTTP on 127.0.0.1 rather than a custom URI scheme is
+12//! deliberate: it makes range support a property of the transport instead of
+13//! depending on whether a platform's webview forwards `Range` to a custom
+14//! scheme, which differs between Android and the desktop webviews.
+15//!
+16//! Two things confine it, because **loopback is shared between apps on
+17//! Android** — any other installed app can connect to this port:
+18//!
+19//! - it binds `127.0.0.1` only, so nothing off-device can reach it; and
+20//! - every URL carries a random per-session token, so another app cannot guess a
+21//!   working URL, and paths are confined to the app data directory even if one
+22//!   did.
+23//!
+24//! Phase 1 serves local files only. The same origin is the intended home for
+25//! remote passthrough (and download-while-watching) later; see the stage-2 spec.
+26//!
+27//! TRACES: UR-071 | DR-137 | UT-127
+28
+29use std::fs::File;
+30use std::io::{Read, Seek, SeekFrom};
+31use std::path::{Path, PathBuf};
+32use std::sync::Arc;
+33
+34use log::{debug, error, info, warn};
+35use rand::Rng;
+36use tiny_http::{Header, Response, Server, StatusCode};
+37
+38/// Bytes per response. Large enough that a film needs relatively few round
+39/// trips, small enough that one response is never a memory problem on a phone.
+40/// Tauri's asset protocol uses 1 MiB; 4 MiB quarters the request count for the
+41/// multi-gigabyte files this exists to serve.
+42const CHUNK_LEN: u64 = 4 * 1024 * 1024;
+43
+44/// Managed state. `None` when the server could not bind — local playback then
+45/// fails with a clear error instead of the app refusing to start.
+46pub struct MediaServerWrapper(pub Option<MediaServer>);
+47
+48/// A running server. Dropping this does not stop the thread; the server lives
+49/// for the life of the process by design, since playback can start at any time.
+50pub struct MediaServer {
+51    port: u16,
+52    token: String,
+53}
+54
+55impl MediaServer {
+56    /// The base a media URL is built on, e.g. `http://127.0.0.1:53412/<token>`.
+57    pub fn base_url(&self) -> String {
+58        format!("http://127.0.0.1:{}/{}", self.port, self.token)
+59    }
+60
+61    /// A playable URL for an absolute on-disk path.
+62    pub fn url_for(&self, path: &str) -> String {
+63        format!("{}/{}", self.base_url(), urlencoding::encode(path))
+64    }
+65}
+66
+67/// Bind to an ephemeral loopback port and start serving `root` in a background
+68/// thread.
+69///
+70/// TRACES: UR-071 | DR-137
+71pub fn start(root: PathBuf) -> Result<MediaServer, String> {
+72    // Port 0 → the OS picks a free one. Binding 127.0.0.1 (not 0.0.0.0) keeps
+73    // this off the network.
+74    let server =
+75        Server::http("127.0.0.1:0").map_err(|e| format!("Failed to bind media server: {e}"))?;
+76
+77    let port = server
+78        .server_addr()
+79        .to_ip()
+80        .ok_or_else(|| "Media server bound to a non-IP address".to_string())?
+81        .port();
+82
+83    let token: String = {
+84        let mut rng = rand::thread_rng();
+85        (0..32)
+86            .map(|_| char::from_digit(rng.gen_range(0..16), 16).unwrap())
+87            .collect()
+88    };
+89
+90    info!(
+91        "[MediaServer] Serving {} on 127.0.0.1:{}",
+92        root.display(),
+93        port
+94    );
+95
+96    let server = Arc::new(server);
+97    let shared = Arc::new((root, token.clone()));
+98
+99    std::thread::Builder::new()
+100        .name("media-server".into())
+101        .spawn(move || loop {
+102            let request = match server.recv() {
+103                Ok(r) => r,
+104                Err(e) => {
+105                    error!("[MediaServer] accept failed: {e}");
+106                    continue;
+107                }
+108            };
+109            let shared = Arc::clone(&shared);
+110            // A thread per request: media clients open several connections at
+111            // once, and a blocking read of one must not stall the others.
+112            if let Err(e) = std::thread::Builder::new()
+113                .name("media-server-req".into())
+114                .spawn(move || {
+115                    let (root, token) = &*shared;
+116                    handle(request, root, token);
+117                })
+118            {
+119                error!("[MediaServer] could not spawn handler: {e}");
+120            }
+121        })
+122        .map_err(|e| format!("Failed to start media server thread: {e}"))?;
+123
+124    Ok(MediaServer { port, token })
+125}
+126
+127fn header(name: &str, value: &str) -> Header {
+128    Header::from_bytes(name.as_bytes(), value.as_bytes())
+129        .expect("static header name/value are valid")
+130}
+131
+132fn empty(status: u16) -> Response<std::io::Empty> {
+133    Response::empty(StatusCode(status)).with_header(header("Accept-Ranges", "bytes"))
+134}
+135
+136fn handle(request: tiny_http::Request, root: &Path, token: &str) {
+137    let url = request.url().to_string();
+138    let method = request.method().as_str().to_string();
+139
+140    let outcome = match route(&url, root, token) {
+141        Ok(path) => path,
+142        Err(status) => {
+143            let _ = request.respond(empty(status));
+144            return;
+145        }
+146    };
+147
+148    if method != "GET" && method != "HEAD" {
+149        let _ = request.respond(empty(405));
+150        return;
+151    }
+152
+153    let mut file = match File::open(&outcome) {
+154        Ok(f) => f,
+155        Err(e) => {
+156            warn!("[MediaServer] {}: {}", outcome.display(), e);
+157            let _ = request.respond(empty(404));
+158            return;
+159        }
+160    };
+161    let len = match file.metadata() {
+162        Ok(m) => m.len(),
+163        Err(e) => {
+164            warn!(
+165                "[MediaServer] metadata failed for {}: {}",
+166                outcome.display(),
+167                e
+168            );
+169            let _ = request.respond(empty(404));
+170            return;
+171        }
+172    };
+173
+174    let range = request
+175        .headers()
+176        .iter()
+177        .find(|h| h.field.equiv("Range"))
+178        .map(|h| h.value.as_str().to_string());
+179
+180    debug!(
+181        "[MediaServer] {} {} ({} bytes) range={:?}",
+182        method,
+183        outcome.display(),
+184        len,
+185        range
+186    );
+187
+188    let Some(span) = span_for(range.as_deref(), len) else {
+189        let _ = request
+190            .respond(empty(416).with_header(header("Content-Range", &format!("bytes */{len}"))));
+191        return;
+192    };
+193
+194    // Sniff before seeking to the span, for extension-less files.
+195    let mut head = [0u8; 16];
+196    let head_len = file.read(&mut head).unwrap_or(0);
+197    let mime = content_type(&outcome, &head[..head_len]);
+198
+199    if method == "HEAD" {
+200        let _ = request.respond(
+201            empty(200)
+202                .with_header(header("Content-Type", mime))
+203                .with_header(header("Content-Length", &len.to_string())),
+204        );
+205        return;
+206    }
+207
+208    if let Err(e) = file.seek(SeekFrom::Start(span.start)) {
+209        warn!("[MediaServer] seek failed for {}: {}", outcome.display(), e);
+210        let _ = request.respond(empty(500));
+211        return;
+212    }
+213
+214    // Streamed straight from the file handle: at no point is more than the
+215    // span in memory, and the span is capped at CHUNK_LEN.
+216    let nbytes = span.len();
+217    let body = file.take(nbytes);
+218    // tiny_http switches to chunked transfer above a 32 KiB default, which drops
+219    // Content-Length — and a 206 without one is unusable to Chromium's media
+220    // loader, which needs the range's size. Raising the threshold past our own
+221    // cap keeps every response length-delimited.
+222    let response = Response::new(
+223        StatusCode(206),
+224        vec![
+225            header("Accept-Ranges", "bytes"),
+226            header("Content-Type", mime),
+227            header(
+228                "Content-Range",
+229                &format!("bytes {}-{}/{}", span.start, span.end, len),
+230            ),
+231        ],
+232        body,
+233        Some(nbytes as usize),
+234        None,
+235    )
+236    .with_chunked_threshold(usize::MAX);
+237
+238    if let Err(e) = request.respond(response) {
+239        // A client that seeks away closes the connection mid-body; that is
+240        // normal and must not be logged as a failure.
+241        debug!("[MediaServer] response ended early: {e}");
+242    }
+243}
+244
+245/// Check the token and resolve the path, or return the status to answer with.
+246fn route(url: &str, root: &Path, token: &str) -> Result<PathBuf, u16> {
+247    let trimmed = url.trim_start_matches('/');
+248    let (got_token, rest) = trimmed.split_once('/').ok_or(404u16)?;
+249
+250    // Constant-time-ish: length check first, then a byte compare. The token is
+251    // the only thing standing between another app on the device and this server.
+252    if got_token.len() != token.len() || got_token != token {
+253        warn!("[MediaServer] Rejected a request with a bad token");
+254        return Err(403);
+255    }
+256
+257    // Strip any query string before decoding.
+258    let raw = rest.split('?').next().unwrap_or("");
+259    match resolve_path(raw, root) {
+260        Resolved::Allow(p) => Ok(p),
+261        Resolved::Forbidden => {
+262            warn!("[MediaServer] Refused a path outside the app data directory");
+263            Err(403)
+264        }
+265    }
+266}
+267
+268/// What a request path resolved to, before any file is touched.
+269#[derive(Debug, PartialEq, Eq)]
+270pub enum Resolved {
+271    Allow(PathBuf),
+272    /// Escaped the allowed root.
+273    Forbidden,
+274}
+275
+276/// Resolve a percent-encoded request path to a file inside `root`.
+277///
+278/// `..` segments are folded away lexically rather than through `canonicalize`,
+279/// so a missing file still resolves (and then 404s) instead of being reported as
+280/// a scope violation.
+281///
+282/// TRACES: UR-071 | DR-137 | UT-127
+283pub fn resolve_path(raw: &str, root: &Path) -> Resolved {
+284    let decoded = match urlencoding::decode(raw) {
+285        Ok(d) => d.into_owned(),
+286        Err(_) => raw.to_string(),
+287    };
+288
+289    let mut normalised = PathBuf::new();
+290    for part in Path::new(&decoded).components() {
+291        match part {
+292            std::path::Component::ParentDir => {
+293                normalised.pop();
+294            }
+295            std::path::Component::CurDir => {}
+296            other => normalised.push(other),
+297        }
+298    }
+299
+300    if normalised.starts_with(root) {
+301        Resolved::Allow(normalised)
+302    } else {
+303        Resolved::Forbidden
+304    }
+305}
+306
+307/// The byte range a response should carry. `end` is inclusive.
+308#[derive(Debug, PartialEq, Eq)]
+309pub struct Span {
+310    pub start: u64,
+311    pub end: u64,
+312}
+313
+314impl Span {
+315    pub fn len(&self) -> u64 {
+316        self.end + 1 - self.start
+317    }
+318}
+319
+320/// Decide which span to send for a `Range` header (or its absence).
+321///
+322/// `None` means unsatisfiable — answer 416. A missing or unparseable header
+323/// yields the first chunk, so a client that did not ask for a range still gets a
+324/// bounded response it can continue from, which is exactly the case the asset
+325/// protocol answered with the whole file.
+326///
+327/// TRACES: UR-071 | DR-137 | UT-127
+328pub fn span_for(range: Option<&str>, len: u64) -> Option<Span> {
+329    if len == 0 {
+330        return Some(Span { start: 0, end: 0 });
+331    }
+332    let last = len - 1;
+333    let first_chunk = Span {
+334        start: 0,
+335        end: (CHUNK_LEN - 1).min(last),
+336    };
+337
+338    let Some(raw) = range else {
+339        return Some(first_chunk);
+340    };
+341    let Some(spec) = raw.trim().strip_prefix("bytes=") else {
+342        return Some(first_chunk);
+343    };
+344    // Only the first range of a multi-range request is honoured; media clients
+345    // ask for one, and a single 206 is a valid answer either way.
+346    let spec = spec.split(',').next().unwrap_or("").trim();
+347    let Some((from, to)) = spec.split_once('-') else {
+348        return Some(first_chunk);
+349    };
+350
+351    let (start, end) = if from.is_empty() {
+352        // Suffix form: `-500` is the final 500 bytes.
+353        let suffix: u64 = match to.parse() {
+354            Ok(n) => n,
+355            Err(_) => return Some(first_chunk),
+356        };
+357        if suffix == 0 {
+358            return None;
+359        }
+360        (len.saturating_sub(suffix), last)
+361    } else {
+362        let start: u64 = match from.parse() {
+363            Ok(n) => n,
+364            Err(_) => return Some(first_chunk),
+365        };
+366        let end = if to.is_empty() {
+367            last
+368        } else {
+369            match to.parse::<u64>() {
+370                Ok(n) => n.min(last),
+371                Err(_) => return Some(first_chunk),
+372            }
+373        };
+374        (start, end)
+375    };
+376
+377    if start > last || end < start {
+378        return None;
+379    }
+380
+381    Some(Span {
+382        start,
+383        end: end.min(start + CHUNK_LEN - 1),
+384    })
+385}
+386
+387/// Guess a content type.
+388///
+389/// **Magic bytes win over the extension.** Downloading at `original` quality
+390/// asks Jellyfin for a direct static copy, which returns the *source file's*
+391/// bytes under a `.mp4` name whatever the real container is — a downloaded film
+392/// named `.mp4` turned out to be an AVI holding XVID. Trusting the extension
+393/// there labels it `video/mp4` and the player is handed a container that is not
+394/// what the header claims. The extension is only a fallback for a file whose
+395/// bytes are unrecognised, and for the extension-less files the offline queue
+396/// writes under an item id.
+397///
+398/// TRACES: UR-071 | DR-137 | UT-127
+399fn content_type(path: &Path, head: &[u8]) -> &'static str {
+400    // `ftyp` at offset 4 marks an ISO base media file (mp4 and friends).
+401    if head.len() > 11 && &head[4..8] == b"ftyp" {
+402        return "video/mp4";
+403    }
+404    if head.len() > 11 && head.starts_with(b"RIFF") && &head[8..11] == b"AVI" {
+405        return "video/x-msvideo";
+406    }
+407    if head.starts_with(b"\x1aE\xdf\xa3") {
+408        return "video/x-matroska";
+409    }
+410    if head.starts_with(b"ID3") || head.starts_with(b"\xff\xfb") {
+411        return "audio/mpeg";
+412    }
+413    if head.starts_with(b"OggS") {
+414        return "audio/ogg";
+415    }
+416    if head.starts_with(b"fLaC") {
+417        return "audio/flac";
+418    }
+419
+420    match path
+421        .extension()
+422        .and_then(|e| e.to_str())
+423        .map(|e| e.to_ascii_lowercase())
+424        .as_deref()
+425    {
+426        Some("mp4" | "m4v" | "mov") => return "video/mp4",
+427        Some("mkv") => return "video/x-matroska",
+428        Some("webm") => return "video/webm",
+429        Some("mp3") => return "audio/mpeg",
+430        Some("m4a" | "aac") => return "audio/mp4",
+431        Some("flac") => return "audio/flac",
+432        Some("ogg" | "opus") => return "audio/ogg",
+433        Some("wav") => return "audio/wav",
+434        Some("avi") => return "video/x-msvideo",
+435        _ => {}
+436    }
+437
+438    "application/octet-stream"
+439}
+440
+441#[cfg(test)]
+442mod tests {
+443    use super::*;
+444
+445    /// The whole point: a request with no `Range` must still come back bounded.
+446    /// That is the case Tauri's asset protocol answers with the entire file —
+447    /// the read Chromium abandoned after 31s.
+448    ///
+449    /// TRACES: UR-071 | DR-137 | UT-127
+450    #[test]
+451    fn a_rangeless_request_is_answered_with_one_chunk_not_the_file() {
+452        let huge = 8 * 1024 * 1024 * 1024; // 8 GiB
+453        let span = span_for(None, huge).unwrap();
+454        assert_eq!(span.start, 0);
+455        assert_eq!(span.len(), CHUNK_LEN);
+456    }
+457
+458    /// TRACES: UR-071 | DR-137 | UT-127
+459    #[test]
+460    fn no_response_ever_exceeds_one_chunk() {
+461        let len = 8 * 1024 * 1024 * 1024;
+462        for h in [
+463            "bytes=0-",
+464            "bytes=0-99999999999",
+465            "bytes=1024-",
+466            "bytes=-99999999",
+467        ] {
+468            let span = span_for(Some(h), len).unwrap();
+469            assert!(span.len() <= CHUNK_LEN, "{h} produced {} bytes", span.len());
+470        }
+471    }
+472
+473    /// TRACES: UR-071 | DR-137 | UT-127
+474    #[test]
+475    fn ranges_are_honoured() {
+476        let len = 1000u64;
+477        assert_eq!(
+478            span_for(Some("bytes=100-199"), len).unwrap(),
+479            Span {
+480                start: 100,
+481                end: 199
+482            }
+483        );
+484        // Open-ended runs to the end of a small file.
+485        assert_eq!(
+486            span_for(Some("bytes=900-"), len).unwrap(),
+487            Span {
+488                start: 900,
+489                end: 999
+490            }
+491        );
+492        // Suffix form.
+493        assert_eq!(
+494            span_for(Some("bytes=-100"), len).unwrap(),
+495            Span {
+496                start: 900,
+497                end: 999
+498            }
+499        );
+500        // Past the end is unsatisfiable, not a clamp — a clamp would make a
+501        // seek past the end silently replay earlier bytes.
+502        assert!(span_for(Some("bytes=1000-"), len).is_none());
+503    }
+504
+505    /// A malformed header must not fail the request: playing from the start is
+506    /// strictly better than refusing to open the file.
+507    ///
+508    /// TRACES: UR-071 | DR-137 | UT-127
+509    #[test]
+510    fn a_malformed_range_falls_back_to_the_first_chunk() {
+511        assert_eq!(span_for(Some("pages=1-2"), 5000).unwrap().start, 0);
+512        assert_eq!(span_for(Some("bytes=abc-def"), 5000).unwrap().start, 0);
+513    }
+514
+515    /// TRACES: UR-071 | DR-137 | UT-127
+516    #[test]
+517    fn reads_are_confined_to_the_app_data_directory() {
+518        let root = Path::new("/data/user/0/app");
+519
+520        assert_eq!(
+521            resolve_path("/data/user/0/app/videos/f.mp4", root),
+522            Resolved::Allow(PathBuf::from("/data/user/0/app/videos/f.mp4"))
+523        );
+524        // Percent-encoded, as the URL builder produces.
+525        assert_eq!(
+526            resolve_path("%2Fdata%2Fuser%2F0%2Fapp%2Fa%20b.mp4", root),
+527            Resolved::Allow(PathBuf::from("/data/user/0/app/a b.mp4"))
+528        );
+529        // Traversal out of the root, and an unrelated absolute path, are refused.
+530        assert_eq!(
+531            resolve_path("/data/user/0/app/../../../etc/passwd", root),
+532            Resolved::Forbidden
+533        );
+534        assert_eq!(resolve_path("/etc/passwd", root), Resolved::Forbidden);
+535    }
+536
+537    /// Loopback is shared between apps on Android, so the token is the only
+538    /// thing stopping another installed app from reading downloaded media.
+539    ///
+540    /// TRACES: UR-071 | DR-137 | UT-127
+541    #[test]
+542    fn a_request_without_the_right_token_is_refused() {
+543        let root = Path::new("/data/user/0/app");
+544        let good = "0123456789abcdef0123456789abcdef";
+545
+546        assert_eq!(
+547            route(
+548                &format!("/{good}/%2Fdata%2Fuser%2F0%2Fapp%2Ff.mp4"),
+549                root,
+550                good
+551            ),
+552            Ok(PathBuf::from("/data/user/0/app/f.mp4"))
+553        );
+554        assert_eq!(
+555            route("/wrong-token/%2Fdata%2Fuser%2F0%2Fapp%2Ff.mp4", root, good),
+556            Err(403)
+557        );
+558        // No token segment at all.
+559        assert_eq!(route("/f.mp4", root, good), Err(404));
+560        // Right token, but a path outside the root is still refused.
+561        assert_eq!(
+562            route(&format!("/{good}/%2Fetc%2Fpasswd"), root, good),
+563            Err(403)
+564        );
+565    }
+566
+567    /// TRACES: UR-071 | DR-137 | UT-127
+568    #[test]
+569    fn content_type_uses_the_extension_then_the_magic_bytes() {
+570        assert_eq!(content_type(Path::new("/a/f.mp4"), &[]), "video/mp4");
+571        assert_eq!(content_type(Path::new("/a/f.mp3"), &[]), "audio/mpeg");
+572        // A `.mp4` that is really an AVI: downloading at `original` quality
+573        // copies the source bytes under an mp4 name, so the extension lies and
+574        // the magic bytes must win.
+575        let avi_head = b"RIFF\xcc\xf3\xbc\x2bAVI LIST";
+576        assert_eq!(
+577            content_type(Path::new("/a/film.mp4"), avi_head),
+578            "video/x-msvideo"
+579        );
+580        // Extension-less, as the offline queue writes them: sniff instead.
+581        let mp4_head = b"\x00\x00\x00\x20ftypisom\x00\x00\x02\x00";
+582        assert_eq!(content_type(Path::new("/a/abc123"), mp4_head), "video/mp4");
+583        assert_eq!(
+584            content_type(Path::new("/a/abc123"), b"ID3\x03junk"),
+585            "audio/mpeg"
+586        );
+587    }
+588}
+
\ No newline at end of file diff --git a/api/src/jellytau_lib/playback_mode/mod.rs.html b/api/src/jellytau_lib/playback_mode/mod.rs.html new file mode 100644 index 00000000..9b258aa2 --- /dev/null +++ b/api/src/jellytau_lib/playback_mode/mod.rs.html @@ -0,0 +1,1306 @@ +mod.rs - sourceSkip to main content

jellytau_lib/playback_mode/
mod.rs

1use crate::utils::lock::{MutexSafe, RwLockSafe};
+2use log::{debug, error, info};
+3use serde::{Deserialize, Serialize};
+4use std::sync::{
+5    atomic::{AtomicBool, Ordering},
+6    Arc, Mutex, RwLock,
+7};
+8use tokio::sync::Mutex as TokioMutex;
+9use tokio::time::{sleep, Duration};
+10
+11use crate::jellyfin::JellyfinClient;
+12use crate::player::{PlayerController, PlayerEventEmitter, PlayerStatusEvent, QueueContext};
+13
+14/// Playback mode - local device, remote session, or idle
+15#[derive(specta::Type, Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
+16#[serde(tag = "type", rename_all = "lowercase")]
+17pub enum PlaybackMode {
+18    Local,
+19    Remote { session_id: String },
+20    Idle,
+21}
+22
+23/// Number of Jellyfin ticks per second (100ns units).
+24const TICKS_PER_SECOND: f64 = 10_000_000.0;
+25
+26/// Below this many seconds we treat the position as "at the start" and don't
+27/// send a resume position, so a fresh track casts from 0 rather than ~0.
+28const RESUME_THRESHOLD_SECONDS: f64 = 0.5;
+29
+30/// Volume level (0-100) the remote volume slider starts at. The real level is
+31/// corrected by the session poller once the remote session reports its volume.
+32const DEFAULT_REMOTE_VOLUME: i32 = 50;
+33
+34/// Convert a live playback position (seconds) into the `StartPositionTicks` to
+35/// hand to a remote session, or `None` if we're effectively at the start.
+36///
+37/// Pure helper so the resume-position math is unit-testable without a remote
+38/// session or HTTP. The *source* of `position_seconds` matters too: callers
+39/// must pass the live backend position (`PlayerController::position()`), not the
+40/// snapshot embedded in `PlayerState`, which is stale mid-track on Android.
+41fn start_position_ticks_from_seconds(position_seconds: f64) -> Option<i64> {
+42    if position_seconds > RESUME_THRESHOLD_SECONDS {
+43        Some((position_seconds * TICKS_PER_SECOND) as i64)
+44    } else {
+45        None
+46    }
+47}
+48
+49/// Platform hook for attaching/detaching the OS remote-volume control.
+50///
+51/// On Android, entering remote mode hands the `MediaSession` a
+52/// `VolumeProviderCompat` so hardware volume buttons and the system slider drive
+53/// the *remote* session; leaving remote mode must hand it back to the local
+54/// media stream. Behind a trait so the routing rule (see
+55/// [`PlaybackModeManager::set_mode`]) is unit-testable off-device — the real
+56/// implementation is JNI and only exists on Android.
+57pub trait RemoteVolumeControl: Send + Sync {
+58    /// Attach remote-volume control (and, on Android, start the playback service).
+59    fn enable(&self, initial_volume: i32);
+60    /// Return volume control to the local device speaker.
+61    fn disable(&self);
+62}
+63
+64/// Production hook: forwards to the Android JNI bridge; no-op elsewhere.
+65struct PlatformRemoteVolumeControl;
+66
+67impl RemoteVolumeControl for PlatformRemoteVolumeControl {
+68    #[allow(unused_variables)]
+69    fn enable(&self, initial_volume: i32) {
+70        #[cfg(target_os = "android")]
+71        {
+72            if let Err(e) = crate::player::enable_remote_volume(initial_volume) {
+73                log::warn!(
+74                    "[PlaybackMode] Failed to enable remote volume/service: {}",
+75                    e
+76                );
+77                // Non-fatal - continue; the next poll tick will retry metadata.
+78            }
+79        }
+80    }
+81
+82    fn disable(&self) {
+83        #[cfg(target_os = "android")]
+84        {
+85            if let Err(e) = crate::player::disable_remote_volume() {
+86                log::warn!("[PlaybackMode] Failed to disable remote volume: {}", e);
+87                // Non-fatal - the mode change itself has already happened.
+88            }
+89        }
+90    }
+91}
+92
+93/// Manages playback mode transfers between local and remote sessions
+94pub struct PlaybackModeManager {
+95    jellyfin_client: Arc<Mutex<Option<JellyfinClient>>>,
+96    player_controller: Arc<TokioMutex<PlayerController>>,
+97    current_mode: Arc<RwLock<PlaybackMode>>,
+98    is_transferring: Arc<AtomicBool>,
+99    /// Optional emitter used to notify the frontend when the mode changes, so its
+100    /// mirror store stays in sync with this authoritative one. `None` in tests.
+101    event_emitter: Arc<Mutex<Option<Arc<dyn PlayerEventEmitter>>>>,
+102    /// Platform hook for OS-level remote volume routing (swapped in tests).
+103    remote_volume: Arc<dyn RemoteVolumeControl>,
+104}
+105
+106impl PlaybackModeManager {
+107    /// Create a new playback mode manager
+108    pub fn new(
+109        jellyfin_client: Arc<Mutex<Option<JellyfinClient>>>,
+110        player_controller: Arc<TokioMutex<PlayerController>>,
+111    ) -> Self {
+112        Self {
+113            jellyfin_client,
+114            player_controller,
+115            current_mode: Arc::new(RwLock::new(PlaybackMode::Idle)),
+116            is_transferring: Arc::new(AtomicBool::new(false)),
+117            event_emitter: Arc::new(Mutex::new(None)),
+118            remote_volume: Arc::new(PlatformRemoteVolumeControl),
+119        }
+120    }
+121
+122    /// Construct with a custom remote-volume hook (tests).
+123    #[cfg(test)]
+124    fn with_remote_volume(
+125        jellyfin_client: Arc<Mutex<Option<JellyfinClient>>>,
+126        player_controller: Arc<TokioMutex<PlayerController>>,
+127        remote_volume: Arc<dyn RemoteVolumeControl>,
+128    ) -> Self {
+129        Self {
+130            jellyfin_client,
+131            player_controller,
+132            current_mode: Arc::new(RwLock::new(PlaybackMode::Idle)),
+133            is_transferring: Arc::new(AtomicBool::new(false)),
+134            event_emitter: Arc::new(Mutex::new(None)),
+135            remote_volume,
+136        }
+137    }
+138
+139    /// Wire the event emitter so `set_mode` notifies the frontend. Called once
+140    /// during setup; safe to leave unset (tests do), in which case mode changes
+141    /// simply aren't broadcast.
+142    pub fn set_event_emitter(&self, emitter: Arc<dyn PlayerEventEmitter>) {
+143        *self.event_emitter.lock_safe() = Some(emitter);
+144    }
+145
+146    /// Get current playback mode
+147    pub fn get_mode(&self) -> PlaybackMode {
+148        self.current_mode.read_safe().clone()
+149    }
+150
+151    /// Set playback mode (internal use).
+152    ///
+153    /// Broadcasts a `PlaybackModeChanged` event when the mode actually changes so
+154    /// the frontend's mirror store reconciles to this authoritative value. The
+155    /// write lock is released before emitting to avoid holding it across the
+156    /// emitter call.
+157    ///
+158    /// Also owns **OS volume routing**, which is derived from the transition
+159    /// rather than from each call site: entering remote mode attaches the remote
+160    /// volume control, and *any* exit from remote mode hands it back to the local
+161    /// speaker. Doing this per-call-site is what caused the bug where stopping a
+162    /// remote session (`player_stop` → Idle) left Android stuck on the remote
+163    /// volume slider — only the transfer-to-local path tore it down.
+164    ///
+165    /// TRACES: UR-010 | DR-059, IR-021
+166    pub fn set_mode(&self, mode: PlaybackMode) {
+167        log::info!("[PlaybackMode] Setting mode to: {:?}", mode);
+168        let (changed, was_remote) = {
+169            let mut current = self.current_mode.write_safe();
+170            let changed = *current != mode;
+171            let was_remote = matches!(*current, PlaybackMode::Remote { .. });
+172            *current = mode.clone();
+173            (changed, was_remote)
+174        };
+175
+176        if !changed {
+177            return;
+178        }
+179
+180        // Volume routing follows the transition. Note remote->remote (switching
+181        // target session) re-arms rather than releasing control.
+182        let is_remote = matches!(mode, PlaybackMode::Remote { .. });
+183        if is_remote {
+184            self.remote_volume.enable(DEFAULT_REMOTE_VOLUME);
+185        } else if was_remote {
+186            log::info!("[PlaybackMode] Leaving remote mode - restoring local volume control");
+187            self.remote_volume.disable();
+188        }
+189
+190        let (mode_str, session_id) = match &mode {
+191            PlaybackMode::Local => ("local".to_string(), None),
+192            PlaybackMode::Idle => ("idle".to_string(), None),
+193            PlaybackMode::Remote { session_id } => ("remote".to_string(), Some(session_id.clone())),
+194        };
+195
+196        if let Some(emitter) = self.event_emitter.lock_safe().as_ref() {
+197            emitter.emit(PlayerStatusEvent::PlaybackModeChanged {
+198                mode: mode_str,
+199                session_id,
+200            });
+201        }
+202    }
+203
+204    /// Start the Android playback service and hand it remote-volume control.
+205    ///
+206    /// Must run on EVERY transition into remote mode, because it is what starts
+207    /// the foreground service. Without a running service there is no media
+208    /// notification (the lockscreen card is missing) AND system volume buttons
+209    /// aren't intercepted for the remote session (remote volume control dead).
+210    /// Both symptoms share this one cause, so this must not be skipped on any
+211    /// remote-entry path (notably the empty-queue early return in
+212    /// `transfer_to_remote_inner`). No-op / non-Android builds do nothing.
+213    ///
+214    /// [`set_mode`](Self::set_mode) already arms this on entry into remote mode;
+215    /// calling it again is harmless (the service start is idempotent) and keeps
+216    /// the guarantee when the mode was already remote, which `set_mode` skips as
+217    /// a no-op transition.
+218    fn enable_remote_control(&self) {
+219        self.remote_volume.enable(DEFAULT_REMOTE_VOLUME);
+220    }
+221
+222    /// Check if currently transferring
+223    pub fn is_transferring(&self) -> bool {
+224        self.is_transferring.load(Ordering::Relaxed)
+225    }
+226
+227    /// Set the transferring flag directly.
+228    ///
+229    /// The remote->local transfer is driven from the frontend in two steps
+230    /// (`player_play_tracks` to start local playback, then
+231    /// `playback_mode_transfer_to_local` to stop the remote). The first step's
+232    /// routing depends on this flag: while it's set, `player_play_tracks` plays
+233    /// locally instead of casting back to the remote session. The frontend must
+234    /// raise the flag *before* that first call and lower it when the sequence is
+235    /// done (or aborts), so it can't be left stuck on.
+236    pub fn set_transferring(&self, transferring: bool) {
+237        self.is_transferring.store(transferring, Ordering::Relaxed);
+238    }
+239
+240    /// Send volume command to remote session
+241    /// Commands: "SetVolume", "VolumeUp", "VolumeDown"
+242    #[allow(dead_code)] // Called from Android JNI callback
+243    pub async fn send_remote_volume_command(
+244        &self,
+245        command: &str,
+246        volume: i32,
+247    ) -> Result<(), String> {
+248        log::info!(
+249            "[PlaybackMode] send_remote_volume_command ENTERED: command={}, volume={}",
+250            command,
+251            volume
+252        );
+253
+254        // Get the current session ID
+255        let session_id = match self.get_mode() {
+256            PlaybackMode::Remote { session_id } => session_id,
+257            _ => {
+258                log::warn!("[PlaybackMode] Ignoring remote volume command - not in remote mode");
+259                return Ok(());
+260            }
+261        };
+262
+263        log::info!(
+264            "[PlaybackMode] Current mode is Remote, session_id={}",
+265            session_id
+266        );
+267
+268        // Get Jellyfin client
+269        let client = {
+270            log::info!("[PlaybackMode] Attempting to lock Jellyfin client...");
+271            let client_opt = self.jellyfin_client.lock().map_err(|e| {
+272                log::error!("[PlaybackMode] Failed to lock Jellyfin client: {}", e);
+273                format!("Failed to lock Jellyfin client: {}", e)
+274            })?;
+275
+276            log::info!("[PlaybackMode] Jellyfin client lock acquired");
+277
+278            match client_opt.as_ref() {
+279                Some(c) => {
+280                    log::info!("[PlaybackMode] Jellyfin client is configured, cloning...");
+281                    c.clone()
+282                }
+283                None => {
+284                    log::error!("[PlaybackMode] Jellyfin client is NOT configured!");
+285                    return Err("Jellyfin client not configured".to_string());
+286                }
+287            }
+288        };
+289
+290        log::info!("[PlaybackMode] About to call client.session_set_volume...");
+291
+292        // Send the volume command
+293        log::info!(
+294            "[PlaybackMode] Sending {} command to session {} (volume: {})",
+295            command,
+296            session_id,
+297            volume
+298        );
+299        let result = client.session_set_volume(session_id, volume).await;
+300
+301        match &result {
+302            Ok(_) => log::info!("[PlaybackMode] session_set_volume returned Ok"),
+303            Err(e) => log::error!("[PlaybackMode] session_set_volume returned Err: {}", e),
+304        }
+305
+306        result
+307    }
+308
+309    /// Extract Jellyfin item IDs from queue items
+310    /// Returns (item_ids, adjusted_current_index)
+311    fn extract_jellyfin_ids(
+312        &self,
+313        items: &[crate::player::MediaItem],
+314        original_index: usize,
+315    ) -> Result<(Vec<String>, usize), String> {
+316        let mut jellyfin_ids: Vec<String> = Vec::new();
+317        let mut adjusted_index: Option<usize> = None;
+318        let mut jellyfin_item_count = 0;
+319
+320        for (i, item) in items.iter().enumerate() {
+321            if let Some(id) = item.jellyfin_id() {
+322                jellyfin_ids.push(id.to_string());
+323
+324                // If this is the currently playing item, record its new index
+325                if i == original_index {
+326                    adjusted_index = Some(jellyfin_item_count);
+327                }
+328
+329                jellyfin_item_count += 1;
+330            }
+331        }
+332
+333        // Ensure the currently playing item has a Jellyfin ID
+334        let final_index = match adjusted_index {
+335            Some(idx) => idx,
+336            None => {
+337                log::warn!(
+338                    "[PlaybackMode] Currently playing item (index {}) does not have a Jellyfin ID",
+339                    original_index
+340                );
+341                return Err(
+342                    "Cannot transfer: currently playing item is not from Jellyfin".to_string(),
+343                );
+344            }
+345        };
+346
+347        log::info!(
+348            "[PlaybackMode] Extracted {} Jellyfin IDs from queue (original index: {} -> adjusted: {})",
+349            jellyfin_ids.len(),
+350            original_index,
+351            final_index
+352        );
+353
+354        Ok((jellyfin_ids, final_index))
+355    }
+356
+357    /// Transfer playback from local device to remote Jellyfin session
+358    pub async fn transfer_to_remote(
+359        &self,
+360        session_id: String,
+361        position_override: Option<f64>,
+362    ) -> Result<(), String> {
+363        debug!("[PlaybackMode] transfer_to_remote ENTERED");
+364        debug!("[PlaybackMode] session_id: {}", session_id);
+365        log::info!(
+366            "[PlaybackMode] Transferring to remote session: {}",
+367            session_id
+368        );
+369
+370        // Set transferring flag
+371        debug!("[PlaybackMode] Setting is_transferring flag");
+372        self.is_transferring.store(true, Ordering::Relaxed);
+373        debug!("[PlaybackMode] Flag set, calling transfer_to_remote_inner");
+374
+375        // Perform the transfer
+376        let result = self
+377            .transfer_to_remote_inner(&session_id, position_override)
+378            .await;
+379
+380        // Clear transferring flag
+381        self.is_transferring.store(false, Ordering::Relaxed);
+382
+383        result
+384    }
+385
+386    async fn transfer_to_remote_inner(
+387        &self,
+388        session_id: &str,
+389        position_override: Option<f64>,
+390    ) -> Result<(), String> {
+391        log::info!("[PlaybackMode] transfer_to_remote_inner ENTERED");
+392        debug!(
+393            "[PlaybackMode] transfer_to_remote_inner: session_id={}",
+394            session_id
+395        );
+396
+397        // If we're already controlling a remote session, that *old* session — not
+398        // the idle local player — is the source of truth for the current track and
+399        // position. Capture it so we can resume there and stop it afterwards.
+400        let previous_remote_session = match self.get_mode() {
+401            PlaybackMode::Remote { session_id: prev } if prev != session_id => Some(prev),
+402            _ => None,
+403        };
+404
+405        // Get current player state and queue context
+406        let (queue_ids, mut current_index, mut position_seconds, queue_context) = {
+407            log::info!("[PlaybackMode] Acquiring player controller lock...");
+408            debug!("[PlaybackMode] Acquiring player controller lock...");
+409            let player = self.player_controller.lock().await;
+410            log::info!("[PlaybackMode] Player controller lock acquired");
+411            debug!("[PlaybackMode] Player controller lock acquired");
+412
+413            let queue_arc = player.queue();
+414            let queue = queue_arc.lock_safe();
+415
+416            let original_index = queue.current_index().unwrap_or(0);
+417            let items = queue.items();
+418
+419            log::info!(
+420                "[PlaybackMode] Queue has {} items, original_index={}",
+421                items.len(),
+422                original_index
+423            );
+424            debug!(
+425                "[PlaybackMode] Queue has {} items, original_index={}",
+426                items.len(),
+427                original_index
+428            );
+429
+430            // Log each item's jellyfin_id for debugging
+431            for (i, item) in items.iter().enumerate() {
+432                let jf_id = item.jellyfin_id().unwrap_or("NONE");
+433                log::debug!(
+434                    "[PlaybackMode] Item {}: id={}, jellyfin_id={}",
+435                    i,
+436                    item.id,
+437                    jf_id
+438                );
+439            }
+440
+441            let (ids, adjusted_index) = self.extract_jellyfin_ids(items, original_index)?;
+442            // Prefer the frontend-supplied position when available. The backend
+443            // position is unreliable as a transfer source: on Linux, *video* plays
+444            // in the HTML5 <video> element and the MPV backend is never loaded, so
+445            // PlayerController::position() is always 0; only the frontend knows the
+446            // true position. We fall back to the live backend position (correct for
+447            // Linux audio via MPV) when the frontend doesn't pass one.
+448            let position = match position_override {
+449                Some(p) => {
+450                    log::info!("[PlaybackMode] Using frontend position override: {:.2}s", p);
+451                    p
+452                }
+453                None => player.position(),
+454            };
+455            let context = queue.context().clone();
+456
+457            log::info!(
+458                "[PlaybackMode] Queue context: {:?}, {} items, current index: {}",
+459                context,
+460                ids.len(),
+461                adjusted_index
+462            );
+463            debug!(
+464                "[PlaybackMode] Extracted {} jellyfin IDs, adjusted_index={}, position={:.2}s",
+465                ids.len(),
+466                adjusted_index,
+467                position
+468            );
+469
+470            (ids, adjusted_index, position, context)
+471        };
+472
+473        // If queue is empty, just switch mode
+474        if queue_ids.is_empty() {
+475            log::info!("[PlaybackMode] Queue is empty, just switching mode");
+476            self.set_mode(PlaybackMode::Remote {
+477                session_id: session_id.to_string(),
+478            });
+479            // Start the service + remote-volume control here too — otherwise this
+480            // early return leaves remote mode with no media notification and no
+481            // volume interception (lockscreen card missing + remote volume dead).
+482            self.enable_remote_control();
+483            return Ok(());
+484        }
+485
+486        log::info!(
+487            "[PlaybackMode] Queue has {} items, current index: {}, position: {:.2}s",
+488            queue_ids.len(),
+489            current_index,
+490            position_seconds
+491        );
+492
+493        // Get Jellyfin client for remote transfer
+494        log::info!("[PlaybackMode] Getting Jellyfin client for transfer...");
+495        debug!("[PlaybackMode] Getting Jellyfin client for transfer...");
+496        let client = {
+497            let client_opt = self.jellyfin_client.lock().map_err(|e| {
+498                log::error!("[PlaybackMode] Failed to lock Jellyfin client: {}", e);
+499                error!("[PlaybackMode] Failed to lock Jellyfin client: {}", e);
+500                format!("Failed to lock Jellyfin client: {}", e)
+501            })?;
+502
+503            match client_opt.as_ref() {
+504                Some(c) => {
+505                    log::info!("[PlaybackMode] Jellyfin client is configured");
+506                    debug!("[PlaybackMode] Jellyfin client is configured");
+507                    c.clone()
+508                }
+509                None => {
+510                    log::error!("[PlaybackMode] Jellyfin client NOT configured!");
+511                    error!("[PlaybackMode] Jellyfin client NOT configured!");
+512                    return Err("Jellyfin client not configured".to_string());
+513                }
+514            }
+515        };
+516
+517        // Remote -> remote switch: take the current track and position from the
+518        // session we're leaving, since the local player is idle and reports 0.
+519        if let Some(ref prev_session_id) = previous_remote_session {
+520            log::info!(
+521                "[PlaybackMode] Remote->remote switch; reading state from previous session {}",
+522                prev_session_id
+523            );
+524            match client.get_session(prev_session_id).await {
+525                Ok(Some(session)) => {
+526                    // Resume at the previous session's position.
+527                    if let Some(ticks) =
+528                        session.play_state.as_ref().and_then(|ps| ps.position_ticks)
+529                    {
+530                        position_seconds = ticks as f64 / TICKS_PER_SECOND;
+531                        log::info!(
+532                            "[PlaybackMode] Using previous remote position: {:.2}s",
+533                            position_seconds
+534                        );
+535                    }
+536                    // Resume on whichever track the previous session reached.
+537                    if let Some(now_id) = session
+538                        .now_playing_item
+539                        .as_ref()
+540                        .and_then(|i| i.id.as_deref())
+541                    {
+542                        if let Some(idx) = queue_ids.iter().position(|id| id == now_id) {
+543                            log::info!(
+544                                "[PlaybackMode] Previous session is on track {} (queue index {})",
+545                                now_id,
+546                                idx
+547                            );
+548                            current_index = idx;
+549                        } else {
+550                            log::warn!(
+551                                "[PlaybackMode] Previous session's track {} not found in queue; keeping index {}",
+552                                now_id,
+553                                current_index
+554                            );
+555                        }
+556                    }
+557                }
+558                Ok(None) => log::warn!(
+559                    "[PlaybackMode] Previous remote session not found while reading state"
+560                ),
+561                Err(e) => log::warn!(
+562                    "[PlaybackMode] Failed to read previous remote session: {}",
+563                    e
+564                ),
+565            }
+566        }
+567
+568        // Calculate position in ticks (from the live position read above)
+569        let start_position_ticks = start_position_ticks_from_seconds(position_seconds);
+570
+571        // Log queue context for debugging (context is tracked but we always send track IDs)
+572        match &queue_context {
+573            QueueContext::Album {
+574                album_id,
+575                album_name,
+576            } => {
+577                log::info!(
+578                    "[PlaybackMode] Transferring album '{}' (ID: {}) with {} tracks to remote",
+579                    album_name,
+580                    album_id,
+581                    queue_ids.len()
+582                );
+583            }
+584            QueueContext::Playlist {
+585                playlist_id,
+586                playlist_name,
+587            } => {
+588                log::info!(
+589                    "[PlaybackMode] Transferring playlist '{}' (ID: {}) with {} tracks to remote",
+590                    playlist_name,
+591                    playlist_id,
+592                    queue_ids.len()
+593                );
+594            }
+595            QueueContext::Custom => {
+596                log::info!(
+597                    "[PlaybackMode] Transferring custom queue with {} tracks to remote",
+598                    queue_ids.len()
+599                );
+600            }
+601        }
+602
+603        // Always send individual track IDs - Jellyfin's play_on_session expects track IDs,
+604        // not album/playlist container IDs
+605        let expected_item_id = queue_ids
+606            .get(current_index)
+607            .cloned()
+608            .ok_or("Invalid start index")?;
+609
+610        // Send play command to remote session with all track IDs
+611        log::info!(
+612            "[PlaybackMode] Sending play command to remote session: {} ({} tracks, starting at index {}, position: {:.2}s)",
+613            session_id,
+614            queue_ids.len(),
+615            current_index,
+616            position_seconds
+617        );
+618        debug!(
+619            "[PlaybackMode] Calling play_on_session: session={}, tracks={}, index={}, position_ticks={:?}",
+620            session_id,
+621            queue_ids.len(),
+622            current_index,
+623            start_position_ticks
+624        );
+625
+626        // Log first few track IDs for debugging
+627        if !queue_ids.is_empty() {
+628            let preview: Vec<&str> = queue_ids.iter().take(3).map(|s| s.as_str()).collect();
+629            debug!("[PlaybackMode] First track IDs: {:?}...", preview);
+630        }
+631
+632        client
+633            .play_on_session(
+634                session_id.to_string(),
+635                queue_ids.clone(),
+636                current_index,
+637                start_position_ticks,
+638            )
+639            .await
+640            .map_err(|e| {
+641                log::error!("[PlaybackMode] Failed to send play command: {}", e);
+642                error!("[PlaybackMode] Failed to send play command: {}", e);
+643                format!("Failed to start playback on remote session: {}", e)
+644            })?;
+645
+646        log::info!("[PlaybackMode] Play command sent successfully");
+647        info!("[PlaybackMode] Play command sent successfully to remote session");
+648
+649        // Wait for remote session to load the track (poll with timeout)
+650        log::info!("[PlaybackMode] Waiting for remote session to load track...");
+651
+652        let mut attempts = 0;
+653        let max_attempts = 50; // 5 seconds max (50 * 100ms)
+654        let mut track_loaded = false;
+655
+656        while attempts < max_attempts {
+657            sleep(Duration::from_millis(100)).await;
+658            attempts += 1;
+659
+660            match client.get_session(session_id).await {
+661                Ok(Some(session)) => {
+662                    if let Some(now_playing) = &session.now_playing_item {
+663                        if now_playing.id.as_deref() == Some(&expected_item_id) {
+664                            log::info!(
+665                                "[PlaybackMode] Remote session loaded track '{}' after {}ms",
+666                                now_playing.name.as_deref().unwrap_or("Unknown"),
+667                                attempts * 100
+668                            );
+669                            track_loaded = true;
+670                            break;
+671                        }
+672                    }
+673                }
+674                Ok(None) => {
+675                    log::warn!("[PlaybackMode] Remote session not found while polling");
+676                    return Err("Remote session not found".to_string());
+677                }
+678                Err(e) => {
+679                    log::warn!(
+680                        "[PlaybackMode] Error polling session (attempt {}): {}",
+681                        attempts,
+682                        e
+683                    );
+684                    // Continue polling - transient errors are OK
+685                }
+686            }
+687        }
+688
+689        if !track_loaded {
+690            log::error!("[PlaybackMode] Timeout waiting for remote session to load track");
+691            return Err("Remote session did not load track in time".to_string());
+692        }
+693
+694        // Resume at the right position. We send StartPositionTicks in the play
+695        // command above, but some Jellyfin client/server combinations ignore it
+696        // and start from 0. Now that the track is confirmed loaded, issue an
+697        // explicit seek as well (mirrors how the local resume path works). This
+698        // is the reliable mechanism; StartPositionTicks is best-effort.
+699        if let Some(ticks) = start_position_ticks {
+700            log::info!(
+701                "[PlaybackMode] Seeking remote session to resume position: {} ticks",
+702                ticks
+703            );
+704            if let Err(e) = client.session_seek(session_id.to_string(), ticks).await {
+705                // Non-fatal: the track is already playing, just not at the
+706                // resume point. Log and continue rather than failing the transfer.
+707                log::warn!("[PlaybackMode] Resume seek on remote failed: {}", e);
+708            }
+709        }
+710
+711        // Remote -> remote switch: stop the session we just left so we don't end
+712        // up with two devices playing at once. Do this only after the new session
+713        // is confirmed playing, so a failure here doesn't leave us with silence.
+714        if let Some(prev_session_id) = previous_remote_session {
+715            log::info!(
+716                "[PlaybackMode] Stopping previous remote session {}",
+717                prev_session_id
+718            );
+719            if let Err(e) = client.send_session_command(prev_session_id, "Stop").await {
+720                log::warn!(
+721                    "[PlaybackMode] Failed to stop previous remote session: {}",
+722                    e
+723                );
+724            }
+725        }
+726
+727        // Stop local playback (queue should remain intact for remote session)
+728        log::info!("[PlaybackMode] Stopping local playback - queue should NOT be cleared");
+729        {
+730            let player = self.player_controller.lock().await;
+731
+732            // Log queue state BEFORE stop
+733            {
+734                let queue_arc = player.queue();
+735                let queue = queue_arc.lock_safe();
+736                info!(
+737                    "[PlaybackMode] BEFORE STOP: Queue has {} items, current_index={:?}",
+738                    queue.items().len(),
+739                    queue.current_index()
+740                );
+741            }
+742
+743            player
+744                .stop()
+745                .map_err(|e| format!("Failed to stop playback: {}", e))?;
+746
+747            // Log queue state AFTER stop (should be unchanged)
+748            {
+749                let queue_arc = player.queue();
+750                let queue = queue_arc.lock_safe();
+751                info!(
+752                    "[PlaybackMode] AFTER STOP: Queue has {} items, current_index={:?}",
+753                    queue.items().len(),
+754                    queue.current_index()
+755                );
+756            }
+757        }
+758
+759        // Update mode to remote
+760        self.set_mode(PlaybackMode::Remote {
+761            session_id: session_id.to_string(),
+762        });
+763
+764        // Start the service + remote-volume control (intercepts volume buttons,
+765        // and starts the foreground service that renders the lockscreen card).
+766        self.enable_remote_control();
+767
+768        log::info!("[PlaybackMode] Successfully transferred to remote");
+769        Ok(())
+770    }
+771
+772    /// Transfer playback from remote session back to local device
+773    pub async fn transfer_to_local(
+774        &self,
+775        current_item_id: String,
+776        position_ticks: i64,
+777    ) -> Result<(), String> {
+778        log::info!("[PlaybackMode] Transferring to local playback");
+779
+780        // Set transferring flag
+781        self.is_transferring.store(true, Ordering::Relaxed);
+782
+783        // Perform the transfer
+784        let result = self
+785            .transfer_to_local_inner(&current_item_id, position_ticks)
+786            .await;
+787
+788        // Clear transferring flag
+789        self.is_transferring.store(false, Ordering::Relaxed);
+790
+791        result
+792    }
+793
+794    async fn transfer_to_local_inner(
+795        &self,
+796        current_item_id: &str,
+797        position_ticks: i64,
+798    ) -> Result<(), String> {
+799        // Get current remote session info
+800        let session_id = match self.get_mode() {
+801            PlaybackMode::Remote { session_id } => session_id,
+802            _ => return Err("Not in remote playback mode".to_string()),
+803        };
+804
+805        let position_seconds = position_ticks as f64 / 10_000_000.0;
+806
+807        log::info!(
+808            "[PlaybackMode] Transfer to local: session={}, item_id={}, position={:.2}s",
+809            session_id,
+810            current_item_id,
+811            position_seconds
+812        );
+813
+814        // Get Jellyfin client for stopping remote playback
+815        let client = {
+816            let client_opt = self
+817                .jellyfin_client
+818                .lock()
+819                .map_err(|e| format!("Failed to lock Jellyfin client: {}", e))?;
+820
+821            client_opt
+822                .as_ref()
+823                .ok_or("Jellyfin client not configured")?
+824                .clone()
+825        };
+826
+827        // Stop remote playback
+828        log::info!(
+829            "[PlaybackMode] Stopping remote playback on session: {}",
+830            session_id
+831        );
+832        match client
+833            .send_session_command(session_id.clone(), "Stop")
+834            .await
+835        {
+836            Ok(_) => log::info!("[PlaybackMode] Stop command sent successfully"),
+837            Err(e) => {
+838                log::warn!(
+839                    "[PlaybackMode] Failed to stop remote session (non-fatal): {}",
+840                    e
+841                );
+842                // Don't fail the transfer if we can't stop the remote session
+843                // The user is already playing locally, so this is not critical
+844            }
+845        }
+846
+847        // For now, we'll return an error indicating that the TypeScript side needs to handle
+848        // loading the media item, since we don't have access to the repository here yet.
+849        // This will be improved in Phase 3 when repository is migrated to Rust.
+850        log::debug!("[PlaybackMode] Cannot load media item in Rust yet - frontend handled it");
+851
+852        // Update mode to local. This also returns volume control to the local
+853        // device speaker — set_mode owns that for every exit from remote mode.
+854        self.set_mode(PlaybackMode::Local);
+855
+856        log::info!("[PlaybackMode] Successfully transferred to local");
+857        Ok(())
+858    }
+859}
+860
+861#[cfg(test)]
+862mod tests {
+863    use super::*;
+864
+865    /// Test PlaybackMode enum serialization to JSON
+866    ///
+867    /// @req-test: DR-003 - Playback mode manager (Local/Remote/Idle states)
+868    /// @req-test: UR-010 - Control playback of Jellyfin remote sessions
+869    #[test]
+870    fn test_playback_mode_serialization() {
+871        let mode_idle = PlaybackMode::Idle;
+872        let json = serde_json::to_string(&mode_idle).unwrap();
+873        assert_eq!(json, r#"{"type":"idle"}"#);
+874
+875        let mode_local = PlaybackMode::Local;
+876        let json = serde_json::to_string(&mode_local).unwrap();
+877        assert_eq!(json, r#"{"type":"local"}"#);
+878
+879        let mode_remote = PlaybackMode::Remote {
+880            session_id: "abc123".to_string(),
+881        };
+882        let json = serde_json::to_string(&mode_remote).unwrap();
+883        assert!(json.contains(r#""type":"remote""#));
+884        assert!(json.contains(r#""session_id":"abc123""#));
+885    }
+886
+887    /// Test PlaybackMode enum deserialization from JSON
+888    ///
+889    /// @req-test: DR-003 - Playback mode manager (Local/Remote/Idle states)
+890    #[test]
+891    fn test_playback_mode_deserialization() {
+892        let json = r#"{"type":"idle"}"#;
+893        let mode: PlaybackMode = serde_json::from_str(json).unwrap();
+894        assert_eq!(mode, PlaybackMode::Idle);
+895
+896        let json = r#"{"type":"local"}"#;
+897        let mode: PlaybackMode = serde_json::from_str(json).unwrap();
+898        assert_eq!(mode, PlaybackMode::Local);
+899
+900        let json = r#"{"type":"remote","session_id":"test_session"}"#;
+901        let mode: PlaybackMode = serde_json::from_str(json).unwrap();
+902        assert_eq!(
+903            mode,
+904            PlaybackMode::Remote {
+905                session_id: "test_session".to_string()
+906            }
+907        );
+908    }
+909
+910    /// Capturing emitter so we can assert what `set_mode` broadcasts.
+911    struct CapturingEmitter {
+912        events: Mutex<Vec<PlayerStatusEvent>>,
+913    }
+914
+915    impl PlayerEventEmitter for CapturingEmitter {
+916        fn emit(&self, event: PlayerStatusEvent) {
+917            self.events.lock_safe().push(event);
+918        }
+919    }
+920
+921    fn manager_with_emitter() -> (PlaybackModeManager, Arc<CapturingEmitter>) {
+922        let emitter = Arc::new(CapturingEmitter {
+923            events: Mutex::new(Vec::new()),
+924        });
+925        let manager = PlaybackModeManager::new(
+926            Arc::new(Mutex::new(None)),
+927            Arc::new(TokioMutex::new(crate::player::PlayerController::default())),
+928        );
+929        manager.set_event_emitter(emitter.clone());
+930        (manager, emitter)
+931    }
+932
+933    /// set_mode broadcasts a PlaybackModeChanged event with the right payload so
+934    /// the frontend can reconcile its mirror store to this authoritative one.
+935    #[test]
+936    fn test_set_mode_emits_change_event() {
+937        let (manager, emitter) = manager_with_emitter();
+938
+939        manager.set_mode(PlaybackMode::Remote {
+940            session_id: "sess-1".to_string(),
+941        });
+942        manager.set_mode(PlaybackMode::Local);
+943        manager.set_mode(PlaybackMode::Idle);
+944
+945        let events = emitter.events.lock_safe();
+946        assert_eq!(events.len(), 3, "one event per real mode change");
+947
+948        match &events[0] {
+949            PlayerStatusEvent::PlaybackModeChanged { mode, session_id } => {
+950                assert_eq!(mode, "remote");
+951                assert_eq!(session_id.as_deref(), Some("sess-1"));
+952            }
+953            other => panic!("expected PlaybackModeChanged, got {:?}", other),
+954        }
+955        match &events[1] {
+956            PlayerStatusEvent::PlaybackModeChanged { mode, session_id } => {
+957                assert_eq!(mode, "local");
+958                assert_eq!(session_id.as_deref(), None);
+959            }
+960            other => panic!("expected PlaybackModeChanged, got {:?}", other),
+961        }
+962        match &events[2] {
+963            PlayerStatusEvent::PlaybackModeChanged { mode, session_id } => {
+964                assert_eq!(mode, "idle");
+965                assert_eq!(session_id.as_deref(), None);
+966            }
+967            other => panic!("expected PlaybackModeChanged, got {:?}", other),
+968        }
+969    }
+970
+971    /// Records enable/disable calls so tests can assert volume routing.
+972    struct RecordingVolumeControl {
+973        calls: Mutex<Vec<&'static str>>,
+974    }
+975
+976    impl RemoteVolumeControl for RecordingVolumeControl {
+977        fn enable(&self, _initial_volume: i32) {
+978            self.calls.lock_safe().push("enable");
+979        }
+980        fn disable(&self) {
+981            self.calls.lock_safe().push("disable");
+982        }
+983    }
+984
+985    fn manager_with_volume_control() -> (PlaybackModeManager, Arc<RecordingVolumeControl>) {
+986        let volume = Arc::new(RecordingVolumeControl {
+987            calls: Mutex::new(Vec::new()),
+988        });
+989        let manager = PlaybackModeManager::with_remote_volume(
+990            Arc::new(Mutex::new(None)),
+991            Arc::new(TokioMutex::new(crate::player::PlayerController::default())),
+992            volume.clone(),
+993        );
+994        (manager, volume)
+995    }
+996
+997    /// Leaving remote mode must hand volume control back to the local device.
+998    ///
+999    /// Stopping a remote session (`player_stop`) drives the manager
+1000    /// Remote -> Idle without going through `transfer_to_local`. Before this was
+1001    /// centralised in `set_mode`, only the transfer path tore the Android
+1002    /// `VolumeProviderCompat` down, so a plain stop left the system stuck on the
+1003    /// remote volume slider with no way back to the phone speaker.
+1004    ///
+1005    /// @req-test: UR-010 - Control playback of Jellyfin remote sessions
+1006    #[test]
+1007    fn test_leaving_remote_mode_restores_local_volume() {
+1008        let (manager, volume) = manager_with_volume_control();
+1009
+1010        manager.set_mode(PlaybackMode::Remote {
+1011            session_id: "sess-1".to_string(),
+1012        });
+1013        // The stop path: remote -> idle, no transfer involved.
+1014        manager.set_mode(PlaybackMode::Idle);
+1015
+1016        assert_eq!(
+1017            *volume.calls.lock_safe(),
+1018            vec!["enable", "disable"],
+1019            "remote->idle must return volume control to the local speaker"
+1020        );
+1021    }
+1022
+1023    /// The same must hold for remote -> local (transfer back to this device).
+1024    ///
+1025    /// @req-test: UR-010 - Control playback of Jellyfin remote sessions
+1026    #[test]
+1027    fn test_remote_to_local_restores_local_volume() {
+1028        let (manager, volume) = manager_with_volume_control();
+1029
+1030        manager.set_mode(PlaybackMode::Remote {
+1031            session_id: "sess-1".to_string(),
+1032        });
+1033        manager.set_mode(PlaybackMode::Local);
+1034
+1035        assert_eq!(
+1036            *volume.calls.lock_safe(),
+1037            vec!["enable", "disable"],
+1038            "remote->local must return volume control to the local speaker"
+1039        );
+1040    }
+1041
+1042    /// Volume routing must not be touched by transitions that never involve
+1043    /// remote mode — an idle->local start would otherwise issue a pointless
+1044    /// `setPlaybackToLocal` on every playback start.
+1045    ///
+1046    /// @req-test: UR-010 - Control playback of Jellyfin remote sessions
+1047    #[test]
+1048    fn test_non_remote_transitions_leave_volume_routing_alone() {
+1049        let (manager, volume) = manager_with_volume_control();
+1050
+1051        manager.set_mode(PlaybackMode::Local);
+1052        manager.set_mode(PlaybackMode::Idle);
+1053        manager.set_mode(PlaybackMode::Local);
+1054
+1055        assert!(
+1056            volume.calls.lock_safe().is_empty(),
+1057            "local/idle transitions must not touch remote volume routing"
+1058        );
+1059    }
+1060
+1061    /// Switching directly between two remote sessions stays remote: control must
+1062    /// remain attached (re-armed for the new session), never handed back local.
+1063    ///
+1064    /// @req-test: UR-010 - Control playback of Jellyfin remote sessions
+1065    #[test]
+1066    fn test_remote_to_remote_keeps_remote_volume() {
+1067        let (manager, volume) = manager_with_volume_control();
+1068
+1069        manager.set_mode(PlaybackMode::Remote {
+1070            session_id: "sess-1".to_string(),
+1071        });
+1072        manager.set_mode(PlaybackMode::Remote {
+1073            session_id: "sess-2".to_string(),
+1074        });
+1075
+1076        assert_eq!(
+1077            *volume.calls.lock_safe(),
+1078            vec!["enable", "enable"],
+1079            "remote->remote re-arms control without releasing it to local"
+1080        );
+1081    }
+1082
+1083    /// Setting the same mode twice must not re-emit — the frontend reconciler
+1084    /// (and the event channel) shouldn't be spammed on no-op transitions.
+1085    #[test]
+1086    fn test_set_mode_deduplicates_no_op() {
+1087        let (manager, emitter) = manager_with_emitter();
+1088
+1089        manager.set_mode(PlaybackMode::Local);
+1090        manager.set_mode(PlaybackMode::Local);
+1091        manager.set_mode(PlaybackMode::Local);
+1092
+1093        assert_eq!(
+1094            emitter.events.lock_safe().len(),
+1095            1,
+1096            "repeated identical mode set emits only once"
+1097        );
+1098    }
+1099
+1100    /// The resume position handed to a remote session is derived from a live
+1101    /// playback position. Guards the seconds->ticks conversion and the
+1102    /// at-the-start threshold (Bug: casting restarted the track from 0).
+1103    #[test]
+1104    fn test_start_position_ticks_from_seconds() {
+1105        // Mid-track positions convert to ticks (10M ticks per second).
+1106        assert_eq!(start_position_ticks_from_seconds(5.0), Some(50_000_000));
+1107        assert_eq!(
+1108            start_position_ticks_from_seconds(123.45),
+1109            Some(1_234_500_000)
+1110        );
+1111
+1112        // At/near the start, send no resume position so the track casts from 0.
+1113        assert_eq!(start_position_ticks_from_seconds(0.0), None);
+1114        assert_eq!(start_position_ticks_from_seconds(0.5), None);
+1115
+1116        // Just past the threshold resumes rather than restarting.
+1117        assert!(start_position_ticks_from_seconds(0.6).is_some());
+1118    }
+1119
+1120    // Tests for extract_jellyfin_ids - verify all track IDs are sent to remote, not just album/playlist ID
+1121    mod extract_jellyfin_ids_tests {
+1122        use crate::player::{MediaItem, MediaSource, MediaType};
+1123        use std::sync::{Arc, Mutex};
+1124        use tokio::sync::Mutex as TokioMutex;
+1125
+1126        fn create_test_item_with_jellyfin_id(id: &str, jellyfin_id: &str) -> MediaItem {
+1127            MediaItem {
+1128                id: id.to_string(),
+1129                title: format!("Track {}", id),
+1130                name: Some(format!("Track {}", id)),
+1131                artist: Some("Test Artist".to_string()),
+1132                album: Some("Test Album".to_string()),
+1133                album_name: Some("Test Album".to_string()),
+1134                album_id: Some("album_123".to_string()),
+1135                artist_items: None,
+1136                artists: Some(vec!["Test Artist".to_string()]),
+1137                primary_image_tag: None,
+1138                image_id: None,
+1139                item_type: Some("Audio".to_string()),
+1140                playlist_id: None,
+1141                duration: Some(180.0),
+1142                artwork_url: None,
+1143                media_type: MediaType::Audio,
+1144                source: MediaSource::Remote {
+1145                    stream_url: format!("http://example.com/{}.mp3", id),
+1146                    jellyfin_item_id: jellyfin_id.to_string(),
+1147                },
+1148                video_codec: None,
+1149                needs_transcoding: false,
+1150                video_width: None,
+1151                video_height: None,
+1152                subtitles: vec![],
+1153                series_id: None,
+1154                server_id: None,
+1155            }
+1156        }
+1157
+1158        fn create_test_item_local(id: &str) -> MediaItem {
+1159            MediaItem {
+1160                id: id.to_string(),
+1161                title: format!("Local Track {}", id),
+1162                name: Some(format!("Local Track {}", id)),
+1163                artist: Some("Test Artist".to_string()),
+1164                album: None,
+1165                album_name: None,
+1166                album_id: None,
+1167                artist_items: None,
+1168                artists: Some(vec!["Test Artist".to_string()]),
+1169                primary_image_tag: None,
+1170                image_id: None,
+1171                item_type: Some("Audio".to_string()),
+1172                playlist_id: None,
+1173                duration: Some(180.0),
+1174                artwork_url: None,
+1175                media_type: MediaType::Audio,
+1176                source: MediaSource::DirectUrl {
+1177                    url: format!("http://example.com/{}.mp3", id),
+1178                },
+1179                video_codec: None,
+1180                needs_transcoding: false,
+1181                video_width: None,
+1182                video_height: None,
+1183                subtitles: vec![],
+1184                series_id: None,
+1185                server_id: None,
+1186            }
+1187        }
+1188
+1189        /// Test extracting all Jellyfin track IDs from album
+1190        ///
+1191        /// Verifies that all individual track IDs are extracted, not just the album ID.
+1192        ///
+1193        /// @req-test: UR-010 - Control playback of Jellyfin remote sessions
+1194        /// @req-test: DR-003 - Playback mode manager (Jellyfin ID extraction)
+1195        /// @req-test: IR-012 - Jellyfin Sessions API for remote playback control
+1196        #[test]
+1197        fn test_extract_all_jellyfin_ids_from_album() {
+1198            // Simulate an album with 5 tracks - all should be extracted
+1199            let items: Vec<MediaItem> = (1..=5)
+1200                .map(|i| {
+1201                    create_test_item_with_jellyfin_id(
+1202                        &format!("track_{}", i),
+1203                        &format!("jf_track_{}", i),
+1204                    )
+1205                })
+1206                .collect();
+1207
+1208            let manager = super::PlaybackModeManager::new(
+1209                Arc::new(Mutex::new(None)),
+1210                Arc::new(TokioMutex::new(crate::player::PlayerController::default())),
+1211            );
+1212
+1213            let result = manager.extract_jellyfin_ids(&items, 2);
+1214            assert!(result.is_ok());
+1215
+1216            let (ids, index) = result.unwrap();
+1217
+1218            // All 5 track IDs should be extracted (not just the album ID)
+1219            assert_eq!(ids.len(), 5, "All 5 track IDs should be extracted");
+1220            assert_eq!(ids[0], "jf_track_1");
+1221            assert_eq!(ids[1], "jf_track_2");
+1222            assert_eq!(ids[2], "jf_track_3");
+1223            assert_eq!(ids[3], "jf_track_4");
+1224            assert_eq!(ids[4], "jf_track_5");
+1225
+1226            // Index should point to track 3 (original index 2)
+1227            assert_eq!(index, 2, "Current index should be preserved");
+1228        }
+1229
+1230        /// Test extracting Jellyfin IDs filters out local items
+1231        ///
+1232        /// @req-test: UR-010 - Control playback of remote sessions (local filtering)
+1233        /// @req-test: DR-003 - Playback mode manager (local item filtering)
+1234        #[test]
+1235        fn test_extract_filters_local_items() {
+1236            // Mix of Jellyfin and local items - only Jellyfin items should be extracted
+1237            let items = vec![
+1238                create_test_item_with_jellyfin_id("1", "jf_1"),
+1239                create_test_item_local("2"), // Local, no Jellyfin ID
+1240                create_test_item_with_jellyfin_id("3", "jf_3"),
+1241                create_test_item_with_jellyfin_id("4", "jf_4"),
+1242            ];
+1243
+1244            let manager = super::PlaybackModeManager::new(
+1245                Arc::new(Mutex::new(None)),
+1246                Arc::new(TokioMutex::new(crate::player::PlayerController::default())),
+1247            );
+1248
+1249            // Playing track 3 (index 2 in original, should become index 1 after filtering)
+1250            let result = manager.extract_jellyfin_ids(&items, 2);
+1251            assert!(result.is_ok());
+1252
+1253            let (ids, index) = result.unwrap();
+1254
+1255            // Only 3 Jellyfin tracks should be extracted
+1256            assert_eq!(ids.len(), 3);
+1257            assert_eq!(ids[0], "jf_1");
+1258            assert_eq!(ids[1], "jf_3");
+1259            assert_eq!(ids[2], "jf_4");
+1260
+1261            // Index should be adjusted (track 3 is now at position 1)
+1262            assert_eq!(index, 1);
+1263        }
+1264
+1265        /// Test extraction fails when current item is local
+1266        ///
+1267        /// @req-test: DR-003 - Playback mode manager (error handling)
+1268        /// @req-test: UR-010 - Control playback of remote sessions (validation)
+1269        #[test]
+1270        fn test_extract_fails_when_current_item_is_local() {
+1271            // Current item has no Jellyfin ID - should fail
+1272            let items = vec![
+1273                create_test_item_with_jellyfin_id("1", "jf_1"),
+1274                create_test_item_local("2"), // Local, no Jellyfin ID
+1275                create_test_item_with_jellyfin_id("3", "jf_3"),
+1276            ];
+1277
+1278            let manager = super::PlaybackModeManager::new(
+1279                Arc::new(Mutex::new(None)),
+1280                Arc::new(TokioMutex::new(crate::player::PlayerController::default())),
+1281            );
+1282
+1283            // Playing the local track (index 1) should fail
+1284            let result = manager.extract_jellyfin_ids(&items, 1);
+1285            assert!(result.is_err());
+1286            assert!(result.unwrap_err().contains("not from Jellyfin"));
+1287        }
+1288
+1289        /// Test extraction fails on empty queue
+1290        ///
+1291        /// @req-test: DR-003 - Playback mode manager (edge case: empty queue)
+1292        #[test]
+1293        fn test_extract_empty_queue() {
+1294            let items: Vec<MediaItem> = vec![];
+1295
+1296            let manager = super::PlaybackModeManager::new(
+1297                Arc::new(Mutex::new(None)),
+1298                Arc::new(TokioMutex::new(crate::player::PlayerController::default())),
+1299            );
+1300
+1301            let result = manager.extract_jellyfin_ids(&items, 0);
+1302            assert!(result.is_err());
+1303        }
+1304    }
+1305}
+
\ No newline at end of file diff --git a/api/src/jellytau_lib/playback_reporting/mod.rs.html b/api/src/jellytau_lib/playback_reporting/mod.rs.html new file mode 100644 index 00000000..cb592db5 --- /dev/null +++ b/api/src/jellytau_lib/playback_reporting/mod.rs.html @@ -0,0 +1,10 @@ +mod.rs - sourceSkip to main content

jellytau_lib/playback_reporting/
mod.rs

1pub mod reporter;
+2pub mod sync_processor;
+3pub mod throttle;
+4
+5pub use reporter::{PlaybackContext, PlaybackOperation, PlaybackReporter};
+6#[allow(unused_imports)] // Will be used when sync processor is integrated
+7pub use sync_processor::SyncProcessor;
+8#[allow(unused_imports)] // Will be used when position updates are hooked
+9pub use throttle::EventThrottler;
+
\ No newline at end of file diff --git a/api/src/jellytau_lib/playback_reporting/reporter.rs.html b/api/src/jellytau_lib/playback_reporting/reporter.rs.html new file mode 100644 index 00000000..7cf1cbd5 --- /dev/null +++ b/api/src/jellytau_lib/playback_reporting/reporter.rs.html @@ -0,0 +1,408 @@ +reporter.rs - sourceSkip to main content

jellytau_lib/playback_reporting/
reporter.rs

1//! Playback reporter implementation
+2//!
+3//! This module is fully implemented but not yet integrated with the player.
+4//! Dead code warnings are suppressed until integration is complete.
+5
+6#![allow(dead_code)]
+7
+8use std::sync::Arc;
+9use tokio::sync::Mutex as TokioMutex;
+10
+11use crate::jellyfin::client::JellyfinClient;
+12use crate::storage::db_service::{DatabaseService, Query, QueryParam, RusqliteService};
+13
+14/// Playback context information
+15#[derive(Debug, Clone)]
+16pub struct PlaybackContext {
+17    pub context_type: String, // "container" or "single"
+18    pub context_id: Option<String>,
+19}
+20
+21/// Playback operation types
+22#[derive(Debug, Clone)]
+23pub enum PlaybackOperation {
+24    Start {
+25        item_id: String,
+26        position_ticks: i64,
+27        context: Option<PlaybackContext>,
+28    },
+29    Progress {
+30        item_id: String,
+31        position_ticks: i64,
+32        is_paused: bool,
+33    },
+34    Stopped {
+35        item_id: String,
+36        position_ticks: i64,
+37    },
+38    MarkPlayed {
+39        item_id: String,
+40    },
+41}
+42
+43/// Main playback reporter that handles dual sync (local DB + server)
+44pub struct PlaybackReporter {
+45    db_service: Arc<RusqliteService>,
+46    jellyfin_client: Arc<TokioMutex<Option<JellyfinClient>>>,
+47    user_id: String,
+48}
+49
+50impl PlaybackReporter {
+51    /// Creates a new PlaybackReporter
+52    pub fn new(
+53        db_service: Arc<RusqliteService>,
+54        jellyfin_client: Arc<TokioMutex<Option<JellyfinClient>>>,
+55        user_id: String,
+56    ) -> Self {
+57        Self {
+58            db_service,
+59            jellyfin_client,
+60            user_id,
+61        }
+62    }
+63
+64    /// Reports a playback operation (dual sync: local DB + server)
+65    ///
+66    /// Always updates local DB first, then attempts server sync if online.
+67    /// If server sync fails, operation is queued for retry.
+68    pub async fn report(
+69        &self,
+70        operation: PlaybackOperation,
+71        is_online: bool,
+72    ) -> Result<(), String> {
+73        log::info!("[PlaybackReporter] Reporting operation: {:?}", operation);
+74
+75        // Always update local DB first (works offline)
+76        self.update_local_db(&operation).await?;
+77
+78        // If online, attempt server sync
+79        if is_online {
+80            if let Err(e) = self.sync_to_server(&operation).await {
+81                log::warn!("[PlaybackReporter] Server sync failed, queueing: {}", e);
+82                self.queue_for_sync(&operation).await?;
+83            } else {
+84                // Mark as synced on success
+85                if let Some(item_id) = self.get_item_id(&operation) {
+86                    self.mark_synced(&item_id).await?;
+87                }
+88            }
+89        } else {
+90            log::debug!("[PlaybackReporter] Offline - queueing operation");
+91            self.queue_for_sync(&operation).await?;
+92        }
+93
+94        Ok(())
+95    }
+96
+97    /// Updates local database with playback info
+98    async fn update_local_db(&self, operation: &PlaybackOperation) -> Result<(), String> {
+99        match operation {
+100            PlaybackOperation::Start {
+101                item_id,
+102                position_ticks,
+103                context,
+104            } => {
+105                let query = Query::with_params(
+106                    "INSERT INTO user_data (user_id, item_id, playback_position_ticks, last_played_at,
+107                                            playback_context_type, playback_context_id, pending_sync)
+108                     VALUES (?, ?, ?, CURRENT_TIMESTAMP, ?, ?, 1)
+109                     ON CONFLICT(user_id, item_id) DO UPDATE SET
+110                        playback_position_ticks = excluded.playback_position_ticks,
+111                        last_played_at = excluded.last_played_at,
+112                        playback_context_type = excluded.playback_context_type,
+113                        playback_context_id = excluded.playback_context_id,
+114                        pending_sync = 1",
+115                    vec![
+116                        QueryParam::String(self.user_id.clone()),
+117                        QueryParam::String(item_id.clone()),
+118                        QueryParam::Int64(*position_ticks),
+119                        context.as_ref().map(|c| QueryParam::String(c.context_type.clone())).unwrap_or(QueryParam::Null),
+120                        context.as_ref().and_then(|c| c.context_id.as_ref()).map(|id| QueryParam::String(id.clone())).unwrap_or(QueryParam::Null),
+121                    ],
+122                );
+123
+124                self.db_service
+125                    .execute(query)
+126                    .await
+127                    .map_err(|e| e.to_string())?;
+128                log::debug!("[PlaybackReporter] Updated local DB for start: {}", item_id);
+129            }
+130
+131            PlaybackOperation::Progress {
+132                item_id,
+133                position_ticks,
+134                is_paused: _,
+135            }
+136            | PlaybackOperation::Stopped {
+137                item_id,
+138                position_ticks,
+139            } => {
+140                let query = Query::with_params(
+141                    "INSERT INTO user_data (user_id, item_id, playback_position_ticks, last_played_at, pending_sync)
+142                     VALUES (?, ?, ?, CURRENT_TIMESTAMP, 1)
+143                     ON CONFLICT(user_id, item_id) DO UPDATE SET
+144                        playback_position_ticks = excluded.playback_position_ticks,
+145                        last_played_at = excluded.last_played_at,
+146                        pending_sync = 1",
+147                    vec![
+148                        QueryParam::String(self.user_id.clone()),
+149                        QueryParam::String(item_id.clone()),
+150                        QueryParam::Int64(*position_ticks),
+151                    ],
+152                );
+153
+154                self.db_service
+155                    .execute(query)
+156                    .await
+157                    .map_err(|e| e.to_string())?;
+158                log::debug!(
+159                    "[PlaybackReporter] Updated local DB for progress/stop: {}",
+160                    item_id
+161                );
+162            }
+163
+164            PlaybackOperation::MarkPlayed { item_id } => {
+165                let query = Query::with_params(
+166                    "INSERT INTO user_data (user_id, item_id, is_played, play_count, last_played_at, pending_sync)
+167                     VALUES (?, ?, 1, 1, CURRENT_TIMESTAMP, 1)
+168                     ON CONFLICT(user_id, item_id) DO UPDATE SET
+169                        is_played = 1,
+170                        play_count = COALESCE(play_count, 0) + 1,
+171                        last_played_at = CURRENT_TIMESTAMP,
+172                        pending_sync = 1",
+173                    vec![
+174                        QueryParam::String(self.user_id.clone()),
+175                        QueryParam::String(item_id.clone()),
+176                    ],
+177                );
+178
+179                self.db_service
+180                    .execute(query)
+181                    .await
+182                    .map_err(|e| e.to_string())?;
+183                log::debug!(
+184                    "[PlaybackReporter] Updated local DB for mark played: {}",
+185                    item_id
+186                );
+187            }
+188        }
+189
+190        Ok(())
+191    }
+192
+193    /// Syncs to Jellyfin server
+194    async fn sync_to_server(&self, operation: &PlaybackOperation) -> Result<(), String> {
+195        let client_guard = self.jellyfin_client.lock().await;
+196        let client = client_guard
+197            .as_ref()
+198            .ok_or("JellyfinClient not initialized")?;
+199
+200        match operation {
+201            PlaybackOperation::Start {
+202                item_id,
+203                position_ticks,
+204                ..
+205            } => {
+206                client
+207                    .report_playback_start(
+208                        item_id.clone(),
+209                        *position_ticks,
+210                        None, // play_session_id
+211                    )
+212                    .await?;
+213                log::info!("[PlaybackReporter] Reported start to server: {}", item_id);
+214            }
+215
+216            PlaybackOperation::Progress {
+217                item_id,
+218                position_ticks,
+219                is_paused,
+220            } => {
+221                client
+222                    .report_playback_progress(
+223                        item_id.clone(),
+224                        *position_ticks,
+225                        *is_paused,
+226                        None, // play_session_id
+227                    )
+228                    .await?;
+229                log::debug!(
+230                    "[PlaybackReporter] Reported progress to server: {} (paused: {})",
+231                    item_id,
+232                    is_paused
+233                );
+234            }
+235
+236            PlaybackOperation::Stopped {
+237                item_id,
+238                position_ticks,
+239            } => {
+240                client
+241                    .report_playback_stopped(
+242                        item_id.clone(),
+243                        *position_ticks,
+244                        None, // play_session_id
+245                    )
+246                    .await?;
+247                log::info!("[PlaybackReporter] Reported stop to server: {}", item_id);
+248            }
+249
+250            PlaybackOperation::MarkPlayed { item_id } => {
+251                // For mark as played, we need to get the item's runtime
+252                // For now, report as stopped at max position
+253                // TODO: Fetch item runtime from DB or assume 100% completion
+254                let max_ticks = i64::MAX; // Temporary - should be actual runtime
+255                client
+256                    .report_playback_stopped(item_id.clone(), max_ticks, None)
+257                    .await?;
+258                log::info!(
+259                    "[PlaybackReporter] Reported mark played to server: {}",
+260                    item_id
+261                );
+262            }
+263        }
+264
+265        Ok(())
+266    }
+267
+268    /// Queues operation for later sync
+269    async fn queue_for_sync(&self, operation: &PlaybackOperation) -> Result<(), String> {
+270        let (op_name, item_id, payload) = match operation {
+271            PlaybackOperation::Start {
+272                item_id,
+273                position_ticks,
+274                context,
+275            } => {
+276                let payload_data = serde_json::json!({
+277                    "position_ticks": position_ticks,
+278                    "context_type": context.as_ref().map(|c| &c.context_type),
+279                    "context_id": context.as_ref().and_then(|c| c.context_id.as_ref()),
+280                });
+281                (
+282                    "report_playback_start",
+283                    Some(item_id.clone()),
+284                    Some(payload_data.to_string()),
+285                )
+286            }
+287
+288            PlaybackOperation::Progress { .. } => {
+289                // Don't queue progress reports - too frequent
+290                // Progress is captured by final stop report
+291                log::debug!("[PlaybackReporter] Skipping queue for progress report (too frequent)");
+292                return Ok(());
+293            }
+294
+295            PlaybackOperation::Stopped {
+296                item_id,
+297                position_ticks,
+298            } => {
+299                let payload_data = serde_json::json!({
+300                    "position_ticks": position_ticks,
+301                });
+302                (
+303                    "report_playback_stopped",
+304                    Some(item_id.clone()),
+305                    Some(payload_data.to_string()),
+306                )
+307            }
+308
+309            PlaybackOperation::MarkPlayed { item_id } => {
+310                ("mark_played", Some(item_id.clone()), None)
+311            }
+312        };
+313
+314        let query = Query::with_params(
+315            "INSERT INTO sync_queue (user_id, operation, item_id, payload, status, created_at)
+316             VALUES (?, ?, ?, ?, 'pending', CURRENT_TIMESTAMP)",
+317            vec![
+318                QueryParam::String(self.user_id.clone()),
+319                QueryParam::String(op_name.to_string()),
+320                item_id.map(QueryParam::String).unwrap_or(QueryParam::Null),
+321                payload.map(QueryParam::String).unwrap_or(QueryParam::Null),
+322            ],
+323        );
+324
+325        self.db_service
+326            .execute(query)
+327            .await
+328            .map_err(|e| e.to_string())?;
+329        log::info!("[PlaybackReporter] Queued operation: {}", op_name);
+330
+331        Ok(())
+332    }
+333
+334    /// Marks an item as synced in the local database
+335    async fn mark_synced(&self, item_id: &str) -> Result<(), String> {
+336        let query = Query::with_params(
+337            "UPDATE user_data SET pending_sync = 0 WHERE user_id = ? AND item_id = ?",
+338            vec![
+339                QueryParam::String(self.user_id.clone()),
+340                QueryParam::String(item_id.to_string()),
+341            ],
+342        );
+343
+344        self.db_service
+345            .execute(query)
+346            .await
+347            .map_err(|e| e.to_string())?;
+348        log::debug!("[PlaybackReporter] Marked as synced: {}", item_id);
+349
+350        Ok(())
+351    }
+352
+353    /// Extracts item_id from operation
+354    fn get_item_id(&self, operation: &PlaybackOperation) -> Option<String> {
+355        match operation {
+356            PlaybackOperation::Start { item_id, .. }
+357            | PlaybackOperation::Progress { item_id, .. }
+358            | PlaybackOperation::Stopped { item_id, .. }
+359            | PlaybackOperation::MarkPlayed { item_id } => Some(item_id.clone()),
+360        }
+361    }
+362}
+363
+364impl Clone for PlaybackReporter {
+365    fn clone(&self) -> Self {
+366        Self {
+367            db_service: Arc::clone(&self.db_service),
+368            jellyfin_client: Arc::clone(&self.jellyfin_client),
+369            user_id: self.user_id.clone(),
+370        }
+371    }
+372}
+373
+374#[cfg(test)]
+375mod tests {
+376    use super::*;
+377
+378    // Unit tests will be added incrementally as dependencies are mocked
+379
+380    #[test]
+381    fn test_playback_operation_debug() {
+382        let op = PlaybackOperation::Start {
+383            item_id: "item123".to_string(),
+384            position_ticks: 1000,
+385            context: Some(PlaybackContext {
+386                context_type: "container".to_string(),
+387                context_id: Some("album456".to_string()),
+388            }),
+389        };
+390
+391        let debug_str = format!("{:?}", op);
+392        assert!(debug_str.contains("Start"));
+393        assert!(debug_str.contains("item123"));
+394    }
+395
+396    #[test]
+397    fn test_playback_context_clone() {
+398        let context = PlaybackContext {
+399            context_type: "single".to_string(),
+400            context_id: None,
+401        };
+402
+403        let cloned = context.clone();
+404        assert_eq!(cloned.context_type, "single");
+405        assert_eq!(cloned.context_id, None);
+406    }
+407}
+
\ No newline at end of file diff --git a/api/src/jellytau_lib/playback_reporting/sync_processor.rs.html b/api/src/jellytau_lib/playback_reporting/sync_processor.rs.html new file mode 100644 index 00000000..53fa4e70 --- /dev/null +++ b/api/src/jellytau_lib/playback_reporting/sync_processor.rs.html @@ -0,0 +1,128 @@ +sync_processor.rs - sourceSkip to main content

jellytau_lib/playback_reporting/
sync_processor.rs

1//! Sync queue processor with retry logic and exponential backoff
+2//!
+3//! This is a placeholder implementation. Full implementation will be added
+4//! when the reporter is integrated. Dead code warnings are suppressed.
+5
+6#![allow(dead_code)]
+7#![allow(unused_imports)]
+8
+9use std::sync::atomic::{AtomicBool, Ordering};
+10use std::sync::Arc;
+11use std::time::Duration;
+12use tokio::sync::Mutex as TokioMutex;
+13
+14use crate::jellyfin::client::JellyfinClient;
+15use crate::repository::MediaRepository;
+16use crate::storage::db_service::RusqliteService;
+17
+18/// Configuration for sync processor
+19pub struct SyncConfig {
+20    pub max_retries: u32,         // 5
+21    pub base_retry_delay_ms: u64, // 1000ms
+22    pub batch_size: usize,        // 10 items
+23}
+24
+25impl Default for SyncConfig {
+26    fn default() -> Self {
+27        Self {
+28            max_retries: 5,
+29            base_retry_delay_ms: 1000,
+30            batch_size: 10,
+31        }
+32    }
+33}
+34
+35/// Sync queue processor that handles retry logic with exponential backoff
+36///
+37/// This is a placeholder implementation. Full implementation will be added
+38/// in a subsequent task following the plan.
+39pub struct SyncProcessor {
+40    _db_service: Arc<RusqliteService>,
+41    _jellyfin_client: Arc<TokioMutex<Option<JellyfinClient>>>,
+42    _repository: Arc<dyn MediaRepository>,
+43    _processing: Arc<TokioMutex<bool>>,
+44    _cancelled: Arc<AtomicBool>,
+45    _config: SyncConfig,
+46}
+47
+48impl SyncProcessor {
+49    /// Creates a new SyncProcessor
+50    pub fn new(
+51        db_service: Arc<RusqliteService>,
+52        jellyfin_client: Arc<TokioMutex<Option<JellyfinClient>>>,
+53        repository: Arc<dyn MediaRepository>,
+54    ) -> Self {
+55        Self {
+56            _db_service: db_service,
+57            _jellyfin_client: jellyfin_client,
+58            _repository: repository,
+59            _processing: Arc::new(TokioMutex::new(false)),
+60            _cancelled: Arc::new(AtomicBool::new(false)),
+61            _config: SyncConfig::default(),
+62        }
+63    }
+64
+65    /// Starts the sync processor
+66    pub async fn start(&self) -> Result<(), String> {
+67        log::info!("[SyncProcessor] Started (placeholder implementation)");
+68        // TODO: Implement full processor logic
+69        Ok(())
+70    }
+71
+72    /// Stops the sync processor
+73    pub async fn stop(&self) -> Result<(), String> {
+74        log::info!("[SyncProcessor] Stopped (placeholder implementation)");
+75        // TODO: Implement stop logic
+76        Ok(())
+77    }
+78
+79    /// Processes the sync queue once
+80    pub async fn process_queue(&self) -> Result<(), String> {
+81        log::debug!("[SyncProcessor] Processing queue (placeholder)");
+82        // TODO: Implement queue processing
+83        Ok(())
+84    }
+85
+86    /// Calculates exponential backoff delay
+87    fn _calculate_backoff(&self, retry_count: u32) -> Duration {
+88        let delay_ms = self._config.base_retry_delay_ms * 2_u64.pow(retry_count);
+89        let max_delay_ms = 10_000; // 10 seconds max
+90        Duration::from_millis(delay_ms.min(max_delay_ms))
+91    }
+92}
+93
+94#[cfg(test)]
+95mod tests {
+96    use super::*;
+97
+98    #[test]
+99    fn test_sync_config_default() {
+100        let config = SyncConfig::default();
+101        assert_eq!(config.max_retries, 5);
+102        assert_eq!(config.base_retry_delay_ms, 1000);
+103        assert_eq!(config.batch_size, 10);
+104    }
+105
+106    // TODO: Re-enable when SyncProcessor is fully implemented
+107    // #[test]
+108    // fn test_calculate_backoff() {
+109    //     let config = SyncConfig::default();
+110    //     let processor = SyncProcessor {
+111    //         _db_service: Arc::new(unsafe { std::mem::zeroed() }),  // Placeholder for test
+112    //         _jellyfin_client: Arc::new(TokioMutex::new(None)),
+113    //         _repository: Arc::new(unsafe { std::mem::zeroed() }),  // Placeholder for test
+114    //         _processing: Arc::new(TokioMutex::new(false)),
+115    //         _cancelled: Arc::new(AtomicBool::new(false)),
+116    //         _config: config,
+117    //     };
+118    //
+119    //     // Test exponential backoff: 1s, 2s, 4s, 8s, 10s (capped)
+120    //     assert_eq!(processor._calculate_backoff(0), Duration::from_millis(1000));
+121    //     assert_eq!(processor._calculate_backoff(1), Duration::from_millis(2000));
+122    //     assert_eq!(processor._calculate_backoff(2), Duration::from_millis(4000));
+123    //     assert_eq!(processor._calculate_backoff(3), Duration::from_millis(8000));
+124    //     assert_eq!(processor._calculate_backoff(4), Duration::from_millis(10000)); // capped
+125    //     assert_eq!(processor._calculate_backoff(5), Duration::from_millis(10000)); // capped
+126    // }
+127}
+
\ No newline at end of file diff --git a/api/src/jellytau_lib/playback_reporting/throttle.rs.html b/api/src/jellytau_lib/playback_reporting/throttle.rs.html new file mode 100644 index 00000000..c3e529b7 --- /dev/null +++ b/api/src/jellytau_lib/playback_reporting/throttle.rs.html @@ -0,0 +1,177 @@ +throttle.rs - sourceSkip to main content

jellytau_lib/playback_reporting/
throttle.rs

1//! Event throttler for position update reporting
+2//!
+3//! This module is fully implemented but not yet integrated with the player.
+4//! Dead code warnings are suppressed until integration is complete.
+5
+6#![allow(dead_code)]
+7
+8use crate::utils::lock::MutexSafe;
+9use std::collections::HashMap;
+10use std::sync::{Arc, Mutex};
+11use std::time::{Duration, Instant};
+12
+13/// Event throttler to prevent spam from frequent position updates.
+14///
+15/// Tracks the last report time for each item and ensures reports are only
+16/// sent at most once per throttle duration (default 30 seconds).
+17pub struct EventThrottler {
+18    last_report_time: Arc<Mutex<HashMap<String, Instant>>>,
+19    throttle_duration: Duration,
+20}
+21
+22impl EventThrottler {
+23    /// Creates a new EventThrottler with 30 second default interval
+24    pub fn new() -> Self {
+25        Self::with_duration(Duration::from_secs(30))
+26    }
+27
+28    /// Creates a new EventThrottler with custom interval
+29    pub fn with_duration(duration: Duration) -> Self {
+30        Self {
+31            last_report_time: Arc::new(Mutex::new(HashMap::new())),
+32            throttle_duration: duration,
+33        }
+34    }
+35
+36    /// Checks if enough time has elapsed since the last report for this item
+37    pub fn should_report(&self, item_id: &str) -> bool {
+38        let last_times = self.last_report_time.lock_safe();
+39
+40        if let Some(last_time) = last_times.get(item_id) {
+41            let elapsed = last_time.elapsed();
+42            if elapsed < self.throttle_duration {
+43                log::debug!(
+44                    "[EventThrottler] Skipping report for {}, last reported {:.1}s ago (threshold: {}s)",
+45                    item_id,
+46                    elapsed.as_secs_f64(),
+47                    self.throttle_duration.as_secs()
+48                );
+49                return false;
+50            }
+51        }
+52
+53        true
+54    }
+55
+56    /// Marks the item as reported at the current time
+57    pub fn mark_reported(&self, item_id: &str) {
+58        let mut last_times = self.last_report_time.lock_safe();
+59        last_times.insert(item_id.to_string(), Instant::now());
+60
+61        log::debug!(
+62            "[EventThrottler] Marked {} as reported at {:?}",
+63            item_id,
+64            Instant::now()
+65        );
+66    }
+67
+68    /// Clears all tracked report times
+69    pub fn clear(&self) {
+70        let mut last_times = self.last_report_time.lock_safe();
+71        last_times.clear();
+72        log::debug!("[EventThrottler] Cleared all tracked report times");
+73    }
+74
+75    /// Removes a specific item from tracking
+76    pub fn clear_item(&self, item_id: &str) {
+77        let mut last_times = self.last_report_time.lock_safe();
+78        last_times.remove(item_id);
+79        log::debug!("[EventThrottler] Cleared tracking for {}", item_id);
+80    }
+81}
+82
+83impl Default for EventThrottler {
+84    fn default() -> Self {
+85        Self::new()
+86    }
+87}
+88
+89impl Clone for EventThrottler {
+90    fn clone(&self) -> Self {
+91        Self {
+92            last_report_time: Arc::clone(&self.last_report_time),
+93            throttle_duration: self.throttle_duration,
+94        }
+95    }
+96}
+97
+98#[cfg(test)]
+99mod tests {
+100    use super::*;
+101    use std::thread;
+102
+103    #[test]
+104    fn test_throttler_allows_first_report() {
+105        let throttler = EventThrottler::new();
+106        assert!(throttler.should_report("item1"));
+107    }
+108
+109    #[test]
+110    fn test_throttler_blocks_immediate_second_report() {
+111        let throttler = EventThrottler::new();
+112        assert!(throttler.should_report("item1"));
+113        throttler.mark_reported("item1");
+114        assert!(!throttler.should_report("item1"));
+115    }
+116
+117    #[test]
+118    fn test_throttler_allows_report_after_duration() {
+119        let throttler = EventThrottler::with_duration(Duration::from_millis(100));
+120        assert!(throttler.should_report("item1"));
+121        throttler.mark_reported("item1");
+122        assert!(!throttler.should_report("item1"));
+123
+124        thread::sleep(Duration::from_millis(150));
+125        assert!(throttler.should_report("item1"));
+126    }
+127
+128    #[test]
+129    fn test_throttler_handles_multiple_items() {
+130        let throttler = EventThrottler::new();
+131        assert!(throttler.should_report("item1"));
+132        throttler.mark_reported("item1");
+133
+134        assert!(throttler.should_report("item2"));
+135        throttler.mark_reported("item2");
+136
+137        assert!(!throttler.should_report("item1"));
+138        assert!(!throttler.should_report("item2"));
+139    }
+140
+141    #[test]
+142    fn test_throttler_clear() {
+143        let throttler = EventThrottler::new();
+144        throttler.mark_reported("item1");
+145        assert!(!throttler.should_report("item1"));
+146
+147        throttler.clear();
+148        assert!(throttler.should_report("item1"));
+149    }
+150
+151    #[test]
+152    fn test_throttler_clear_item() {
+153        let throttler = EventThrottler::new();
+154        throttler.mark_reported("item1");
+155        throttler.mark_reported("item2");
+156
+157        assert!(!throttler.should_report("item1"));
+158        assert!(!throttler.should_report("item2"));
+159
+160        throttler.clear_item("item1");
+161        assert!(throttler.should_report("item1"));
+162        assert!(!throttler.should_report("item2"));
+163    }
+164
+165    #[test]
+166    fn test_throttler_clone_shares_state() {
+167        let throttler1 = EventThrottler::new();
+168        throttler1.mark_reported("item1");
+169
+170        let throttler2 = throttler1.clone();
+171        assert!(!throttler2.should_report("item1"));
+172
+173        throttler2.mark_reported("item2");
+174        assert!(!throttler1.should_report("item2"));
+175    }
+176}
+
\ No newline at end of file diff --git a/api/src/jellytau_lib/player/autoplay.rs.html b/api/src/jellytau_lib/player/autoplay.rs.html new file mode 100644 index 00000000..183df82f --- /dev/null +++ b/api/src/jellytau_lib/player/autoplay.rs.html @@ -0,0 +1,120 @@ +autoplay.rs - sourceSkip to main content

jellytau_lib/player/
autoplay.rs

1// Autoplay decision logic
+2// TRACES: UR-023, UR-026 | DR-047, DR-048, DR-029
+3use crate::repository::types::MediaItem;
+4use serde::{Deserialize, Serialize};
+5
+6/// Autoplay decision result - determines what happens after playback ends
+7#[derive(specta::Type, Debug, Clone, Serialize)]
+8#[serde(tag = "action", rename_all = "camelCase")]
+9// `ShowNextEpisodePopup` carries two `MediaItem`s, so it dwarfs the unit
+10// variants. Boxing them is not worth it here: this enum is constructed once per
+11// end-of-item (never in a hot loop or a large collection), and it is an IPC type
+12// — the indirection would have to stay invisible to serde/specta while every
+13// match arm gained a deref, for no measurable gain.
+14#[allow(clippy::large_enum_variant)]
+15pub enum AutoplayDecision {
+16    /// Stop playback (no next item or timer expired)
+17    Stop,
+18    /// Advance to next track in queue (for audio/movies)
+19    AdvanceToNext,
+20    /// The stream ended well short of the item's runtime — the connection
+21    /// dropped, not the media. Re-open the same stream at `position` instead of
+22    /// running any end-of-item logic (UR-040).
+23    ResumeStream { position: f64 },
+24    /// Show next episode popup with countdown
+25    ShowNextEpisodePopup {
+26        current_episode: MediaItem,
+27        next_episode: MediaItem,
+28        countdown_seconds: u32,
+29        auto_advance: bool,
+30    },
+31}
+32
+33/// Autoplay settings (controls next episode behavior)
+34#[derive(specta::Type, Debug, Clone, Serialize, Deserialize)]
+35#[serde(rename_all = "camelCase")]
+36pub struct AutoplaySettings {
+37    /// Whether autoplay is enabled for next episodes
+38    pub enabled: bool,
+39    /// Countdown duration in seconds before auto-playing next episode
+40    pub countdown_seconds: u32,
+41    /// Maximum number of episodes to auto-play consecutively (0 = unlimited)
+42    #[serde(default)]
+43    pub max_episodes: u32,
+44}
+45
+46impl Default for AutoplaySettings {
+47    fn default() -> Self {
+48        Self {
+49            enabled: true,
+50            countdown_seconds: 10,
+51            max_episodes: 0,
+52        }
+53    }
+54}
+55
+56impl AutoplaySettings {
+57    /// Validate and clamp countdown seconds to reasonable range (5-30 seconds)
+58    pub fn with_validated_countdown(mut self) -> Self {
+59        self.countdown_seconds = self.countdown_seconds.clamp(5, 30);
+60        self
+61    }
+62}
+63
+64#[cfg(test)]
+65mod tests {
+66    use super::*;
+67
+68    #[test]
+69    fn test_autoplay_settings_defaults() {
+70        let settings = AutoplaySettings::default();
+71        assert!(settings.enabled);
+72        assert_eq!(settings.countdown_seconds, 10);
+73        assert_eq!(settings.max_episodes, 0);
+74    }
+75
+76    #[test]
+77    fn test_autoplay_settings_backward_compat() {
+78        // Deserialize old JSON without max_episodes field
+79        let json = r#"{"enabled":true,"countdownSeconds":15}"#;
+80        let settings: AutoplaySettings = serde_json::from_str(json).unwrap();
+81        assert!(settings.enabled);
+82        assert_eq!(settings.countdown_seconds, 15);
+83        assert_eq!(settings.max_episodes, 0); // defaults to 0 (unlimited)
+84    }
+85
+86    #[test]
+87    fn test_autoplay_settings_with_max_episodes() {
+88        let json = r#"{"enabled":true,"countdownSeconds":10,"maxEpisodes":5}"#;
+89        let settings: AutoplaySettings = serde_json::from_str(json).unwrap();
+90        assert_eq!(settings.max_episodes, 5);
+91    }
+92
+93    #[test]
+94    fn test_countdown_validation() {
+95        let settings = AutoplaySettings {
+96            enabled: true,
+97            countdown_seconds: 2, // Too short
+98            max_episodes: 0,
+99        }
+100        .with_validated_countdown();
+101        assert_eq!(settings.countdown_seconds, 5); // Clamped to min
+102
+103        let settings = AutoplaySettings {
+104            enabled: true,
+105            countdown_seconds: 60, // Too long
+106            max_episodes: 0,
+107        }
+108        .with_validated_countdown();
+109        assert_eq!(settings.countdown_seconds, 30); // Clamped to max
+110
+111        let settings = AutoplaySettings {
+112            enabled: true,
+113            countdown_seconds: 15, // Valid
+114            max_episodes: 0,
+115        }
+116        .with_validated_countdown();
+117        assert_eq!(settings.countdown_seconds, 15); // Unchanged
+118    }
+119}
+
\ No newline at end of file diff --git a/api/src/jellytau_lib/player/backend.rs.html b/api/src/jellytau_lib/player/backend.rs.html new file mode 100644 index 00000000..c4976d7b --- /dev/null +++ b/api/src/jellytau_lib/player/backend.rs.html @@ -0,0 +1,534 @@ +backend.rs - sourceSkip to main content

jellytau_lib/player/
backend.rs

1use super::media::MediaItem;
+2use super::state::PlayerState;
+3use crate::settings::AudioSettings;
+4
+5/// Error type for player operations
+6#[derive(Debug, Clone)]
+7pub struct PlayerError {
+8    pub message: String,
+9}
+10
+11impl std::fmt::Display for PlayerError {
+12    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
+13        write!(f, "{}", self.message)
+14    }
+15}
+16
+17impl std::error::Error for PlayerError {}
+18
+19impl PlayerError {
+20    pub fn not_implemented() -> Self {
+21        Self {
+22            message: "Not implemented".to_string(),
+23        }
+24    }
+25
+26    /// Create a playback failure error
+27    ///
+28    /// Only available on Android where ExoPlayer uses it for JNI errors
+29    #[cfg(target_os = "android")]
+30    pub fn playback_failed<S: Into<String>>(message: S) -> Self {
+31        Self {
+32            message: message.into(),
+33        }
+34    }
+35}
+36
+37/// Player backend trait - implemented by platform-specific players
+38///
+39/// TRACES: UR-003, UR-004 | IR-003, IR-004 | DR-004
+40pub trait PlayerBackend: Send + Sync {
+41    /// Load a media item for playback
+42    /// TRACES: UR-005
+43    fn load(&mut self, media: &MediaItem) -> Result<(), PlayerError>;
+44
+45    /// Start or resume playback
+46    /// TRACES: UR-005
+47    fn play(&mut self) -> Result<(), PlayerError>;
+48
+49    /// Pause playback
+50    /// TRACES: UR-005
+51    fn pause(&mut self) -> Result<(), PlayerError>;
+52
+53    /// Stop playback and unload media
+54    /// TRACES: UR-005
+55    fn stop(&mut self) -> Result<(), PlayerError>;
+56
+57    /// Seek to a position in seconds
+58    /// TRACES: UR-005
+59    fn seek(&mut self, position: f64) -> Result<(), PlayerError>;
+60
+61    /// Set volume (0.0 - 1.0)
+62    /// TRACES: UR-016
+63    fn set_volume(&mut self, volume: f32) -> Result<(), PlayerError>;
+64
+65    /// Get current playback position in seconds
+66    fn position(&self) -> f64;
+67
+68    /// Get total duration in seconds
+69    fn duration(&self) -> Option<f64>;
+70
+71    /// Get current player state
+72    fn state(&self) -> PlayerState;
+73
+74    /// Get current volume
+75    fn volume(&self) -> f32;
+76
+77    /// Apply audio settings (crossfade, gapless, normalization)
+78    ///
+79    /// @req-partial: UR-031 (Linux only) - Crossfade between audio tracks
+80    /// @req-partial: UR-032 (Linux only) - Gapless playback for seamless album listening
+81    /// @req-partial: UR-033 (Linux only) - Volume normalization to prevent volume jumps
+82    /// @req: DR-034 - Crossfade engine with configurable duration (0-12s)
+83    /// @req: DR-035 - Gapless playback between sequential tracks
+84    /// @req: DR-036 - Volume normalization with preset levels (Loud/Normal/Quiet)
+85    fn set_audio_settings(&mut self, _settings: &AudioSettings) -> Result<(), PlayerError> {
+86        // Default implementation does nothing - override in platform-specific backends
+87        Ok(())
+88    }
+89
+90    /// Get current audio settings
+91    ///
+92    /// @req: DR-034 - Crossfade engine
+93    /// @req: DR-035 - Gapless playback
+94    /// @req: DR-036 - Volume normalization
+95    fn audio_settings(&self) -> AudioSettings {
+96        AudioSettings::default()
+97    }
+98
+99    /// Set the active audio track by stream index
+100    ///
+101    /// Overridden by the Android (ExoPlayer) backend. `MpvBackend` deliberately
+102    /// does **not** override it — MPV is the audio-only backend here, so it keeps
+103    /// this `not_implemented()` default and the Linux video path switches track by
+104    /// re-opening the stream instead (`player_switch_audio_track`).
+105    ///
+106    /// TRACES: UR-021 | IR-019, DR-024
+107    fn set_audio_track(&mut self, _stream_index: i32) -> Result<(), PlayerError> {
+108        // Default implementation does nothing - override in platform-specific backends
+109        Err(PlayerError::not_implemented())
+110    }
+111
+112    /// Set the active subtitle track by stream index (None to disable subtitles)
+113    ///
+114    /// Overridden by the Android (ExoPlayer) backend. `MpvBackend` deliberately
+115    /// does **not** override it, so it keeps this `not_implemented()` default;
+116    /// the Linux video path renders subtitles as `<track>` children of the
+117    /// WebKitGTK HTML5 `<video>` element and never calls this.
+118    ///
+119    /// TRACES: UR-020 | IR-018, DR-023
+120    fn set_subtitle_track(&mut self, _stream_index: Option<i32>) -> Result<(), PlayerError> {
+121        // Default implementation does nothing - override in platform-specific backends
+122        Err(PlayerError::not_implemented())
+123    }
+124}
+125
+126/// Null player backend (for testing or when no real player is available)
+127///
+128/// @req: DR-004 - PlayerBackend trait (mock implementation for testing)
+129pub struct NullBackend {
+130    state: PlayerState,
+131    volume: f32,
+132    position: f64,
+133    duration: Option<f64>,
+134    audio_settings: AudioSettings,
+135}
+136
+137impl Default for NullBackend {
+138    fn default() -> Self {
+139        Self::new()
+140    }
+141}
+142
+143impl NullBackend {
+144    pub fn new() -> Self {
+145        Self {
+146            state: PlayerState::Idle,
+147            volume: 1.0,
+148            position: 0.0,
+149            duration: None,
+150            audio_settings: AudioSettings::default(),
+151        }
+152    }
+153}
+154
+155impl PlayerBackend for NullBackend {
+156    fn load(&mut self, media: &MediaItem) -> Result<(), PlayerError> {
+157        self.state = PlayerState::Loading {
+158            media: media.clone(),
+159        };
+160        // Simulate immediate load
+161        self.duration = media.duration;
+162        self.position = 0.0;
+163        self.state = PlayerState::Paused {
+164            media: media.clone(),
+165            position: 0.0,
+166            duration: media.duration.unwrap_or(0.0),
+167        };
+168        Ok(())
+169    }
+170
+171    fn play(&mut self) -> Result<(), PlayerError> {
+172        if let PlayerState::Paused {
+173            media,
+174            position,
+175            duration,
+176        } = &self.state
+177        {
+178            self.state = PlayerState::Playing {
+179                media: media.clone(),
+180                position: *position,
+181                duration: *duration,
+182            };
+183        }
+184        Ok(())
+185    }
+186
+187    fn pause(&mut self) -> Result<(), PlayerError> {
+188        if let PlayerState::Playing {
+189            media,
+190            position,
+191            duration,
+192        } = &self.state
+193        {
+194            self.state = PlayerState::Paused {
+195                media: media.clone(),
+196                position: *position,
+197                duration: *duration,
+198            };
+199        }
+200        Ok(())
+201    }
+202
+203    fn stop(&mut self) -> Result<(), PlayerError> {
+204        self.state = PlayerState::Idle;
+205        self.position = 0.0;
+206        self.duration = None;
+207        Ok(())
+208    }
+209
+210    fn seek(&mut self, position: f64) -> Result<(), PlayerError> {
+211        self.position = position;
+212        match &mut self.state {
+213            PlayerState::Playing { position: pos, .. } => *pos = position,
+214            PlayerState::Paused { position: pos, .. } => *pos = position,
+215            _ => {}
+216        }
+217        Ok(())
+218    }
+219
+220    fn set_volume(&mut self, volume: f32) -> Result<(), PlayerError> {
+221        self.volume = volume.clamp(0.0, 1.0);
+222        Ok(())
+223    }
+224
+225    fn position(&self) -> f64 {
+226        self.position
+227    }
+228
+229    fn duration(&self) -> Option<f64> {
+230        self.duration
+231    }
+232
+233    fn state(&self) -> PlayerState {
+234        self.state.clone()
+235    }
+236
+237    fn volume(&self) -> f32 {
+238        self.volume
+239    }
+240
+241    fn set_audio_settings(&mut self, settings: &AudioSettings) -> Result<(), PlayerError> {
+242        self.audio_settings = settings.clone().with_crossfade_clamped();
+243        Ok(())
+244    }
+245
+246    fn audio_settings(&self) -> AudioSettings {
+247        self.audio_settings.clone()
+248    }
+249}
+250
+251// TRACES: UR-003, UR-004 | DR-004 | UT-026, UT-027, UT-028, UT-029, UT-030, UT-031, UT-032, UT-033
+252#[cfg(test)]
+253mod tests {
+254    use super::*;
+255
+256    /// Test NullBackend volume default value
+257    /// TRACES: UR-016 | DR-004 | UT-026
+258    #[test]
+259    fn test_null_backend_volume_default() {
+260        let backend = NullBackend::new();
+261        assert_eq!(backend.volume(), 1.0);
+262    }
+263
+264    /// Test NullBackend set volume
+265    ///
+266    /// @req-test: UT-027 - NullBackend set volume
+267    /// @req-test: UR-016 - Change system settings while playing (volume)
+268    #[test]
+269    fn test_null_backend_set_volume() {
+270        let mut backend = NullBackend::new();
+271        backend.set_volume(0.5).unwrap();
+272        assert_eq!(backend.volume(), 0.5);
+273    }
+274
+275    /// Test NullBackend volume clamping (high)
+276    ///
+277    /// @req-test: UT-028 - NullBackend volume clamping (high/low)
+278    /// @req-test: UR-016 - Change system settings while playing (volume)
+279    #[test]
+280    fn test_null_backend_volume_clamping_high() {
+281        let mut backend = NullBackend::new();
+282        backend.set_volume(1.5).unwrap();
+283        assert_eq!(backend.volume(), 1.0);
+284    }
+285
+286    /// Test NullBackend volume clamping (low)
+287    ///
+288    /// @req-test: UT-028 - NullBackend volume clamping (high/low)
+289    /// @req-test: UR-016 - Change system settings while playing (volume)
+290    #[test]
+291    fn test_null_backend_volume_clamping_low() {
+292        let mut backend = NullBackend::new();
+293        backend.set_volume(-0.5).unwrap();
+294        assert_eq!(backend.volume(), 0.0);
+295    }
+296
+297    /// Test NullBackend volume boundary values
+298    ///
+299    /// @req-test: UT-029 - NullBackend volume boundary values
+300    /// @req-test: UR-016 - Change system settings while playing (volume)
+301    #[test]
+302    fn test_null_backend_volume_boundary() {
+303        let mut backend = NullBackend::new();
+304
+305        backend.set_volume(0.0).unwrap();
+306        assert_eq!(backend.volume(), 0.0);
+307
+308        backend.set_volume(1.0).unwrap();
+309        assert_eq!(backend.volume(), 1.0);
+310    }
+311
+312    /// Test NullBackend audio settings default values
+313    ///
+314    /// @req-test: DR-034 - Crossfade engine
+315    /// @req-test: DR-035 - Gapless playback
+316    /// @req-test: DR-036 - Volume normalization
+317    #[test]
+318    fn test_null_backend_audio_settings_default() {
+319        let backend = NullBackend::new();
+320        let settings = backend.audio_settings();
+321        assert_eq!(settings.crossfade_duration, 0.0);
+322        assert!(settings.gapless_playback);
+323        assert!(!settings.normalize_volume);
+324    }
+325
+326    /// Test NullBackend set audio settings
+327    ///
+328    /// @req-test: DR-034 - Crossfade engine with configurable duration
+329    /// @req-test: DR-035 - Gapless playback between sequential tracks
+330    /// @req-test: DR-036 - Volume normalization with preset levels
+331    #[test]
+332    fn test_null_backend_set_audio_settings() {
+333        use crate::settings::VolumeLevel;
+334
+335        let mut backend = NullBackend::new();
+336        let settings = AudioSettings {
+337            crossfade_duration: 5.0,
+338            gapless_playback: false,
+339            normalize_volume: true,
+340            volume_level: VolumeLevel::Loud,
+341            ..Default::default()
+342        };
+343
+344        backend.set_audio_settings(&settings).unwrap();
+345
+346        let result = backend.audio_settings();
+347        assert_eq!(result.crossfade_duration, 5.0);
+348        assert!(!result.gapless_playback);
+349        assert!(result.normalize_volume);
+350        assert_eq!(result.volume_level, VolumeLevel::Loud);
+351    }
+352
+353    /// Test NullBackend audio settings crossfade clamping to 12s max
+354    ///
+355    /// @req-test: DR-034 - Crossfade engine with configurable duration (0-12s)
+356    #[test]
+357    fn test_null_backend_audio_settings_crossfade_clamping() {
+358        let mut backend = NullBackend::new();
+359        let settings = AudioSettings {
+360            crossfade_duration: 20.0,
+361            ..Default::default()
+362        };
+363
+364        backend.set_audio_settings(&settings).unwrap();
+365        assert_eq!(backend.audio_settings().crossfade_duration, 12.0);
+366    }
+367
+368    /// Test NullBackend seek updates position
+369    ///
+370    /// @req-test: UR-005 - Control media playback (scrub operation)
+371    /// @req-test: DR-004 - PlayerBackend trait
+372    #[test]
+373    fn test_null_backend_seek_updates_position() {
+374        use crate::player::media::{MediaItem, MediaSource, MediaType};
+375
+376        let mut backend = NullBackend::new();
+377
+378        // Create a test media item
+379        let media = MediaItem {
+380            id: "test_media".to_string(),
+381            title: "Test Track".to_string(),
+382            name: Some("Test Track".to_string()),
+383            artist: Some("Test Artist".to_string()),
+384            album: Some("Test Album".to_string()),
+385            album_name: Some("Test Album".to_string()),
+386            album_id: None,
+387            artist_items: None,
+388            artists: Some(vec!["Test Artist".to_string()]),
+389            primary_image_tag: None,
+390            image_id: None,
+391            item_type: Some("Audio".to_string()),
+392            playlist_id: None,
+393            duration: Some(180.0),
+394            artwork_url: None,
+395            media_type: MediaType::Audio,
+396            source: MediaSource::DirectUrl {
+397                url: "http://example.com/test.mp3".to_string(),
+398            },
+399            video_codec: None,
+400            needs_transcoding: false,
+401            video_width: None,
+402            video_height: None,
+403            subtitles: vec![],
+404            series_id: None,
+405            server_id: None,
+406        };
+407
+408        // Load and play the media
+409        backend.load(&media).unwrap();
+410        backend.play().unwrap();
+411
+412        // Verify initial position
+413        assert_eq!(backend.position(), 0.0);
+414
+415        // Seek to 30 seconds
+416        backend.seek(30.0).unwrap();
+417        assert_eq!(backend.position(), 30.0);
+418
+419        // Seek to 60 seconds
+420        backend.seek(60.0).unwrap();
+421        assert_eq!(backend.position(), 60.0);
+422
+423        // Seek backward
+424        backend.seek(15.0).unwrap();
+425        assert_eq!(backend.position(), 15.0);
+426    }
+427
+428    /// Test NullBackend seek while paused
+429    ///
+430    /// @req-test: UR-005 - Control media playback (scrub while paused)
+431    /// @req-test: DR-001 - Player state machine (seeking from paused state)
+432    #[test]
+433    fn test_null_backend_seek_while_paused() {
+434        use crate::player::media::{MediaItem, MediaSource, MediaType};
+435
+436        let mut backend = NullBackend::new();
+437
+438        let media = MediaItem {
+439            id: "test_media".to_string(),
+440            title: "Test Track".to_string(),
+441            name: Some("Test Track".to_string()),
+442            artist: Some("Test Artist".to_string()),
+443            album: Some("Test Album".to_string()),
+444            album_name: Some("Test Album".to_string()),
+445            album_id: None,
+446            artist_items: None,
+447            artists: Some(vec!["Test Artist".to_string()]),
+448            primary_image_tag: None,
+449            image_id: None,
+450            item_type: Some("Audio".to_string()),
+451            playlist_id: None,
+452            duration: Some(180.0),
+453            artwork_url: None,
+454            media_type: MediaType::Audio,
+455            source: MediaSource::DirectUrl {
+456                url: "http://example.com/test.mp3".to_string(),
+457            },
+458            video_codec: None,
+459            needs_transcoding: false,
+460            video_width: None,
+461            video_height: None,
+462            subtitles: vec![],
+463            series_id: None,
+464            server_id: None,
+465        };
+466
+467        // Load media (starts paused)
+468        backend.load(&media).unwrap();
+469
+470        // Verify state is paused
+471        assert!(matches!(backend.state(), PlayerState::Paused { .. }));
+472
+473        // Seek while paused
+474        backend.seek(45.0).unwrap();
+475        assert_eq!(backend.position(), 45.0);
+476
+477        // Verify still paused
+478        assert!(matches!(backend.state(), PlayerState::Paused { .. }));
+479    }
+480
+481    /// Test NullBackend position updates reflected in state
+482    ///
+483    /// @req-test: DR-001 - Player state machine (position tracking)
+484    /// @req-test: UR-005 - Control media playback (position accuracy)
+485    #[test]
+486    fn test_null_backend_position_updates_in_state() {
+487        use crate::player::media::{MediaItem, MediaSource, MediaType};
+488
+489        let mut backend = NullBackend::new();
+490
+491        let media = MediaItem {
+492            id: "test_media".to_string(),
+493            title: "Test Track".to_string(),
+494            name: Some("Test Track".to_string()),
+495            artist: Some("Test Artist".to_string()),
+496            album: Some("Test Album".to_string()),
+497            album_name: Some("Test Album".to_string()),
+498            album_id: None,
+499            artist_items: None,
+500            artists: Some(vec!["Test Artist".to_string()]),
+501            primary_image_tag: None,
+502            image_id: None,
+503            item_type: Some("Audio".to_string()),
+504            playlist_id: None,
+505            duration: Some(180.0),
+506            artwork_url: None,
+507            media_type: MediaType::Audio,
+508            source: MediaSource::DirectUrl {
+509                url: "http://example.com/test.mp3".to_string(),
+510            },
+511            video_codec: None,
+512            needs_transcoding: false,
+513            video_width: None,
+514            video_height: None,
+515            subtitles: vec![],
+516            series_id: None,
+517            server_id: None,
+518        };
+519
+520        backend.load(&media).unwrap();
+521        backend.play().unwrap();
+522
+523        // Seek to 30 seconds
+524        backend.seek(30.0).unwrap();
+525
+526        // Verify the state reflects the new position
+527        if let PlayerState::Playing { position, .. } = backend.state() {
+528            assert_eq!(position, 30.0);
+529        } else {
+530            panic!("Expected Playing state");
+531        }
+532    }
+533}
+
\ No newline at end of file diff --git a/api/src/jellytau_lib/player/background_policy.rs.html b/api/src/jellytau_lib/player/background_policy.rs.html new file mode 100644 index 00000000..c4c3f338 --- /dev/null +++ b/api/src/jellytau_lib/player/background_policy.rs.html @@ -0,0 +1,156 @@ +background_policy.rs - sourceSkip to main content

jellytau_lib/player/
background_policy.rs

1//! What playback should do when the app stops being visible.
+2//!
+3//! TRACES: UR-040 | DR-224 | UT-211
+4//!
+5//! # The defect this exists for
+6//!
+7//! The per-player background-audio toggle (UR-040) was built for the WebView
+8//! `<video>` path, where backgrounding the app kills the decode: the toggle
+9//! decided whether to *hand off* to a native audio stream or let playback die.
+10//!
+11//! Native video then became the default renderer (DR-188). On that path playback
+12//! runs through ExoPlayer inside a `MediaSessionService` — a foreground media
+13//! service whose entire purpose is to keep playing when the app is not visible.
+14//! Nothing stops it, and nothing in the codebase paused playback on background.
+15//!
+16//! So locking the screen kept the audio playing **whether or not the toggle was
+17//! on**. The toggle governed a handoff that no longer had anything to hand off
+18//! *from*: there was no gap in playback to bridge. A user who had never touched
+19//! it got background audio anyway, which is the bug as reported.
+20//!
+21//! # Why the decision lives in Rust
+22//!
+23//! It depends on what the item *is* (a video keeps its picture; music has none
+24//! to lose) and on a user setting — domain questions, not presentation ones, and
+25//! the answer must be identical for both renderers. The frontend and the Android
+26//! activity carry it out; neither decides it. Putting the rule in either would
+27//! have reproduced exactly the split that caused this: two renderers, two
+28//! behaviours, one toggle that only reached one of them.
+29
+30use crate::player::media::MediaType;
+31
+32/// What the player should do when the app is backgrounded.
+33#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize, serde::Deserialize, specta::Type)]
+34#[serde(rename_all = "camelCase")]
+35pub enum BackgroundAction {
+36    /// Carry on. Music, and video the user explicitly asked to keep hearing
+37    /// while it is in a picture-in-picture window.
+38    KeepPlaying,
+39    /// Swap the video stream for an audio-only one and keep playing.
+40    HandOffToAudio,
+41    /// Stop making sound. The user did not ask for background playback.
+42    Pause,
+43}
+44
+45/// Decide what backgrounding should do.
+46///
+47/// * `is_video` — whether the current item has a picture to lose. Music is
+48///   never paused by backgrounding; that is what a music player is for.
+49/// * `background_audio_armed` — the per-player toggle (UR-040).
+50/// * `in_picture_in_picture` — the app is not "gone", it is in a floating
+51///   window and still visible. Pausing here would break PiP (UR-041).
+52///
+53/// TRACES: UR-040, UR-041 | DR-224 | UT-211
+54pub fn background_action(
+55    is_video: bool,
+56    background_audio_armed: bool,
+57    in_picture_in_picture: bool,
+58) -> BackgroundAction {
+59    // PiP first: the window is still on screen, so this is not backgrounding in
+60    // any sense the user would recognise.
+61    if in_picture_in_picture {
+62        return BackgroundAction::KeepPlaying;
+63    }
+64
+65    // Music has no picture to lose; a music player that stopped when the screen
+66    // locked would be broken in an obvious way.
+67    if !is_video {
+68        return BackgroundAction::KeepPlaying;
+69    }
+70
+71    if background_audio_armed {
+72        BackgroundAction::HandOffToAudio
+73    } else {
+74        BackgroundAction::Pause
+75    }
+76}
+77
+78/// Whether a media type has a picture that backgrounding would throw away.
+79///
+80/// TRACES: UR-040 | DR-224
+81pub fn is_video_media(media_type: MediaType) -> bool {
+82    matches!(media_type, MediaType::Video)
+83}
+84
+85#[cfg(test)]
+86mod tests {
+87    use super::*;
+88
+89    #[test]
+90    fn video_without_the_toggle_pauses() {
+91        // THE REPORTED BUG. Native video runs in a foreground media service that
+92        // keeps playing when the app is hidden, and nothing paused it -- so
+93        // locking the screen gave background audio to a user who never asked
+94        // for it.
+95        assert_eq!(
+96            background_action(true, false, false),
+97            BackgroundAction::Pause
+98        );
+99    }
+100
+101    #[test]
+102    fn video_with_the_toggle_hands_off_to_audio() {
+103        assert_eq!(
+104            background_action(true, true, false),
+105            BackgroundAction::HandOffToAudio
+106        );
+107    }
+108
+109    #[test]
+110    fn music_always_keeps_playing() {
+111        // Backgrounding a music player and having it stop would be absurd. The
+112        // toggle is irrelevant here: there is no picture to give up.
+113        assert_eq!(
+114            background_action(false, false, false),
+115            BackgroundAction::KeepPlaying
+116        );
+117        assert_eq!(
+118            background_action(false, true, false),
+119            BackgroundAction::KeepPlaying
+120        );
+121    }
+122
+123    #[test]
+124    fn picture_in_picture_is_not_backgrounding() {
+125        // The video is in a floating window and still on screen. Pausing would
+126        // break PiP (UR-041), which is a separate feature reached through
+127        // onUserLeaveHint rather than onStop.
+128        assert_eq!(
+129            background_action(true, false, true),
+130            BackgroundAction::KeepPlaying
+131        );
+132        assert_eq!(
+133            background_action(true, true, true),
+134            BackgroundAction::KeepPlaying
+135        );
+136    }
+137
+138    #[test]
+139    fn the_rule_does_not_depend_on_the_renderer() {
+140        // There is deliberately no renderer parameter. The WebView path and the
+141        // native path must answer identically -- the split between them is what
+142        // produced the defect, because the toggle only ever reached one.
+143        for armed in [true, false] {
+144            let once = background_action(true, armed, false);
+145            let again = background_action(true, armed, false);
+146            assert_eq!(once, again);
+147        }
+148    }
+149
+150    #[test]
+151    fn only_video_counts_as_having_a_picture() {
+152        assert!(is_video_media(MediaType::Video));
+153        assert!(!is_video_media(MediaType::Audio));
+154    }
+155}
+
\ No newline at end of file diff --git a/api/src/jellytau_lib/player/events.rs.html b/api/src/jellytau_lib/player/events.rs.html new file mode 100644 index 00000000..d97d8e3e --- /dev/null +++ b/api/src/jellytau_lib/player/events.rs.html @@ -0,0 +1,387 @@ +events.rs - sourceSkip to main content

jellytau_lib/player/
events.rs

1//! Player events for frontend communication via Tauri events.
+2//!
+3//! These events are emitted from the player backend to notify the frontend
+4//! of playback state changes, position updates, etc.
+5//!
+6//! TRACES: UR-005, UR-019, UR-023, UR-026 | DR-001, DR-028, DR-047
+7
+8#[cfg(test)]
+9use crate::utils::lock::MutexSafe;
+10use log::error;
+11use serde::{Deserialize, Serialize};
+12use std::sync::Arc;
+13use tauri::AppHandle;
+14use tauri_specta::Event;
+15
+16use super::{MediaSessionType, SleepTimerMode};
+17
+18/// Events emitted by the player backend to the frontend via Tauri events.
+19///
+20/// These are distinct from `PlayerEvent` in state.rs, which handles internal
+21/// state machine transitions.
+22///
+23/// TRACES: UR-005, UR-019, UR-023, UR-026 | DR-001, DR-028, DR-047
+24#[derive(Debug, Clone, Serialize, Deserialize, specta::Type, tauri_specta::Event)]
+25// NOTE: fields are intentionally snake_case on the wire. specta generates the
+26// TypeScript bindings with snake_case field names (it does not apply serde's
+27// `rename_all_fields`), so adding `rename_all_fields = "camelCase"` here makes
+28// serde emit camelCase payloads that no longer match the generated schema —
+29// tauri-specta then silently drops those events (e.g. state_changed,
+30// queue_changed never reach the frontend, so the mini player never appears).
+31// Keep serde and specta agreeing: snake_case fields, snake_case variant tags.
+32#[serde(tag = "type", rename_all = "snake_case")]
+33// `ShowNextEpisodePopup` carries two `MediaItem`s, so it dwarfs the small
+34// position/state variants. Boxing them is rejected deliberately: this is a
+35// serde + specta wire type whose generated TypeScript must not shift, and the
+36// events are emitted a few times a second at most — never bulk-allocated — so
+37// the size difference costs nothing measurable.
+38#[allow(clippy::large_enum_variant)]
+39pub enum PlayerStatusEvent {
+40    /// Playback position updated (emitted periodically during playback)
+41    PositionUpdate {
+42        /// Current position in seconds
+43        position: f64,
+44        /// Total duration in seconds
+45        duration: f64,
+46    },
+47    /// Player state changed
+48    StateChanged {
+49        /// New state: "playing", "paused", "stopped", "loading", "idle"
+50        state: String,
+51        /// ID of the current media item, if any
+52        media_id: Option<String>,
+53    },
+54    /// Media has finished loading and is ready to play
+55    MediaLoaded {
+56        /// Total duration in seconds
+57        duration: f64,
+58    },
+59    /// Playback has ended naturally (reached end of media)
+60    PlaybackEnded,
+61    /// Buffering state changed
+62    Buffering {
+63        /// Buffering progress (0-100)
+64        percent: u8,
+65    },
+66    /// An error occurred during playback
+67    Error {
+68        /// Error message
+69        message: String,
+70        /// Whether the error is recoverable
+71        recoverable: bool,
+72    },
+73    /// Volume changed
+74    VolumeChanged {
+75        /// New volume level (0.0-1.0)
+76        volume: f32,
+77        /// Whether audio is muted
+78        muted: bool,
+79    },
+80    /// Sleep timer state changed
+81    SleepTimerChanged {
+82        /// Sleep timer mode
+83        mode: SleepTimerMode,
+84        /// Remaining seconds (for time-based timer)
+85        remaining_seconds: u32,
+86    },
+87    /// Time-based sleep timer expired: playback must stop. The backend stops
+88    /// its own (MPV/ExoPlayer) playback, but HTML5 video on Linux plays in the
+89    /// webview outside the backend's control — the frontend pauses it on this
+90    /// event.
+91    SleepTimerExpired,
+92    /// Show next episode popup with countdown
+93    ShowNextEpisodePopup {
+94        /// Current episode that just finished
+95        current_episode: crate::repository::types::MediaItem,
+96        /// Next episode to play
+97        next_episode: crate::repository::types::MediaItem,
+98        /// Countdown duration in seconds
+99        countdown_seconds: u32,
+100        /// Whether to auto-advance when countdown reaches 0
+101        auto_advance: bool,
+102    },
+103    /// Countdown tick (emitted every second during autoplay countdown)
+104    CountdownTick {
+105        /// Remaining seconds in countdown
+106        remaining_seconds: u32,
+107    },
+108    /// Queue changed (items added, removed, reordered, or playback mode changed)
+109    QueueChanged {
+110        /// All items in the queue
+111        items: Vec<crate::player::media::MediaItem>,
+112        /// Current item index
+113        current_index: Option<usize>,
+114        /// Whether shuffle is enabled
+115        shuffle: bool,
+116        /// Current repeat mode
+117        repeat: crate::player::queue::RepeatMode,
+118        /// Whether there's a next track available
+119        has_next: bool,
+120        /// Whether there's a previous track available
+121        has_previous: bool,
+122    },
+123    /// Media session changed (activity context changed: Audio/Movie/TvShow/Idle)
+124    SessionChanged {
+125        /// Current session state
+126        session: MediaSessionType,
+127    },
+128    /// Remote sessions updated (for cast/remote control UI)
+129    SessionsUpdated {
+130        /// All active controllable sessions from Jellyfin
+131        sessions: Vec<crate::jellyfin::client::SessionInfo>,
+132    },
+133    /// The authoritative playback mode changed in the Rust backend.
+134    ///
+135    /// The Rust `PlaybackModeManager` is the single source of truth for which
+136    /// device playback commands route to (local vs a remote session). The
+137    /// frontend keeps a mirror store for the UI; without this event that mirror
+138    /// drifts out of sync (e.g. a mode transition happens inside a transfer or a
+139    /// local stop that the frontend never learns about), and controls then route
+140    /// to the wrong device — the classic "it keeps playing on the remote" bug.
+141    /// The frontend reconciles its store to this payload whenever it fires.
+142    PlaybackModeChanged {
+143        /// New mode: "local", "remote", or "idle".
+144        mode: String,
+145        /// Session id when `mode == "remote"`, otherwise `None`.
+146        session_id: Option<String>,
+147    },
+148    /// The user asked to disconnect from the remote session and resume locally.
+149    ///
+150    /// Emitted when the lockscreen Stop button is pressed while casting. The
+151    /// frontend owns the two-step remote->local transfer (it must reload the
+152    /// media item locally), so the native side only signals intent here.
+153    RemoteDisconnectRequested,
+154    /// Backend-originated control command targeting the active frontend player
+155    /// adapter (the HTML5 <video> that lives in the webview, which Rust cannot
+156    /// drive directly). Emitted by control paths like the sleep timer, lockscreen,
+157    /// or remote so they can pause/play/seek/stop the webview element.
+158    /// `playerEvents.ts` routes this to the active PlayerAdapter via the facade.
+159    ControlCommand {
+160        /// One of: "play", "pause", "stop", "seek".
+161        action: String,
+162        /// Target position in seconds (only meaningful for "seek").
+163        position: Option<f64>,
+164    },
+165    /// Ask the frontend webview `<audio>` element to load and play a stream.
+166    ///
+167    /// Emitted by `WebviewAudioBackend` on platforms with no native audio
+168    /// backend (e.g. Windows): audio-only playback is rendered by an `<audio>`
+169    /// element in the webview, mirroring how all video already renders through
+170    /// the webview `<video>`. The element then reports its state/position back
+171    /// through the `player_report_*` commands, so the Rust controller stays the
+172    /// single source of truth. Subsequent play/pause/seek/stop reach the element
+173    /// via `ControlCommand`.
+174    WebviewAudioLoad {
+175        /// Stream URL for the `<audio>` element to play.
+176        url: String,
+177        /// Jellyfin item id, used as the media_id when reporting state back.
+178        media_id: Option<String>,
+179        /// Resume position in seconds (0 = start from the beginning).
+180        position: f64,
+181        /// Whether to begin playing immediately after loading.
+182        autoplay: bool,
+183    },
+184}
+185
+186/// Trait for emitting player events to the frontend.
+187///
+188/// This abstraction allows backends to emit events without depending
+189/// directly on Tauri, making them easier to test.
+190pub trait PlayerEventEmitter: Send + Sync {
+191    /// Emit a player status event to the frontend
+192    fn emit(&self, event: PlayerStatusEvent);
+193}
+194
+195/// Tauri-based implementation of PlayerEventEmitter.
+196///
+197/// Uses Tauri's `AppHandle::emit()` to broadcast events to all windows.
+198pub struct TauriEventEmitter {
+199    app_handle: AppHandle,
+200}
+201
+202impl TauriEventEmitter {
+203    /// Create a new TauriEventEmitter with the given app handle.
+204    pub fn new(app_handle: AppHandle) -> Self {
+205        Self { app_handle }
+206    }
+207}
+208
+209impl PlayerEventEmitter for TauriEventEmitter {
+210    fn emit(&self, event: PlayerStatusEvent) {
+211        // Emitted via the tauri-specta Event trait so the payload shape and event
+212        // name match the generated TypeScript bindings (events.playerStatusEvent).
+213        if let Err(e) = Event::emit(&event, &self.app_handle) {
+214            error!("Failed to emit player event: {}", e);
+215        }
+216    }
+217}
+218
+219/// Thread-safe wrapper for event emitters.
+220#[allow(dead_code)]
+221pub type SharedEventEmitter = Arc<dyn PlayerEventEmitter>;
+222
+223#[cfg(test)]
+224mod tests {
+225    use super::*;
+226    use std::sync::Mutex;
+227    use std::thread;
+228
+229    /// Test event emitter that captures events for verification
+230    pub struct TestEventEmitter {
+231        events: Mutex<Vec<PlayerStatusEvent>>,
+232    }
+233
+234    impl TestEventEmitter {
+235        pub fn new() -> Self {
+236            Self {
+237                events: Mutex::new(Vec::new()),
+238            }
+239        }
+240
+241        pub fn events(&self) -> Vec<PlayerStatusEvent> {
+242            self.events.lock_safe().clone()
+243        }
+244    }
+245
+246    impl PlayerEventEmitter for TestEventEmitter {
+247        fn emit(&self, event: PlayerStatusEvent) {
+248            self.events.lock_safe().push(event);
+249        }
+250    }
+251
+252    #[test]
+253    fn test_position_update_serialization() {
+254        let event = PlayerStatusEvent::PositionUpdate {
+255            position: 30.5,
+256            duration: 180.0,
+257        };
+258        let json = serde_json::to_string(&event).unwrap();
+259        assert!(json.contains("position_update"));
+260        assert!(json.contains("30.5"));
+261        assert!(json.contains("180"));
+262    }
+263
+264    #[test]
+265    fn test_state_changed_serialization() {
+266        let event = PlayerStatusEvent::StateChanged {
+267            state: "playing".to_string(),
+268            media_id: Some("test-id-123".to_string()),
+269        };
+270        let json = serde_json::to_string(&event).unwrap();
+271        assert!(json.contains("state_changed"));
+272        assert!(json.contains("playing"));
+273        assert!(json.contains("test-id-123"));
+274    }
+275
+276    #[test]
+277    fn test_state_changed_no_media_id() {
+278        let event = PlayerStatusEvent::StateChanged {
+279            state: "idle".to_string(),
+280            media_id: None,
+281        };
+282        let json = serde_json::to_string(&event).unwrap();
+283        assert!(json.contains("state_changed"));
+284        assert!(json.contains("idle"));
+285        assert!(json.contains("null"));
+286    }
+287
+288    #[test]
+289    fn test_media_loaded_serialization() {
+290        let event = PlayerStatusEvent::MediaLoaded { duration: 245.5 };
+291        let json = serde_json::to_string(&event).unwrap();
+292        assert!(json.contains("media_loaded"));
+293        assert!(json.contains("245.5"));
+294    }
+295
+296    #[test]
+297    fn test_playback_ended_serialization() {
+298        let event = PlayerStatusEvent::PlaybackEnded;
+299        let json = serde_json::to_string(&event).unwrap();
+300        assert!(json.contains("playback_ended"));
+301    }
+302
+303    #[test]
+304    fn test_buffering_serialization() {
+305        let event = PlayerStatusEvent::Buffering { percent: 75 };
+306        let json = serde_json::to_string(&event).unwrap();
+307        assert!(json.contains("buffering"));
+308        assert!(json.contains("75"));
+309    }
+310
+311    #[test]
+312    fn test_error_serialization() {
+313        let event = PlayerStatusEvent::Error {
+314            message: "Failed to load media".to_string(),
+315            recoverable: true,
+316        };
+317        let json = serde_json::to_string(&event).unwrap();
+318        assert!(json.contains("error"));
+319        assert!(json.contains("Failed to load media"));
+320        assert!(json.contains("true"));
+321    }
+322
+323    #[test]
+324    fn test_volume_changed_serialization() {
+325        let event = PlayerStatusEvent::VolumeChanged {
+326            volume: 0.75,
+327            muted: false,
+328        };
+329        let json = serde_json::to_string(&event).unwrap();
+330        assert!(json.contains("volume_changed"));
+331        assert!(json.contains("0.75"));
+332        assert!(json.contains("false"));
+333    }
+334
+335    #[test]
+336    fn test_event_emitter_captures_events() {
+337        let emitter = TestEventEmitter::new();
+338        emitter.emit(PlayerStatusEvent::PlaybackEnded);
+339        assert_eq!(emitter.events().len(), 1);
+340    }
+341
+342    #[test]
+343    fn test_event_emitter_multiple_events() {
+344        let emitter = TestEventEmitter::new();
+345        emitter.emit(PlayerStatusEvent::PlaybackEnded);
+346        emitter.emit(PlayerStatusEvent::PositionUpdate {
+347            position: 10.0,
+348            duration: 100.0,
+349        });
+350        emitter.emit(PlayerStatusEvent::StateChanged {
+351            state: "paused".to_string(),
+352            media_id: None,
+353        });
+354        assert_eq!(emitter.events().len(), 3);
+355    }
+356
+357    #[test]
+358    fn test_event_emitter_thread_safety() {
+359        let emitter = Arc::new(TestEventEmitter::new());
+360        let mut handles = vec![];
+361
+362        for i in 0..10 {
+363            let emitter_clone = Arc::clone(&emitter);
+364            let handle = thread::spawn(move || {
+365                emitter_clone.emit(PlayerStatusEvent::PositionUpdate {
+366                    position: i as f64,
+367                    duration: 100.0,
+368                });
+369            });
+370            handles.push(handle);
+371        }
+372
+373        for handle in handles {
+374            handle.join().unwrap();
+375        }
+376
+377        assert_eq!(emitter.events().len(), 10);
+378    }
+379
+380    #[test]
+381    fn test_shared_event_emitter() {
+382        let emitter: SharedEventEmitter = Arc::new(TestEventEmitter::new());
+383        emitter.emit(PlayerStatusEvent::PlaybackEnded);
+384        // Verify it compiles and works as a trait object
+385    }
+386}
+
\ No newline at end of file diff --git a/api/src/jellytau_lib/player/media.rs.html b/api/src/jellytau_lib/player/media.rs.html new file mode 100644 index 00000000..08190de8 --- /dev/null +++ b/api/src/jellytau_lib/player/media.rs.html @@ -0,0 +1,581 @@ +media.rs - sourceSkip to main content

jellytau_lib/player/
media.rs

1use serde::{Deserialize, Serialize};
+2use std::path::PathBuf;
+3
+4/// Context for the current queue - where did the queue items come from?
+5/// This is used for remote playback transfer to send album/playlist context.
+6#[derive(specta::Type, Debug, Clone, Serialize, Deserialize, PartialEq, Default)]
+7#[serde(tag = "type", rename_all = "lowercase")]
+8pub enum QueueContext {
+9    /// Playing from a specific album
+10    Album {
+11        album_id: String,
+12        album_name: String,
+13    },
+14    /// Playing from a specific playlist
+15    Playlist {
+16        playlist_id: String,
+17        playlist_name: String,
+18    },
+19    /// Custom queue (search results, manual queue, etc.)
+20    /// Will create a temporary playlist on remote transfer
+21    #[default]
+22    Custom,
+23}
+24
+25/// Represents a subtitle track
+26///
+27/// 🔴 **Do not add `#[serde(rename_all = "camelCase")]` here.** This is the one
+28/// struct in the player that deliberately keeps snake_case on the wire, because
+29/// the *same* serialization feeds two consumers that both spell `mime_type`:
+30///
+31/// * the JNI boundary — `player/android/mod.rs` serializes `MediaItem::subtitles`
+32///   with `serde_json` and hands the string to `JellyTauPlayer.loadWithMetadata`,
+33///   whose parser reads `url`, `language`, `label` and `optString("mime_type")`;
+34/// * the IPC boundary — `PlayItemRequest::subtitles` deserializes this same type
+35///   from the frontend, and the generated binding (`SubtitleTrack` in
+36///   `bindings.ts`) therefore also declares `mime_type`.
+37///
+38/// Renaming would not break the build and would not fail the IPC: Kotlin's
+39/// `optString` would just fall back to its default MIME type for every track, so
+40/// the failure would be silent. UT-146 asserts the serialized keys.
+41///
+42/// TRACES: UR-020 | IR-016, JA-008 | UT-146
+43#[derive(specta::Type, Debug, Clone, Serialize, Deserialize, PartialEq)]
+44pub struct SubtitleTrack {
+45    /// Stream index in the media source
+46    pub index: i32,
+47    /// Subtitle URL
+48    pub url: String,
+49    /// Language code (e.g., "eng", "spa")
+50    pub language: Option<String>,
+51    /// Display title
+52    pub label: Option<String>,
+53    /// MIME type (e.g., "text/vtt", "application/x-subrip").
+54    /// Snake_case on purpose — see the note on the struct.
+55    pub mime_type: String,
+56}
+57
+58/// Represents a media item that can be played
+59///
+60/// TRACES: UR-003, UR-004 | DR-002
+61#[derive(specta::Type, Debug, Clone, Serialize, Deserialize, PartialEq)]
+62#[serde(rename_all = "camelCase")]
+63#[specta(rename = "PlayerMediaItem")]
+64pub struct MediaItem {
+65    /// Unique identifier
+66    pub id: String,
+67    /// Display title
+68    pub title: String,
+69    /// Name (alias for title - for frontend compatibility)
+70    #[serde(default, skip_serializing_if = "Option::is_none")]
+71    pub name: Option<String>,
+72    /// Artist name(s) for audio
+73    pub artist: Option<String>,
+74    /// Album name for audio
+75    pub album: Option<String>,
+76    /// Album name (alias - for frontend compatibility)
+77    #[serde(default, skip_serializing_if = "Option::is_none")]
+78    pub album_name: Option<String>,
+79    /// Album ID (Jellyfin ID) for remote transfer context
+80    #[serde(default)]
+81    pub album_id: Option<String>,
+82    /// Artist items with IDs for clickable links
+83    #[serde(default, skip_serializing_if = "Option::is_none")]
+84    pub artist_items: Option<Vec<crate::repository::types::ArtistItem>>,
+85    /// Artists as array of strings (fallback when artist_items not available)
+86    #[serde(default, skip_serializing_if = "Option::is_none")]
+87    pub artists: Option<Vec<String>>,
+88    /// Primary image tag for artwork.
+89    ///
+90    /// Legacy Jellyfin name; being replaced by `image_id` (same value). Dual-carried
+91    /// while the frontend migrates (docs/specs/frontend-domain-model.md).
+92    #[serde(default, skip_serializing_if = "Option::is_none")]
+93    pub primary_image_tag: Option<String>,
+94    /// Neutral image identifier the frontend resolves to a URL — replaces
+95    /// `primary_image_tag`.
+96    #[serde(default, skip_serializing_if = "Option::is_none")]
+97    pub image_id: Option<String>,
+98    /// Item type (Audio, Movie, Episode, etc.)
+99    #[serde(rename = "type", default, skip_serializing_if = "Option::is_none")]
+100    pub item_type: Option<String>,
+101    /// Playlist ID (Jellyfin ID) for remote transfer context
+102    #[serde(default)]
+103    pub playlist_id: Option<String>,
+104    /// Duration in seconds
+105    pub duration: Option<f64>,
+106    /// URL or path to artwork image
+107    pub artwork_url: Option<String>,
+108    /// Type of media
+109    pub media_type: MediaType,
+110    /// Source of the media
+111    pub source: MediaSource,
+112    /// Video codec (e.g., "h264", "hevc") for video media
+113    #[serde(default)]
+114    pub video_codec: Option<String>,
+115    /// Whether the video requires server-side transcoding
+116    #[serde(default)]
+117    pub needs_transcoding: bool,
+118    /// Video width in pixels
+119    #[serde(default)]
+120    pub video_width: Option<u32>,
+121    /// Video height in pixels
+122    #[serde(default)]
+123    pub video_height: Option<u32>,
+124    /// Available subtitle tracks
+125    #[serde(default)]
+126    pub subtitles: Vec<SubtitleTrack>,
+127    /// Series ID (for TV show episodes) - used for series audio preferences
+128    #[serde(default)]
+129    pub series_id: Option<String>,
+130    /// Server ID - used for series audio preferences
+131    #[serde(default)]
+132    pub server_id: Option<String>,
+133}
+134
+135#[derive(specta::Type, Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
+136#[serde(rename_all = "lowercase")]
+137pub enum MediaType {
+138    Audio,
+139    Video,
+140}
+141
+142/// TRACES: UR-002, UR-003, UR-004, UR-011 | DR-003
+143#[derive(specta::Type, Debug, Clone, Serialize, Deserialize, PartialEq)]
+144#[serde(tag = "type", rename_all = "lowercase")]
+145#[specta(rename = "PlayerMediaSource")]
+146pub enum MediaSource {
+147    /// Streaming from Jellyfin server
+148    Remote {
+149        stream_url: String,
+150        jellyfin_item_id: String,
+151    },
+152    /// Downloaded/cached locally
+153    Local {
+154        file_path: PathBuf,
+155        /// Original Jellyfin ID for sync-back
+156        jellyfin_item_id: Option<String>,
+157    },
+158    /// Direct URL (e.g., channel plugins)
+159    DirectUrl { url: String },
+160}
+161
+162impl MediaItem {
+163    /// Get the Jellyfin item ID if available
+164    pub fn jellyfin_id(&self) -> Option<&str> {
+165        match &self.source {
+166            MediaSource::Remote {
+167                jellyfin_item_id, ..
+168            } => Some(jellyfin_item_id),
+169            MediaSource::Local {
+170                jellyfin_item_id, ..
+171            } => jellyfin_item_id.as_deref(),
+172            MediaSource::DirectUrl { .. } => None,
+173        }
+174    }
+175
+176    /// Get the playback URL or file path
+177    ///
+178    /// Only available on Android where ExoPlayer needs direct URL access
+179    #[cfg(target_os = "android")]
+180    pub fn playback_url(&self) -> String {
+181        match &self.source {
+182            MediaSource::Remote { stream_url, .. } => stream_url.clone(),
+183            MediaSource::Local { file_path, .. } => file_path.to_string_lossy().to_string(),
+184            MediaSource::DirectUrl { url } => url.clone(),
+185        }
+186    }
+187}
+188
+189#[cfg(test)]
+190mod tests {
+191    use super::*;
+192    use std::path::PathBuf;
+193
+194    #[test]
+195    fn test_queue_context_album() {
+196        let context = QueueContext::Album {
+197            album_id: "album-123".to_string(),
+198            album_name: "Test Album".to_string(),
+199        };
+200
+201        assert!(matches!(context, QueueContext::Album { .. }));
+202    }
+203
+204    #[test]
+205    fn test_queue_context_playlist() {
+206        let context = QueueContext::Playlist {
+207            playlist_id: "playlist-456".to_string(),
+208            playlist_name: "Test Playlist".to_string(),
+209        };
+210
+211        assert!(matches!(context, QueueContext::Playlist { .. }));
+212    }
+213
+214    #[test]
+215    fn test_queue_context_custom() {
+216        let context = QueueContext::Custom;
+217        assert!(matches!(context, QueueContext::Custom));
+218    }
+219
+220    #[test]
+221    fn test_queue_context_serialization() {
+222        let context = QueueContext::Album {
+223            album_id: "alb-001".to_string(),
+224            album_name: "Album 001".to_string(),
+225        };
+226
+227        let json = serde_json::to_string(&context);
+228        assert!(json.is_ok());
+229        let serialized = json.unwrap();
+230        assert!(serialized.contains("album"));
+231    }
+232
+233    #[test]
+234    fn test_queue_context_default() {
+235        let context = QueueContext::default();
+236        assert!(matches!(context, QueueContext::Custom));
+237    }
+238
+239    #[test]
+240    fn test_queue_context_clone() {
+241        let context = QueueContext::Album {
+242            album_id: "clone-alb".to_string(),
+243            album_name: "Clone Album".to_string(),
+244        };
+245
+246        let cloned = context.clone();
+247        assert_eq!(context, cloned);
+248    }
+249
+250    #[test]
+251    fn test_subtitle_track_creation() {
+252        let track = SubtitleTrack {
+253            index: 0,
+254            url: "https://example.com/subs.vtt".to_string(),
+255            language: Some("eng".to_string()),
+256            label: Some("English".to_string()),
+257            mime_type: "text/vtt".to_string(),
+258        };
+259
+260        assert_eq!(track.index, 0);
+261        assert_eq!(track.language, Some("eng".to_string()));
+262    }
+263
+264    #[test]
+265    fn test_subtitle_track_without_language() {
+266        let track = SubtitleTrack {
+267            index: 1,
+268            url: "https://example.com/subs.srt".to_string(),
+269            language: None,
+270            label: Some("Subtitles".to_string()),
+271            mime_type: "application/x-subrip".to_string(),
+272        };
+273
+274        assert!(track.language.is_none());
+275        assert!(track.label.is_some());
+276    }
+277
+278    #[test]
+279    fn test_subtitle_track_serialization() {
+280        let track = SubtitleTrack {
+281            index: 0,
+282            url: "url.vtt".to_string(),
+283            language: Some("eng".to_string()),
+284            label: None,
+285            mime_type: "text/vtt".to_string(),
+286        };
+287
+288        let json = serde_json::to_string(&track);
+289        assert!(json.is_ok());
+290        let serialized = json.unwrap();
+291        assert!(serialized.contains("0"));
+292        assert!(serialized.contains("eng"));
+293    }
+294
+295    #[test]
+296    fn test_media_type_audio() {
+297        let media_type = MediaType::Audio;
+298        let json = serde_json::to_string(&media_type).unwrap();
+299        assert!(json.contains("audio"));
+300    }
+301
+302    #[test]
+303    fn test_media_type_video() {
+304        let media_type = MediaType::Video;
+305        let json = serde_json::to_string(&media_type).unwrap();
+306        assert!(json.contains("video"));
+307    }
+308
+309    #[test]
+310    fn test_media_source_remote() {
+311        let source = MediaSource::Remote {
+312            stream_url: "https://server.com/video.mp4".to_string(),
+313            jellyfin_item_id: "item-123".to_string(),
+314        };
+315
+316        assert!(matches!(source, MediaSource::Remote { .. }));
+317    }
+318
+319    #[test]
+320    fn test_media_source_local() {
+321        let source = MediaSource::Local {
+322            file_path: PathBuf::from("/path/to/video.mp4"),
+323            jellyfin_item_id: Some("item-456".to_string()),
+324        };
+325
+326        assert!(matches!(source, MediaSource::Local { .. }));
+327    }
+328
+329    #[test]
+330    fn test_media_source_local_without_jellyfin_id() {
+331        let source = MediaSource::Local {
+332            file_path: PathBuf::from("/downloads/audio.mp3"),
+333            jellyfin_item_id: None,
+334        };
+335
+336        assert!(matches!(source, MediaSource::Local { .. }));
+337    }
+338
+339    #[test]
+340    fn test_media_source_direct_url() {
+341        let source = MediaSource::DirectUrl {
+342            url: "https://external.com/stream".to_string(),
+343        };
+344
+345        assert!(matches!(source, MediaSource::DirectUrl { .. }));
+346    }
+347
+348    #[test]
+349    fn test_media_source_serialization() {
+350        let source = MediaSource::DirectUrl {
+351            url: "https://example.com/stream".to_string(),
+352        };
+353
+354        let json = serde_json::to_string(&source);
+355        assert!(json.is_ok());
+356        let serialized = json.unwrap();
+357        assert!(serialized.contains("directurl"));
+358    }
+359
+360    #[test]
+361    fn test_media_item_creation_minimal() {
+362        let item = MediaItem {
+363            id: "item-1".to_string(),
+364            title: "Test Item".to_string(),
+365            name: None,
+366            artist: None,
+367            album: None,
+368            album_name: None,
+369            album_id: None,
+370            artist_items: None,
+371            artists: None,
+372            primary_image_tag: None,
+373            image_id: None,
+374            item_type: None,
+375            playlist_id: None,
+376            duration: None,
+377            artwork_url: None,
+378            media_type: MediaType::Video,
+379            source: MediaSource::DirectUrl {
+380                url: "https://example.com/video".to_string(),
+381            },
+382            video_codec: None,
+383            needs_transcoding: false,
+384            video_width: None,
+385            video_height: None,
+386            subtitles: vec![],
+387            series_id: None,
+388            server_id: None,
+389        };
+390
+391        assert_eq!(item.id, "item-1");
+392        assert_eq!(item.title, "Test Item");
+393        assert!(!item.needs_transcoding);
+394    }
+395
+396    #[test]
+397    fn test_media_item_jellyfin_id() {
+398        let item = MediaItem {
+399            id: "item-2".to_string(),
+400            title: "Test".to_string(),
+401            name: None,
+402            artist: None,
+403            album: None,
+404            album_name: None,
+405            album_id: None,
+406            artist_items: None,
+407            artists: None,
+408            primary_image_tag: None,
+409            image_id: None,
+410            item_type: None,
+411            playlist_id: None,
+412            duration: None,
+413            artwork_url: None,
+414            media_type: MediaType::Audio,
+415            source: MediaSource::Remote {
+416                stream_url: "https://server/stream".to_string(),
+417                jellyfin_item_id: "jf-id-123".to_string(),
+418            },
+419            video_codec: None,
+420            needs_transcoding: false,
+421            video_width: None,
+422            video_height: None,
+423            subtitles: vec![],
+424            series_id: None,
+425            server_id: None,
+426        };
+427
+428        assert_eq!(item.jellyfin_id(), Some("jf-id-123"));
+429    }
+430
+431    #[test]
+432    fn test_media_item_jellyfin_id_local() {
+433        let item = MediaItem {
+434            id: "item-3".to_string(),
+435            title: "Local".to_string(),
+436            name: None,
+437            artist: None,
+438            album: None,
+439            album_name: None,
+440            album_id: None,
+441            artist_items: None,
+442            artists: None,
+443            primary_image_tag: None,
+444            image_id: None,
+445            item_type: None,
+446            playlist_id: None,
+447            duration: None,
+448            artwork_url: None,
+449            media_type: MediaType::Video,
+450            source: MediaSource::Local {
+451                file_path: PathBuf::from("/local/video.mp4"),
+452                jellyfin_item_id: Some("jf-local".to_string()),
+453            },
+454            video_codec: None,
+455            needs_transcoding: false,
+456            video_width: None,
+457            video_height: None,
+458            subtitles: vec![],
+459            series_id: None,
+460            server_id: None,
+461        };
+462
+463        assert_eq!(item.jellyfin_id(), Some("jf-local"));
+464    }
+465
+466    #[test]
+467    fn test_media_item_jellyfin_id_direct_url() {
+468        let item = MediaItem {
+469            id: "item-4".to_string(),
+470            title: "Direct".to_string(),
+471            name: None,
+472            artist: None,
+473            album: None,
+474            album_name: None,
+475            album_id: None,
+476            artist_items: None,
+477            artists: None,
+478            primary_image_tag: None,
+479            image_id: None,
+480            item_type: None,
+481            playlist_id: None,
+482            duration: None,
+483            artwork_url: None,
+484            media_type: MediaType::Video,
+485            source: MediaSource::DirectUrl {
+486                url: "https://external.com/media".to_string(),
+487            },
+488            video_codec: None,
+489            needs_transcoding: false,
+490            video_width: None,
+491            video_height: None,
+492            subtitles: vec![],
+493            series_id: None,
+494            server_id: None,
+495        };
+496
+497        assert_eq!(item.jellyfin_id(), None);
+498    }
+499
+500    #[test]
+501    fn test_media_item_with_subtitles() {
+502        let sub = SubtitleTrack {
+503            index: 0,
+504            url: "subs.vtt".to_string(),
+505            language: Some("eng".to_string()),
+506            label: Some("English".to_string()),
+507            mime_type: "text/vtt".to_string(),
+508        };
+509
+510        let item = MediaItem {
+511            id: "item-subs".to_string(),
+512            title: "With Subs".to_string(),
+513            name: None,
+514            artist: None,
+515            album: None,
+516            album_name: None,
+517            album_id: None,
+518            artist_items: None,
+519            artists: None,
+520            primary_image_tag: None,
+521            image_id: None,
+522            item_type: None,
+523            playlist_id: None,
+524            duration: None,
+525            artwork_url: None,
+526            media_type: MediaType::Video,
+527            source: MediaSource::DirectUrl {
+528                url: "video.mp4".to_string(),
+529            },
+530            video_codec: None,
+531            needs_transcoding: false,
+532            video_width: None,
+533            video_height: None,
+534            subtitles: vec![sub],
+535            series_id: None,
+536            server_id: None,
+537        };
+538
+539        assert_eq!(item.subtitles.len(), 1);
+540        assert_eq!(item.subtitles[0].language, Some("eng".to_string()));
+541    }
+542
+543    #[test]
+544    fn test_media_item_serialization() {
+545        let item = MediaItem {
+546            id: "serial-item".to_string(),
+547            title: "Serial Test".to_string(),
+548            name: Some("Name".to_string()),
+549            artist: None,
+550            album: None,
+551            album_name: None,
+552            album_id: None,
+553            artist_items: None,
+554            artists: None,
+555            primary_image_tag: None,
+556            image_id: None,
+557            item_type: Some("Movie".to_string()),
+558            playlist_id: None,
+559            duration: Some(120.0),
+560            artwork_url: None,
+561            media_type: MediaType::Video,
+562            source: MediaSource::DirectUrl {
+563                url: "https://example.com/movie.mp4".to_string(),
+564            },
+565            video_codec: Some("h264".to_string()),
+566            needs_transcoding: false,
+567            video_width: Some(1920),
+568            video_height: Some(1080),
+569            subtitles: vec![],
+570            series_id: None,
+571            server_id: None,
+572        };
+573
+574        let json = serde_json::to_string(&item);
+575        assert!(json.is_ok());
+576        let serialized = json.unwrap();
+577        assert!(serialized.contains("serial-item"));
+578        assert!(serialized.contains("Serial Test"));
+579    }
+580}
+
\ No newline at end of file diff --git a/api/src/jellytau_lib/player/mod.rs.html b/api/src/jellytau_lib/player/mod.rs.html new file mode 100644 index 00000000..d10ecba5 --- /dev/null +++ b/api/src/jellytau_lib/player/mod.rs.html @@ -0,0 +1,4734 @@ +mod.rs - sourceSkip to main content

jellytau_lib/player/
mod.rs

1// Player module - Complete playback control system
+2// TRACES: UR-003, UR-004, UR-005, UR-019, UR-023, UR-026 |
+3//         IR-003, IR-004, IR-006, IR-008 |
+4//         DR-001, DR-004, DR-005, DR-009, DR-028, DR-029, DR-047
+5pub mod autoplay;
+6pub mod backend;
+7pub mod background_policy;
+8pub mod events;
+9pub mod media;
+10pub mod queue;
+11pub mod seek;
+12pub mod session;
+13pub mod sleep_timer;
+14pub mod state;
+15pub mod stream_end;
+16
+17#[cfg(test)]
+18mod mpv_backend_test;
+19
+20// Platform-specific backends
+21#[cfg(target_os = "android")]
+22pub mod android;
+23
+24#[cfg(target_os = "linux")]
+25pub mod mpv_backend;
+26
+27// Platforms with no native audio backend (e.g. Windows) render audio-only
+28// playback through a webview <audio> element, mirroring how all video renders.
+29#[cfg(not(any(target_os = "linux", target_os = "android")))]
+30pub mod webview_audio_backend;
+31
+32// Re-export commonly used types
+33pub use autoplay::{AutoplayDecision, AutoplaySettings};
+34pub use backend::{NullBackend, PlayerBackend, PlayerError};
+35pub use events::{PlayerEventEmitter, PlayerStatusEvent, TauriEventEmitter};
+36pub use media::{MediaItem, MediaSource, MediaType, QueueContext, SubtitleTrack};
+37pub use queue::{QueueManager, RepeatMode};
+38pub use seek::{determine_video_seek_strategy, VideoSeekStrategy};
+39pub use session::{MediaSessionManager, MediaSessionType};
+40pub use sleep_timer::{SleepTimerMode, SleepTimerState};
+41pub use state::{EndReason, PlayerState};
+42
+43// Re-export platform-specific backends
+44#[cfg(target_os = "android")]
+45pub use android::ExoPlayerBackend;
+46
+47#[cfg(target_os = "linux")]
+48pub use mpv_backend::MpvBackend;
+49
+50#[cfg(not(any(target_os = "linux", target_os = "android")))]
+51pub use webview_audio_backend::WebviewAudioBackend;
+52
+53#[cfg(target_os = "android")]
+54pub use android::{
+55    disable_remote_volume, enable_remote_volume, get_detected_codecs, set_media_command_handler,
+56    set_remote_volume_handler, MediaCommandHandler, RemoteVolumeHandler,
+57};
+58
+59/// Where the player's playback reports go.
+60///
+61/// The controller's side of reporting is "send this, don't make me wait": a slow
+62/// or failing sync must never stall playback, so every send is fire-and-forget.
+63/// Production wires this to [`PlaybackReporter`] (local DB, server sync, offline
+64/// queueing); tests capture the operations instead of standing up a database and
+65/// an HTTP client, which is what let the missing reports below be written as
+66/// failing tests rather than found on a device.
+67///
+68/// TRACES: UR-025 | DR-179
+69pub trait PlaybackReportSink: Send + Sync {
+70    /// Deliver `operation`. Must not block the caller.
+71    fn send(&self, operation: PlaybackOperation);
+72}
+73
+74/// The production sink: hands each operation to the `PlaybackReporter`.
+75///
+76/// Reports originate on whatever thread playback ended or ticked on — including
+77/// JNI callbacks with no Tokio runtime attached — so the spawn falls back to a
+78/// throwaway runtime on its own thread rather than assuming one is current.
+79struct ReporterSink {
+80    reporter: Arc<TokioMutex<Option<PlaybackReporter>>>,
+81}
+82
+83impl PlaybackReportSink for ReporterSink {
+84    fn send(&self, operation: PlaybackOperation) {
+85        let reporter = self.reporter.clone();
+86        let task = async move {
+87            let guard = reporter.lock().await;
+88            let Some(reporter) = guard.as_ref() else {
+89                warn!("[PlayerController] PlaybackReporter not initialized; dropping report");
+90                return;
+91            };
+92            // `report` decides local-vs-server and queues for sync itself.
+93            if let Err(e) = reporter.report(operation, true).await {
+94                log::error!("[PlayerController] Failed to report playback: {}", e);
+95            }
+96        };
+97
+98        if let Ok(handle) = tokio::runtime::Handle::try_current() {
+99            handle.spawn(task);
+100        } else {
+101            std::thread::spawn(move || match tokio::runtime::Runtime::new() {
+102                Ok(rt) => rt.block_on(task),
+103                Err(e) => log::error!(
+104                    "[PlayerController] No runtime available to report playback: {}",
+105                    e
+106                ),
+107            });
+108        }
+109    }
+110}
+111
+112/// The position to report when a stream ends naturally.
+113///
+114/// The item's runtime when we know it, because the point of the report is to say
+115/// the episode *finished* and Jellyfin decides that by percentage — the last
+116/// position actually observed can be seconds short, and on a handoff whose ticks
+117/// stopped early it can be nowhere near the end. Without a runtime the best
+118/// available answer is where playback got to.
+119///
+120/// TRACES: UR-025, UR-040 | DR-179 | UT-179
+121fn completion_report_position(runtime: Option<f64>, last_position: f64) -> f64 {
+122    match runtime {
+123        Some(runtime) if runtime > 0.0 => runtime,
+124        _ => last_position.max(0.0),
+125    }
+126}
+127
+128/// Seconds added per attempt before retrying a stream that failed with an error.
+129///
+130/// Attempt 1 waits this long, attempt 2 twice as long, and so on — a spread that
+131/// covers roughly a quarter-minute of outage across the retry budget without
+132/// leaving the user staring at a dead notification when the network is truly gone.
+133/// Only *read* by the Android error callback (`#[cfg(android)]`), but compiled
+134/// and unit-tested on the host, hence `allow(dead_code)` off-Android.
+135#[cfg_attr(not(target_os = "android"), allow(dead_code))]
+136const RESUME_BACKOFF_STEP_SECS: u64 = 2;
+137
+138/// Metadata for the lockscreen / media notification.
+139///
+140/// Used to drive the Android MediaSession from Rust in remote (cast) mode, where
+141/// the local ExoPlayer is idle and so can't supply now-playing info. The session
+142/// poller fills this in from the remote Jellyfin session and pushes it to the
+143/// notification so the lockscreen stays in sync while casting.
+144///
+145/// TRACES: UR-006 | IR-006
+146#[derive(Debug, Clone)]
+147// Fields are read only by the Android MediaSession bridge; on other platforms
+148// `update_lockscreen_metadata` is a no-op, so they're constructed but unread.
+149#[cfg_attr(not(target_os = "android"), allow(dead_code))]
+150pub struct LockscreenMetadata {
+151    pub title: String,
+152    pub artist: String,
+153    pub album: Option<String>,
+154    /// Track duration in milliseconds.
+155    pub duration_ms: i64,
+156    /// Current playback position in milliseconds.
+157    pub position_ms: i64,
+158    pub is_playing: bool,
+159}
+160
+161/// Push now-playing metadata to the Android lockscreen. No-op off Android, so the
+162/// session poller can call it unconditionally and stay platform-agnostic.
+163///
+164/// No-op on Linux specifically because there is no MPRIS/D-Bus publisher — see
+165/// IR-005, which is still Planned.
+166///
+167/// TRACES: UR-006 | IR-006
+168pub fn update_lockscreen_metadata(_meta: &LockscreenMetadata) -> Result<(), String> {
+169    #[cfg(target_os = "android")]
+170    {
+171        return android::update_lockscreen_metadata(_meta);
+172    }
+173    #[cfg(not(target_os = "android"))]
+174    {
+175        Ok(())
+176    }
+177}
+178
+179/// Set the base offset (seconds) added to positions reported to the Android
+180/// lockscreen scrubber. Used by the background-audio handoff: the audio stream
+181/// starts at the handoff point (StartTimeTicks), so ExoPlayer's position is
+182/// relative and must be shifted back to absolute to match the full duration.
+183/// Pass 0.0 to clear on exit. No-op off Android.
+184pub fn set_lockscreen_position_offset(_offset_seconds: f64) -> Result<(), String> {
+185    #[cfg(target_os = "android")]
+186    {
+187        return android::set_position_offset(_offset_seconds);
+188    }
+189    #[cfg(not(target_os = "android"))]
+190    {
+191        Ok(())
+192    }
+193}
+194
+195use crate::utils::lock::MutexSafe;
+196use log::{debug, error, info, warn};
+197use std::sync::{Arc, Mutex};
+198use std::time::Duration;
+199use tokio::sync::Mutex as TokioMutex;
+200
+201use crate::jellyfin::JellyfinClient;
+202use crate::playback_reporting::{
+203    EventThrottler, PlaybackContext, PlaybackOperation, PlaybackReporter,
+204};
+205use crate::repository::MediaRepository;
+206use crate::settings::AudioSettings;
+207use crate::utils::conversions::seconds_to_ticks;
+208
+209/// Central player controller that coordinates playback
+210pub struct PlayerController {
+211    backend: Arc<Mutex<Box<dyn PlayerBackend>>>,
+212    queue: Arc<Mutex<QueueManager>>,
+213    jellyfin_client: Arc<Mutex<Option<JellyfinClient>>>,
+214    muted: bool,
+215
+216    // Sleep timer state
+217    sleep_timer: Arc<Mutex<SleepTimerState>>,
+218
+219    // Autoplay settings
+220    autoplay_settings: Arc<Mutex<AutoplaySettings>>,
+221
+222    // Repository for fetching next episodes
+223    repository: Arc<Mutex<Option<Arc<dyn MediaRepository>>>>,
+224
+225    // Event emitter for notifications
+226    event_emitter: Arc<Mutex<Option<Arc<dyn PlayerEventEmitter>>>>,
+227
+228    // Countdown cancellation handle
+229    countdown_cancel: Arc<Mutex<Option<Arc<Mutex<bool>>>>>,
+230
+231    // Playback reporting (dual sync: local DB + server)
+232    playback_reporter: Arc<TokioMutex<Option<PlaybackReporter>>>,
+233
+234    // Where playback reports go. Swappable so tests can assert on what the
+235    // player tells Jellyfin. See `PlaybackReportSink`.
+236    reports: Arc<Mutex<Arc<dyn PlaybackReportSink>>>,
+237
+238    // Bounds progress reports to one per item per 30s. Position ticks arrive
+239    // four times a second; the server needs a resume point, not a firehose.
+240    position_throttler: Arc<EventThrottler>,
+241
+242    // End reason tracking for autoplay decision making
+243    end_reason: Arc<Mutex<Option<EndReason>>>,
+244
+245    // Auto-play episode counter (session-based, resets on manual play)
+246    autoplay_episode_count: Arc<Mutex<u32>>,
+247
+248    // Base offset (seconds) of the active background-audio handoff.
+249    //
+250    // The audio-only stream is requested with `StartTimeTicks` = the position the
+251    // video was handed off at, so the server makes that point the stream's zero
+252    // and the native player reports position RELATIVE to it. Adding this base back
+253    // yields the absolute position to resume the video at on the way out.
+254    //
+255    // Lives on the controller (not beside the command) because the queue and this
+256    // offset describe the same stream: whenever the controller loads a different
+257    // one — notably the backend-driven advance to the next episode — the base has
+258    // to move with it.
+259    //
+260    // TRACES: UR-040 | DR-052
+261    background_audio_base: Arc<Mutex<f64>>,
+262
+263    // True while a background-audio handoff owns playback: the native audio
+264    // player is the real player and the webview <video> has been torn down.
+265    //
+266    // The teardown is what makes this necessary. It fires a DOM `pause` that the
+267    // frontend reports like any other, which would otherwise leave the controller
+268    // believing webview media is still active — aiming lockscreen transport at an
+269    // element that no longer exists (see `is_html5_active`).
+270    //
+271    // TRACES: UR-040 | DR-052, DR-097
+272    background_audio_active: Arc<Mutex<bool>>,
+273
+274    // Budget for re-opening a stream that ended short of the item's runtime.
+275    //
+276    // A resume re-requests the same URL, so a server that is genuinely gone would
+277    // otherwise end → resume → end without limit. The tracker only bounds retries
+278    // that make no progress; a resume that plays on refills it.
+279    //
+280    // TRACES: UR-040 | DR-129
+281    stream_resume: Arc<Mutex<stream_end::ResumeTracker>>,
+282
+283    // Last state reported by a webview-rendered HTML5 <video>/<audio> element.
+284    //
+285    // Webview-rendered media is played by an element the native backend cannot
+286    // reach, so the backend's own state() says nothing about it. Tracking the
+287    // REPORTED state here is what lets transport (play/pause/toggle) be decided
+288    // in Rust for that media instead of the frontend reading `el.paused` off the
+289    // DOM — a value that flips transiently while buffering/seeking and caused
+290    // competing intents to take opposing actions. `None` means no webview media
+291    // is active and the native backend is authoritative. See DR-097.
+292    html5_playing: Arc<Mutex<Option<bool>>>,
+293
+294    // Last position/duration reported by webview-rendered media.
+295    //
+296    // On the webview path the `<video>` element IS the player: nothing is loaded
+297    // into the native backend, so `backend.position()` is a permanent 0. Those
+298    // reports used to be re-emitted to the frontend and then dropped, which is
+299    // why every position the *backend* sent to Jellyfin — including the stop
+300    // report that sets the resume point — was zero, overwriting the correct one
+301    // the frontend had just sent. Storing them here makes
+302    // `absolute_position()` answer for both rendering paths.
+303    //
+304    // TRACES: UR-005, UR-025 | DR-178
+305    reported_time: Arc<Mutex<stream_end::ObservedTime>>,
+306}
+307
+308impl PlayerController {
+309    pub fn new(
+310        backend: Box<dyn PlayerBackend>,
+311        playback_reporter: Arc<TokioMutex<Option<PlaybackReporter>>>,
+312        position_throttler: Arc<EventThrottler>,
+313    ) -> Self {
+314        let reports: Arc<dyn PlaybackReportSink> = Arc::new(ReporterSink {
+315            reporter: playback_reporter.clone(),
+316        });
+317        let controller = Self {
+318            backend: Arc::new(Mutex::new(backend)),
+319            queue: Arc::new(Mutex::new(QueueManager::new())),
+320            jellyfin_client: Arc::new(Mutex::new(None)),
+321            muted: false,
+322            sleep_timer: Arc::new(Mutex::new(SleepTimerState::default())),
+323            autoplay_settings: Arc::new(Mutex::new(AutoplaySettings::default())),
+324            repository: Arc::new(Mutex::new(None)),
+325            event_emitter: Arc::new(Mutex::new(None)),
+326            countdown_cancel: Arc::new(Mutex::new(None)),
+327            playback_reporter,
+328            reports: Arc::new(Mutex::new(reports)),
+329            position_throttler,
+330            end_reason: Arc::new(Mutex::new(None)),
+331            autoplay_episode_count: Arc::new(Mutex::new(0)),
+332            background_audio_base: Arc::new(Mutex::new(0.0)),
+333            background_audio_active: Arc::new(Mutex::new(false)),
+334            stream_resume: Arc::new(Mutex::new(stream_end::ResumeTracker::default())),
+335            html5_playing: Arc::new(Mutex::new(None)),
+336            reported_time: Arc::new(Mutex::new(stream_end::ObservedTime::default())),
+337        };
+338
+339        // Start background timer thread for sleep timer countdown
+340        controller.start_timer_thread();
+341
+342        controller
+343    }
+344
+345    /// Configure the Jellyfin API client for automatic playback reporting
+346    pub fn set_jellyfin_client(&self, client: Option<JellyfinClient>) {
+347        let mut jellyfin = self.jellyfin_client.lock_safe();
+348        *jellyfin = client;
+349        log::info!(
+350            "[PlayerController] Jellyfin client configured: {}",
+351            jellyfin.is_some()
+352        );
+353    }
+354
+355    /// Get a reference to the Jellyfin client (for remote session control)
+356    pub fn jellyfin_client(&self) -> Arc<Mutex<Option<JellyfinClient>>> {
+357        self.jellyfin_client.clone()
+358    }
+359
+360    /// Configure the media repository used for next-episode lookups.
+361    ///
+362    /// The Android ExoPlayer ended-callback calls `on_playback_ended` with no
+363    /// repository handle (unlike the Linux HTML5 path, which passes one per
+364    /// call), so the controller needs a repository of its own or episode
+365    /// autoplay silently decides Stop.
+366    pub fn set_repository(&self, repo: Arc<dyn MediaRepository>) {
+367        *self.repository.lock_safe() = Some(repo);
+368    }
+369
+370    /// Configure the playback reporter for dual sync (local DB + server).
+371    /// Called from `player_configure_jellyfin` on login/restore/reauth.
+372    pub async fn set_playback_reporter(&self, reporter: Option<PlaybackReporter>) {
+373        let mut reporter_guard = self.playback_reporter.lock().await;
+374        *reporter_guard = reporter;
+375        log::info!(
+376            "[PlayerController] Playback reporter configured: {}",
+377            reporter_guard.is_some()
+378        );
+379    }
+380
+381    /// Get a reference to the playback reporter (for backend position updates)
+382    /// Will be used when position update hooks are added to backends
+383    #[allow(dead_code)]
+384    pub fn playback_reporter(&self) -> Arc<TokioMutex<Option<PlaybackReporter>>> {
+385        self.playback_reporter.clone()
+386    }
+387
+388    /// Get a reference to the position throttler (for backend position updates)
+389    /// Will be used when position update hooks are added to backends
+390    #[allow(dead_code)]
+391    pub fn position_throttler(&self) -> Arc<EventThrottler> {
+392        self.position_throttler.clone()
+393    }
+394
+395    /// Set the end reason for the next playback end event
+396    fn set_end_reason(&self, reason: EndReason) {
+397        log::debug!("[PlayerController] Setting end reason: {:?}", reason);
+398        *self.end_reason.lock_safe() = Some(reason);
+399    }
+400
+401    /// Get and clear the current end reason
+402    fn take_end_reason(&self) -> Option<EndReason> {
+403        self.end_reason.lock_safe().take()
+404    }
+405
+406    /// Read the end reason WITHOUT consuming it.
+407    ///
+408    /// `take_end_reason` has an owner: on Android the JNI ended-callback consumes
+409    /// the `NewTrackLoaded` every load sets, and the frontend's echoed call is the
+410    /// one that sees `None` and decides. The truncated-stream check runs in both
+411    /// calls and must not disturb that hand-off, so it peeks.
+412    fn peek_end_reason(&self) -> Option<EndReason> {
+413        *self.end_reason.lock_safe()
+414    }
+415
+416    /// Record that playback is being stopped by an expiring sleep timer.
+417    ///
+418    /// Stopping the backend makes it fire its ended callback (ExoPlayer does on
+419    /// Android), which lands in `on_playback_ended`. Without an end reason that
+420    /// reads as a natural finish and autoplay advances — defeating the timer.
+421    /// `UserStop` is the honest label: the stop was user-initiated, just via the
+422    /// timer they set rather than the stop button.
+423    ///
+424    /// Takes the shared slot rather than `&self` so the sleep-timer thread —
+425    /// which owns clones, not the controller — records it the same way.
+426    ///
+427    /// TRACES: UR-023, UR-026 | DR-029
+428    fn note_sleep_timer_stop(end_reason: &Arc<Mutex<Option<EndReason>>>) {
+429        log::debug!("[PlayerController] Sleep timer stop: marking end reason UserStop");
+430        *end_reason.lock_safe() = Some(EndReason::UserStop);
+431    }
+432
+433    /// Increment autoplay episode counter. Returns true if limit is reached.
+434    fn increment_autoplay_count(&self) -> bool {
+435        let max = self.autoplay_settings.lock_safe().max_episodes;
+436
+437        if max == 0 {
+438            // Unlimited
+439            return false;
+440        }
+441
+442        let mut count = self.autoplay_episode_count.lock_safe();
+443        *count += 1;
+444        debug!(
+445            "[PlayerController] Autoplay episode count: {}/{}",
+446            *count, max
+447        );
+448
+449        *count >= max
+450    }
+451
+452    /// Reset autoplay episode counter (called on manual play actions)
+453    fn reset_autoplay_count(&self) {
+454        let mut count = self.autoplay_episode_count.lock_safe();
+455        if *count > 0 {
+456            debug!(
+457                "[PlayerController] Resetting autoplay episode counter (was {})",
+458                *count
+459            );
+460        }
+461        *count = 0;
+462    }
+463
+464    /// Load and play a single item (also sets the queue to contain only this item)
+465    pub fn play_item(&self, item: MediaItem) -> Result<(), PlayerError> {
+466        debug!("[PlayerController] play_item: {}", item.title);
+467
+468        // Reset autoplay counter on manual play
+469        self.reset_autoplay_count();
+470
+471        // Update queue with this single item
+472        {
+473            let mut queue = self.queue.lock_safe();
+474            queue.set_queue(vec![item.clone()], 0);
+475        }
+476
+477        // Load and play the item
+478        self.load_and_play(&item)?;
+479
+480        Ok(())
+481    }
+482
+483    /// Set the current queue item without loading it into the playback backend.
+484    ///
+485    /// Used on platforms where video is rendered outside the native backend
+486    /// (Linux WebKitGTK HTML5 <video>): the queue/UI state must reflect the
+487    /// item, but MPV must not start a redundant decode for it.
+488    #[cfg(target_os = "linux")]
+489    pub fn set_current_item(&self, item: MediaItem) -> Result<(), PlayerError> {
+490        debug!(
+491            "[PlayerController] set_current_item (no backend load): {}",
+492            item.title
+493        );
+494
+495        self.reset_autoplay_count();
+496        // A different item is current; the last one's reported position must not
+497        // be reported against it. This path is how webview-rendered video is
+498        // queued (no backend load at all), so it is exactly where a stale
+499        // reading would otherwise survive.
+500        // TRACES: UR-005 | DR-178
+501        self.clear_reported_time();
+502
+503        let mut queue = self.queue.lock_safe();
+504        queue.set_queue(vec![item], 0);
+505
+506        Ok(())
+507    }
+508
+509    /// Load and play an item without modifying the queue
+510    /// Use this when the queue is already set up and you just want to play a specific item from it
+511    pub fn load_and_play(&self, item: &MediaItem) -> Result<(), PlayerError> {
+512        debug!("[PlayerController] load_and_play: {}", item.title);
+513
+514        // Set end reason to NewTrackLoaded to prevent autoplay when MPV ends current track
+515        self.set_end_reason(EndReason::NewTrackLoaded);
+516
+517        // Loading into the native backend IS the statement that native renders
+518        // this item, so transport authority returns to it.
+519        //
+520        // `html5_playing` is written only by the webview element's own reports
+521        // and cleared only when it reports "stopped"/"idle". An element that
+522        // went away without that final report — or webview-rendered music
+523        // earlier in the same process — left `is_html5_active()` true, and then
+524        // every play/pause intent was emitted as a ControlCommand at an element
+525        // that no longer existed instead of reaching the backend. On Android's
+526        // native video path that is a pause button that does nothing, from the
+527        // surface tap and the control bar alike, while seek and skip keep
+528        // working because they decide elsewhere. Whether it happened at all
+529        // depended on what had played before, which is what made it look
+530        // intermittent.
+531        //
+532        // The webview re-establishes its own authority the moment an element
+533        // reports again, so nothing is lost on the HTML5 path: this is the same
+534        // "element is gone" semantics as the "stopped"/"idle" report, applied at
+535        // the point where we can know it directly.
+536        //
+537        // TRACES: UR-005, UR-003 | DR-193
+538        *self.html5_playing.lock_safe() = None;
+539
+540        let mut backend = self.backend.lock_safe();
+541        backend.load(item)?;
+542        backend.play()?;
+543        drop(backend);
+544
+545        // A different item is loading; the last one's reported position must not
+546        // be attributed to it.
+547        self.clear_reported_time();
+548
+549        // Report playback start using PlaybackReporter (dual sync: local DB + server)
+550        if let Some(jellyfin_id) = item.jellyfin_id() {
+551            // Build playback context from item metadata
+552            let context = if item.album_id.is_some() {
+553                Some(PlaybackContext {
+554                    context_type: "container".to_string(),
+555                    context_id: item.album_id.clone(),
+556                })
+557            } else {
+558                None
+559            };
+560
+561            // Where this stream actually begins. Zero for an ordinary load, but a
+562            // background-audio handoff loads a stream whose zero is the handoff
+563            // point — telling the server the session started at 0:00 there both
+564            // misreports the session and, being a position, competes with the
+565            // real one.
+566            let position = self.absolute_position();
+567
+568            log::info!(
+569                "[PlayerController] Reporting playback start: {} @ {:.1}s",
+570                jellyfin_id,
+571                position
+572            );
+573            self.report(PlaybackOperation::Start {
+574                item_id: jellyfin_id.to_string(),
+575                position_ticks: seconds_to_ticks(position),
+576                context,
+577            });
+578        }
+579
+580        Ok(())
+581    }
+582
+583    /// Set the queue and start playing from the specified index
+584    pub fn play_queue(&self, items: Vec<MediaItem>, start_index: usize) -> Result<(), PlayerError> {
+585        self.play_queue_from(items, start_index, None)
+586    }
+587
+588    /// Set the queue and start playing from the specified index, optionally
+589    /// resuming the starting track at `start_position` (seconds).
+590    ///
+591    /// The seek happens immediately after load so the backend never audibly
+592    /// starts at 0 and there's no race against a fixed delay. Used when taking
+593    /// over playback from a remote session.
+594    pub fn play_queue_from(
+595        &self,
+596        items: Vec<MediaItem>,
+597        start_index: usize,
+598        start_position: Option<f64>,
+599    ) -> Result<(), PlayerError> {
+600        debug!(
+601            "[PlayerController] play_queue: {} items, starting at index {} (resume: {:?})",
+602            items.len(),
+603            start_index,
+604            start_position
+605        );
+606
+607        // Reset autoplay counter on manual queue start
+608        self.reset_autoplay_count();
+609
+610        {
+611            let mut queue = self.queue.lock_safe();
+612            queue.set_queue(items, start_index);
+613        }
+614
+615        // Play the current item (without modifying the queue we just set)
+616        if let Some(item) = self.queue.lock_safe().current().cloned() {
+617            self.load_and_play(&item)?;
+618
+619            // Resume from the requested position. Seeking right after load (while
+620            // the backend lock is no longer held) avoids the start-at-0-then-jump
+621            // race that a delayed frontend seek suffers from.
+622            if let Some(position) = start_position {
+623                if position > 0.5 {
+624                    self.seek(position)?;
+625                }
+626            }
+627        }
+628
+629        Ok(())
+630    }
+631
+632    /// Replace the queue without starting local playback.
+633    ///
+634    /// Used when we're controlling a remote session: the tracks play on the
+635    /// remote device, but we keep the local queue in sync so the UI reflects
+636    /// what's playing and a later transfer-to-local has the queue to resume.
+637    pub fn set_queue(&self, items: Vec<MediaItem>, start_index: usize) -> Result<(), PlayerError> {
+638        debug!(
+639            "[PlayerController] set_queue (no local playback): {} items, index {}",
+640            items.len(),
+641            start_index
+642        );
+643        self.reset_autoplay_count();
+644        let mut queue = self.queue.lock_safe();
+645        queue.set_queue(items, start_index);
+646        Ok(())
+647    }
+648
+649    /// True while webview-rendered media (HTML5 `<video>`/`<audio>`) is the real
+650    /// player, so transport must be routed to it rather than the native backend.
+651    ///
+652    /// TRACES: UR-005 | DR-097
+653    pub fn is_html5_active(&self) -> bool {
+654        self.html5_playing.lock_safe().is_some()
+655    }
+656
+657    /// Whether the webview element last reported itself as playing. Meaningless
+658    /// unless [`Self::is_html5_active`] is true.
+659    ///
+660    /// TRACES: UR-005 | DR-097
+661    pub fn html5_is_playing(&self) -> bool {
+662        self.html5_playing.lock_safe().unwrap_or(false)
+663    }
+664
+665    /// Send a transport intent to the webview element that is rendering media.
+666    fn emit_html5_control(&self, action: &str) {
+667        if let Some(emitter) = self.event_emitter.lock_safe().as_ref() {
+668            emitter.emit(PlayerStatusEvent::ControlCommand {
+669                action: action.to_string(),
+670                position: None,
+671            });
+672        }
+673    }
+674
+675    /// Play/resume playback
+676    pub fn play(&self) -> Result<(), PlayerError> {
+677        debug!("[PlayerController] play");
+678        // Webview-rendered media: the native backend isn't playing it, so drive
+679        // the element via a ControlCommand instead (DR-097).
+680        if self.is_html5_active() {
+681            self.emit_html5_control("play");
+682            return Ok(());
+683        }
+684        let mut backend = self.backend.lock_safe();
+685        backend.play()
+686    }
+687
+688    /// Pause playback
+689    pub fn pause(&self) -> Result<(), PlayerError> {
+690        if self.is_html5_active() {
+691            self.emit_html5_control("pause");
+692            return Ok(());
+693        }
+694        let mut backend = self.backend.lock_safe();
+695        backend.pause()
+696    }
+697
+698    /// Toggle play/pause.
+699    ///
+700    /// The decision is made HERE, from authoritative state — the reported webview
+701    /// state for HTML5-rendered media, or the native backend's state otherwise.
+702    /// The frontend must never decide this from the DOM (see DR-097).
+703    ///
+704    /// TRACES: UR-005 | DR-097
+705    pub fn toggle_playback(&self) -> Result<(), PlayerError> {
+706        if self.is_html5_active() {
+707            let action = if self.html5_is_playing() {
+708                "pause"
+709            } else {
+710                "play"
+711            };
+712            self.emit_html5_control(action);
+713            return Ok(());
+714        }
+715        let mut backend = self.backend.lock_safe();
+716        if backend.state().is_playing() {
+717            backend.pause()
+718        } else {
+719            backend.play()
+720        }
+721    }
+722
+723    /// Stop playback
+724    pub fn stop(&self) -> Result<(), PlayerError> {
+725        // Set end reason to UserStop to prevent autoplay
+726        self.set_end_reason(EndReason::UserStop);
+727
+728        // Get current playback info before stopping
+729        let jellyfin_id = {
+730            let queue = self.queue.lock_safe();
+731            queue
+732                .current()
+733                .and_then(|item| item.jellyfin_id().map(|s| s.to_string()))
+734        };
+735
+736        // Read across every rendering path BEFORE stopping: the backend zeroes
+737        // its position on stop, and the element that was reporting is gone.
+738        let position = self.absolute_position();
+739
+740        let mut backend = self.backend.lock_safe();
+741        backend.stop()?;
+742        drop(backend);
+743        self.clear_reported_time();
+744
+745        if let Some(jellyfin_id) = jellyfin_id {
+746            self.report_stopped_at(jellyfin_id, position);
+747        }
+748
+749        Ok(())
+750    }
+751
+752    /// Tell Jellyfin playback stopped at `position`, unless that position is
+753    /// zero.
+754    ///
+755    /// Jellyfin stores the reported position as the resume point, so a zero is
+756    /// not a harmless no-op — it is an instruction to forget where the viewer
+757    /// was. And it is never *information*: nobody watched zero seconds of
+758    /// anything, so every zero this app ever sent came from asking a player that
+759    /// was not rendering the media (webview video, or a handoff whose first tick
+760    /// had not landed). On a device trace, 14 of 14 stop reports in 35 minutes
+761    /// were zeroes, one of them 40s after the frontend had correctly reported
+762    /// 15:22 for the same episode.
+763    ///
+764    /// TRACES: UR-025, UR-005 | DR-179 | UT-178
+765    fn report_stopped_at(&self, jellyfin_id: String, position: f64) {
+766        if position <= 0.0 {
+767            debug!(
+768                "[PlayerController] Withholding zero-position stop report for {} \
+769                 (nothing played; reporting it would clear the resume point)",
+770                jellyfin_id
+771            );
+772            return;
+773        }
+774
+775        log::info!(
+776            "[PlayerController] Reporting playback stopped: {} @ {:.1}s",
+777            jellyfin_id,
+778            position
+779        );
+780        self.report(PlaybackOperation::Stopped {
+781            item_id: jellyfin_id,
+782            position_ticks: seconds_to_ticks(position),
+783        });
+784    }
+785
+786    /// Skip to next track
+787    ///
+788    /// Note: load_and_play sets EndReason::NewTrackLoaded to prevent autoplay
+789    /// from triggering when the current track's EndFile event fires
+790    pub fn next(&self) -> Result<(), PlayerError> {
+791        // Reset autoplay counter on manual skip
+792        self.reset_autoplay_count();
+793
+794        let next_item = {
+795            let mut queue = self.queue.lock_safe();
+796            queue.next().cloned()
+797        };
+798
+799        debug!(
+800            "[PlayerController] next: {:?}",
+801            next_item.as_ref().map(|i| &i.title)
+802        );
+803
+804        if let Some(item) = next_item {
+805            self.load_and_play(&item)
+806        } else {
+807            debug!("[PlayerController] No next item, stopping");
+808            self.stop()
+809        }
+810    }
+811
+812    /// Skip to previous track
+813    ///
+814    /// Note: load_and_play sets EndReason::NewTrackLoaded to prevent autoplay
+815    /// from triggering when the current track's EndFile event fires
+816    pub fn previous(&self) -> Result<(), PlayerError> {
+817        // Reset autoplay counter on manual skip
+818        self.reset_autoplay_count();
+819        // If we're more than 3 seconds in, restart current track
+820        {
+821            let backend = self.backend.lock_safe();
+822            if backend.position() > 3.0 {
+823                debug!("[PlayerController] previous: restarting current track (position > 3s)");
+824                drop(backend);
+825                return self.seek(0.0);
+826            }
+827        }
+828
+829        let prev_item = {
+830            let mut queue = self.queue.lock_safe();
+831            queue.previous().cloned()
+832        };
+833
+834        debug!(
+835            "[PlayerController] previous: {:?}",
+836            prev_item.as_ref().map(|i| &i.title)
+837        );
+838
+839        if let Some(item) = prev_item {
+840            self.load_and_play(&item)
+841        } else {
+842            self.seek(0.0)
+843        }
+844    }
+845
+846    /// Seek to a position in seconds, **on the player's own timeline**.
+847    ///
+848    /// During a background-audio handoff that timeline is relative to the handoff
+849    /// point, so this is not the call a lockscreen scrub or a UI seek wants — use
+850    /// [`seek_absolute`](Self::seek_absolute), which speaks the episode's
+851    /// timeline and is what every caller outside the player itself means.
+852    pub fn seek(&self, position: f64) -> Result<(), PlayerError> {
+853        let mut backend = self.backend.lock_safe();
+854        backend.seek(position)
+855    }
+856
+857    /// Seek to an **absolute** position on the item's own timeline.
+858    ///
+859    /// This is the boundary every outside seek comes through — the UI, the
+860    /// lockscreen scrubber, a headset gesture — because all of them are looking
+861    /// at the whole episode, not at whatever fragment of it the player happens to
+862    /// be streaming.
+863    ///
+864    /// Outside a background-audio handoff the two timelines are the same and this
+865    /// is an ordinary seek. Inside one they differ by the handoff base, and the
+866    /// stream cannot be seeked at all: `/Audio/{id}/universal` is a chunked
+867    /// transcode with no length, so ExoPlayer either refuses or clamps — and a
+868    /// clamped seek lands at stream zero, which is the handoff point. That is the
+869    /// "jumps back to where I locked the screen" symptom. Honouring the seek means
+870    /// re-opening the URL at the new position, which is exactly what the
+871    /// truncation recovery already does, so it shares `resume_stream_at`.
+872    ///
+873    /// TRACES: UR-040, UR-005 | DR-159 | UT-155
+874    pub async fn seek_absolute(&self, position: f64) -> Result<(), String> {
+875        // Only a *streamed* handoff needs the rebuild. A downloaded file seeks
+876        // like any other file — and `resume_stream_at` refuses a non-remote
+877        // source, so sending one through here fails the seek outright.
+878        // TRACES: UR-071 | DR-180 | UT-181
+879        let rebuild = self.is_background_audio_active() && {
+880            let queue = self.queue.lock_safe();
+881            queue
+882                .current()
+883                .map(|item| {
+884                    Self::is_audio_only_video(item)
+885                        && matches!(item.source, MediaSource::Remote { .. })
+886                })
+887                .unwrap_or(false)
+888        };
+889
+890        if rebuild {
+891            return self.resume_stream_at(position.max(0.0)).await;
+892        }
+893
+894        self.seek(position).map_err(|e| e.to_string())
+895    }
+896
+897    /// Set volume (0.0 - 1.0)
+898    pub fn set_volume(&self, volume: f32) -> Result<(), PlayerError> {
+899        self.backend.lock_safe().set_volume(volume)
+900    }
+901
+902    /// Set the active audio track by stream index
+903    pub fn set_audio_track(&self, stream_index: i32) -> Result<(), PlayerError> {
+904        let mut backend = self.backend.lock_safe();
+905        backend.set_audio_track(stream_index)
+906    }
+907
+908    /// Set the active subtitle track by stream index (None to disable subtitles)
+909    pub fn set_subtitle_track(&self, stream_index: Option<i32>) -> Result<(), PlayerError> {
+910        let mut backend = self.backend.lock_safe();
+911        backend.set_subtitle_track(stream_index)
+912    }
+913
+914    /// Get current state
+915    pub fn state(&self) -> PlayerState {
+916        self.backend.lock_safe().state()
+917    }
+918
+919    /// Get current position
+920    pub fn position(&self) -> f64 {
+921        self.backend.lock_safe().position()
+922    }
+923
+924    /// The position on the **item's own timeline**, whatever is rendering it.
+925    ///
+926    /// This is what every outbound position must be taken from — the resume point
+927    /// sent to Jellyfin, the point the video reloads at when a handoff ends, the
+928    /// truncation comparison. `position()` alone answers for exactly one of the
+929    /// three ways this app plays media, and reads 0 for the other two:
+930    ///
+931    /// - **Webview `<video>`/`<audio>`**: nothing is loaded into the native
+932    ///   backend, so its position is a permanent 0. The element's own reports are
+933    ///   the only reading there is.
+934    /// - **Background-audio handoff**: the audio-only stream's zero is the
+935    ///   handoff point, and the base is added at the native tick boundary
+936    ///   (DR-159) — so before the first tick lands, nothing has applied it.
+937    ///   Flooring at the base is exact rather than approximate: the stream cannot
+938    ///   physically be behind its own starting point.
+939    /// - **Native playback**: the backend is authoritative and both other terms
+940    ///   are zero, so the max is its own value.
+941    ///
+942    /// Returning to the foreground during that pre-first-tick window is what
+943    /// restarted an episode from 0:00 and wiped its server-side resume point.
+944    ///
+945    /// TRACES: UR-040, UR-005, UR-025 | DR-178 | UT-176, UT-177
+946    pub fn absolute_position(&self) -> f64 {
+947        let native = self.backend.lock_safe().position().max(0.0);
+948        let reported = self.reported_time.lock_safe().last_position();
+949        let base = if self.is_background_audio_active() {
+950            *self.background_audio_base.lock_safe()
+951        } else {
+952            0.0
+953        };
+954
+955        native.max(reported).max(base)
+956    }
+957
+958    /// The duration last reported by webview-rendered media, if any.
+959    ///
+960    /// TRACES: UR-005 | DR-178 | UT-177
+961    pub fn observed_duration(&self) -> Option<f64> {
+962        self.reported_time.lock_safe().last_duration()
+963    }
+964
+965    /// Forget what webview-rendered media reported.
+966    ///
+967    /// Called wherever that element stops being the player — it was torn down,
+968    /// a handoff took over, or a different item is loading. A stale position
+969    /// outliving its element would be reported against whatever plays next.
+970    ///
+971    /// TRACES: UR-005 | DR-178 | UT-177
+972    fn clear_reported_time(&self) {
+973        self.reported_time.lock_safe().reset();
+974    }
+975
+976    /// Replace the sink playback reports go to. Tests capture; production wires
+977    /// the `PlaybackReporter` at construction and never swaps it.
+978    ///
+979    /// TRACES: UR-025 | DR-179
+980    #[cfg_attr(not(test), allow(dead_code))]
+981    pub fn set_report_sink(&self, sink: Arc<dyn PlaybackReportSink>) {
+982        *self.reports.lock_safe() = sink;
+983    }
+984
+985    /// Send a playback report. Fire-and-forget by contract, so callers can do
+986    /// this while holding nothing and waiting for nothing.
+987    ///
+988    /// TRACES: UR-025 | DR-179
+989    fn report(&self, operation: PlaybackOperation) {
+990        let sink = self.reports.lock_safe().clone();
+991        sink.send(operation);
+992    }
+993
+994    /// The Jellyfin id of whatever is currently queued, if it has one.
+995    fn current_jellyfin_id(&self) -> Option<String> {
+996        let queue = self.queue.lock_safe();
+997        queue
+998            .current()
+999            .and_then(|item| item.jellyfin_id().map(|id| id.to_string()))
+1000    }
+1001
+1002    /// Get duration.
+1003    ///
+1004    /// Falls back to what webview-rendered media reported for the same reason
+1005    /// [`absolute_position`](Self::absolute_position) does: on that path nothing
+1006    /// is loaded into the native backend, so its duration is `None` and the
+1007    /// element's report is the only one there is.
+1008    ///
+1009    /// TRACES: UR-005 | DR-178
+1010    pub fn duration(&self) -> Option<f64> {
+1011        self.backend
+1012            .lock_safe()
+1013            .duration()
+1014            .or_else(|| self.observed_duration())
+1015    }
+1016
+1017    /// Get queue reference
+1018    pub fn queue(&self) -> Arc<Mutex<QueueManager>> {
+1019        self.queue.clone()
+1020    }
+1021
+1022    /// True when the current item is a TV episode being played in audio-only
+1023    /// (background) mode — i.e. an `item_type == "Episode"` item loaded as
+1024    /// `MediaType::Audio`. Used to decide whether the backend must drive the
+1025    /// next-episode advance itself (the frontend is suspended in the background).
+1026    ///
+1027    /// Only *called* from the Android autoplay dispatch (`#[cfg(android)]`), but
+1028    /// compiled and unit-tested on the host, hence `allow(dead_code)` off-Android.
+1029    #[cfg_attr(not(target_os = "android"), allow(dead_code))]
+1030    pub fn current_is_audio_episode(&self) -> bool {
+1031        self.queue
+1032            .lock_safe()
+1033            .current()
+1034            .map(|item| {
+1035                item.media_type == MediaType::Audio && item.item_type.as_deref() == Some("Episode")
+1036            })
+1037            .unwrap_or(false)
+1038    }
+1039
+1040    /// Clear the queue entirely (used when playback genuinely stops, e.g. the
+1041    /// sleep timer fires or the queue ends with repeat off). Pair with
+1042    /// `emit_queue_changed` so the frontend hides the mini player.
+1043    pub fn clear_queue(&self) {
+1044        self.queue.lock_safe().clear();
+1045    }
+1046
+1047    /// Toggle shuffle
+1048    pub fn toggle_shuffle(&self) {
+1049        self.queue.lock_safe().toggle_shuffle();
+1050    }
+1051
+1052    /// Cycle repeat mode
+1053    pub fn cycle_repeat(&self) {
+1054        self.queue.lock_safe().cycle_repeat();
+1055    }
+1056
+1057    /// Check if shuffle is enabled
+1058    pub fn is_shuffle(&self) -> bool {
+1059        self.queue.lock_safe().is_shuffle()
+1060    }
+1061
+1062    /// Get repeat mode
+1063    pub fn repeat_mode(&self) -> RepeatMode {
+1064        self.queue.lock_safe().repeat_mode()
+1065    }
+1066
+1067    /// Get current volume (0.0 - 1.0)
+1068    pub fn volume(&self) -> f32 {
+1069        self.backend.lock_safe().volume()
+1070    }
+1071
+1072    /// Check if muted
+1073    pub fn muted(&self) -> bool {
+1074        self.muted
+1075    }
+1076
+1077    /// Set audio settings (crossfade, gapless, normalization)
+1078    pub fn set_audio_settings(&mut self, settings: &AudioSettings) -> Result<(), PlayerError> {
+1079        self.backend.lock_safe().set_audio_settings(settings)
+1080    }
+1081
+1082    /// Get current audio settings
+1083    pub fn audio_settings(&self) -> AudioSettings {
+1084        self.backend.lock_safe().audio_settings()
+1085    }
+1086
+1087    // ===== Sleep Timer Methods =====
+1088
+1089    /// Set the event emitter for notifications
+1090    pub fn set_event_emitter(&self, emitter: Arc<dyn PlayerEventEmitter>) {
+1091        let mut event_emitter = self.event_emitter.lock_safe();
+1092        *event_emitter = Some(emitter);
+1093    }
+1094
+1095    /// Get the event emitter
+1096    pub fn event_emitter(&self) -> Option<Arc<dyn PlayerEventEmitter>> {
+1097        self.event_emitter.lock_safe().clone()
+1098    }
+1099
+1100    /// Get sleep timer state
+1101    pub fn sleep_timer_state(&self) -> SleepTimerState {
+1102        self.sleep_timer.lock_safe().clone()
+1103    }
+1104
+1105    /// Set sleep timer mode (in-memory only, not persisted)
+1106    pub fn set_sleep_timer(&self, mode: SleepTimerMode) {
+1107        let mut timer = self.sleep_timer.lock_safe();
+1108        timer.mode = mode.clone();
+1109        if let SleepTimerMode::Time { end_time } = mode {
+1110            let now = chrono::Utc::now().timestamp_millis();
+1111            timer.remaining_seconds = ((end_time - now) / 1000).max(0) as u32;
+1112        } else {
+1113            timer.remaining_seconds = 0;
+1114        }
+1115        drop(timer);
+1116
+1117        // Emit event to frontend for display update
+1118        self.emit_sleep_timer_changed();
+1119    }
+1120
+1121    /// Cancel sleep timer
+1122    pub fn cancel_sleep_timer(&self) {
+1123        self.set_sleep_timer(SleepTimerMode::Off);
+1124    }
+1125
+1126    /// Start background timer thread for sleep timer countdown updates
+1127    fn start_timer_thread(&self) {
+1128        let sleep_timer = self.sleep_timer.clone();
+1129        let event_emitter = self.event_emitter.clone();
+1130        let backend = self.backend.clone();
+1131        let end_reason = self.end_reason.clone();
+1132
+1133        std::thread::spawn(move || {
+1134            loop {
+1135                std::thread::sleep(Duration::from_secs(1));
+1136
+1137                let mut timer = sleep_timer.lock_safe();
+1138                if timer.is_active() {
+1139                    timer.update_remaining_seconds();
+1140
+1141                    // Time-based timer expired: stop playback
+1142                    if matches!(timer.mode, SleepTimerMode::Time { .. })
+1143                        && timer.remaining_seconds == 0
+1144                    {
+1145                        debug!("[SleepTimer] Time-based timer expired, stopping playback");
+1146                        timer.cancel();
+1147
+1148                        // Mark the stop *before* it reaches the backend. Stopping
+1149                        // makes the native player fire its ended callback, and
+1150                        // cancelling the timer above means on_playback_ended can no
+1151                        // longer tell this apart from a natural end — without this
+1152                        // it would show the next-episode popup / autoplay right
+1153                        // after the sleep timer fired.
+1154                        Self::note_sleep_timer_stop(&end_reason);
+1155
+1156                        // Emit cancelled state
+1157                        if let Some(emitter) = event_emitter.lock_safe().as_ref() {
+1158                            emitter.emit(PlayerStatusEvent::SleepTimerChanged {
+1159                                mode: SleepTimerMode::Off,
+1160                                remaining_seconds: 0,
+1161                            });
+1162                            // Tell the frontend playback must stop: HTML5 video
+1163                            // (Linux) plays outside the backend, so stopping the
+1164                            // backend below doesn't reach it.
+1165                            emitter.emit(PlayerStatusEvent::SleepTimerExpired);
+1166                        }
+1167                        drop(timer);
+1168
+1169                        // Stop the backend
+1170                        if let Err(e) = backend.lock_safe().stop() {
+1171                            error!("[SleepTimer] Failed to stop playback: {}", e);
+1172                        }
+1173                        continue;
+1174                    }
+1175
+1176                    // Emit update event
+1177                    if let Some(emitter) = event_emitter.lock_safe().as_ref() {
+1178                        emitter.emit(PlayerStatusEvent::SleepTimerChanged {
+1179                            mode: timer.mode.clone(),
+1180                            remaining_seconds: timer.remaining_seconds,
+1181                        });
+1182                    }
+1183                }
+1184                drop(timer);
+1185            }
+1186        });
+1187    }
+1188
+1189    /// Emit sleep timer changed event to frontend
+1190    fn emit_sleep_timer_changed(&self) {
+1191        let timer = self.sleep_timer.lock_safe().clone();
+1192
+1193        if let Some(emitter) = self.event_emitter.lock_safe().as_ref() {
+1194            emitter.emit(PlayerStatusEvent::SleepTimerChanged {
+1195                mode: timer.mode,
+1196                remaining_seconds: timer.remaining_seconds,
+1197            });
+1198        }
+1199    }
+1200
+1201    /// Emit queue changed event to frontend
+1202    pub fn emit_queue_changed(&self) {
+1203        let queue = self.queue.lock_safe();
+1204
+1205        debug!("PlayerController::emit_queue_changed() - Emitting queue with {} items, current_index: {:?}",
+1206            queue.items().len(), queue.current_index());
+1207
+1208        if let Some(emitter) = self.event_emitter.lock_safe().as_ref() {
+1209            emitter.emit(PlayerStatusEvent::QueueChanged {
+1210                items: queue.items().to_vec(),
+1211                current_index: queue.current_index(),
+1212                shuffle: queue.is_shuffle(),
+1213                repeat: queue.repeat_mode(),
+1214                has_next: queue.has_next(),
+1215                has_previous: queue.has_previous(),
+1216            });
+1217        } else {
+1218            warn!("PlayerController::emit_queue_changed() - WARNING: No event emitter set!");
+1219        }
+1220    }
+1221
+1222    // ===== HTML5 video report methods =====
+1223    //
+1224    // On platforms where video is rendered in the webview (Linux WebKitGTK
+1225    // HTML5 <video>), the real player lives outside the native backend, so it
+1226    // cannot emit PlayerStatusEvents itself. The frontend HTML5 adapter reports
+1227    // DOM events here, and these methods re-emit them through the SAME event
+1228    // pipeline the native backends use. This keeps the frontend's player store
+1229    // fed from one place (playerEvents.ts) in both native and HTML5 modes, so
+1230    // the Rust controller stays the single source of truth for player state.
+1231
+1232    /// Report an HTML5 <video> state change (playing/paused/loading/stopped).
+1233    ///
+1234    /// Re-emits a `StateChanged` event identical to what MpvBackend/ExoPlayer
+1235    /// would emit, so `playerEvents.ts` needs no HTML5-specific branch.
+1236    pub fn report_html5_state(&self, state: String, media_id: Option<String>) {
+1237        // A background-audio handoff has already moved playback to the native
+1238        // player and torn the element down; anything it still reports describes
+1239        // a video that is no longer playing. Dropping it keeps the UI on the
+1240        // audio that IS playing and leaves transport with the native backend.
+1241        if self.is_background_audio_active() {
+1242            debug!("[PlayerController] Ignoring HTML5 state '{state}' during background audio");
+1243            return;
+1244        }
+1245        // Track it: this is the authoritative play/pause state for
+1246        // webview-rendered media, and what transport decisions read (DR-097).
+1247        // "stopped"/"idle" mean the element is gone, so hand authority back to
+1248        // the native backend — otherwise music playback would keep emitting
+1249        // ControlCommands at a element that no longer exists.
+1250        let element_gone = {
+1251            let mut tracked = self.html5_playing.lock_safe();
+1252            *tracked = match state.as_str() {
+1253                "playing" => Some(true),
+1254                // "loading" counts as active-but-not-playing so a toggle during
+1255                // load resolves to "play" rather than falling through to the
+1256                // native backend.
+1257                "paused" | "loading" => Some(false),
+1258                // "stopped"/"idle": element is gone, native backend resumes authority.
+1259                _ => None,
+1260            };
+1261            tracked.is_none()
+1262        };
+1263        // Its last position goes with it: whatever plays next is loaded into the
+1264        // native backend, and a stale reading would be reported against that.
+1265        if element_gone {
+1266            self.clear_reported_time();
+1267        }
+1268        if let Some(emitter) = self.event_emitter.lock_safe().as_ref() {
+1269            emitter.emit(PlayerStatusEvent::StateChanged { state, media_id });
+1270        }
+1271    }
+1272
+1273    /// Report an HTML5 <video> position tick.
+1274    ///
+1275    /// Re-emits a `PositionUpdate` event mirroring the native backends' periodic
+1276    /// position updates (the adapter is expected to throttle to ~250ms like MPV).
+1277    pub fn report_html5_position(&self, position: f64, duration: f64) {
+1278        // Stale by definition during a handoff — the native player's ticks are
+1279        // the real position. See `report_html5_state`.
+1280        if self.is_background_audio_active() {
+1281            return;
+1282        }
+1283        // The element is the player on this path, so this tick is the position —
+1284        // for the resume point, the stop report and everything else that asks.
+1285        self.reported_time.lock_safe().record(position, duration);
+1286
+1287        if let Some(emitter) = self.event_emitter.lock_safe().as_ref() {
+1288            emitter.emit(PlayerStatusEvent::PositionUpdate { position, duration });
+1289        }
+1290
+1291        self.report_progress_throttled(position);
+1292    }
+1293
+1294    /// Send a throttled progress report to Jellyfin.
+1295    ///
+1296    /// Progress is what makes a position survive anything other than a clean
+1297    /// exit — a crash, a swipe-away, a battery death — and lets another device
+1298    /// resume mid-episode. Webview-rendered media reported none: the frontend
+1299    /// service writes progress to the local DB only, and Rust had no position
+1300    /// for it to report. A device trace covering 35 minutes of playback hit
+1301    /// `/Sessions/Playing/Progress` exactly zero times.
+1302    ///
+1303    /// The throttler is the one the controller already owned for this purpose
+1304    /// (30s per item), so ticks arriving four times a second cost one request
+1305    /// per half-minute.
+1306    ///
+1307    /// TRACES: UR-005, UR-025 | DR-179 | UT-180
+1308    fn report_progress_throttled(&self, position: f64) {
+1309        if position <= 0.0 {
+1310            return;
+1311        }
+1312        let Some(item_id) = self.current_jellyfin_id() else {
+1313            return;
+1314        };
+1315        if !self.position_throttler.should_report(&item_id) {
+1316            return;
+1317        }
+1318
+1319        self.report(PlaybackOperation::Progress {
+1320            item_id: item_id.clone(),
+1321            position_ticks: seconds_to_ticks(position),
+1322            // Ticks only arrive while the element is playing; a pause is carried
+1323            // by the state report, not by a position that stopped moving.
+1324            is_paused: false,
+1325        });
+1326        self.position_throttler.mark_reported(&item_id);
+1327    }
+1328
+1329    /// Report that the HTML5 <video> element finished loading and knows its
+1330    /// duration. Mirrors the native `MediaLoaded` event.
+1331    pub fn report_html5_media_loaded(&self, duration: f64) {
+1332        // See `report_html5_state` — the element is not the player right now.
+1333        if self.is_background_audio_active() {
+1334            return;
+1335        }
+1336        if let Some(emitter) = self.event_emitter.lock_safe().as_ref() {
+1337            emitter.emit(PlayerStatusEvent::MediaLoaded { duration });
+1338        }
+1339    }
+1340
+1341    // ===== Autoplay Methods =====
+1342
+1343    /// Get autoplay settings
+1344    pub fn autoplay_settings(&self) -> AutoplaySettings {
+1345        self.autoplay_settings.lock_safe().clone()
+1346    }
+1347
+1348    /// Set autoplay settings (in-memory only, persistence handled by command layer)
+1349    pub fn set_autoplay_settings(&self, settings: AutoplaySettings) {
+1350        let validated = settings.with_validated_countdown();
+1351        *self.autoplay_settings.lock_safe() = validated;
+1352    }
+1353
+1354    /// Cancel active autoplay countdown
+1355    pub fn cancel_autoplay_countdown(&self) {
+1356        if let Some(cancel_flag) = self.countdown_cancel.lock_safe().as_ref() {
+1357            *cancel_flag.lock_safe() = true;
+1358        }
+1359    }
+1360
+1361    /// Handle playback ended event - decides what to do next
+1362    ///
+1363    /// Only triggers autoplay if the track finished naturally (EndReason::Finished or None).
+1364    /// If EndReason is NewTrackLoaded, UserStop, UserSkip, or Error, returns Stop without autoplay.
+1365    pub async fn on_playback_ended(&self) -> Result<AutoplayDecision, String> {
+1366        // A truncated stream is not an end at all, so this is decided BEFORE the
+1367        // end-reason gate below — which returns early for the `NewTrackLoaded`
+1368        // that every load sets, and would therefore swallow the whole question on
+1369        // Android's JNI callback: the one call guaranteed to run while the app is
+1370        // backgrounded and the webview cannot echo anything back.
+1371        if let Some(position) = self.truncated_stream_resume_position() {
+1372            return Ok(AutoplayDecision::ResumeStream { position });
+1373        }
+1374
+1375        // Check why playback ended
+1376        let end_reason = self.take_end_reason();
+1377
+1378        debug!(
+1379            "[PlayerController] on_playback_ended: end_reason={:?}",
+1380            end_reason
+1381        );
+1382
+1383        // Only proceed with autoplay logic if track finished naturally
+1384        match end_reason {
+1385            None | Some(EndReason::Finished) => {
+1386                // Track ended naturally, proceed with autoplay logic
+1387                debug!("[PlayerController] Track finished naturally, checking autoplay");
+1388            }
+1389            Some(EndReason::NewTrackLoaded) => {
+1390                // User loaded a new track, don't autoplay
+1391                debug!("[PlayerController] NewTrackLoaded - stopping without autoplay");
+1392                return Ok(AutoplayDecision::Stop);
+1393            }
+1394            Some(EndReason::UserStop) => {
+1395                // User stopped playback, don't autoplay
+1396                debug!("[PlayerController] UserStop - stopping without autoplay");
+1397                return Ok(AutoplayDecision::Stop);
+1398            }
+1399            Some(EndReason::UserSkip) => {
+1400                // User skipped, already handled by next/previous
+1401                debug!("[PlayerController] UserSkip - stopping without autoplay");
+1402                return Ok(AutoplayDecision::Stop);
+1403            }
+1404            Some(EndReason::Error) => {
+1405                // Playback error, don't autoplay
+1406                debug!("[PlayerController] Error - stopping without autoplay");
+1407                return Ok(AutoplayDecision::Stop);
+1408            }
+1409        }
+1410
+1411        let current_item = {
+1412            let queue = self.queue.lock_safe();
+1413            queue.current().cloned()
+1414        };
+1415
+1416        let Some(current) = current_item else {
+1417            return Ok(AutoplayDecision::Stop);
+1418        };
+1419
+1420        // The item is genuinely finished (a truncated stream returned above), so
+1421        // report it before anything advances — after an advance the queue's
+1422        // current item is the *next* episode and this one is unreachable.
+1423        self.report_completion(&current);
+1424
+1425        // Check sleep timer state
+1426        let timer_mode = {
+1427            let timer = self.sleep_timer.lock_safe();
+1428            timer.mode.clone()
+1429        };
+1430
+1431        match &timer_mode {
+1432            SleepTimerMode::Time { end_time } => {
+1433                // If time has expired, stop instead of playing next
+1434                let now = chrono::Utc::now().timestamp_millis();
+1435                if now >= *end_time {
+1436                    debug!("[PlayerController] Time-based sleep timer expired at track boundary");
+1437                    self.sleep_timer.lock_safe().cancel();
+1438                    self.emit_sleep_timer_changed();
+1439                    return Ok(AutoplayDecision::Stop);
+1440                }
+1441            }
+1442            SleepTimerMode::EndOfTrack => {
+1443                // Stop at end of track
+1444                self.sleep_timer.lock_safe().cancel();
+1445                self.emit_sleep_timer_changed();
+1446                return Ok(AutoplayDecision::Stop);
+1447            }
+1448            SleepTimerMode::Episodes { .. } => {
+1449                // Only count TV episodes (not audio tracks or movies). Note an
+1450                // episode played in background-audio mode is MediaType::Audio, so
+1451                // rely on is_episode_item (which checks item_type) rather than the
+1452                // media_type alone.
+1453                let is_episode = self.is_episode_item(&current).await;
+1454
+1455                if is_episode {
+1456                    let should_stop = self.sleep_timer.lock_safe().decrement_episode();
+1457                    self.emit_sleep_timer_changed();
+1458
+1459                    if should_stop {
+1460                        return Ok(AutoplayDecision::Stop);
+1461                    }
+1462                }
+1463            }
+1464            _ => {
+1465                // No action needed for other modes
+1466            }
+1467        }
+1468
+1469        // For episodes, fetch next episode and show popup.
+1470        // Note: This path is typically not hit for HTML5 video (which uses on_video_playback_ended).
+1471        // It's here for the Android ExoPlayer path where episode items sit in the
+1472        // backend queue — including background-audio mode, where the episode is a
+1473        // MediaType::Audio item, so gate on is_episode_item (item_type), not media_type.
+1474        if self.is_episode_item(&current).await {
+1475            let repo = self.repository.lock_safe().clone();
+1476            let jellyfin_id = current.jellyfin_id().unwrap_or(&current.id);
+1477            let next_ep_result = if let Some(repo) = &repo {
+1478                // Degrade lookup failures to Stop: playback already ended, and
+1479                // surfacing an error here just kills autoplay silently upstream.
+1480                match self.fetch_next_episode_for_item(jellyfin_id, repo).await {
+1481                    Ok(next) => next,
+1482                    Err(e) => {
+1483                        warn!(
+1484                            "[PlayerController] Next-episode lookup failed for {}: {}",
+1485                            jellyfin_id, e
+1486                        );
+1487                        None
+1488                    }
+1489                }
+1490            } else {
+1491                warn!("[PlayerController] No repository available for episode lookup - cannot autoplay next episode");
+1492                None
+1493            };
+1494            if let Some(next_ep) = next_ep_result {
+1495                let settings = self.autoplay_settings.lock_safe().clone();
+1496
+1497                // Check if auto-play episode limit is reached
+1498                let limit_reached = self.increment_autoplay_count();
+1499                if limit_reached {
+1500                    debug!(
+1501                        "[PlayerController] Auto-play episode limit reached ({} episodes)",
+1502                        settings.max_episodes
+1503                    );
+1504                }
+1505
+1506                return Ok(AutoplayDecision::ShowNextEpisodePopup {
+1507                    current_episode: next_ep.0, // Repository MediaItem
+1508                    next_episode: next_ep.1,
+1509                    countdown_seconds: settings.countdown_seconds,
+1510                    auto_advance: settings.enabled && !limit_reached,
+1511                });
+1512            }
+1513            // No next episode found
+1514            return Ok(AutoplayDecision::Stop);
+1515        }
+1516
+1517        // For audio/movies, check if there's a next track in the queue
+1518        let has_next = {
+1519            let queue = self.queue.lock_safe();
+1520            queue.has_next()
+1521        };
+1522
+1523        if has_next {
+1524            // Advance to next track
+1525            Ok(AutoplayDecision::AdvanceToNext)
+1526        } else {
+1527            // End of queue
+1528            Ok(AutoplayDecision::Stop)
+1529        }
+1530    }
+1531
+1532    /// Report an item that just finished as stopped at its runtime, so Jellyfin
+1533    /// marks it played.
+1534    ///
+1535    /// Jellyfin decides "watched" from the `PlaybackStopped` report and its
+1536    /// position — no report, no completion, however much of the episode was
+1537    /// actually heard. In the foreground the frontend sends one when the
+1538    /// `<video>` ends. In background audio-only mode there is nobody: the webview
+1539    /// is suspended and its element was torn down at the handoff, while the
+1540    /// backend drove the advance to the next episode and said nothing about the
+1541    /// one that ended. An episode listened to end-to-end on the lockscreen
+1542    /// therefore never counted, and (before DR-179) was often reset to 0 by the
+1543    /// stop report that followed.
+1544    ///
+1545    /// Scoped to the audio-only handoff — the case the frontend provably cannot
+1546    /// report — so foreground playback keeps its single existing report rather
+1547    /// than gaining a second one. Music tracks ending natively remain
+1548    /// unreported; that is the same gap through a different door and wants its
+1549    /// own change.
+1550    ///
+1551    /// TRACES: UR-040, UR-025 | DR-179 | UT-179
+1552    fn report_completion(&self, item: &MediaItem) {
+1553        if !Self::is_audio_only_video(item) {
+1554            return;
+1555        }
+1556        let Some(jellyfin_id) = item.jellyfin_id().map(|id| id.to_string()) else {
+1557            return;
+1558        };
+1559
+1560        let position = completion_report_position(item.duration, self.absolute_position());
+1561        log::info!(
+1562            "[PlayerController] Audio-only {} finished — reporting complete at {:.1}s",
+1563            jellyfin_id,
+1564            position
+1565        );
+1566        self.report_stopped_at(jellyfin_id, position);
+1567    }
+1568
+1569    /// Record the base offset of a background-audio handoff (the position the
+1570    /// video was handed off at, which is the audio stream's zero).
+1571    ///
+1572    /// TRACES: UR-040 | DR-052
+1573    pub fn set_background_audio_base(&self, seconds: f64) {
+1574        *self.background_audio_base.lock_safe() = seconds.max(0.0);
+1575    }
+1576
+1577    /// Enter a background-audio handoff at `position` (the video's position, and
+1578    /// therefore the audio stream's zero).
+1579    ///
+1580    /// Hands transport authority to the native audio player: the webview
+1581    /// `<video>` is about to be torn down, so its last reports — including the
+1582    /// `pause` the teardown itself fires — must not keep it looking like the
+1583    /// player. Without this the lockscreen pause emitted a ControlCommand at a
+1584    /// dead element and the audio played straight through it.
+1585    ///
+1586    /// TRACES: UR-040, UR-005 | DR-052, DR-097
+1587    pub fn enter_background_audio(&self, position: f64) {
+1588        self.set_background_audio_base(position);
+1589        *self.background_audio_active.lock_safe() = true;
+1590        *self.html5_playing.lock_safe() = None;
+1591        // The element is being torn down; its last position describes a video
+1592        // that is no longer playing, and the base describes the one that is.
+1593        self.clear_reported_time();
+1594    }
+1595
+1596    /// Leave a background-audio handoff, returning the base offset to add to the
+1597    /// native player's relative position.
+1598    ///
+1599    /// The webview `<video>` becomes the player again once it reloads, so its
+1600    /// reports are honoured from here on.
+1601    ///
+1602    /// TRACES: UR-040, UR-005 | DR-052, DR-097
+1603    pub fn exit_background_audio(&self) -> f64 {
+1604        *self.background_audio_active.lock_safe() = false;
+1605        self.take_background_audio_base()
+1606    }
+1607
+1608    /// True while the native audio player owns playback via a background-audio
+1609    /// handoff.
+1610    ///
+1611    /// TRACES: UR-040 | DR-052
+1612    pub fn is_background_audio_active(&self) -> bool {
+1613        *self.background_audio_active.lock_safe()
+1614    }
+1615
+1616    /// Read and clear the background-audio base offset.
+1617    ///
+1618    /// TRACES: UR-040 | DR-052
+1619    pub fn take_background_audio_base(&self) -> f64 {
+1620        let mut base = self.background_audio_base.lock_safe();
+1621        std::mem::replace(&mut *base, 0.0)
+1622    }
+1623
+1624    /// Perform the auto-advance for a `ShowNextEpisodePopup` decision.
+1625    ///
+1626    /// Single place both end-of-playback dispatchers agree on: the Android JNI
+1627    /// callback (`nativeOnPlaybackEnded`) and the frontend-invoked command
+1628    /// (`player_on_playback_ended`). They used to each carry their own copy of
+1629    /// this branch, and the command's copy was missing the background-audio case
+1630    /// entirely — so an audio-only episode ending while backgrounded only ever
+1631    /// started a countdown that nothing could act on.
+1632    ///
+1633    /// TRACES: UR-040, UR-023 | DR-052
+1634    pub async fn auto_advance_to_next_episode(
+1635        &self,
+1636        next_episode: crate::repository::types::MediaItem,
+1637        countdown_seconds: u32,
+1638    ) {
+1639        // Background audio-only episode: the countdown only emits ticks — the
+1640        // advance itself is a `goto('/player/<id>')` in the webview, which cannot
+1641        // start audio while the app is backgrounded. Load the next episode's
+1642        // audio-only stream here instead, or playback stalls at the boundary.
+1643        if self.current_is_audio_episode() {
+1644            info!(
+1645                "[PlayerController] Background audio episode — advancing to {} in backend",
+1646                next_episode.id
+1647            );
+1648            match self
+1649                .advance_to_next_episode_audio_only(&next_episode.id)
+1650                .await
+1651            {
+1652                Ok(()) => self.emit_queue_changed(),
+1653                Err(e) => {
+1654                    error!(
+1655                        "[PlayerController] Background audio advance failed: {} — stopping",
+1656                        e
+1657                    );
+1658                    if let Some(emitter) = self.event_emitter() {
+1659                        emitter.emit(PlayerStatusEvent::PlaybackEnded);
+1660                    }
+1661                }
+1662            }
+1663            return;
+1664        }
+1665
+1666        // Foreground: the frontend drives the advance off the countdown ticks.
+1667        self.start_autoplay_countdown(next_episode, countdown_seconds);
+1668    }
+1669
+1670    /// A video item played through the native *audio* path — i.e. the background
+1671    /// audio-only handoff, the only place a length-less progressive transcode is
+1672    /// used. Jellyfin's item-type taxonomy stays in Rust (CLAUDE.md).
+1673    fn is_audio_only_video(item: &MediaItem) -> bool {
+1674        stream_end::is_audio_only_video(item)
+1675    }
+1676
+1677    /// Claim a resume attempt for the current stream, returning the absolute
+1678    /// position to re-open at and the 1-based attempt number. `None` when the
+1679    /// current item cannot meaningfully be re-requested, or when retrying at this
+1680    /// position has stopped helping.
+1681    ///
+1682    /// Only `Remote` sources qualify. A downloaded file cannot fail because of
+1683    /// the network, so re-opening one would paper over a real read error; a
+1684    /// `DirectUrl` is a plugin's endpoint with no Jellyfin item behind it.
+1685    ///
+1686    /// The player's position is relative to the stream's own zero (the handoff
+1687    /// URL's `StartTimeTicks`), so the base is added back to get an absolute one.
+1688    /// It is zero for everything else, where positions are already absolute.
+1689    ///
+1690    /// TRACES: UR-040, UR-004 | DR-129 | UT-117
+1691    fn claim_stream_resume(&self) -> Option<(f64, u32)> {
+1692        let current = {
+1693            let queue = self.queue.lock_safe();
+1694            queue.current().cloned()
+1695        }?;
+1696        if !matches!(current.source, MediaSource::Remote { .. }) {
+1697            return None;
+1698        }
+1699
+1700        // The Android position tick shifts by the handoff base before anything
+1701        // sees the value, so adding it again here would double-count it
+1702        // (DR-159) — `absolute_position` floors at the base instead, which is
+1703        // what a stream that died before its first tick needs. (DR-178)
+1704        let absolute = self.absolute_position();
+1705
+1706        match self.stream_resume.lock_safe().allow_attempt(absolute) {
+1707            Some(attempt) => Some((absolute, attempt)),
+1708            None => {
+1709                warn!(
+1710                    "[PlayerController] Stream for {} keeps failing at {:.1}s — giving up on resuming",
+1711                    current.id, absolute
+1712                );
+1713                None
+1714            }
+1715        }
+1716    }
+1717
+1718    /// The absolute position to re-open the current stream at, when the reported
+1719    /// end was really a dropped connection — `None` when the end looks genuine,
+1720    /// when this is not an audio-only handoff, or when retrying has stopped
+1721    /// helping.
+1722    ///
+1723    /// TRACES: UR-040 | DR-129 | UT-117
+1724    fn truncated_stream_resume_position(&self) -> Option<f64> {
+1725        // An explicit user intent already explains the end; never resume over it.
+1726        if matches!(
+1727            self.peek_end_reason(),
+1728            Some(EndReason::UserStop) | Some(EndReason::UserSkip) | Some(EndReason::Error)
+1729        ) {
+1730            return None;
+1731        }
+1732
+1733        // One lock at a time — `position()` reaches into the backend, and nesting
+1734        // that inside the queue lock would invent a lock order nothing else here
+1735        // takes.
+1736        let item_duration = {
+1737            let queue = self.queue.lock_safe();
+1738            let current = queue.current()?;
+1739            if !Self::is_audio_only_video(current) {
+1740                return None;
+1741            }
+1742            current.duration
+1743        };
+1744        // Already absolute — see claim_stream_resume. (DR-159)
+1745        let absolute = self.position().max(0.0);
+1746
+1747        // Only spend a resume attempt once the runtime says this really was cut
+1748        // short — a genuine end must stay a genuine end.
+1749        if !stream_end::is_truncated_end(
+1750            absolute,
+1751            item_duration,
+1752            stream_end::TRUNCATED_STREAM_TOLERANCE_SECS,
+1753        ) {
+1754            return None;
+1755        }
+1756
+1757        self.claim_stream_resume().map(|(position, _)| position)
+1758    }
+1759
+1760    /// Where to re-open the current stream after a *recoverable* playback error,
+1761    /// plus how many seconds to wait first.
+1762    ///
+1763    /// The media was decoding fine a moment ago, so a mid-playback failure on a
+1764    /// server stream is the network — and stopping the player (the previous
+1765    /// behaviour, via the frontend's error handler) turns a hiccup into "playback
+1766    /// just died". Applies to every streamed item, not only the audio-only
+1767    /// handoff: music and video reach here instead of the truncation path because
+1768    /// their streams declare a length, so a cut connection surfaces as an error
+1769    /// rather than a phantom end.
+1770    ///
+1771    /// The wait grows with the attempt number so a short outage has time to
+1772    /// clear, and the shared budget stops the retries when it doesn't.
+1773    ///
+1774    /// Called from the Android error callback, which decides in-process, and from
+1775    /// `player_recover_stream`, which is how the same decision reaches the
+1776    /// backends whose event thread has no controller to call — MPV is built
+1777    /// before the controller exists, so on Linux the error is emitted, echoed by
+1778    /// the frontend, and decided here.
+1779    ///
+1780    /// TRACES: UR-040, UR-004 | DR-129, DR-130 | UT-117
+1781    pub fn recoverable_error_resume(&self) -> Option<(f64, u64)> {
+1782        self.claim_stream_resume()
+1783            .map(|(position, attempt)| (position, attempt as u64 * RESUME_BACKOFF_STEP_SECS))
+1784    }
+1785
+1786    /// Re-open the current stream at `position` after the network cut it short.
+1787    ///
+1788    /// Single place every dispatcher agrees on, for the same reason
+1789    /// `auto_advance_to_next_episode` is: the Android JNI callbacks and the
+1790    /// frontend-invoked command must not disagree about what a failed stream
+1791    /// means. None of them emits `PlaybackEnded` for this, so nothing downstream
+1792    /// clears the queue or tears the session down — from the outside this is a
+1793    /// buffering hiccup, which is what it actually was.
+1794    ///
+1795    /// Reloads the item **in place** rather than through `play_item`, which
+1796    /// replaces the queue with a single item: recovering a track that way would
+1797    /// throw away the rest of the album, turning a network blip into lost state.
+1798    ///
+1799    /// Two shapes of stream, two ways back to `position`:
+1800    ///
+1801    /// - The audio-only handoff's `/Audio/{id}/universal` transcode is chunked
+1802    ///   with no length, so it cannot be seeked. Its URL is rewritten to start at
+1803    ///   the position instead — edited, not rebuilt from the repository, since it
+1804    ///   already carries the user's audio track and media source and recovering
+1805    ///   from a network failure must not itself need a network round-trip.
+1806    /// - Everything else (a static file with byte ranges, an HLS playlist)
+1807    ///   declares its whole timeline, so re-preparing the URL it already has and
+1808    ///   seeking lands in the right place — and leaves any transcode session
+1809    ///   behind it alone.
+1810    ///
+1811    /// TRACES: UR-040, UR-004 | DR-129 | UT-117
+1812    pub async fn resume_stream_at(&self, position: f64) -> Result<(), String> {
+1813        let current = {
+1814            let queue = self.queue.lock_safe();
+1815            queue.current().cloned()
+1816        }
+1817        .ok_or_else(|| "No current item to resume".to_string())?;
+1818
+1819        let MediaSource::Remote { stream_url, .. } = &current.source else {
+1820            return Err(format!(
+1821                "Cannot resume a non-remote source for {}",
+1822                current.id
+1823            ));
+1824        };
+1825
+1826        info!(
+1827            "[PlayerController] Stream for {} failed — re-opening at {:.1}s",
+1828            current.id, position
+1829        );
+1830
+1831        if !Self::is_audio_only_video(&current) {
+1832            self.load_and_play(&current).map_err(|e| e.to_string())?;
+1833            if position > 0.5 {
+1834                self.seek(position).map_err(|e| e.to_string())?;
+1835            }
+1836            return Ok(());
+1837        }
+1838
+1839        let restarted_url = stream_end::with_start_time(stream_url, position);
+1840        {
+1841            let queue_arc = self.queue.clone();
+1842            let mut queue = queue_arc.lock_safe();
+1843            if !queue.update_current_stream_url(restarted_url) {
+1844                return Err(format!("Failed to update stream URL for {}", current.id));
+1845            }
+1846        }
+1847        let resumed = {
+1848            let queue = self.queue.lock_safe();
+1849            queue.current().cloned()
+1850        }
+1851        .ok_or_else(|| "Current item vanished mid-resume".to_string())?;
+1852
+1853        // The re-opened stream's timeline starts at `position` (StartTimeTicks),
+1854        // so that is its zero: the exit-to-foreground maths and the lockscreen
+1855        // scrubber both read absolute positions off this base.
+1856        self.set_background_audio_base(position);
+1857        let _ = set_lockscreen_position_offset(position.max(0.0));
+1858
+1859        self.load_and_play(&resumed).map_err(|e| e.to_string())
+1860    }
+1861
+1862    /// Advance to the next episode while playing audio-only in the background.
+1863    ///
+1864    /// The normal autoplay-next path navigates the frontend to `/player/<id>`,
+1865    /// which is unavailable when the app is backgrounded and the WebView is
+1866    /// suspended. This drives the advance entirely in the backend: build the next
+1867    /// episode's *audio-only* stream URL and load it into the native audio player,
+1868    /// so playback continues without any frontend involvement (UR-040).
+1869    ///
+1870    /// `next_episode_id` is the Jellyfin item ID of the episode to play next.
+1871    ///
+1872    /// Reached through `auto_advance_to_next_episode`, which gates it on
+1873    /// `current_is_audio_episode()` — only ever true after a background-audio
+1874    /// handoff (Android), but compiled and unit-tested on every platform.
+1875    /// TRACES: UR-040, UR-023 | DR-052
+1876    pub async fn advance_to_next_episode_audio_only(
+1877        &self,
+1878        next_episode_id: &str,
+1879    ) -> Result<(), String> {
+1880        let repo = self
+1881            .repository
+1882            .lock_safe()
+1883            .clone()
+1884            .ok_or_else(|| "No repository for background episode advance".to_string())?;
+1885
+1886        // Details for session metadata (title/series/artwork) and the stream URL.
+1887        let next = repo
+1888            .get_item(next_episode_id)
+1889            .await
+1890            .map_err(|e| format!("Failed to fetch next episode {}: {}", next_episode_id, e))?;
+1891
+1892        // Audio-only transcode from the start of the episode (no resume offset —
+1893        // a freshly-started next episode always plays from the beginning).
+1894        let stream_url = repo
+1895            .get_audio_only_stream_url_for_video(next_episode_id, None, None, None)
+1896            .await
+1897            .map_err(|e| format!("Failed to build audio-only URL for next episode: {}", e))?;
+1898
+1899        let media_item = MediaItem {
+1900            id: next.id.clone(),
+1901            title: next.name.clone(),
+1902            name: Some(next.name.clone()),
+1903            artist: next.series_name.clone(),
+1904            album: None,
+1905            album_name: None,
+1906            album_id: None,
+1907            artist_items: None,
+1908            artists: None,
+1909            primary_image_tag: next.primary_image_tag.clone(),
+1910            image_id: next.image_id.clone().or(next.primary_image_tag.clone()),
+1911            // Preserve episode identity so the NEXT end-of-track also advances.
+1912            item_type: Some("Episode".to_string()),
+1913            playlist_id: None,
+1914            duration: next.duration_ms.map(|ms| ms as f64 / 1000.0),
+1915            artwork_url: None,
+1916            media_type: MediaType::Audio,
+1917            source: MediaSource::Remote {
+1918                stream_url,
+1919                jellyfin_item_id: next.id.clone(),
+1920            },
+1921            video_codec: None,
+1922            needs_transcoding: false,
+1923            video_width: None,
+1924            video_height: None,
+1925            subtitles: vec![],
+1926            series_id: next.series_id.clone(),
+1927            server_id: Some(next.server_id.clone()),
+1928        };
+1929
+1930        // The previous episode's handoff base described the stream we are leaving.
+1931        // This one is built without StartTimeTicks, so its timeline is already
+1932        // absolute: clear the base (used to resolve the resume position on the way
+1933        // back to the foreground) and the lockscreen scrubber's matching shift.
+1934        self.set_background_audio_base(0.0);
+1935        let _ = set_lockscreen_position_offset(0.0);
+1936        // Different stream entirely: whatever was stuck about the last one is not
+1937        // this one's problem.
+1938        self.stream_resume.lock_safe().reset();
+1939
+1940        self.play_item(media_item).map_err(|e| e.to_string())
+1941    }
+1942
+1943    /// Handle video playback ended from HTML5 video element.
+1944    ///
+1945    /// HTML5 video plays independently of the Rust backend, so the backend
+1946    /// queue has no knowledge of the video item. This method bypasses the
+1947    /// queue lookup and end_reason check, using the provided Jellyfin item ID
+1948    /// to look up the item and check for next episodes.
+1949    pub async fn on_video_playback_ended(
+1950        &self,
+1951        item_id: &str,
+1952        repo: Arc<dyn crate::repository::MediaRepository>,
+1953    ) -> Result<AutoplayDecision, String> {
+1954        // Clear any stale end_reason (e.g., UserStop from stopping audio before video)
+1955        let stale_reason = self.take_end_reason();
+1956        if stale_reason.is_some() {
+1957            debug!(
+1958                "[PlayerController] Cleared stale end_reason for video: {:?}",
+1959                stale_reason
+1960            );
+1961        }
+1962
+1963        log::info!(
+1964            "[PlayerController] on_video_playback_ended: item_id={}",
+1965            item_id
+1966        );
+1967
+1968        // Check sleep timer state
+1969        let timer_mode = {
+1970            let timer = self.sleep_timer.lock_safe();
+1971            timer.mode.clone()
+1972        };
+1973
+1974        match &timer_mode {
+1975            SleepTimerMode::Time { end_time } => {
+1976                let now = chrono::Utc::now().timestamp_millis();
+1977                if now >= *end_time {
+1978                    debug!("[PlayerController] Time-based sleep timer expired at video end");
+1979                    self.sleep_timer.lock_safe().cancel();
+1980                    self.emit_sleep_timer_changed();
+1981                    return Ok(AutoplayDecision::Stop);
+1982                }
+1983            }
+1984            SleepTimerMode::EndOfTrack => {
+1985                self.sleep_timer.lock_safe().cancel();
+1986                self.emit_sleep_timer_changed();
+1987                return Ok(AutoplayDecision::Stop);
+1988            }
+1989            SleepTimerMode::Episodes { .. } => {
+1990                let should_stop = self.sleep_timer.lock_safe().decrement_episode();
+1991                self.emit_sleep_timer_changed();
+1992                if should_stop {
+1993                    return Ok(AutoplayDecision::Stop);
+1994                }
+1995            }
+1996            _ => {}
+1997        }
+1998
+1999        // Fetch next episode for the video that just ended. Degrade lookup
+2000        // failures to Stop: playback already ended, and propagating an error
+2001        // here just kills autoplay silently upstream.
+2002        let next_ep_result = match self.fetch_next_episode_for_item(item_id, &repo).await {
+2003            Ok(next) => next,
+2004            Err(e) => {
+2005                warn!(
+2006                    "[PlayerController] Next-episode lookup failed for {}: {}",
+2007                    item_id, e
+2008                );
+2009                None
+2010            }
+2011        };
+2012        if let Some(next_ep) = next_ep_result {
+2013            let settings = self.autoplay_settings.lock_safe().clone();
+2014
+2015            let limit_reached = self.increment_autoplay_count();
+2016            if limit_reached {
+2017                debug!(
+2018                    "[PlayerController] Auto-play episode limit reached ({} episodes)",
+2019                    settings.max_episodes
+2020                );
+2021            }
+2022
+2023            return Ok(AutoplayDecision::ShowNextEpisodePopup {
+2024                current_episode: next_ep.0,
+2025                next_episode: next_ep.1,
+2026                countdown_seconds: settings.countdown_seconds,
+2027                auto_advance: settings.enabled && !limit_reached,
+2028            });
+2029        }
+2030
+2031        // No next episode found
+2032        debug!("[PlayerController] No next episode found for {}", item_id);
+2033        Ok(AutoplayDecision::Stop)
+2034    }
+2035
+2036    /// Check if a media item is an episode (has Jellyfin ID to query).
+2037    ///
+2038    /// An explicit `item_type == "Episode"` wins so that a TV episode handed off
+2039    /// to the audio path for background playback (UR-040) is still recognised as
+2040    /// an episode — otherwise autoplay would fall through to the queue-based
+2041    /// audio path, find nothing next, and stop at the episode boundary. When the
+2042    /// type is unknown we fall back to the historical heuristic (video == episode).
+2043    async fn is_episode_item(&self, item: &MediaItem) -> bool {
+2044        match item.item_type.as_deref() {
+2045            Some("Episode") => true,
+2046            Some(_) => item.media_type == MediaType::Video,
+2047            None => item.media_type == MediaType::Video,
+2048        }
+2049    }
+2050
+2051    /// Fetch next episode for a series by looking up the season's episodes
+2052    /// sorted by index number and picking the one after the current episode.
+2053    ///
+2054    /// This is deterministic and doesn't depend on Jellyfin's "Next Up" API
+2055    /// (which relies on watch history that may not be updated yet due to
+2056    /// the async nature of playback progress reporting).
+2057    async fn fetch_next_episode_for_item(
+2058        &self,
+2059        item_id: &str,
+2060        repo: &Arc<dyn crate::repository::MediaRepository>,
+2061    ) -> Result<
+2062        Option<(
+2063            crate::repository::types::MediaItem,
+2064            crate::repository::types::MediaItem,
+2065        )>,
+2066        String,
+2067    > {
+2068        use crate::repository::types::GetItemsOptions;
+2069
+2070        // Get the current item details from repository
+2071        let current_repo_item = repo
+2072            .get_item(item_id)
+2073            .await
+2074            .map_err(|e| format!("Failed to get current item: {}", e))?;
+2075
+2076        // Need season_id to fetch sibling episodes
+2077        let season_id = match &current_repo_item.season_id {
+2078            Some(sid) => sid.clone(),
+2079            None => {
+2080                log::info!(
+2081                    "[PlayerController] Current item has no season_id, cannot find next episode"
+2082                );
+2083                return Ok(None);
+2084            }
+2085        };
+2086
+2087        // Fetch all episodes in the season sorted by episode number
+2088        let options = GetItemsOptions {
+2089            sort_by: Some("IndexNumber".to_string()),
+2090            sort_order: Some("Ascending".to_string()),
+2091            limit: Some(500),
+2092            include_item_types: Some(vec!["Episode".to_string()]),
+2093            ..Default::default()
+2094        };
+2095
+2096        let result = repo
+2097            .get_items(&season_id, Some(options))
+2098            .await
+2099            .map_err(|e| format!("Failed to fetch season episodes: {}", e))?;
+2100
+2101        // Sort client-side by index_number to ensure correct ordering
+2102        // (offline repo ignores sort_by and sorts by sort_name instead)
+2103        let mut episodes = result.items;
+2104        episodes.sort_by_key(|e| e.index_number.unwrap_or(i32::MAX));
+2105        log::info!(
+2106            "[PlayerController] Season has {} episodes, looking for next after {}",
+2107            episodes.len(),
+2108            current_repo_item.id
+2109        );
+2110
+2111        // Find the current episode by ID and return the next one
+2112        if let Some(current_idx) = episodes.iter().position(|e| e.id == current_repo_item.id) {
+2113            if current_idx + 1 < episodes.len() {
+2114                let next = &episodes[current_idx + 1];
+2115                log::info!(
+2116                    "[PlayerController] Found next episode: {} (index {})",
+2117                    next.name,
+2118                    current_idx + 1
+2119                );
+2120                return Ok(Some((current_repo_item, next.clone())));
+2121            } else {
+2122                log::info!("[PlayerController] Current episode is the last in the season");
+2123            }
+2124        } else {
+2125            log::info!(
+2126                "[PlayerController] Current episode not found in season episodes (ids: {:?})",
+2127                episodes
+2128                    .iter()
+2129                    .map(|e| e.id.as_str())
+2130                    .take(20)
+2131                    .collect::<Vec<_>>()
+2132            );
+2133        }
+2134
+2135        Ok(None)
+2136    }
+2137
+2138    /// Start autoplay countdown thread
+2139    pub fn start_autoplay_countdown(
+2140        &self,
+2141        _next_item: crate::repository::types::MediaItem,
+2142        countdown_seconds: u32,
+2143    ) {
+2144        // Create cancellation flag
+2145        let cancel_flag = Arc::new(Mutex::new(false));
+2146        *self.countdown_cancel.lock_safe() = Some(cancel_flag.clone());
+2147
+2148        let event_emitter = self.event_emitter.clone();
+2149
+2150        std::thread::spawn(move || {
+2151            let mut remaining = countdown_seconds;
+2152
+2153            while remaining > 0 {
+2154                std::thread::sleep(Duration::from_secs(1));
+2155
+2156                // Check cancellation
+2157                if *cancel_flag.lock_safe() {
+2158                    log::info!("[PlayerController] Autoplay countdown cancelled");
+2159                    return;
+2160                }
+2161
+2162                remaining -= 1;
+2163
+2164                // Emit countdown tick event
+2165                if let Some(emitter) = event_emitter.lock_safe().as_ref() {
+2166                    emitter.emit(PlayerStatusEvent::CountdownTick {
+2167                        remaining_seconds: remaining,
+2168                    });
+2169                }
+2170            }
+2171
+2172            // Countdown finished (final tick at 0 was already emitted inside the loop)
+2173            log::info!("[PlayerController] Autoplay countdown finished");
+2174        });
+2175    }
+2176}
+2177
+2178impl Default for PlayerController {
+2179    fn default() -> Self {
+2180        let playback_reporter = Arc::new(TokioMutex::new(None));
+2181        let position_throttler = Arc::new(EventThrottler::new());
+2182        Self::new(
+2183            Box::new(NullBackend::new()),
+2184            playback_reporter,
+2185            position_throttler,
+2186        )
+2187    }
+2188}
+2189
+2190#[cfg(test)]
+2191mod tests {
+2192    use super::*;
+2193
+2194    /// Test emitter that captures events for asserting the HTML5 report methods
+2195    /// re-emit through the normal PlayerStatusEvent pipeline.
+2196    struct CapturingEmitter {
+2197        events: std::sync::Mutex<Vec<PlayerStatusEvent>>,
+2198    }
+2199
+2200    impl CapturingEmitter {
+2201        fn new() -> Self {
+2202            Self {
+2203                events: std::sync::Mutex::new(Vec::new()),
+2204            }
+2205        }
+2206        fn events(&self) -> Vec<PlayerStatusEvent> {
+2207            self.events.lock_safe().clone()
+2208        }
+2209    }
+2210
+2211    impl PlayerEventEmitter for CapturingEmitter {
+2212        fn emit(&self, event: PlayerStatusEvent) {
+2213            self.events.lock_safe().push(event);
+2214        }
+2215    }
+2216
+2217    /// Captures what the controller reports to Jellyfin, so tests can assert on
+2218    /// the operations themselves rather than on a database and an HTTP client.
+2219    struct CapturingReports {
+2220        operations: std::sync::Mutex<Vec<PlaybackOperation>>,
+2221    }
+2222
+2223    impl CapturingReports {
+2224        fn new() -> Self {
+2225            Self {
+2226                operations: std::sync::Mutex::new(Vec::new()),
+2227            }
+2228        }
+2229
+2230        /// Every `Stopped` report as `(item_id, position_seconds)`.
+2231        fn stops(&self) -> Vec<(String, f64)> {
+2232            self.operations
+2233                .lock_safe()
+2234                .iter()
+2235                .filter_map(|op| match op {
+2236                    PlaybackOperation::Stopped {
+2237                        item_id,
+2238                        position_ticks,
+2239                    } => Some((item_id.clone(), *position_ticks as f64 / 10_000_000.0)),
+2240                    _ => None,
+2241                })
+2242                .collect()
+2243        }
+2244
+2245        /// Every `Progress` report as `(item_id, position_seconds)`.
+2246        fn progress(&self) -> Vec<(String, f64)> {
+2247            self.operations
+2248                .lock_safe()
+2249                .iter()
+2250                .filter_map(|op| match op {
+2251                    PlaybackOperation::Progress {
+2252                        item_id,
+2253                        position_ticks,
+2254                        ..
+2255                    } => Some((item_id.clone(), *position_ticks as f64 / 10_000_000.0)),
+2256                    _ => None,
+2257                })
+2258                .collect()
+2259        }
+2260    }
+2261
+2262    impl PlaybackReportSink for CapturingReports {
+2263        fn send(&self, operation: PlaybackOperation) {
+2264            self.operations.lock_safe().push(operation);
+2265        }
+2266    }
+2267
+2268    #[test]
+2269    fn test_report_html5_state_emits_state_changed() {
+2270        let controller = PlayerController::default();
+2271        let emitter = Arc::new(CapturingEmitter::new());
+2272        controller.set_event_emitter(emitter.clone());
+2273
+2274        controller.report_html5_state("playing".to_string(), Some("item-1".to_string()));
+2275
+2276        let events = emitter.events();
+2277        assert_eq!(events.len(), 1);
+2278        match &events[0] {
+2279            PlayerStatusEvent::StateChanged { state, media_id } => {
+2280                assert_eq!(state, "playing");
+2281                assert_eq!(media_id.as_deref(), Some("item-1"));
+2282            }
+2283            other => panic!("expected StateChanged, got {:?}", other),
+2284        }
+2285    }
+2286
+2287    #[test]
+2288    fn test_report_html5_position_emits_position_update() {
+2289        let controller = PlayerController::default();
+2290        let emitter = Arc::new(CapturingEmitter::new());
+2291        controller.set_event_emitter(emitter.clone());
+2292
+2293        controller.report_html5_position(12.5, 300.0);
+2294
+2295        let events = emitter.events();
+2296        assert_eq!(events.len(), 1);
+2297        match &events[0] {
+2298            PlayerStatusEvent::PositionUpdate { position, duration } => {
+2299                assert_eq!(*position, 12.5);
+2300                assert_eq!(*duration, 300.0);
+2301            }
+2302            other => panic!("expected PositionUpdate, got {:?}", other),
+2303        }
+2304    }
+2305
+2306    #[test]
+2307    fn test_report_html5_media_loaded_emits_media_loaded() {
+2308        let controller = PlayerController::default();
+2309        let emitter = Arc::new(CapturingEmitter::new());
+2310        controller.set_event_emitter(emitter.clone());
+2311
+2312        controller.report_html5_media_loaded(420.0);
+2313
+2314        let events = emitter.events();
+2315        assert_eq!(events.len(), 1);
+2316        match &events[0] {
+2317            PlayerStatusEvent::MediaLoaded { duration } => assert_eq!(*duration, 420.0),
+2318            other => panic!("expected MediaLoaded, got {:?}", other),
+2319        }
+2320    }
+2321
+2322    // ===== HTML5 transport authority (DR-097) =====
+2323    //
+2324    // Webview-rendered video is played by an element the native backend cannot
+2325    // reach, so transport for it must be decided from the state the element
+2326    // REPORTS and executed by emitting a ControlCommand. Previously the frontend
+2327    // decided play-vs-pause itself by reading `el.paused` off the DOM, which
+2328    // flips transiently while buffering/seeking — two intents ~150ms apart read
+2329    // different values, took opposing actions, and self-sustained a pause loop.
+2330
+2331    #[test]
+2332    fn test_html5_state_is_tracked_from_reports() {
+2333        let controller = PlayerController::default();
+2334        let emitter = Arc::new(CapturingEmitter::new());
+2335        controller.set_event_emitter(emitter.clone());
+2336
+2337        // No HTML5 media reported yet: the native backend stays authoritative.
+2338        assert!(!controller.is_html5_active());
+2339
+2340        controller.report_html5_state("playing".to_string(), Some("item-1".to_string()));
+2341        assert!(controller.is_html5_active());
+2342        assert!(controller.html5_is_playing());
+2343
+2344        controller.report_html5_state("paused".to_string(), Some("item-1".to_string()));
+2345        assert!(controller.is_html5_active());
+2346        assert!(!controller.html5_is_playing());
+2347    }
+2348
+2349    #[test]
+2350    fn test_html5_toggle_from_paused_emits_play_control() {
+2351        let controller = PlayerController::default();
+2352        let emitter = Arc::new(CapturingEmitter::new());
+2353        controller.set_event_emitter(emitter.clone());
+2354        controller.report_html5_state("paused".to_string(), Some("item-1".to_string()));
+2355
+2356        controller.toggle_playback().unwrap();
+2357
+2358        let controls: Vec<_> = emitter
+2359            .events()
+2360            .into_iter()
+2361            .filter_map(|e| match e {
+2362                PlayerStatusEvent::ControlCommand { action, .. } => Some(action),
+2363                _ => None,
+2364            })
+2365            .collect();
+2366        assert_eq!(controls, vec!["play".to_string()]);
+2367    }
+2368
+2369    #[test]
+2370    fn test_html5_toggle_from_playing_emits_pause_control() {
+2371        let controller = PlayerController::default();
+2372        let emitter = Arc::new(CapturingEmitter::new());
+2373        controller.set_event_emitter(emitter.clone());
+2374        controller.report_html5_state("playing".to_string(), Some("item-1".to_string()));
+2375
+2376        controller.toggle_playback().unwrap();
+2377
+2378        let controls: Vec<_> = emitter
+2379            .events()
+2380            .into_iter()
+2381            .filter_map(|e| match e {
+2382                PlayerStatusEvent::ControlCommand { action, .. } => Some(action),
+2383                _ => None,
+2384            })
+2385            .collect();
+2386        assert_eq!(controls, vec!["pause".to_string()]);
+2387    }
+2388
+2389    #[test]
+2390    fn test_html5_repeated_toggles_alternate_and_never_repeat_an_action() {
+2391        // The loop signature: two intents in quick succession must NOT both
+2392        // resolve the same way, and must not produce opposing actions from a
+2393        // stale read. Rust's own tracked state makes the sequence deterministic
+2394        // as long as the element reports back between intents.
+2395        let controller = PlayerController::default();
+2396        let emitter = Arc::new(CapturingEmitter::new());
+2397        controller.set_event_emitter(emitter.clone());
+2398        controller.report_html5_state("playing".to_string(), Some("item-1".to_string()));
+2399
+2400        controller.toggle_playback().unwrap();
+2401        // Element confirms the pause it was told to do.
+2402        controller.report_html5_state("paused".to_string(), Some("item-1".to_string()));
+2403        controller.toggle_playback().unwrap();
+2404
+2405        let controls: Vec<_> = emitter
+2406            .events()
+2407            .into_iter()
+2408            .filter_map(|e| match e {
+2409                PlayerStatusEvent::ControlCommand { action, .. } => Some(action),
+2410                _ => None,
+2411            })
+2412            .collect();
+2413        assert_eq!(controls, vec!["pause".to_string(), "play".to_string()]);
+2414    }
+2415
+2416    #[test]
+2417    fn test_html5_play_and_pause_emit_control_commands() {
+2418        let controller = PlayerController::default();
+2419        let emitter = Arc::new(CapturingEmitter::new());
+2420        controller.set_event_emitter(emitter.clone());
+2421        controller.report_html5_state("paused".to_string(), Some("item-1".to_string()));
+2422
+2423        controller.play().unwrap();
+2424        controller.pause().unwrap();
+2425
+2426        let controls: Vec<_> = emitter
+2427            .events()
+2428            .into_iter()
+2429            .filter_map(|e| match e {
+2430                PlayerStatusEvent::ControlCommand { action, .. } => Some(action),
+2431                _ => None,
+2432            })
+2433            .collect();
+2434        assert_eq!(controls, vec!["play".to_string(), "pause".to_string()]);
+2435    }
+2436
+2437    #[test]
+2438    fn test_background_audio_handoff_moves_transport_to_native_backend() {
+2439        // Lockscreen pause while playing a video's audio in the background.
+2440        //
+2441        // The handoff tears the WebView <video> down AFTER native audio starts,
+2442        // and that teardown fires a DOM `pause` the frontend dutifully reports.
+2443        // That report used to leave `html5_playing = Some(false)`, so transport
+2444        // kept being aimed at an element that no longer exists: the lockscreen
+2445        // pause emitted a ControlCommand into the void and the audio played on.
+2446        let controller = PlayerController::default();
+2447        let emitter = Arc::new(CapturingEmitter::new());
+2448        controller.set_event_emitter(emitter.clone());
+2449
+2450        // Video was playing in the webview.
+2451        controller.report_html5_state("playing".to_string(), Some("ep-1".to_string()));
+2452        assert!(controller.is_html5_active());
+2453
+2454        // Hand off to the native audio player, then tear the element down.
+2455        controller.enter_background_audio(1200.0);
+2456        controller.report_html5_state("paused".to_string(), Some("ep-1".to_string()));
+2457
+2458        assert!(
+2459            !controller.is_html5_active(),
+2460            "native audio owns transport during a background-audio handoff"
+2461        );
+2462
+2463        controller.pause().unwrap();
+2464        let controls: Vec<_> = emitter
+2465            .events()
+2466            .into_iter()
+2467            .filter_map(|e| match e {
+2468                PlayerStatusEvent::ControlCommand { action, .. } => Some(action),
+2469                _ => None,
+2470            })
+2471            .collect();
+2472        assert!(
+2473            controls.is_empty(),
+2474            "pause must drive the native backend, not a torn-down element: {:?}",
+2475            controls
+2476        );
+2477    }
+2478
+2479    #[test]
+2480    fn test_background_audio_handoff_suppresses_stale_element_events() {
+2481        // The dying element's pause/position reports describe the video, not the
+2482        // audio now playing — re-emitting them flips the UI to paused and yanks
+2483        // the position backwards while native audio keeps going.
+2484        let controller = PlayerController::default();
+2485        let emitter = Arc::new(CapturingEmitter::new());
+2486        controller.set_event_emitter(emitter.clone());
+2487
+2488        controller.enter_background_audio(1200.0);
+2489        controller.report_html5_state("paused".to_string(), Some("ep-1".to_string()));
+2490        controller.report_html5_position(1200.0, 2400.0);
+2491
+2492        assert!(
+2493            emitter.events().is_empty(),
+2494            "stale webview reports must not reach the event pipeline: {:?}",
+2495            emitter.events()
+2496        );
+2497    }
+2498
+2499    #[test]
+2500    fn test_exit_background_audio_returns_transport_to_the_webview() {
+2501        // Back in the foreground the <video> is the player again, so its reports
+2502        // must be honoured — and the base offset still comes back for the resume.
+2503        let controller = PlayerController::default();
+2504        let emitter = Arc::new(CapturingEmitter::new());
+2505        controller.set_event_emitter(emitter.clone());
+2506
+2507        controller.enter_background_audio(1200.0);
+2508        assert_eq!(controller.exit_background_audio(), 1200.0);
+2509
+2510        controller.report_html5_state("playing".to_string(), Some("ep-1".to_string()));
+2511        assert!(controller.is_html5_active());
+2512        assert!(controller.html5_is_playing());
+2513    }
+2514
+2515    #[test]
+2516    fn test_html5_stopped_report_releases_transport_to_native_backend() {
+2517        // When webview video goes away, transport must fall back to the native
+2518        // backend (music playback must not keep emitting ControlCommands).
+2519        let controller = PlayerController::default();
+2520        let emitter = Arc::new(CapturingEmitter::new());
+2521        controller.set_event_emitter(emitter.clone());
+2522
+2523        controller.report_html5_state("playing".to_string(), Some("item-1".to_string()));
+2524        assert!(controller.is_html5_active());
+2525
+2526        controller.report_html5_state("stopped".to_string(), None);
+2527        assert!(!controller.is_html5_active());
+2528    }
+2529
+2530    #[test]
+2531    fn test_html5_transport_emits_exactly_one_control_per_intent() {
+2532        // Guards against a double-drive on platforms where the *backend* is also
+2533        // webview-based (WebviewAudioBackend on Windows): the html5 short-circuit
+2534        // must replace the backend call, not run in addition to it.
+2535        let controller = PlayerController::default();
+2536        let emitter = Arc::new(CapturingEmitter::new());
+2537        controller.set_event_emitter(emitter.clone());
+2538        controller.report_html5_state("playing".to_string(), Some("item-1".to_string()));
+2539
+2540        controller.pause().unwrap();
+2541
+2542        let controls = emitter
+2543            .events()
+2544            .into_iter()
+2545            .filter(|e| matches!(e, PlayerStatusEvent::ControlCommand { .. }))
+2546            .count();
+2547        assert_eq!(controls, 1, "one intent must produce exactly one control");
+2548    }
+2549
+2550    #[test]
+2551    fn test_controller_volume_default() {
+2552        let controller = PlayerController::default();
+2553        assert_eq!(controller.volume(), 1.0);
+2554    }
+2555
+2556    #[test]
+2557    fn test_controller_set_volume() {
+2558        let controller = PlayerController::default();
+2559        controller.set_volume(0.5).unwrap();
+2560        assert_eq!(controller.volume(), 0.5);
+2561    }
+2562
+2563    #[test]
+2564    fn test_controller_muted_default() {
+2565        let controller = PlayerController::default();
+2566        assert!(!controller.muted());
+2567    }
+2568
+2569    #[test]
+2570    fn test_controller_volume_delegates_to_backend() {
+2571        let controller = PlayerController::default();
+2572
+2573        // Set volume through controller
+2574        controller.set_volume(0.75).unwrap();
+2575
+2576        // Verify it's reflected in both controller.volume() and backend
+2577        assert_eq!(controller.volume(), 0.75);
+2578    }
+2579
+2580    fn create_test_items(count: usize) -> Vec<MediaItem> {
+2581        (0..count)
+2582            .map(|i| MediaItem {
+2583                id: format!("item_{}", i),
+2584                title: format!("Track {}", i + 1),
+2585                name: Some(format!("Track {}", i + 1)),
+2586                artist: Some("Test Artist".to_string()),
+2587                album: Some("Test Album".to_string()),
+2588                album_name: Some("Test Album".to_string()),
+2589                album_id: None,
+2590                artist_items: None,
+2591                artists: Some(vec!["Test Artist".to_string()]),
+2592                primary_image_tag: None,
+2593                image_id: None,
+2594                item_type: Some("Audio".to_string()),
+2595                playlist_id: None,
+2596                duration: Some(180.0),
+2597                artwork_url: None,
+2598                media_type: MediaType::Audio,
+2599                source: MediaSource::DirectUrl {
+2600                    url: format!("http://example.com/track_{}.mp3", i),
+2601                },
+2602                video_codec: None,
+2603                needs_transcoding: false,
+2604                video_width: None,
+2605                video_height: None,
+2606                subtitles: vec![],
+2607                series_id: None,
+2608                server_id: None,
+2609            })
+2610            .collect()
+2611    }
+2612
+2613    #[test]
+2614    fn test_skip_preserves_queue() {
+2615        let controller = PlayerController::default();
+2616
+2617        // Create a queue with 5 items
+2618        let items = create_test_items(5);
+2619        let items_clone = items.clone();
+2620
+2621        // Play the queue starting at index 0
+2622        controller.play_queue(items, 0).unwrap();
+2623
+2624        // Verify initial state
+2625        {
+2626            let queue = controller.queue();
+2627            let queue_lock = queue.lock_safe();
+2628            assert_eq!(queue_lock.items().len(), 5, "Queue should have 5 items");
+2629            assert_eq!(
+2630                queue_lock.current_index(),
+2631                Some(0),
+2632                "Should start at index 0"
+2633            );
+2634            assert_eq!(
+2635                queue_lock.current().unwrap().id,
+2636                "item_0",
+2637                "Current item should be item_0"
+2638            );
+2639        }
+2640
+2641        // Skip to next track
+2642        controller.next().unwrap();
+2643
+2644        // Verify queue is intact and index advanced
+2645        {
+2646            let queue = controller.queue();
+2647            let queue_lock = queue.lock_safe();
+2648            assert_eq!(
+2649                queue_lock.items().len(),
+2650                5,
+2651                "Queue should still have 5 items after skip"
+2652            );
+2653            assert_eq!(
+2654                queue_lock.current_index(),
+2655                Some(1),
+2656                "Index should advance to 1"
+2657            );
+2658            assert_eq!(
+2659                queue_lock.current().unwrap().id,
+2660                "item_1",
+2661                "Current item should be item_1"
+2662            );
+2663
+2664            // Verify all original items are still present
+2665            let current_items = queue_lock.items();
+2666            for (i, original) in items_clone.iter().enumerate() {
+2667                assert_eq!(
+2668                    current_items[i].id, original.id,
+2669                    "Item {} should still be in queue",
+2670                    i
+2671                );
+2672                assert_eq!(
+2673                    current_items[i].title, original.title,
+2674                    "Item {} title should be unchanged",
+2675                    i
+2676                );
+2677            }
+2678        }
+2679
+2680        // Skip again
+2681        controller.next().unwrap();
+2682
+2683        // Verify queue still intact and index advanced again
+2684        {
+2685            let queue = controller.queue();
+2686            let queue_lock = queue.lock_safe();
+2687            assert_eq!(
+2688                queue_lock.items().len(),
+2689                5,
+2690                "Queue should still have 5 items after second skip"
+2691            );
+2692            assert_eq!(
+2693                queue_lock.current_index(),
+2694                Some(2),
+2695                "Index should advance to 2"
+2696            );
+2697            assert_eq!(
+2698                queue_lock.current().unwrap().id,
+2699                "item_2",
+2700                "Current item should be item_2"
+2701            );
+2702        }
+2703
+2704        // Skip multiple times to reach the end
+2705        controller.next().unwrap(); // -> item_3
+2706        controller.next().unwrap(); // -> item_4
+2707
+2708        // Verify we're at the last item
+2709        {
+2710            let queue = controller.queue();
+2711            let queue_lock = queue.lock_safe();
+2712            assert_eq!(
+2713                queue_lock.items().len(),
+2714                5,
+2715                "Queue should still have 5 items at end"
+2716            );
+2717            assert_eq!(
+2718                queue_lock.current_index(),
+2719                Some(4),
+2720                "Index should be at last item (4)"
+2721            );
+2722            assert_eq!(
+2723                queue_lock.current().unwrap().id,
+2724                "item_4",
+2725                "Current item should be item_4"
+2726            );
+2727        }
+2728    }
+2729
+2730    #[test]
+2731    fn test_skip_at_end_without_repeat() {
+2732        let controller = PlayerController::default();
+2733
+2734        // Create a queue with 3 items
+2735        let items = create_test_items(3);
+2736        controller.play_queue(items, 0).unwrap();
+2737
+2738        // Skip to last item
+2739        controller.next().unwrap(); // -> item_1
+2740        controller.next().unwrap(); // -> item_2
+2741
+2742        // Verify we're at the last item
+2743        {
+2744            let queue = controller.queue();
+2745            let queue_lock = queue.lock_safe();
+2746            assert_eq!(
+2747                queue_lock.current_index(),
+2748                Some(2),
+2749                "Should be at last item"
+2750            );
+2751        }
+2752
+2753        // Try to skip past the end (without repeat mode)
+2754        // This should succeed but stop playback while preserving the queue
+2755        controller.next().unwrap();
+2756
+2757        // Verify queue is still intact
+2758        {
+2759            let queue = controller.queue();
+2760            let queue_lock = queue.lock_safe();
+2761            assert_eq!(
+2762                queue_lock.items().len(),
+2763                3,
+2764                "Queue should still have 3 items after skip at end"
+2765            );
+2766            // When we skip past the end, the queue index should stay at the last item
+2767            // or become None (depending on implementation)
+2768            // The key is the queue items themselves should be preserved
+2769        }
+2770    }
+2771
+2772    #[test]
+2773    fn test_skip_with_repeat_all() {
+2774        let controller = PlayerController::default();
+2775
+2776        // Create a queue with 3 items
+2777        let items = create_test_items(3);
+2778        controller.play_queue(items, 0).unwrap();
+2779
+2780        // Enable repeat all
+2781        controller.cycle_repeat();
+2782
+2783        // Skip to last item
+2784        controller.next().unwrap(); // -> item_1
+2785        controller.next().unwrap(); // -> item_2
+2786
+2787        // Skip again - should wrap to beginning
+2788        controller.next().unwrap();
+2789
+2790        // Verify we wrapped to the first item
+2791        {
+2792            let queue = controller.queue();
+2793            let queue_lock = queue.lock_safe();
+2794            assert_eq!(
+2795                queue_lock.items().len(),
+2796                3,
+2797                "Queue should still have 3 items"
+2798            );
+2799            assert_eq!(
+2800                queue_lock.current_index(),
+2801                Some(0),
+2802                "Should wrap to index 0"
+2803            );
+2804            assert_eq!(
+2805                queue_lock.current().unwrap().id,
+2806                "item_0",
+2807                "Should be back at item_0"
+2808            );
+2809        }
+2810    }
+2811
+2812    #[test]
+2813    fn test_previous_preserves_queue() {
+2814        let controller = PlayerController::default();
+2815
+2816        // Create a queue with 5 items, start at item 3
+2817        let items = create_test_items(5);
+2818        let items_clone = items.clone();
+2819        controller.play_queue(items, 3).unwrap();
+2820
+2821        // Verify starting position
+2822        {
+2823            let queue = controller.queue();
+2824            let queue_lock = queue.lock_safe();
+2825            assert_eq!(
+2826                queue_lock.current_index(),
+2827                Some(3),
+2828                "Should start at index 3"
+2829            );
+2830        }
+2831
+2832        // Go to previous track
+2833        controller.previous().unwrap();
+2834
+2835        // Verify queue is intact and index moved back
+2836        {
+2837            let queue = controller.queue();
+2838            let queue_lock = queue.lock_safe();
+2839            assert_eq!(
+2840                queue_lock.items().len(),
+2841                5,
+2842                "Queue should still have 5 items after previous"
+2843            );
+2844            assert_eq!(
+2845                queue_lock.current_index(),
+2846                Some(2),
+2847                "Index should move to 2"
+2848            );
+2849            assert_eq!(
+2850                queue_lock.current().unwrap().id,
+2851                "item_2",
+2852                "Current item should be item_2"
+2853            );
+2854
+2855            // Verify all original items are still present
+2856            let current_items = queue_lock.items();
+2857            for (i, original) in items_clone.iter().enumerate() {
+2858                assert_eq!(
+2859                    current_items[i].id, original.id,
+2860                    "Item {} should still be in queue",
+2861                    i
+2862                );
+2863            }
+2864        }
+2865    }
+2866
+2867    #[test]
+2868    fn test_seek_updates_position() {
+2869        let controller = PlayerController::default();
+2870
+2871        // Create and play a single item
+2872        let item = create_test_items(1).into_iter().next().unwrap();
+2873        controller.play_item(item).unwrap();
+2874
+2875        // Verify initial position
+2876        assert_eq!(controller.position(), 0.0, "Initial position should be 0");
+2877
+2878        // Seek to 30 seconds
+2879        controller.seek(30.0).unwrap();
+2880        assert_eq!(
+2881            controller.position(),
+2882            30.0,
+2883            "Position should be 30 after seeking"
+2884        );
+2885
+2886        // Seek to 60 seconds
+2887        controller.seek(60.0).unwrap();
+2888        assert_eq!(
+2889            controller.position(),
+2890            60.0,
+2891            "Position should be 60 after seeking"
+2892        );
+2893
+2894        // Seek backward to 15 seconds
+2895        controller.seek(15.0).unwrap();
+2896        assert_eq!(
+2897            controller.position(),
+2898            15.0,
+2899            "Position should be 15 after seeking backward"
+2900        );
+2901    }
+2902
+2903    #[test]
+2904    fn test_seek_while_paused() {
+2905        let controller = PlayerController::default();
+2906
+2907        // Create and play a single item
+2908        let item = create_test_items(1).into_iter().next().unwrap();
+2909        controller.play_item(item).unwrap();
+2910
+2911        // Pause playback
+2912        controller.pause().unwrap();
+2913
+2914        // Verify paused state
+2915        assert!(controller.state().is_paused(), "Should be paused");
+2916
+2917        // Seek while paused
+2918        controller.seek(45.0).unwrap();
+2919        assert_eq!(
+2920            controller.position(),
+2921            45.0,
+2922            "Position should update while paused"
+2923        );
+2924
+2925        // Verify still paused after seeking
+2926        assert!(
+2927            controller.state().is_paused(),
+2928            "Should still be paused after seeking"
+2929        );
+2930    }
+2931
+2932    #[test]
+2933    fn test_seek_while_playing() {
+2934        let controller = PlayerController::default();
+2935
+2936        // Create and play a single item
+2937        let item = create_test_items(1).into_iter().next().unwrap();
+2938        controller.play_item(item).unwrap();
+2939
+2940        // Ensure playing
+2941        controller.play().unwrap();
+2942
+2943        // Verify playing state
+2944        assert!(controller.state().is_playing(), "Should be playing");
+2945
+2946        // Seek while playing
+2947        controller.seek(20.0).unwrap();
+2948        assert_eq!(
+2949            controller.position(),
+2950            20.0,
+2951            "Position should update while playing"
+2952        );
+2953
+2954        // Verify still playing after seeking
+2955        assert!(
+2956            controller.state().is_playing(),
+2957            "Should still be playing after seeking"
+2958        );
+2959    }
+2960
+2961    #[test]
+2962    fn test_multiple_sequential_seeks() {
+2963        let controller = PlayerController::default();
+2964
+2965        let item = create_test_items(1).into_iter().next().unwrap();
+2966        controller.play_item(item).unwrap();
+2967
+2968        // Perform multiple seeks in sequence
+2969        let positions = vec![10.0, 25.0, 50.0, 75.0, 100.0, 30.0];
+2970
+2971        for pos in positions {
+2972            controller.seek(pos).unwrap();
+2973            assert_eq!(
+2974                controller.position(),
+2975                pos,
+2976                "Position should match after seeking to {}",
+2977                pos
+2978            );
+2979        }
+2980    }
+2981
+2982    /// Resuming a queue at a position seeks the starting track immediately.
+2983    /// Regression guard for taking over a remote session: the local player must
+2984    /// pick up where the remote left off, not restart from 0.
+2985    #[test]
+2986    fn test_play_queue_from_resumes_at_position() {
+2987        let controller = PlayerController::default();
+2988        let items = create_test_items(3);
+2989
+2990        controller.play_queue_from(items, 1, Some(42.5)).unwrap();
+2991
+2992        {
+2993            let queue = controller.queue();
+2994            let queue_lock = queue.lock_safe();
+2995            assert_eq!(
+2996                queue_lock.current_index(),
+2997                Some(1),
+2998                "Should start at index 1"
+2999            );
+3000        }
+3001        assert_eq!(
+3002            controller.position(),
+3003            42.5,
+3004            "Should resume at the requested position"
+3005        );
+3006    }
+3007
+3008    /// A None / near-zero start position starts the track from the beginning.
+3009    #[test]
+3010    fn test_play_queue_from_without_position_starts_at_zero() {
+3011        let controller = PlayerController::default();
+3012
+3013        controller
+3014            .play_queue_from(create_test_items(2), 0, None)
+3015            .unwrap();
+3016        assert_eq!(controller.position(), 0.0, "No resume position starts at 0");
+3017
+3018        controller
+3019            .play_queue_from(create_test_items(2), 0, Some(0.2))
+3020            .unwrap();
+3021        assert_eq!(
+3022            controller.position(),
+3023            0.0,
+3024            "Sub-threshold resume position is ignored (starts at 0)"
+3025        );
+3026    }
+3027
+3028    #[test]
+3029    fn test_seek_to_zero() {
+3030        let controller = PlayerController::default();
+3031
+3032        let item = create_test_items(1).into_iter().next().unwrap();
+3033        controller.play_item(item).unwrap();
+3034
+3035        // Seek forward
+3036        controller.seek(60.0).unwrap();
+3037        assert_eq!(controller.position(), 60.0);
+3038
+3039        // Seek back to zero
+3040        controller.seek(0.0).unwrap();
+3041        assert_eq!(
+3042            controller.position(),
+3043            0.0,
+3044            "Should be able to seek to position 0"
+3045        );
+3046    }
+3047
+3048    // Autoplay decision tests
+3049    #[tokio::test]
+3050    async fn test_audio_with_next_advances() {
+3051        let controller = PlayerController::default();
+3052
+3053        // Create queue with 2 audio items
+3054        let items = create_test_items(2);
+3055        controller.play_queue(items, 0).unwrap();
+3056
+3057        // Clear the NewTrackLoaded reason set by play_queue to simulate natural track end
+3058        controller.take_end_reason();
+3059
+3060        // Simulate first track ending naturally
+3061        let decision = controller.on_playback_ended().await.unwrap();
+3062
+3063        // Should decide to advance to next
+3064        assert!(
+3065            matches!(decision, AutoplayDecision::AdvanceToNext),
+3066            "Expected AdvanceToNext decision when queue has next item"
+3067        );
+3068    }
+3069
+3070    #[tokio::test]
+3071    async fn test_audio_at_end_stops() {
+3072        let controller = PlayerController::default();
+3073
+3074        // Create queue with 2 items, start at last one
+3075        let items = create_test_items(2);
+3076        controller.play_queue(items, 1).unwrap();
+3077
+3078        // Clear the NewTrackLoaded reason to simulate natural track end
+3079        controller.take_end_reason();
+3080
+3081        // Simulate last track ending naturally
+3082        let decision = controller.on_playback_ended().await.unwrap();
+3083
+3084        // Should decide to stop (no more items)
+3085        assert!(
+3086            matches!(decision, AutoplayDecision::Stop),
+3087            "Expected Stop decision when at end of queue without repeat"
+3088        );
+3089    }
+3090
+3091    #[tokio::test]
+3092    async fn test_sleep_timer_end_of_track() {
+3093        let controller = PlayerController::default();
+3094
+3095        // Create queue with next items
+3096        let items = create_test_items(3);
+3097        controller.play_queue(items, 0).unwrap();
+3098
+3099        // Clear the NewTrackLoaded reason to simulate natural track end
+3100        controller.take_end_reason();
+3101
+3102        // Set sleep timer to end of track
+3103        {
+3104            let mut timer = controller.sleep_timer.lock_safe();
+3105            timer.mode = SleepTimerMode::EndOfTrack;
+3106        }
+3107
+3108        // Simulate track ending naturally
+3109        let decision = controller.on_playback_ended().await.unwrap();
+3110
+3111        // Should stop despite having next items
+3112        assert!(
+3113            matches!(decision, AutoplayDecision::Stop),
+3114            "Expected Stop decision when sleep timer is EndOfTrack"
+3115        );
+3116
+3117        // Verify timer was cancelled
+3118        {
+3119            let timer = controller.sleep_timer.lock_safe();
+3120            assert!(
+3121                matches!(timer.mode, SleepTimerMode::Off),
+3122                "Sleep timer should be cancelled after EndOfTrack"
+3123            );
+3124        }
+3125    }
+3126
+3127    /// A time-based sleep timer that fires mid-episode must not let the ended
+3128    /// callback fall through to autoplay.
+3129    ///
+3130    /// The timer thread stops the backend directly, which makes ExoPlayer emit
+3131    /// its ended callback. That callback races the thread's own `timer.cancel()`:
+3132    /// by the time `on_playback_ended` inspects the sleep timer it reads `Off`,
+3133    /// so the timer branch is skipped and the episode path runs — showing a
+3134    /// next-episode popup (or advancing) after the user's sleep timer expired.
+3135    #[tokio::test]
+3136    async fn test_expired_time_sleep_timer_stops_without_autoplay() {
+3137        let controller = PlayerController::default();
+3138
+3139        let items = create_test_items(3);
+3140        controller.play_queue(items, 0).unwrap();
+3141        controller.take_end_reason();
+3142
+3143        // Arm a time-based timer that is already due, then let the real timer
+3144        // thread (started in the constructor, 1s tick) observe the expiry and
+3145        // run its stop path. Driving the actual thread is the point: the bug was
+3146        // that this path stopped the backend without recording an end reason.
+3147        let now = chrono::Utc::now().timestamp_millis();
+3148        controller.set_sleep_timer(SleepTimerMode::Time { end_time: now });
+3149
+3150        // Wait for the timer thread to process the expiry (tick is 1s).
+3151        for _ in 0..40 {
+3152            tokio::time::sleep(std::time::Duration::from_millis(100)).await;
+3153            if !controller.sleep_timer.lock_safe().is_active() {
+3154                break;
+3155            }
+3156        }
+3157        assert!(
+3158            !controller.sleep_timer.lock_safe().is_active(),
+3159            "Timer thread should have expired and cancelled the sleep timer"
+3160        );
+3161
+3162        // The backend stop above makes the native player fire its ended callback.
+3163        let decision = controller.on_playback_ended().await.unwrap();
+3164
+3165        assert!(
+3166            matches!(decision, AutoplayDecision::Stop),
+3167            "Expected Stop after an expired time-based sleep timer, got {:?}",
+3168            decision
+3169        );
+3170    }
+3171
+3172    #[tokio::test]
+3173    async fn test_empty_queue_stops() {
+3174        let controller = PlayerController::default();
+3175
+3176        // Don't set up any queue
+3177        let decision = controller.on_playback_ended().await.unwrap();
+3178
+3179        // Should stop (no current item)
+3180        assert!(
+3181            matches!(decision, AutoplayDecision::Stop),
+3182            "Expected Stop decision when queue is empty"
+3183        );
+3184    }
+3185
+3186    #[tokio::test]
+3187    async fn test_repeat_all_advances_at_end() {
+3188        let controller = PlayerController::default();
+3189
+3190        // Create queue with 2 items, enable repeat all
+3191        let items = create_test_items(2);
+3192        controller.play_queue(items, 1).unwrap(); // Start at last item
+3193        controller.cycle_repeat(); // Enable repeat all
+3194
+3195        // Clear the NewTrackLoaded reason to simulate natural track end
+3196        controller.take_end_reason();
+3197
+3198        // Simulate last track ending naturally
+3199        let decision = controller.on_playback_ended().await.unwrap();
+3200
+3201        // Should advance (will wrap to beginning due to repeat all)
+3202        assert!(
+3203            matches!(decision, AutoplayDecision::AdvanceToNext),
+3204            "Expected AdvanceToNext decision at end of queue with repeat all"
+3205        );
+3206    }
+3207
+3208    #[tokio::test]
+3209    async fn test_repeat_one_advances() {
+3210        let controller = PlayerController::default();
+3211
+3212        // Create queue with 2 items
+3213        let items = create_test_items(2);
+3214        controller.play_queue(items, 0).unwrap();
+3215
+3216        // Enable repeat one
+3217        controller.cycle_repeat(); // Once for all
+3218        controller.cycle_repeat(); // Twice for one
+3219
+3220        // Clear the NewTrackLoaded reason to simulate natural track end
+3221        controller.take_end_reason();
+3222
+3223        // Simulate track ending naturally
+3224        let decision = controller.on_playback_ended().await.unwrap();
+3225
+3226        // Should advance (which repeats the same track)
+3227        assert!(
+3228            matches!(decision, AutoplayDecision::AdvanceToNext),
+3229            "Expected AdvanceToNext decision with repeat one (repeats same track)"
+3230        );
+3231    }
+3232
+3233    #[test]
+3234    fn test_native_load_returns_transport_authority_to_the_backend() {
+3235        // Play/pause did nothing on the Android native video path, from the
+3236        // on-screen tap AND from the control-bar button, while seek and skip
+3237        // worked — those take a different decision path.
+3238        //
+3239        // `html5_playing` is written only by the webview element's own reports
+3240        // and cleared only when it reports "stopped"/"idle" (or on a
+3241        // background-audio handoff). A previous element that went away without
+3242        // that final report — or webview-rendered music earlier in the same
+3243        // process — therefore left `is_html5_active()` true, and every transport
+3244        // intent was emitted as a ControlCommand at an element that no longer
+3245        // existed. Nothing reached ExoPlayer. It looked intermittent because it
+3246        // depends entirely on what played before.
+3247        //
+3248        // Loading into the native backend IS the statement that native renders
+3249        // this item, so it hands authority back — the same "element is gone"
+3250        // semantics the "stopped"/"idle" report already has.
+3251        //
+3252        // TRACES: UR-005, UR-003 | DR-193
+3253        let controller = PlayerController::default();
+3254        let emitter = Arc::new(CapturingEmitter::new());
+3255        controller.set_event_emitter(emitter.clone());
+3256
+3257        // A webview element reported itself playing and never said "stopped".
+3258        controller.report_html5_state("playing".to_string(), Some("ep-1".to_string()));
+3259        assert!(controller.is_html5_active());
+3260
+3261        // Now a native item loads — Android video through ExoPlayer.
+3262        let item = create_test_items(1).into_iter().next().unwrap();
+3263        controller.play_item(item).unwrap();
+3264
+3265        assert!(
+3266            !controller.is_html5_active(),
+3267            "loading into the native backend hands transport back to it"
+3268        );
+3269
+3270        // The toggle must reach the backend, not be emitted at a dead element.
+3271        controller.toggle_playback().unwrap();
+3272        let controls: Vec<_> = emitter
+3273            .events()
+3274            .into_iter()
+3275            .filter_map(|e| match e {
+3276                PlayerStatusEvent::ControlCommand { action, .. } => Some(action),
+3277                _ => None,
+3278            })
+3279            .collect();
+3280        assert!(
+3281            controls.is_empty(),
+3282            "transport went to a webview element that is not rendering: {controls:?}"
+3283        );
+3284    }
+3285
+3286    // EndReason state machine tests
+3287    #[test]
+3288    fn test_load_and_play_sets_new_track_loaded() {
+3289        let controller = PlayerController::default();
+3290        let item = create_test_items(1).into_iter().next().unwrap();
+3291
+3292        // End reason should be None initially
+3293        assert!(controller.take_end_reason().is_none());
+3294
+3295        // Load and play should set NewTrackLoaded
+3296        controller.load_and_play(&item).unwrap();
+3297
+3298        // Verify end reason was set
+3299        let reason = controller.take_end_reason();
+3300        assert_eq!(reason, Some(EndReason::NewTrackLoaded));
+3301    }
+3302
+3303    #[test]
+3304    fn test_stop_sets_user_stop() {
+3305        let controller = PlayerController::default();
+3306        let item = create_test_items(1).into_iter().next().unwrap();
+3307
+3308        // Play an item first
+3309        controller.play_item(item).unwrap();
+3310
+3311        // Clear any end reason from load_and_play
+3312        controller.take_end_reason();
+3313
+3314        // Stop should set UserStop
+3315        controller.stop().unwrap();
+3316
+3317        // Verify end reason was set
+3318        let reason = controller.take_end_reason();
+3319        assert_eq!(reason, Some(EndReason::UserStop));
+3320    }
+3321
+3322    #[tokio::test]
+3323    async fn test_on_playback_ended_with_new_track_loaded_stops() {
+3324        let controller = PlayerController::default();
+3325
+3326        // Create queue with 2 items
+3327        let items = create_test_items(2);
+3328        controller.play_queue(items, 0).unwrap();
+3329
+3330        // Manually set end reason to NewTrackLoaded
+3331        controller.set_end_reason(EndReason::NewTrackLoaded);
+3332
+3333        // Call on_playback_ended
+3334        let decision = controller.on_playback_ended().await.unwrap();
+3335
+3336        // Should stop without advancing
+3337        assert!(
+3338            matches!(decision, AutoplayDecision::Stop),
+3339            "Expected Stop decision when EndReason is NewTrackLoaded"
+3340        );
+3341    }
+3342
+3343    #[tokio::test]
+3344    async fn test_on_playback_ended_with_user_stop_stops() {
+3345        let controller = PlayerController::default();
+3346
+3347        // Create queue with 2 items
+3348        let items = create_test_items(2);
+3349        controller.play_queue(items, 0).unwrap();
+3350
+3351        // Manually set end reason to UserStop
+3352        controller.set_end_reason(EndReason::UserStop);
+3353
+3354        // Call on_playback_ended
+3355        let decision = controller.on_playback_ended().await.unwrap();
+3356
+3357        // Should stop without advancing
+3358        assert!(
+3359            matches!(decision, AutoplayDecision::Stop),
+3360            "Expected Stop decision when EndReason is UserStop"
+3361        );
+3362    }
+3363
+3364    #[tokio::test]
+3365    async fn test_on_playback_ended_natural_end_advances() {
+3366        let controller = PlayerController::default();
+3367
+3368        // Create queue with 2 items
+3369        let items = create_test_items(2);
+3370        controller.play_queue(items, 0).unwrap();
+3371
+3372        // Clear the NewTrackLoaded reason to simulate natural track end
+3373        controller.take_end_reason();
+3374
+3375        // Call on_playback_ended (no end reason set = natural end)
+3376        let decision = controller.on_playback_ended().await.unwrap();
+3377
+3378        // Should advance to next (natural end with next track available)
+3379        assert!(
+3380            matches!(decision, AutoplayDecision::AdvanceToNext),
+3381            "Expected AdvanceToNext decision when track ends naturally with next track available"
+3382        );
+3383    }
+3384
+3385    #[tokio::test]
+3386    async fn test_on_playback_ended_with_user_skip_stops() {
+3387        let controller = PlayerController::default();
+3388
+3389        // Create queue with 2 items
+3390        let items = create_test_items(2);
+3391        controller.play_queue(items, 0).unwrap();
+3392
+3393        // Set end reason to UserSkip
+3394        controller.set_end_reason(EndReason::UserSkip);
+3395
+3396        // Call on_playback_ended
+3397        let decision = controller.on_playback_ended().await.unwrap();
+3398
+3399        // Should stop without advancing (skip already handled)
+3400        assert!(
+3401            matches!(decision, AutoplayDecision::Stop),
+3402            "Expected Stop decision when EndReason is UserSkip"
+3403        );
+3404    }
+3405
+3406    #[tokio::test]
+3407    async fn test_on_playback_ended_with_error_stops() {
+3408        let controller = PlayerController::default();
+3409
+3410        // Create queue with 2 items
+3411        let items = create_test_items(2);
+3412        controller.play_queue(items, 0).unwrap();
+3413
+3414        // Set end reason to Error
+3415        controller.set_end_reason(EndReason::Error);
+3416
+3417        // Call on_playback_ended
+3418        let decision = controller.on_playback_ended().await.unwrap();
+3419
+3420        // Should stop without advancing
+3421        assert!(
+3422            matches!(decision, AutoplayDecision::Stop),
+3423            "Expected Stop decision when EndReason is Error"
+3424        );
+3425    }
+3426
+3427    #[tokio::test]
+3428    async fn test_take_end_reason_clears_state() {
+3429        let controller = PlayerController::default();
+3430
+3431        // Set a reason
+3432        controller.set_end_reason(EndReason::NewTrackLoaded);
+3433
+3434        // Take it once
+3435        let reason = controller.take_end_reason();
+3436        assert_eq!(reason, Some(EndReason::NewTrackLoaded));
+3437
+3438        // Take it again - should be None
+3439        let reason = controller.take_end_reason();
+3440        assert!(reason.is_none(), "take_end_reason should clear the state");
+3441    }
+3442
+3443    // ===== Next-episode autoplay decision tests =====
+3444
+3445    use crate::repository::types as repo_types;
+3446
+3447    /// Mock repository serving a single season of episodes for next-episode
+3448    /// lookup tests. Only `get_item` and `get_items` are used by
+3449    /// `fetch_next_episode_for_item`; everything else is unreachable.
+3450    struct MockEpisodeRepo {
+3451        episodes: Vec<repo_types::MediaItem>,
+3452    }
+3453
+3454    impl MockEpisodeRepo {
+3455        fn season(count: usize) -> Self {
+3456            let episodes = (1..=count)
+3457                .map(|i| {
+3458                    let mut item = make_repo_episode(&format!("ep{}", i), i as i32);
+3459                    item.name = format!("Episode {}", i);
+3460                    item
+3461                })
+3462                .collect();
+3463            Self { episodes }
+3464        }
+3465    }
+3466
+3467    fn make_repo_episode(id: &str, index: i32) -> repo_types::MediaItem {
+3468        repo_types::MediaItem {
+3469            id: id.to_string(),
+3470            name: format!("Episode {}", index),
+3471            item_type: "Episode".to_string(),
+3472            kind: crate::domain::MediaKind::Episode,
+3473            is_folder: false,
+3474            server_id: "server".to_string(),
+3475            parent_id: Some("season1".to_string()),
+3476            library_id: None,
+3477            overview: None,
+3478            genres: None,
+3479            runtime_ticks: None,
+3480            duration_ms: None,
+3481            production_year: None,
+3482            premiere_date: None,
+3483            community_rating: None,
+3484            official_rating: None,
+3485            primary_image_tag: None,
+3486            image_id: None,
+3487            backdrop_image_tags: None,
+3488            parent_backdrop_image_tags: None,
+3489            album_id: None,
+3490            album_name: None,
+3491            album_artist: None,
+3492            artists: None,
+3493            artist_items: None,
+3494            index_number: Some(index),
+3495            series_id: Some("series1".to_string()),
+3496            series_name: Some("Test Series".to_string()),
+3497            season_id: Some("season1".to_string()),
+3498            season_name: Some("Season 1".to_string()),
+3499            parent_index_number: Some(1),
+3500            user_data: None,
+3501            media_streams: None,
+3502            media_sources: None,
+3503            people: None,
+3504        }
+3505    }
+3506
+3507    #[async_trait::async_trait]
+3508    impl crate::repository::MediaRepository for MockEpisodeRepo {
+3509        async fn get_libraries(&self) -> Result<Vec<repo_types::Library>, repo_types::RepoError> {
+3510            unimplemented!()
+3511        }
+3512        async fn get_items(
+3513            &self,
+3514            parent_id: &str,
+3515            _options: Option<repo_types::GetItemsOptions>,
+3516        ) -> Result<repo_types::SearchResult, repo_types::RepoError> {
+3517            assert_eq!(parent_id, "season1", "episode lookup must query the season");
+3518            Ok(repo_types::SearchResult {
+3519                items: self.episodes.clone(),
+3520                total_record_count: self.episodes.len(),
+3521            })
+3522        }
+3523        async fn get_item(
+3524            &self,
+3525            item_id: &str,
+3526        ) -> Result<repo_types::MediaItem, repo_types::RepoError> {
+3527            self.episodes
+3528                .iter()
+3529                .find(|e| e.id == item_id)
+3530                .cloned()
+3531                .ok_or(repo_types::RepoError::NotFound {
+3532                    message: format!("{} not found", item_id),
+3533                })
+3534        }
+3535        async fn get_latest_items(
+3536            &self,
+3537            _: &str,
+3538            _: Option<usize>,
+3539        ) -> Result<Vec<repo_types::MediaItem>, repo_types::RepoError> {
+3540            unimplemented!()
+3541        }
+3542        async fn get_resume_items(
+3543            &self,
+3544            _: Option<&str>,
+3545            _: Option<usize>,
+3546        ) -> Result<Vec<repo_types::MediaItem>, repo_types::RepoError> {
+3547            unimplemented!()
+3548        }
+3549        async fn get_next_up_episodes(
+3550            &self,
+3551            _: Option<&str>,
+3552            _: Option<usize>,
+3553        ) -> Result<Vec<repo_types::MediaItem>, repo_types::RepoError> {
+3554            unimplemented!()
+3555        }
+3556        async fn get_recently_played_audio(
+3557            &self,
+3558            _: Option<usize>,
+3559        ) -> Result<Vec<repo_types::MediaItem>, repo_types::RepoError> {
+3560            unimplemented!()
+3561        }
+3562        async fn get_rediscover_albums(
+3563            &self,
+3564            _: Option<&str>,
+3565            _: Option<usize>,
+3566        ) -> Result<Vec<repo_types::MediaItem>, repo_types::RepoError> {
+3567            unimplemented!()
+3568        }
+3569        async fn get_resume_movies(
+3570            &self,
+3571            _: Option<usize>,
+3572        ) -> Result<Vec<repo_types::MediaItem>, repo_types::RepoError> {
+3573            unimplemented!()
+3574        }
+3575        async fn get_genres(
+3576            &self,
+3577            _: Option<&str>,
+3578        ) -> Result<Vec<repo_types::Genre>, repo_types::RepoError> {
+3579            unimplemented!()
+3580        }
+3581        async fn search(
+3582            &self,
+3583            _: &str,
+3584            _: Option<repo_types::SearchOptions>,
+3585        ) -> Result<repo_types::SearchResult, repo_types::RepoError> {
+3586            unimplemented!()
+3587        }
+3588        async fn get_playback_info(
+3589            &self,
+3590            _: &str,
+3591        ) -> Result<repo_types::PlaybackInfo, repo_types::RepoError> {
+3592            unimplemented!()
+3593        }
+3594        async fn get_audio_stream_url(&self, _: &str) -> Result<String, repo_types::RepoError> {
+3595            unimplemented!()
+3596        }
+3597        async fn get_audio_only_stream_url_for_video(
+3598            &self,
+3599            item_id: &str,
+3600            _media_source_id: Option<&str>,
+3601            _start_time_seconds: Option<f64>,
+3602            _audio_stream_index: Option<i32>,
+3603        ) -> Result<String, repo_types::RepoError> {
+3604            Ok(format!("http://example.com/{}-audio.mp3", item_id))
+3605        }
+3606        async fn get_live_tv_channels(
+3607            &self,
+3608        ) -> Result<Vec<repo_types::MediaItem>, repo_types::RepoError> {
+3609            unimplemented!()
+3610        }
+3611        async fn get_channels(&self) -> Result<repo_types::SearchResult, repo_types::RepoError> {
+3612            unimplemented!()
+3613        }
+3614        async fn open_live_stream(
+3615            &self,
+3616            _: &str,
+3617        ) -> Result<repo_types::LiveStreamInfo, repo_types::RepoError> {
+3618            unimplemented!()
+3619        }
+3620        async fn report_playback_start(
+3621            &self,
+3622            _: &str,
+3623            _: i64,
+3624        ) -> Result<(), repo_types::RepoError> {
+3625            unimplemented!()
+3626        }
+3627        async fn report_playback_progress(
+3628            &self,
+3629            _: &str,
+3630            _: i64,
+3631        ) -> Result<(), repo_types::RepoError> {
+3632            unimplemented!()
+3633        }
+3634        async fn report_playback_stopped(
+3635            &self,
+3636            _: &str,
+3637            _: i64,
+3638        ) -> Result<(), repo_types::RepoError> {
+3639            unimplemented!()
+3640        }
+3641        fn get_image_url(
+3642            &self,
+3643            _: &str,
+3644            _: repo_types::ImageType,
+3645            _: Option<repo_types::ImageOptions>,
+3646        ) -> String {
+3647            unimplemented!()
+3648        }
+3649        fn get_subtitle_url(&self, _: &str, _: &str, _: i32, _: &str) -> String {
+3650            unimplemented!()
+3651        }
+3652        fn get_video_download_url(
+3653            &self,
+3654            _: &str,
+3655            _: &str,
+3656            _: Option<&str>,
+3657            _: Option<&str>,
+3658        ) -> String {
+3659            unimplemented!()
+3660        }
+3661        async fn mark_favorite(&self, _: &str) -> Result<(), repo_types::RepoError> {
+3662            unimplemented!()
+3663        }
+3664        async fn unmark_favorite(&self, _: &str) -> Result<(), repo_types::RepoError> {
+3665            unimplemented!()
+3666        }
+3667        async fn get_favorites(
+3668            &self,
+3669            _: repo_types::SearchScope,
+3670            _: Option<repo_types::GetItemsOptions>,
+3671        ) -> Result<repo_types::SearchResult, repo_types::RepoError> {
+3672            unimplemented!()
+3673        }
+3674        async fn clear_watch_history(&self, _: &str) -> Result<(), repo_types::RepoError> {
+3675            unimplemented!()
+3676        }
+3677        async fn mark_played(&self, _: &str) -> Result<(), repo_types::RepoError> {
+3678            unimplemented!()
+3679        }
+3680        async fn get_person(
+3681            &self,
+3682            _: &str,
+3683        ) -> Result<repo_types::MediaItem, repo_types::RepoError> {
+3684            unimplemented!()
+3685        }
+3686        async fn get_items_by_person(
+3687            &self,
+3688            _: &str,
+3689            _: Option<repo_types::GetItemsOptions>,
+3690        ) -> Result<repo_types::SearchResult, repo_types::RepoError> {
+3691            unimplemented!()
+3692        }
+3693        async fn get_similar_items(
+3694            &self,
+3695            _: &str,
+3696            _: Option<usize>,
+3697        ) -> Result<repo_types::SearchResult, repo_types::RepoError> {
+3698            unimplemented!()
+3699        }
+3700        async fn create_playlist(
+3701            &self,
+3702            _: &str,
+3703            _: &[String],
+3704        ) -> Result<repo_types::PlaylistCreatedResult, repo_types::RepoError> {
+3705            unimplemented!()
+3706        }
+3707        async fn delete_playlist(&self, _: &str) -> Result<(), repo_types::RepoError> {
+3708            unimplemented!()
+3709        }
+3710        async fn rename_playlist(&self, _: &str, _: &str) -> Result<(), repo_types::RepoError> {
+3711            unimplemented!()
+3712        }
+3713        async fn get_playlist_items(
+3714            &self,
+3715            _: &str,
+3716        ) -> Result<Vec<repo_types::PlaylistEntry>, repo_types::RepoError> {
+3717            unimplemented!()
+3718        }
+3719        async fn add_to_playlist(
+3720            &self,
+3721            _: &str,
+3722            _: &[String],
+3723        ) -> Result<(), repo_types::RepoError> {
+3724            unimplemented!()
+3725        }
+3726        async fn remove_from_playlist(
+3727            &self,
+3728            _: &str,
+3729            _: &[String],
+3730        ) -> Result<(), repo_types::RepoError> {
+3731            unimplemented!()
+3732        }
+3733        async fn move_playlist_item(
+3734            &self,
+3735            _: &str,
+3736            _: &str,
+3737            _: u32,
+3738        ) -> Result<(), repo_types::RepoError> {
+3739            unimplemented!()
+3740        }
+3741    }
+3742
+3743    /// Video (HTML5/Linux) path: ending mid-season must produce the
+3744    /// next-episode popup with auto-advance.
+3745    #[tokio::test]
+3746    async fn test_video_playback_ended_offers_next_episode() {
+3747        let controller = PlayerController::default();
+3748        let repo: Arc<dyn MediaRepository> = Arc::new(MockEpisodeRepo::season(3));
+3749
+3750        let decision = controller
+3751            .on_video_playback_ended("ep2", repo)
+3752            .await
+3753            .expect("decision should succeed");
+3754
+3755        match decision {
+3756            AutoplayDecision::ShowNextEpisodePopup {
+3757                current_episode,
+3758                next_episode,
+3759                auto_advance,
+3760                ..
+3761            } => {
+3762                assert_eq!(current_episode.id, "ep2");
+3763                assert_eq!(next_episode.id, "ep3");
+3764                assert!(auto_advance, "default settings should auto-advance");
+3765            }
+3766            other => panic!("Expected ShowNextEpisodePopup, got {:?}", other),
+3767        }
+3768    }
+3769
+3770    /// Last episode of the season: no popup, stop.
+3771    #[tokio::test]
+3772    async fn test_video_playback_ended_last_episode_stops() {
+3773        let controller = PlayerController::default();
+3774        let repo: Arc<dyn MediaRepository> = Arc::new(MockEpisodeRepo::season(3));
+3775
+3776        let decision = controller
+3777            .on_video_playback_ended("ep3", repo)
+3778            .await
+3779            .expect("decision should succeed");
+3780
+3781        assert!(matches!(decision, AutoplayDecision::Stop));
+3782    }
+3783
+3784    /// Android/ExoPlayer path: `on_playback_ended` has no per-call repository,
+3785    /// so the controller-level repository (wired up in `repository_create`)
+3786    /// must be used for the next-episode lookup. Regression test for episode
+3787    /// autoplay never triggering on Android because no repository was set.
+3788    #[tokio::test]
+3789    async fn test_playback_ended_uses_controller_repository_for_episodes() {
+3790        let controller = PlayerController::default();
+3791        controller.set_repository(Arc::new(MockEpisodeRepo::season(3)));
+3792
+3793        // Queue holds the episode that just finished playing
+3794        let episode = MediaItem {
+3795            media_type: MediaType::Video,
+3796            source: MediaSource::Remote {
+3797                stream_url: "http://example.com/ep1.mkv".to_string(),
+3798                jellyfin_item_id: "ep1".to_string(),
+3799            },
+3800            ..create_test_items(1).remove(0)
+3801        };
+3802        controller.play_queue(vec![episode], 0).unwrap();
+3803
+3804        // Clear the NewTrackLoaded reason to simulate natural track end
+3805        controller.take_end_reason();
+3806
+3807        let decision = controller.on_playback_ended().await.unwrap();
+3808
+3809        match decision {
+3810            AutoplayDecision::ShowNextEpisodePopup { next_episode, .. } => {
+3811                assert_eq!(next_episode.id, "ep2");
+3812            }
+3813            other => panic!("Expected ShowNextEpisodePopup, got {:?}", other),
+3814        }
+3815    }
+3816
+3817    /// Background audio-only mode (UR-040): a video episode is handed off to the
+3818    /// native ExoPlayer *audio* path as a `MediaType::Audio` item so it keeps
+3819    /// playing while the app is backgrounded. When that audio track ends, autoplay
+3820    /// must STILL recognise it as an episode and offer the next one — otherwise
+3821    /// playback just pauses at the episode boundary (the reported bug). The item
+3822    /// carries its episode identity via `item_type: "Episode"` + `series_id`.
+3823    #[tokio::test]
+3824    async fn test_playback_ended_background_audio_episode_advances() {
+3825        let controller = PlayerController::default();
+3826        controller.set_repository(Arc::new(MockEpisodeRepo::season(3)));
+3827
+3828        // Mirrors what player_enter_background_audio builds: the episode as AUDIO.
+3829        let episode = MediaItem {
+3830            item_type: Some("Episode".to_string()),
+3831            media_type: MediaType::Audio, // audio-only handoff, not Video
+3832            series_id: Some("series1".to_string()),
+3833            duration: Some(180.0),
+3834            source: MediaSource::Remote {
+3835                stream_url: "http://example.com/ep2-audio.m3u8".to_string(),
+3836                jellyfin_item_id: "ep2".to_string(),
+3837            },
+3838            ..create_test_items(1).remove(0)
+3839        };
+3840        controller.play_queue(vec![episode], 0).unwrap();
+3841
+3842        // Played through to the end — a natural finish, not a stream cut short.
+3843        controller.seek(180.0).unwrap();
+3844        // Clear the NewTrackLoaded reason to simulate natural track end.
+3845        controller.take_end_reason();
+3846
+3847        let decision = controller.on_playback_ended().await.unwrap();
+3848
+3849        match decision {
+3850            AutoplayDecision::ShowNextEpisodePopup { next_episode, .. } => {
+3851                assert_eq!(next_episode.id, "ep3");
+3852            }
+3853            other => panic!(
+3854                "background-audio episode end must advance to the next episode, got {:?}",
+3855                other
+3856            ),
+3857        }
+3858    }
+3859
+3860    /// The backend-driven advance (used when backgrounded) must load the next
+3861    /// episode as an AUDIO item carrying its episode identity, so the *following*
+3862    /// end-of-track also advances rather than stopping.
+3863    #[tokio::test]
+3864    async fn test_advance_to_next_episode_audio_only_loads_audio_episode() {
+3865        let controller = PlayerController::default();
+3866        controller.set_repository(Arc::new(MockEpisodeRepo::season(3)));
+3867
+3868        controller
+3869            .advance_to_next_episode_audio_only("ep2")
+3870            .await
+3871            .expect("advance should succeed");
+3872
+3873        let current = controller
+3874            .queue
+3875            .lock_safe()
+3876            .current()
+3877            .cloned()
+3878            .expect("an item should be loaded");
+3879        assert_eq!(current.id, "ep2");
+3880        assert_eq!(current.media_type, MediaType::Audio);
+3881        assert_eq!(current.item_type.as_deref(), Some("Episode"));
+3882        assert_eq!(current.series_id.as_deref(), Some("series1"));
+3883        // Uses the audio-only URL, not a video stream.
+3884        match &current.source {
+3885            MediaSource::Remote { stream_url, .. } => {
+3886                assert!(
+3887                    stream_url.contains("audio"),
+3888                    "expected audio-only URL, got {}",
+3889                    stream_url
+3890                );
+3891            }
+3892            other => panic!("expected Remote source, got {:?}", other),
+3893        }
+3894
+3895        // The controller now considers itself mid background-audio episode, so the
+3896        // next end-of-track will advance again rather than stop.
+3897        assert!(controller.current_is_audio_episode());
+3898    }
+3899
+3900    /// The handoff base offset describes ONE stream: the audio-only URL built
+3901    /// with `StartTimeTicks` = the position the video was handed off at, whose
+3902    /// timeline therefore starts at that point. The next episode is loaded from
+3903    /// its own beginning, so its timeline is already absolute and the base must
+3904    /// be cleared — otherwise returning to the foreground resolves the resume
+3905    /// position as `old_base + position_in_new_episode` and the video jumps to a
+3906    /// point that has nothing to do with what was playing.
+3907    #[tokio::test]
+3908    async fn test_advance_to_next_episode_audio_only_clears_handoff_base() {
+3909        let controller = PlayerController::default();
+3910        controller.set_repository(Arc::new(MockEpisodeRepo::season(3)));
+3911
+3912        // Handed off 20 minutes into the previous episode.
+3913        controller.set_background_audio_base(1200.0);
+3914
+3915        controller
+3916            .advance_to_next_episode_audio_only("ep2")
+3917            .await
+3918            .expect("advance should succeed");
+3919
+3920        assert_eq!(
+3921            controller.take_background_audio_base(),
+3922            0.0,
+3923            "the next episode starts at its own zero, so the previous handoff \
+3924             base must not survive the advance"
+3925        );
+3926    }
+3927
+3928    /// A background audio-only episode must advance IN THE BACKEND when the
+3929    /// autoplay decision comes back as ShowNextEpisodePopup — never by starting a
+3930    /// countdown the frontend is supposed to act on.
+3931    ///
+3932    /// The countdown only emits CountdownTick events; the actual advance is a
+3933    /// `goto('/player/<id>')` in the webview. While the app is backgrounded that
+3934    /// navigation cannot start audio, so playback stalls at the episode boundary
+3935    /// with ExoPlayer parked in STATE_ENDED — and any later play intent
+3936    /// (lockscreen, headset, Bluetooth reconnect) replays the ended item from the
+3937    /// start, which is what surfaces to the user as "the episode randomly
+3938    /// restarted".
+3939    #[tokio::test]
+3940    async fn test_auto_advance_background_audio_episode_advances_in_backend() {
+3941        let controller = PlayerController::default();
+3942        controller.set_repository(Arc::new(MockEpisodeRepo::season(3)));
+3943
+3944        // Currently playing: ep2 handed off to audio-only background playback.
+3945        let episode = MediaItem {
+3946            id: "ep2".to_string(),
+3947            item_type: Some("Episode".to_string()),
+3948            media_type: MediaType::Audio,
+3949            series_id: Some("series1".to_string()),
+3950            source: MediaSource::Remote {
+3951                stream_url: "http://example.com/ep2-audio.mp3".to_string(),
+3952                jellyfin_item_id: "ep2".to_string(),
+3953            },
+3954            ..create_test_items(1).remove(0)
+3955        };
+3956        controller.play_queue(vec![episode], 0).unwrap();
+3957
+3958        let next = make_repo_episode("ep3", 3);
+3959        controller.auto_advance_to_next_episode(next, 10).await;
+3960
+3961        let current = controller
+3962            .queue
+3963            .lock_safe()
+3964            .current()
+3965            .cloned()
+3966            .expect("an item should still be loaded");
+3967        assert_eq!(
+3968            current.id, "ep3",
+3969            "background audio-only episode must advance in the backend, not wait \
+3970             for a frontend navigation that cannot happen while backgrounded"
+3971        );
+3972        assert_eq!(current.media_type, MediaType::Audio);
+3973        assert!(controller.current_is_audio_episode());
+3974    }
+3975
+3976    /// Build the audio-only episode the background handoff loads: a video item
+3977    /// played through the native audio path, with a known runtime and a stream
+3978    /// URL carrying the handoff position.
+3979    fn audio_only_episode(runtime_seconds: f64) -> MediaItem {
+3980        MediaItem {
+3981            id: "ep2".to_string(),
+3982            item_type: Some("Episode".to_string()),
+3983            media_type: MediaType::Audio,
+3984            series_id: Some("series1".to_string()),
+3985            duration: Some(runtime_seconds),
+3986            source: MediaSource::Remote {
+3987                stream_url:
+3988                    "http://s/Audio/ep2/universal?api_key=k&AudioStreamIndex=2&StartTimeTicks=0"
+3989                        .to_string(),
+3990                jellyfin_item_id: "ep2".to_string(),
+3991            },
+3992            ..create_test_items(1).remove(0)
+3993        }
+3994    }
+3995
+3996    /// The same episode handed off from a **downloaded file** — the handoff's
+3997    /// other source, which starts at the episode's own zero rather than at the
+3998    /// handoff point.
+3999    fn local_audio_only_episode(runtime_seconds: f64) -> MediaItem {
+4000        MediaItem {
+4001            source: MediaSource::Local {
+4002                file_path: std::path::PathBuf::from("/downloads/ep2.mkv"),
+4003                jellyfin_item_id: Some("ep2".to_string()),
+4004            },
+4005            ..audio_only_episode(runtime_seconds)
+4006        }
+4007    }
+4008
+4009    /// A file seeks like a file. The rebuild path exists because a chunked
+4010    /// length-less transcode cannot honour a seek, which is not true of local
+4011    /// media — and `resume_stream_at` refuses a non-remote source outright, so
+4012    /// routing a lockscreen scrub through it fails the seek instead of doing it.
+4013    ///
+4014    /// TRACES: UR-040, UR-071 | DR-180 | UT-181
+4015    #[tokio::test]
+4016    async fn test_seek_absolute_on_a_downloaded_handoff_is_an_ordinary_seek() {
+4017        let controller = PlayerController::default();
+4018        controller
+4019            .play_queue(vec![local_audio_only_episode(1500.0)], 0)
+4020            .unwrap();
+4021        // A downloaded handoff claims no base: the file's zero is the episode's.
+4022        controller.enter_background_audio(0.0);
+4023
+4024        controller.seek_absolute(900.0).await.unwrap();
+4025
+4026        assert_eq!(controller.position(), 900.0);
+4027    }
+4028
+4029    /// A flaky connection truncates the progressive mp3 transcode that carries
+4030    /// background audio-only playback. ExoPlayer sees end-of-input on a stream
+4031    /// with no reliable length, so it reports STATE_ENDED ten minutes into a
+4032    /// twenty-five minute episode — indistinguishable, to the player, from the
+4033    /// real end.
+4034    ///
+4035    /// Treating that as "the episode finished" is what the user experiences as
+4036    /// the episode randomly restarting: playback parks in STATE_ENDED and the
+4037    /// next play intent (lockscreen, notification, Bluetooth reconnect) seeks an
+4038    /// ended player to position 0 before playing. The runtime we already know
+4039    /// says the stream died early, so the decision must be to resume it.
+4040    #[tokio::test]
+4041    async fn test_truncated_background_audio_stream_resumes_instead_of_ending() {
+4042        let controller = PlayerController::default();
+4043        controller.set_repository(Arc::new(MockEpisodeRepo::season(3)));
+4044
+4045        controller
+4046            .play_queue(vec![audio_only_episode(1500.0)], 0)
+4047            .unwrap();
+4048        // The connection dropped 10 minutes into a 25-minute episode.
+4049        controller.seek(600.0).unwrap();
+4050        controller.take_end_reason();
+4051
+4052        let decision = controller.on_playback_ended().await.unwrap();
+4053
+4054        match decision {
+4055            AutoplayDecision::ResumeStream { position } => {
+4056                assert_eq!(position, 600.0, "must resume where the stream died");
+4057            }
+4058            other => panic!(
+4059                "a stream that ended 15 minutes short of the runtime must resume, \
+4060                 not run end-of-episode logic; got {:?}",
+4061                other
+4062            ),
+4063        }
+4064    }
+4065
+4066    /// A seek arriving during a background-audio handoff is **absolute** — the
+4067    /// lockscreen scrubber shows the whole episode, so a scrub to 25:00 means
+4068    /// 25:00 of the episode, not 25:00 into the handoff stream.
+4069    ///
+4070    /// The handoff stream cannot be seeked at all (a chunked, length-less
+4071    /// transcode), so honouring it means re-opening the URL at the new position,
+4072    /// exactly as the truncation recovery does. Passing the number through to
+4073    /// ExoPlayer instead — which is what used to happen — asked a stream that
+4074    /// cannot seek to jump past its own end, and a clamped seek lands at stream
+4075    /// zero: the handoff point.
+4076    ///
+4077    /// TRACES: UR-040, UR-005 | DR-159 | UT-155
+4078    #[tokio::test]
+4079    async fn test_seek_during_handoff_reopens_the_stream_at_the_absolute_position() {
+4080        let controller = PlayerController::default();
+4081        controller.set_repository(Arc::new(MockEpisodeRepo::season(3)));
+4082        controller
+4083            .play_queue(vec![audio_only_episode(1500.0)], 0)
+4084            .unwrap();
+4085
+4086        // Handed off 20 minutes in, so the stream's zero is 1200s.
+4087        controller.enter_background_audio(1200.0);
+4088
+4089        // The viewer scrubs the lockscreen to 25:00 absolute.
+4090        controller.seek_absolute(1490.0).await.unwrap();
+4091
+4092        let url = {
+4093            let queue = controller.queue();
+4094            let queue = queue.lock_safe();
+4095            match &queue.current().unwrap().source {
+4096                MediaSource::Remote { stream_url, .. } => stream_url.clone(),
+4097                other => panic!("expected a remote source, got {:?}", other),
+4098            }
+4099        };
+4100        assert!(
+4101            url.contains(&format!(
+4102                "StartTimeTicks={}",
+4103                (1490.0 * 10_000_000.0) as i64
+4104            )),
+4105            "the stream must be re-opened at the absolute position; got {}",
+4106            url
+4107        );
+4108
+4109        assert_eq!(
+4110            *controller.background_audio_base.lock_safe(),
+4111            1490.0,
+4112            "the re-opened stream's zero is the position it was opened at, or \
+4113             every later reading is off by the difference"
+4114        );
+4115    }
+4116
+4117    /// Outside a handoff there is no base and nothing to re-open: an absolute
+4118    /// seek is just a seek, and must not be turned into a stream rebuild.
+4119    ///
+4120    /// TRACES: UR-005 | DR-159 | UT-155
+4121    #[tokio::test]
+4122    async fn test_seek_outside_a_handoff_is_an_ordinary_seek() {
+4123        let controller = PlayerController::default();
+4124        controller.set_repository(Arc::new(MockEpisodeRepo::season(3)));
+4125        controller
+4126            .play_queue(vec![audio_only_episode(1500.0)], 0)
+4127            .unwrap();
+4128
+4129        controller.seek_absolute(300.0).await.unwrap();
+4130
+4131        assert_eq!(controller.position(), 300.0);
+4132        assert_eq!(
+4133            *controller.background_audio_base.lock_safe(),
+4134            0.0,
+4135            "an ordinary seek must not invent a handoff base"
+4136        );
+4137    }
+4138
+4139    // ===== Position authority and reporting (DR-178, DR-179) =====
+4140    //
+4141    // Every position that leaves the app — the resume point Jellyfin stores, the
+4142    // point the video reloads at on the way back from a handoff, the truncation
+4143    // maths — is read off the controller. The device trace showed all of them
+4144    // reading 0: the native backend is not the player on the webview path, and
+4145    // during a handoff its base is only applied once ExoPlayer has ticked, which
+4146    // it has not while the audio-only transcode is still opening.
+4147
+4148    /// Returning to the foreground before the audio-only stream has started
+4149    /// playing hands back the handoff's own starting point, never zero.
+4150    ///
+4151    /// Observed on device: locked at 18.4s, unlocked 3.5s later with ExoPlayer
+4152    /// still `IDLE`, `player_exit_background_audio` returned `0.0`, and the video
+4153    /// reloaded with `StartTimeTicks=0` — the episode restarted from the
+4154    /// beginning, and the `Stopped` report that followed wiped the server's
+4155    /// resume point too.
+4156    ///
+4157    /// TRACES: UR-040 | DR-178 | UT-176
+4158    #[test]
+4159    fn test_absolute_position_floors_at_the_handoff_base() {
+4160        let controller = PlayerController::default();
+4161        controller.enter_background_audio(18.4);
+4162
+4163        // No tick has landed, so nothing has applied the base yet.
+4164        assert_eq!(controller.position(), 0.0);
+4165        assert_eq!(
+4166            controller.absolute_position(),
+4167            18.4,
+4168            "the audio stream's zero IS the handoff point, so the position can \
+4169             never legitimately read below it"
+4170        );
+4171    }
+4172
+4173    /// Once ticks are flowing the base has already been applied at the native
+4174    /// boundary (DR-159), so flooring must not add it a second time.
+4175    ///
+4176    /// TRACES: UR-040 | DR-178 | UT-176
+4177    #[test]
+4178    fn test_absolute_position_does_not_double_count_the_handoff_base() {
+4179        let controller = PlayerController::default();
+4180        controller.enter_background_audio(18.4);
+4181
+4182        // What the real backend reports after a tick: already absolute.
+4183        controller.seek(120.0).unwrap();
+4184
+4185        assert_eq!(controller.absolute_position(), 120.0);
+4186    }
+4187
+4188    /// On the webview path the `<video>` element is the player and the native
+4189    /// backend holds nothing, so the position it reports is the only one there
+4190    /// is. It used to be re-emitted to the frontend and then dropped, leaving
+4191    /// every backend-side report at 0.
+4192    ///
+4193    /// TRACES: UR-005, UR-025 | DR-178 | UT-177
+4194    #[test]
+4195    fn test_webview_position_reports_become_the_controllers_position() {
+4196        let controller = PlayerController::default();
+4197
+4198        controller.report_html5_position(253.4, 2640.0);
+4199
+4200        assert_eq!(controller.absolute_position(), 253.4);
+4201        assert_eq!(controller.observed_duration(), Some(2640.0));
+4202    }
+4203
+4204    /// A torn-down element's last position must not outlive it: the next thing
+4205    /// to play is loaded into the native backend, and a stale 253s would be
+4206    /// reported against it.
+4207    ///
+4208    /// TRACES: UR-005 | DR-178 | UT-177
+4209    #[test]
+4210    fn test_webview_teardown_clears_the_observed_position() {
+4211        let controller = PlayerController::default();
+4212        controller.report_html5_position(253.4, 2640.0);
+4213
+4214        controller.report_html5_state("stopped".to_string(), None);
+4215
+4216        assert_eq!(controller.absolute_position(), 0.0);
+4217    }
+4218
+4219    /// Entering a handoff tears the element down, so its position stops being
+4220    /// the answer at that exact moment — the native audio player's does.
+4221    ///
+4222    /// TRACES: UR-040 | DR-178 | UT-177
+4223    #[test]
+4224    fn test_entering_a_handoff_drops_the_torn_down_elements_position() {
+4225        let controller = PlayerController::default();
+4226        controller.report_html5_position(253.4, 2640.0);
+4227
+4228        controller.enter_background_audio(18.4);
+4229
+4230        assert_eq!(
+4231            controller.absolute_position(),
+4232            18.4,
+4233            "the video element is gone; only the handoff base describes the \
+4234             stream that is now playing"
+4235        );
+4236    }
+4237
+4238    /// Nobody ever watched zero seconds of anything. A `Stopped` at 0 carries no
+4239    /// information and Jellyfin stores it as the resume point, so the only thing
+4240    /// it can do is destroy one — which is what the device trace caught it doing
+4241    /// 14 times in 35 minutes, including 40s after the frontend had correctly
+4242    /// reported 922s for the same episode.
+4243    ///
+4244    /// TRACES: UR-025 | DR-179 | UT-178
+4245    #[tokio::test]
+4246    async fn test_a_stop_at_zero_is_never_reported() {
+4247        let controller = PlayerController::default();
+4248        let reports = Arc::new(CapturingReports::new());
+4249        controller.set_report_sink(reports.clone());
+4250        controller
+4251            .play_queue(vec![audio_only_episode(1500.0)], 0)
+4252            .unwrap();
+4253
+4254        // Nothing ever played: the backend is at 0 and no element reported in.
+4255        controller.stop().unwrap();
+4256
+4257        assert!(
+4258            reports.stops().is_empty(),
+4259            "a zero-position stop must be withheld, not sent; got {:?}",
+4260            reports.stops()
+4261        );
+4262    }
+4263
+4264    /// A real position is still reported, so withholding zero cannot be
+4265    /// mistaken for withholding everything — from either rendering path.
+4266    ///
+4267    /// The webview half is the one that was broken: the element reports 253s, the
+4268    /// native backend holds nothing, and the stop report went out as 0 and
+4269    /// overwrote the resume point the frontend had just written correctly.
+4270    ///
+4271    /// TRACES: UR-025 | DR-178, DR-179 | UT-178
+4272    #[tokio::test]
+4273    async fn test_a_stop_reports_the_position_actually_reached() {
+4274        // Webview-rendered: the element is the only thing that knows.
+4275        let webview = PlayerController::default();
+4276        let webview_reports = Arc::new(CapturingReports::new());
+4277        webview.set_report_sink(webview_reports.clone());
+4278        webview
+4279            .play_queue(vec![audio_only_episode(1500.0)], 0)
+4280            .unwrap();
+4281        webview.report_html5_position(253.0, 1500.0);
+4282
+4283        webview.stop().unwrap();
+4284
+4285        assert_eq!(webview_reports.stops(), vec![("ep2".to_string(), 253.0)]);
+4286
+4287        // Natively rendered: the backend is authoritative and still is.
+4288        let native = PlayerController::default();
+4289        let native_reports = Arc::new(CapturingReports::new());
+4290        native.set_report_sink(native_reports.clone());
+4291        native
+4292            .play_queue(vec![audio_only_episode(1500.0)], 0)
+4293            .unwrap();
+4294        native.seek(253.0).unwrap();
+4295
+4296        native.stop().unwrap();
+4297
+4298        assert_eq!(native_reports.stops(), vec![("ep2".to_string(), 253.0)]);
+4299    }
+4300
+4301    /// An episode listened to end-to-end on the lockscreen must count as
+4302    /// watched. Jellyfin decides that on the `PlaybackStopped` report — no
+4303    /// report, no completion — and in background audio-only mode there is
+4304    /// nobody else to send one: the webview is suspended and its `<video>` was
+4305    /// torn down at the handoff, so the frontend's end-of-playback reporting
+4306    /// cannot run. The backend advanced to the next episode and said nothing
+4307    /// about the one that finished.
+4308    ///
+4309    /// TRACES: UR-040, UR-025 | DR-179 | UT-179
+4310    #[tokio::test]
+4311    async fn test_a_finished_audio_only_episode_is_reported_complete() {
+4312        let controller = PlayerController::default();
+4313        let reports = Arc::new(CapturingReports::new());
+4314        controller.set_report_sink(reports.clone());
+4315        controller.set_repository(Arc::new(MockEpisodeRepo::season(3)));
+4316        controller
+4317            .play_queue(vec![audio_only_episode(1500.0)], 0)
+4318            .unwrap();
+4319        // Played out to the end of the 25-minute episode.
+4320        controller.seek(1499.0).unwrap();
+4321        controller.take_end_reason();
+4322
+4323        controller.on_playback_ended().await.unwrap();
+4324
+4325        assert_eq!(
+4326            reports.stops(),
+4327            vec![("ep2".to_string(), 1500.0)],
+4328            "the finished episode must be reported stopped at its runtime, or \
+4329             Jellyfin's ≥90% rule never marks it played"
+4330        );
+4331    }
+4332
+4333    /// The completion report is for ends that are really ends. A truncated
+4334    /// stream is about to be re-opened and the episode is nowhere near over, so
+4335    /// reporting it stopped would tell Jellyfin the opposite of the truth.
+4336    ///
+4337    /// TRACES: UR-040, UR-025 | DR-179 | UT-179
+4338    #[tokio::test]
+4339    async fn test_a_truncated_stream_reports_no_completion() {
+4340        let controller = PlayerController::default();
+4341        let reports = Arc::new(CapturingReports::new());
+4342        controller.set_report_sink(reports.clone());
+4343        controller.set_repository(Arc::new(MockEpisodeRepo::season(3)));
+4344        controller
+4345            .play_queue(vec![audio_only_episode(1500.0)], 0)
+4346            .unwrap();
+4347        controller.seek(600.0).unwrap();
+4348        controller.take_end_reason();
+4349
+4350        let decision = controller.on_playback_ended().await.unwrap();
+4351
+4352        assert!(matches!(decision, AutoplayDecision::ResumeStream { .. }));
+4353        assert!(
+4354            reports.stops().is_empty(),
+4355            "a dropped connection is not a finished episode; got {:?}",
+4356            reports.stops()
+4357        );
+4358    }
+4359
+4360    /// Position ticks reach Jellyfin while playback is still going, so closing
+4361    /// the app — or losing it to a crash — cannot cost the whole session. The
+4362    /// device trace requested `/Sessions/Playing/Progress` exactly zero times in
+4363    /// 35 minutes: the frontend service writes progress to the local DB only,
+4364    /// and nothing on the Rust side reported it for webview-rendered media.
+4365    ///
+4366    /// TRACES: UR-005, UR-025 | DR-179 | UT-180
+4367    #[tokio::test]
+4368    async fn test_webview_position_ticks_report_progress_to_the_server() {
+4369        let controller = PlayerController::default();
+4370        let reports = Arc::new(CapturingReports::new());
+4371        controller.set_report_sink(reports.clone());
+4372        controller
+4373            .play_queue(vec![audio_only_episode(1500.0)], 0)
+4374            .unwrap();
+4375
+4376        controller.report_html5_position(253.4, 1500.0);
+4377
+4378        assert_eq!(reports.progress(), vec![("ep2".to_string(), 253.4)]);
+4379    }
+4380
+4381    /// Ticks arrive four times a second; reports must not. The throttler the
+4382    /// controller already owns bounds them to one per item per 30s.
+4383    ///
+4384    /// TRACES: UR-005 | DR-179 | UT-180
+4385    #[tokio::test]
+4386    async fn test_progress_reports_are_throttled_not_sent_per_tick() {
+4387        let controller = PlayerController::default();
+4388        let reports = Arc::new(CapturingReports::new());
+4389        controller.set_report_sink(reports.clone());
+4390        controller
+4391            .play_queue(vec![audio_only_episode(1500.0)], 0)
+4392            .unwrap();
+4393
+4394        for tick in 0..12 {
+4395            controller.report_html5_position(250.0 + tick as f64 * 0.25, 1500.0);
+4396        }
+4397
+4398        assert_eq!(
+4399            reports.progress().len(),
+4400            1,
+4401            "twelve ticks inside one throttle window are one report"
+4402        );
+4403    }
+4404
+4405    /// The truncation check compares the position against the item's runtime, so
+4406    /// both must be on the same timeline.
+4407    ///
+4408    /// They now are by construction: the Android position tick shifts by the
+4409    /// handoff base before anything sees the value, so what the player reports is
+4410    /// already a position on the episode. The base is therefore *not* added here —
+4411    /// doing so would double-count it and make the last minute of a handoff look
+4412    /// like a truncation. What the mock backend holds is what the real one would
+4413    /// report: 24:56 absolute, not 0:56 into the handoff stream. (DR-159)
+4414    #[tokio::test]
+4415    async fn test_truncated_check_uses_the_absolute_position() {
+4416        let controller = PlayerController::default();
+4417        controller.set_repository(Arc::new(MockEpisodeRepo::season(3)));
+4418
+4419        controller
+4420            .play_queue(vec![audio_only_episode(1500.0)], 0)
+4421            .unwrap();
+4422        // Handed off at 24:00; the stream then played its last 56 seconds out, so
+4423        // the player reports 24:56 of the episode.
+4424        controller.set_background_audio_base(1440.0);
+4425        controller.seek(1496.0).unwrap();
+4426        controller.take_end_reason();
+4427
+4428        let decision = controller.on_playback_ended().await.unwrap();
+4429
+4430        assert!(
+4431            matches!(decision, AutoplayDecision::ShowNextEpisodePopup { .. }),
+4432            "24:56 of a 25:00 episode is the real end, not a truncation; got {:?}",
+4433            decision
+4434        );
+4435    }
+4436
+4437    /// The resume re-opens the same URL, so a server that is actually gone would
+4438    /// otherwise end → resume → end forever. After the budget runs out the
+4439    /// decision falls back to normal end-of-item handling.
+4440    #[tokio::test]
+4441    async fn test_repeated_truncation_at_the_same_position_gives_up() {
+4442        let controller = PlayerController::default();
+4443        controller.set_repository(Arc::new(MockEpisodeRepo::season(3)));
+4444
+4445        controller
+4446            .play_queue(vec![audio_only_episode(1500.0)], 0)
+4447            .unwrap();
+4448        controller.seek(600.0).unwrap();
+4449
+4450        for attempt in 1..=stream_end::MAX_STALLED_RESUME_ATTEMPTS {
+4451            controller.take_end_reason();
+4452            let decision = controller.on_playback_ended().await.unwrap();
+4453            assert!(
+4454                matches!(decision, AutoplayDecision::ResumeStream { .. }),
+4455                "attempt {} should still resume, got {:?}",
+4456                attempt,
+4457                decision
+4458            );
+4459        }
+4460
+4461        controller.take_end_reason();
+4462        let decision = controller.on_playback_ended().await.unwrap();
+4463        assert!(
+4464            !matches!(decision, AutoplayDecision::ResumeStream { .. }),
+4465            "a stream stuck at the same position must stop retrying, got {:?}",
+4466            decision
+4467        );
+4468    }
+4469
+4470    /// Ordinary music is not covered: its streams are not the length-less
+4471    /// progressive transcode this guards, and a short track legitimately ends
+4472    /// well before a stale duration would suggest.
+4473    #[tokio::test]
+4474    async fn test_truncation_check_does_not_touch_plain_audio_tracks() {
+4475        let controller = PlayerController::default();
+4476
+4477        let mut items = create_test_items(2);
+4478        items[0].duration = Some(1500.0);
+4479        controller.play_queue(items, 0).unwrap();
+4480        controller.seek(60.0).unwrap();
+4481        controller.take_end_reason();
+4482
+4483        let decision = controller.on_playback_ended().await.unwrap();
+4484        assert!(
+4485            matches!(decision, AutoplayDecision::AdvanceToNext),
+4486            "plain queue audio must keep advancing, got {:?}",
+4487            decision
+4488        );
+4489    }
+4490
+4491    /// Music and video stream from URLs that declare their own length (a static
+4492    /// file with byte ranges, an HLS playlist), so a truncation reaches the
+4493    /// player as an *error* rather than a phantom end. It is the same network
+4494    /// failure, and the same recovery applies — the previous behaviour turned it
+4495    /// into `playerStop()` and silence.
+4496    #[tokio::test]
+4497    async fn test_recoverable_error_resumes_a_music_track() {
+4498        let controller = PlayerController::default();
+4499
+4500        let mut items = create_test_items(3);
+4501        for item in &mut items {
+4502            item.source = MediaSource::Remote {
+4503                stream_url: format!("http://s/Audio/{}/stream?Static=true", item.id),
+4504                jellyfin_item_id: item.id.clone(),
+4505            };
+4506        }
+4507        controller.play_queue(items, 1).unwrap();
+4508        controller.seek(45.0).unwrap();
+4509
+4510        let (position, _) = controller
+4511            .recoverable_error_resume()
+4512            .expect("a streamed music track must be resumable after a network error");
+4513        assert_eq!(position, 45.0);
+4514    }
+4515
+4516    /// The resume must reload the failed track IN PLACE. `play_item` replaces the
+4517    /// whole queue with a single item, so recovering a track that way would throw
+4518    /// away the rest of the album — turning a network blip into lost state.
+4519    #[tokio::test]
+4520    async fn test_resume_keeps_the_rest_of_the_queue() {
+4521        let controller = PlayerController::default();
+4522
+4523        let mut items = create_test_items(3);
+4524        for item in &mut items {
+4525            item.source = MediaSource::Remote {
+4526                stream_url: format!("http://s/Audio/{}/stream?Static=true", item.id),
+4527                jellyfin_item_id: item.id.clone(),
+4528            };
+4529        }
+4530        controller.play_queue(items, 1).unwrap();
+4531
+4532        controller
+4533            .resume_stream_at(45.0)
+4534            .await
+4535            .expect("resume should succeed");
+4536
+4537        let queue = controller.queue.lock_safe();
+4538        assert_eq!(queue.items().len(), 3, "the queue must survive a resume");
+4539        assert_eq!(queue.current_index(), Some(1), "still on the same track");
+4540        assert_eq!(queue.current().unwrap().id, "item_1");
+4541    }
+4542
+4543    /// A seekable stream is re-opened by re-preparing the URL it already has and
+4544    /// seeking — its timeline is intact, and rewriting the URL would restart a
+4545    /// transcode session for no reason.
+4546    #[tokio::test]
+4547    async fn test_resume_seeks_a_seekable_stream_rather_than_rewriting_its_url() {
+4548        let controller = PlayerController::default();
+4549
+4550        let mut items = create_test_items(1);
+4551        items[0].source = MediaSource::Remote {
+4552            stream_url: "http://s/Audio/item_0/stream?Static=true".to_string(),
+4553            jellyfin_item_id: "item_0".to_string(),
+4554        };
+4555        controller.play_queue(items, 0).unwrap();
+4556
+4557        controller.resume_stream_at(45.0).await.unwrap();
+4558
+4559        match &controller.queue.lock_safe().current().unwrap().source {
+4560            MediaSource::Remote { stream_url, .. } => {
+4561                assert_eq!(
+4562                    stream_url, "http://s/Audio/item_0/stream?Static=true",
+4563                    "a seekable stream's URL must be left alone"
+4564                );
+4565            }
+4566            other => panic!("expected Remote source, got {:?}", other),
+4567        }
+4568        assert_eq!(
+4569            controller.position(),
+4570            45.0,
+4571            "and it must land at the position"
+4572        );
+4573    }
+4574
+4575    /// Downloaded media cannot fail from the network, and re-opening a local file
+4576    /// would paper over a real read error.
+4577    #[tokio::test]
+4578    async fn test_recoverable_error_ignores_local_media() {
+4579        let controller = PlayerController::default();
+4580
+4581        let mut items = create_test_items(1);
+4582        items[0].source = MediaSource::Local {
+4583            file_path: "/music/track.flac".into(),
+4584            jellyfin_item_id: Some("item_0".to_string()),
+4585        };
+4586        controller.play_queue(items, 0).unwrap();
+4587        controller.seek(45.0).unwrap();
+4588
+4589        assert!(controller.recoverable_error_resume().is_none());
+4590    }
+4591
+4592    /// A recoverable error during background audio-only playback is the network,
+4593    /// not the media — the previous behaviour (surface it, frontend stops the
+4594    /// player) turned a hiccup into silence. Retrying must also back off, or the
+4595    /// three attempts are spent inside a second and the outage outlives them.
+4596    #[tokio::test]
+4597    async fn test_recoverable_error_during_audio_only_resumes_with_backoff() {
+4598        let controller = PlayerController::default();
+4599
+4600        controller
+4601            .play_queue(vec![audio_only_episode(1500.0)], 0)
+4602            .unwrap();
+4603        controller.seek(600.0).unwrap();
+4604
+4605        let mut waits = Vec::new();
+4606        for attempt in 1..=stream_end::MAX_STALLED_RESUME_ATTEMPTS {
+4607            let (position, delay) = controller
+4608                .recoverable_error_resume()
+4609                .unwrap_or_else(|| panic!("attempt {} should still retry", attempt));
+4610            assert_eq!(position, 600.0);
+4611            waits.push(delay);
+4612        }
+4613        assert_eq!(waits, vec![2, 4, 6], "the wait must grow between attempts");
+4614        assert!(
+4615            controller.recoverable_error_resume().is_none(),
+4616            "a stream that keeps failing at the same spot must surface the error"
+4617        );
+4618    }
+4619
+4620    /// Plugin/channel `DirectUrl` sources are somebody else's endpoint with no
+4621    /// Jellyfin item behind them, so the resume has nothing to re-request.
+4622    #[tokio::test]
+4623    async fn test_recoverable_error_ignores_direct_url_playback() {
+4624        let controller = PlayerController::default();
+4625        controller.play_queue(create_test_items(2), 0).unwrap();
+4626
+4627        assert!(controller.recoverable_error_resume().is_none());
+4628    }
+4629
+4630    /// Re-opening the stream must land where it died and keep playing, with the
+4631    /// handoff base moved to the new stream's zero so returning to the
+4632    /// foreground still resolves an absolute position.
+4633    #[tokio::test]
+4634    async fn test_resume_truncated_stream_reloads_at_position() {
+4635        let controller = PlayerController::default();
+4636
+4637        controller
+4638            .play_queue(vec![audio_only_episode(1500.0)], 0)
+4639            .unwrap();
+4640        controller.set_background_audio_base(0.0);
+4641
+4642        controller
+4643            .resume_stream_at(600.0)
+4644            .await
+4645            .expect("resume should succeed");
+4646
+4647        let current = controller
+4648            .queue
+4649            .lock_safe()
+4650            .current()
+4651            .cloned()
+4652            .expect("the same item should still be loaded");
+4653        assert_eq!(current.id, "ep2", "resume must not change the item");
+4654        match &current.source {
+4655            MediaSource::Remote { stream_url, .. } => {
+4656                assert!(
+4657                    stream_url.contains("StartTimeTicks=6000000000"),
+4658                    "stream must re-open at 600s, got {}",
+4659                    stream_url
+4660                );
+4661                assert!(
+4662                    stream_url.contains("AudioStreamIndex=2"),
+4663                    "the selected audio track must survive the resume, got {}",
+4664                    stream_url
+4665                );
+4666            }
+4667            other => panic!("expected Remote source, got {:?}", other),
+4668        }
+4669        assert_eq!(
+4670            controller.take_background_audio_base(),
+4671            600.0,
+4672            "the re-opened stream's zero is the resume position"
+4673        );
+4674    }
+4675
+4676    /// Foreground video playback keeps the countdown-driven advance: the frontend
+4677    /// owns the navigation there, so the backend must NOT load the next episode
+4678    /// itself (that would race the page transition and double-start playback).
+4679    #[tokio::test]
+4680    async fn test_auto_advance_foreground_video_episode_uses_countdown() {
+4681        let controller = PlayerController::default();
+4682        controller.set_repository(Arc::new(MockEpisodeRepo::season(3)));
+4683
+4684        let episode = MediaItem {
+4685            id: "ep2".to_string(),
+4686            item_type: Some("Episode".to_string()),
+4687            media_type: MediaType::Video,
+4688            series_id: Some("series1".to_string()),
+4689            source: MediaSource::Remote {
+4690                stream_url: "http://example.com/ep2.m3u8".to_string(),
+4691                jellyfin_item_id: "ep2".to_string(),
+4692            },
+4693            ..create_test_items(1).remove(0)
+4694        };
+4695        controller.play_queue(vec![episode], 0).unwrap();
+4696
+4697        let next = make_repo_episode("ep3", 3);
+4698        controller.auto_advance_to_next_episode(next, 10).await;
+4699
+4700        let current = controller
+4701            .queue
+4702            .lock_safe()
+4703            .current()
+4704            .cloned()
+4705            .expect("an item should still be loaded");
+4706        assert_eq!(
+4707            current.id, "ep2",
+4708            "foreground video advance is frontend-driven; the backend must not \
+4709             swap the queue item out from under it"
+4710        );
+4711    }
+4712
+4713    /// Without a controller repository the Android episode path must still
+4714    /// stop gracefully (previous behavior) rather than error.
+4715    #[tokio::test]
+4716    async fn test_playback_ended_without_repository_stops() {
+4717        let controller = PlayerController::default();
+4718
+4719        let episode = MediaItem {
+4720            media_type: MediaType::Video,
+4721            source: MediaSource::Remote {
+4722                stream_url: "http://example.com/ep1.mkv".to_string(),
+4723                jellyfin_item_id: "ep1".to_string(),
+4724            },
+4725            ..create_test_items(1).remove(0)
+4726        };
+4727        controller.play_queue(vec![episode], 0).unwrap();
+4728        controller.take_end_reason();
+4729
+4730        let decision = controller.on_playback_ended().await.unwrap();
+4731        assert!(matches!(decision, AutoplayDecision::Stop));
+4732    }
+4733}
+
\ No newline at end of file diff --git a/api/src/jellytau_lib/player/mpv_backend.rs.html b/api/src/jellytau_lib/player/mpv_backend.rs.html new file mode 100644 index 00000000..97ab9e1c --- /dev/null +++ b/api/src/jellytau_lib/player/mpv_backend.rs.html @@ -0,0 +1,848 @@ +mpv_backend.rs - sourceSkip to main content

jellytau_lib/player/
mpv_backend.rs

1use super::backend::{PlayerBackend, PlayerError};
+2use super::events::{PlayerEventEmitter, PlayerStatusEvent};
+3use super::media::{MediaItem, MediaSource};
+4use super::state::PlayerState;
+5use super::stream_end::ObservedTime;
+6use crate::playback_reporting::{EventThrottler, PlaybackOperation, PlaybackReporter};
+7use crate::settings::{AudioSettings, VolumeLevel, EQ_BANDS};
+8use crate::utils::conversions::{seconds_to_ticks, volume_to_percent};
+9use crate::utils::lock::MutexSafe;
+10use libmpv::Mpv;
+11use log::{debug, error, info, warn};
+12use std::process::Command;
+13use std::sync::atomic::{AtomicU64, Ordering};
+14use std::sync::{Arc, Mutex};
+15use std::time::{Duration, SystemTime, UNIX_EPOCH};
+16use tokio::sync::Mutex as TokioMutex;
+17
+18/// MPV-based player backend for Linux
+19///
+20/// Uses libmpv for audio playback with full control over playback state,
+21/// position tracking, and event handling.
+22pub struct MpvBackend {
+23    mpv: Arc<Mpv>,
+24    state: Arc<Mutex<InternalState>>,
+25    event_emitter: Option<Arc<dyn PlayerEventEmitter>>,
+26    audio_settings: AudioSettings,
+27    playback_reporter: Arc<TokioMutex<Option<PlaybackReporter>>>,
+28    position_throttler: Arc<EventThrottler>,
+29    last_seek_time: Arc<AtomicU64>,
+30    /// Last position/duration seen while a file was loaded.
+31    ///
+32    /// `time-pos` and `duration` are live properties of the *loaded* file: at
+33    /// EOF MPV unloads it and both stop resolving, so reading them straight
+34    /// through reported 0.0 / unknown exactly when end-of-file handling needed to
+35    /// know where playback reached. See [`ObservedTime`].
+36    observed: Arc<Mutex<ObservedTime>>,
+37}
+38
+39struct InternalState {
+40    current_media: Option<MediaItem>,
+41    volume: f32,
+42}
+43
+44/// Detect which audio system is available on the system
+45fn detect_audio_system() -> String {
+46    info!("[MpvBackend] Detecting audio system...");
+47
+48    // Try PulseAudio/PipeWire first (most common on modern Linux)
+49    if let Ok(output) = Command::new("pactl").arg("info").output() {
+50        if output.status.success() {
+51            let stdout = String::from_utf8_lossy(&output.stdout);
+52            if stdout.contains("PipeWire") {
+53                info!("[MpvBackend] Detected PipeWire (with PulseAudio compatibility)");
+54                return "pulse".to_string();
+55            } else if stdout.contains("PulseAudio") {
+56                info!("[MpvBackend] Detected PulseAudio");
+57                return "pulse".to_string();
+58            }
+59        }
+60    }
+61
+62    // Try detecting PipeWire directly
+63    if let Ok(output) = Command::new("pw-cli").arg("info").arg("0").output() {
+64        if output.status.success() {
+65            info!("[MpvBackend] Detected PipeWire");
+66            return "pulse".to_string(); // PipeWire works with pulse driver
+67        }
+68    }
+69
+70    // Check if ALSA is available
+71    if std::path::Path::new("/proc/asound/cards").exists() {
+72        info!("[MpvBackend] Falling back to ALSA");
+73        return "alsa".to_string();
+74    }
+75
+76    // Default fallback
+77    warn!("[MpvBackend] Could not detect audio system, using 'auto'");
+78    "auto".to_string()
+79}
+80
+81/// Helper to get stream URL from MediaItem
+82fn get_stream_url(media: &MediaItem) -> String {
+83    match &media.source {
+84        MediaSource::Remote { stream_url, .. } => stream_url.clone(),
+85        MediaSource::Local { file_path, .. } => {
+86            format!("file://{}", file_path.to_string_lossy())
+87        }
+88        MediaSource::DirectUrl { url } => url.clone(),
+89    }
+90}
+91
+92impl MpvBackend {
+93    /// Create a new MPV backend
+94    pub fn new(
+95        event_emitter: Option<Arc<dyn PlayerEventEmitter>>,
+96        playback_reporter: Arc<TokioMutex<Option<PlaybackReporter>>>,
+97        position_throttler: Arc<EventThrottler>,
+98    ) -> Result<Self, PlayerError> {
+99        info!("[MpvBackend] Initializing MPV backend...");
+100
+101        // MPV requires LC_NUMERIC to be set to "C" locale
+102        // Set it before initializing MPV, then restore it after
+103        use std::ffi::CString;
+104        unsafe {
+105            let c_locale = CString::new("C").unwrap();
+106            libc::setlocale(libc::LC_NUMERIC, c_locale.as_ptr());
+107        }
+108
+109        let mpv = Mpv::new().map_err(|e| PlayerError {
+110            message: format!("Failed to initialize MPV: {:?}", e),
+111        })?;
+112
+113        // Detect and configure audio output
+114        let audio_driver = detect_audio_system();
+115        info!(
+116            "[MpvBackend] Configuring audio output driver: {}",
+117            audio_driver
+118        );
+119
+120        mpv.set_property("ao", audio_driver.as_str())
+121            .map_err(|e| PlayerError {
+122                message: format!(
+123                    "Failed to set audio output to '{}': {:?}. Make sure audio system is working.",
+124                    audio_driver, e
+125                ),
+126            })?;
+127
+128        // Enable verbose logging for audio initialization
+129        mpv.set_property("msg-level", "all=warn,ao=debug")
+130            .unwrap_or_else(|e| {
+131                warn!("[MpvBackend] Warning: Could not set MPV log level: {:?}", e);
+132            });
+133
+134        // Configure MPV for audio playback
+135        mpv.set_property("audio-display", "no")
+136            .map_err(|e| PlayerError {
+137                message: format!("Failed to configure MPV audio-display: {:?}", e),
+138            })?;
+139
+140        mpv.set_property("video", "no").map_err(|e| PlayerError {
+141            message: format!("Failed to configure MPV video: {:?}", e),
+142        })?;
+143
+144        // Set volume to 100% (we'll control via MPV's volume property)
+145        mpv.set_property("volume", 100i64)
+146            .map_err(|e| PlayerError {
+147                message: format!("Failed to set initial volume: {:?}", e),
+148            })?;
+149
+150        // Survive a flaky connection instead of dying on it. Without these,
+151        // ffmpeg's HTTP demuxer gives up the moment a read fails and MPV raises
+152        // EndFile(ERROR) — a blip on wifi kills the track outright. Reconnecting
+153        // in the demuxer handles the common case entirely below our level, so
+154        // most outages never reach the recovery in `player_recover_stream`.
+155        //
+156        // Non-fatal: these are ffmpeg-side options whose availability varies with
+157        // the libmpv/ffmpeg build, and losing resilience is not a reason to
+158        // refuse to play anything (graceful backend init, CLAUDE.md).
+159        mpv.set_property(
+160            "stream-lavf-o",
+161            "reconnect=1,reconnect_streamed=1,reconnect_on_network_error=1,reconnect_delay_max=5",
+162        )
+163        .unwrap_or_else(|e| {
+164            warn!(
+165                "[MpvBackend] Could not enable stream reconnection: {:?} — \
+166                 playback will not survive network interruptions",
+167                e
+168            );
+169        });
+170        mpv.set_property("network-timeout", 15i64)
+171            .unwrap_or_else(|e| {
+172                warn!("[MpvBackend] Could not set network timeout: {:?}", e);
+173            });
+174
+175        let state = Arc::new(Mutex::new(InternalState {
+176            current_media: None,
+177            volume: 1.0,
+178        }));
+179
+180        let backend = MpvBackend {
+181            mpv: Arc::new(mpv),
+182            state,
+183            event_emitter,
+184            audio_settings: AudioSettings::default(),
+185            playback_reporter,
+186            position_throttler,
+187            last_seek_time: Arc::new(AtomicU64::new(0)),
+188            observed: Arc::new(Mutex::new(ObservedTime::default())),
+189        };
+190
+191        // Start event loop in background thread
+192        backend.start_event_loop();
+193
+194        info!("[MpvBackend] Initialized successfully");
+195        Ok(backend)
+196    }
+197
+198    /// Start the MPV event loop in a background thread
+199    fn start_event_loop(&self) {
+200        let mpv = self.mpv.clone();
+201        let event_emitter = self.event_emitter.clone();
+202        let state = self.state.clone();
+203        let reporter = self.playback_reporter.clone();
+204        let throttler = self.position_throttler.clone();
+205
+206        std::thread::spawn(move || {
+207            info!("[MpvBackend] Event loop started");
+208
+209            let mut ev_ctx = mpv.create_event_context();
+210            ev_ctx.disable_deprecated_events().unwrap_or_else(|e| {
+211                error!("[MpvBackend] Failed to disable deprecated events: {:?}", e);
+212            });
+213
+214            loop {
+215                match ev_ctx.wait_event(1.0) {
+216                    Some(Ok(event)) => match event {
+217                        libmpv::events::Event::StartFile => {
+218                            debug!("[MpvBackend] Starting file");
+219                        }
+220                        libmpv::events::Event::FileLoaded => {
+221                            info!("[MpvBackend] File loaded");
+222
+223                            // Get duration
+224                            if let Ok(duration) = mpv.get_property::<f64>("duration") {
+225                                if let Some(emitter) = &event_emitter {
+226                                    emitter.emit(PlayerStatusEvent::MediaLoaded { duration });
+227                                }
+228                            }
+229                        }
+230                        libmpv::events::Event::PlaybackRestart => {
+231                            debug!("[MpvBackend] Playback started/resumed");
+232
+233                            let media_id = state
+234                                .lock_safe()
+235                                .current_media
+236                                .as_ref()
+237                                .map(|m| m.id.clone());
+238
+239                            if let Some(emitter) = &event_emitter {
+240                                emitter.emit(PlayerStatusEvent::StateChanged {
+241                                    state: "playing".to_string(),
+242                                    media_id,
+243                                });
+244                            }
+245                        }
+246                        libmpv::events::Event::PropertyChange { name: "pause", .. } => {
+247                            // Handle pause state changes
+248                            if let Ok(is_paused) = mpv.get_property::<bool>("pause") {
+249                                let media_id = state
+250                                    .lock_safe()
+251                                    .current_media
+252                                    .as_ref()
+253                                    .map(|m| m.id.clone());
+254
+255                                if let Some(emitter) = &event_emitter {
+256                                    emitter.emit(PlayerStatusEvent::StateChanged {
+257                                        state: if is_paused { "paused" } else { "playing" }
+258                                            .to_string(),
+259                                        media_id,
+260                                    });
+261                                }
+262                            }
+263                        }
+264                        libmpv::events::Event::EndFile(reason) => {
+265                            debug!("[MpvBackend] End file with reason: {}", reason);
+266
+267                            // Only emit PlaybackEnded for natural track completion (EOF = 0)
+268                            // Don't emit for Stop (2), Quit (3), Error (4), or other reasons
+269                            // Constants from MPV_END_FILE_REASON enum: EOF=0, STOP=2, QUIT=3, ERROR=4
+270                            const MPV_END_FILE_REASON_EOF: u32 = 0;
+271                            const MPV_END_FILE_REASON_STOP: u32 = 2;
+272                            const MPV_END_FILE_REASON_QUIT: u32 = 3;
+273                            const MPV_END_FILE_REASON_ERROR: u32 = 4;
+274
+275                            if reason == MPV_END_FILE_REASON_EOF {
+276                                debug!("[MpvBackend] Track finished naturally (EOF), emitting PlaybackEnded");
+277                                if let Some(emitter) = &event_emitter {
+278                                    emitter.emit(PlayerStatusEvent::PlaybackEnded);
+279                                }
+280                            } else if reason == MPV_END_FILE_REASON_STOP {
+281                                debug!("[MpvBackend] Track stopped (loading new track), NOT emitting PlaybackEnded");
+282                                // Don't emit - user is loading a new track
+283                            } else if reason == MPV_END_FILE_REASON_QUIT {
+284                                debug!("[MpvBackend] Player quitting, NOT emitting PlaybackEnded");
+285                                // Don't emit - player is shutting down
+286                            } else if reason == MPV_END_FILE_REASON_ERROR {
+287                                // NOT PlaybackEnded — the track did not finish, so
+288                                // autoplay must not advance. It is an error, and it
+289                                // has to be *said*: emitting nothing here left
+290                                // playback halted with the UI still showing
+291                                // "playing" and no way back. Marked recoverable so
+292                                // the frontend echoes it into player_recover_stream,
+293                                // which re-opens the stream where it stopped —
+294                                // MPV's own reconnect handles shorter blips before
+295                                // they ever get this far.
+296                                warn!("[MpvBackend] Track ended with an error — reporting as recoverable");
+297                                if let Some(emitter) = &event_emitter {
+298                                    emitter.emit(PlayerStatusEvent::Error {
+299                                        message: "Playback stream failed".to_string(),
+300                                        recoverable: true,
+301                                    });
+302                                }
+303                            } else {
+304                                debug!("[MpvBackend] Unknown end file reason {}, NOT emitting PlaybackEnded", reason);
+305                            }
+306                        }
+307                        libmpv::events::Event::Shutdown => {
+308                            info!("[MpvBackend] Shutdown event received");
+309                            break;
+310                        }
+311                        _ => {}
+312                    },
+313                    Some(Err(e)) => {
+314                        error!("[MpvBackend] Event error: {:?}", e);
+315                    }
+316                    None => {
+317                        // Timeout, continue
+318                    }
+319                }
+320
+321                std::thread::sleep(Duration::from_millis(10));
+322            }
+323
+324            info!("[MpvBackend] Event loop ended");
+325        });
+326
+327        // Start position update thread
+328        let mpv_for_position = self.mpv.clone();
+329        let emitter_for_position = self.event_emitter.clone();
+330        let state_for_position = self.state.clone();
+331        let reporter_for_position = reporter.clone();
+332        let throttler_for_position = throttler.clone();
+333        let last_seek_time_for_position = self.last_seek_time.clone();
+334        let observed_for_position = self.observed.clone();
+335
+336        std::thread::spawn(move || {
+337            loop {
+338                std::thread::sleep(Duration::from_millis(250));
+339
+340                // Get current position and duration
+341                // Note: We emit position updates even when paused so scrubbing works
+342                if let (Ok(pos), Ok(dur)) = (
+343                    mpv_for_position.get_property::<f64>("time-pos"),
+344                    mpv_for_position.get_property::<f64>("duration"),
+345                ) {
+346                    // Remember it: both properties belong to the *loaded* file and
+347                    // stop resolving the instant MPV unloads it at EOF, which is
+348                    // exactly when end-of-file handling asks where playback got to.
+349                    // Recorded before the post-seek skip below so a track that ends
+350                    // right after a seek still reports the seek target, not zero.
+351                    observed_for_position.lock_safe().record(pos, dur);
+352
+353                    // Check if we recently seeked - skip position updates briefly after seeks
+354                    // to avoid "jumping to zero" visual glitches while MPV is seeking
+355                    let now = SystemTime::now()
+356                        .duration_since(UNIX_EPOCH)
+357                        .unwrap()
+358                        .as_millis() as u64;
+359                    let last_seek = last_seek_time_for_position.load(Ordering::Relaxed);
+360                    let time_since_seek = now.saturating_sub(last_seek);
+361
+362                    // Skip position updates for 150ms after a seek to let MPV stabilize
+363                    if time_since_seek < 150 {
+364                        continue;
+365                    }
+366
+367                    // Emit position update event (even when paused, for scrubbing)
+368                    if let Some(emitter) = &emitter_for_position {
+369                        emitter.emit(PlayerStatusEvent::PositionUpdate {
+370                            position: pos,
+371                            duration: dur,
+372                        });
+373                    }
+374
+375                    // Check if we're playing for progress reporting
+376                    let is_paused = mpv_for_position
+377                        .get_property::<bool>("pause")
+378                        .unwrap_or(true);
+379
+380                    // Only report progress to server when playing (not paused)
+381                    if !is_paused {
+382                        // Throttled progress reporting (every 30s)
+383                        let jellyfin_id = {
+384                            let state = state_for_position.lock_safe();
+385                            state
+386                                .current_media
+387                                .as_ref()
+388                                .and_then(|m| m.jellyfin_id().map(|s| s.to_string()))
+389                        };
+390
+391                        if let Some(item_id) = jellyfin_id {
+392                            if throttler_for_position.should_report(&item_id) {
+393                                let position_ticks = seconds_to_ticks(pos);
+394                                let reporter_clone = reporter_for_position.clone();
+395                                let item_id_clone = item_id.clone();
+396
+397                                // Spawn async task to report progress
+398                                // Check if we're in a Tokio runtime, otherwise spawn a new thread with its own runtime
+399                                if let Ok(handle) = tokio::runtime::Handle::try_current() {
+400                                    handle.spawn(async move {
+401                                        let reporter_guard = reporter_clone.lock().await;
+402                                        if let Some(reporter_instance) = reporter_guard.as_ref() {
+403                                            let operation = PlaybackOperation::Progress {
+404                                                item_id: item_id_clone.clone(),
+405                                                position_ticks,
+406                                                is_paused: false,
+407                                            };
+408
+409                                            match reporter_instance.report(operation, true).await {
+410                                                Ok(_) => debug!(
+411                                                    "[MpvBackend] Reported progress for {}",
+412                                                    item_id_clone
+413                                                ),
+414                                                Err(e) => warn!(
+415                                                    "[MpvBackend] Failed to report progress: {}",
+416                                                    e
+417                                                ),
+418                                            }
+419                                        }
+420                                    });
+421                                } else {
+422                                    // Fallback: spawn in a new thread with its own runtime
+423                                    std::thread::spawn(move || {
+424                                        let rt = tokio::runtime::Runtime::new().unwrap();
+425                                        rt.block_on(async move {
+426                                            let reporter_guard = reporter_clone.lock().await;
+427                                            if let Some(reporter_instance) = reporter_guard.as_ref() {
+428                                                let operation = PlaybackOperation::Progress {
+429                                                    item_id: item_id_clone.clone(),
+430                                                    position_ticks,
+431                                                    is_paused: false,
+432                                                };
+433
+434                                                match reporter_instance.report(operation, true).await {
+435                                                    Ok(_) => debug!("[MpvBackend] Reported progress for {}", item_id_clone),
+436                                                    Err(e) => warn!("[MpvBackend] Failed to report progress: {}", e),
+437                                                }
+438                                            }
+439                                        });
+440                                    });
+441                                }
+442
+443                                throttler_for_position.mark_reported(&item_id);
+444                            }
+445                        }
+446                    }
+447                }
+448            }
+449        });
+450    }
+451}
+452
+453impl PlayerBackend for MpvBackend {
+454    fn load(&mut self, media: &MediaItem) -> Result<(), PlayerError> {
+455        let stream_url = get_stream_url(media);
+456        info!("[MpvBackend] Loading: {} - {}", media.title, stream_url);
+457
+458        // Update state
+459        {
+460            let mut state = self.state.lock_safe();
+461            state.current_media = Some(media.clone());
+462        }
+463        // A different file: the previous one's timestamp must not survive as this
+464        // one's "last observed" position.
+465        self.observed.lock_safe().reset();
+466
+467        // Load the media file
+468        self.mpv
+469            .command("loadfile", &[&stream_url])
+470            .map_err(|e| PlayerError {
+471                message: format!("Failed to load file: {:?}", e),
+472            })?;
+473
+474        debug!("[MpvBackend] Load command sent successfully");
+475        Ok(())
+476    }
+477
+478    fn play(&mut self) -> Result<(), PlayerError> {
+479        debug!("[MpvBackend] Play command");
+480
+481        self.mpv
+482            .set_property("pause", false)
+483            .map_err(|e| PlayerError {
+484                message: format!("Failed to play: {:?}", e),
+485            })?;
+486
+487        Ok(())
+488    }
+489
+490    fn pause(&mut self) -> Result<(), PlayerError> {
+491        debug!("[MpvBackend] Pause command");
+492
+493        self.mpv
+494            .set_property("pause", true)
+495            .map_err(|e| PlayerError {
+496                message: format!("Failed to pause: {:?}", e),
+497            })?;
+498
+499        Ok(())
+500    }
+501
+502    fn stop(&mut self) -> Result<(), PlayerError> {
+503        debug!("[MpvBackend] Stop command");
+504
+505        self.mpv.command("stop", &[]).map_err(|e| PlayerError {
+506            message: format!("Failed to stop: {:?}", e),
+507        })?;
+508
+509        let mut state = self.state.lock_safe();
+510        state.current_media = None;
+511
+512        Ok(())
+513    }
+514
+515    fn seek(&mut self, position: f64) -> Result<(), PlayerError> {
+516        debug!("[MpvBackend] Seek to {} seconds", position);
+517
+518        // Record the seek time to suppress position updates briefly
+519        let now = SystemTime::now()
+520            .duration_since(UNIX_EPOCH)
+521            .unwrap()
+522            .as_millis() as u64;
+523        self.last_seek_time.store(now, Ordering::Relaxed);
+524
+525        self.mpv
+526            .set_property("time-pos", position)
+527            .map_err(|e| PlayerError {
+528                message: format!("Failed to seek: {:?}", e),
+529            })?;
+530
+531        // The poll thread suppresses updates for 150ms after a seek, so without
+532        // this a file ending inside that window would report the pre-seek time.
+533        self.observed.lock_safe().record_position(position);
+534
+535        Ok(())
+536    }
+537
+538    fn set_volume(&mut self, volume: f32) -> Result<(), PlayerError> {
+539        let clamped = volume.clamp(0.0, 1.0);
+540        debug!("[MpvBackend] Set volume to {}", clamped);
+541
+542        // MPV expects volume as percentage (0-100)
+543        let mpv_volume = volume_to_percent(clamped as f64) as i64;
+544
+545        self.mpv
+546            .set_property("volume", mpv_volume)
+547            .map_err(|e| PlayerError {
+548                message: format!("Failed to set volume: {:?}", e),
+549            })?;
+550
+551        let mut state = self.state.lock_safe();
+552        state.volume = clamped;
+553
+554        Ok(())
+555    }
+556
+557    /// Current position — the live `time-pos`, or the last one observed while a
+558    /// file was loaded.
+559    ///
+560    /// The fallback is the point: `time-pos` is a property of the *loaded* file,
+561    /// so at EOF it stops resolving and a bare `unwrap_or(0.0)` reported 0:00 at
+562    /// exactly the moment end-of-file handling asks where playback reached.
+563    ///
+564    /// TRACES: UR-005 | DR-130 | UT-121
+565    fn position(&self) -> f64 {
+566        let live = self.mpv.get_property::<f64>("time-pos").ok();
+567        self.observed.lock_safe().position_or_last(live)
+568    }
+569
+570    /// Total duration — live, or the last one observed. Unloaded at EOF for the
+571    /// same reason as `position`.
+572    ///
+573    /// TRACES: UR-005 | DR-130 | UT-121
+574    fn duration(&self) -> Option<f64> {
+575        let live = self.mpv.get_property::<f64>("duration").ok();
+576        self.observed.lock_safe().duration_or_last(live)
+577    }
+578
+579    fn state(&self) -> PlayerState {
+580        let state = self.state.lock_safe();
+581
+582        if let Some(ref media) = state.current_media {
+583            let is_paused = self.mpv.get_property::<bool>("pause").unwrap_or(true);
+584            let position = self.position();
+585            let duration = self.duration().unwrap_or(0.0);
+586
+587            if is_paused {
+588                PlayerState::Paused {
+589                    media: media.clone(),
+590                    position,
+591                    duration,
+592                }
+593            } else {
+594                PlayerState::Playing {
+595                    media: media.clone(),
+596                    position,
+597                    duration,
+598                }
+599            }
+600        } else {
+601            PlayerState::Idle
+602        }
+603    }
+604
+605    fn volume(&self) -> f32 {
+606        let state = self.state.lock_safe();
+607        state.volume
+608    }
+609
+610    fn set_audio_settings(&mut self, settings: &AudioSettings) -> Result<(), PlayerError> {
+611        info!("[MpvBackend] Applying audio settings");
+612        self.audio_settings = settings.clone();
+613
+614        // Apply gapless playback
+615        if settings.gapless_playback {
+616            self.mpv
+617                .set_property("gapless-audio", "yes")
+618                .map_err(|e| PlayerError {
+619                    message: format!("Failed to enable gapless: {:?}", e),
+620                })?;
+621        } else {
+622            self.mpv
+623                .set_property("gapless-audio", "no")
+624                .map_err(|e| PlayerError {
+625                    message: format!("Failed to disable gapless: {:?}", e),
+626                })?;
+627        }
+628
+629        // Audio filter chain: build a single lavfi graph combining the EQ
+630        // peaking bands and (optionally) a dynamic loudness normalizer, and
+631        // set the `af` property. An empty string clears all filters. Both
+632        // features share one `af` graph because MPV exposes a single filter
+633        // property. See docs/architecture/05-platform-backends.md and IR-020.
+634        let af = build_af_filter(settings);
+635        self.mpv
+636            .set_property("af", af.as_str())
+637            .map_err(|e| PlayerError {
+638                message: format!("Failed to set audio filters: {:?}", e),
+639            })?;
+640
+641        // TODO: Implement crossfade via MPV audio filters if needed
+642
+643        Ok(())
+644    }
+645
+646    fn audio_settings(&self) -> AudioSettings {
+647        self.audio_settings.clone()
+648    }
+649}
+650
+651/// Build the full MPV `af` (audio filter) value from the audio settings.
+652///
+653/// Combines the equalizer peaking bands and the loudness-normalization filter
+654/// into a single `lavfi` graph, because MPV exposes one `af` property. The
+655/// normalizer runs *after* the EQ so it levels the post-EQ signal. Returns an
+656/// empty string when neither feature contributes a filter, which clears `af`.
+657///
+658/// TRACES: UR-027, UR-033 | IR-020, DR-036
+659fn build_af_filter(settings: &AudioSettings) -> String {
+660    let mut entries = eq_filter_entries(settings.equalizer_enabled, &settings.equalizer_bands);
+661    if let Some(norm) = normalize_filter_entry(settings.normalize_volume, settings.volume_level) {
+662        entries.push(norm);
+663    }
+664
+665    if entries.is_empty() {
+666        return String::new();
+667    }
+668    format!("lavfi=[{}]", entries.join(","))
+669}
+670
+671/// Peaking-EQ filter entries (unwrapped), one ffmpeg `equalizer` (two-pole
+672/// peaking) per band with a non-zero gain, e.g.
+673/// `equalizer=f=31:width_type=o:width=1:g=5`. Returns an empty vec when the EQ
+674/// is disabled or every gain is ~0. Gains are assumed already normalised by
+675/// [`AudioSettings::with_equalizer_normalised`]; bands beyond [`EQ_BANDS`] are
+676/// ignored.
+677///
+678/// TRACES: UR-027 | IR-020
+679fn eq_filter_entries(enabled: bool, bands: &[f32]) -> Vec<String> {
+680    if !enabled {
+681        return Vec::new();
+682    }
+683    bands
+684        .iter()
+685        .zip(EQ_BANDS.iter())
+686        .filter(|(gain, _)| gain.abs() >= 0.05) // skip ~0 dB bands
+687        .map(|(gain, freq)| {
+688            // width_type=o → octave bandwidth; width=1 → one octave per band.
+689            format!("equalizer=f={}:width_type=o:width=1:g={}", freq, gain)
+690        })
+691        .collect()
+692}
+693
+694/// Reference peak (`dynaudnorm` `p`, linear amplitude) for the default
+695/// [`VolumeLevel::Normal`] (−14 LUFS) target, leaving −1.2 dB of headroom.
+696const NORMALIZE_REF_PEAK: f32 = 0.87;
+697/// Reference loudness the peak table is anchored at (Normal preset, −14 LUFS).
+698const NORMALIZE_REF_LUFS: f32 = -14.0;
+699
+700/// The loudness-normalization filter entry (unwrapped), or `None` when
+701/// normalization is disabled. Uses ffmpeg's `dynaudnorm`, a gentle real-time
+702/// dynamic normalizer that avoids the gain "pumping" `loudnorm`'s single-pass
+703/// mode can produce on very dynamic material.
+704///
+705/// `dynaudnorm` targets a peak amplitude (`p`, linear 0–1), not a LUFS value,
+706/// so the Loud/Normal/Quiet presets become *approximate*: each preset's LUFS
+707/// offset from the Normal reference is applied as a dB offset to the reference
+708/// peak, preserving the Loud > Normal > Quiet ordering. `g=15` (gaussian window
+709/// size) further smooths gain changes; the peak is clamped to a safe (0, 0.99]
+710/// so loud presets never request full-scale.
+711///
+712/// TRACES: UR-033 | DR-036
+713fn normalize_filter_entry(enabled: bool, level: VolumeLevel) -> Option<String> {
+714    if !enabled {
+715        return None;
+716    }
+717    // LUFS above the reference → louder → higher peak; each +1 LUFS ≈ +1 dB.
+718    let db_offset = level.target_lufs() - NORMALIZE_REF_LUFS;
+719    let peak = (NORMALIZE_REF_PEAK * 10f32.powf(db_offset / 20.0)).clamp(0.10, 0.99);
+720    // 3 decimals is plenty for a peak target and keeps the filter string stable.
+721    Some(format!("dynaudnorm=p={:.3}:g=15", peak))
+722}
+723
+724impl Drop for MpvBackend {
+725    fn drop(&mut self) {
+726        info!("[MpvBackend] Shutting down");
+727        // MPV will be automatically cleaned up
+728    }
+729}
+730
+731#[cfg(test)]
+732mod af_filter_tests {
+733    use super::{build_af_filter, eq_filter_entries, normalize_filter_entry};
+734    use crate::settings::{AudioSettings, VolumeLevel};
+735
+736    fn settings() -> AudioSettings {
+737        AudioSettings {
+738            equalizer_enabled: false,
+739            equalizer_bands: vec![0.0; 10],
+740            normalize_volume: false,
+741            ..AudioSettings::default()
+742        }
+743    }
+744
+745    /// Disabled EQ, or an all-zero curve, produces no EQ entries.
+746    ///
+747    /// TRACES: UR-027 | IR-020 | UT-083
+748    #[test]
+749    fn test_eq_entries_empty_when_disabled_or_flat() {
+750        assert!(eq_filter_entries(false, &[5.0, -3.0, 2.0]).is_empty());
+751        assert!(eq_filter_entries(true, &[0.0; 10]).is_empty());
+752        // Sub-threshold gains count as flat.
+753        assert!(eq_filter_entries(true, &[0.01, -0.02]).is_empty());
+754    }
+755
+756    /// Enabled EQ builds one peaking `equalizer` per non-zero band at the right
+757    /// centre frequency and gain, chained inside a single `lavfi` filter.
+758    ///
+759    /// TRACES: UR-027 | IR-020 | UT-084
+760    #[test]
+761    fn test_eq_filter_builds_lavfi_chain() {
+762        // First band (31 Hz) +5 dB, third band (125 Hz) -2 dB, rest flat.
+763        let mut s = settings();
+764        s.equalizer_enabled = true;
+765        s.equalizer_bands = vec![5.0, 0.0, -2.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0];
+766        let af = build_af_filter(&s);
+767        assert!(af.starts_with("lavfi=["), "wrapped in lavfi: {af}");
+768        assert!(af.ends_with("]"));
+769        assert!(af.contains("equalizer=f=31:width_type=o:width=1:g=5"));
+770        assert!(af.contains("equalizer=f=125:width_type=o:width=1:g=-2"));
+771        // Only two bands are non-zero → exactly two peaking filters.
+772        assert_eq!(af.matches("equalizer=").count(), 2);
+773    }
+774
+775    /// Disabled normalization yields no filter entry; the combined `af` for a
+776    /// fully default (all-off) settings is empty, which clears `af`.
+777    ///
+778    /// TRACES: UR-033 | DR-036 | UT-085
+779    #[test]
+780    fn test_normalize_disabled_produces_no_filter() {
+781        assert!(normalize_filter_entry(false, VolumeLevel::Normal).is_none());
+782        assert_eq!(build_af_filter(&settings()), "");
+783    }
+784
+785    /// Enabled normalization emits a `dynaudnorm` filter with a peak target, and
+786    /// the peak preserves the Loud > Normal > Quiet ordering.
+787    ///
+788    /// TRACES: UR-033 | DR-036 | UT-086
+789    #[test]
+790    fn test_normalize_peak_preserves_preset_ordering() {
+791        fn peak_of(entry: &str) -> f32 {
+792            // "dynaudnorm=p=0.870:g=15" → 0.870
+793            entry
+794                .split("p=")
+795                .nth(1)
+796                .and_then(|s| s.split(':').next())
+797                .and_then(|s| s.parse().ok())
+798                .expect("parseable peak")
+799        }
+800
+801        let loud = normalize_filter_entry(true, VolumeLevel::Loud).unwrap();
+802        let normal = normalize_filter_entry(true, VolumeLevel::Normal).unwrap();
+803        let quiet = normalize_filter_entry(true, VolumeLevel::Quiet).unwrap();
+804        for entry in [&loud, &normal, &quiet] {
+805            assert!(
+806                entry.starts_with("dynaudnorm="),
+807                "dynaudnorm filter: {entry}"
+808            );
+809        }
+810        assert!(
+811            peak_of(&loud) > peak_of(&normal) && peak_of(&normal) > peak_of(&quiet),
+812            "Loud {} > Normal {} > Quiet {}",
+813            peak_of(&loud),
+814            peak_of(&normal),
+815            peak_of(&quiet),
+816        );
+817        // Every preset stays within the safe (0, 0.99] clamp.
+818        for p in [peak_of(&loud), peak_of(&normal), peak_of(&quiet)] {
+819            assert!(p > 0.0 && p <= 0.99, "peak in range: {p}");
+820        }
+821
+822        let mut s = settings();
+823        s.normalize_volume = true;
+824        s.volume_level = VolumeLevel::Quiet;
+825        let af = build_af_filter(&s);
+826        assert!(af.starts_with("lavfi=["));
+827        assert!(af.contains("dynaudnorm=p="));
+828    }
+829
+830    /// EQ and normalization coexist in one `lavfi` graph, with the normalizer
+831    /// placed after the EQ bands so it levels the post-EQ signal.
+832    ///
+833    /// TRACES: UR-027, UR-033 | IR-020, DR-036 | UT-087
+834    #[test]
+835    fn test_eq_and_normalize_combine_in_order() {
+836        let mut s = settings();
+837        s.equalizer_enabled = true;
+838        s.equalizer_bands = vec![5.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0];
+839        s.normalize_volume = true;
+840        s.volume_level = VolumeLevel::Normal;
+841        let af = build_af_filter(&s);
+842
+843        let eq_pos = af.find("equalizer=").expect("has EQ");
+844        let norm_pos = af.find("dynaudnorm=").expect("has normalizer");
+845        assert!(eq_pos < norm_pos, "normalizer runs after EQ: {af}");
+846    }
+847}
+
\ No newline at end of file diff --git a/api/src/jellytau_lib/player/queue.rs.html b/api/src/jellytau_lib/player/queue.rs.html new file mode 100644 index 00000000..112e791e --- /dev/null +++ b/api/src/jellytau_lib/player/queue.rs.html @@ -0,0 +1,1017 @@ +queue.rs - sourceSkip to main content

jellytau_lib/player/
queue.rs

1use rand::seq::SliceRandom;
+2use serde::{Deserialize, Serialize};
+3
+4use super::media::{MediaItem, MediaSource, QueueContext};
+5
+6/// Repeat mode for the queue
+7///
+8/// TRACES: UR-005 | DR-005
+9#[derive(specta::Type, Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
+10#[serde(rename_all = "lowercase")]
+11pub enum RepeatMode {
+12    #[default]
+13    Off,
+14    All,
+15    One,
+16}
+17
+18/// Queue manager for playlist functionality
+19///
+20/// TRACES: UR-005, UR-015 | DR-005, DR-020
+21#[derive(specta::Type, Debug, Clone, Serialize, Deserialize)]
+22pub struct QueueManager {
+23    /// All items in the queue
+24    items: Vec<MediaItem>,
+25    /// Current item index
+26    current_index: Option<usize>,
+27    /// Whether shuffle is enabled
+28    shuffle: bool,
+29    /// Current repeat mode
+30    repeat: RepeatMode,
+31    /// Shuffled order of indices (used when shuffle is on)
+32    shuffle_order: Vec<usize>,
+33    /// History of played indices (for going back with shuffle)
+34    history: Vec<usize>,
+35    /// Context for the queue (album, playlist, or custom)
+36    /// Used for remote playback transfer to maintain album/playlist context
+37    #[serde(default)]
+38    context: QueueContext,
+39}
+40
+41impl Default for QueueManager {
+42    fn default() -> Self {
+43        Self::new()
+44    }
+45}
+46
+47impl QueueManager {
+48    pub fn new() -> Self {
+49        Self {
+50            items: Vec::new(),
+51            current_index: None,
+52            shuffle: false,
+53            repeat: RepeatMode::Off,
+54            shuffle_order: Vec::new(),
+55            history: Vec::new(),
+56            context: QueueContext::Custom,
+57        }
+58    }
+59
+60    /// Get all items in the queue
+61    pub fn items(&self) -> &[MediaItem] {
+62        &self.items
+63    }
+64
+65    /// Mutable access to queue items.
+66    ///
+67    /// Used to re-point streaming entries at a completed local download
+68    /// without disturbing queue order, shuffle state, or the current index.
+69    pub fn items_mut(&mut self) -> &mut [MediaItem] {
+70        &mut self.items
+71    }
+72
+73    /// Get the current item index
+74    pub fn current_index(&self) -> Option<usize> {
+75        self.current_index
+76    }
+77
+78    /// Get the current item
+79    pub fn current(&self) -> Option<&MediaItem> {
+80        self.current_index.and_then(|i| self.items.get(i))
+81    }
+82
+83    /// Check if shuffle is enabled
+84    pub fn is_shuffle(&self) -> bool {
+85        self.shuffle
+86    }
+87
+88    /// Get the current repeat mode
+89    pub fn repeat_mode(&self) -> RepeatMode {
+90        self.repeat
+91    }
+92
+93    /// Get the current queue context (album, playlist, or custom)
+94    pub fn context(&self) -> &QueueContext {
+95        &self.context
+96    }
+97
+98    /// Set the queue context
+99    pub fn set_context(&mut self, context: QueueContext) {
+100        self.context = context;
+101    }
+102
+103    /// Set the queue with new items (resets context to Custom)
+104    pub fn set_queue(&mut self, items: Vec<MediaItem>, start_index: usize) {
+105        self.set_queue_with_context(items, start_index, QueueContext::Custom);
+106    }
+107
+108    /// Set the queue with new items and explicit context
+109    pub fn set_queue_with_context(
+110        &mut self,
+111        items: Vec<MediaItem>,
+112        start_index: usize,
+113        context: QueueContext,
+114    ) {
+115        let start_index = start_index.min(items.len().saturating_sub(1));
+116
+117        if self.shuffle && !items.is_empty() {
+118            self.shuffle_order = self.generate_shuffle_order(items.len(), Some(start_index));
+119        } else {
+120            self.shuffle_order.clear();
+121        }
+122
+123        self.items = items;
+124        self.current_index = if self.items.is_empty() {
+125            None
+126        } else {
+127            Some(start_index)
+128        };
+129        self.history.clear();
+130        self.context = context;
+131    }
+132
+133    /// Clear the queue entirely, returning it to the empty state.
+134    ///
+135    /// Used when playback genuinely stops (sleep timer fires, or the queue ends
+136    /// with repeat off) so the frontend's `currentQueueItem` becomes null and
+137    /// the mini player hides. History and shuffle order are reset too.
+138    pub fn clear(&mut self) {
+139        self.items.clear();
+140        self.current_index = None;
+141        self.history.clear();
+142        self.shuffle_order.clear();
+143    }
+144
+145    /// Add items to the queue
+146    pub fn add(&mut self, items: Vec<MediaItem>, position: AddPosition) {
+147        if items.is_empty() {
+148            return;
+149        }
+150
+151        let insert_index = match position {
+152            AddPosition::Next => self
+153                .current_index
+154                .map(|i| i + 1)
+155                .unwrap_or(self.items.len()),
+156            AddPosition::End => self.items.len(),
+157        };
+158
+159        // Insert items
+160        for (i, item) in items.into_iter().enumerate() {
+161            self.items.insert(insert_index + i, item);
+162        }
+163
+164        // Update current index if needed
+165        if let Some(current) = self.current_index {
+166            if insert_index <= current {
+167                self.current_index = Some(current + 1);
+168            }
+169        }
+170
+171        // Regenerate shuffle order if shuffle is on
+172        if self.shuffle {
+173            self.shuffle_order = self.generate_shuffle_order(self.items.len(), self.current_index);
+174        }
+175    }
+176
+177    /// Remove an item from the queue
+178    pub fn remove(&mut self, index: usize) -> Option<MediaItem> {
+179        if index >= self.items.len() {
+180            return None;
+181        }
+182
+183        let removed = self.items.remove(index);
+184
+185        // Update current index
+186        if let Some(current) = self.current_index {
+187            if index < current {
+188                self.current_index = Some(current - 1);
+189            } else if index == current {
+190                self.current_index = if self.items.is_empty() {
+191                    None
+192                } else if index >= self.items.len() {
+193                    Some(self.items.len() - 1)
+194                } else {
+195                    Some(index)
+196                };
+197            }
+198        }
+199
+200        // Update shuffle order
+201        if self.shuffle {
+202            self.shuffle_order = self
+203                .shuffle_order
+204                .iter()
+205                .filter(|&&i| i != index)
+206                .map(|&i| if i > index { i - 1 } else { i })
+207                .collect();
+208        }
+209
+210        Some(removed)
+211    }
+212
+213    /// Move to the next item
+214    pub fn next(&mut self) -> Option<&MediaItem> {
+215        if self.items.is_empty() {
+216            return None;
+217        }
+218
+219        let current = self.current_index?;
+220
+221        let next_index = if self.repeat == RepeatMode::One {
+222            // Repeat current track
+223            current
+224        } else if self.shuffle {
+225            // Find current position in shuffle order and get next
+226            let pos = self.shuffle_order.iter().position(|&i| i == current)?;
+227            if pos + 1 < self.shuffle_order.len() {
+228                self.shuffle_order[pos + 1]
+229            } else if self.repeat == RepeatMode::All {
+230                // Wrap around to beginning of shuffle
+231                self.shuffle_order[0]
+232            } else {
+233                log::debug!("[Queue] next() at end of shuffle order, no next track");
+234                return None;
+235            }
+236        } else {
+237            // Normal sequential order
+238            if current + 1 < self.items.len() {
+239                current + 1
+240            } else if self.repeat == RepeatMode::All {
+241                0
+242            } else {
+243                log::debug!(
+244                    "[Queue] next() at end of queue (index {}), no next track",
+245                    current
+246                );
+247                return None;
+248            }
+249        };
+250
+251        // Only add to history if we're actually changing position
+252        if next_index != current {
+253            self.history.push(current);
+254            log::debug!("[Queue] next() moving: {} -> {}", current, next_index);
+255        } else {
+256            log::debug!("[Queue] next() repeat one, staying at index {}", current);
+257        }
+258
+259        self.current_index = Some(next_index);
+260        self.items.get(next_index)
+261    }
+262
+263    /// Move to the previous item
+264    pub fn previous(&mut self) -> Option<&MediaItem> {
+265        if self.items.is_empty() {
+266            return None;
+267        }
+268
+269        let current = self.current_index?;
+270
+271        // If we have history, go back in history
+272        // But validate it to prevent wraparound bugs
+273        if let Some(prev) = self.history.pop() {
+274            // Safety check: ensure the history entry is valid
+275            if prev >= self.items.len() {
+276                log::warn!(
+277                    "[Queue] Invalid history entry {} (queue has {} items), clearing history",
+278                    prev,
+279                    self.items.len()
+280                );
+281                self.history.clear();
+282                return None;
+283            }
+284
+285            // In non-shuffle mode, previous track should be before current (or this is from a skip_to)
+286            // This prevents going from first track to last track
+287            if !self.shuffle && prev >= current {
+288                log::warn!("[Queue] Suspicious history: going from index {} to {} (non-shuffle mode), clearing history",
+289                    current, prev);
+290                self.history.clear();
+291                return None;
+292            }
+293
+294            log::debug!("[Queue] previous() using history: {} -> {}", current, prev);
+295            self.current_index = Some(prev);
+296            return self.items.get(prev);
+297        }
+298
+299        log::debug!("[Queue] previous() no history, current={}", current);
+300
+301        let prev_index = if self.shuffle {
+302            // In shuffle mode without history, go to previous in shuffle order
+303            let pos = self.shuffle_order.iter().position(|&i| i == current)?;
+304            if pos > 0 {
+305                self.shuffle_order[pos - 1]
+306            } else {
+307                log::debug!("[Queue] previous() at start of shuffle order, staying at current");
+308                return None;
+309            }
+310        } else {
+311            // Normal sequential order
+312            if current > 0 {
+313                current - 1
+314            } else {
+315                log::debug!("[Queue] previous() at index 0, staying at current");
+316                return None;
+317            }
+318        };
+319
+320        log::debug!("[Queue] previous() moving: {} -> {}", current, prev_index);
+321        self.current_index = Some(prev_index);
+322        self.items.get(prev_index)
+323    }
+324
+325    /// Skip to a specific index
+326    pub fn skip_to(&mut self, index: usize) -> Option<&MediaItem> {
+327        if index >= self.items.len() {
+328            return None;
+329        }
+330
+331        if let Some(current) = self.current_index {
+332            self.history.push(current);
+333        }
+334
+335        self.current_index = Some(index);
+336        self.items.get(index)
+337    }
+338
+339    /// Toggle shuffle mode
+340    pub fn toggle_shuffle(&mut self) {
+341        self.shuffle = !self.shuffle;
+342
+343        if self.shuffle && !self.items.is_empty() {
+344            self.shuffle_order = self.generate_shuffle_order(self.items.len(), self.current_index);
+345        } else {
+346            self.shuffle_order.clear();
+347        }
+348    }
+349
+350    /// Cycle through repeat modes
+351    pub fn cycle_repeat(&mut self) {
+352        self.repeat = match self.repeat {
+353            RepeatMode::Off => RepeatMode::All,
+354            RepeatMode::All => RepeatMode::One,
+355            RepeatMode::One => RepeatMode::Off,
+356        };
+357    }
+358
+359    /// Set repeat mode directly (for testing)
+360    #[cfg(test)]
+361    pub fn set_repeat(&mut self, mode: RepeatMode) {
+362        self.repeat = mode;
+363    }
+364
+365    /// Check if there's a next item available
+366    pub fn has_next(&self) -> bool {
+367        if self.items.is_empty() {
+368            return false;
+369        }
+370
+371        match self.current_index {
+372            None => false,
+373            Some(current) => {
+374                if self.repeat == RepeatMode::All || self.repeat == RepeatMode::One {
+375                    true
+376                } else if self.shuffle {
+377                    let pos = self.shuffle_order.iter().position(|&i| i == current);
+378                    pos.map(|p| p + 1 < self.shuffle_order.len())
+379                        .unwrap_or(false)
+380                } else {
+381                    current + 1 < self.items.len()
+382                }
+383            }
+384        }
+385    }
+386
+387    /// Check if there's a previous item available
+388    pub fn has_previous(&self) -> bool {
+389        !self.history.is_empty() || {
+390            match self.current_index {
+391                None => false,
+392                Some(current) => {
+393                    if self.shuffle {
+394                        let pos = self.shuffle_order.iter().position(|&i| i == current);
+395                        pos.map(|p| p > 0).unwrap_or(false)
+396                    } else {
+397                        current > 0
+398                    }
+399                }
+400            }
+401        }
+402    }
+403
+404    /// Get the next N upcoming items (for preloading)
+405    /// Returns items that will play after the current item, respecting shuffle order
+406    pub fn get_upcoming(&self, count: usize) -> Vec<&MediaItem> {
+407        if self.items.is_empty() || count == 0 {
+408            return Vec::new();
+409        }
+410
+411        let current = match self.current_index {
+412            Some(idx) => idx,
+413            None => return Vec::new(),
+414        };
+415
+416        let mut upcoming = Vec::with_capacity(count);
+417
+418        if self.shuffle {
+419            // Find current position in shuffle order
+420            if let Some(pos) = self.shuffle_order.iter().position(|&i| i == current) {
+421                for i in 1..=count {
+422                    let next_pos = pos + i;
+423                    if next_pos < self.shuffle_order.len() {
+424                        if let Some(item) = self.items.get(self.shuffle_order[next_pos]) {
+425                            upcoming.push(item);
+426                        }
+427                    } else if self.repeat == RepeatMode::All {
+428                        // Wrap around if repeat all is enabled
+429                        let wrapped_pos = (next_pos) % self.shuffle_order.len();
+430                        if let Some(item) = self.items.get(self.shuffle_order[wrapped_pos]) {
+431                            upcoming.push(item);
+432                        }
+433                    }
+434                }
+435            }
+436        } else {
+437            // Normal sequential order
+438            for i in 1..=count {
+439                let next_idx = current + i;
+440                if next_idx < self.items.len() {
+441                    upcoming.push(&self.items[next_idx]);
+442                } else if self.repeat == RepeatMode::All {
+443                    // Wrap around if repeat all is enabled
+444                    let wrapped_idx = next_idx % self.items.len();
+445                    upcoming.push(&self.items[wrapped_idx]);
+446                }
+447            }
+448        }
+449
+450        upcoming
+451    }
+452
+453    /// Move an item from one index to another
+454    pub fn move_item(&mut self, from_index: usize, to_index: usize) -> bool {
+455        if from_index >= self.items.len() || to_index >= self.items.len() || from_index == to_index
+456        {
+457            return false;
+458        }
+459
+460        // Remove the item and insert at new position
+461        let item = self.items.remove(from_index);
+462        self.items.insert(to_index, item);
+463
+464        // Update current_index if affected
+465        if let Some(current) = self.current_index {
+466            if current == from_index {
+467                // The moved item was the current one
+468                self.current_index = Some(to_index);
+469            } else if from_index < current && to_index >= current {
+470                // Item moved from before current to after/at current
+471                self.current_index = Some(current - 1);
+472            } else if from_index > current && to_index <= current {
+473                // Item moved from after current to before/at current
+474                self.current_index = Some(current + 1);
+475            }
+476        }
+477
+478        // Update shuffle order if shuffle is on
+479        if self.shuffle && !self.shuffle_order.is_empty() {
+480            // Regenerate shuffle order to maintain consistency
+481            self.shuffle_order = self.generate_shuffle_order(self.items.len(), self.current_index);
+482        }
+483
+484        true
+485    }
+486
+487    /// Update the stream URL of the current item (for transcoded seeking)
+488    /// Returns true if the update was successful
+489    pub fn update_current_stream_url(&mut self, new_url: String) -> bool {
+490        if let Some(current_index) = self.current_index {
+491            if let Some(item) = self.items.get_mut(current_index) {
+492                // Only update if it's a Remote source
+493                if let MediaSource::Remote {
+494                    jellyfin_item_id, ..
+495                } = &item.source
+496                {
+497                    item.source = MediaSource::Remote {
+498                        stream_url: new_url,
+499                        jellyfin_item_id: jellyfin_item_id.clone(),
+500                    };
+501                    return true;
+502                }
+503            }
+504        }
+505        false
+506    }
+507
+508    /// Generate a shuffle order, optionally starting from a specific index
+509    fn generate_shuffle_order(&self, length: usize, start_index: Option<usize>) -> Vec<usize> {
+510        let mut indices: Vec<usize> = (0..length).collect();
+511        let mut rng = rand::thread_rng();
+512        indices.shuffle(&mut rng);
+513
+514        // Move start index to the front if specified
+515        if let Some(start) = start_index {
+516            if let Some(pos) = indices.iter().position(|&i| i == start) {
+517                indices.remove(pos);
+518                indices.insert(0, start);
+519            }
+520        }
+521
+522        indices
+523    }
+524}
+525
+526/// Position to add items to the queue
+527#[derive(Debug, Clone, Copy, PartialEq, Eq)]
+528pub enum AddPosition {
+529    /// Add immediately after current item
+530    Next,
+531    /// Add at the end of the queue
+532    End,
+533}
+534
+535// TRACES: UR-005, UR-015 | DR-005 | UT-003, UT-004, UT-005
+536#[cfg(test)]
+537mod tests {
+538    use super::*;
+539    use crate::player::media::{MediaSource, MediaType};
+540
+541    fn create_test_items(count: usize) -> Vec<MediaItem> {
+542        (0..count)
+543            .map(|i| MediaItem {
+544                id: format!("item_{}", i),
+545                title: format!("Track {}", i + 1),
+546                name: Some(format!("Track {}", i + 1)),
+547                artist: Some("Artist".to_string()),
+548                album: None,
+549                album_name: None,
+550                album_id: None,
+551                artist_items: None,
+552                artists: Some(vec!["Artist".to_string()]),
+553                primary_image_tag: None,
+554                image_id: None,
+555                item_type: Some("Audio".to_string()),
+556                playlist_id: None,
+557                duration: Some(180.0),
+558                artwork_url: None,
+559                media_type: MediaType::Audio,
+560                source: MediaSource::DirectUrl {
+561                    url: format!("http://example.com/track_{}.mp3", i),
+562                },
+563                video_codec: None,
+564                needs_transcoding: false,
+565                video_width: None,
+566                video_height: None,
+567                subtitles: vec![],
+568                series_id: None,
+569                server_id: None,
+570            })
+571            .collect()
+572    }
+573
+574    /// Test setting up the queue with items
+575    ///
+576    /// @req-test: UR-015 - View and manage audio queue (add tracks)
+577    /// @req-test: DR-005 - Queue manager with shuffle, repeat, history
+578    #[test]
+579    fn test_set_queue() {
+580        let mut queue = QueueManager::new();
+581        let items = create_test_items(5);
+582
+583        queue.set_queue(items.clone(), 0);
+584
+585        assert_eq!(queue.items().len(), 5);
+586        assert_eq!(queue.current_index(), Some(0));
+587        assert_eq!(queue.current().unwrap().id, "item_0");
+588    }
+589
+590    /// Test clearing the queue returns it to the empty state so the frontend
+591    /// hides the mini player on a genuine stop.
+592    #[test]
+593    fn test_clear() {
+594        let mut queue = QueueManager::new();
+595        queue.set_queue(create_test_items(3), 1);
+596        assert_eq!(queue.current_index(), Some(1));
+597
+598        queue.clear();
+599
+600        assert_eq!(queue.items().len(), 0);
+601        assert_eq!(queue.current_index(), None);
+602        assert!(queue.current().is_none());
+603    }
+604
+605    /// Test next track navigation
+606    ///
+607    /// @req-test: UR-005 - Control media playback (skip to next track)
+608    /// @req-test: DR-005 - Queue manager with shuffle, repeat, history
+609    #[test]
+610    fn test_next() {
+611        let mut queue = QueueManager::new();
+612        queue.set_queue(create_test_items(3), 0);
+613
+614        assert_eq!(queue.current().unwrap().id, "item_0");
+615
+616        queue.next();
+617        assert_eq!(queue.current().unwrap().id, "item_1");
+618
+619        queue.next();
+620        assert_eq!(queue.current().unwrap().id, "item_2");
+621
+622        // No next without repeat
+623        assert!(queue.next().is_none());
+624    }
+625
+626    /// Test repeat all mode wraps to beginning
+627    ///
+628    /// @req-test: UR-005 - Control media playback (repeat all mode)
+629    /// @req-test: DR-005 - Queue manager with repeat
+630    #[test]
+631    fn test_repeat_all() {
+632        let mut queue = QueueManager::new();
+633        queue.set_queue(create_test_items(2), 0);
+634        queue.set_repeat(RepeatMode::All);
+635
+636        queue.next(); // Move to item_1
+637        let next = queue.next(); // Should wrap to item_0
+638
+639        assert!(next.is_some());
+640        assert_eq!(queue.current().unwrap().id, "item_0");
+641    }
+642
+643    /// Test previous track navigation
+644    ///
+645    /// @req-test: UR-005 - Control media playback (previous track)
+646    /// @req-test: DR-005 - Queue manager
+647    #[test]
+648    fn test_previous() {
+649        let mut queue = QueueManager::new();
+650        queue.set_queue(create_test_items(3), 2);
+651
+652        queue.previous();
+653        assert_eq!(queue.current_index(), Some(1));
+654    }
+655
+656    /// Test viewing upcoming tracks in queue
+657    ///
+658    /// @req-test: UR-015 - View and manage audio queue
+659    /// @req-test: DR-020 - Queue management UI (upcoming tracks)
+660    #[test]
+661    fn test_get_upcoming() {
+662        let mut queue = QueueManager::new();
+663        queue.set_queue(create_test_items(5), 0);
+664
+665        // Get next 3 items
+666        let upcoming = queue.get_upcoming(3);
+667        assert_eq!(upcoming.len(), 3);
+668        assert_eq!(upcoming[0].id, "item_1");
+669        assert_eq!(upcoming[1].id, "item_2");
+670        assert_eq!(upcoming[2].id, "item_3");
+671
+672        // Move to item 2 and get upcoming
+673        queue.next();
+674        queue.next();
+675        let upcoming = queue.get_upcoming(3);
+676        assert_eq!(upcoming.len(), 2); // Only 2 items remaining
+677        assert_eq!(upcoming[0].id, "item_3");
+678        assert_eq!(upcoming[1].id, "item_4");
+679    }
+680
+681    /// Test upcoming tracks with repeat all mode
+682    ///
+683    /// @req-test: UR-015 - View and manage audio queue
+684    /// @req-test: DR-005 - Queue manager with repeat
+685    #[test]
+686    fn test_get_upcoming_with_repeat() {
+687        let mut queue = QueueManager::new();
+688        queue.set_queue(create_test_items(3), 1);
+689        queue.set_repeat(RepeatMode::All);
+690
+691        // At item_1, get upcoming with repeat
+692        let upcoming = queue.get_upcoming(4);
+693        assert_eq!(upcoming.len(), 4);
+694        assert_eq!(upcoming[0].id, "item_2"); // Next
+695        assert_eq!(upcoming[1].id, "item_0"); // Wrapped
+696        assert_eq!(upcoming[2].id, "item_1"); // Wrapped (current again)
+697        assert_eq!(upcoming[3].id, "item_2"); // Wrapped
+698    }
+699
+700    /// Test upcoming tracks on empty queue
+701    ///
+702    /// @req-test: DR-005 - Queue manager (edge case: empty queue)
+703    #[test]
+704    fn test_get_upcoming_empty() {
+705        let queue = QueueManager::new();
+706        let upcoming = queue.get_upcoming(3);
+707        assert!(upcoming.is_empty());
+708    }
+709
+710    /// Test next stops at end without repeat mode
+711    ///
+712    /// @req-test: UR-005 - Control media playback (queue end behavior)
+713    /// @req-test: DR-005 - Queue manager
+714    #[test]
+715    fn test_next_no_repeat_reaches_end() {
+716        let mut queue = QueueManager::new();
+717        queue.set_queue(create_test_items(3), 0);
+718        queue.set_repeat(RepeatMode::Off);
+719
+720        // At item 0, should have next
+721        assert!(queue.has_next());
+722        assert_eq!(queue.current_index(), Some(0));
+723
+724        // Move to item 1
+725        queue.next();
+726        assert!(queue.has_next());
+727        assert_eq!(queue.current_index(), Some(1));
+728
+729        // Move to item 2 (last)
+730        queue.next();
+731        assert!(!queue.has_next()); // No more items
+732        assert_eq!(queue.current_index(), Some(2));
+733
+734        // Try to move past end
+735        let result = queue.next();
+736        assert!(result.is_none());
+737        assert_eq!(queue.current_index(), Some(2)); // Should stay at last item
+738    }
+739
+740    /// Test next wraps to beginning with repeat all
+741    ///
+742    /// @req-test: UR-005 - Control media playback (repeat all wrapping)
+743    /// @req-test: DR-005 - Queue manager with repeat
+744    #[test]
+745    fn test_next_repeat_all_wraps() {
+746        let mut queue = QueueManager::new();
+747        queue.set_queue(create_test_items(3), 0);
+748        queue.set_repeat(RepeatMode::All);
+749
+750        // With repeat all, has_next should always be true
+751        assert!(queue.has_next());
+752
+753        // Move through all items
+754        queue.next(); // item_1
+755        assert!(queue.has_next());
+756        queue.next(); // item_2
+757        assert!(queue.has_next());
+758
+759        // Wrap to beginning
+760        let result = queue.next();
+761        assert!(result.is_some());
+762        assert_eq!(queue.current().unwrap().id, "item_0");
+763        assert!(queue.has_next()); // Still has next (loops forever)
+764    }
+765
+766    /// Test next repeats same track with repeat one mode
+767    ///
+768    /// @req-test: UR-005 - Control media playback (repeat one mode)
+769    /// @req-test: DR-005 - Queue manager with repeat
+770    #[test]
+771    fn test_next_repeat_one_stays() {
+772        let mut queue = QueueManager::new();
+773        queue.set_queue(create_test_items(3), 1);
+774        queue.set_repeat(RepeatMode::One);
+775
+776        // Should always have next (repeats current)
+777        assert!(queue.has_next());
+778        assert_eq!(queue.current_index(), Some(1));
+779
+780        // Call next multiple times - should stay on same track
+781        for _ in 0..5 {
+782            let result = queue.next();
+783            assert!(result.is_some());
+784            assert_eq!(queue.current().unwrap().id, "item_1");
+785            assert_eq!(queue.current_index(), Some(1));
+786            assert!(queue.has_next());
+787        }
+788    }
+789
+790    /// Test shuffle mode follows randomized order
+791    ///
+792    /// @req-test: UR-005 - Control media playback (shuffle mode)
+793    /// @req-test: DR-005 - Queue manager with shuffle
+794    #[test]
+795    fn test_next_shuffle_follows_order() {
+796        let mut queue = QueueManager::new();
+797        queue.set_queue(create_test_items(4), 0);
+798        queue.toggle_shuffle(); // Enable shuffle
+799
+800        // Get the shuffle order for verification
+801        let shuffle_order = queue.shuffle_order.clone();
+802        assert_eq!(shuffle_order.len(), 4);
+803
+804        // Current should be first item in shuffle order
+805        let first_shuffled_index = shuffle_order[0];
+806        assert_eq!(queue.current_index(), Some(first_shuffled_index));
+807
+808        // Move through shuffle order
+809        for &expected_index in &shuffle_order[1..] {
+810            assert!(queue.has_next());
+811            let result = queue.next();
+812            assert!(result.is_some());
+813            assert_eq!(queue.current_index(), Some(expected_index));
+814        }
+815
+816        // At end of shuffle without repeat
+817        assert!(!queue.has_next());
+818        let result = queue.next();
+819        assert!(result.is_none());
+820    }
+821
+822    /// Test shuffle with repeat all wraps shuffle order
+823    ///
+824    /// @req-test: UR-005 - Control media playback (shuffle + repeat)
+825    /// @req-test: DR-005 - Queue manager with shuffle and repeat
+826    #[test]
+827    fn test_next_shuffle_with_repeat_all() {
+828        let mut queue = QueueManager::new();
+829        queue.set_queue(create_test_items(3), 0);
+830        queue.toggle_shuffle(); // Enable shuffle
+831        queue.set_repeat(RepeatMode::All);
+832
+833        let shuffle_order = queue.shuffle_order.clone();
+834
+835        // Move through entire shuffle order
+836        for _ in 1..shuffle_order.len() {
+837            queue.next();
+838        }
+839
+840        // At end, should wrap to beginning of shuffle order
+841        assert!(queue.has_next());
+842        let result = queue.next();
+843        assert!(result.is_some());
+844        assert_eq!(queue.current_index(), Some(shuffle_order[0]));
+845    }
+846
+847    /// Test has_next logic accuracy across different scenarios
+848    ///
+849    /// @req-test: DR-005 - Queue manager (has_next accuracy)
+850    /// @req-test: UR-015 - View and manage audio queue
+851    #[test]
+852    fn test_has_next_accuracy() {
+853        // Test 1: Empty queue
+854        let queue = QueueManager::new();
+855        assert!(!queue.has_next());
+856
+857        // Test 2: Last track with repeat off
+858        let mut queue = QueueManager::new();
+859        queue.set_queue(create_test_items(2), 1); // Start at last item
+860        queue.set_repeat(RepeatMode::Off);
+861        assert!(!queue.has_next());
+862
+863        // Test 3: Last track with repeat all
+864        let mut queue = QueueManager::new();
+865        queue.set_queue(create_test_items(2), 1); // Start at last item
+866        queue.set_repeat(RepeatMode::All);
+867        assert!(queue.has_next()); // Should wrap
+868
+869        // Test 4: Repeat one mode (always has next)
+870        let mut queue = QueueManager::new();
+871        queue.set_queue(create_test_items(1), 0);
+872        queue.set_repeat(RepeatMode::One);
+873        assert!(queue.has_next()); // Repeats forever
+874
+875        // Test 5: Middle of queue with repeat off
+876        let mut queue = QueueManager::new();
+877        queue.set_queue(create_test_items(3), 1); // Middle item
+878        queue.set_repeat(RepeatMode::Off);
+879        assert!(queue.has_next()); // Has item_2 next
+880
+881        // Test 6: Shuffle at end without repeat
+882        let mut queue = QueueManager::new();
+883        queue.set_queue(create_test_items(3), 0);
+884        queue.toggle_shuffle(); // Enable shuffle
+885        queue.set_repeat(RepeatMode::Off);
+886        // Move to last item in shuffle order
+887        let shuffle_len = queue.shuffle_order.len();
+888        for _ in 1..shuffle_len {
+889            queue.next();
+890        }
+891        assert!(!queue.has_next());
+892    }
+893
+894    /// Test has_next on empty queue edge case
+895    ///
+896    /// @req-test: DR-005 - Queue manager (edge case: empty queue)
+897    #[test]
+898    fn test_has_next_empty_queue() {
+899        let queue = QueueManager::new();
+900        assert!(!queue.has_next());
+901        assert_eq!(queue.current_index(), None);
+902    }
+903
+904    /// Test that selecting a specific track in an album starts at the correct index
+905    ///
+906    /// Reproduces the bug where clicking songs 1-5 always played song 13
+907    #[test]
+908    fn test_play_specific_track_from_album() {
+909        let mut queue = QueueManager::new();
+910        let items = create_test_items(14); // 14-track album
+911
+912        // Simulate playing track 0 (first track)
+913        queue.set_queue(items.clone(), 0);
+914        assert_eq!(queue.current_index(), Some(0));
+915        assert_eq!(queue.current().unwrap().id, "item_0");
+916
+917        // Simulate playing track 3 (fourth track)
+918        queue.set_queue(items.clone(), 3);
+919        assert_eq!(queue.current_index(), Some(3));
+920        assert_eq!(queue.current().unwrap().id, "item_3");
+921
+922        // Simulate playing track 13 (last track)
+923        queue.set_queue(items, 13);
+924        assert_eq!(queue.current_index(), Some(13));
+925        assert_eq!(queue.current().unwrap().id, "item_13");
+926    }
+927
+928    /// Test that next() then previous() returns to the original track
+929    #[test]
+930    fn test_next_previous_roundtrip() {
+931        let mut queue = QueueManager::new();
+932        queue.set_queue(create_test_items(5), 2); // Start at middle track
+933
+934        assert_eq!(queue.current_index(), Some(2));
+935
+936        // Go to next track
+937        queue.next();
+938        assert_eq!(queue.current_index(), Some(3));
+939
+940        // Go back - should return to track 2
+941        queue.previous();
+942        assert_eq!(queue.current_index(), Some(2));
+943        assert_eq!(queue.current().unwrap().id, "item_2");
+944    }
+945
+946    /// Test that previous() at the first track stays at first track
+947    #[test]
+948    fn test_previous_at_first_track_stays() {
+949        let mut queue = QueueManager::new();
+950        queue.set_queue(create_test_items(5), 0); // Start at first track
+951
+952        assert_eq!(queue.current_index(), Some(0));
+953
+954        // Try to go to previous - should stay at 0
+955        let result = queue.previous();
+956        assert!(result.is_none());
+957        assert_eq!(queue.current_index(), Some(0));
+958        assert_eq!(queue.current().unwrap().id, "item_0");
+959    }
+960
+961    /// Test that next() at last track (no repeat) stays at last track
+962    #[test]
+963    fn test_next_at_last_track_stays() {
+964        let mut queue = QueueManager::new();
+965        queue.set_queue(create_test_items(5), 4); // Start at last track
+966
+967        assert_eq!(queue.current_index(), Some(4));
+968
+969        // Try to go to next - should return None and stay at 4
+970        let result = queue.next();
+971        assert!(result.is_none());
+972        assert_eq!(queue.current_index(), Some(4));
+973        assert_eq!(queue.current().unwrap().id, "item_4");
+974    }
+975
+976    /// Test history validation prevents invalid wraparound
+977    #[test]
+978    fn test_history_validation_prevents_wraparound() {
+979        let mut queue = QueueManager::new();
+980        queue.set_queue(create_test_items(5), 0);
+981
+982        // Manually corrupt history to simulate the bug
+983        // (In the real bug, history would have last track's index)
+984        queue.history.push(4);
+985
+986        // Try to go previous - should detect invalid history and clear it
+987        let result = queue.previous();
+988        assert!(result.is_none()); // Should not wrap to track 4
+989        assert_eq!(queue.current_index(), Some(0)); // Should stay at track 0
+990        assert!(queue.history.is_empty()); // History should be cleared
+991    }
+992
+993    /// Test multiple next() calls build correct history
+994    #[test]
+995    fn test_multiple_next_builds_history() {
+996        let mut queue = QueueManager::new();
+997        queue.set_queue(create_test_items(5), 0);
+998
+999        // Navigate: 0 -> 1 -> 2 -> 3
+1000        queue.next(); // Now at 1, history=[0]
+1001        queue.next(); // Now at 2, history=[0, 1]
+1002        queue.next(); // Now at 3, history=[0, 1, 2]
+1003
+1004        assert_eq!(queue.current_index(), Some(3));
+1005
+1006        // Go back through history: 3 -> 2 -> 1 -> 0
+1007        queue.previous();
+1008        assert_eq!(queue.current_index(), Some(2));
+1009
+1010        queue.previous();
+1011        assert_eq!(queue.current_index(), Some(1));
+1012
+1013        queue.previous();
+1014        assert_eq!(queue.current_index(), Some(0));
+1015    }
+1016}
+
\ No newline at end of file diff --git a/api/src/jellytau_lib/player/seek.rs.html b/api/src/jellytau_lib/player/seek.rs.html new file mode 100644 index 00000000..433b44c8 --- /dev/null +++ b/api/src/jellytau_lib/player/seek.rs.html @@ -0,0 +1,283 @@ +seek.rs - sourceSkip to main content

jellytau_lib/player/
seek.rs

1//! Video seek strategy decision logic.
+2//!
+3//! This is pure logic, extracted from the command layer so it can be unit-tested
+4//! in the player core. The `player_seek_video` command translates the resulting
+5//! [`VideoSeekStrategy`] into a concrete backend/frontend action.
+6
+7/// Seek strategy for video playback, derived from a stream's characteristics.
+8#[derive(Debug, Clone, Copy, PartialEq, Eq)]
+9pub enum VideoSeekStrategy {
+10    /// Local file - always use native seek on backend
+11    LocalNativeSeek,
+12    /// HLS or direct stream with HTML5 - frontend handles seek, skip backend
+13    Html5NativeSeek,
+14    /// HLS or direct stream with native backend - backend handles seek
+15    BackendNativeSeek,
+16    /// Transcoded non-HLS with HTML5 - reload stream, frontend handles
+17    Html5ReloadStream,
+18    /// Transcoded non-HLS with native backend - reload stream, backend handles
+19    BackendReloadStream,
+20}
+21
+22/// Determine the video seek strategy based on stream characteristics.
+23///
+24/// This is a pure function extracted for testability.
+25///
+26/// # Arguments
+27/// * `is_local` - Whether the file is a local download
+28/// * `is_hls` - Whether the stream URL contains ".m3u8" (HLS stream)
+29/// * `needs_transcoding` - Whether the content needs transcoding
+30/// * `use_html5` - Whether frontend is using HTML5 video element
+31pub fn determine_video_seek_strategy(
+32    is_local: bool,
+33    is_hls: bool,
+34    needs_transcoding: bool,
+35    use_html5: bool,
+36) -> VideoSeekStrategy {
+37    // Local files always support native seeking via backend
+38    if is_local {
+39        return VideoSeekStrategy::LocalNativeSeek;
+40    }
+41
+42    // HLS streams and direct play (non-transcoded) support native seeking
+43    if is_hls || !needs_transcoding {
+44        if use_html5 {
+45            // HTML5 backend - frontend handles seeking via videoElement.currentTime
+46            // We don't call backend.seek() because video is in HTML5 element, not in MPV
+47            VideoSeekStrategy::Html5NativeSeek
+48        } else {
+49            // Native backend (MPV) - backend handles seeking
+50            VideoSeekStrategy::BackendNativeSeek
+51        }
+52    } else {
+53        // Transcoded non-HLS streams need server-side seek (reload from new position)
+54        if use_html5 {
+55            VideoSeekStrategy::Html5ReloadStream
+56        } else {
+57            VideoSeekStrategy::BackendReloadStream
+58        }
+59    }
+60}
+61
+62// The four items below are consumed by the Android MediaSessionHandler; on other
+63// targets only the tests exercise them, so dead-code analysis would flag them.
+64
+65/// How far a lockscreen skip-forward jumps while background audio owns playback.
+66#[cfg_attr(not(target_os = "android"), allow(dead_code))]
+67pub const SKIP_FORWARD_SECONDS: f64 = 30.0;
+68
+69/// How far a lockscreen skip-back jumps while background audio owns playback.
+70///
+71/// Deliberately shorter than the forward jump: the back button is used to replay
+72/// dialogue just missed, not to travel.
+73#[cfg_attr(not(target_os = "android"), allow(dead_code))]
+74pub const SKIP_BACK_SECONDS: f64 = 10.0;
+75
+76/// What a lockscreen skip button means for the playback that is actually running.
+77#[cfg_attr(not(target_os = "android"), allow(dead_code))]
+78#[derive(Debug, Clone, Copy, PartialEq)]
+79pub enum SkipAction {
+80    /// Move to the next/previous queue entry — a track, or an episode.
+81    Advance,
+82    /// Scrub within the current item, to this absolute position in seconds.
+83    SeekTo(f64),
+84}
+85
+86/// Decide whether a lockscreen skip advances the queue or scrubs the current item.
+87///
+88/// Music gets queue advance, which is what the buttons look like they do. A video
+89/// whose audio is playing through a background-audio handoff (UR-040) gets a
+90/// relative scrub instead: there is no meaningful "next track" inside a film, and
+91/// jumping to the next *episode* because the user wanted to re-hear a line is a
+92/// much worse outcome than a scrub.
+93///
+94/// `is_background_audio` is the whole test, and it is sufficient on its own —
+95/// the handoff exists only for video, and an episode played through it reports
+96/// `MediaType::Audio`, so media type cannot distinguish this case (see the note
+97/// at `PlayerController::auto_advance_to_next_episode`).
+98///
+99/// Clamped to `[0, duration]` so a skip near either end lands in the item rather
+100/// than at a negative offset or past the end, which some backends treat as EOF
+101/// and would turn a scrub into an unintended advance.
+102///
+103/// TRACES: UR-040, UR-006 | DR-201
+104#[cfg_attr(not(target_os = "android"), allow(dead_code))]
+105pub fn resolve_skip_action(
+106    is_next: bool,
+107    is_background_audio: bool,
+108    position: f64,
+109    duration: Option<f64>,
+110) -> SkipAction {
+111    if !is_background_audio {
+112        return SkipAction::Advance;
+113    }
+114
+115    let target = if is_next {
+116        position + SKIP_FORWARD_SECONDS
+117    } else {
+118        position - SKIP_BACK_SECONDS
+119    };
+120
+121    let clamped = match duration {
+122        Some(d) if d > 0.0 => target.clamp(0.0, d),
+123        _ => target.max(0.0),
+124    };
+125
+126    SkipAction::SeekTo(clamped)
+127}
+128
+129#[cfg(test)]
+130mod tests {
+131    use super::*;
+132
+133    /// Music (no background-audio handoff) keeps queue advance on both buttons.
+134    ///
+135    /// TRACES: UR-006 | DR-201 | UT-194
+136    #[test]
+137    fn test_skip_advances_queue_for_normal_audio() {
+138        assert_eq!(
+139            resolve_skip_action(true, false, 42.0, Some(300.0)),
+140            SkipAction::Advance
+141        );
+142        assert_eq!(
+143            resolve_skip_action(false, false, 42.0, Some(300.0)),
+144            SkipAction::Advance
+145        );
+146    }
+147
+148    /// The reported bug: in background-audio mode the lockscreen skip buttons
+149    /// advanced to the next/previous episode instead of scrubbing, so trying to
+150    /// re-hear a line jumped out of the film entirely.
+151    ///
+152    /// TRACES: UR-040 | DR-201 | UT-195
+153    #[test]
+154    fn test_skip_scrubs_in_background_audio_mode() {
+155        assert_eq!(
+156            resolve_skip_action(true, true, 100.0, Some(3600.0)),
+157            SkipAction::SeekTo(130.0)
+158        );
+159        assert_eq!(
+160            resolve_skip_action(false, true, 100.0, Some(3600.0)),
+161            SkipAction::SeekTo(90.0)
+162        );
+163    }
+164
+165    /// Skipping back near the start clamps to zero rather than going negative,
+166    /// which backends reject (the "Raw(-10)" class of error).
+167    ///
+168    /// TRACES: UR-040 | DR-201 | UT-196
+169    #[test]
+170    fn test_skip_back_clamps_at_start() {
+171        assert_eq!(
+172            resolve_skip_action(false, true, 4.0, Some(3600.0)),
+173            SkipAction::SeekTo(0.0)
+174        );
+175    }
+176
+177    /// Skipping forward near the end clamps to the duration instead of running
+178    /// past it, which would read as end-of-stream and advance — the very thing
+179    /// this function exists to prevent.
+180    ///
+181    /// TRACES: UR-040 | DR-201 | UT-197
+182    #[test]
+183    fn test_skip_forward_clamps_at_end() {
+184        assert_eq!(
+185            resolve_skip_action(true, true, 3590.0, Some(3600.0)),
+186            SkipAction::SeekTo(3600.0)
+187        );
+188    }
+189
+190    /// An unknown duration still scrubs, and still refuses to go negative.
+191    ///
+192    /// TRACES: UR-040 | DR-201 | UT-198
+193    #[test]
+194    fn test_skip_without_duration_still_scrubs() {
+195        assert_eq!(
+196            resolve_skip_action(true, true, 10.0, None),
+197            SkipAction::SeekTo(40.0)
+198        );
+199        assert_eq!(
+200            resolve_skip_action(false, true, 3.0, None),
+201            SkipAction::SeekTo(0.0)
+202        );
+203    }
+204
+205    /// Test video seek strategy for local files
+206    #[test]
+207    fn test_seek_strategy_local_file() {
+208        // Local files always use native backend seek regardless of other flags
+209        assert_eq!(
+210            determine_video_seek_strategy(true, false, false, false),
+211            VideoSeekStrategy::LocalNativeSeek
+212        );
+213        assert_eq!(
+214            determine_video_seek_strategy(true, false, false, true),
+215            VideoSeekStrategy::LocalNativeSeek
+216        );
+217        assert_eq!(
+218            determine_video_seek_strategy(true, true, true, true),
+219            VideoSeekStrategy::LocalNativeSeek
+220        );
+221    }
+222
+223    /// Test video seek strategy for HLS streams
+224    #[test]
+225    fn test_seek_strategy_hls_stream() {
+226        // HLS with HTML5 - frontend handles seek, don't call backend
+227        assert_eq!(
+228            determine_video_seek_strategy(false, true, false, true),
+229            VideoSeekStrategy::Html5NativeSeek
+230        );
+231        // HLS with native backend - backend handles seek
+232        assert_eq!(
+233            determine_video_seek_strategy(false, true, false, false),
+234            VideoSeekStrategy::BackendNativeSeek
+235        );
+236        // HLS even with needs_transcoding flag - still native seek (HLS supports it)
+237        assert_eq!(
+238            determine_video_seek_strategy(false, true, true, true),
+239            VideoSeekStrategy::Html5NativeSeek
+240        );
+241    }
+242
+243    /// Test video seek strategy for direct play (non-transcoded) streams
+244    #[test]
+245    fn test_seek_strategy_direct_play() {
+246        // Direct play with HTML5 - frontend handles seek
+247        assert_eq!(
+248            determine_video_seek_strategy(false, false, false, true),
+249            VideoSeekStrategy::Html5NativeSeek
+250        );
+251        // Direct play with native backend - backend handles seek
+252        assert_eq!(
+253            determine_video_seek_strategy(false, false, false, false),
+254            VideoSeekStrategy::BackendNativeSeek
+255        );
+256    }
+257
+258    /// Test video seek strategy for transcoded non-HLS streams
+259    #[test]
+260    fn test_seek_strategy_transcoded_non_hls() {
+261        // Transcoded non-HLS with HTML5 - need to reload stream, frontend handles
+262        assert_eq!(
+263            determine_video_seek_strategy(false, false, true, true),
+264            VideoSeekStrategy::Html5ReloadStream
+265        );
+266        // Transcoded non-HLS with native backend - need to reload stream, backend handles
+267        assert_eq!(
+268            determine_video_seek_strategy(false, false, true, false),
+269            VideoSeekStrategy::BackendReloadStream
+270        );
+271    }
+272
+273    /// Test the specific bug fix: HLS + HTML5 should NOT call backend seek
+274    /// This was the bug causing "Raw(-10)" errors
+275    #[test]
+276    fn test_hls_html5_does_not_use_backend_seek() {
+277        let strategy = determine_video_seek_strategy(false, true, false, true);
+278        // Should be Html5NativeSeek, NOT BackendNativeSeek
+279        assert_eq!(strategy, VideoSeekStrategy::Html5NativeSeek);
+280        assert_ne!(strategy, VideoSeekStrategy::BackendNativeSeek);
+281    }
+282}
+
\ No newline at end of file diff --git a/api/src/jellytau_lib/player/session.rs.html b/api/src/jellytau_lib/player/session.rs.html new file mode 100644 index 00000000..dc69a8fb --- /dev/null +++ b/api/src/jellytau_lib/player/session.rs.html @@ -0,0 +1,381 @@ +session.rs - sourceSkip to main content

jellytau_lib/player/
session.rs

1use super::media::MediaItem;
+2/**
+3 * Media Session Management
+4 *
+5 * Tracks high-level playback context (Audio/Movie/TvShow/Idle) that persists
+6 * beyond individual playback states. Enables persistent UI (miniplayer) and
+7 * proper transitions between content types.
+8 *
+9 * See docs/architecture/01-rust-backend.md for the state machine diagram.
+10 */
+11use log::info;
+12use serde::{Deserialize, Serialize};
+13
+14/// Media session type tracking the high-level playback context
+15#[derive(specta::Type, Debug, Clone, Serialize, Deserialize, PartialEq)]
+16#[serde(tag = "type", rename_all = "snake_case")]
+17pub enum MediaSessionType {
+18    /// No active session - browsing library
+19    Idle,
+20
+21    /// Audio playback session (music, audiobooks, podcasts)
+22    /// Persists until explicitly dismissed
+23    Audio {
+24        /// Last/current track being played
+25        last_item: Option<MediaItem>,
+26        /// True = playing/paused, False = stopped/ended
+27        is_active: bool,
+28    },
+29
+30    /// Movie playback (single video, auto-dismiss on end)
+31    Movie {
+32        /// Currently loaded movie
+33        item: MediaItem,
+34        /// True = playing/paused, False = ended
+35        is_active: bool,
+36    },
+37
+38    /// TV show playback (supports next episode auto-advance)
+39    TvShow {
+40        /// Currently loaded episode
+41        item: MediaItem,
+42        /// Series ID for fetching next episodes
+43        series_id: String,
+44        /// True = playing/paused, False = ended
+45        is_active: bool,
+46    },
+47}
+48
+49impl MediaSessionType {
+50    /// Check if this is an active session (any type except Idle)
+51    #[allow(dead_code)]
+52    pub fn is_active_session(&self) -> bool {
+53        !matches!(self, MediaSessionType::Idle)
+54    }
+55
+56    /// Check if playback is currently active within the session
+57    #[allow(dead_code)]
+58    pub fn is_playing_or_paused(&self) -> bool {
+59        match self {
+60            MediaSessionType::Audio { is_active, .. } => *is_active,
+61            MediaSessionType::Movie { is_active, .. } => *is_active,
+62            MediaSessionType::TvShow { is_active, .. } => *is_active,
+63            MediaSessionType::Idle => false,
+64        }
+65    }
+66
+67    /// Get the current media item if any
+68    #[allow(dead_code)]
+69    pub fn current_item(&self) -> Option<&MediaItem> {
+70        match self {
+71            MediaSessionType::Audio { last_item, .. } => last_item.as_ref(),
+72            MediaSessionType::Movie { item, .. } => Some(item),
+73            MediaSessionType::TvShow { item, .. } => Some(item),
+74            MediaSessionType::Idle => None,
+75        }
+76    }
+77}
+78
+79/// Manages media session state transitions
+80pub struct MediaSessionManager {
+81    current: MediaSessionType,
+82}
+83
+84#[allow(dead_code)]
+85impl MediaSessionManager {
+86    pub fn new() -> Self {
+87        Self {
+88            current: MediaSessionType::Idle,
+89        }
+90    }
+91
+92    /// Get the current session state
+93    pub fn current(&self) -> &MediaSessionType {
+94        &self.current
+95    }
+96
+97    /// Start an audio session with a queue
+98    /// Transitions: Idle → Audio(active), Any → Audio(active)
+99    pub fn start_audio_session(&mut self, first_item: MediaItem) {
+100        info!(
+101            "[MediaSession] Starting audio session: {}",
+102            first_item.title
+103        );
+104        self.current = MediaSessionType::Audio {
+105            last_item: Some(first_item),
+106            is_active: true,
+107        };
+108    }
+109
+110    /// Update audio session with new track (during playback)
+111    pub fn update_audio_track(&mut self, item: MediaItem) {
+112        if let MediaSessionType::Audio {
+113            last_item,
+114            is_active,
+115        } = &mut self.current
+116        {
+117            info!("[MediaSession] Updating audio track: {}", item.title);
+118            *last_item = Some(item);
+119            *is_active = true;
+120        }
+121    }
+122
+123    /// Mark audio session as inactive (playback ended, queue finished)
+124    /// Session persists for resume
+125    pub fn audio_session_inactive(&mut self) {
+126        if let MediaSessionType::Audio { is_active, .. } = &mut self.current {
+127            info!("[MediaSession] Audio session now inactive (queue ended)");
+128            *is_active = false;
+129        }
+130    }
+131
+132    /// Resume audio session
+133    pub fn resume_audio_session(&mut self) {
+134        if let MediaSessionType::Audio { is_active, .. } = &mut self.current {
+135            info!("[MediaSession] Resuming audio session");
+136            *is_active = true;
+137        }
+138    }
+139
+140    /// Start a movie session
+141    /// Transitions: Any → Movie(active)
+142    pub fn start_movie_session(&mut self, item: MediaItem) {
+143        info!("[MediaSession] Starting movie session: {}", item.title);
+144        self.current = MediaSessionType::Movie {
+145            item,
+146            is_active: true,
+147        };
+148    }
+149
+150    /// Mark movie session as inactive (playback ended)
+151    /// Movie sessions auto-dismiss to Idle
+152    pub fn movie_session_ended(&mut self) {
+153        if matches!(self.current, MediaSessionType::Movie { .. }) {
+154            info!("[MediaSession] Movie ended, transitioning to Idle");
+155            self.current = MediaSessionType::Idle;
+156        }
+157    }
+158
+159    /// Start a TV show session
+160    /// Transitions: Any → TvShow(active)
+161    pub fn start_tv_session(&mut self, item: MediaItem, series_id: String) {
+162        info!("[MediaSession] Starting TV show session: {}", item.title);
+163        self.current = MediaSessionType::TvShow {
+164            item,
+165            series_id,
+166            is_active: true,
+167        };
+168    }
+169
+170    /// Mark TV show session as inactive (episode ended, awaiting next)
+171    pub fn tv_session_episode_ended(&mut self) {
+172        if let MediaSessionType::TvShow { is_active, .. } = &mut self.current {
+173            info!("[MediaSession] Episode ended, awaiting next episode");
+174            *is_active = false;
+175        }
+176    }
+177
+178    /// Advance to next episode in TV session
+179    pub fn tv_session_next_episode(&mut self, next_item: MediaItem) {
+180        if let MediaSessionType::TvShow {
+181            item, is_active, ..
+182        } = &mut self.current
+183        {
+184            info!(
+185                "[MediaSession] Advancing to next episode: {}",
+186                next_item.title
+187            );
+188            *item = next_item;
+189            *is_active = true;
+190        }
+191    }
+192
+193    /// End TV show session (series complete or user dismissed)
+194    pub fn tv_session_ended(&mut self) {
+195        if matches!(self.current, MediaSessionType::TvShow { .. }) {
+196            info!("[MediaSession] TV show session ended, transitioning to Idle");
+197            self.current = MediaSessionType::Idle;
+198        }
+199    }
+200
+201    /// Dismiss/clear current session (user action)
+202    /// Transitions: Any → Idle
+203    pub fn dismiss(&mut self) {
+204        info!("[MediaSession] Dismissing session: {:?}", self.current);
+205        self.current = MediaSessionType::Idle;
+206    }
+207
+208    /// Check if we should show miniplayer
+209    pub fn should_show_miniplayer(&self) -> bool {
+210        matches!(self.current, MediaSessionType::Audio { .. })
+211    }
+212
+213    /// Check if we should show video player
+214    pub fn should_show_video_player(&self) -> bool {
+215        matches!(
+216            self.current,
+217            MediaSessionType::Movie { .. } | MediaSessionType::TvShow { .. }
+218        )
+219    }
+220}
+221
+222impl Default for MediaSessionManager {
+223    fn default() -> Self {
+224        Self::new()
+225    }
+226}
+227
+228#[cfg(test)]
+229mod tests {
+230    use super::*;
+231    use crate::player::media::MediaSource;
+232
+233    fn create_test_audio_item(title: &str) -> MediaItem {
+234        MediaItem {
+235            id: title.to_string(),
+236            title: title.to_string(),
+237            name: Some(title.to_string()),
+238            artist: Some("Test Artist".to_string()),
+239            album: Some("Test Album".to_string()),
+240            album_name: Some("Test Album".to_string()),
+241            album_id: None,
+242            artist_items: None,
+243            artists: Some(vec!["Test Artist".to_string()]),
+244            primary_image_tag: None,
+245            image_id: None,
+246            item_type: Some("Audio".to_string()),
+247            playlist_id: None,
+248            duration: Some(180.0),
+249            artwork_url: None,
+250            media_type: crate::player::media::MediaType::Audio,
+251            source: MediaSource::DirectUrl {
+252                url: "http://example.com/audio.mp3".to_string(),
+253            },
+254            video_codec: None,
+255            needs_transcoding: false,
+256            video_width: None,
+257            video_height: None,
+258            subtitles: vec![],
+259            series_id: None,
+260            server_id: None,
+261        }
+262    }
+263
+264    fn create_test_movie_item(title: &str) -> MediaItem {
+265        MediaItem {
+266            id: title.to_string(),
+267            title: title.to_string(),
+268            name: Some(title.to_string()),
+269            artist: None,
+270            album: None,
+271            album_name: None,
+272            album_id: None,
+273            artist_items: None,
+274            artists: None,
+275            primary_image_tag: None,
+276            image_id: None,
+277            item_type: Some("Movie".to_string()),
+278            playlist_id: None,
+279            duration: Some(7200.0),
+280            artwork_url: None,
+281            media_type: crate::player::media::MediaType::Video,
+282            source: MediaSource::DirectUrl {
+283                url: "http://example.com/movie.mp4".to_string(),
+284            },
+285            video_codec: None,
+286            needs_transcoding: false,
+287            video_width: None,
+288            video_height: None,
+289            subtitles: vec![],
+290            series_id: None,
+291            server_id: None,
+292        }
+293    }
+294
+295    #[test]
+296    fn test_initial_state_is_idle() {
+297        let manager = MediaSessionManager::new();
+298        assert_eq!(manager.current(), &MediaSessionType::Idle);
+299    }
+300
+301    #[test]
+302    fn test_audio_session_lifecycle() {
+303        let mut manager = MediaSessionManager::new();
+304
+305        // Start audio session
+306        let track = create_test_audio_item("Track 1");
+307        manager.start_audio_session(track.clone());
+308
+309        assert!(matches!(
+310            manager.current(),
+311            MediaSessionType::Audio {
+312                is_active: true,
+313                ..
+314            }
+315        ));
+316        assert!(manager.should_show_miniplayer());
+317
+318        // Update to next track
+319        let track2 = create_test_audio_item("Track 2");
+320        manager.update_audio_track(track2);
+321        assert!(manager.current().is_playing_or_paused());
+322
+323        // Queue ends, session goes inactive
+324        manager.audio_session_inactive();
+325        assert!(matches!(
+326            manager.current(),
+327            MediaSessionType::Audio {
+328                is_active: false,
+329                ..
+330            }
+331        ));
+332        assert!(manager.should_show_miniplayer()); // Still shows!
+333
+334        // Resume
+335        manager.resume_audio_session();
+336        assert!(manager.current().is_playing_or_paused());
+337
+338        // Dismiss
+339        manager.dismiss();
+340        assert_eq!(manager.current(), &MediaSessionType::Idle);
+341        assert!(!manager.should_show_miniplayer());
+342    }
+343
+344    #[test]
+345    fn test_movie_session_auto_dismiss() {
+346        let mut manager = MediaSessionManager::new();
+347
+348        let movie = create_test_movie_item("Test Movie");
+349        manager.start_movie_session(movie);
+350
+351        assert!(matches!(
+352            manager.current(),
+353            MediaSessionType::Movie {
+354                is_active: true,
+355                ..
+356            }
+357        ));
+358        assert!(manager.should_show_video_player());
+359
+360        // Movie ends, auto-dismiss to Idle
+361        manager.movie_session_ended();
+362        assert_eq!(manager.current(), &MediaSessionType::Idle);
+363    }
+364
+365    #[test]
+366    fn test_session_replacement() {
+367        let mut manager = MediaSessionManager::new();
+368
+369        // Start with audio
+370        let track = create_test_audio_item("Track 1");
+371        manager.start_audio_session(track);
+372        assert!(manager.should_show_miniplayer());
+373
+374        // Switch to movie (replaces audio session)
+375        let movie = create_test_movie_item("Test Movie");
+376        manager.start_movie_session(movie);
+377        assert!(!manager.should_show_miniplayer());
+378        assert!(manager.should_show_video_player());
+379    }
+380}
+
\ No newline at end of file diff --git a/api/src/jellytau_lib/player/sleep_timer.rs.html b/api/src/jellytau_lib/player/sleep_timer.rs.html new file mode 100644 index 00000000..78c9ee40 --- /dev/null +++ b/api/src/jellytau_lib/player/sleep_timer.rs.html @@ -0,0 +1,152 @@ +sleep_timer.rs - sourceSkip to main content

jellytau_lib/player/
sleep_timer.rs

1use serde::{Deserialize, Serialize};
+2
+3/// Sleep timer mode - determines when playback should stop
+4/// TRACES: UR-026 | DR-029
+5#[derive(specta::Type, Debug, Clone, Serialize, Deserialize, PartialEq)]
+6#[serde(tag = "kind", rename_all = "camelCase")]
+7pub enum SleepTimerMode {
+8    /// Timer is off
+9    Off,
+10    /// Stop after a specific time duration
+11    Time {
+12        #[serde(rename = "endTime")]
+13        end_time: i64, // Unix timestamp in milliseconds
+14    },
+15    /// Stop at the end of current track
+16    EndOfTrack,
+17    /// Stop after N more episodes complete (TV episodes only, not audio tracks)
+18    Episodes { remaining: u32 },
+19}
+20
+21/// Sleep timer state
+22#[derive(specta::Type, Debug, Clone, Serialize, Deserialize)]
+23#[serde(rename_all = "camelCase")]
+24pub struct SleepTimerState {
+25    pub mode: SleepTimerMode,
+26    pub remaining_seconds: u32,
+27}
+28
+29impl Default for SleepTimerState {
+30    fn default() -> Self {
+31        Self {
+32            mode: SleepTimerMode::Off,
+33            remaining_seconds: 0,
+34        }
+35    }
+36}
+37
+38impl SleepTimerState {
+39    /// Check if timer is active
+40    pub fn is_active(&self) -> bool {
+41        !matches!(self.mode, SleepTimerMode::Off)
+42    }
+43
+44    /// Update remaining seconds for time-based timer
+45    pub fn update_remaining_seconds(&mut self) {
+46        if let SleepTimerMode::Time { end_time } = self.mode {
+47            let now = chrono::Utc::now().timestamp_millis();
+48            self.remaining_seconds = if now >= end_time {
+49                0
+50            } else {
+51                ((end_time - now) / 1000).max(0) as u32
+52            };
+53        }
+54    }
+55
+56    /// Decrement episode counter, returns true if should stop
+57    /// Only counts TV episodes, not audio tracks
+58    pub fn decrement_episode(&mut self) -> bool {
+59        match &mut self.mode {
+60            SleepTimerMode::Episodes { remaining } => {
+61                if *remaining <= 1 {
+62                    self.mode = SleepTimerMode::Off;
+63                    self.remaining_seconds = 0;
+64                    true
+65                } else {
+66                    *remaining -= 1;
+67                    false
+68                }
+69            }
+70            _ => false,
+71        }
+72    }
+73
+74    /// Cancel the timer
+75    pub fn cancel(&mut self) {
+76        self.mode = SleepTimerMode::Off;
+77        self.remaining_seconds = 0;
+78    }
+79}
+80
+81// TRACES: UR-026 | DR-029 | UT-012
+82#[cfg(test)]
+83mod tests {
+84    use super::*;
+85
+86    #[test]
+87    fn test_sleep_timer_episode_decrement() {
+88        let mut timer = SleepTimerState {
+89            mode: SleepTimerMode::Episodes { remaining: 2 },
+90            remaining_seconds: 0,
+91        };
+92
+93        assert!(!timer.decrement_episode());
+94        assert!(matches!(
+95            timer.mode,
+96            SleepTimerMode::Episodes { remaining: 1 }
+97        ));
+98
+99        assert!(timer.decrement_episode());
+100        assert!(matches!(timer.mode, SleepTimerMode::Off));
+101    }
+102
+103    #[test]
+104    fn test_sleep_timer_is_active() {
+105        let timer = SleepTimerState::default();
+106        assert!(!timer.is_active());
+107
+108        let timer = SleepTimerState {
+109            mode: SleepTimerMode::Time { end_time: 0 },
+110            remaining_seconds: 0,
+111        };
+112        assert!(timer.is_active());
+113
+114        let timer = SleepTimerState {
+115            mode: SleepTimerMode::EndOfTrack,
+116            remaining_seconds: 0,
+117        };
+118        assert!(timer.is_active());
+119
+120        let timer = SleepTimerState {
+121            mode: SleepTimerMode::Episodes { remaining: 3 },
+122            remaining_seconds: 0,
+123        };
+124        assert!(timer.is_active());
+125    }
+126
+127    #[test]
+128    fn test_sleep_timer_cancel() {
+129        let mut timer = SleepTimerState {
+130            mode: SleepTimerMode::Episodes { remaining: 5 },
+131            remaining_seconds: 300,
+132        };
+133
+134        timer.cancel();
+135        assert!(matches!(timer.mode, SleepTimerMode::Off));
+136        assert_eq!(timer.remaining_seconds, 0);
+137    }
+138
+139    #[test]
+140    fn test_update_remaining_seconds() {
+141        let end_time = chrono::Utc::now().timestamp_millis() + 30000; // 30 seconds from now
+142        let mut timer = SleepTimerState {
+143            mode: SleepTimerMode::Time { end_time },
+144            remaining_seconds: 0,
+145        };
+146
+147        timer.update_remaining_seconds();
+148        // Should be approximately 30 seconds (allow for small time difference)
+149        assert!(timer.remaining_seconds >= 29 && timer.remaining_seconds <= 31);
+150    }
+151}
+
\ No newline at end of file diff --git a/api/src/jellytau_lib/player/state.rs.html b/api/src/jellytau_lib/player/state.rs.html new file mode 100644 index 00000000..6ed5fad8 --- /dev/null +++ b/api/src/jellytau_lib/player/state.rs.html @@ -0,0 +1,348 @@ +state.rs - sourceSkip to main content

jellytau_lib/player/
state.rs

1use serde::{Deserialize, Serialize};
+2
+3use super::media::MediaItem;
+4
+5/// Tracks why playback ended to determine autoplay behavior
+6///
+7/// TRACES: UR-005 | DR-001
+8#[derive(specta::Type, Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
+9#[serde(rename_all = "lowercase")]
+10pub enum EndReason {
+11    /// Track played to completion (natural end) - trigger autoplay
+12    Finished,
+13    /// User pressed next/previous - already handled, don't autoplay
+14    UserSkip,
+15    /// User stopped playback - don't autoplay
+16    UserStop,
+17    /// Playback error - don't autoplay
+18    Error,
+19    /// User selected a different track - don't autoplay
+20    NewTrackLoaded,
+21}
+22
+23/// Player state machine (6 states: Idle, Loading, Playing, Paused, Seeking, Error)
+24///
+25/// TRACES: UR-005 | DR-001
+26#[derive(specta::Type, Debug, Clone, Serialize, Deserialize, Default)]
+27#[serde(tag = "kind", rename_all = "lowercase")]
+28pub enum PlayerState {
+29    #[default]
+30    /// No media loaded
+31    Idle,
+32    /// Media is being loaded/buffered
+33    Loading { media: MediaItem },
+34    /// Media is playing
+35    Playing {
+36        media: MediaItem,
+37        /// Current position in seconds
+38        position: f64,
+39        /// Total duration in seconds
+40        duration: f64,
+41    },
+42    /// Media is paused
+43    Paused {
+44        media: MediaItem,
+45        /// Current position in seconds
+46        position: f64,
+47        /// Total duration in seconds
+48        duration: f64,
+49    },
+50    /// Seeking to a new position
+51    Seeking {
+52        media: MediaItem,
+53        /// Target position in seconds
+54        target: f64,
+55    },
+56    /// An error occurred
+57    Error {
+58        media: Option<MediaItem>,
+59        error: String,
+60    },
+61}
+62
+63impl PlayerState {
+64    /// Get the current playback position if available.
+65    ///
+66    /// Note: this is the position snapshot embedded in the state at the last
+67    /// state transition, not the live backend position. For an up-to-date
+68    /// value use `PlayerController::position()`.
+69    #[allow(dead_code)]
+70    pub fn position(&self) -> Option<f64> {
+71        match self {
+72            PlayerState::Playing { position, .. } => Some(*position),
+73            PlayerState::Paused { position, .. } => Some(*position),
+74            _ => None,
+75        }
+76    }
+77
+78    /// Check if the player is currently playing
+79    pub fn is_playing(&self) -> bool {
+80        matches!(self, PlayerState::Playing { .. })
+81    }
+82
+83    /// Check if the player is currently paused
+84    #[allow(dead_code)]
+85    pub fn is_paused(&self) -> bool {
+86        matches!(self, PlayerState::Paused { .. })
+87    }
+88}
+89
+90#[cfg(test)]
+91mod tests {
+92    use super::*;
+93
+94    #[test]
+95    fn test_end_reason_finished() {
+96        let reason = EndReason::Finished;
+97        let json = serde_json::to_string(&reason);
+98        assert!(json.is_ok());
+99        let serialized = json.unwrap();
+100        assert!(serialized.contains("finished"));
+101    }
+102
+103    #[test]
+104    fn test_end_reason_user_skip() {
+105        let reason = EndReason::UserSkip;
+106        let json = serde_json::to_string(&reason);
+107        assert!(json.is_ok());
+108        let serialized = json.unwrap();
+109        assert!(serialized.contains("userskip"));
+110    }
+111
+112    #[test]
+113    fn test_end_reason_user_stop() {
+114        let reason = EndReason::UserStop;
+115        let json = serde_json::to_string(&reason).unwrap();
+116        assert!(json.contains("userstop"));
+117    }
+118
+119    #[test]
+120    fn test_end_reason_error() {
+121        let reason = EndReason::Error;
+122        let json = serde_json::to_string(&reason).unwrap();
+123        assert!(json.contains("error"));
+124    }
+125
+126    #[test]
+127    fn test_end_reason_new_track_loaded() {
+128        let reason = EndReason::NewTrackLoaded;
+129        let json = serde_json::to_string(&reason).unwrap();
+130        assert!(json.contains("newtrackloa"));
+131    }
+132
+133    #[test]
+134    fn test_end_reason_all_variants() {
+135        let reasons = vec![
+136            EndReason::Finished,
+137            EndReason::UserSkip,
+138            EndReason::UserStop,
+139            EndReason::Error,
+140            EndReason::NewTrackLoaded,
+141        ];
+142
+143        for reason in reasons {
+144            let json = serde_json::to_string(&reason);
+145            assert!(json.is_ok());
+146        }
+147    }
+148
+149    #[test]
+150    fn test_end_reason_equality() {
+151        let reason1 = EndReason::Finished;
+152        let reason2 = EndReason::Finished;
+153        assert_eq!(reason1, reason2);
+154
+155        let reason3 = EndReason::UserSkip;
+156        assert_ne!(reason1, reason3);
+157    }
+158
+159    #[test]
+160    fn test_end_reason_clone() {
+161        let reason = EndReason::Finished;
+162        // Deliberately exercising the derived `Clone` impl, not a plain copy:
+163        // `EndReason` is also `Copy`, so clippy flags the call as redundant.
+164        #[allow(clippy::clone_on_copy)]
+165        let cloned = reason.clone();
+166        assert_eq!(reason, cloned);
+167    }
+168
+169    #[test]
+170    fn test_player_state_idle_default() {
+171        let state = PlayerState::default();
+172        assert!(matches!(state, PlayerState::Idle));
+173    }
+174
+175    #[test]
+176    fn test_player_state_idle_serialization() {
+177        let state = PlayerState::Idle;
+178        let json = serde_json::to_string(&state);
+179        assert!(json.is_ok());
+180        let serialized = json.unwrap();
+181        assert!(serialized.contains("idle"));
+182    }
+183
+184    #[test]
+185    fn test_player_state_position_when_idle() {
+186        let state = PlayerState::Idle;
+187        assert_eq!(state.position(), None);
+188    }
+189
+190    #[test]
+191    fn test_player_state_is_playing_when_idle() {
+192        let state = PlayerState::Idle;
+193        assert!(!state.is_playing());
+194    }
+195
+196    #[test]
+197    fn test_player_state_is_paused_when_idle() {
+198        let state = PlayerState::Idle;
+199        assert!(!state.is_paused());
+200    }
+201
+202    #[test]
+203    fn test_player_state_loading() {
+204        let media = create_test_media_item("item-1", "Test Item");
+205        let state = PlayerState::Loading {
+206            media: media.clone(),
+207        };
+208        assert!(matches!(state, PlayerState::Loading { .. }));
+209        assert_eq!(state.position(), None);
+210        assert!(!state.is_playing());
+211    }
+212
+213    #[test]
+214    fn test_player_state_playing_position() {
+215        let media = create_test_media_item("item-2", "Playing Item");
+216        let state = PlayerState::Playing {
+217            media,
+218            position: 45.5,
+219            duration: 180.0,
+220        };
+221
+222        assert!(state.is_playing());
+223        assert!(!state.is_paused());
+224        assert_eq!(state.position(), Some(45.5));
+225    }
+226
+227    #[test]
+228    fn test_player_state_paused_position() {
+229        let media = create_test_media_item("item-3", "Paused Item");
+230        let state = PlayerState::Paused {
+231            media,
+232            position: 123.75,
+233            duration: 300.0,
+234        };
+235
+236        assert!(state.is_paused());
+237        assert!(!state.is_playing());
+238        assert_eq!(state.position(), Some(123.75));
+239    }
+240
+241    #[test]
+242    fn test_player_state_seeking() {
+243        let media = create_test_media_item("item-4", "Seeking Item");
+244        let state = PlayerState::Seeking {
+245            media,
+246            target: 60.0,
+247        };
+248
+249        assert!(!state.is_playing());
+250        assert!(!state.is_paused());
+251        assert_eq!(state.position(), None);
+252    }
+253
+254    #[test]
+255    fn test_player_state_error_with_media() {
+256        let media = create_test_media_item("item-5", "Error Item");
+257        let state = PlayerState::Error {
+258            media: Some(media),
+259            error: "Playback failed".to_string(),
+260        };
+261
+262        assert!(!state.is_playing());
+263        assert_eq!(state.position(), None);
+264    }
+265
+266    #[test]
+267    fn test_player_state_error_without_media() {
+268        let state = PlayerState::Error {
+269            media: None,
+270            error: "Connection lost".to_string(),
+271        };
+272
+273        assert!(!state.is_playing());
+274        assert_eq!(state.position(), None);
+275    }
+276
+277    #[test]
+278    fn test_player_state_clone() {
+279        let state = PlayerState::Idle;
+280        let cloned = state.clone();
+281        assert!(matches!(cloned, PlayerState::Idle));
+282    }
+283
+284    #[test]
+285    fn test_player_state_playing_serialization() {
+286        let media = create_test_media_item("item-6", "Serial Item");
+287        let state = PlayerState::Playing {
+288            media,
+289            position: 30.0,
+290            duration: 120.0,
+291        };
+292
+293        let json = serde_json::to_string(&state);
+294        assert!(json.is_ok());
+295        let serialized = json.unwrap();
+296        assert!(serialized.contains("playing"));
+297        assert!(serialized.contains("30"));
+298    }
+299
+300    #[test]
+301    fn test_player_state_multiple_positions() {
+302        let positions = vec![0.0, 45.5, 100.0, 999.99];
+303
+304        for pos in positions {
+305            let media = create_test_media_item("item-test", "Test");
+306            let state = PlayerState::Playing {
+307                media,
+308                position: pos,
+309                duration: 1000.0,
+310            };
+311
+312            assert_eq!(state.position(), Some(pos));
+313        }
+314    }
+315
+316    // Helper function to create test MediaItem instances
+317    fn create_test_media_item(id: &str, title: &str) -> MediaItem {
+318        MediaItem {
+319            id: id.to_string(),
+320            title: title.to_string(),
+321            name: None,
+322            artist: None,
+323            album: None,
+324            album_name: None,
+325            album_id: None,
+326            artist_items: None,
+327            artists: None,
+328            primary_image_tag: None,
+329            image_id: None,
+330            item_type: Some("Video".to_string()),
+331            playlist_id: None,
+332            duration: Some(100.0),
+333            artwork_url: None,
+334            media_type: super::super::media::MediaType::Video,
+335            source: super::super::media::MediaSource::DirectUrl {
+336                url: "http://example.com/media".to_string(),
+337            },
+338            video_codec: None,
+339            needs_transcoding: false,
+340            video_width: None,
+341            video_height: None,
+342            subtitles: vec![],
+343            series_id: None,
+344            server_id: None,
+345        }
+346    }
+347}
+
\ No newline at end of file diff --git a/api/src/jellytau_lib/player/stream_end.rs.html b/api/src/jellytau_lib/player/stream_end.rs.html new file mode 100644 index 00000000..023b7c9d --- /dev/null +++ b/api/src/jellytau_lib/player/stream_end.rs.html @@ -0,0 +1,517 @@ +stream_end.rs - sourceSkip to main content

jellytau_lib/player/
stream_end.rs

1//! Telling a *finished* stream apart from a *truncated* one.
+2//!
+3//! TRACES: UR-040 | DR-129 | UT-117
+4//!
+5//! Background audio-only playback of a video item streams a **progressive mp3
+6//! transcode over plain HTTP** (see
+7//! `OnlineRepository::build_audio_only_stream_url_for_video`). That response has
+8//! no reliable length — a live transcode is chunked — so when the connection
+9//! drops mid-episode the data source simply sees end-of-input. ExoPlayer cannot
+10//! distinguish that from the real end of the media and reports
+11//! `Player.STATE_ENDED`, which the app then treats as "the episode finished".
+12//!
+13//! The user-visible damage is not the missed advance itself. Playback parks in
+14//! ExoPlayer's `STATE_ENDED`, and the next play intent from the lockscreen,
+15//! notification or a Bluetooth reconnect goes through media3's
+16//! `Util.handlePlayButtonAction`, which seeks an ENDED player to its default
+17//! position before playing — so **the episode starts over from 0:00**. On a
+18//! flaky connection that reads as "it randomly restarts the episode".
+19//!
+20//! The player itself has no way to know; the *duration* does. Jellyfin gives us
+21//! the item's real runtime, so an end reported well short of it is a truncation,
+22//! not a finish — and the right response is to re-open the stream where it died,
+23//! which is the "buffer and resume" the user expects.
+24
+25use crate::player::media::{MediaItem, MediaSource, MediaType};
+26
+27/// How far short of the item's runtime a stream may end and still count as a
+28/// natural finish.
+29///
+30/// Sized to swallow the two sources of slack in the comparison — the position
+31/// poll is up to 250 ms stale, and Jellyfin's reported runtime can disagree with
+32/// the transcoded output by a second or two — while staying far below the
+33/// minutes-long gap a dropped connection leaves. Erring long is the safe
+34/// direction: a false "finished" is the bug we are fixing, whereas a false
+35/// "truncated" only re-opens the stream for its last few seconds and then ends
+36/// again normally.
+37pub const TRUNCATED_STREAM_TOLERANCE_SECS: f64 = 10.0;
+38
+39/// Consecutive resume attempts allowed at the same position before giving up.
+40///
+41/// A resume re-opens the same URL, so a server that is genuinely gone would
+42/// otherwise end → resume → end forever. Progress past the last attempt resets
+43/// the budget (see [`ResumeTracker`]), so this only bounds *stuck* retries.
+44pub const MAX_STALLED_RESUME_ATTEMPTS: u32 = 3;
+45
+46/// Position change that counts as "this is a different playback context" —
+47/// either the resume made progress, or a different item is loaded.
+48const RESUME_PROGRESS_EPSILON_SECS: f64 = 1.0;
+49
+50/// A video item played through the native *audio* path — i.e. the background
+51/// audio-only handoff, the only place a length-less progressive transcode is
+52/// used. Jellyfin's item-type taxonomy stays in Rust (CLAUDE.md).
+53///
+54/// TRACES: UR-040 | DR-129, DR-203 | UT-117, UT-200
+55pub fn is_audio_only_video(item: &MediaItem) -> bool {
+56    item.media_type == MediaType::Audio
+57        && matches!(item.item_type.as_deref(), Some("Episode") | Some("Movie"))
+58}
+59
+60/// Would the *player's own* load-error retry restart this stream from its
+61/// beginning? If so the retry must be switched off and recovery left to
+62/// [`crate::player::PlayerController::recoverable_error_resume`].
+63///
+64/// ExoPlayer resumes a failed load in place only when it knows where "in place"
+65/// is: `ProgressiveMediaPeriod.configureRetry` keeps the load position when the
+66/// content length is known *or* the extractor produced a seek map with a
+67/// duration, and otherwise treats the source as live — the data at the URL is
+68/// assumed to have changed, so it resets every sample queue and re-requests the
+69/// URL from offset 0.
+70///
+71/// The handoff transcode satisfies neither condition: it is chunked (no
+72/// `Content-Length`) and a live mp3 encode carries no `Xing` header, so the
+73/// player reports its duration as unset — visible in logcat as every position
+74/// tick reading `<position> / 0.0`. Its URL carries `StartTimeTicks` = the
+75/// handoff point, so restarting it from offset 0 restarts the *episode* at the
+76/// handoff point, and playback then runs on from there. Nothing surfaces: no
+77/// error, no `STATE_ENDED`, so neither the truncation path nor the error path of
+78/// DR-129 is consulted, and the app's only sign of it is a position that jumps
+79/// backwards. That is the "it randomly jumps back to where audio-only started"
+80/// the user sees, and how random it is depends on whether a network blip happens
+81/// to land while a load is in flight rather than while the ~50s buffer covers it.
+82///
+83/// A retry that can only restart the stream is worth less than no retry at all:
+84/// declining it turns the silent rewind into a recoverable error, which
+85/// `recoverable_error_resume` answers by re-opening the stream at the position
+86/// playback actually reached (`StartTimeTicks` rewritten, backoff and attempt
+87/// budget included). Every other source keeps the player's retry: a static file
+88/// and an HLS playlist both declare their timeline, so ExoPlayer resumes them
+89/// exactly where the load failed.
+90///
+91/// TRACES: UR-040, UR-004 | DR-203 | UT-200
+92#[cfg_attr(not(target_os = "android"), allow(dead_code))]
+93pub fn player_retry_restarts_stream(item: &MediaItem) -> bool {
+94    is_audio_only_video(item) && matches!(item.source, MediaSource::Remote { .. })
+95}
+96
+97/// Did this end-of-stream happen far enough short of the item's runtime to be a
+98/// truncation rather than a finish?
+99///
+100/// `position` and `duration` must be on the same timeline — for a handoff stream
+101/// built with `StartTimeTicks`, that means the *absolute* position (handoff base
+102/// + the player's relative position) against the item's full runtime.
+103///
+104/// An unknown or non-positive `duration` answers `false`: with nothing to
+105/// compare against, the reported end is taken at face value (previous behaviour).
+106pub fn is_truncated_end(position: f64, duration: Option<f64>, tolerance: f64) -> bool {
+107    let Some(duration) = duration else {
+108        return false;
+109    };
+110    if duration <= 0.0 {
+111        return false;
+112    }
+113    position.max(0.0) + tolerance < duration
+114}
+115
+116/// Rewrite an audio-only stream URL to start at `position_seconds`.
+117///
+118/// Resuming re-opens *the stream we were already playing*, so the URL is edited
+119/// in place rather than rebuilt from the repository: every other parameter —
+120/// `AudioStreamIndex` (the track the user picked in the video player),
+121/// `MediaSourceId`, `api_key` — is carried over untouched, and no network call
+122/// is needed to recover from a network failure.
+123pub fn with_start_time(url: &str, position_seconds: f64) -> String {
+124    let ticks = (position_seconds.max(0.0) * 10_000_000.0) as i64;
+125    let param = format!("StartTimeTicks={}", ticks);
+126
+127    let (base, query) = match url.split_once('?') {
+128        Some((base, query)) => (base, query),
+129        // No query string at all: the URL was not built by us, but appending the
+130        // parameter is still the correct request to make.
+131        None => return format!("{}?{}", url, param),
+132    };
+133
+134    let mut replaced = false;
+135    let mut parts: Vec<String> = query
+136        .split('&')
+137        .map(|part| {
+138            if part.split('=').next() == Some("StartTimeTicks") {
+139                replaced = true;
+140                param.clone()
+141            } else {
+142                part.to_string()
+143            }
+144        })
+145        .collect();
+146
+147    if !replaced {
+148        parts.push(param);
+149    }
+150
+151    format!("{}?{}", base, parts.join("&"))
+152}
+153
+154/// The last playback time actually observed while media was loaded.
+155///
+156/// Some backends expose position and duration as **live** properties of the
+157/// loaded file — MPV's `time-pos` and `duration` stop resolving the moment it
+158/// unloads the file at EOF. Reading them straight through means that at exactly
+159/// the moment end-of-file handling wants to know where playback got to, the
+160/// answer is `0.0` / unknown: the player appears to rewind to 0:00 as it ends.
+161///
+162/// The polling thread records here, and the accessors fall back to it, so an EOF
+163/// reads as the last timestamp rather than as zero.
+164#[derive(Debug, Default, Clone, Copy)]
+165pub struct ObservedTime {
+166    position: f64,
+167    duration: Option<f64>,
+168}
+169
+170impl ObservedTime {
+171    /// Record a live reading. Non-positive durations are treated as unknown —
+172    /// that is how a backend reports "not established yet", not a real zero.
+173    pub fn record(&mut self, position: f64, duration: f64) {
+174        self.position = position.max(0.0);
+175        if duration > 0.0 {
+176            self.duration = Some(duration);
+177        }
+178    }
+179
+180    /// Record a position alone, e.g. straight after a seek, before the next poll.
+181    pub fn record_position(&mut self, position: f64) {
+182        self.position = position.max(0.0);
+183    }
+184
+185    /// Forget everything — a different file is loading, and the previous one's
+186    /// timestamp must not leak into it.
+187    pub fn reset(&mut self) {
+188        *self = Self::default();
+189    }
+190
+191    /// The live reading if there is one, else the last observed value.
+192    pub fn position_or_last(&self, live: Option<f64>) -> f64 {
+193        live.filter(|p| *p >= 0.0).unwrap_or(self.position)
+194    }
+195
+196    /// The last observed position, with no live reading to prefer — the case
+197    /// where the *reporter* is the only source there is (webview-rendered media,
+198    /// which the native backend cannot see at all).
+199    pub fn last_position(&self) -> f64 {
+200        self.position
+201    }
+202
+203    /// The last observed duration, if one was ever established.
+204    pub fn last_duration(&self) -> Option<f64> {
+205        self.duration
+206    }
+207
+208    /// The live reading if there is one, else the last observed value.
+209    pub fn duration_or_last(&self, live: Option<f64>) -> Option<f64> {
+210        live.filter(|d| *d > 0.0).or(self.duration)
+211    }
+212}
+213
+214/// Budget for consecutive resume attempts that make no progress.
+215///
+216/// Held by the player controller across ends of the *same* stream. Any position
+217/// change larger than [`RESUME_PROGRESS_EPSILON_SECS`] — the resume played on,
+218/// or a different item was loaded — is a fresh context and refills the budget.
+219#[derive(Debug, Default)]
+220pub struct ResumeTracker {
+221    last_position: Option<f64>,
+222    attempts: u32,
+223}
+224
+225impl ResumeTracker {
+226    /// Record an attempt at `position`, returning its 1-based number — or `None`
+227    /// once the budget is spent. Callers use the number to back off: a stream
+228    /// that failed twice at the same spot is waiting on something slower than an
+229    /// immediate retry can outrun.
+230    pub fn allow_attempt(&mut self, position: f64) -> Option<u32> {
+231        let progressed = match self.last_position {
+232            Some(last) => (position - last).abs() > RESUME_PROGRESS_EPSILON_SECS,
+233            None => true,
+234        };
+235        if progressed {
+236            self.attempts = 0;
+237        }
+238        self.last_position = Some(position);
+239        self.attempts += 1;
+240        (self.attempts <= MAX_STALLED_RESUME_ATTEMPTS).then_some(self.attempts)
+241    }
+242
+243    /// Forget the budget — a new item is playing, so nothing is stuck.
+244    pub fn reset(&mut self) {
+245        self.last_position = None;
+246        self.attempts = 0;
+247    }
+248}
+249
+250#[cfg(test)]
+251mod tests {
+252    use super::*;
+253
+254    use std::path::PathBuf;
+255
+256    /// The background-audio handoff item, as `player_enter_background_audio`
+257    /// builds it: the episode replayed as AUDIO off a remote stream URL whose
+258    /// `StartTimeTicks` is the handoff point.
+259    fn handoff_item() -> MediaItem {
+260        MediaItem {
+261            id: "ep2".to_string(),
+262            title: "Episode 2".to_string(),
+263            name: None,
+264            artist: None,
+265            album: None,
+266            album_name: None,
+267            album_id: None,
+268            artist_items: None,
+269            artists: None,
+270            primary_image_tag: None,
+271            image_id: None,
+272            item_type: Some("Episode".to_string()),
+273            playlist_id: None,
+274            duration: Some(1500.0),
+275            artwork_url: None,
+276            media_type: MediaType::Audio,
+277            source: MediaSource::Remote {
+278                stream_url: "http://s/Audio/ep2/universal?Container=mp3&StartTimeTicks=1250000000"
+279                    .to_string(),
+280                jellyfin_item_id: "ep2".to_string(),
+281            },
+282            video_codec: None,
+283            needs_transcoding: false,
+284            video_width: None,
+285            video_height: None,
+286            subtitles: vec![],
+287            series_id: Some("series1".to_string()),
+288            server_id: None,
+289        }
+290    }
+291
+292    /// The reported bug: a load error on the length-less handoff transcode let
+293    /// ExoPlayer "retry" the only way it can — from offset 0 — which re-opens
+294    /// the URL at its `StartTimeTicks` and drops playback back to the handoff
+295    /// point, silently. This item must never be left to the player's own retry.
+296    #[test]
+297    fn test_handoff_transcode_must_not_use_the_players_own_retry() {
+298        assert!(player_retry_restarts_stream(&handoff_item()));
+299    }
+300
+301    #[test]
+302    fn test_music_keeps_the_players_retry() {
+303        // `/Audio/{id}/stream?Static=true` — a real Content-Length and byte
+304        // ranges, so ExoPlayer resumes it where the load failed.
+305        let track = MediaItem {
+306            item_type: Some("Audio".to_string()),
+307            ..handoff_item()
+308        };
+309        assert!(!player_retry_restarts_stream(&track));
+310    }
+311
+312    #[test]
+313    fn test_video_keeps_the_players_retry() {
+314        // An HLS playlist declares its segments, so a failed segment load is
+315        // retried at that segment, not at the start of the episode.
+316        let video = MediaItem {
+317            media_type: MediaType::Video,
+318            ..handoff_item()
+319        };
+320        assert!(!player_retry_restarts_stream(&video));
+321    }
+322
+323    #[test]
+324    fn test_downloaded_episode_keeps_the_players_retry() {
+325        // A local file has no length problem and no network to lose.
+326        let local = MediaItem {
+327            source: MediaSource::Local {
+328                file_path: PathBuf::from("/data/ep2.mkv"),
+329                jellyfin_item_id: Some("ep2".to_string()),
+330            },
+331            ..handoff_item()
+332        };
+333        assert!(!player_retry_restarts_stream(&local));
+334    }
+335
+336    #[test]
+337    fn test_end_near_duration_is_a_natural_finish() {
+338        // Episode runtime 25:00, stream ended at 24:56 — that is the end.
+339        assert!(!is_truncated_end(
+340            1496.0,
+341            Some(1500.0),
+342            TRUNCATED_STREAM_TOLERANCE_SECS
+343        ));
+344    }
+345
+346    #[test]
+347    fn test_end_far_short_of_duration_is_truncated() {
+348        // Episode runtime 25:00, stream died at 10:00 — the connection dropped.
+349        assert!(is_truncated_end(
+350            600.0,
+351            Some(1500.0),
+352            TRUNCATED_STREAM_TOLERANCE_SECS
+353        ));
+354    }
+355
+356    #[test]
+357    fn test_unknown_duration_is_taken_at_face_value() {
+358        // Nothing to compare against: keep the previous end-of-track behaviour
+359        // rather than resuming a stream that may really have finished.
+360        assert!(!is_truncated_end(
+361            600.0,
+362            None,
+363            TRUNCATED_STREAM_TOLERANCE_SECS
+364        ));
+365        assert!(!is_truncated_end(
+366            600.0,
+367            Some(0.0),
+368            TRUNCATED_STREAM_TOLERANCE_SECS
+369        ));
+370    }
+371
+372    #[test]
+373    fn test_tolerance_boundary() {
+374        // Exactly one tolerance short still counts as finished, so poll staleness
+375        // and runtime rounding never fabricate a truncation.
+376        assert!(!is_truncated_end(1490.0, Some(1500.0), 10.0));
+377        assert!(is_truncated_end(1489.0, Some(1500.0), 10.0));
+378    }
+379
+380    #[test]
+381    fn test_with_start_time_replaces_existing_ticks() {
+382        let url = "http://s/Audio/ep2/universal?api_key=k&AudioStreamIndex=2&StartTimeTicks=1200000000&Container=mp3";
+383        let out = with_start_time(url, 600.0);
+384        assert_eq!(
+385            out,
+386            "http://s/Audio/ep2/universal?api_key=k&AudioStreamIndex=2&StartTimeTicks=6000000000&Container=mp3"
+387        );
+388    }
+389
+390    #[test]
+391    fn test_with_start_time_appends_when_absent() {
+392        // The next-episode stream is built without StartTimeTicks.
+393        let url = "http://s/Audio/ep3/universal?api_key=k&AudioStreamIndex=0";
+394        let out = with_start_time(url, 90.0);
+395        assert_eq!(
+396            out,
+397            "http://s/Audio/ep3/universal?api_key=k&AudioStreamIndex=0&StartTimeTicks=900000000"
+398        );
+399    }
+400
+401    #[test]
+402    fn test_with_start_time_preserves_selected_audio_track() {
+403        // The whole point of editing the URL instead of rebuilding it: the track
+404        // the user chose in the video player survives the resume.
+405        let url = "http://s/Audio/ep2/universal?AudioStreamIndex=3&MediaSourceId=src-1";
+406        let out = with_start_time(url, 10.0);
+407        assert!(out.contains("AudioStreamIndex=3"));
+408        assert!(out.contains("MediaSourceId=src-1"));
+409    }
+410
+411    #[test]
+412    fn test_with_start_time_without_query() {
+413        assert_eq!(
+414            with_start_time("http://s/Audio/ep2/universal", 1.0),
+415            "http://s/Audio/ep2/universal?StartTimeTicks=10000000"
+416        );
+417    }
+418
+419    /// The bug: MPV unloads the file at EOF, so `time-pos` stops resolving and a
+420    /// straight read reports 0.0 — the position collapses to zero at precisely
+421    /// the moment end-of-file handling needs to know where playback reached.
+422    #[test]
+423    fn test_eof_reads_as_the_last_observed_timestamp() {
+424        let mut observed = ObservedTime::default();
+425        observed.record(178.0, 180.0);
+426
+427        // The file is gone: both live properties fail.
+428        assert_eq!(observed.position_or_last(None), 178.0);
+429        assert_eq!(observed.duration_or_last(None), Some(180.0));
+430    }
+431
+432    #[test]
+433    fn test_live_readings_win_while_the_file_is_loaded() {
+434        let mut observed = ObservedTime::default();
+435        observed.record(178.0, 180.0);
+436
+437        assert_eq!(observed.position_or_last(Some(12.0)), 12.0);
+438        assert_eq!(observed.duration_or_last(Some(240.0)), Some(240.0));
+439    }
+440
+441    #[test]
+442    fn test_unestablished_duration_is_not_recorded_as_zero() {
+443        let mut observed = ObservedTime::default();
+444        // A backend reports 0.0 for "duration not known yet", not a real zero.
+445        observed.record(5.0, 0.0);
+446        assert_eq!(observed.duration_or_last(None), None);
+447        assert_eq!(observed.position_or_last(None), 5.0);
+448
+449        observed.record(6.0, 180.0);
+450        assert_eq!(observed.duration_or_last(Some(0.0)), Some(180.0));
+451    }
+452
+453    #[test]
+454    fn test_reset_stops_the_previous_file_leaking_into_the_next() {
+455        let mut observed = ObservedTime::default();
+456        observed.record(178.0, 180.0);
+457        observed.reset();
+458
+459        assert_eq!(observed.position_or_last(None), 0.0);
+460        assert_eq!(observed.duration_or_last(None), None);
+461    }
+462
+463    #[test]
+464    fn test_seek_updates_the_last_position_before_the_next_poll() {
+465        let mut observed = ObservedTime::default();
+466        observed.record(10.0, 180.0);
+467        observed.record_position(120.0);
+468
+469        assert_eq!(observed.position_or_last(None), 120.0);
+470        assert_eq!(
+471            observed.duration_or_last(None),
+472            Some(180.0),
+473            "seeking does not change how long the file is"
+474        );
+475    }
+476
+477    #[test]
+478    fn test_resume_tracker_bounds_stalled_retries() {
+479        let mut tracker = ResumeTracker::default();
+480        // Same position over and over: the stream is not recovering.
+481        for n in 1..=MAX_STALLED_RESUME_ATTEMPTS {
+482            assert_eq!(
+483                tracker.allow_attempt(600.0),
+484                Some(n),
+485                "attempts are numbered so callers can back off"
+486            );
+487        }
+488        assert_eq!(
+489            tracker.allow_attempt(600.0),
+490            None,
+491            "a stream that ends at the same position every time must stop retrying"
+492        );
+493    }
+494
+495    #[test]
+496    fn test_resume_tracker_refills_after_progress() {
+497        let mut tracker = ResumeTracker::default();
+498        for _ in 0..MAX_STALLED_RESUME_ATTEMPTS {
+499            tracker.allow_attempt(600.0);
+500        }
+501        assert_eq!(tracker.allow_attempt(600.0), None);
+502        // The next drop happened further in — the resumes are working, so the
+503        // budget must not be exhausted by earlier trouble.
+504        assert_eq!(tracker.allow_attempt(900.0), Some(1));
+505    }
+506
+507    #[test]
+508    fn test_resume_tracker_reset() {
+509        let mut tracker = ResumeTracker::default();
+510        for _ in 0..=MAX_STALLED_RESUME_ATTEMPTS {
+511            tracker.allow_attempt(600.0);
+512        }
+513        tracker.reset();
+514        assert_eq!(tracker.allow_attempt(600.0), Some(1));
+515    }
+516}
+
\ No newline at end of file diff --git a/api/src/jellytau_lib/repository/device_profile.rs.html b/api/src/jellytau_lib/repository/device_profile.rs.html new file mode 100644 index 00000000..f38630fb --- /dev/null +++ b/api/src/jellytau_lib/repository/device_profile.rs.html @@ -0,0 +1,551 @@ +device_profile.rs - sourceSkip to main content

jellytau_lib/repository/
device_profile.rs

1//! Device-profile policy: turning what a device *reports* about its audio
+2//! output into the constraints we send Jellyfin.
+3//!
+4//! The platform layer reports raw facts (what `MediaCodecList` enumerates, how
+5//! many channels the current audio route accepts); deciding what those facts
+6//! mean for a `DeviceProfile` is domain logic and lives here, on the Rust side
+7//! of the boundary, where it is testable without a device.
+8
+9/// Channel count assumed when the platform cannot tell us — every audio route
+10/// can voice stereo, so it is the only safe floor.
+11const FALLBACK_AUDIO_CHANNELS: u32 = 2;
+12
+13/// Upper bound we are willing to claim. Jellyfin profiles top out at 7.1, and a
+14/// nonsense reading from a driver should not become a nonsense profile.
+15const MAX_SUPPORTED_AUDIO_CHANNELS: u32 = 8;
+16
+17/// Decide the `MaxAudioChannels` to advertise, given what the current audio
+18/// route reported.
+19///
+20/// Without this constraint Jellyfin is free to direct-play a 5.1 or 7.1 track to
+21/// a sink that only has two channels. What the user hears then is device
+22/// dependent and rarely correct — a failed `AudioSink` configuration (silence),
+23/// or centre-channel dialogue folded away to near-inaudibility. Naming the real
+24/// channel count makes the server downmix instead, which is always audible.
+25///
+26/// A missing or zero reading means "route not established yet", not "no audio":
+27/// fall back to stereo rather than claiming a capability we have not seen.
+28///
+29/// TRACES: UR-004 | DR-141 | UT-141
+30pub fn clamp_max_audio_channels(reported: Option<u32>) -> u32 {
+31    match reported {
+32        Some(channels) if channels >= 1 => channels.min(MAX_SUPPORTED_AUDIO_CHANNELS),
+33        _ => FALLBACK_AUDIO_CHANNELS,
+34    }
+35}
+36
+37/// The channel cap for this device, reading the platform's report where one
+38/// exists.
+39///
+40/// TRACES: UR-004 | DR-141 | UT-141
+41pub fn max_audio_channels() -> u32 {
+42    #[cfg(target_os = "android")]
+43    let reported = crate::player::get_detected_codecs().and_then(|(_, _, channels)| channels);
+44
+45    // Desktop plays video through the WebKitGTK HTML5 <video> element, which we
+46    // do not interrogate for a channel count; stereo is the safe assumption.
+47    #[cfg(not(target_os = "android"))]
+48    let reported: Option<u32> = None;
+49
+50    clamp_max_audio_channels(reported)
+51}
+52
+53/// Audio codecs the webview's `<video>` element can decode.
+54///
+55/// Deliberately narrower than what the platform reports: see
+56/// [`video_audio_codecs`].
+57const WEBVIEW_AUDIO_CODECS: &[&str] = &["aac", "mp3", "opus", "vorbis", "flac"];
+58
+59/// The codec claimed when a device reports nothing we can use. Every renderer
+60/// decodes AAC, and claiming *something* is what makes the server transcode to
+61/// it rather than give up.
+62const FALLBACK_AUDIO_CODEC: &str = "aac";
+63
+64/// Jellyfin's sentinel for "negotiate no subtitle stream at all".
+65///
+66/// Omitting `SubtitleStreamIndex` does **not** mean this: the server then applies
+67/// the source's default/forced flags and picks a track itself. See
+68/// [`playback_subtitle_stream_index`] for why that is never what we want.
+69pub const NO_SUBTITLE_STREAM: i32 = -1;
+70
+71/// Subtitle formats we can render ourselves, delivered as an external sidecar
+72/// track rather than painted into the video.
+73///
+74/// Every entry here is *text*. Image-based subtitles (PGS, DVD, DVB) are
+75/// deliberately absent: they are bitmaps, so the only way a server can show them
+76/// on a client that cannot composite them is to burn them into the picture.
+77const EXTERNAL_SUBTITLE_FORMATS: &[&str] = &["srt", "subrip", "ass", "ssa", "vtt"];
+78
+79/// The `SubtitleProfile` entries to advertise, as `(format, method)`.
+80///
+81/// All `External`: the app fetches subtitle tracks itself and renders them over
+82/// the video (UR-020), so it never needs the server to composite them.
+83///
+84/// TRACES: UR-020 | DR-176 | UT-168
+85pub fn subtitle_profiles() -> Vec<(&'static str, &'static str)> {
+86    EXTERNAL_SUBTITLE_FORMATS
+87        .iter()
+88        .map(|format| (*format, "External"))
+89        .collect()
+90}
+91
+92/// Whether asking the server to serve this subtitle codec forces it to burn the
+93/// subtitle into the picture.
+94///
+95/// Burn-in is not a subtitle cost — it is a *video* cost. It rules out remuxing
+96/// the video stream, so a source we would otherwise have passed through untouched
+97/// gets fully re-encoded frame by frame.
+98///
+99/// TRACES: UR-020 | DR-176 | UT-168
+100pub fn subtitle_forces_burn_in(codec: &str) -> bool {
+101    !EXTERNAL_SUBTITLE_FORMATS
+102        .iter()
+103        .any(|format| format.eq_ignore_ascii_case(codec.trim()))
+104}
+105
+106/// The `SubtitleStreamIndex` to negotiate with: always "none".
+107///
+108/// The reported bug: a source with an E-AC-3 track and a **PGSSUB** default
+109/// subtitle track. Sending no index let the server honour that default, and since
+110/// PGS cannot go out as a sidecar it chose `SubtitleMethod=Encode` — burn-in.
+111/// That turned an audio-only transcode (the HEVC video was directly supported)
+112/// into a full HEVC→h264 re-encode, which the server could not sustain in real
+113/// time: the buffer never grew beyond one segment and playback stalled every few
+114/// seconds, taking seeking down with it.
+115///
+116/// Asking for no subtitle stream costs nothing, because the app never wanted the
+117/// server's composited version — it fetches the text tracks separately and
+118/// renders them itself (UR-020).
+119///
+120/// TRACES: UR-020, UR-004 | DR-176 | UT-168
+121pub fn playback_subtitle_stream_index() -> i32 {
+122    NO_SUBTITLE_STREAM
+123}
+124
+125/// Query keys through which a stream URL can carry a subtitle decision.
+126///
+127/// Jellyfin binds query keys case-insensitively, so the match has to be too —
+128/// the server itself mixes casing (`SubtitleStreamIndex` but
+129/// `alwaysBurnInSubtitleWhenTranscoding`).
+130const SUBTITLE_QUERY_KEYS: &[&str] = &[
+131    "subtitlestreamindex",
+132    "subtitlemethod",
+133    "subtitlecodec",
+134    "alwaysburninsubtitlewhentranscoding",
+135];
+136
+137/// Rewrite a stream URL so it asks for no subtitle, whoever built it.
+138///
+139/// [`playback_subtitle_stream_index`] only governs the URLs *this app* builds.
+140/// When `PlaybackInfo` answers with a `TranscodingUrl`, the URL was built by the
+141/// server from its own subtitle verdict, and we play it verbatim — so a server
+142/// that picked a track anyway (a live channel opened without an index, a source
+143/// whose default is image-based) hands us `SubtitleMethod=Encode`, and the
+144/// burn-in the negotiation just declined comes back through the URL. Burn-in is
+145/// a *video* cost: it rules out remuxing and forces a full re-encode.
+146///
+147/// Stripping the keys is not enough on its own — an absent index is not "none",
+148/// it is "you choose" — so the sentinel is always appended.
+149///
+150/// TRACES: UR-020, UR-004 | DR-176 | UT-168
+151pub fn without_server_chosen_subtitle(url: &str) -> String {
+152    let (path, query) = match url.split_once('?') {
+153        Some((path, query)) => (path, query),
+154        None => (url, ""),
+155    };
+156
+157    let mut kept: Vec<&str> = query
+158        .split('&')
+159        .filter(|param| !param.is_empty())
+160        .filter(|param| {
+161            let key = param.split_once('=').map_or(*param, |(key, _)| key);
+162            !SUBTITLE_QUERY_KEYS
+163                .iter()
+164                .any(|subtitle_key| key.eq_ignore_ascii_case(subtitle_key))
+165        })
+166        .collect();
+167
+168    let sentinel = format!("SubtitleStreamIndex={}", NO_SUBTITLE_STREAM);
+169    kept.push(&sentinel);
+170
+171    format!("{}?{}", path, kept.join("&"))
+172}
+173
+174/// Whether a subtitle in this format can reach the app as a sidecar it draws
+175/// itself — the same verdict as [`subtitle_forces_burn_in`], from the reader's
+176/// side, and the one a subtitle picker needs.
+177///
+178/// Since the app asks for burn-in nowhere (see
+179/// [`playback_subtitle_stream_index`]), a format that only burn-in could deliver
+180/// is one it can never display. An unnamed format is treated as undeliverable
+181/// rather than guessed at: offering a track and drawing nothing is worse than
+182/// not offering it.
+183///
+184/// TRACES: UR-020 | DR-176 | UT-168
+185pub fn subtitle_supports_external_delivery(codec: Option<&str>) -> bool {
+186    codec.is_some_and(|codec| !subtitle_forces_burn_in(codec))
+187}
+188
+189/// Narrow a detected audio-codec list to what the renderer that will actually
+190/// play the **video** can decode.
+191///
+192/// The platform list comes from `MediaCodecList`, which describes ExoPlayer —
+193/// but the webview `<video>` element may be what renders the video, and
+194/// Chromium/WebKit decode a much smaller set than the platform does. Advertising
+195/// the raw list makes Jellyfin direct-play a track the webview cannot decode, and
+196/// the user gets picture with no sound.
+197///
+198/// Which renderer gets it is not fixed: Linux is always the element, and Android
+199/// follows `experimentalNativeVideo`, which took ExoPlayer as its default in
+200/// DR-161 but is a user setting either way. So the *narrow* list is the only one
+201/// that holds on both sides of that switch. The cost is a Dolby-licensed Android
+202/// device transcoding an E-AC-3 track its ExoPlayer could have direct-played;
+203/// the alternative is silence for everyone the switch lands the other way, which
+204/// is the bug this exists to prevent.
+205///
+206/// The gap is widest on devices whose vendor licenses Dolby: a phone with
+207/// `c2.dolby.eac3.decoder` reports `eac3`, so it — and only it — gets a silent
+208/// direct play where a leaner device is transcoded to AAC and plays fine.
+209///
+210/// This applies to the *video* direct-play profile only. Audio-only playback
+211/// really is ExoPlayer's, so its profile keeps the full platform list.
+212///
+213/// TRACES: UR-004 | DR-148 | UT-142
+214pub fn video_audio_codecs(detected: &str) -> String {
+215    let kept: Vec<&str> = detected
+216        .split(',')
+217        .filter_map(|codec| {
+218            let codec = codec.trim();
+219            // Match case-insensitively but emit our own spelling: the platform
+220            // list is assembled from MIME strings and its casing is not ours to
+221            // forward to the server.
+222            WEBVIEW_AUDIO_CODECS
+223                .iter()
+224                .copied()
+225                .find(|supported| supported.eq_ignore_ascii_case(codec))
+226        })
+227        .collect();
+228
+229    if kept.is_empty() {
+230        FALLBACK_AUDIO_CODEC.to_string()
+231    } else {
+232        kept.join(",")
+233    }
+234}
+235
+236/// Whether the webview `<video>` element can decode this audio codec.
+237///
+238/// TRACES: UR-004 | DR-149 | UT-148
+239pub fn webview_can_decode_audio(codec: &str) -> bool {
+240    WEBVIEW_AUDIO_CODECS
+241        .iter()
+242        .any(|supported| supported.eq_ignore_ascii_case(codec.trim()))
+243}
+244
+245/// Decide whether we must transcode *regardless of what the server negotiated*,
+246/// given the source's audio streams as `(codec, is_default)` in source order.
+247///
+248/// Advertising a narrow profile ([`video_audio_codecs`]) is necessary but not
+249/// sufficient: Jellyfin 10.11.5 enforces a `DirectPlayProfile`'s container and
+250/// video codec but **ignores its audio codec** — an E-AC-3 track is offered for
+251/// direct play even when the profile lists only AAC, and neither a `VideoAudio`
+252/// `CodecProfile` nor `MaxAudioChannels` changes that. So the client cannot
+253/// delegate this decision; it knows what its own renderer can decode and must
+254/// apply that itself.
+255///
+256/// The track that matters is the one the server will actually serve (see
+257/// [`served_audio_codec`]). An unknown codec is left alone — forcing a transcode
+258/// on a guess would burn server CPU for files that play.
+259///
+260/// TRACES: UR-004 | DR-149 | UT-148
+261pub fn audio_forces_transcode(streams: &[(Option<&str>, bool)]) -> bool {
+262    match served_audio_codec(streams) {
+263        Some(codec) => !webview_can_decode_audio(codec),
+264        // No audio at all, or a codec the server did not name: leave it alone.
+265        None => false,
+266    }
+267}
+268
+269/// The codec of the audio track the server will actually serve, given the
+270/// source's audio streams as `(codec, is_default)` in source order: the default,
+271/// or the first when none is marked.
+272///
+273/// `None` means "nothing to judge" — no audio streams, or the server named no
+274/// codec for the one it would serve. Both callers of this rule treat that as
+275/// leave-well-alone, never as a licence to assume compatibility.
+276///
+277/// TRACES: UR-004, UR-071 | DR-149, DR-171 | UT-148, UT-166
+278pub fn served_audio_codec<'a>(streams: &[(Option<&'a str>, bool)]) -> Option<&'a str> {
+279    streams
+280        .iter()
+281        .find(|(_, is_default)| *is_default)
+282        .or_else(|| streams.first())
+283        .and_then(|(codec, _)| *codec)
+284}
+285
+286#[cfg(test)]
+287mod tests {
+288    use super::*;
+289
+290    /// The reported bug, at the level it was decided: a source whose default
+291    /// subtitle track is PGSSUB must not drag the video into a re-encode.
+292    ///
+293    /// TRACES: UR-020 | DR-176 | UT-168
+294    #[test]
+295    fn no_subtitle_stream_is_negotiated_so_the_server_never_burns_one_in() {
+296        assert_eq!(playback_subtitle_stream_index(), NO_SUBTITLE_STREAM);
+297        // Not `None`/omitted: that is what let the server pick the PGS track.
+298        assert_eq!(playback_subtitle_stream_index(), -1);
+299    }
+300
+301    /// A transcode URL the *server* built carries the server's own subtitle
+302    /// verdict. Adopting it verbatim re-introduces the burn-in
+303    /// [`playback_subtitle_stream_index`] exists to prevent — the negotiation
+304    /// asks for no subtitle, and then we play a URL that asks for one anyway.
+305    ///
+306    /// TRACES: UR-020, UR-004 | DR-176 | UT-168
+307    #[test]
+308    fn a_server_built_transcode_url_has_its_burn_in_stripped() {
+309        // Shape taken from Jellyfin's `StreamInfo.ToUrl`: it appends
+310        // `SubtitleStreamIndex` and `SubtitleMethod` whenever it picked a track.
+311        let served = "/videos/abc/master.m3u8?DeviceId=jt&MediaSourceId=src1\
+312             &VideoCodec=h264&SubtitleMethod=Encode&SubtitleStreamIndex=2\
+313             &PlaySessionId=xyz";
+314
+315        let url = without_server_chosen_subtitle(served);
+316
+317        assert!(
+318            url.contains("SubtitleStreamIndex=-1"),
+319            "the adopted URL must ask for no subtitle: {url}"
+320        );
+321        assert!(
+322            !url.contains("SubtitleStreamIndex=2"),
+323            "the server's chosen track must not survive: {url}"
+324        );
+325        assert!(
+326            !url.contains("SubtitleMethod"),
+327            "burn-in must not be requested: {url}"
+328        );
+329        // Everything else identifies the job and must survive untouched.
+330        for kept in [
+331            "DeviceId=jt",
+332            "MediaSourceId=src1",
+333            "VideoCodec=h264",
+334            "PlaySessionId=xyz",
+335        ] {
+336            assert!(url.contains(kept), "{kept} must survive: {url}");
+337        }
+338    }
+339
+340    /// The server may also be told to burn in unconditionally
+341    /// (`alwaysBurnInSubtitleWhenTranscoding`), which is appended to the URL
+342    /// rather than expressed as a method — and its keys are not PascalCase.
+343    ///
+344    /// TRACES: UR-020, UR-004 | DR-176 | UT-168
+345    #[test]
+346    fn an_unconditional_burn_in_flag_is_stripped_whatever_its_casing() {
+347        let url = without_server_chosen_subtitle(
+348            "/videos/abc/master.m3u8?api_key=k&alwaysBurnInSubtitleWhenTranscoding=true\
+349             &subtitlestreamindex=3&SubtitleCodec=ass",
+350        );
+351
+352        assert!(!url.to_lowercase().contains("alwaysburnin"), "{url}");
+353        assert!(!url.to_lowercase().contains("subtitlecodec"), "{url}");
+354        assert!(!url.contains("subtitlestreamindex=3"), "{url}");
+355        assert!(url.contains("SubtitleStreamIndex=-1"), "{url}");
+356        assert!(url.contains("api_key=k"), "{url}");
+357    }
+358
+359    /// A URL the server built without any subtitle in it still has to *say* so:
+360    /// omitting the index is what makes the server apply the source's default.
+361    ///
+362    /// TRACES: UR-020, UR-004 | DR-176 | UT-168
+363    #[test]
+364    fn a_url_with_no_subtitle_params_is_still_made_to_ask_for_none() {
+365        let url = without_server_chosen_subtitle("/videos/abc/master.m3u8?api_key=k");
+366        assert_eq!(
+367            url,
+368            "/videos/abc/master.m3u8?api_key=k&SubtitleStreamIndex=-1"
+369        );
+370
+371        // A bare URL is rare but must not come out malformed.
+372        let bare = without_server_chosen_subtitle("/videos/abc/master.m3u8");
+373        assert_eq!(bare, "/videos/abc/master.m3u8?SubtitleStreamIndex=-1");
+374    }
+375
+376    /// TRACES: UR-020 | DR-176 | UT-168
+377    #[test]
+378    fn text_subtitles_are_advertised_as_external_sidecars() {
+379        let profiles = subtitle_profiles();
+380        for format in ["srt", "subrip", "ass", "ssa", "vtt"] {
+381            let entry = profiles.iter().find(|(f, _)| *f == format);
+382            assert!(
+383                entry.is_some(),
+384                "{format} must be advertised or the server burns it into the picture"
+385            );
+386            assert_eq!(entry.unwrap().1, "External");
+387        }
+388    }
+389
+390    /// TRACES: UR-020 | DR-176 | UT-168
+391    #[test]
+392    fn text_subtitles_never_force_burn_in_but_image_ones_do() {
+393        // Text: deliverable as a sidecar, so the video can still be remuxed.
+394        assert!(!subtitle_forces_burn_in("subrip"));
+395        assert!(!subtitle_forces_burn_in("ASS"));
+396        assert!(!subtitle_forces_burn_in("ssa"));
+397        // Image formats are bitmaps — the server can only composite them.
+398        assert!(subtitle_forces_burn_in("PGSSUB"));
+399        assert!(subtitle_forces_burn_in("dvdsub"));
+400    }
+401
+402    #[test]
+403    fn an_undecodable_default_track_forces_a_transcode() {
+404        // The reported bug: one E-AC-3 track, which the webview cannot decode.
+405        assert!(audio_forces_transcode(&[(Some("eac3"), false)]));
+406        assert!(audio_forces_transcode(&[(Some("ac3"), true)]));
+407    }
+408
+409    #[test]
+410    fn a_decodable_track_is_left_to_direct_play() {
+411        // Never spend server CPU on a file that already plays.
+412        assert!(!audio_forces_transcode(&[(Some("aac"), true)]));
+413        assert!(!audio_forces_transcode(&[(Some("mp3"), false)]));
+414    }
+415
+416    #[test]
+417    fn the_default_track_decides_not_the_first() {
+418        // The webview plays the default track, so that is the one that has to be
+419        // decodable — a supported track further down does not save us.
+420        assert!(audio_forces_transcode(&[
+421            (Some("aac"), false),
+422            (Some("eac3"), true)
+423        ]));
+424        assert!(!audio_forces_transcode(&[
+425            (Some("eac3"), false),
+426            (Some("aac"), true)
+427        ]));
+428    }
+429
+430    #[test]
+431    fn with_no_default_marked_the_first_track_decides() {
+432        // Jellyfin leaves IsDefault false on every stream for some files; the
+433        // server then serves the first, so judge that one.
+434        assert!(audio_forces_transcode(&[
+435            (Some("eac3"), false),
+436            (Some("aac"), false)
+437        ]));
+438    }
+439
+440    #[test]
+441    fn a_source_with_no_audio_is_not_transcoded() {
+442        // Nothing to rescue, and a transcode would not create audio.
+443        assert!(!audio_forces_transcode(&[]));
+444    }
+445
+446    #[test]
+447    fn an_unknown_codec_is_not_second_guessed() {
+448        // The server did not tell us the codec; assuming the worst would
+449        // transcode files that play perfectly.
+450        assert!(!audio_forces_transcode(&[(None, true)]));
+451    }
+452
+453    /// The download path needs the codec itself, not just the verdict, so it can
+454    /// tell the server what to re-encode. It picks the same track the streaming
+455    /// verdict is formed from — one rule, one place.
+456    ///
+457    /// TRACES: UR-071 | DR-171 | UT-166
+458    #[test]
+459    fn the_served_codec_is_the_one_the_verdict_is_formed_from() {
+460        assert_eq!(
+461            served_audio_codec(&[(Some("aac"), false), (Some("eac3"), true)]),
+462            Some("eac3")
+463        );
+464        assert_eq!(
+465            served_audio_codec(&[(Some("eac3"), false), (Some("aac"), false)]),
+466            Some("eac3")
+467        );
+468        assert_eq!(served_audio_codec(&[]), None);
+469        assert_eq!(served_audio_codec(&[(None, true)]), None);
+470    }
+471
+472    #[test]
+473    fn a_dolby_device_does_not_advertise_dolby_for_video() {
+474        // The bug: a Motorola reporting c2.dolby.eac3.decoder direct-played
+475        // E-AC-3 into a webview that cannot decode it — silent video, on that
+476        // device only.
+477        let codecs = video_audio_codecs("aac,ac3,amrnb,amrwb,eac3,flac,mp3,opus,pcm,vorbis");
+478        assert_eq!(codecs, "aac,flac,mp3,opus,vorbis");
+479    }
+480
+481    #[test]
+482    fn codecs_the_webview_cannot_decode_are_dropped() {
+483        // AMR and raw PCM come from the AOSP set, so this is not a Dolby-only
+484        // problem — it is just rarer content.
+485        assert_eq!(video_audio_codecs("amrnb,amrwb,pcm,aac"), "aac");
+486        assert_eq!(video_audio_codecs("dts,truehd,mp3"), "mp3");
+487    }
+488
+489    #[test]
+490    fn a_list_the_webview_fully_supports_is_untouched() {
+491        assert_eq!(
+492            video_audio_codecs("aac,mp3,opus,vorbis,flac"),
+493            "aac,mp3,opus,vorbis,flac"
+494        );
+495    }
+496
+497    #[test]
+498    fn nothing_decodable_still_claims_aac() {
+499        // Claiming an empty list invites the server to give up rather than
+500        // transcode. AAC is universally decodable, so ask for it.
+501        assert_eq!(video_audio_codecs("eac3,dts"), "aac");
+502        assert_eq!(video_audio_codecs(""), "aac");
+503    }
+504
+505    #[test]
+506    fn spacing_and_case_in_the_platform_list_are_tolerated() {
+507        // The list is assembled from MediaCodecList strings; do not let
+508        // whitespace decide whether the user gets sound.
+509        assert_eq!(video_audio_codecs("aac, EAC3 , Mp3"), "aac,mp3");
+510    }
+511
+512    #[test]
+513    fn an_unknown_route_falls_back_to_stereo() {
+514        // Codec detection has not run yet, or the platform has no answer. Never
+515        // claim surround we have not seen — every sink can do stereo.
+516        assert_eq!(clamp_max_audio_channels(None), 2);
+517    }
+518
+519    #[test]
+520    fn a_zero_reading_is_not_a_capability() {
+521        // A route that has not been established reports 0; taking that literally
+522        // would advertise a device with no audio at all.
+523        assert_eq!(clamp_max_audio_channels(Some(0)), 2);
+524    }
+525
+526    #[test]
+527    fn a_stereo_sink_is_reported_as_stereo() {
+528        // The phone speaker / Bluetooth headset case: the server must downmix
+529        // 5.1 rather than direct-play it.
+530        assert_eq!(clamp_max_audio_channels(Some(2)), 2);
+531    }
+532
+533    #[test]
+534    fn a_surround_route_keeps_its_channels() {
+535        // HDMI to an AVR: 5.1 and 7.1 direct play stay available.
+536        assert_eq!(clamp_max_audio_channels(Some(6)), 6);
+537        assert_eq!(clamp_max_audio_channels(Some(8)), 8);
+538    }
+539
+540    #[test]
+541    fn an_absurd_reading_is_capped_rather_than_forwarded() {
+542        // Some drivers report the AudioTrack maximum rather than the route's.
+543        assert_eq!(clamp_max_audio_channels(Some(32)), 8);
+544    }
+545
+546    #[test]
+547    fn a_mono_route_is_taken_at_its_word() {
+548        assert_eq!(clamp_max_audio_channels(Some(1)), 1);
+549    }
+550}
+
\ No newline at end of file diff --git a/api/src/jellytau_lib/repository/exclusions.rs.html b/api/src/jellytau_lib/repository/exclusions.rs.html new file mode 100644 index 00000000..20629cac --- /dev/null +++ b/api/src/jellytau_lib/repository/exclusions.rs.html @@ -0,0 +1,353 @@ +exclusions.rs - sourceSkip to main content

jellytau_lib/repository/
exclusions.rs

1//! Library folders the user has chosen to keep out of browsing.
+2//!
+3//! Some people file things inside a library that they never want to see while
+4//! browsing it — a "Podcasts" folder sitting in the music library is the
+5//! canonical case: its albums and tracks leak into album, artist, track and
+6//! playlist listings even though the user thinks of them as a different medium.
+7//!
+8//! This is a *domain* rule, not a presentation one: what an item belongs to, and
+9//! therefore whether a query should return it, is decided here in the repository
+10//! layer so every query path agrees. The predecessor of this module was a
+11//! frontend filter that dropped anything literally named "Podcasts" — one user's
+12//! folder layout, keyed on an English string, shipped to everyone. Excluding by
+13//! **id** instead of name is what makes the setting survive a rename, a
+14//! translation, or two folders sharing a name.
+15//!
+16//! The excluded set is process-wide rather than a field on a repository for the
+17//! same reason as `online::STREAMING_QUALITY`: it is a preference about *this
+18//! user's browsing*, not about a server session, so it must survive a repository
+19//! being rebuilt on re-login. It is written by the settings command and restored
+20//! from the database at startup.
+21//!
+22//! TRACES: UR-076 | DR-209
+23
+24use std::collections::HashSet;
+25use std::sync::RwLock;
+26
+27use super::types::{MediaItem, SearchResult};
+28use crate::utils::lock::RwLockSafe;
+29
+30/// Ids (normalised — see [`normalise_id`]) of items the user has hidden.
+31///
+32/// Empty by default: nobody inherits somebody else's folder layout.
+33///
+34/// TRACES: UR-076 | DR-209
+35static EXCLUDED_IDS: RwLock<Vec<String>> = RwLock::new(Vec::new());
+36
+37/// Jellyfin writes the same GUID both dashed and undashed depending on the
+38/// endpoint, and ids arriving over IPC may carry stray whitespace. Comparing a
+39/// canonical form means a stored id keeps matching whichever spelling a query
+40/// happens to return.
+41fn normalise_id(id: &str) -> String {
+42    id.trim().replace('-', "").to_ascii_lowercase()
+43}
+44
+45/// Replace the excluded set. Ids are normalised, de-duplicated and blanks
+46/// dropped, so a malformed value can never hide more than it names.
+47///
+48/// TRACES: UR-076 | DR-209
+49pub fn set_excluded_item_ids(ids: &[String]) {
+50    let mut normalised: Vec<String> = Vec::with_capacity(ids.len());
+51    for id in ids {
+52        let id = normalise_id(id);
+53        if id.is_empty() || normalised.contains(&id) {
+54            continue;
+55        }
+56        normalised.push(id);
+57    }
+58    *EXCLUDED_IDS.write_safe() = normalised;
+59}
+60
+61/// The excluded set as currently applied, normalised.
+62///
+63/// TRACES: UR-076 | DR-209
+64pub fn excluded_item_ids() -> Vec<String> {
+65    EXCLUDED_IDS.read_safe().clone()
+66}
+67
+68/// Snapshot of the excluded set, taken once per list so a long listing does not
+69/// re-lock per item.
+70fn excluded_snapshot() -> HashSet<String> {
+71    EXCLUDED_IDS.read_safe().iter().cloned().collect()
+72}
+73
+74/// Whether `item` falls under one of `excluded`.
+75///
+76/// The set is passed in rather than read from the global so the rule itself is a
+77/// pure function and can be tested without touching process state.
+78///
+79/// An item matches on its own id or on any of the *links* it carries back to a
+80/// container: parent, album, library, series or season, and its artist entries.
+81/// That covers the shapes a hidden folder actually reaches a listing in — the
+82/// folder itself in a container listing, its albums (whose `parent_id` is the
+83/// folder), and their tracks (whose `album_id` is the album). It is deliberately
+84/// link-based rather than a full ancestry walk: the repository has no ancestor
+85/// index, and walking one would cost a round trip per row.
+86///
+87/// TRACES: UR-076 | DR-209
+88pub fn is_excluded_by(excluded: &HashSet<String>, item: &MediaItem) -> bool {
+89    if excluded.is_empty() {
+90        return false;
+91    }
+92
+93    fn hidden(excluded: &HashSet<String>, id: &str) -> bool {
+94        excluded.contains(&normalise_id(id))
+95    }
+96
+97    fn hidden_opt(excluded: &HashSet<String>, id: &Option<String>) -> bool {
+98        match id {
+99            Some(id) => hidden(excluded, id),
+100            None => false,
+101        }
+102    }
+103
+104    hidden(excluded, &item.id)
+105        || hidden_opt(excluded, &item.parent_id)
+106        || hidden_opt(excluded, &item.album_id)
+107        || hidden_opt(excluded, &item.library_id)
+108        || hidden_opt(excluded, &item.series_id)
+109        || hidden_opt(excluded, &item.season_id)
+110        || match &item.artist_items {
+111            Some(artists) => artists.iter().any(|a| hidden(excluded, &a.id)),
+112            None => false,
+113        }
+114}
+115
+116/// Drop the user's hidden items from a repository result.
+117///
+118/// Implemented as a trait so the hybrid repository's generic result helpers —
+119/// where the cache and server legs of every cache-first race converge — can
+120/// apply it to whatever they are carrying, instead of each query having to
+121/// remember to.
+122///
+123/// TRACES: UR-076 | DR-209
+124pub trait ExcludeHidden: Sized {
+125    fn without_excluded(self) -> Self;
+126}
+127
+128impl ExcludeHidden for Vec<MediaItem> {
+129    fn without_excluded(mut self) -> Self {
+130        let excluded = excluded_snapshot();
+131        if excluded.is_empty() {
+132            return self;
+133        }
+134        self.retain(|item| !is_excluded_by(&excluded, item));
+135        self
+136    }
+137}
+138
+139impl ExcludeHidden for SearchResult {
+140    fn without_excluded(mut self) -> Self {
+141        let before = self.items.len();
+142        self.items = self.items.without_excluded();
+143        // `total_record_count` is what the UI shows as "N results" and what
+144        // paging is built against; leaving the server's count would advertise
+145        // rows that were just removed.
+146        let removed = before.saturating_sub(self.items.len());
+147        self.total_record_count = self.total_record_count.saturating_sub(removed);
+148        self
+149    }
+150}
+151
+152impl ExcludeHidden for MediaItem {
+153    /// A single item fetched by id is never hidden.
+154    ///
+155    /// Exclusion hides things from *browsing*. An item asked for by id was
+156    /// navigated to deliberately, or is being resolved by the player or a
+157    /// download — answering "not found" there would break playback of anything
+158    /// inside a hidden folder rather than merely tidying a listing.
+159    fn without_excluded(self) -> Self {
+160        self
+161    }
+162}
+163
+164#[cfg(test)]
+165mod tests {
+166    use super::*;
+167    use crate::repository::types::ArtistItem;
+168    use crate::utils::lock::MutexSafe;
+169    use std::sync::Mutex;
+170
+171    /// Serialises the tests that write the process-global excluded set. Cargo
+172    /// runs a crate's tests in one process, so without this two of them racing
+173    /// would see each other's ids.
+174    static EXCLUSION_TEST_LOCK: Mutex<()> = Mutex::new(());
+175
+176    fn item(id: &str) -> MediaItem {
+177        MediaItem {
+178            id: id.to_string(),
+179            name: format!("item {id}"),
+180            ..MediaItem::default()
+181        }
+182    }
+183
+184    fn excluded(ids: &[&str]) -> HashSet<String> {
+185        ids.iter().map(|id| normalise_id(id)).collect()
+186    }
+187
+188    /// The default is empty: nobody inherits another user's folder layout, which
+189    /// is exactly what the hardcoded "Podcasts" name filter did.
+190    ///
+191    /// TRACES: UR-076 | DR-209 | UT-203
+192    #[test]
+193    fn test_no_exclusions_by_default_keeps_everything() {
+194        let empty = HashSet::new();
+195        assert!(!is_excluded_by(&empty, &item("anything")));
+196
+197        let items = vec![item("a"), item("b")];
+198        assert_eq!(items.without_excluded().len(), 2);
+199    }
+200
+201    /// The folder itself, and anything linking back to it, is hidden.
+202    ///
+203    /// TRACES: UR-076 | DR-209 | UT-203
+204    #[test]
+205    fn test_excludes_the_folder_and_what_points_at_it() {
+206        let set = excluded(&["folder-1"]);
+207
+208        assert!(is_excluded_by(&set, &item("folder-1")), "the folder itself");
+209
+210        let album = MediaItem {
+211            parent_id: Some("folder-1".to_string()),
+212            ..item("album-1")
+213        };
+214        assert!(is_excluded_by(&set, &album), "an album inside the folder");
+215
+216        let track = MediaItem {
+217            album_id: Some("folder-1".to_string()),
+218            ..item("track-1")
+219        };
+220        assert!(is_excluded_by(&set, &track), "a track of the folder");
+221
+222        let elsewhere = MediaItem {
+223            parent_id: Some("folder-2".to_string()),
+224            ..item("album-2")
+225        };
+226        assert!(!is_excluded_by(&set, &elsewhere), "an unrelated album");
+227    }
+228
+229    /// A whole library, a series/season and an artist are all excludable by the
+230    /// same check — the setting is "hide this container", not "hide albums".
+231    ///
+232    /// TRACES: UR-076 | DR-209 | UT-203
+233    #[test]
+234    fn test_excludes_via_every_container_link() {
+235        let set = excluded(&["container"]);
+236
+237        let by_library = MediaItem {
+238            library_id: Some("container".to_string()),
+239            ..item("x")
+240        };
+241        assert!(is_excluded_by(&set, &by_library));
+242
+243        let by_series = MediaItem {
+244            series_id: Some("container".to_string()),
+245            ..item("x")
+246        };
+247        assert!(is_excluded_by(&set, &by_series));
+248
+249        let by_season = MediaItem {
+250            season_id: Some("container".to_string()),
+251            ..item("x")
+252        };
+253        assert!(is_excluded_by(&set, &by_season));
+254
+255        let by_artist = MediaItem {
+256            artist_items: Some(vec![
+257                ArtistItem {
+258                    id: "other".to_string(),
+259                    name: "Other".to_string(),
+260                },
+261                ArtistItem {
+262                    id: "container".to_string(),
+263                    name: "Hidden".to_string(),
+264                },
+265            ]),
+266            ..item("x")
+267        };
+268        assert!(is_excluded_by(&set, &by_artist));
+269    }
+270
+271    /// Ids are matched by identity, not spelling: Jellyfin serves the same GUID
+272    /// dashed on one endpoint and undashed on another, and a stored id that
+273    /// stopped matching would silently un-hide the folder.
+274    ///
+275    /// TRACES: UR-076 | DR-209 | UT-203
+276    #[test]
+277    fn test_id_matching_ignores_dashes_case_and_padding() {
+278        let set = excluded(&[" A1B2C3D4-0000-0000-0000-000000000000 "]);
+279        assert!(is_excluded_by(
+280            &set,
+281            &item("a1b2c3d4-0000-0000-0000-000000000000")
+282        ));
+283        assert!(is_excluded_by(
+284            &set,
+285            &item("A1B2C3D4000000000000000000000000")
+286        ));
+287        assert!(!is_excluded_by(&set, &item("a1b2c3d4-0000-0000-0000-1")));
+288    }
+289
+290    /// Filtering a `SearchResult` must also correct its count — the listing
+291    /// header reads it, and a stale total advertises rows that are not there.
+292    ///
+293    /// TRACES: UR-076 | DR-209 | UT-203
+294    #[test]
+295    fn test_search_result_count_follows_the_filter() {
+296        let _guard = EXCLUSION_TEST_LOCK.lock_safe();
+297        set_excluded_item_ids(&["hidden".to_string()]);
+298
+299        let result = SearchResult {
+300            items: vec![item("keep"), item("hidden"), item("keep-2")],
+301            total_record_count: 3,
+302        }
+303        .without_excluded();
+304
+305        set_excluded_item_ids(&[]);
+306
+307        assert_eq!(result.items.len(), 2);
+308        assert_eq!(result.total_record_count, 2);
+309        assert!(result.items.iter().all(|i| i.id != "hidden"));
+310    }
+311
+312    /// A single item asked for by id is never withheld: exclusion hides things
+313    /// from browsing, and refusing it here would break playback and downloads of
+314    /// anything inside a hidden folder.
+315    ///
+316    /// TRACES: UR-076 | DR-209 | UT-203
+317    #[test]
+318    fn test_direct_item_lookup_is_never_hidden() {
+319        let _guard = EXCLUSION_TEST_LOCK.lock_safe();
+320        set_excluded_item_ids(&["hidden".to_string()]);
+321
+322        let still_matches = is_excluded_by(&excluded_snapshot(), &item("hidden"));
+323        let survives = item("hidden").without_excluded();
+324
+325        set_excluded_item_ids(&[]);
+326
+327        assert!(still_matches, "the predicate still matches the item");
+328        assert_eq!(survives.id, "hidden", "but a direct lookup keeps it");
+329    }
+330
+331    /// The stored set is sanitised on the way in: blanks dropped, duplicates
+332    /// collapsed, spellings normalised.
+333    ///
+334    /// TRACES: UR-076 | DR-209 | UT-203
+335    #[test]
+336    fn test_set_excluded_item_ids_sanitises() {
+337        let _guard = EXCLUSION_TEST_LOCK.lock_safe();
+338        set_excluded_item_ids(&[
+339            "  ".to_string(),
+340            "AB-CD".to_string(),
+341            "abcd".to_string(),
+342            "ef".to_string(),
+343        ]);
+344        let stored = excluded_item_ids();
+345
+346        set_excluded_item_ids(&[]);
+347        let cleared = excluded_item_ids();
+348
+349        assert_eq!(stored, vec!["abcd".to_string(), "ef".to_string()]);
+350        assert!(cleared.is_empty());
+351    }
+352}
+
\ No newline at end of file diff --git a/api/src/jellytau_lib/repository/hybrid.rs.html b/api/src/jellytau_lib/repository/hybrid.rs.html new file mode 100644 index 00000000..daf90499 --- /dev/null +++ b/api/src/jellytau_lib/repository/hybrid.rs.html @@ -0,0 +1,2183 @@ +hybrid.rs - sourceSkip to main content

jellytau_lib/repository/
hybrid.rs

1// Hybrid repository - parallel racing between cache and server
+2//
+3// @req: UR-002 - Access media when online or offline
+4// @req: IR-013 - SQLite integration for local database
+5// @req: DR-012 - Local database for media metadata cache
+6// @req: DR-013 - Repository pattern for online/offline data access
+7//
+8// TRACES: UR-002, UR-052 | IR-013 | DR-012, DR-013, DR-080
+9
+10#[cfg(test)]
+11use crate::utils::lock::MutexSafe;
+12use std::sync::Arc;
+13
+14use async_trait::async_trait;
+15use log::{debug, warn};
+16use tokio::time::{timeout, Duration};
+17
+18use super::exclusions::ExcludeHidden;
+19use super::{types::*, MediaRepository, OfflineRepository, OnlineRepository};
+20
+21/// Hybrid repository combining online and offline data sources
+22///
+23/// Uses cache-first parallel racing strategy:
+24/// - Runs SQLite cache and HTTP server queries in parallel
+25/// - Cache has 100ms timeout for fast feedback
+26/// - Returns cache result if it has meaningful content
+27/// - Falls back to server result if cache is empty/stale
+28///
+29/// @req: UR-002 - Access media when online or offline
+30/// @req: DR-012 - Local database for media metadata cache
+31/// @req: DR-013 - Repository pattern for online/offline data access
+32pub struct HybridRepository {
+33    online: Arc<OnlineRepository>,
+34    offline: Arc<OfflineRepository>,
+35}
+36
+37impl HybridRepository {
+38    pub fn new(online: OnlineRepository, offline: OfflineRepository) -> Self {
+39        Self {
+40            online: Arc::new(online),
+41            offline: Arc::new(offline),
+42        }
+43    }
+44
+45    /// The signed-in user this repository acts for.
+46    ///
+47    /// TRACES: UR-069 | DR-120
+48    pub fn user_id(&self) -> &str {
+49        self.online.user_id()
+50    }
+51
+52    /// Download raw bytes from a URL using the shared authenticated HTTP client.
+53    /// Delegates to online repository for connection reuse and proper auth.
+54    pub async fn download_bytes(&self, url: &str) -> Result<Vec<u8>, String> {
+55        self.online.download_bytes(url).await
+56    }
+57
+58    /// Remove catalog entries the server no longer has. Cache-only, so it goes
+59    /// straight to the offline repository. Callers must only invoke this after a
+60    /// crawl in which every library succeeded — see
+61    /// `OfflineRepository::prune_stale_catalog` for why a partial crawl must not
+62    /// sweep.
+63    ///
+64    /// TRACES: UR-065 | DR-110
+65    pub async fn prune_stale_catalog(
+66        &self,
+67        cutoff: &str,
+68        item_types: &[String],
+69    ) -> Result<usize, RepoError> {
+70        self.offline.prune_stale_catalog(cutoff, item_types).await
+71    }
+72
+73    /// Query the JRay plugin for actors on screen at time `t`. Online-only
+74    /// (the plugin lives on the Jellyfin server); empty when JRay isn't present.
+75    pub async fn get_jray_actors(
+76        &self,
+77        item_id: &str,
+78        t: f64,
+79    ) -> Result<Vec<super::JRayActor>, RepoError> {
+80        self.online.get_jray_actors(item_id, t).await
+81    }
+82
+83    /// Get video stream URL. This method is online-only since offline playback
+84    /// uses local file paths.
+85    ///
+86    /// Takes no start position: the URL is an HLS playlist spanning the whole
+87    /// item, and a position on it would 400 every segment — see
+88    /// `OnlineRepository::get_video_stream_url`. Resume by seeking after load.
+89    pub async fn get_video_stream_url(
+90        &self,
+91        item_id: &str,
+92        media_source_id: Option<&str>,
+93        audio_stream_index: Option<i32>,
+94    ) -> Result<String, RepoError> {
+95        self.online
+96            .get_video_stream_url(item_id, media_source_id, audio_stream_index)
+97            .await
+98    }
+99
+100    /// Get an audio-only stream URL for a video item (background-audio handoff).
+101    /// Online-only, like `get_video_stream_url`.
+102    ///
+103    /// TRACES: UR-040 | JA-032
+104    pub async fn get_audio_only_stream_url_for_video(
+105        &self,
+106        item_id: &str,
+107        media_source_id: Option<&str>,
+108        start_time_seconds: Option<f64>,
+109        audio_stream_index: Option<i32>,
+110    ) -> Result<String, RepoError> {
+111        self.online
+112            .get_audio_only_stream_url_for_video(
+113                item_id,
+114                media_source_id,
+115                start_time_seconds,
+116                audio_stream_index,
+117            )
+118            .await
+119    }
+120
+121    /// Every track of an album, asked of the **server** rather than the cache.
+122    ///
+123    /// Deliberately not `get_items`, which is cache-first: it answers from SQLite
+124    /// the moment the cache has any content. That is right for browsing and wrong
+125    /// for deciding what to download, because a partial or unlinked cache then
+126    /// decides how much of the album gets queued while the user is told the whole
+127    /// album is downloading. Downloading is the one operation that must know the
+128    /// album's *complete* contents.
+129    ///
+130    /// Errors when the server cannot answer (offline); the caller falls back to
+131    /// the local catalog and the rows are queued either way, resolving on
+132    /// reconnect. Server results are written back to the cache, so browsing
+133    /// benefits from the round trip too.
+134    ///
+135    /// TRACES: UR-018, UR-055 | DR-173
+136    pub async fn get_album_tracks(&self, album_id: &str) -> Result<Vec<MediaItem>, RepoError> {
+137        let options = Some(GetItemsOptions {
+138            include_item_types: Some(vec!["Audio".to_string()]),
+139            sort_by: Some("ParentIndexNumber,IndexNumber,SortName".to_string()),
+140            limit: Some(1000),
+141            ..Default::default()
+142        });
+143
+144        let result = self.online.get_items(album_id, options).await?;
+145
+146        if !result.items.is_empty() {
+147            if let Err(e) = self.offline.save_to_cache(album_id, &result.items).await {
+148                warn!("[HybridRepo] Failed to cache album tracks: {:?}", e);
+149            }
+150        }
+151
+152        Ok(result.items)
+153    }
+154
+155    /// Immediate children of a container with the user's browsing exclusions
+156    /// **not** applied.
+157    ///
+158    /// Exists for the exclusion picker in settings. Everything else in this
+159    /// repository hides what the user has hidden, which would make the setting
+160    /// one-way: a folder already excluded would vanish from the list of folders
+161    /// to exclude and could never be un-hidden. Server-first so the picker sees
+162    /// the real library, falling back to the cache when unreachable.
+163    ///
+164    /// TRACES: UR-076 | DR-209
+165    pub async fn get_items_unfiltered(
+166        &self,
+167        parent_id: &str,
+168        options: Option<GetItemsOptions>,
+169    ) -> Result<SearchResult, RepoError> {
+170        match self.online.get_items(parent_id, options.clone()).await {
+171            Ok(result) => Ok(result),
+172            Err(e) => self.offline.get_items(parent_id, options).await.or(Err(e)),
+173        }
+174    }
+175
+176    /// Search only the local SQLite cache (downloaded content).
+177    ///
+178    /// Fast (100ms timeout) — used to render instant results before the server
+179    /// responds. Returns an empty result rather than erroring on timeout so the
+180    /// caller can still fall through to the server.
+181    pub async fn search_cache_only(
+182        &self,
+183        query: &str,
+184        options: Option<SearchOptions>,
+185    ) -> Result<SearchResult, RepoError> {
+186        let offline = Arc::clone(&self.offline);
+187        let query = query.to_string();
+188        self.cache_with_timeout(async move { offline.search(&query, options).await })
+189            .await
+190            .map(ExcludeHidden::without_excluded)
+191    }
+192
+193    /// Favourites held locally, without touching the server. Backs the instant
+194    /// leg of the two-phase favourites read in the command layer.
+195    ///
+196    /// TRACES: UR-067 | DR-115
+197    pub async fn get_favorites_cache_only(
+198        &self,
+199        scope: SearchScope,
+200        options: Option<GetItemsOptions>,
+201    ) -> Result<SearchResult, RepoError> {
+202        let offline = Arc::clone(&self.offline);
+203        self.cache_with_timeout(async move { offline.get_favorites(scope, options).await })
+204            .await
+205            .map(ExcludeHidden::without_excluded)
+206    }
+207
+208    /// Favourites straight from the server, persisted to the cache on the way
+209    /// through — which is also what mirrors their favourite flags into
+210    /// `user_data` (DR-114), so the next offline read agrees with the server.
+211    ///
+212    /// TRACES: UR-067 | DR-115
+213    pub async fn get_favorites_server_only(
+214        &self,
+215        scope: SearchScope,
+216        options: Option<GetItemsOptions>,
+217    ) -> Result<SearchResult, RepoError> {
+218        let result = self.online.get_favorites(scope, options).await?;
+219        if !result.items.is_empty() {
+220            // Favourites span libraries, so there is no single parent to file
+221            // them under; the parent id is only used for stub rows.
+222            if let Err(e) = self.offline.save_to_cache("favorites", &result.items).await {
+223                debug!("[HybridRepo] Failed to cache favourites: {:?}", e);
+224            }
+225        }
+226        Ok(result.without_excluded())
+227    }
+228
+229    /// Fetch a folder's items from the live server and persist them to the
+230    /// offline cache synchronously (unlike `get_items`, which saves in a
+231    /// fire-and-forget background task after a 100ms cache race).
+232    ///
+233    /// Used by the full-catalog pre-sync (`sync_full_catalog`) to deterministically
+234    /// walk every library while online so the whole catalog is browsable — greyed
+235    /// out — offline. Returns the items fetched so the caller can recurse into
+236    /// containers. Server-only: errors if unreachable.
+237    pub async fn cache_items_from_server(
+238        &self,
+239        parent_id: &str,
+240        options: Option<GetItemsOptions>,
+241    ) -> Result<Vec<MediaItem>, RepoError> {
+242        let result = self.online.get_items(parent_id, options).await?;
+243        if !result.items.is_empty() {
+244            self.offline.save_to_cache(parent_id, &result.items).await?;
+245        }
+246        Ok(result.items)
+247    }
+248
+249    /// Browse downloaded content only — the dedicated Downloads surface.
+250    ///
+251    /// Bypasses the cache/server merge entirely and reads the offline repository
+252    /// directly, so an empty result is authoritative ("nothing downloaded here")
+253    /// and never falls through to the server (DR-080). Available online too — a
+254    /// user who is reachable still wants to browse what's on the device.
+255    ///
+256    /// TRACES: UR-055 | DR-082, DR-083
+257    pub async fn get_downloaded_items(
+258        &self,
+259        parent_id: &str,
+260        options: Option<GetItemsOptions>,
+261    ) -> Result<SearchResult, RepoError> {
+262        // Deliberately *not* filtered by the user's hidden folders: this surface
+263        // manages what is on the device, and hiding a download would leave the
+264        // user unable to delete a file they can still see the disk usage of.
+265        // TRACES: UR-076 | DR-209
+266        self.offline.get_downloaded_items(parent_id, options).await
+267    }
+268
+269    /// Libraries that contain downloaded content (offline-only, authoritative).
+270    ///
+271    /// TRACES: UR-055 | DR-082
+272    pub async fn get_downloaded_libraries(&self) -> Result<Vec<Library>, RepoError> {
+273        self.offline.get_downloaded_libraries().await
+274    }
+275
+276    /// On-disk usage of downloaded content, for the disk-usage display.
+277    ///
+278    /// TRACES: UR-056 | DR-085
+279    pub async fn get_download_disk_usage(&self) -> Result<DownloadDiskUsage, RepoError> {
+280        self.offline.get_download_disk_usage().await
+281    }
+282
+283    /// Search only the live Jellyfin server (full library).
+284    pub async fn search_server_only(
+285        &self,
+286        query: &str,
+287        options: Option<SearchOptions>,
+288    ) -> Result<SearchResult, RepoError> {
+289        self.online
+290            .search(query, options)
+291            .await
+292            .map(ExcludeHidden::without_excluded)
+293    }
+294
+295    /// Merge cache and server search results into a single de-duplicated list.
+296    ///
+297    /// Ordering: local (cached/downloaded) items first, then server-only items
+298    /// appended. On a duplicate `id`, the server's item wins (fresher, more
+299    /// complete metadata) but keeps the local item's earlier position.
+300    pub fn merge_search_results(cache: SearchResult, server: SearchResult) -> SearchResult {
+301        use std::collections::HashMap;
+302
+303        // Index server items by id so we can (a) override duplicates with the
+304        // server's metadata and (b) know which server items are brand new.
+305        let mut server_by_id: HashMap<String, MediaItem> = HashMap::new();
+306        let mut server_order: Vec<String> = Vec::with_capacity(server.items.len());
+307        for item in server.items {
+308            if !server_by_id.contains_key(&item.id) {
+309                server_order.push(item.id.clone());
+310            }
+311            server_by_id.insert(item.id.clone(), item);
+312        }
+313
+314        let mut items: Vec<MediaItem> = Vec::new();
+315        let mut seen: std::collections::HashSet<String> = std::collections::HashSet::new();
+316
+317        // Local items first, in their original order. If the server also
+318        // returned this item, take the server's copy (newer metadata).
+319        for local in cache.items {
+320            if !seen.insert(local.id.clone()) {
+321                continue;
+322            }
+323            match server_by_id.remove(&local.id) {
+324                Some(server_item) => items.push(server_item),
+325                None => items.push(local),
+326            }
+327        }
+328
+329        // Then append server-only items, preserving the server's order.
+330        for id in server_order {
+331            if let Some(server_item) = server_by_id.remove(&id) {
+332                if seen.insert(id) {
+333                    items.push(server_item);
+334                }
+335            }
+336        }
+337
+338        let total_record_count = items.len();
+339        SearchResult {
+340            items,
+341            total_record_count,
+342        }
+343    }
+344
+345    /// Cache-first query: try cache, fall back to server on miss.
+346    ///
+347    /// 1. Check cache (100ms timeout applied by caller via cache_with_timeout)
+348    /// 2. If cache has meaningful content → return immediately (fast path)
+349    /// 3. If cache is empty/stale → query server (fresh data)
+350    /// 4. If server fails → return cache even if empty (offline fallback)
+351    ///
+352    /// Both legs are passed through [`ExcludeHidden`] before the "does the cache
+353    /// have content?" question is asked. This is the single place the cache and
+354    /// server results of a cache-first query converge, so applying the user's
+355    /// browsing exclusions here covers every query built on it at once — and
+356    /// filtering *before* the content check is what makes a cache page holding
+357    /// nothing but hidden items fall through to the server instead of being
+358    /// served as an empty listing.
+359    ///
+360    /// @req: UR-002 - Access media when online or offline
+361    /// @req: DR-013 - Repository pattern for online/offline data access
+362    ///
+363    /// TRACES: UR-002, UR-076 | DR-013, DR-209
+364    async fn parallel_race<T, F1, F2>(
+365        &self,
+366        cache_future: F1,
+367        server_future: F2,
+368    ) -> Result<T, RepoError>
+369    where
+370        T: MeaningfulContent + ExcludeHidden + Clone + Send + 'static,
+371        F1: std::future::Future<Output = Result<T, RepoError>> + Send,
+372        F2: std::future::Future<Output = Result<T, RepoError>> + Send,
+373    {
+374        // Try cache first (100ms timeout already applied by callers)
+375        let cache_result = cache_future.await.map(ExcludeHidden::without_excluded);
+376
+377        if let Ok(data) = &cache_result {
+378            if data.has_content() {
+379                debug!("[HybridRepo] Cache hit, returning immediately");
+380                return Ok(data.clone());
+381            }
+382        }
+383
+384        // Cache miss — fall back to server
+385        debug!("[HybridRepo] Cache miss, querying server");
+386        match server_future.await {
+387            Ok(data) => Ok(data.without_excluded()),
+388            Err(e) => {
+389                // Server failed, try to return cache even if empty
+390                cache_result.or(Err(e))
+391            }
+392        }
+393    }
+394
+395    /// [`Self::parallel_race`], plus a callback fired on the fast path so the
+396    /// caller can refresh the cache in the background.
+397    ///
+398    /// A plain cache hit answers from data that may be arbitrarily old, which
+399    /// is right for the *response* and wrong for what it leaves behind: per-user
+400    /// state (watch positions, favourites) only reaches the local tables when a
+401    /// server result is cached, so a surface that always hits cache never learns
+402    /// what another device did. `get_items` had a bespoke version of this; this
+403    /// is the same idea, reusable.
+404    ///
+405    /// The callback runs only on a cache hit — on a miss the server result is
+406    /// already being fetched and cached by the normal path.
+407    ///
+408    /// TRACES: UR-002, UR-025, UR-076 | DR-155, DR-209
+409    async fn race_with_refresh<T, F1, F2, R>(
+410        &self,
+411        cache_future: F1,
+412        server_future: F2,
+413        on_cache_hit: R,
+414    ) -> Result<T, RepoError>
+415    where
+416        T: MeaningfulContent + ExcludeHidden + Clone + Send + 'static,
+417        F1: std::future::Future<Output = Result<T, RepoError>> + Send,
+418        F2: std::future::Future<Output = Result<T, RepoError>> + Send,
+419        R: FnOnce(),
+420    {
+421        let cache_result = cache_future.await.map(ExcludeHidden::without_excluded);
+422
+423        if let Ok(data) = &cache_result {
+424            if data.has_content() {
+425                debug!("[HybridRepo] Cache hit, returning immediately (refreshing in background)");
+426                on_cache_hit();
+427                return Ok(data.clone());
+428            }
+429        }
+430
+431        debug!("[HybridRepo] Cache miss, querying server");
+432        match server_future.await {
+433            Ok(data) => Ok(data.without_excluded()),
+434            Err(e) => cache_result.or(Err(e)),
+435        }
+436    }
+437
+438    /// Simple timeout wrapper for cache queries (100ms timeout)
+439    ///
+440    /// @req: DR-013 - Repository pattern (cache-first with timeout)
+441    async fn cache_with_timeout<T>(
+442        &self,
+443        future: impl std::future::Future<Output = Result<T, RepoError>> + Send,
+444    ) -> Result<T, RepoError> {
+445        timeout(Duration::from_millis(100), future)
+446            .await
+447            .unwrap_or_else(|_| {
+448                Err(RepoError::Database {
+449                    message: "Cache query timeout".to_string(),
+450                })
+451            })
+452    }
+453}
+454
+455#[async_trait]
+456impl MediaRepository for HybridRepository {
+457    async fn get_libraries(&self) -> Result<Vec<Library>, RepoError> {
+458        // Cache-first (100ms). On a cache hit, refresh the cache from the server
+459        // in the background. On a miss, fetch from the server and persist so the
+460        // list is available on the next (possibly offline) startup.
+461        let cache_result = self.cache_with_timeout(self.offline.get_libraries()).await;
+462
+463        if let Ok(libs) = &cache_result {
+464            if libs.has_content() {
+465                debug!("[HybridRepo] Cache hit for libraries, returning immediately");
+466                let online = Arc::clone(&self.online);
+467                let offline = Arc::clone(&self.offline);
+468                tokio::spawn(async move {
+469                    if let Ok(server_libs) = online.get_libraries().await {
+470                        if !server_libs.is_empty() {
+471                            if let Err(e) = offline.save_libraries_to_cache(&server_libs).await {
+472                                warn!(
+473                                    "[HybridRepo] Background library cache update failed: {:?}",
+474                                    e
+475                                );
+476                            }
+477                        }
+478                    }
+479                });
+480                return cache_result;
+481            }
+482        }
+483
+484        // Cache miss — fetch from server and persist for offline use.
+485        match self.online.get_libraries().await {
+486            Ok(server_libs) => {
+487                if !server_libs.is_empty() {
+488                    if let Err(e) = self.offline.save_libraries_to_cache(&server_libs).await {
+489                        warn!(
+490                            "[HybridRepo] Failed to cache {} libraries: {:?}",
+491                            server_libs.len(),
+492                            e
+493                        );
+494                    }
+495                }
+496                Ok(server_libs)
+497            }
+498            Err(e) => cache_result.or(Err(e)),
+499        }
+500    }
+501
+502    async fn get_items(
+503        &self,
+504        parent_id: &str,
+505        options: Option<GetItemsOptions>,
+506    ) -> Result<SearchResult, RepoError> {
+507        let offline = Arc::clone(&self.offline);
+508        let offline_for_save = Arc::clone(&self.offline);
+509        let online = Arc::clone(&self.online);
+510        let parent_id = parent_id.to_string();
+511        let parent_id_clone = parent_id.clone();
+512        let parent_id_for_save = parent_id.clone();
+513        let opts_clone = options.clone();
+514
+515        // Start server request in background (non-blocking)
+516        let server_handle =
+517            tokio::spawn(async move { online.get_items(&parent_id_clone, options).await });
+518
+519        // Check cache first (fast, 100ms timeout).
+520        //
+521        // Exclusions are applied here rather than at each return below so the
+522        // "has content" decisions further down are made about what the user will
+523        // actually see. `get_items` is the one query that does not go through
+524        // `parallel_race` — it interleaves the downloads-only gate and a
+525        // background cache write — so it applies the filter itself.
+526        // TRACES: UR-076 | DR-209
+527        let cache_result = self
+528            .cache_with_timeout(async move { offline.get_items(&parent_id, opts_clone).await })
+529            .await
+530            .map(ExcludeHidden::without_excluded);
+531
+532        // Downloads-only gate: when the "Show all server media" toggle is off
+533        // (offline), an empty offline result is authoritative — the user asked
+534        // for downloaded media only and this library has none. Return it as-is
+535        // rather than falling through to the server, which would re-pad the page
+536        // with the full catalog and re-defeat the filter (DR-080). When the flag
+537        // is on (the default, and always so while reachable) behaviour below is
+538        // unchanged, including the background cache refresh on a hit.
+539        if !crate::repository::offline::include_catalog_browse() {
+540            if let Ok(data) = &cache_result {
+541                debug!(
+542                    "[HybridRepo] Downloads-only gate: returning offline result ({} items) as authoritative for parent {}",
+543                    data.items.len(),
+544                    &parent_id_for_save[..8.min(parent_id_for_save.len())]
+545                );
+546                // Abort the in-flight server request; we won't use it.
+547                server_handle.abort();
+548                return Ok(data.clone());
+549            }
+550        }
+551
+552        // Cache hit: return immediately, update cache in background
+553        if let Ok(data) = &cache_result {
+554            if data.has_content() {
+555                debug!(
+556                    "[HybridRepo] Cache hit for get_items, returning immediately for parent {}",
+557                    &parent_id_for_save[..8.min(parent_id_for_save.len())]
+558                );
+559                // Background: save server result to cache when it arrives
+560                tokio::spawn(async move {
+561                    match server_handle.await {
+562                        Ok(Ok(server_data)) if !server_data.items.is_empty() => {
+563                            if let Err(e) = offline_for_save
+564                                .save_to_cache(&parent_id_for_save, &server_data.items)
+565                                .await
+566                            {
+567                                warn!("[HybridRepo] Background cache update failed: {:?}", e);
+568                            } else {
+569                                debug!(
+570                                    "[HybridRepo] Background updated {} cached items for parent {}",
+571                                    server_data.items.len(),
+572                                    &parent_id_for_save[..8.min(parent_id_for_save.len())]
+573                                );
+574                            }
+575                        }
+576                        _ => {} // Server failed or returned empty — keep existing cache
+577                    }
+578                });
+579                return Ok(data.clone());
+580            }
+581        }
+582
+583        // Cache miss — wait for server result
+584        match server_handle.await {
+585            Ok(Ok(server_data)) => {
+586                if !server_data.items.is_empty() {
+587                    let items_clone = server_data.items.clone();
+588                    tokio::spawn(async move {
+589                        if let Err(e) = offline_for_save
+590                            .save_to_cache(&parent_id_for_save, &items_clone)
+591                            .await
+592                        {
+593                            warn!(
+594                                "[HybridRepo] Failed to save {} items to cache: {:?}",
+595                                items_clone.len(),
+596                                e
+597                            );
+598                        } else {
+599                            debug!(
+600                                "[HybridRepo] Saved {} items to cache for parent {}",
+601                                items_clone.len(),
+602                                &parent_id_for_save[..8.min(parent_id_for_save.len())]
+603                            );
+604                        }
+605                    });
+606                }
+607                // The cache keeps the server's full page (above) — an exclusion
+608                // is a view preference and can be undone, so hiding items from
+609                // the *cache* would make un-hiding them require a re-crawl. Only
+610                // what is handed back is filtered.
+611                // TRACES: UR-076 | DR-209
+612                Ok(server_data.without_excluded())
+613            }
+614            Ok(Err(e)) => cache_result.or(Err(e)),
+615            Err(join_err) => cache_result.or(Err(RepoError::Network {
+616                message: format!("Server task failed: {}", join_err),
+617            })),
+618        }
+619    }
+620
+621    /// A single item, cache-first — and, on a cache hit, refreshed in the
+622    /// background so the stored copy keeps up with the server.
+623    ///
+624    /// The background refresh is what carries per-user state home: caching an
+625    /// item runs `mirror_user_data`, which is the only path by which a watch
+626    /// position set on another device reaches the local `user_data` row the
+627    /// resume check reads. Without it a cache hit returned this device's own
+628    /// stale position forever and cross-device resume silently did nothing —
+629    /// `get_items` already refreshes this way, so browsing a season worked
+630    /// while opening the episode directly did not.
+631    ///
+632    /// The refreshed value lands for the *next* read rather than this one: the
+633    /// point of the cache-first race is to answer immediately.
+634    ///
+635    /// TRACES: UR-025, UR-002 | DR-155 | UT-152
+636    async fn get_item(&self, item_id: &str) -> Result<MediaItem, RepoError> {
+637        let offline = Arc::clone(&self.offline);
+638        let online = Arc::clone(&self.online);
+639        let item_id = item_id.to_string();
+640        let item_id_clone = item_id.clone();
+641
+642        let cache_future = self.cache_with_timeout(async move { offline.get_item(&item_id).await });
+643
+644        let online_for_refresh = Arc::clone(&self.online);
+645        let offline_for_save = Arc::clone(&self.offline);
+646        let refresh_id = item_id_clone.clone();
+647        let on_cache_hit = move || {
+648            tokio::spawn(async move {
+649                match online_for_refresh.get_item(&refresh_id).await {
+650                    Ok(fresh) => {
+651                        // `save_to_cache` files the row under a parent; the item's
+652                        // own parent keeps it where a later listing expects it.
+653                        let parent = fresh
+654                            .parent_id
+655                            .clone()
+656                            .unwrap_or_else(|| "item".to_string());
+657                        if let Err(e) = offline_for_save.save_to_cache(&parent, &[fresh]).await {
+658                            debug!("[HybridRepo] Background item refresh failed: {:?}", e);
+659                        }
+660                    }
+661                    Err(e) => debug!("[HybridRepo] Background item refresh unavailable: {:?}", e),
+662                }
+663            });
+664        };
+665
+666        let server_future = async move { online.get_item(&item_id_clone).await };
+667
+668        self.race_with_refresh(cache_future, server_future, on_cache_hit)
+669            .await
+670    }
+671
+672    async fn get_latest_items(
+673        &self,
+674        parent_id: &str,
+675        limit: Option<usize>,
+676    ) -> Result<Vec<MediaItem>, RepoError> {
+677        let offline = Arc::clone(&self.offline);
+678        let online = Arc::clone(&self.online);
+679        let parent_id = parent_id.to_string();
+680        let parent_id_clone = parent_id.clone();
+681        let limit_clone = limit;
+682
+683        let cache_future = self
+684            .cache_with_timeout(async move { offline.get_latest_items(&parent_id, limit).await });
+685
+686        let server_future =
+687            async move { online.get_latest_items(&parent_id_clone, limit_clone).await };
+688
+689        self.parallel_race(cache_future, server_future).await
+690    }
+691
+692    async fn get_resume_items(
+693        &self,
+694        parent_id: Option<&str>,
+695        limit: Option<usize>,
+696    ) -> Result<Vec<MediaItem>, RepoError> {
+697        let offline = Arc::clone(&self.offline);
+698        let online = Arc::clone(&self.online);
+699        let parent_id_str = parent_id.map(|s| s.to_string());
+700        let parent_id_clone = parent_id_str.clone();
+701        let limit_clone = limit;
+702
+703        let cache_future = self.cache_with_timeout(async move {
+704            offline
+705                .get_resume_items(parent_id_str.as_deref(), limit)
+706                .await
+707        });
+708
+709        let server_future = async move {
+710            online
+711                .get_resume_items(parent_id_clone.as_deref(), limit_clone)
+712                .await
+713        };
+714
+715        self.parallel_race(cache_future, server_future).await
+716    }
+717
+718    async fn get_next_up_episodes(
+719        &self,
+720        series_id: Option<&str>,
+721        limit: Option<usize>,
+722    ) -> Result<Vec<MediaItem>, RepoError> {
+723        // Next up is dynamic, always fetch from server
+724        self.online
+725            .get_next_up_episodes(series_id, limit)
+726            .await
+727            .map(ExcludeHidden::without_excluded)
+728    }
+729
+730    async fn get_recently_played_audio(
+731        &self,
+732        limit: Option<usize>,
+733    ) -> Result<Vec<MediaItem>, RepoError> {
+734        let offline = Arc::clone(&self.offline);
+735        let online = Arc::clone(&self.online);
+736        let limit_clone = limit;
+737
+738        let cache_future =
+739            self.cache_with_timeout(async move { offline.get_recently_played_audio(limit).await });
+740
+741        let server_future = async move { online.get_recently_played_audio(limit_clone).await };
+742
+743        self.parallel_race(cache_future, server_future).await
+744    }
+745
+746    async fn get_resume_movies(&self, limit: Option<usize>) -> Result<Vec<MediaItem>, RepoError> {
+747        let offline = Arc::clone(&self.offline);
+748        let online = Arc::clone(&self.online);
+749        let limit_clone = limit;
+750
+751        let cache_future =
+752            self.cache_with_timeout(async move { offline.get_resume_movies(limit).await });
+753
+754        let server_future = async move { online.get_resume_movies(limit_clone).await };
+755
+756        self.parallel_race(cache_future, server_future).await
+757    }
+758
+759    async fn get_rediscover_albums(
+760        &self,
+761        parent_id: Option<&str>,
+762        limit: Option<usize>,
+763    ) -> Result<Vec<MediaItem>, RepoError> {
+764        let offline = Arc::clone(&self.offline);
+765        let online = Arc::clone(&self.online);
+766        let parent_id_owned = parent_id.map(|s| s.to_string());
+767        let parent_id_clone = parent_id_owned.clone();
+768
+769        let cache_future = self.cache_with_timeout(async move {
+770            offline
+771                .get_rediscover_albums(parent_id_owned.as_deref(), limit)
+772                .await
+773        });
+774
+775        let server_future = async move {
+776            online
+777                .get_rediscover_albums(parent_id_clone.as_deref(), limit)
+778                .await
+779        };
+780
+781        self.parallel_race(cache_future, server_future).await
+782    }
+783
+784    async fn get_genres(&self, parent_id: Option<&str>) -> Result<Vec<Genre>, RepoError> {
+785        // Cache-first (100ms). On a cache hit, refresh the cached genre catalog
+786        // from the server in the background. On a miss, fetch from the server and
+787        // persist so the full genre list is available offline. Mirrors
+788        // get_libraries — NOT parallel_race, whose "any non-empty cache wins"
+789        // rule would pin genres to whatever sparse set the local albums yield.
+790        let parent_id_str = parent_id.map(|s| s.to_string());
+791
+792        let cache_offline = Arc::clone(&self.offline);
+793        let cache_pid = parent_id_str.clone();
+794        let cache_result = self
+795            .cache_with_timeout(async move { cache_offline.get_genres(cache_pid.as_deref()).await })
+796            .await;
+797
+798        if let Ok(genres) = &cache_result {
+799            if genres.has_content() {
+800                debug!("[HybridRepo] Cache hit for genres, returning immediately");
+801                let online = Arc::clone(&self.online);
+802                let offline = Arc::clone(&self.offline);
+803                let pid = parent_id_str.clone();
+804                tokio::spawn(async move {
+805                    if let Ok(server_genres) = online.get_genres(pid.as_deref()).await {
+806                        if !server_genres.is_empty() {
+807                            if let Err(e) = offline
+808                                .save_genres_to_cache(pid.as_deref(), &server_genres)
+809                                .await
+810                            {
+811                                warn!("[HybridRepo] Background genre cache update failed: {:?}", e);
+812                            }
+813                        }
+814                    }
+815                });
+816                return cache_result;
+817            }
+818        }
+819
+820        // Cache miss — fetch from server and persist for offline use.
+821        match self.online.get_genres(parent_id_str.as_deref()).await {
+822            Ok(server_genres) => {
+823                if !server_genres.is_empty() {
+824                    if let Err(e) = self
+825                        .offline
+826                        .save_genres_to_cache(parent_id_str.as_deref(), &server_genres)
+827                        .await
+828                    {
+829                        warn!(
+830                            "[HybridRepo] Failed to cache {} genres: {:?}",
+831                            server_genres.len(),
+832                            e
+833                        );
+834                    }
+835                }
+836                Ok(server_genres)
+837            }
+838            Err(e) => cache_result.or(Err(e)),
+839        }
+840    }
+841
+842    async fn search(
+843        &self,
+844        query: &str,
+845        options: Option<SearchOptions>,
+846    ) -> Result<SearchResult, RepoError> {
+847        let offline = Arc::clone(&self.offline);
+848        let online = Arc::clone(&self.online);
+849        let query = query.to_string();
+850        let query_clone = query.clone();
+851        let opts_clone = options.clone();
+852
+853        let cache_future =
+854            self.cache_with_timeout(async move { offline.search(&query, opts_clone).await });
+855
+856        let server_future = async move { online.search(&query_clone, options).await };
+857
+858        self.parallel_race(cache_future, server_future).await
+859    }
+860
+861    async fn get_playback_info(&self, item_id: &str) -> Result<PlaybackInfo, RepoError> {
+862        // Playback info requires server communication for transcoding decisions
+863        self.online.get_playback_info(item_id).await
+864    }
+865
+866    async fn get_audio_stream_url(&self, item_id: &str) -> Result<String, RepoError> {
+867        // Stream URLs require server communication - delegate to online repository
+868        self.online.get_audio_stream_url(item_id).await
+869    }
+870
+871    async fn get_audio_only_stream_url_for_video(
+872        &self,
+873        item_id: &str,
+874        media_source_id: Option<&str>,
+875        start_time_seconds: Option<f64>,
+876        audio_stream_index: Option<i32>,
+877    ) -> Result<String, RepoError> {
+878        // Audio-only transcode of a video requires the server - delegate to online.
+879        self.online
+880            .build_audio_only_stream_url_for_video(
+881                item_id,
+882                media_source_id,
+883                start_time_seconds,
+884                audio_stream_index,
+885            )
+886            .await
+887    }
+888
+889    async fn get_live_tv_channels(&self) -> Result<Vec<MediaItem>, RepoError> {
+890        // Live TV requires server communication - delegate to online repository
+891        self.online
+892            .get_live_tv_channels()
+893            .await
+894            .map(ExcludeHidden::without_excluded)
+895    }
+896
+897    async fn get_channels(&self) -> Result<SearchResult, RepoError> {
+898        // Plugin channels require server communication - delegate to online repository
+899        self.online
+900            .get_channels()
+901            .await
+902            .map(ExcludeHidden::without_excluded)
+903    }
+904
+905    async fn open_live_stream(&self, item_id: &str) -> Result<LiveStreamInfo, RepoError> {
+906        // Opening a live stream requires server communication - delegate to online
+907        self.online.open_live_stream(item_id).await
+908    }
+909
+910    async fn report_playback_start(
+911        &self,
+912        item_id: &str,
+913        position_ticks: i64,
+914    ) -> Result<(), RepoError> {
+915        // Playback reporting goes directly to server
+916        self.online
+917            .report_playback_start(item_id, position_ticks)
+918            .await
+919    }
+920
+921    async fn report_playback_progress(
+922        &self,
+923        item_id: &str,
+924        position_ticks: i64,
+925    ) -> Result<(), RepoError> {
+926        // Playback reporting goes directly to server
+927        self.online
+928            .report_playback_progress(item_id, position_ticks)
+929            .await
+930    }
+931
+932    async fn report_playback_stopped(
+933        &self,
+934        item_id: &str,
+935        position_ticks: i64,
+936    ) -> Result<(), RepoError> {
+937        // Playback reporting goes directly to server
+938        self.online
+939            .report_playback_stopped(item_id, position_ticks)
+940            .await
+941    }
+942
+943    fn get_image_url(
+944        &self,
+945        item_id: &str,
+946        image_type: ImageType,
+947        options: Option<ImageOptions>,
+948    ) -> String {
+949        // Always use online URL for images (thumbnail cache handles offline)
+950        self.online.get_image_url(item_id, image_type, options)
+951    }
+952
+953    fn get_subtitle_url(
+954        &self,
+955        item_id: &str,
+956        media_source_id: &str,
+957        stream_index: i32,
+958        format: &str,
+959    ) -> String {
+960        // Always use online URL for subtitles
+961        self.online
+962            .get_subtitle_url(item_id, media_source_id, stream_index, format)
+963    }
+964
+965    fn get_video_download_url(
+966        &self,
+967        item_id: &str,
+968        quality: &str,
+969        media_source_id: Option<&str>,
+970        source_audio_codec: Option<&str>,
+971    ) -> String {
+972        // Always use online URL for downloads
+973        self.online
+974            .get_video_download_url(item_id, quality, media_source_id, source_audio_codec)
+975    }
+976
+977    async fn mark_favorite(&self, item_id: &str) -> Result<(), RepoError> {
+978        // Write operations go directly to server
+979        self.online.mark_favorite(item_id).await
+980    }
+981
+982    async fn unmark_favorite(&self, item_id: &str) -> Result<(), RepoError> {
+983        // Write operations go directly to server
+984        self.online.unmark_favorite(item_id).await
+985    }
+986
+987    async fn clear_watch_history(&self, item_id: &str) -> Result<(), RepoError> {
+988        // Write operations go directly to server
+989        self.online.clear_watch_history(item_id).await
+990    }
+991
+992    async fn mark_played(&self, item_id: &str) -> Result<(), RepoError> {
+993        // Write operations go directly to server
+994        self.online.mark_played(item_id).await
+995    }
+996
+997    async fn get_person(&self, person_id: &str) -> Result<MediaItem, RepoError> {
+998        let offline = Arc::clone(&self.offline);
+999        let online = Arc::clone(&self.online);
+1000        let person_id = person_id.to_string();
+1001        let person_id_clone = person_id.clone();
+1002
+1003        let cache_future =
+1004            self.cache_with_timeout(async move { offline.get_person(&person_id).await });
+1005
+1006        let server_future = async move { online.get_person(&person_id_clone).await };
+1007
+1008        self.parallel_race(cache_future, server_future).await
+1009    }
+1010
+1011    async fn get_items_by_person(
+1012        &self,
+1013        person_id: &str,
+1014        options: Option<GetItemsOptions>,
+1015    ) -> Result<SearchResult, RepoError> {
+1016        let offline = Arc::clone(&self.offline);
+1017        let online = Arc::clone(&self.online);
+1018        let person_id = person_id.to_string();
+1019        let person_id_clone = person_id.clone();
+1020        let opts_clone = options.clone();
+1021
+1022        let cache_future = self.cache_with_timeout(async move {
+1023            offline.get_items_by_person(&person_id, opts_clone).await
+1024        });
+1025
+1026        let server_future =
+1027            async move { online.get_items_by_person(&person_id_clone, options).await };
+1028
+1029        self.parallel_race(cache_future, server_future).await
+1030    }
+1031
+1032    /// TRACES: UR-067 | DR-115
+1033    async fn get_favorites(
+1034        &self,
+1035        scope: SearchScope,
+1036        options: Option<GetItemsOptions>,
+1037    ) -> Result<SearchResult, RepoError> {
+1038        let cache_result = self.get_favorites_cache_only(scope, options.clone()).await;
+1039
+1040        // Downloads-only gate: with "Show all server media" off, an empty local
+1041        // result means "nothing favourited is on this device" and is
+1042        // authoritative. Falling through to the server here would re-pad the
+1043        // page with the full favourited catalog and defeat the filter (DR-080).
+1044        if !crate::repository::offline::include_catalog_browse() {
+1045            if let Ok(data) = &cache_result {
+1046                return Ok(data.clone());
+1047            }
+1048        }
+1049
+1050        if let Ok(data) = &cache_result {
+1051            if data.has_content() {
+1052                return Ok(data.clone());
+1053            }
+1054        }
+1055
+1056        // Cache miss — answer from the server, *saving through* on the way back.
+1057        // Every other read path persists what it fetches; skipping it here would
+1058        // mean the favourites page re-queries the server on every visit and the
+1059        // offline mirror (DR-114) never learns about favourites marked
+1060        // elsewhere, since this path is what fills it on a fresh install.
+1061        match self.get_favorites_server_only(scope, options).await {
+1062            Ok(data) => Ok(data),
+1063            Err(e) => cache_result.or(Err(e)),
+1064        }
+1065    }
+1066
+1067    async fn get_similar_items(
+1068        &self,
+1069        item_id: &str,
+1070        limit: Option<usize>,
+1071    ) -> Result<SearchResult, RepoError> {
+1072        let offline = Arc::clone(&self.offline);
+1073        let online = Arc::clone(&self.online);
+1074        let item_id = item_id.to_string();
+1075        let item_id_clone = item_id.clone();
+1076
+1077        let cache_future = self
+1078            .cache_with_timeout(async move { offline.get_similar_items(&item_id, limit).await });
+1079
+1080        let server_future = async move { online.get_similar_items(&item_id_clone, limit).await };
+1081
+1082        self.parallel_race(cache_future, server_future).await
+1083    }
+1084
+1085    // ===== Playlist Methods =====
+1086
+1087    async fn create_playlist(
+1088        &self,
+1089        name: &str,
+1090        item_ids: &[String],
+1091    ) -> Result<PlaylistCreatedResult, RepoError> {
+1092        // Write operation - delegate directly to server
+1093        self.online.create_playlist(name, item_ids).await
+1094    }
+1095
+1096    async fn delete_playlist(&self, playlist_id: &str) -> Result<(), RepoError> {
+1097        // Write operation - delegate directly to server
+1098        self.online.delete_playlist(playlist_id).await
+1099    }
+1100
+1101    async fn rename_playlist(&self, playlist_id: &str, name: &str) -> Result<(), RepoError> {
+1102        // Write operation - delegate directly to server
+1103        self.online.rename_playlist(playlist_id, name).await
+1104    }
+1105
+1106    async fn get_playlist_items(&self, playlist_id: &str) -> Result<Vec<PlaylistEntry>, RepoError> {
+1107        let offline = Arc::clone(&self.offline);
+1108        let offline_for_save = Arc::clone(&self.offline);
+1109        let online = Arc::clone(&self.online);
+1110        let playlist_id = playlist_id.to_string();
+1111        let playlist_id_clone = playlist_id.clone();
+1112        let playlist_id_for_save = playlist_id.clone();
+1113
+1114        // Start server request in background (non-blocking)
+1115        let server_handle =
+1116            tokio::spawn(async move { online.get_playlist_items(&playlist_id_clone).await });
+1117
+1118        // Check cache first (fast, 100ms timeout)
+1119        let cache_result = self
+1120            .cache_with_timeout(async move { offline.get_playlist_items(&playlist_id).await })
+1121            .await;
+1122
+1123        // Cache hit: return immediately, update cache in background
+1124        if let Ok(data) = &cache_result {
+1125            if data.has_content() {
+1126                debug!("[HybridRepo] Cache hit for playlist items, returning immediately");
+1127                tokio::spawn(async move {
+1128                    if let Ok(Ok(server_entries)) = server_handle.await {
+1129                        if let Err(e) = offline_for_save
+1130                            .save_playlist_items_to_cache(&playlist_id_for_save, &server_entries)
+1131                            .await
+1132                        {
+1133                            warn!("[HybridRepo] Failed to update playlist cache: {:?}", e);
+1134                        }
+1135                    }
+1136                });
+1137                return cache_result;
+1138            }
+1139        }
+1140
+1141        // Cache miss — wait for server result
+1142        match server_handle.await {
+1143            Ok(Ok(entries)) => {
+1144                let entries_clone = entries.clone();
+1145                tokio::spawn(async move {
+1146                    if let Err(e) = offline_for_save
+1147                        .save_playlist_items_to_cache(&playlist_id_for_save, &entries_clone)
+1148                        .await
+1149                    {
+1150                        warn!(
+1151                            "[HybridRepo] Failed to save playlist items to cache: {:?}",
+1152                            e
+1153                        );
+1154                    }
+1155                });
+1156                Ok(entries)
+1157            }
+1158            Ok(Err(e)) => cache_result.or(Err(e)),
+1159            Err(join_err) => cache_result.or(Err(RepoError::Network {
+1160                message: format!("Server task failed: {}", join_err),
+1161            })),
+1162        }
+1163    }
+1164
+1165    async fn add_to_playlist(
+1166        &self,
+1167        playlist_id: &str,
+1168        item_ids: &[String],
+1169    ) -> Result<(), RepoError> {
+1170        // Write operation - delegate directly to server
+1171        self.online.add_to_playlist(playlist_id, item_ids).await
+1172    }
+1173
+1174    async fn remove_from_playlist(
+1175        &self,
+1176        playlist_id: &str,
+1177        entry_ids: &[String],
+1178    ) -> Result<(), RepoError> {
+1179        // Write operation - delegate directly to server
+1180        self.online
+1181            .remove_from_playlist(playlist_id, entry_ids)
+1182            .await
+1183    }
+1184
+1185    async fn move_playlist_item(
+1186        &self,
+1187        playlist_id: &str,
+1188        item_id: &str,
+1189        new_index: u32,
+1190    ) -> Result<(), RepoError> {
+1191        // Write operation - delegate directly to server
+1192        self.online
+1193            .move_playlist_item(playlist_id, item_id, new_index)
+1194            .await
+1195    }
+1196}
+1197
+1198#[cfg(test)]
+1199mod tests {
+1200    // `GATE_TEST_LOCK` below serialises the tests that flip the process-global
+1201    // `INCLUDE_CATALOG_BROWSE` flag, so its guard is deliberately held across
+1202    // the `.await` of the repository call under test — that await *is* the
+1203    // critical section. This is not the production deadlock hazard the lint
+1204    // targets: the lock is test-only, uncontended outside these tests, and each
+1205    // `#[tokio::test]` runs on its own single-threaded runtime, so a held guard
+1206    // cannot block another task on the same worker. Restructuring around it
+1207    // would reintroduce the flag race the lock exists to prevent.
+1208    #![allow(clippy::await_holding_lock)]
+1209
+1210    use super::*;
+1211    use std::sync::Mutex;
+1212
+1213    /// Mock offline repository that tracks queries and saves
+1214    struct MockOfflineRepo {
+1215        items: Arc<Mutex<Vec<MediaItem>>>,
+1216        query_count: Arc<Mutex<usize>>,
+1217        save_count: Arc<Mutex<usize>>,
+1218    }
+1219
+1220    impl MockOfflineRepo {
+1221        fn new() -> Self {
+1222            Self {
+1223                items: Arc::new(Mutex::new(Vec::new())),
+1224                query_count: Arc::new(Mutex::new(0)),
+1225                save_count: Arc::new(Mutex::new(0)),
+1226            }
+1227        }
+1228
+1229        fn get_query_count(&self) -> usize {
+1230            *self.query_count.lock_safe()
+1231        }
+1232
+1233        fn get_save_count(&self) -> usize {
+1234            *self.save_count.lock_safe()
+1235        }
+1236
+1237        async fn save_to_cache(
+1238            &self,
+1239            _parent_id: &str,
+1240            items: &[MediaItem],
+1241        ) -> Result<usize, RepoError> {
+1242            *self.save_count.lock_safe() += 1;
+1243            *self.items.lock_safe() = items.to_vec();
+1244            Ok(items.len())
+1245        }
+1246    }
+1247
+1248    #[async_trait]
+1249    impl MediaRepository for MockOfflineRepo {
+1250        async fn get_libraries(&self) -> Result<Vec<Library>, RepoError> {
+1251            unimplemented!()
+1252        }
+1253
+1254        async fn get_items(
+1255            &self,
+1256            _parent_id: &str,
+1257            _options: Option<GetItemsOptions>,
+1258        ) -> Result<SearchResult, RepoError> {
+1259            *self.query_count.lock_safe() += 1;
+1260            let items = self.items.lock_safe().clone();
+1261            let count = items.len();
+1262            Ok(SearchResult {
+1263                items,
+1264                total_record_count: count,
+1265            })
+1266        }
+1267
+1268        async fn get_item(&self, _item_id: &str) -> Result<MediaItem, RepoError> {
+1269            unimplemented!()
+1270        }
+1271
+1272        async fn get_latest_items(
+1273            &self,
+1274            _parent_id: &str,
+1275            _limit: Option<usize>,
+1276        ) -> Result<Vec<MediaItem>, RepoError> {
+1277            unimplemented!()
+1278        }
+1279
+1280        async fn get_resume_items(
+1281            &self,
+1282            _parent_id: Option<&str>,
+1283            _limit: Option<usize>,
+1284        ) -> Result<Vec<MediaItem>, RepoError> {
+1285            unimplemented!()
+1286        }
+1287
+1288        async fn get_next_up_episodes(
+1289            &self,
+1290            _series_id: Option<&str>,
+1291            _limit: Option<usize>,
+1292        ) -> Result<Vec<MediaItem>, RepoError> {
+1293            unimplemented!()
+1294        }
+1295
+1296        async fn get_recently_played_audio(
+1297            &self,
+1298            _limit: Option<usize>,
+1299        ) -> Result<Vec<MediaItem>, RepoError> {
+1300            unimplemented!()
+1301        }
+1302
+1303        async fn get_resume_movies(
+1304            &self,
+1305            _limit: Option<usize>,
+1306        ) -> Result<Vec<MediaItem>, RepoError> {
+1307            unimplemented!()
+1308        }
+1309
+1310        async fn get_rediscover_albums(
+1311            &self,
+1312            _parent_id: Option<&str>,
+1313            _limit: Option<usize>,
+1314        ) -> Result<Vec<MediaItem>, RepoError> {
+1315            unimplemented!()
+1316        }
+1317
+1318        async fn get_genres(&self, _parent_id: Option<&str>) -> Result<Vec<Genre>, RepoError> {
+1319            unimplemented!()
+1320        }
+1321
+1322        async fn search(
+1323            &self,
+1324            _query: &str,
+1325            _options: Option<SearchOptions>,
+1326        ) -> Result<SearchResult, RepoError> {
+1327            unimplemented!()
+1328        }
+1329
+1330        async fn get_playback_info(&self, _item_id: &str) -> Result<PlaybackInfo, RepoError> {
+1331            unimplemented!()
+1332        }
+1333
+1334        async fn get_audio_stream_url(&self, _item_id: &str) -> Result<String, RepoError> {
+1335            unimplemented!()
+1336        }
+1337
+1338        async fn get_audio_only_stream_url_for_video(
+1339            &self,
+1340            _item_id: &str,
+1341            _media_source_id: Option<&str>,
+1342            _start_time_seconds: Option<f64>,
+1343            _audio_stream_index: Option<i32>,
+1344        ) -> Result<String, RepoError> {
+1345            unimplemented!()
+1346        }
+1347
+1348        async fn get_live_tv_channels(&self) -> Result<Vec<MediaItem>, RepoError> {
+1349            unimplemented!()
+1350        }
+1351
+1352        async fn get_channels(&self) -> Result<SearchResult, RepoError> {
+1353            unimplemented!()
+1354        }
+1355
+1356        async fn open_live_stream(&self, _item_id: &str) -> Result<LiveStreamInfo, RepoError> {
+1357            unimplemented!()
+1358        }
+1359
+1360        async fn report_playback_start(
+1361            &self,
+1362            _item_id: &str,
+1363            _position_ticks: i64,
+1364        ) -> Result<(), RepoError> {
+1365            unimplemented!()
+1366        }
+1367
+1368        async fn report_playback_progress(
+1369            &self,
+1370            _item_id: &str,
+1371            _position_ticks: i64,
+1372        ) -> Result<(), RepoError> {
+1373            unimplemented!()
+1374        }
+1375
+1376        async fn report_playback_stopped(
+1377            &self,
+1378            _item_id: &str,
+1379            _position_ticks: i64,
+1380        ) -> Result<(), RepoError> {
+1381            unimplemented!()
+1382        }
+1383
+1384        fn get_image_url(
+1385            &self,
+1386            _item_id: &str,
+1387            _image_type: ImageType,
+1388            _options: Option<ImageOptions>,
+1389        ) -> String {
+1390            unimplemented!()
+1391        }
+1392
+1393        fn get_subtitle_url(
+1394            &self,
+1395            _item_id: &str,
+1396            _media_source_id: &str,
+1397            _stream_index: i32,
+1398            _format: &str,
+1399        ) -> String {
+1400            unimplemented!()
+1401        }
+1402
+1403        fn get_video_download_url(
+1404            &self,
+1405            _item_id: &str,
+1406            _quality: &str,
+1407            _media_source_id: Option<&str>,
+1408            _source_audio_codec: Option<&str>,
+1409        ) -> String {
+1410            unimplemented!()
+1411        }
+1412
+1413        async fn mark_favorite(&self, _item_id: &str) -> Result<(), RepoError> {
+1414            unimplemented!()
+1415        }
+1416
+1417        async fn unmark_favorite(&self, _item_id: &str) -> Result<(), RepoError> {
+1418            unimplemented!()
+1419        }
+1420
+1421        async fn get_favorites(
+1422            &self,
+1423            _scope: SearchScope,
+1424            _options: Option<GetItemsOptions>,
+1425        ) -> Result<SearchResult, RepoError> {
+1426            unimplemented!()
+1427        }
+1428
+1429        async fn clear_watch_history(&self, _item_id: &str) -> Result<(), RepoError> {
+1430            unimplemented!()
+1431        }
+1432
+1433        async fn mark_played(&self, _item_id: &str) -> Result<(), RepoError> {
+1434            unimplemented!()
+1435        }
+1436
+1437        async fn get_person(&self, _person_id: &str) -> Result<MediaItem, RepoError> {
+1438            unimplemented!()
+1439        }
+1440
+1441        async fn get_items_by_person(
+1442            &self,
+1443            _person_id: &str,
+1444            _options: Option<GetItemsOptions>,
+1445        ) -> Result<SearchResult, RepoError> {
+1446            unimplemented!()
+1447        }
+1448
+1449        async fn get_similar_items(
+1450            &self,
+1451            _item_id: &str,
+1452            _limit: Option<usize>,
+1453        ) -> Result<SearchResult, RepoError> {
+1454            unimplemented!()
+1455        }
+1456
+1457        async fn create_playlist(
+1458            &self,
+1459            _name: &str,
+1460            _item_ids: &[String],
+1461        ) -> Result<PlaylistCreatedResult, RepoError> {
+1462            unimplemented!()
+1463        }
+1464
+1465        async fn delete_playlist(&self, _playlist_id: &str) -> Result<(), RepoError> {
+1466            unimplemented!()
+1467        }
+1468
+1469        async fn rename_playlist(&self, _playlist_id: &str, _name: &str) -> Result<(), RepoError> {
+1470            unimplemented!()
+1471        }
+1472
+1473        async fn get_playlist_items(
+1474            &self,
+1475            _playlist_id: &str,
+1476        ) -> Result<Vec<PlaylistEntry>, RepoError> {
+1477            unimplemented!()
+1478        }
+1479
+1480        async fn add_to_playlist(
+1481            &self,
+1482            _playlist_id: &str,
+1483            _item_ids: &[String],
+1484        ) -> Result<(), RepoError> {
+1485            unimplemented!()
+1486        }
+1487
+1488        async fn remove_from_playlist(
+1489            &self,
+1490            _playlist_id: &str,
+1491            _entry_ids: &[String],
+1492        ) -> Result<(), RepoError> {
+1493            unimplemented!()
+1494        }
+1495
+1496        async fn move_playlist_item(
+1497            &self,
+1498            _playlist_id: &str,
+1499            _item_id: &str,
+1500            _new_index: u32,
+1501        ) -> Result<(), RepoError> {
+1502            unimplemented!()
+1503        }
+1504    }
+1505
+1506    /// Mock online repository that returns predefined items
+1507    struct MockOnlineRepo {
+1508        items: Vec<MediaItem>,
+1509        query_count: Arc<Mutex<usize>>,
+1510    }
+1511
+1512    impl MockOnlineRepo {
+1513        fn new(items: Vec<MediaItem>) -> Self {
+1514            Self {
+1515                items,
+1516                query_count: Arc::new(Mutex::new(0)),
+1517            }
+1518        }
+1519
+1520        fn get_query_count(&self) -> usize {
+1521            *self.query_count.lock_safe()
+1522        }
+1523    }
+1524
+1525    #[async_trait]
+1526    impl MediaRepository for MockOnlineRepo {
+1527        async fn get_libraries(&self) -> Result<Vec<Library>, RepoError> {
+1528            unimplemented!()
+1529        }
+1530
+1531        async fn get_items(
+1532            &self,
+1533            _parent_id: &str,
+1534            _options: Option<GetItemsOptions>,
+1535        ) -> Result<SearchResult, RepoError> {
+1536            *self.query_count.lock_safe() += 1;
+1537            Ok(SearchResult {
+1538                items: self.items.clone(),
+1539                total_record_count: self.items.len(),
+1540            })
+1541        }
+1542
+1543        async fn get_item(&self, _item_id: &str) -> Result<MediaItem, RepoError> {
+1544            unimplemented!()
+1545        }
+1546
+1547        async fn get_latest_items(
+1548            &self,
+1549            _parent_id: &str,
+1550            _limit: Option<usize>,
+1551        ) -> Result<Vec<MediaItem>, RepoError> {
+1552            unimplemented!()
+1553        }
+1554
+1555        async fn get_resume_items(
+1556            &self,
+1557            _parent_id: Option<&str>,
+1558            _limit: Option<usize>,
+1559        ) -> Result<Vec<MediaItem>, RepoError> {
+1560            unimplemented!()
+1561        }
+1562
+1563        async fn get_next_up_episodes(
+1564            &self,
+1565            _series_id: Option<&str>,
+1566            _limit: Option<usize>,
+1567        ) -> Result<Vec<MediaItem>, RepoError> {
+1568            unimplemented!()
+1569        }
+1570
+1571        async fn get_recently_played_audio(
+1572            &self,
+1573            _limit: Option<usize>,
+1574        ) -> Result<Vec<MediaItem>, RepoError> {
+1575            unimplemented!()
+1576        }
+1577
+1578        async fn get_resume_movies(
+1579            &self,
+1580            _limit: Option<usize>,
+1581        ) -> Result<Vec<MediaItem>, RepoError> {
+1582            unimplemented!()
+1583        }
+1584
+1585        async fn get_rediscover_albums(
+1586            &self,
+1587            _parent_id: Option<&str>,
+1588            _limit: Option<usize>,
+1589        ) -> Result<Vec<MediaItem>, RepoError> {
+1590            unimplemented!()
+1591        }
+1592
+1593        async fn get_genres(&self, _parent_id: Option<&str>) -> Result<Vec<Genre>, RepoError> {
+1594            unimplemented!()
+1595        }
+1596
+1597        async fn search(
+1598            &self,
+1599            _query: &str,
+1600            _options: Option<SearchOptions>,
+1601        ) -> Result<SearchResult, RepoError> {
+1602            unimplemented!()
+1603        }
+1604
+1605        async fn get_playback_info(&self, _item_id: &str) -> Result<PlaybackInfo, RepoError> {
+1606            unimplemented!()
+1607        }
+1608
+1609        async fn get_audio_stream_url(&self, _item_id: &str) -> Result<String, RepoError> {
+1610            unimplemented!()
+1611        }
+1612
+1613        async fn get_audio_only_stream_url_for_video(
+1614            &self,
+1615            _item_id: &str,
+1616            _media_source_id: Option<&str>,
+1617            _start_time_seconds: Option<f64>,
+1618            _audio_stream_index: Option<i32>,
+1619        ) -> Result<String, RepoError> {
+1620            unimplemented!()
+1621        }
+1622
+1623        async fn get_live_tv_channels(&self) -> Result<Vec<MediaItem>, RepoError> {
+1624            unimplemented!()
+1625        }
+1626
+1627        async fn get_channels(&self) -> Result<SearchResult, RepoError> {
+1628            unimplemented!()
+1629        }
+1630
+1631        async fn open_live_stream(&self, _item_id: &str) -> Result<LiveStreamInfo, RepoError> {
+1632            unimplemented!()
+1633        }
+1634
+1635        async fn report_playback_start(
+1636            &self,
+1637            _item_id: &str,
+1638            _position_ticks: i64,
+1639        ) -> Result<(), RepoError> {
+1640            unimplemented!()
+1641        }
+1642
+1643        async fn report_playback_progress(
+1644            &self,
+1645            _item_id: &str,
+1646            _position_ticks: i64,
+1647        ) -> Result<(), RepoError> {
+1648            unimplemented!()
+1649        }
+1650
+1651        async fn report_playback_stopped(
+1652            &self,
+1653            _item_id: &str,
+1654            _position_ticks: i64,
+1655        ) -> Result<(), RepoError> {
+1656            unimplemented!()
+1657        }
+1658
+1659        fn get_image_url(
+1660            &self,
+1661            _item_id: &str,
+1662            _image_type: ImageType,
+1663            _options: Option<ImageOptions>,
+1664        ) -> String {
+1665            unimplemented!()
+1666        }
+1667
+1668        fn get_subtitle_url(
+1669            &self,
+1670            _item_id: &str,
+1671            _media_source_id: &str,
+1672            _stream_index: i32,
+1673            _format: &str,
+1674        ) -> String {
+1675            unimplemented!()
+1676        }
+1677
+1678        fn get_video_download_url(
+1679            &self,
+1680            _item_id: &str,
+1681            _quality: &str,
+1682            _media_source_id: Option<&str>,
+1683            _source_audio_codec: Option<&str>,
+1684        ) -> String {
+1685            unimplemented!()
+1686        }
+1687
+1688        async fn mark_favorite(&self, _item_id: &str) -> Result<(), RepoError> {
+1689            unimplemented!()
+1690        }
+1691
+1692        async fn unmark_favorite(&self, _item_id: &str) -> Result<(), RepoError> {
+1693            unimplemented!()
+1694        }
+1695
+1696        async fn get_favorites(
+1697            &self,
+1698            _scope: SearchScope,
+1699            _options: Option<GetItemsOptions>,
+1700        ) -> Result<SearchResult, RepoError> {
+1701            unimplemented!()
+1702        }
+1703
+1704        async fn clear_watch_history(&self, _item_id: &str) -> Result<(), RepoError> {
+1705            unimplemented!()
+1706        }
+1707
+1708        async fn mark_played(&self, _item_id: &str) -> Result<(), RepoError> {
+1709            unimplemented!()
+1710        }
+1711
+1712        async fn get_person(&self, _person_id: &str) -> Result<MediaItem, RepoError> {
+1713            unimplemented!()
+1714        }
+1715
+1716        async fn get_items_by_person(
+1717            &self,
+1718            _person_id: &str,
+1719            _options: Option<GetItemsOptions>,
+1720        ) -> Result<SearchResult, RepoError> {
+1721            unimplemented!()
+1722        }
+1723
+1724        async fn get_similar_items(
+1725            &self,
+1726            _item_id: &str,
+1727            _limit: Option<usize>,
+1728        ) -> Result<SearchResult, RepoError> {
+1729            unimplemented!()
+1730        }
+1731
+1732        async fn create_playlist(
+1733            &self,
+1734            _name: &str,
+1735            _item_ids: &[String],
+1736        ) -> Result<PlaylistCreatedResult, RepoError> {
+1737            unimplemented!()
+1738        }
+1739
+1740        async fn delete_playlist(&self, _playlist_id: &str) -> Result<(), RepoError> {
+1741            unimplemented!()
+1742        }
+1743
+1744        async fn rename_playlist(&self, _playlist_id: &str, _name: &str) -> Result<(), RepoError> {
+1745            unimplemented!()
+1746        }
+1747
+1748        async fn get_playlist_items(
+1749            &self,
+1750            _playlist_id: &str,
+1751        ) -> Result<Vec<PlaylistEntry>, RepoError> {
+1752            unimplemented!()
+1753        }
+1754
+1755        async fn add_to_playlist(
+1756            &self,
+1757            _playlist_id: &str,
+1758            _item_ids: &[String],
+1759        ) -> Result<(), RepoError> {
+1760            unimplemented!()
+1761        }
+1762
+1763        async fn remove_from_playlist(
+1764            &self,
+1765            _playlist_id: &str,
+1766            _entry_ids: &[String],
+1767        ) -> Result<(), RepoError> {
+1768            unimplemented!()
+1769        }
+1770
+1771        async fn move_playlist_item(
+1772            &self,
+1773            _playlist_id: &str,
+1774            _item_id: &str,
+1775            _new_index: u32,
+1776        ) -> Result<(), RepoError> {
+1777            unimplemented!()
+1778        }
+1779    }
+1780
+1781    fn create_test_item(id: &str, name: &str) -> MediaItem {
+1782        MediaItem {
+1783            id: id.to_string(),
+1784            name: name.to_string(),
+1785            item_type: "Movie".to_string(),
+1786            kind: crate::domain::MediaKind::Movie,
+1787            is_folder: false,
+1788            server_id: "test-server".to_string(),
+1789            parent_id: Some("parent-123".to_string()),
+1790            library_id: Some("library-456".to_string()),
+1791            overview: Some("Test overview".to_string()),
+1792            genres: Some(vec!["Action".to_string(), "Adventure".to_string()]),
+1793            runtime_ticks: Some(7200000000),
+1794            duration_ms: Some(720000),
+1795            production_year: Some(2024),
+1796            premiere_date: None,
+1797            community_rating: Some(8.5),
+1798            official_rating: Some("PG-13".to_string()),
+1799            primary_image_tag: Some("image-tag-123".to_string()),
+1800            image_id: Some("image-tag-123".to_string()),
+1801            backdrop_image_tags: Some(vec!["backdrop-1".to_string()]),
+1802            parent_backdrop_image_tags: None,
+1803            album_id: None,
+1804            album_name: None,
+1805            album_artist: None,
+1806            artists: None,
+1807            artist_items: None,
+1808            index_number: None,
+1809            series_id: None,
+1810            series_name: None,
+1811            season_id: None,
+1812            season_name: None,
+1813            parent_index_number: None,
+1814            user_data: None,
+1815            media_streams: None,
+1816            media_sources: None,
+1817            people: None,
+1818        }
+1819    }
+1820
+1821    /// Helper to test the caching logic
+1822    struct TestHybridRepo {
+1823        offline: Arc<MockOfflineRepo>,
+1824        online: Arc<MockOnlineRepo>,
+1825    }
+1826
+1827    impl TestHybridRepo {
+1828        fn new(server_items: Vec<MediaItem>) -> Self {
+1829            let offline = Arc::new(MockOfflineRepo::new());
+1830            let online = Arc::new(MockOnlineRepo::new(server_items));
+1831            Self { offline, online }
+1832        }
+1833
+1834        /// Test version of get_items that implements the cache logic
+1835        async fn get_items(&self, parent_id: &str) -> Result<SearchResult, RepoError> {
+1836            let offline = Arc::clone(&self.offline);
+1837            let offline_for_save = Arc::clone(&self.offline);
+1838            let online = Arc::clone(&self.online);
+1839            let parent_id = parent_id.to_string();
+1840            let parent_id_clone = parent_id.clone();
+1841            let parent_id_for_save = parent_id.clone();
+1842
+1843            // Check cache first
+1844            let cache_future = async move { offline.get_items(&parent_id, None).await };
+1845
+1846            let server_future = async move { online.get_items(&parent_id_clone, None).await };
+1847
+1848            // Wait for both, prefer cache if available
+1849            let (cache_result, server_result) = tokio::join!(cache_future, server_future);
+1850
+1851            // Check if cache had meaningful content
+1852            let cache_had_content = cache_result
+1853                .as_ref()
+1854                .map(|data| data.has_content())
+1855                .unwrap_or(false);
+1856
+1857            // Prefer cache if it has content (mimics hybrid.rs get_items logic)
+1858            let result = if cache_had_content {
+1859                cache_result?
+1860            } else {
+1861                // Use server result and save to cache for next time
+1862                let server_data = server_result?;
+1863
+1864                if !server_data.items.is_empty() {
+1865                    let items_clone = server_data.items.clone();
+1866                    offline_for_save
+1867                        .save_to_cache(&parent_id_for_save, &items_clone)
+1868                        .await?;
+1869                }
+1870
+1871                server_data
+1872            };
+1873
+1874            Ok(result)
+1875        }
+1876
+1877        /// Test version mirroring the real `HybridRepository::get_items`
+1878        /// downloads-only gate: when `include_catalog_browse()` is false, the
+1879        /// offline result is authoritative and the server is NOT queried, even
+1880        /// when the cache is empty. Otherwise falls through to the normal
+1881        /// cache-first logic in `get_items`.
+1882        async fn get_items_gated(&self, parent_id: &str) -> Result<SearchResult, RepoError> {
+1883            if !crate::repository::offline::include_catalog_browse() {
+1884                let items = self.offline.get_items(parent_id, None).await?;
+1885                // Authoritative: return as-is, never touch the server.
+1886                return Ok(items);
+1887            }
+1888            self.get_items(parent_id).await
+1889        }
+1890    }
+1891
+1892    /// Serialize tests that mutate the process-global INCLUDE_CATALOG_BROWSE
+1893    /// flag, and always restore it to the default (true) afterwards.
+1894    static GATE_TEST_LOCK: Mutex<()> = Mutex::new(());
+1895
+1896    /// UT-070: with the downloads-only gate off, an empty offline result is
+1897    /// returned as-is and the server is NOT queried.
+1898    ///
+1899    /// @req-test: UR-052 - Offline "downloaded only" filtering
+1900    /// @req-test: DR-080 - Empty offline result is authoritative when gate off
+1901    #[tokio::test]
+1902    async fn test_get_items_gate_off_empty_does_not_query_server() {
+1903        let _guard = GATE_TEST_LOCK.lock_safe();
+1904        crate::repository::offline::set_include_catalog_browse(false);
+1905
+1906        // Server has items, cache is empty. Gate off ⇒ the server must be ignored.
+1907        let repo = TestHybridRepo::new(vec![
+1908            create_test_item("s-1", "Server 1"),
+1909            create_test_item("s-2", "Server 2"),
+1910        ]);
+1911
+1912        let result = repo.get_items_gated("parent-123").await.unwrap();
+1913
+1914        assert_eq!(
+1915            result.items.len(),
+1916            0,
+1917            "empty offline result is authoritative when the gate is off"
+1918        );
+1919        assert_eq!(
+1920            repo.online.get_query_count(),
+1921            0,
+1922            "server must NOT be queried when the gate is off"
+1923        );
+1924
+1925        crate::repository::offline::set_include_catalog_browse(true);
+1926    }
+1927
+1928    /// Guard the online path: with the gate ON and an empty cache, get_items
+1929    /// still falls through to the server (unchanged behaviour).
+1930    ///
+1931    /// @req-test: UR-052 - Offline "downloaded only" filtering
+1932    /// @req-test: DR-080 - Gate on ⇒ empty cache still queries the server
+1933    #[tokio::test]
+1934    async fn test_get_items_gate_on_empty_falls_through_to_server() {
+1935        let _guard = GATE_TEST_LOCK.lock_safe();
+1936        crate::repository::offline::set_include_catalog_browse(true);
+1937
+1938        let repo = TestHybridRepo::new(vec![
+1939            create_test_item("s-1", "Server 1"),
+1940            create_test_item("s-2", "Server 2"),
+1941        ]);
+1942
+1943        let result = repo.get_items_gated("parent-123").await.unwrap();
+1944
+1945        assert_eq!(result.items.len(), 2, "server result used on empty cache");
+1946        assert_eq!(
+1947            repo.online.get_query_count(),
+1948            1,
+1949            "server IS queried when the gate is on and the cache is empty"
+1950        );
+1951    }
+1952
+1953    /// Test cache miss saves server data to cache for next time
+1954    ///
+1955    /// @req-test: UR-002 - Access media when online or offline
+1956    /// @req-test: DR-013 - Repository pattern for online/offline data access
+1957    /// @req-test: DR-012 - Local database for media metadata cache
+1958    #[tokio::test]
+1959    async fn test_cache_miss_saves_to_cache() {
+1960        // Setup: Server has 3 items, cache is empty
+1961        let server_items = vec![
+1962            create_test_item("item-1", "Movie 1"),
+1963            create_test_item("item-2", "Movie 2"),
+1964            create_test_item("item-3", "Movie 3"),
+1965        ];
+1966
+1967        let repo = TestHybridRepo::new(server_items.clone());
+1968
+1969        // First request - cache miss
+1970        let result = repo.get_items("parent-123").await.unwrap();
+1971
+1972        // Should return server items
+1973        assert_eq!(result.items.len(), 3);
+1974        assert_eq!(result.items[0].id, "item-1");
+1975
+1976        // Should have queried both cache and server
+1977        assert_eq!(
+1978            repo.offline.get_query_count(),
+1979            1,
+1980            "Cache should be queried once"
+1981        );
+1982        assert_eq!(
+1983            repo.online.get_query_count(),
+1984            1,
+1985            "Server should be queried once"
+1986        );
+1987
+1988        // Should have saved to cache
+1989        assert_eq!(
+1990            repo.offline.get_save_count(),
+1991            1,
+1992            "Should save to cache on miss"
+1993        );
+1994    }
+1995
+1996    /// Test cache hit prevents duplicate save to cache
+1997    ///
+1998    /// Verifies parallel racing strategy: both cache and server are queried,
+1999    /// but when cache has content, it's used and no duplicate save occurs.
+2000    ///
+2001    /// @req-test: UR-002 - Access media when online or offline
+2002    /// @req-test: DR-013 - Repository pattern for online/offline data access
+2003    /// @req-test: DR-012 - Local database cache (avoid duplicate writes)
+2004    #[tokio::test]
+2005    async fn test_cache_hit_no_save() {
+2006        // Setup: Server has 3 items, we'll pre-populate cache
+2007        let server_items = vec![
+2008            create_test_item("item-1", "Movie 1"),
+2009            create_test_item("item-2", "Movie 2"),
+2010            create_test_item("item-3", "Movie 3"),
+2011        ];
+2012
+2013        let repo = TestHybridRepo::new(server_items.clone());
+2014
+2015        // Pre-populate cache
+2016        repo.offline
+2017            .save_to_cache("parent-123", &server_items)
+2018            .await
+2019            .unwrap();
+2020        assert_eq!(repo.offline.get_save_count(), 1);
+2021
+2022        // Second request - cache hit
+2023        let result = repo.get_items("parent-123").await.unwrap();
+2024
+2025        // Should return cached items
+2026        assert_eq!(result.items.len(), 3);
+2027        assert_eq!(result.items[0].id, "item-1");
+2028
+2029        // Should have queried cache and server (parallel race)
+2030        assert_eq!(repo.offline.get_query_count(), 1, "Cache should be queried");
+2031        assert_eq!(
+2032            repo.online.get_query_count(),
+2033            1,
+2034            "Server is queried in parallel"
+2035        );
+2036
+2037        // Should NOT have saved again (no duplicate save)
+2038        assert_eq!(
+2039            repo.offline.get_save_count(),
+2040            1,
+2041            "Should NOT save when using cache"
+2042        );
+2043    }
+2044
+2045    /// Test empty results are not saved to cache
+2046    ///
+2047    /// @req-test: DR-013 - Repository pattern (edge case handling)
+2048    /// @req-test: DR-012 - Local database cache (avoid saving empty data)
+2049    #[tokio::test]
+2050    async fn test_empty_cache_returns_empty_result() {
+2051        // Setup: Server has no items
+2052        let repo = TestHybridRepo::new(vec![]);
+2053
+2054        // Request with empty server
+2055        let result = repo.get_items("parent-123").await.unwrap();
+2056
+2057        // Should return empty result
+2058        assert_eq!(result.items.len(), 0);
+2059
+2060        // Should NOT save empty results
+2061        assert_eq!(
+2062            repo.offline.get_save_count(),
+2063            0,
+2064            "Should not save empty results"
+2065        );
+2066    }
+2067
+2068    /// Test SearchResult::has_content helper method
+2069    ///
+2070    /// @req-test: DR-013 - Repository pattern (content detection helper)
+2071    #[tokio::test]
+2072    async fn test_has_content_check() {
+2073        // Test that SearchResult::has_content works correctly
+2074        let empty_result = SearchResult {
+2075            items: vec![],
+2076            total_record_count: 0,
+2077        };
+2078        assert!(
+2079            !empty_result.has_content(),
+2080            "Empty result should not have content"
+2081        );
+2082
+2083        let result_with_items = SearchResult {
+2084            items: vec![create_test_item("item-1", "Movie 1")],
+2085            total_record_count: 1,
+2086        };
+2087        assert!(
+2088            result_with_items.has_content(),
+2089            "Result with items should have content"
+2090        );
+2091    }
+2092
+2093    #[test]
+2094    fn test_merge_search_local_first_then_server_appended() {
+2095        let cache = SearchResult {
+2096            items: vec![
+2097                create_test_item("a", "Cached A"),
+2098                create_test_item("b", "Cached B"),
+2099            ],
+2100            total_record_count: 2,
+2101        };
+2102        let server = SearchResult {
+2103            items: vec![
+2104                create_test_item("c", "Server C"),
+2105                create_test_item("d", "Server D"),
+2106            ],
+2107            total_record_count: 2,
+2108        };
+2109
+2110        let merged = HybridRepository::merge_search_results(cache, server);
+2111
+2112        // Local items first (in order), then server-only items appended.
+2113        let ids: Vec<&str> = merged.items.iter().map(|i| i.id.as_str()).collect();
+2114        assert_eq!(ids, vec!["a", "b", "c", "d"]);
+2115        assert_eq!(merged.total_record_count, 4);
+2116    }
+2117
+2118    #[test]
+2119    fn test_merge_search_dedupes_with_server_winning() {
+2120        // "b" appears in both. Server metadata should win, but the item keeps
+2121        // its earlier (local) position and is not duplicated.
+2122        let cache = SearchResult {
+2123            items: vec![
+2124                create_test_item("a", "Cached A"),
+2125                create_test_item("b", "Cached B"),
+2126            ],
+2127            total_record_count: 2,
+2128        };
+2129        let server = SearchResult {
+2130            items: vec![
+2131                create_test_item("b", "Server B (fresher)"),
+2132                create_test_item("c", "Server C"),
+2133            ],
+2134            total_record_count: 2,
+2135        };
+2136
+2137        let merged = HybridRepository::merge_search_results(cache, server);
+2138
+2139        let ids: Vec<&str> = merged.items.iter().map(|i| i.id.as_str()).collect();
+2140        assert_eq!(
+2141            ids,
+2142            vec!["a", "b", "c"],
+2143            "no duplicate, local position kept"
+2144        );
+2145
+2146        let b = merged.items.iter().find(|i| i.id == "b").unwrap();
+2147        assert_eq!(
+2148            b.name, "Server B (fresher)",
+2149            "server metadata wins on conflict"
+2150        );
+2151        assert_eq!(merged.total_record_count, 3);
+2152    }
+2153
+2154    #[test]
+2155    fn test_merge_search_handles_empty_sides() {
+2156        let only_server = HybridRepository::merge_search_results(
+2157            SearchResult {
+2158                items: vec![],
+2159                total_record_count: 0,
+2160            },
+2161            SearchResult {
+2162                items: vec![create_test_item("x", "X")],
+2163                total_record_count: 1,
+2164            },
+2165        );
+2166        assert_eq!(only_server.items.len(), 1);
+2167        assert_eq!(only_server.items[0].id, "x");
+2168
+2169        let only_cache = HybridRepository::merge_search_results(
+2170            SearchResult {
+2171                items: vec![create_test_item("y", "Y")],
+2172                total_record_count: 1,
+2173            },
+2174            SearchResult {
+2175                items: vec![],
+2176                total_record_count: 0,
+2177            },
+2178        );
+2179        assert_eq!(only_cache.items.len(), 1);
+2180        assert_eq!(only_cache.items[0].id, "y");
+2181    }
+2182}
+
\ No newline at end of file diff --git a/api/src/jellytau_lib/repository/mod.rs.html b/api/src/jellytau_lib/repository/mod.rs.html new file mode 100644 index 00000000..8a9748ee --- /dev/null +++ b/api/src/jellytau_lib/repository/mod.rs.html @@ -0,0 +1,379 @@ +mod.rs - sourceSkip to main content

jellytau_lib/repository/
mod.rs

1pub mod device_profile;
+2/// User-chosen browsing exclusions (UR-076 / DR-209).
+3pub mod exclusions;
+4pub mod hybrid;
+5pub mod offline;
+6pub mod online;
+7pub mod series_progress;
+8pub mod types;
+9
+10pub use hybrid::HybridRepository;
+11pub use offline::OfflineRepository;
+12pub use online::{JRayActor, OnlineRepository};
+13pub use types::*;
+14
+15use async_trait::async_trait;
+16
+17/// Repository trait for media access (online, offline, or hybrid)
+18///
+19/// @req: UR-002 - Access media when online or offline
+20/// @req: UR-007 - Navigate media in library
+21/// @req: UR-008 - Search media across libraries
+22/// @req: IR-010 - Jellyfin API client for library browsing
+23/// @req: DR-012 - Local database for media metadata cache
+24/// @req: DR-013 - Repository pattern for online/offline data access
+25#[async_trait]
+26pub trait MediaRepository: Send + Sync {
+27    /// Get all libraries
+28    ///
+29    /// @req: UR-007 - Navigate media in library
+30    /// @req: JA-003 - Get user library views
+31    async fn get_libraries(&self) -> Result<Vec<Library>, RepoError>;
+32
+33    /// Get items in a library or parent
+34    ///
+35    /// @req: UR-007 - Navigate media in library
+36    /// @req: JA-004 - Get library items (paginated)
+37    async fn get_items(
+38        &self,
+39        parent_id: &str,
+40        options: Option<GetItemsOptions>,
+41    ) -> Result<SearchResult, RepoError>;
+42
+43    /// Get a single item by ID
+44    ///
+45    /// @req: UR-007 - Navigate media in library
+46    /// @req: JA-005 - Get item details and metadata
+47    async fn get_item(&self, item_id: &str) -> Result<MediaItem, RepoError>;
+48
+49    /// Get latest items in a library
+50    ///
+51    /// @req: UR-024 - View recently added content on server
+52    /// @req: JA-016 - Get recently added items
+53    async fn get_latest_items(
+54        &self,
+55        parent_id: &str,
+56        limit: Option<usize>,
+57    ) -> Result<Vec<MediaItem>, RepoError>;
+58
+59    /// Get resume items (continue watching/listening)
+60    ///
+61    /// @req: UR-019 - Resume playback from where you left off
+62    /// @req: UR-023 - View "Next Up" / Continue Watching on home screen
+63    /// @req: JA-015 - Get "Continue Watching" items
+64    async fn get_resume_items(
+65        &self,
+66        parent_id: Option<&str>,
+67        limit: Option<usize>,
+68    ) -> Result<Vec<MediaItem>, RepoError>;
+69
+70    /// Get next up episodes
+71    ///
+72    /// @req: UR-023 - View "Next Up" / Continue Watching; auto-play next episode
+73    /// @req: JA-014 - Get "Next Up" items
+74    async fn get_next_up_episodes(
+75        &self,
+76        series_id: Option<&str>,
+77        limit: Option<usize>,
+78    ) -> Result<Vec<MediaItem>, RepoError>;
+79
+80    /// Get recently played audio
+81    async fn get_recently_played_audio(
+82        &self,
+83        limit: Option<usize>,
+84    ) -> Result<Vec<MediaItem>, RepoError>;
+85
+86    /// Get albums the user has played, but not recently ("rediscover" / haven't
+87    /// listened to in a while). Returns albums sorted by least-recently played
+88    /// first, optionally restricted to a parent library.
+89    async fn get_rediscover_albums(
+90        &self,
+91        parent_id: Option<&str>,
+92        limit: Option<usize>,
+93    ) -> Result<Vec<MediaItem>, RepoError>;
+94
+95    /// Get resume movies
+96    async fn get_resume_movies(&self, limit: Option<usize>) -> Result<Vec<MediaItem>, RepoError>;
+97
+98    /// Get genres
+99    async fn get_genres(&self, parent_id: Option<&str>) -> Result<Vec<Genre>, RepoError>;
+100
+101    /// Search for items
+102    ///
+103    /// @req: UR-008 - Search media across libraries
+104    /// @req: JA-006 - Search across libraries
+105    async fn search(
+106        &self,
+107        query: &str,
+108        options: Option<SearchOptions>,
+109    ) -> Result<SearchResult, RepoError>;
+110
+111    /// Get playback info for streaming
+112    ///
+113    /// @req: UR-003 - Play videos
+114    /// @req: UR-004 - Play audio uninterrupted
+115    /// @req: JA-007 - Get playback info and stream URL
+116    async fn get_playback_info(&self, item_id: &str) -> Result<PlaybackInfo, RepoError>;
+117
+118    /// Get audio stream URL for a track
+119    ///
+120    /// @req: UR-004 - Play audio uninterrupted
+121    /// @req: JA-007 - Get playback info and stream URL
+122    async fn get_audio_stream_url(&self, item_id: &str) -> Result<String, RepoError>;
+123
+124    /// Get an audio-only stream URL for a *video* item (background-audio handoff).
+125    ///
+126    /// Used when autoplay advances to the next episode while the app is playing a
+127    /// video in audio-only mode in the background: the backend needs the next
+128    /// episode's audio-only URL without any frontend round-trip. Online-only;
+129    /// offline/cache repositories return an error.
+130    ///
+131    /// TRACES: UR-040 | JA-032
+132    async fn get_audio_only_stream_url_for_video(
+133        &self,
+134        item_id: &str,
+135        media_source_id: Option<&str>,
+136        start_time_seconds: Option<f64>,
+137        audio_stream_index: Option<i32>,
+138    ) -> Result<String, RepoError>;
+139
+140    /// Get Live TV channels (broadcast / IPTV) for browsing.
+141    async fn get_live_tv_channels(&self) -> Result<Vec<MediaItem>, RepoError>;
+142
+143    /// Get the root list of plugin "Channels" (Jellyfin Channels feature).
+144    /// Drill-down into a channel reuses `get_items(channel_id, ...)`.
+145    async fn get_channels(&self) -> Result<SearchResult, RepoError>;
+146
+147    /// Open a live stream (Live TV channel or live channel item) for playback.
+148    ///
+149    /// Returns the server transcoding URL plus identifiers needed to manage the
+150    /// stream. Required before a live channel can be played over HLS.
+151    async fn open_live_stream(&self, item_id: &str) -> Result<LiveStreamInfo, RepoError>;
+152
+153    /// Report playback start
+154    ///
+155    /// @req: UR-025 - Sync watch history and progress back to Jellyfin
+156    /// @req: JA-010 - Report playback start
+157    async fn report_playback_start(
+158        &self,
+159        item_id: &str,
+160        position_ticks: i64,
+161    ) -> Result<(), RepoError>;
+162
+163    /// Report playback progress
+164    ///
+165    /// @req: UR-025 - Sync watch history and progress back to Jellyfin
+166    /// @req: JA-011 - Report playback progress (periodic)
+167    async fn report_playback_progress(
+168        &self,
+169        item_id: &str,
+170        position_ticks: i64,
+171    ) -> Result<(), RepoError>;
+172
+173    /// Report playback stopped
+174    ///
+175    /// @req: UR-025 - Sync watch history and progress back to Jellyfin
+176    /// @req: JA-012 - Report playback stopped
+177    async fn report_playback_stopped(
+178        &self,
+179        item_id: &str,
+180        position_ticks: i64,
+181    ) -> Result<(), RepoError>;
+182
+183    /// Get image URL (synchronous - just constructs URL)
+184    fn get_image_url(
+185        &self,
+186        item_id: &str,
+187        image_type: ImageType,
+188        options: Option<ImageOptions>,
+189    ) -> String;
+190
+191    /// Get subtitle URL (synchronous - just constructs URL)
+192    /// Called by frontend via Tauri invoke (getSubtitleUrl in VideoPlayer.svelte)
+193    #[allow(dead_code)]
+194    fn get_subtitle_url(
+195        &self,
+196        item_id: &str,
+197        media_source_id: &str,
+198        stream_index: i32,
+199        format: &str,
+200    ) -> String;
+201
+202    /// Build the URL a video download is fetched from. Synchronous — it only
+203    /// constructs a URL, so it stays testable without a server. Reach it through
+204    /// [`resolve_video_download_url`] rather than calling it directly.
+205    ///
+206    /// `source_audio_codec` is the codec of the audio track the server would
+207    /// serve (see [`served_audio_codec`]); `None` when it is not known. At
+208    /// `original` quality it decides whether the file can be copied byte-for-byte
+209    /// or has to have its audio re-encoded on the way down — a downloaded file is
+210    /// played back with no server in reach, so it has to be decodable *here*.
+211    ///
+212    /// TRACES: UR-071 | DR-171
+213    #[allow(dead_code)]
+214    fn get_video_download_url(
+215        &self,
+216        item_id: &str,
+217        quality: &str,
+218        media_source_id: Option<&str>,
+219        source_audio_codec: Option<&str>,
+220    ) -> String;
+221
+222    /// Mark item as favorite
+223    async fn mark_favorite(&self, item_id: &str) -> Result<(), RepoError>;
+224
+225    /// Unmark item as favorite
+226    async fn unmark_favorite(&self, item_id: &str) -> Result<(), RepoError>;
+227
+228    /// Everything the viewer has favourited, across every library.
+229    ///
+230    /// Separate from `get_items` because favourites span libraries and
+231    /// `get_items` is `ParentId`-shaped. `scope` is the opaque enum the
+232    /// frontend sends; this layer expands it to item types (DR-063) so no
+233    /// Jellyfin taxonomy is needed on the other side of the IPC boundary.
+234    ///
+235    /// TRACES: UR-067 | DR-115, JA-033 | UT-100, UT-101
+236    async fn get_favorites(
+237        &self,
+238        scope: SearchScope,
+239        options: Option<GetItemsOptions>,
+240    ) -> Result<SearchResult, RepoError>;
+241
+242    /// Erase the viewer's watch history for an item: clear its played flag and
+243    /// its resume position. On a container (series, season) this applies to
+244    /// everything inside it, so a series is returned to "never watched" and
+245    /// reopens on its premiere.
+246    ///
+247    /// TRACES: UR-064 | DR-106
+248    async fn clear_watch_history(&self, item_id: &str) -> Result<(), RepoError>;
+249
+250    /// Mark an item played — the inverse of `clear_watch_history`. Needed by the
+251    /// sync-queue drain, which replays `mark_played` rows queued while the
+252    /// server was unreachable; reporting a stop at a made-up position was the
+253    /// previous stand-in and does not set the played flag reliably.
+254    ///
+255    /// TRACES: UR-025 | DR-131 | JA-035
+256    async fn mark_played(&self, item_id: &str) -> Result<(), RepoError>;
+257
+258    /// Get person details
+259    async fn get_person(&self, person_id: &str) -> Result<MediaItem, RepoError>;
+260
+261    /// Get items by person (filmography)
+262    async fn get_items_by_person(
+263        &self,
+264        person_id: &str,
+265        options: Option<GetItemsOptions>,
+266    ) -> Result<SearchResult, RepoError>;
+267
+268    /// Get similar/related items for a movie or show
+269    ///
+270    /// @req: UR-009 - Discover similar content based on current item
+271    async fn get_similar_items(
+272        &self,
+273        item_id: &str,
+274        limit: Option<usize>,
+275    ) -> Result<SearchResult, RepoError>;
+276
+277    // ===== Playlist Methods =====
+278
+279    /// Create a new playlist on the server
+280    ///
+281    /// @req: UR-014 - Make and edit playlists of music that sync back to Jellyfin
+282    /// @req: JA-019 - Get/create/update playlists
+283    async fn create_playlist(
+284        &self,
+285        name: &str,
+286        item_ids: &[String],
+287    ) -> Result<PlaylistCreatedResult, RepoError>;
+288
+289    /// Delete a playlist
+290    ///
+291    /// @req: UR-014 - Make and edit playlists of music that sync back to Jellyfin
+292    /// @req: JA-019 - Get/create/update playlists
+293    async fn delete_playlist(&self, playlist_id: &str) -> Result<(), RepoError>;
+294
+295    /// Rename a playlist
+296    ///
+297    /// @req: UR-014 - Make and edit playlists of music that sync back to Jellyfin
+298    /// @req: JA-019 - Get/create/update playlists
+299    async fn rename_playlist(&self, playlist_id: &str, name: &str) -> Result<(), RepoError>;
+300
+301    /// Get playlist items with PlaylistItemId (needed for remove/reorder)
+302    ///
+303    /// @req: UR-014 - Make and edit playlists of music that sync back to Jellyfin
+304    /// @req: JA-019 - Get/create/update playlists
+305    async fn get_playlist_items(&self, playlist_id: &str) -> Result<Vec<PlaylistEntry>, RepoError>;
+306
+307    /// Add items to a playlist
+308    ///
+309    /// @req: UR-014 - Make and edit playlists of music that sync back to Jellyfin
+310    /// @req: JA-020 - Add/remove items from playlist
+311    async fn add_to_playlist(
+312        &self,
+313        playlist_id: &str,
+314        item_ids: &[String],
+315    ) -> Result<(), RepoError>;
+316
+317    /// Remove items from a playlist using entry IDs (PlaylistItemId, NOT media item IDs)
+318    ///
+319    /// @req: UR-014 - Make and edit playlists of music that sync back to Jellyfin
+320    /// @req: JA-020 - Add/remove items from playlist
+321    async fn remove_from_playlist(
+322        &self,
+323        playlist_id: &str,
+324        entry_ids: &[String],
+325    ) -> Result<(), RepoError>;
+326
+327    /// Move a playlist item to a new position
+328    ///
+329    /// @req: UR-014 - Make and edit playlists of music that sync back to Jellyfin
+330    /// @req: JA-020 - Add/remove items from playlist
+331    async fn move_playlist_item(
+332        &self,
+333        playlist_id: &str,
+334        item_id: &str,
+335        new_index: u32,
+336    ) -> Result<(), RepoError>;
+337}
+338
+339/// The audio codec the server would serve for `item_id` — the default track, or
+340/// the first when none is marked, matching the track Jellyfin picks.
+341///
+342/// `None` when the item has no audio, names no codec, or cannot be fetched. A
+343/// caller must read that as "unknown", never as "fine": it is the input to a
+344/// policy that only *adds* a transcode, so an unknown codec leaves behaviour
+345/// exactly as it was.
+346///
+347/// TRACES: UR-071 | DR-171 | UT-166
+348pub async fn served_audio_codec(repo: &dyn MediaRepository, item_id: &str) -> Option<String> {
+349    let item = repo.get_item(item_id).await.ok()?;
+350    let audio: Vec<(Option<&str>, bool)> = item
+351        .media_streams
+352        .as_deref()
+353        .unwrap_or_default()
+354        .iter()
+355        .filter(|s| s.stream_type == "Audio")
+356        .map(|s| (s.codec.as_deref(), s.is_default))
+357        .collect();
+358
+359    device_profile::served_audio_codec(&audio).map(str::to_string)
+360}
+361
+362/// Resolve the download URL for a video, applying the audio-codec policy that
+363/// keeps the saved file playable offline (DR-171).
+364///
+365/// Every video download goes through here rather than calling the builder
+366/// directly: the builder is pure and cannot look the codec up, and a caller that
+367/// forgets to is exactly how the silent downloads shipped.
+368///
+369/// TRACES: UR-071 | DR-171
+370pub async fn resolve_video_download_url(
+371    repo: &dyn MediaRepository,
+372    item_id: &str,
+373    quality: &str,
+374    media_source_id: Option<&str>,
+375) -> String {
+376    let codec = served_audio_codec(repo, item_id).await;
+377    repo.get_video_download_url(item_id, quality, media_source_id, codec.as_deref())
+378}
+
\ No newline at end of file diff --git a/api/src/jellytau_lib/repository/offline.rs.html b/api/src/jellytau_lib/repository/offline.rs.html new file mode 100644 index 00000000..d3a132d9 --- /dev/null +++ b/api/src/jellytau_lib/repository/offline.rs.html @@ -0,0 +1,4820 @@ +offline.rs - sourceSkip to main content

jellytau_lib/repository/
offline.rs

1// Offline repository - queries SQLite database for cached data
+2//
+3// TRACES: UR-002, UR-052 | DR-012, DR-013, DR-078
+4use std::sync::atomic::{AtomicBool, Ordering};
+5use std::sync::Arc;
+6
+7use async_trait::async_trait;
+8use log::debug;
+9
+10use super::{types::*, MediaRepository};
+11use crate::storage::db_service::{DatabaseService, Query, QueryParam, RusqliteService};
+12
+13/// Whether offline library queries may include catalog items that are merely
+14/// *browsed/synced* but not downloaded (the greyed-out "browse the whole server"
+15/// view). Defaults to `true` so online browsing (which reads this same cache as
+16/// a fast path) still sees the full catalog.
+17///
+18/// While offline, the frontend drives this from the "Show all server media"
+19/// toggle: OFF means library pages show only downloaded/local media, ON reveals
+20/// the full greyed-out catalog. See `set_include_catalog_browse` and the
+21/// `showServerCatalog` UI flag. Fixes the bug where offline library pages showed
+22/// every server item regardless of the toggle.
+23///
+24/// TRACES: UR-052 | DR-078
+25static INCLUDE_CATALOG_BROWSE: AtomicBool = AtomicBool::new(true);
+26
+27/// Set whether offline `get_items` includes non-downloaded (synced-only) catalog
+28/// items. Called from the frontend: `true` when online or when the offline
+29/// "Show all server media" toggle is on; `false` when offline with the toggle
+30/// off (show downloaded/local media only).
+31pub fn set_include_catalog_browse(include: bool) {
+32    INCLUDE_CATALOG_BROWSE.store(include, Ordering::Relaxed);
+33}
+34
+35/// Whether offline `get_items` currently includes the synced-but-not-downloaded
+36/// catalog (the greyed-out browse view). Mirrors `set_include_catalog_browse`.
+37///
+38/// Exposed so the hybrid repo can tell "cache is cold, ask the server" from
+39/// "user asked for downloads only and there are none here": when this is false,
+40/// an empty offline `get_items` is authoritative and must not fall through to
+41/// the server. See hybrid.rs `get_items`.
+42///
+43/// TRACES: UR-052 | DR-078, DR-080
+44pub fn include_catalog_browse() -> bool {
+45    INCLUDE_CATALOG_BROWSE.load(Ordering::Relaxed)
+46}
+47
+48/// Build a safe FTS5 prefix query from raw user input.
+49///
+50/// Every whitespace-separated token is emitted as a *quoted phrase*, so
+51/// punctuation the user types (apostrophes in `Bob's Burgers`, hyphens in
+52/// `Spider-Man`, the `/` in `AC/DC`) is treated as data rather than FTS5
+53/// operator syntax — unquoted, those characters make `MATCH` raise a syntax
+54/// error and the whole search fails. The final token carries the `*` prefix
+55/// operator so results appear while the user is still typing; earlier tokens are
+56/// implicitly ANDed, which preserves the pre-existing matching semantics.
+57///
+58/// Returns `None` when the input holds nothing searchable (empty, or pure
+59/// punctuation), so callers skip the query rather than handing FTS5 a string it
+60/// will reject — `search("")` is a real call site, used to list all playlists.
+61///
+62/// TRACES: UR-065 | DR-108 | UT-111
+63fn build_fts_prefix_query(query: &str) -> Option<String> {
+64    let tokens: Vec<String> = query
+65        .split_whitespace()
+66        .filter(|token| token.chars().any(char::is_alphanumeric))
+67        .map(|token| format!("\"{}\"", token.replace('"', "\"\"")))
+68        .collect();
+69
+70    let last = tokens.len().checked_sub(1)?;
+71    Some(
+72        tokens
+73            .iter()
+74            .enumerate()
+75            .map(|(i, token)| {
+76                if i == last {
+77                    format!("{}*", token)
+78                } else {
+79                    token.clone()
+80                }
+81            })
+82            .collect::<Vec<_>>()
+83            .join(" "),
+84    )
+85}
+86
+87pub struct OfflineRepository {
+88    db_service: Arc<RusqliteService>,
+89    server_id: String,
+90    user_id: String,
+91}
+92
+93impl OfflineRepository {
+94    pub fn new(db_service: Arc<RusqliteService>, server_id: String, user_id: String) -> Self {
+95        Self {
+96            db_service,
+97            server_id,
+98            user_id,
+99        }
+100    }
+101
+102    /// Helper to convert CachedItem from storage to MediaItem
+103    fn cached_item_to_media_item(item: CachedItem, user_data: Option<UserData>) -> MediaItem {
+104        let artists_vec = item
+105            .artists
+106            .as_ref()
+107            .and_then(|s| serde_json::from_str::<Vec<String>>(s).ok())
+108            .unwrap_or_default();
+109
+110        let kind = crate::domain::kind_from_jellyfin(&item.item_type, item.is_folder);
+111
+112        MediaItem {
+113            id: item.id.clone(),
+114            name: item.name,
+115            item_type: item.item_type,
+116            kind,
+117            is_folder: item.is_folder,
+118            server_id: item.server_id,
+119            parent_id: item.parent_id,
+120            library_id: item.library_id,
+121            overview: item.overview,
+122            genres: item
+123                .genres
+124                .as_ref()
+125                .and_then(|s| serde_json::from_str::<Vec<String>>(s).ok()),
+126            runtime_ticks: item.runtime_ticks,
+127            duration_ms: item.runtime_ticks.map(crate::domain::ticks_to_ms),
+128            production_year: item.production_year,
+129            premiere_date: item.premiere_date,
+130            community_rating: item.community_rating,
+131            official_rating: item.official_rating,
+132            primary_image_tag: item.primary_image_tag.clone(),
+133            image_id: item.primary_image_tag,
+134            backdrop_image_tags: item.backdrop_image_tags,
+135            parent_backdrop_image_tags: item.parent_backdrop_image_tags,
+136            album_id: item.album_id,
+137            album_name: item.album_name,
+138            album_artist: item.album_artist,
+139            artists: Some(artists_vec),
+140            artist_items: None, // Not stored in cache yet - TODO: add to database schema
+141            index_number: item.index_number,
+142            series_id: item.series_id,
+143            series_name: item.series_name,
+144            season_id: item.season_id,
+145            season_name: item.season_name,
+146            parent_index_number: item.parent_index_number,
+147            user_data,
+148            media_streams: None, // Not cached offline
+149            media_sources: None, // Not cached offline
+150            people: None,        // Not cached offline - TODO: add to database schema
+151        }
+152    }
+153
+154    /// Get user data for an item (playback position, favorite, etc.)
+155    async fn get_user_data(&self, item_id: &str) -> Option<UserData> {
+156        let query = Query::with_params(
+157            "SELECT playback_position_ticks, is_played, is_favorite, play_count, last_played_at, playback_context_type, playback_context_id
+158             FROM user_data WHERE user_id = ? AND item_id = ?",
+159            vec![
+160                QueryParam::String(self.user_id.clone()),
+161                QueryParam::String(item_id.to_string()),
+162            ],
+163        );
+164
+165        self.db_service
+166            .query_optional(query, |row| {
+167                let playback_position_ticks: Option<i64> = row.get(0).ok();
+168                Ok(UserData {
+169                    playback_position_ticks,
+170                    playback_position_ms: playback_position_ticks.map(crate::domain::ticks_to_ms),
+171                    is_played: row.get::<_, Option<i32>>(1).ok().flatten().map(|v| v != 0),
+172                    is_favorite: row.get::<_, Option<i32>>(2).ok().flatten().map(|v| v != 0),
+173                    play_count: row.get(3).ok(),
+174                    last_played_date: row.get(4).ok(),
+175                    playback_context_type: row.get(5).ok(),
+176                    playback_context_id: row.get(6).ok(),
+177                })
+178            })
+179            .await
+180            .ok()
+181            .flatten()
+182    }
+183}
+184
+185// Helper struct matching storage.rs CachedItem structure
+186#[derive(Debug)]
+187struct CachedItem {
+188    id: String,
+189    name: String,
+190    item_type: String,
+191    is_folder: bool,
+192    server_id: String,
+193    parent_id: Option<String>,
+194    library_id: Option<String>,
+195    overview: Option<String>,
+196    genres: Option<String>,
+197    runtime_ticks: Option<i64>,
+198    production_year: Option<i32>,
+199    premiere_date: Option<String>,
+200    community_rating: Option<f64>,
+201    official_rating: Option<String>,
+202    primary_image_tag: Option<String>,
+203    backdrop_image_tags: Option<Vec<String>>,
+204    parent_backdrop_image_tags: Option<Vec<String>>,
+205    album_id: Option<String>,
+206    album_name: Option<String>,
+207    album_artist: Option<String>,
+208    artists: Option<String>,
+209    index_number: Option<i32>,
+210    series_id: Option<String>,
+211    series_name: Option<String>,
+212    season_id: Option<String>,
+213    season_name: Option<String>,
+214    parent_index_number: Option<i32>,
+215}
+216
+217fn row_to_cached_item(row: &rusqlite::Row) -> rusqlite::Result<CachedItem> {
+218    Ok(CachedItem {
+219        id: row.get(0)?,
+220        name: row.get(1)?,
+221        item_type: row.get(2)?,
+222        server_id: row.get(3)?,
+223        parent_id: row.get(4)?,
+224        library_id: row.get(5)?,
+225        overview: row.get(6)?,
+226        genres: row.get(7)?,
+227        runtime_ticks: row.get(8)?,
+228        production_year: row.get(9)?,
+229        community_rating: row.get(10)?,
+230        official_rating: row.get(11)?,
+231        primary_image_tag: row.get(12)?,
+232        backdrop_image_tags: None,        // TODO: Add to DB schema
+233        parent_backdrop_image_tags: None, // TODO: Add to DB schema
+234        album_id: row.get(13)?,
+235        album_name: row.get(14)?,
+236        album_artist: row.get(15)?,
+237        artists: row.get(16)?,
+238        index_number: row.get(17)?,
+239        series_id: row.get(18)?,
+240        series_name: row.get(19)?,
+241        season_id: row.get(20)?,
+242        season_name: row.get(21)?,
+243        parent_index_number: row.get(22)?,
+244        // Appended as the final columns in every SELECT that maps through this fn.
+245        is_folder: row.get::<_, Option<i64>>(23)?.unwrap_or(0) != 0,
+246        premiere_date: row.get(24)?,
+247    })
+248}
+249
+250impl OfflineRepository {
+251    /// Remove catalog entries the server no longer has (mark-and-sweep).
+252    ///
+253    /// `save_to_cache` stamps every row it writes with a fresh `synced_at`, so
+254    /// after a complete crawl anything still on the server carries a timestamp
+255    /// newer than `cutoff` (taken before the crawl began) and anything deleted
+256    /// server-side kept its older one. Sweeping by timestamp avoids binding the
+257    /// crawl's entire id set, which would blow past SQLite's variable limit on a
+258    /// large library.
+259    ///
+260    /// Three exclusions, each load-bearing:
+261    ///
+262    /// * **Only `item_types` the crawl actually requested.** The crawl asks for
+263    ///   `CATALOG_ITEM_TYPES`; rows of any other type (artists, playlists, the
+264    ///   `Folder` parent stubs `save_to_cache` inserts) are never refreshed by
+265    ///   it, so sweeping by age alone would delete every one of them.
+266    /// * **Anything a completed download depends on** — the downloaded item
+267    ///   itself, and any container with a downloaded child. The user has those
+268    ///   bytes on disk; dropping the row would orphan the file.
+269    /// * Callers must only invoke this after a crawl in which *every* library
+270    ///   succeeded. `sync_full_catalog` is best-effort per library, and
+271    ///   `items.parent_id` is `ON DELETE CASCADE`, so sweeping after a partial
+272    ///   crawl could cascade an entire series away because one request timed out.
+273    ///
+274    /// Returns the number of rows removed.
+275    ///
+276    /// TRACES: UR-065 | DR-110 | UT-113
+277    pub async fn prune_stale_catalog(
+278        &self,
+279        cutoff: &str,
+280        item_types: &[String],
+281    ) -> Result<usize, RepoError> {
+282        if item_types.is_empty() {
+283            return Ok(0);
+284        }
+285        let placeholders = vec!["?"; item_types.len()].join(",");
+286        let sql = format!(
+287            "DELETE FROM items
+288              WHERE server_id = ?
+289                AND synced_at IS NOT NULL
+290                AND synced_at < ?
+291                AND item_type IN ({})
+292                AND id NOT IN (
+293                    -- Playable items with completed downloads
+294                    SELECT i.id
+295                    FROM items i
+296                    INNER JOIN downloads d ON i.id = d.item_id
+297                    WHERE d.status = 'completed'
+298
+299                    UNION
+300
+301                    -- Containers with downloaded children
+302                    SELECT i.id
+303                    FROM items i
+304                    INNER JOIN items children ON (children.parent_id = i.id OR children.album_id = i.id OR children.season_id = i.id OR children.series_id = i.id)
+305                    INNER JOIN downloads d ON children.id = d.item_id
+306                    WHERE d.status = 'completed'
+307                )",
+308            placeholders
+309        );
+310
+311        let mut params = vec![
+312            QueryParam::String(self.server_id.clone()),
+313            QueryParam::String(cutoff.to_string()),
+314        ];
+315        params.extend(item_types.iter().cloned().map(QueryParam::String));
+316
+317        let removed = self
+318            .db_service
+319            .execute(Query::with_params(sql, params))
+320            .await
+321            .map_err(|e| RepoError::Database { message: e })?;
+322
+323        Ok(removed)
+324    }
+325
+326    /// Full-text search over cached people (cast and crew).
+327    ///
+328    /// People are stored in their own table rather than in `items`, so search
+329    /// has to look them up separately and adapt them to `MediaItem`. Availability
+330    /// gating deliberately does not apply: a person is metadata, never a
+331    /// download, so there is nothing to be offline about.
+332    ///
+333    /// TRACES: UR-065, UR-060 | DR-111 | UT-114
+334    async fn search_people(
+335        &self,
+336        fts_query: &str,
+337        limit: usize,
+338    ) -> Result<Vec<MediaItem>, RepoError> {
+339        let sql = format!(
+340            "SELECT p.id, p.name, p.overview, p.primary_image_tag, p.premiere_date
+341               FROM people p
+342               JOIN people_fts fts ON fts.rowid = p.rowid
+343              WHERE p.server_id = ? AND people_fts MATCH ?
+344              ORDER BY rank
+345              LIMIT {}",
+346            limit
+347        );
+348
+349        let rows = self
+350            .db_service
+351            .query_many(
+352                Query::with_params(
+353                    sql,
+354                    vec![
+355                        QueryParam::String(self.server_id.clone()),
+356                        QueryParam::String(fts_query.to_string()),
+357                    ],
+358                ),
+359                |row| {
+360                    Ok((
+361                        row.get::<_, String>(0)?,
+362                        row.get::<_, String>(1)?,
+363                        row.get::<_, Option<String>>(2)?,
+364                        row.get::<_, Option<String>>(3)?,
+365                        row.get::<_, Option<String>>(4)?,
+366                    ))
+367                },
+368            )
+369            .await
+370            .map_err(|e| RepoError::Database { message: e })?;
+371
+372        Ok(rows
+373            .into_iter()
+374            .map(
+375                |(id, name, overview, primary_image_tag, premiere_date)| MediaItem {
+376                    id,
+377                    name,
+378                    item_type: "Person".to_string(),
+379                    kind: crate::domain::MediaKind::Person,
+380                    is_folder: false,
+381                    server_id: self.server_id.clone(),
+382                    overview,
+383                    primary_image_tag,
+384                    premiere_date,
+385                    ..Default::default()
+386                },
+387            )
+388            .collect())
+389    }
+390
+391    /// Save browsed items to cache for faster subsequent loading
+392    ///
+393    /// This persists metadata for items that were browsed (not necessarily downloaded).
+394    /// Items are marked with current timestamp for freshness tracking.
+395    pub async fn save_to_cache(
+396        &self,
+397        parent_id: &str,
+398        items: &[MediaItem],
+399    ) -> Result<usize, RepoError> {
+400        if items.is_empty() {
+401            return Ok(0);
+402        }
+403
+404        let now = chrono::Utc::now().to_rfc3339();
+405
+406        // Temporarily disable foreign key constraints to avoid CASCADE DELETE issues
+407        // when replacing stub parent items with their actual data
+408        self.db_service
+409            .execute(Query::new("PRAGMA foreign_keys = OFF"))
+410            .await
+411            .map_err(|e| RepoError::Database { message: e })?;
+412
+413        // Ensure we re-enable foreign keys even if an error occurs
+414        let result = self.save_to_cache_impl(parent_id, items, &now).await;
+415
+416        // Re-enable foreign key constraints
+417        let _ = self
+418            .db_service
+419            .execute(Query::new("PRAGMA foreign_keys = ON"))
+420            .await;
+421
+422        result
+423    }
+424
+425    async fn save_to_cache_impl(
+426        &self,
+427        parent_id: &str,
+428        items: &[MediaItem],
+429        now: &str,
+430    ) -> Result<usize, RepoError> {
+431        // Collect all unique parent IDs referenced by items being saved
+432        let mut parent_ids = std::collections::HashSet::new();
+433        parent_ids.insert(parent_id.to_string());
+434
+435        for item in items {
+436            if let Some(pid) = &item.parent_id {
+437                parent_ids.insert(pid.clone());
+438            }
+439        }
+440
+441        // Insert stub entries for all parent IDs to satisfy FK constraints
+442        #[cfg(test)]
+443        println!("Creating stub parents for: {:?}", parent_ids);
+444
+445        for pid in parent_ids {
+446            let parent_query = Query::with_params(
+447                "INSERT OR IGNORE INTO items (id, server_id, name, item_type, synced_at)
+448                 VALUES (?1, ?2, ?3, ?4, ?5)",
+449                vec![
+450                    QueryParam::String(pid.clone()),
+451                    QueryParam::String(self.server_id.clone()),
+452                    QueryParam::String("Parent".to_string()),
+453                    QueryParam::String("Folder".to_string()),
+454                    QueryParam::String(now.to_string()),
+455                ],
+456            );
+457
+458            let _stub_rows = self
+459                .db_service
+460                .execute(parent_query)
+461                .await
+462                .map_err(|e| RepoError::Database { message: e })?;
+463            #[cfg(test)]
+464            println!(
+465                "  Created stub parent {} (rows affected: {})",
+466                pid, _stub_rows
+467            );
+468        }
+469
+470        let mut count = 0;
+471
+472        for item in items {
+473            // Convert Option<Vec<String>> to JSON strings for storage
+474            let genres_json = item
+475                .genres
+476                .as_ref()
+477                .map(|g| serde_json::to_string(g).unwrap_or_else(|_| "[]".to_string()));
+478            let artists_json = item
+479                .artists
+480                .as_ref()
+481                .map(|a| serde_json::to_string(a).unwrap_or_else(|_| "[]".to_string()));
+482            let backdrop_tags_json = item
+483                .backdrop_image_tags
+484                .as_ref()
+485                .map(|b| serde_json::to_string(b).unwrap_or_else(|_| "[]".to_string()));
+486
+487            // A real UPSERT, not INSERT OR REPLACE. REPLACE deletes the existing
+488            // row and inserts a new one, which (a) fires no AFTER DELETE trigger
+489            // unless `recursive_triggers` is on — it is not, so `items_ad` never
+490            // ran and the old `items_fts` row was orphaned — and (b) assigns a
+491            // *fresh rowid*, because `items.id` is a TEXT PRIMARY KEY, so
+492            // `items_ai` then appended a second index entry. The result was one
+493            // duplicate FTS index per catalog pass. ON CONFLICT keeps the rowid
+494            // that `items_fts.content_rowid` refers to and fires `items_au`,
+495            // which correctly replaces the entry.
+496            //
+497            // It also preserves columns absent from this statement (etag,
+498            // sort_name, tagline, …) instead of resetting them to defaults the
+499            // way REPLACE did.
+500            //
+501            // TRACES: UR-065 | DR-110 | UT-112
+502            let query = Query::with_params(
+503                "INSERT INTO items (
+504                    id, server_id, library_id, parent_id,
+505                    name, item_type, is_folder, overview,
+506                    genres, series_id, series_name,
+507                    season_id, season_name, index_number, parent_index_number,
+508                    album_id, album_name, album_artist, artists,
+509                    production_year, premiere_date, runtime_ticks,
+510                    primary_image_tag, backdrop_image_tags,
+511                    community_rating, official_rating,
+512                    synced_at
+513                ) VALUES (
+514                    ?1, ?2, ?3, ?4,
+515                    ?5, ?6, ?7, ?8,
+516                    ?9, ?10, ?11,
+517                    ?12, ?13, ?14, ?15,
+518                    ?16, ?17, ?18, ?19,
+519                    ?20, ?21, ?22,
+520                    ?23, ?24,
+521                    ?25, ?26,
+522                    ?27
+523                )
+524                ON CONFLICT(id) DO UPDATE SET
+525                    server_id = excluded.server_id,
+526                    -- This call site never supplies library_id (it is always
+527                    -- bound NULL), so keep whatever another path recorded rather
+528                    -- than clearing it the way REPLACE did.
+529                    library_id = COALESCE(excluded.library_id, items.library_id),
+530                    parent_id = excluded.parent_id,
+531                    name = excluded.name,
+532                    item_type = excluded.item_type,
+533                    is_folder = excluded.is_folder,
+534                    overview = excluded.overview,
+535                    genres = excluded.genres,
+536                    series_id = excluded.series_id,
+537                    series_name = excluded.series_name,
+538                    season_id = excluded.season_id,
+539                    season_name = excluded.season_name,
+540                    index_number = excluded.index_number,
+541                    parent_index_number = excluded.parent_index_number,
+542                    album_id = excluded.album_id,
+543                    album_name = excluded.album_name,
+544                    album_artist = excluded.album_artist,
+545                    artists = excluded.artists,
+546                    production_year = excluded.production_year,
+547                    premiere_date = excluded.premiere_date,
+548                    runtime_ticks = excluded.runtime_ticks,
+549                    primary_image_tag = excluded.primary_image_tag,
+550                    backdrop_image_tags = excluded.backdrop_image_tags,
+551                    community_rating = excluded.community_rating,
+552                    official_rating = excluded.official_rating,
+553                    synced_at = excluded.synced_at",
+554                vec![
+555                    QueryParam::String(item.id.clone()),
+556                    QueryParam::String(self.server_id.clone()),
+557                    // Library is NULL for cached items (may not be synced yet)
+558                    QueryParam::Null, // library_id
+559                    // Use the item's actual parent_id, not the function parameter
+560                    match &item.parent_id {
+561                        Some(pid) => QueryParam::String(pid.clone()),
+562                        None => QueryParam::Null,
+563                    },
+564                    QueryParam::String(item.name.clone()),
+565                    QueryParam::String(item.item_type.clone()),
+566                    QueryParam::Int(if item.is_folder { 1 } else { 0 }),
+567                    match &item.overview {
+568                        Some(o) => QueryParam::String(o.clone()),
+569                        None => QueryParam::Null,
+570                    },
+571                    match genres_json {
+572                        Some(g) => QueryParam::String(g),
+573                        None => QueryParam::Null,
+574                    },
+575                    match &item.series_id {
+576                        Some(s) => QueryParam::String(s.clone()),
+577                        None => QueryParam::Null,
+578                    },
+579                    match &item.series_name {
+580                        Some(s) => QueryParam::String(s.clone()),
+581                        None => QueryParam::Null,
+582                    },
+583                    match &item.season_id {
+584                        Some(s) => QueryParam::String(s.clone()),
+585                        None => QueryParam::Null,
+586                    },
+587                    match &item.season_name {
+588                        Some(s) => QueryParam::String(s.clone()),
+589                        None => QueryParam::Null,
+590                    },
+591                    match item.index_number {
+592                        Some(i) => QueryParam::Int(i),
+593                        None => QueryParam::Null,
+594                    },
+595                    match item.parent_index_number {
+596                        Some(i) => QueryParam::Int(i),
+597                        None => QueryParam::Null,
+598                    },
+599                    match &item.album_id {
+600                        Some(a) => QueryParam::String(a.clone()),
+601                        None => QueryParam::Null,
+602                    },
+603                    match &item.album_name {
+604                        Some(a) => QueryParam::String(a.clone()),
+605                        None => QueryParam::Null,
+606                    },
+607                    match &item.album_artist {
+608                        Some(a) => QueryParam::String(a.clone()),
+609                        None => QueryParam::Null,
+610                    },
+611                    match artists_json {
+612                        Some(a) => QueryParam::String(a),
+613                        None => QueryParam::Null,
+614                    },
+615                    match item.production_year {
+616                        Some(y) => QueryParam::Int(y),
+617                        None => QueryParam::Null,
+618                    },
+619                    match &item.premiere_date {
+620                        Some(d) => QueryParam::String(d.clone()),
+621                        None => QueryParam::Null,
+622                    },
+623                    match item.runtime_ticks {
+624                        Some(r) => QueryParam::Int64(r),
+625                        None => QueryParam::Null,
+626                    },
+627                    match &item.primary_image_tag {
+628                        Some(t) => QueryParam::String(t.clone()),
+629                        None => QueryParam::Null,
+630                    },
+631                    match backdrop_tags_json {
+632                        Some(b) => QueryParam::String(b),
+633                        None => QueryParam::Null,
+634                    },
+635                    match item.community_rating {
+636                        Some(r) => QueryParam::Float(r),
+637                        None => QueryParam::Null,
+638                    },
+639                    match &item.official_rating {
+640                        Some(r) => QueryParam::String(r.clone()),
+641                        None => QueryParam::Null,
+642                    },
+643                    QueryParam::String(now.to_string()),
+644                ],
+645            );
+646
+647            let _rows_affected =
+648                self.db_service
+649                    .execute(query)
+650                    .await
+651                    .map_err(|e| RepoError::Database {
+652                        message: format!("Failed to insert item {}: {}", item.id, e),
+653                    })?;
+654            #[cfg(test)]
+655            println!(
+656                "  [save_to_cache] Saved item {} (rows affected: {})",
+657                item.id, _rows_affected
+658            );
+659
+660            self.mirror_user_data(item, now).await?;
+661            count += 1;
+662        }
+663
+664        Ok(count)
+665    }
+666
+667    /// Mirror the server's per-user state for an item into the local
+668    /// `user_data` table, so favourites marked — and positions watched — on any
+669    /// other client are visible here, including offline, where the local table
+670    /// is the only source.
+671    ///
+672    /// The `WHERE user_data.pending_sync = 0` on the conflict clause is the
+673    /// conflict rule: a change made while the server was unreachable is still
+674    /// waiting to be pushed, and must not be clobbered by the stale value the
+675    /// server is still reporting. For a position that means it is never pulled
+676    /// *backwards* by a server that has not yet heard where we got to.
+677    ///
+678    /// Each field is mirrored only when the server actually reported it —
+679    /// `COALESCE(excluded.x, user_data.x)` keeps the stored value for anything
+680    /// absent, and a row with neither field is skipped outright rather than
+681    /// written as zeroes, which would fabricate an "unfavourited, unwatched"
+682    /// record from an endpoint that simply omits `UserData`.
+683    ///
+684    /// The position half is what makes cross-device resume work: the resume
+685    /// check reads this table alone, so before it was mirrored an item watched
+686    /// elsewhere resumed from whatever *this* device last saw, or not at all.
+687    ///
+688    /// TRACES: UR-025, UR-069 | DR-114, DR-155 | UT-102, UT-152
+689    async fn mirror_user_data(&self, item: &MediaItem, now: &str) -> Result<(), RepoError> {
+690        let user_data = item.user_data.as_ref();
+691        let is_favorite = user_data.and_then(|ud| ud.is_favorite);
+692        let position_ticks = user_data.and_then(|ud| ud.playback_position_ticks);
+693
+694        // Nothing the server actually told us about — do not invent a row.
+695        if is_favorite.is_none() && position_ticks.is_none() {
+696            return Ok(());
+697        }
+698
+699        let query = Query::with_params(
+700            "INSERT INTO user_data
+701                (user_id, item_id, is_favorite, playback_position_ticks, synced_at, pending_sync)
+702             VALUES (?1, ?2, ?3, ?4, ?5, 0)
+703             ON CONFLICT(user_id, item_id) DO UPDATE SET
+704                is_favorite = COALESCE(excluded.is_favorite, user_data.is_favorite),
+705                playback_position_ticks = COALESCE(
+706                    excluded.playback_position_ticks, user_data.playback_position_ticks),
+707                synced_at = excluded.synced_at
+708             WHERE user_data.pending_sync = 0",
+709            vec![
+710                QueryParam::String(self.user_id.clone()),
+711                QueryParam::String(item.id.clone()),
+712                is_favorite
+713                    .map(|f| QueryParam::Int(if f { 1 } else { 0 }))
+714                    .unwrap_or(QueryParam::Null),
+715                position_ticks
+716                    .map(QueryParam::Int64)
+717                    .unwrap_or(QueryParam::Null),
+718                QueryParam::String(now.to_string()),
+719            ],
+720        );
+721
+722        // A missing item row (FK) is not fatal here — the mirror is best-effort
+723        // metadata, and failing the whole cache write over it would break
+724        // browsing.
+725        if let Err(e) = self.db_service.execute(query).await {
+726            debug!(
+727                "[OfflineRepo] user_data mirror skipped for {}: {}",
+728                item.id, e
+729            );
+730        }
+731
+732        Ok(())
+733    }
+734
+735    /// Cache the library (view) list from the server into the local database.
+736    /// Called by HybridRepository after a successful online fetch so the list is
+737    /// available offline. Without this, the `libraries` table stays empty and
+738    /// offline startup shows no libraries at all.
+739    pub async fn save_libraries_to_cache(&self, libraries: &[Library]) -> Result<usize, RepoError> {
+740        if libraries.is_empty() {
+741            return Ok(0);
+742        }
+743
+744        let mut count = 0;
+745        for (idx, lib) in libraries.iter().enumerate() {
+746            let query = Query::with_params(
+747                "INSERT OR REPLACE INTO libraries (id, server_id, name, collection_type, image_tag, sort_order, synced_at)
+748                 VALUES (?, ?, ?, ?, ?, ?, CURRENT_TIMESTAMP)",
+749                vec![
+750                    QueryParam::String(lib.id.clone()),
+751                    QueryParam::String(self.server_id.clone()),
+752                    QueryParam::String(lib.name.clone()),
+753                    QueryParam::String(lib.collection_type.clone()),
+754                    lib.image_tag.clone().map(QueryParam::String).unwrap_or(QueryParam::Null),
+755                    QueryParam::Int(idx as i32),
+756                ],
+757            );
+758            self.db_service
+759                .execute(query)
+760                .await
+761                .map_err(|e| RepoError::Database { message: e })?;
+762            count += 1;
+763        }
+764        Ok(count)
+765    }
+766
+767    /// Cache the full server genre catalog for a library, so offline (and the
+768    /// hybrid cache-first race) can return the complete list instead of only the
+769    /// genres derivable from locally-cached albums. Replaces the scope's rows
+770    /// wholesale so genres removed on the server don't linger.
+771    pub async fn save_genres_to_cache(
+772        &self,
+773        parent_id: Option<&str>,
+774        genres: &[Genre],
+775    ) -> Result<usize, RepoError> {
+776        if genres.is_empty() {
+777            return Ok(0);
+778        }
+779
+780        // library_id is part of the primary key; NULL keys don't de-dupe in
+781        // SQLite, so store the "no library" scope as an empty string.
+782        let library_id = parent_id.unwrap_or("").to_string();
+783        let server_id = self.server_id.clone();
+784        let genres: Vec<(String, String, Option<u32>)> = genres
+785            .iter()
+786            .map(|g| (g.id.clone(), g.name.clone(), g.album_count))
+787            .collect();
+788        let saved = genres.len();
+789
+790        self.db_service
+791            .transaction(move |tx| {
+792                use crate::storage::db_service::{Query, QueryParam};
+793
+794                // Clear the scope's existing genres, then re-insert the fresh set.
+795                tx.execute(Query::with_params(
+796                    "DELETE FROM genres WHERE server_id = ? AND library_id = ?",
+797                    vec![
+798                        QueryParam::String(server_id.clone()),
+799                        QueryParam::String(library_id.clone()),
+800                    ],
+801                ))?;
+802
+803                for (id, name, album_count) in &genres {
+804                    tx.execute(Query::with_params(
+805                        "INSERT OR REPLACE INTO genres (id, server_id, library_id, name, album_count, synced_at)
+806                         VALUES (?, ?, ?, ?, ?, CURRENT_TIMESTAMP)",
+807                        vec![
+808                            QueryParam::String(id.clone()),
+809                            QueryParam::String(server_id.clone()),
+810                            QueryParam::String(library_id.clone()),
+811                            QueryParam::String(name.clone()),
+812                            album_count.map(|c| QueryParam::Int(c as i32)).unwrap_or(QueryParam::Null),
+813                        ],
+814                    ))?;
+815                }
+816
+817                Ok(())
+818            })
+819            .await
+820            .map_err(|e| RepoError::Database { message: e })?;
+821
+822        Ok(saved)
+823    }
+824
+825    /// SQL fragment: the set of item ids that are "on the device" — playable
+826    /// items with a completed download, plus containers (album/series/season)
+827    /// that have at least one downloaded child. This is the `get_items` CTE with
+828    /// the synced-but-not-downloaded catalog branch deliberately excluded, so it
+829    /// is authoritative regardless of the process-wide catalog-browse flag.
+830    ///
+831    /// Whether cached item `i` belongs to library `l`, decided by media kind.
+832    ///
+833    /// The cache leaves `library_id`/`parent_id` NULL on every item
+834    /// ([[offline-libraries-never-cached]]), so there is no link to follow: a
+835    /// library's `collection_type` and an item's `item_type` are the only things
+836    /// that can associate them. This is Jellyfin taxonomy and therefore lives in
+837    /// Rust, never in the frontend.
+838    ///
+839    /// It is a named constant because it is needed in two places that must agree
+840    /// — which library *appears* in the Downloaded list, and which items appear
+841    /// *inside* it. They disagreed: the listing query used this mapping while the
+842    /// browse query only checked that the requested library existed, so opening
+843    /// any library showed every downloaded top-level item on the server.
+844    ///
+845    /// A library of some other (or unknown) type keeps everything, since there is
+846    /// no mapping to narrow it by and hiding its contents would be worse.
+847    ///
+848    /// TRACES: UR-055 | DR-082, DR-167
+849    const LIBRARY_HOLDS_ITEM: &'static str = "(
+850           (l.collection_type = 'music'   AND i.item_type IN ('MusicAlbum', 'MusicArtist', 'Audio'))
+851        OR (l.collection_type = 'movies'  AND i.item_type = 'Movie')
+852        OR (l.collection_type = 'tvshows' AND i.item_type IN ('Series', 'Season', 'Episode'))
+853        OR l.collection_type IS NULL
+854        OR l.collection_type NOT IN ('music', 'movies', 'tvshows')
+855    )";
+856
+857    /// TRACES: UR-055 | DR-082, DR-083
+858    const DOWNLOADED_ITEMS_CTE: &'static str = "
+859        WITH downloaded_items AS (
+860            SELECT DISTINCT i.id
+861            FROM items i
+862            INNER JOIN downloads d ON i.id = d.item_id
+863            WHERE d.status = 'completed'
+864              AND i.item_type IN ('Audio', 'Movie', 'Episode')
+865
+866            UNION
+867
+868            SELECT DISTINCT i.id
+869            FROM items i
+870            INNER JOIN items children ON (children.parent_id = i.id OR children.album_id = i.id OR children.season_id = i.id OR children.series_id = i.id)
+871            INNER JOIN downloads d ON children.id = d.item_id
+872            WHERE d.status = 'completed'
+873              AND i.item_type IN ('MusicAlbum', 'Series', 'Season', 'BoxSet', 'Folder', 'CollectionFolder')
+874        )";
+875
+876    /// Downloaded-only browse: items under `parent_id` that are on the device.
+877    ///
+878    /// Unlike [`MediaRepository::get_items`], this never includes the
+879    /// synced-but-not-downloaded catalog and never consults the process-wide
+880    /// `INCLUDE_CATALOG_BROWSE` flag — it is the dedicated Downloads surface.
+881    /// An empty result is authoritative ("nothing downloaded here"), so the
+882    /// hybrid repo must call this directly rather than racing the server.
+883    ///
+884    /// TRACES: UR-055 | DR-082, DR-083
+885    pub async fn get_downloaded_items(
+886        &self,
+887        parent_id: &str,
+888        options: Option<GetItemsOptions>,
+889    ) -> Result<SearchResult, RepoError> {
+890        let opts = options.unwrap_or_default();
+891        let limit = opts.limit.unwrap_or(10000);
+892        let start_index = opts.start_index.unwrap_or(0);
+893
+894        let type_filter = if let Some(include_item_types) = &opts.include_item_types {
+895            if !include_item_types.is_empty() {
+896                let types = include_item_types
+897                    .iter()
+898                    .map(|t| format!("'{}'", t.replace('\'', "''")))
+899                    .collect::<Vec<_>>()
+900                    .join(",");
+901                format!(" AND i.item_type IN ({})", types)
+902            } else {
+903                String::new()
+904            }
+905        } else {
+906            String::new()
+907        };
+908
+909        // When the parent is a LIBRARY, cached items carry no link back to it
+910        // (library_id/parent_id are NULL), so the `libraries` EXISTS clause below
+911        // matches every downloaded item on the server — both containers
+912        // (MusicAlbum/Series/…) AND their leaves (Audio/Episode). Listing the
+913        // leaves alongside the containers is the "I see individual songs, not
+914        // albums" bug: a library landing page must show only *top-level* items.
+915        // So at the library level we exclude any leaf whose own container
+916        // (album/season/series/parent) is itself present in `downloaded_items` —
+917        // that container represents it in the grid. Items with no downloaded
+918        // container (e.g. a downloaded Movie, or a stray track whose album isn't
+919        // cached) still surface. This mirrors the online music library, which
+920        // routes to a dedicated albums view. See [[offline-libraries-never-cached]].
+921        let sql = format!(
+922            "{cte}
+923            SELECT i.id, i.name, i.item_type, i.server_id, i.parent_id, i.library_id, i.overview, i.genres,
+924                    i.runtime_ticks, i.production_year, i.community_rating, i.official_rating,
+925                    i.primary_image_tag, i.album_id, i.album_name, i.album_artist, i.artists,
+926                    i.index_number, i.series_id, i.series_name, i.season_id, i.season_name,
+927                    i.parent_index_number, i.is_folder, i.premiere_date
+928            FROM items i
+929             INNER JOIN downloaded_items di ON i.id = di.id
+930             WHERE i.server_id = ?
+931               AND (
+932                   i.parent_id = ? OR i.album_id = ? OR i.season_id = ? OR i.series_id = ?
+933                   OR (
+934                       EXISTS (
+935                           SELECT 1 FROM libraries l
+936                           WHERE l.id = ? AND l.server_id = i.server_id
+937                             AND {membership}
+938                       )
+939                       -- Top-level only: hide leaves whose container is downloaded.
+940                       AND NOT EXISTS (
+941                           SELECT 1 FROM downloaded_items parent
+942                           WHERE parent.id = i.album_id
+943                              OR parent.id = i.season_id
+944                              OR parent.id = i.series_id
+945                              OR parent.id = i.parent_id
+946                       )
+947                   )
+948               ){type_filter}
+949             ORDER BY i.sort_name ASC, i.name ASC
+950             LIMIT {limit} OFFSET {start_index}",
+951            cte = Self::DOWNLOADED_ITEMS_CTE,
+952            membership = Self::LIBRARY_HOLDS_ITEM,
+953        );
+954
+955        let query = Query::with_params(
+956            sql,
+957            vec![
+958                QueryParam::String(self.server_id.clone()),
+959                QueryParam::String(parent_id.to_string()),
+960                QueryParam::String(parent_id.to_string()),
+961                QueryParam::String(parent_id.to_string()),
+962                QueryParam::String(parent_id.to_string()),
+963                QueryParam::String(parent_id.to_string()),
+964            ],
+965        );
+966
+967        let cached_items: Vec<CachedItem> = self
+968            .db_service
+969            .query_many(query, row_to_cached_item)
+970            .await
+971            .map_err(|e| RepoError::Database { message: e })?;
+972
+973        let mut items = Vec::new();
+974        for cached in cached_items {
+975            let user_data = self.get_user_data(&cached.id).await;
+976            items.push(Self::cached_item_to_media_item(cached, user_data));
+977        }
+978
+979        let total_record_count = items.len();
+980        Ok(SearchResult {
+981            items,
+982            total_record_count,
+983        })
+984    }
+985
+986    /// Libraries that contain at least one downloaded item. Libraries with
+987    /// nothing on the device are omitted, so the Downloaded surface only lists
+988    /// libraries the user actually has offline content in.
+989    ///
+990    /// TRACES: UR-055 | DR-082
+991    pub async fn get_downloaded_libraries(&self) -> Result<Vec<Library>, RepoError> {
+992        // A downloaded item links back to its library only indirectly (the
+993        // cache leaves library_id NULL — see [[offline-libraries-never-cached]]).
+994        // We match a library by collection_type ↔ item_type instead: any
+995        // completed download of a given media kind qualifies that library.
+996        let query = Query::with_params(
+997            format!(
+998                "{cte}
+999                SELECT l.id, l.name, l.collection_type, l.image_tag
+1000                FROM libraries l
+1001                WHERE l.server_id = ?
+1002                  AND EXISTS (
+1003                      SELECT 1 FROM items i
+1004                      INNER JOIN downloaded_items di ON i.id = di.id
+1005                      WHERE i.server_id = l.server_id
+1006                        AND {membership}
+1007                  )
+1008                ORDER BY l.sort_order ASC, l.name ASC",
+1009                cte = Self::DOWNLOADED_ITEMS_CTE,
+1010                membership = Self::LIBRARY_HOLDS_ITEM,
+1011            ),
+1012            vec![QueryParam::String(self.server_id.clone())],
+1013        );
+1014
+1015        self.db_service
+1016            .query_many(query, |row| {
+1017                Ok(Library::new(
+1018                    row.get(0)?,
+1019                    row.get(1)?,
+1020                    row.get::<_, Option<String>>(2)?
+1021                        .unwrap_or_else(|| "unknown".to_string()),
+1022                    row.get(3)?,
+1023                ))
+1024            })
+1025            .await
+1026            .map_err(|e| RepoError::Database { message: e })
+1027    }
+1028
+1029    /// On-disk bytes for downloaded content, for the disk-usage display.
+1030    ///
+1031    /// Returns one entry per *container or leaf* that appears in the Downloaded
+1032    /// browse: a leaf's own `file_size`, a container's summed downloaded
+1033    /// descendants — plus the device total and item (leaf) count. This is pure
+1034    /// aggregation over `downloads.file_size`, not new tracking.
+1035    ///
+1036    /// TRACES: UR-056 | DR-085
+1037    pub async fn get_download_disk_usage(&self) -> Result<DownloadDiskUsage, RepoError> {
+1038        // Per-leaf sizes (completed playable downloads only).
+1039        let leaf_query = Query::with_params(
+1040            "SELECT d.item_id, COALESCE(d.file_size, 0)
+1041             FROM downloads d
+1042             INNER JOIN items i ON i.id = d.item_id
+1043             WHERE d.status = 'completed'
+1044               AND i.server_id = ?
+1045               AND i.item_type IN ('Audio', 'Movie', 'Episode')",
+1046            vec![QueryParam::String(self.server_id.clone())],
+1047        );
+1048        let leaves: Vec<(String, i64)> = self
+1049            .db_service
+1050            .query_many(leaf_query, |row| Ok((row.get(0)?, row.get(1)?)))
+1051            .await
+1052            .map_err(|e| RepoError::Database { message: e })?;
+1053
+1054        // Container subtotals: sum each container's downloaded descendants.
+1055        let container_query = Query::with_params(
+1056            "SELECT c.id, COALESCE(SUM(d.file_size), 0)
+1057             FROM items c
+1058             INNER JOIN items children
+1059                ON (children.parent_id = c.id OR children.album_id = c.id OR children.season_id = c.id OR children.series_id = c.id)
+1060             INNER JOIN downloads d ON children.id = d.item_id
+1061             WHERE d.status = 'completed'
+1062               AND c.server_id = ?
+1063               AND c.item_type IN ('MusicAlbum', 'Series', 'Season', 'BoxSet', 'Folder', 'CollectionFolder')
+1064             GROUP BY c.id",
+1065            vec![QueryParam::String(self.server_id.clone())],
+1066        );
+1067        let containers: Vec<(String, i64)> = self
+1068            .db_service
+1069            .query_many(container_query, |row| Ok((row.get(0)?, row.get(1)?)))
+1070            .await
+1071            .map_err(|e| RepoError::Database { message: e })?;
+1072
+1073        // Partiality per container: a container is "partial" when it has cached
+1074        // descendants that are NOT downloaded. We compare downloaded-descendant
+1075        // count against total-cached-descendant count (the offline cache holds
+1076        // the synced full catalog, so this is meaningful).
+1077        //
+1078        // Perf: restrict `c` to containers that actually have a completed
+1079        // download *first* (the CTE), so the OR-based self-join runs over that
+1080        // handful of rows instead of the entire synced catalog. Without this the
+1081        // join is an unindexable O(items²) scan and the Downloaded page hangs on
+1082        // a large library ("Loading your downloads…" forever).
+1083        let partial_query = Query::with_params(
+1084            "WITH downloaded_containers AS (
+1085                 SELECT DISTINCT c.id
+1086                 FROM items c
+1087                 INNER JOIN items children
+1088                    ON (children.parent_id = c.id OR children.album_id = c.id OR children.season_id = c.id OR children.series_id = c.id)
+1089                 INNER JOIN downloads d ON children.id = d.item_id
+1090                 WHERE d.status = 'completed'
+1091                   AND c.server_id = ?
+1092                   AND c.item_type IN ('MusicAlbum', 'Series', 'Season', 'BoxSet', 'Folder', 'CollectionFolder')
+1093             )
+1094             SELECT c.id,
+1095                    COUNT(children.id) AS total_children,
+1096                    SUM(CASE WHEN d.status = 'completed' THEN 1 ELSE 0 END) AS downloaded_children
+1097             FROM items c
+1098             INNER JOIN downloaded_containers dc ON dc.id = c.id
+1099             INNER JOIN items children
+1100                ON (children.parent_id = c.id OR children.album_id = c.id OR children.season_id = c.id OR children.series_id = c.id)
+1101             LEFT JOIN downloads d ON children.id = d.item_id AND d.status = 'completed'
+1102             WHERE children.item_type IN ('Audio', 'Movie', 'Episode', 'Season')
+1103             GROUP BY c.id",
+1104            vec![QueryParam::String(self.server_id.clone())],
+1105        );
+1106        let partial_rows: Vec<(String, i64, i64)> = self
+1107            .db_service
+1108            .query_many(partial_query, |row| {
+1109                Ok((
+1110                    row.get(0)?,
+1111                    row.get(1)?,
+1112                    row.get::<_, Option<i64>>(2)?.unwrap_or(0),
+1113                ))
+1114            })
+1115            .await
+1116            .map_err(|e| RepoError::Database { message: e })?;
+1117
+1118        let mut partial_containers = std::collections::HashMap::new();
+1119        for (id, total, downloaded) in partial_rows {
+1120            // Only record containers that actually have a download (they appear
+1121            // in the browse); mark partial when some cached child is missing.
+1122            if downloaded > 0 && downloaded < total {
+1123                partial_containers.insert(id, true);
+1124            }
+1125        }
+1126
+1127        let item_count = leaves.len() as u32;
+1128        let device_total_bytes: i64 = leaves.iter().map(|(_, b)| *b).sum();
+1129
+1130        let mut sizes = std::collections::HashMap::new();
+1131        for (id, bytes) in leaves.into_iter().chain(containers) {
+1132            // A container id can never collide with a leaf id, so a plain insert
+1133            // is fine; use entry to be defensive against duplicate rows.
+1134            *sizes.entry(id).or_insert(0) += bytes;
+1135        }
+1136
+1137        Ok(DownloadDiskUsage {
+1138            sizes,
+1139            partial_containers,
+1140            device_total_bytes,
+1141            item_count,
+1142        })
+1143    }
+1144
+1145    /// Cache playlist items from server into local database
+1146    /// Called by HybridRepository after fetching from online
+1147    pub async fn save_playlist_items_to_cache(
+1148        &self,
+1149        playlist_id: &str,
+1150        entries: &[PlaylistEntry],
+1151    ) -> Result<(), RepoError> {
+1152        let playlist_id = playlist_id.to_string();
+1153        let user_id = self.user_id.clone();
+1154        let entries: Vec<(String, String, usize)> = entries
+1155            .iter()
+1156            .enumerate()
+1157            .map(|(i, e)| (e.playlist_item_id.clone(), e.item.id.clone(), i))
+1158            .collect();
+1159
+1160        self.db_service
+1161            .transaction(move |tx| {
+1162                use crate::storage::db_service::{Query, QueryParam};
+1163
+1164                // Ensure playlist record exists
+1165                tx.execute(Query::with_params(
+1166                    "INSERT OR IGNORE INTO playlists (id, user_id, name, is_local) VALUES (?1, ?2, '', 0)",
+1167                    vec![QueryParam::String(playlist_id.clone()), QueryParam::String(user_id)],
+1168                ))?;
+1169
+1170                // Clear existing entries and re-insert
+1171                tx.execute(Query::with_params(
+1172                    "DELETE FROM playlist_items WHERE playlist_id = ?",
+1173                    vec![QueryParam::String(playlist_id.clone())],
+1174                ))?;
+1175
+1176                for (_, item_id, sort_order) in &entries {
+1177                    tx.execute(Query::with_params(
+1178                        "INSERT OR IGNORE INTO playlist_items (playlist_id, item_id, sort_order) VALUES (?1, ?2, ?3)",
+1179                        vec![
+1180                            QueryParam::String(playlist_id.clone()),
+1181                            QueryParam::String(item_id.clone()),
+1182                            QueryParam::Int(*sort_order as i32),
+1183                        ],
+1184                    ))?;
+1185                }
+1186
+1187                Ok(())
+1188            })
+1189            .await
+1190            .map_err(|e| RepoError::Database {
+1191                message: format!("Failed to cache playlist items: {}", e),
+1192            })
+1193    }
+1194}
+1195
+1196#[async_trait]
+1197impl MediaRepository for OfflineRepository {
+1198    async fn get_libraries(&self) -> Result<Vec<Library>, RepoError> {
+1199        // Return every cached library for this server. We deliberately do NOT
+1200        // gate on `items.library_id` here: that column is not populated in the
+1201        // cache (the Jellyfin client doesn't parse it), so the old
+1202        // `INNER JOIN items i ON i.library_id = l.id` matched nothing and left
+1203        // offline startup with zero libraries. Navigating into a library still
+1204        // filters to downloaded content via get_items, so listing all cached
+1205        // libraries is correct — it's the "local first" list the UI browses.
+1206        let query = Query::with_params(
+1207            "SELECT l.id, l.name, l.collection_type, l.image_tag
+1208             FROM libraries l
+1209             WHERE l.server_id = ?
+1210             ORDER BY l.sort_order ASC, l.name ASC",
+1211            vec![QueryParam::String(self.server_id.clone())],
+1212        );
+1213
+1214        self.db_service
+1215            .query_many(query, |row| {
+1216                Ok(Library::new(
+1217                    row.get(0)?,
+1218                    row.get(1)?,
+1219                    row.get::<_, Option<String>>(2)?
+1220                        .unwrap_or_else(|| "unknown".to_string()),
+1221                    row.get(3)?,
+1222                ))
+1223            })
+1224            .await
+1225            .map_err(|e| RepoError::Database { message: e })
+1226    }
+1227
+1228    async fn get_items(
+1229        &self,
+1230        parent_id: &str,
+1231        options: Option<GetItemsOptions>,
+1232    ) -> Result<SearchResult, RepoError> {
+1233        debug!(
+1234            "[OfflineRepo] get_items called for parent_id: {}",
+1235            &parent_id[..8.min(parent_id.len())]
+1236        );
+1237        let opts = options.unwrap_or_default();
+1238        let limit = opts.limit.unwrap_or(10000); // Match frontend limit for full library loading
+1239        let start_index = opts.start_index.unwrap_or(0);
+1240
+1241        // SortBy=Random is the only sort the landing pages rely on offline (the
+1242        // hero "surprise" pool); everything else keeps the stable name order.
+1243        let order_by = match opts.sort_by.as_deref() {
+1244            Some("Random") => "RANDOM()",
+1245            _ => "i.sort_name ASC, i.name ASC",
+1246        };
+1247
+1248        // Bind the type filter rather than interpolating it: `include_item_types`
+1249        // is settable straight from the frontend (GenericMediaListPage passes it),
+1250        // so a quote in a type must be data, not syntax. Same shape as `search`
+1251        // and `get_favorites`.
+1252        //
+1253        // TRACES: UR-065 | DR-212 | UT-206
+1254        let type_values: &[String] = opts
+1255            .include_item_types
+1256            .as_deref()
+1257            .filter(|types| !types.is_empty())
+1258            .unwrap_or(&[]);
+1259        let type_filter = if type_values.is_empty() {
+1260            String::new()
+1261        } else {
+1262            let placeholders = vec!["?"; type_values.len()].join(",");
+1263            format!(" AND i.item_type IN ({})", placeholders)
+1264        };
+1265
+1266        // Favourites narrowing for a normal library listing. Bound rather than
+1267        // interpolated, and appended after the parent-matching group so its
+1268        // parameter is simply the last one in the vec below.
+1269        // TRACES: UR-067 | DR-116 | UT-104
+1270        let favorites_filter = if opts.favorites_only == Some(true) {
+1271            " AND EXISTS (
+1272                   SELECT 1 FROM user_data ud
+1273                   WHERE ud.item_id = i.id AND ud.user_id = ? AND ud.is_favorite = 1
+1274               )"
+1275        } else {
+1276            ""
+1277        };
+1278
+1279        // Use CTE to find items that are either:
+1280        // 1. Playable items (Audio, Movie, Episode) with completed downloads (offline mode)
+1281        // 2. Container items (MusicAlbum, Series, Season) with at least one downloaded child (offline mode)
+1282        // 3. Cached items with recent synced_at timestamp (fast online browsing, or the
+1283        //    offline "Show all server media" catalog view) — only when the catalog-browse
+1284        //    flag is set. When offline with the toggle off, this branch is omitted so the
+1285        //    page shows downloaded/local media only. See `set_include_catalog_browse`.
+1286        let catalog_branch = if include_catalog_browse() {
+1287            "UNION
+1288
+1289                -- Cached items for fast browsing (online) or the offline catalog view
+1290                SELECT DISTINCT i.id
+1291                FROM items i
+1292                WHERE i.synced_at IS NOT NULL"
+1293        } else {
+1294            ""
+1295        };
+1296        let sql = format!(
+1297            "WITH available_items AS (
+1298                -- Playable items with completed downloads
+1299                SELECT DISTINCT i.id
+1300                FROM items i
+1301                INNER JOIN downloads d ON i.id = d.item_id
+1302                WHERE d.status = 'completed'
+1303                  AND i.item_type IN ('Audio', 'Movie', 'Episode')
+1304
+1305                UNION
+1306
+1307                -- Containers with downloaded children
+1308                SELECT DISTINCT i.id
+1309                FROM items i
+1310                INNER JOIN items children ON (children.parent_id = i.id OR children.album_id = i.id OR children.season_id = i.id OR children.series_id = i.id)
+1311                INNER JOIN downloads d ON children.id = d.item_id
+1312                WHERE d.status = 'completed'
+1313                  AND i.item_type IN ('MusicAlbum', 'Series', 'Season', 'BoxSet', 'Folder', 'CollectionFolder')
+1314
+1315                {catalog_branch}
+1316            )
+1317            SELECT i.id, i.name, i.item_type, i.server_id, i.parent_id, i.library_id, i.overview, i.genres,
+1318                    i.runtime_ticks, i.production_year, i.community_rating, i.official_rating,
+1319                    i.primary_image_tag, i.album_id, i.album_name, i.album_artist, i.artists,
+1320                    i.index_number, i.series_id, i.series_name, i.season_id, i.season_name,
+1321                    i.parent_index_number, i.is_folder, i.premiere_date
+1322            FROM items i
+1323             INNER JOIN available_items ai ON i.id = ai.id
+1324             WHERE i.server_id = ?
+1325               AND (
+1326                   i.parent_id = ? OR i.album_id = ? OR i.season_id = ? OR i.series_id = ?
+1327                   -- When the requested parent is a LIBRARY, there is no per-item
+1328                   -- link back to it (library_id/parent_id are NULL in the cache),
+1329                   -- so match every item on the server and let the type filter
+1330                   -- (e.g. MusicAlbum / Movie / Series) narrow it. This is what
+1331                   -- makes library landing pages show albums/movies/shows offline.
+1332                   OR EXISTS (
+1333                       SELECT 1 FROM libraries l
+1334                       WHERE l.id = ? AND l.server_id = i.server_id
+1335                   )
+1336               ){}{}
+1337             ORDER BY {}
+1338             LIMIT {} OFFSET {}",
+1339            type_filter, favorites_filter, order_by, limit, start_index
+1340        );
+1341
+1342        // The requested id is compared against every hierarchy-linkage column
+1343        // because `parent_id` is not populated for cached items — music tracks
+1344        // link to their album via `album_id`, episodes to their season/series
+1345        // via `season_id`/`series_id`, and a library parent matches via the
+1346        // `libraries` EXISTS clause. See [[offline-libraries-never-cached]].
+1347        let mut params = vec![
+1348            QueryParam::String(self.server_id.clone()),
+1349            QueryParam::String(parent_id.to_string()), // i.parent_id = ?
+1350            QueryParam::String(parent_id.to_string()), // i.album_id = ?
+1351            QueryParam::String(parent_id.to_string()), // i.season_id = ?
+1352            QueryParam::String(parent_id.to_string()), // i.series_id = ?
+1353            QueryParam::String(parent_id.to_string()), // libraries.id = ?
+1354        ];
+1355        // Positional order matters: the type placeholders sit in `{type_filter}`,
+1356        // which the statement interpolates immediately after the parent-matching
+1357        // group and before `{favorites_filter}`, so they bind here — after the
+1358        // six ids above, before the favourites user id.
+1359        params.extend(type_values.iter().cloned().map(QueryParam::String));
+1360        if !favorites_filter.is_empty() {
+1361            params.push(QueryParam::String(self.user_id.clone())); // ud.user_id = ?
+1362        }
+1363        let query = Query::with_params(sql, params);
+1364
+1365        let cached_items: Vec<CachedItem> = self
+1366            .db_service
+1367            .query_many(query, row_to_cached_item)
+1368            .await
+1369            .map_err(|e| RepoError::Database { message: e })?;
+1370
+1371        debug!(
+1372            "[OfflineRepo] Found {} cached items for parent {}",
+1373            cached_items.len(),
+1374            &parent_id[..8.min(parent_id.len())]
+1375        );
+1376
+1377        // Fetch user data for each item
+1378        let mut items = Vec::new();
+1379        for cached in cached_items {
+1380            let user_data = self.get_user_data(&cached.id).await;
+1381            items.push(Self::cached_item_to_media_item(cached, user_data));
+1382        }
+1383
+1384        let total_record_count = items.len();
+1385
+1386        debug!(
+1387            "[OfflineRepo] Returning {} items for parent {}",
+1388            total_record_count,
+1389            &parent_id[..8.min(parent_id.len())]
+1390        );
+1391
+1392        Ok(SearchResult {
+1393            items,
+1394            total_record_count,
+1395        })
+1396    }
+1397
+1398    async fn get_item(&self, item_id: &str) -> Result<MediaItem, RepoError> {
+1399        // Check if item is available offline (either downloaded itself or has downloaded children)
+1400        let query = Query::with_params(
+1401            "WITH downloaded_items AS (
+1402                -- Playable items with completed downloads
+1403                SELECT DISTINCT i.id
+1404                FROM items i
+1405                INNER JOIN downloads d ON i.id = d.item_id
+1406                WHERE d.status = 'completed'
+1407                  AND i.item_type IN ('Audio', 'Movie', 'Episode')
+1408
+1409                UNION
+1410
+1411                -- Containers with downloaded children
+1412                SELECT DISTINCT i.id
+1413                FROM items i
+1414                INNER JOIN items children ON (children.parent_id = i.id OR children.album_id = i.id OR children.season_id = i.id OR children.series_id = i.id)
+1415                INNER JOIN downloads d ON children.id = d.item_id
+1416                WHERE d.status = 'completed'
+1417                  AND i.item_type IN ('MusicAlbum', 'Series', 'Season', 'BoxSet', 'Folder', 'CollectionFolder')
+1418            )
+1419            SELECT i.id, i.name, i.item_type, i.server_id, i.parent_id, i.library_id, i.overview, i.genres,
+1420                    i.runtime_ticks, i.production_year, i.community_rating, i.official_rating,
+1421                    i.primary_image_tag, i.album_id, i.album_name, i.album_artist, i.artists,
+1422                    i.index_number, i.series_id, i.series_name, i.season_id, i.season_name,
+1423                    i.parent_index_number, i.is_folder, i.premiere_date
+1424            FROM items i
+1425             INNER JOIN downloaded_items di ON i.id = di.id
+1426             WHERE i.id = ?",
+1427            vec![QueryParam::String(item_id.to_string())],
+1428        );
+1429
+1430        let cached = self
+1431            .db_service
+1432            .query_optional(query, row_to_cached_item)
+1433            .await
+1434            .map_err(|e| RepoError::Database { message: e })?
+1435            .ok_or_else(|| RepoError::NotFound {
+1436                message: format!(
+1437                    "Item {} not found in offline cache or not downloaded",
+1438                    item_id
+1439                ),
+1440            })?;
+1441
+1442        let user_data = self.get_user_data(item_id).await;
+1443        Ok(Self::cached_item_to_media_item(cached, user_data))
+1444    }
+1445
+1446    async fn get_latest_items(
+1447        &self,
+1448        parent_id: &str,
+1449        limit: Option<usize>,
+1450    ) -> Result<Vec<MediaItem>, RepoError> {
+1451        let limit_val = limit.unwrap_or(16);
+1452
+1453        let query = Query::with_params(
+1454            format!(
+1455                "WITH downloaded_items AS (
+1456                    -- Playable items with completed downloads
+1457                    SELECT DISTINCT i.id
+1458                    FROM items i
+1459                    INNER JOIN downloads d ON i.id = d.item_id
+1460                    WHERE d.status = 'completed'
+1461                      AND i.item_type IN ('Audio', 'Movie', 'Episode')
+1462
+1463                    UNION
+1464
+1465                    -- Containers with downloaded children
+1466                    SELECT DISTINCT i.id
+1467                    FROM items i
+1468                    INNER JOIN items children ON (children.parent_id = i.id OR children.album_id = i.id OR children.season_id = i.id OR children.series_id = i.id)
+1469                    INNER JOIN downloads d ON children.id = d.item_id
+1470                    WHERE d.status = 'completed'
+1471                      AND i.item_type IN ('MusicAlbum', 'Series', 'Season', 'BoxSet', 'Folder', 'CollectionFolder')
+1472                )
+1473                SELECT i.id, i.name, i.item_type, i.server_id, i.parent_id, i.library_id, i.overview, i.genres,
+1474                        i.runtime_ticks, i.production_year, i.community_rating, i.official_rating,
+1475                        i.primary_image_tag, i.album_id, i.album_name, i.album_artist, i.artists,
+1476                        i.index_number, i.series_id, i.series_name, i.season_id, i.season_name,
+1477                        i.parent_index_number, i.is_folder, i.premiere_date
+1478                FROM items i
+1479                 INNER JOIN downloaded_items di ON i.id = di.id
+1480                 WHERE i.server_id = ? AND i.library_id = ?
+1481                 -- Collapse leaves into the container that was added: a new
+1482                 -- 14-track album should read as one album, not 14 songs. Only
+1483                 -- drops a leaf when its own container is present in the same
+1484                 -- result, so a standalone track or movie still appears.
+1485                 AND NOT EXISTS (
+1486                     SELECT 1 FROM downloaded_items parent
+1487                     WHERE parent.id IN (i.album_id, i.season_id, i.series_id, i.parent_id)
+1488                 )
+1489                 ORDER BY i.synced_at DESC
+1490                 LIMIT {}", limit_val
+1491            ),
+1492            vec![
+1493                QueryParam::String(self.server_id.clone()),
+1494                QueryParam::String(parent_id.to_string()),
+1495            ],
+1496        );
+1497
+1498        let cached_items: Vec<CachedItem> = self
+1499            .db_service
+1500            .query_many(query, row_to_cached_item)
+1501            .await
+1502            .map_err(|e| RepoError::Database { message: e })?;
+1503
+1504        let mut items = Vec::new();
+1505        for cached in cached_items {
+1506            let user_data = self.get_user_data(&cached.id).await;
+1507            items.push(Self::cached_item_to_media_item(cached, user_data));
+1508        }
+1509
+1510        Ok(items)
+1511    }
+1512
+1513    async fn get_resume_items(
+1514        &self,
+1515        parent_id: Option<&str>,
+1516        limit: Option<usize>,
+1517    ) -> Result<Vec<MediaItem>, RepoError> {
+1518        let limit_val = limit.unwrap_or(12);
+1519
+1520        // Resume items are video-only (Movie, Episode) - audio is handled by get_recently_played_audio
+1521        let (sql, params) = if let Some(pid) = parent_id {
+1522            (
+1523                format!(
+1524                    "SELECT i.id, i.name, i.item_type, i.server_id, i.parent_id, i.library_id,
+1525                            i.overview, i.genres, i.runtime_ticks, i.production_year,
+1526                            i.community_rating, i.official_rating, i.primary_image_tag,
+1527                            i.album_id, i.album_name, i.album_artist, i.artists,
+1528                            i.index_number, i.series_id, i.series_name, i.season_id,
+1529                            i.season_name, i.parent_index_number, i.is_folder, i.premiere_date
+1530                    FROM items i
+1531                     JOIN user_data ud ON i.id = ud.item_id
+1532                     INNER JOIN downloads d ON i.id = d.item_id
+1533                     WHERE i.server_id = ? AND ud.user_id = ? AND i.library_id = ?
+1534                       AND ud.playback_position_ticks > 0 AND ud.is_played = 0
+1535                       AND d.status = 'completed'
+1536                       AND i.item_type IN ('Movie', 'Episode')
+1537                     ORDER BY ud.last_played_at DESC
+1538                     LIMIT {}",
+1539                    limit_val
+1540                ),
+1541                vec![
+1542                    QueryParam::String(self.server_id.clone()),
+1543                    QueryParam::String(self.user_id.clone()),
+1544                    QueryParam::String(pid.to_string()),
+1545                ],
+1546            )
+1547        } else {
+1548            (
+1549                format!(
+1550                    "SELECT i.id, i.name, i.item_type, i.server_id, i.parent_id, i.library_id,
+1551                            i.overview, i.genres, i.runtime_ticks, i.production_year,
+1552                            i.community_rating, i.official_rating, i.primary_image_tag,
+1553                            i.album_id, i.album_name, i.album_artist, i.artists,
+1554                            i.index_number, i.series_id, i.series_name, i.season_id,
+1555                            i.season_name, i.parent_index_number, i.is_folder, i.premiere_date
+1556                    FROM items i
+1557                     JOIN user_data ud ON i.id = ud.item_id
+1558                     INNER JOIN downloads d ON i.id = d.item_id
+1559                     WHERE i.server_id = ? AND ud.user_id = ?
+1560                       AND ud.playback_position_ticks > 0 AND ud.is_played = 0
+1561                       AND d.status = 'completed'
+1562                       AND i.item_type IN ('Movie', 'Episode')
+1563                     ORDER BY ud.last_played_at DESC
+1564                     LIMIT {}",
+1565                    limit_val
+1566                ),
+1567                vec![
+1568                    QueryParam::String(self.server_id.clone()),
+1569                    QueryParam::String(self.user_id.clone()),
+1570                ],
+1571            )
+1572        };
+1573
+1574        let query = Query::with_params(sql, params);
+1575
+1576        let cached_items: Vec<CachedItem> = self
+1577            .db_service
+1578            .query_many(query, row_to_cached_item)
+1579            .await
+1580            .map_err(|e| RepoError::Database { message: e })?;
+1581
+1582        let mut items = Vec::new();
+1583        for cached in cached_items {
+1584            let user_data = self.get_user_data(&cached.id).await;
+1585            items.push(Self::cached_item_to_media_item(cached, user_data));
+1586        }
+1587
+1588        Ok(items)
+1589    }
+1590
+1591    async fn get_next_up_episodes(
+1592        &self,
+1593        _series_id: Option<&str>,
+1594        _limit: Option<usize>,
+1595    ) -> Result<Vec<MediaItem>, RepoError> {
+1596        // Next up is complex - would need to track watched episodes and find the next unwatched
+1597        // For now, return empty for offline mode
+1598        Ok(Vec::new())
+1599    }
+1600
+1601    async fn get_recently_played_audio(
+1602        &self,
+1603        limit: Option<usize>,
+1604    ) -> Result<Vec<MediaItem>, RepoError> {
+1605        let limit_val = limit.unwrap_or(12);
+1606
+1607        // Use CTE to intelligently group by playback context and filter by downloads
+1608        // Shows containers (albums) when context_type='container', individual tracks when context_type='single'
+1609        // Falls back to album grouping for legacy data (NULL context)
+1610        // Only shows items that are downloaded or have downloaded children
+1611        let query = Query::with_params(
+1612            format!(
+1613                "WITH downloaded_items AS (
+1614                    -- Playable items with completed downloads (Audio tracks)
+1615                    SELECT DISTINCT i.id
+1616                    FROM items i
+1617                    INNER JOIN downloads d ON i.id = d.item_id
+1618                    WHERE d.status = 'completed'
+1619                      AND i.item_type = 'Audio'
+1620
+1621                    UNION
+1622
+1623                    -- Containers with downloaded children (Albums)
+1624                    SELECT DISTINCT i.id
+1625                    FROM items i
+1626                    INNER JOIN items children ON (children.parent_id = i.id OR children.album_id = i.id OR children.season_id = i.id OR children.series_id = i.id)
+1627                    INNER JOIN downloads d ON children.id = d.item_id
+1628                    WHERE d.status = 'completed'
+1629                      AND i.item_type = 'MusicAlbum'
+1630                ),
+1631                ranked_plays AS (
+1632                    SELECT
+1633                        CASE
+1634                            WHEN ud.playback_context_type = 'container' THEN ud.playback_context_id
+1635                            WHEN ud.playback_context_type = 'single' THEN ud.item_id
+1636                            ELSE COALESCE(i.album_id, ud.item_id)
+1637                        END AS display_id,
+1638                        MAX(ud.last_played_at) AS most_recent_play
+1639                    FROM user_data ud
+1640                    JOIN items i ON ud.item_id = i.id
+1641                    WHERE ud.user_id = ? AND i.server_id = ?
+1642                      AND i.item_type = 'Audio'
+1643                      AND ud.last_played_at IS NOT NULL
+1644                    GROUP BY display_id
+1645                    ORDER BY most_recent_play DESC
+1646                    LIMIT {}
+1647                )
+1648                SELECT DISTINCT i.id, i.name, i.item_type, i.server_id, i.parent_id, i.library_id,
+1649                       i.overview, i.genres, i.runtime_ticks, i.production_year,
+1650                       i.community_rating, i.official_rating, i.primary_image_tag,
+1651                       i.album_id, i.album_name, i.album_artist, i.artists,
+1652                       i.index_number, i.series_id, i.series_name, i.season_id,
+1653                       i.season_name, i.parent_index_number, i.is_folder, i.premiere_date
+1654               FROM ranked_plays rp
+1655                JOIN items i ON rp.display_id = i.id
+1656                INNER JOIN downloaded_items di ON i.id = di.id
+1657                ORDER BY rp.most_recent_play DESC",
+1658                limit_val
+1659            ),
+1660            vec![
+1661                QueryParam::String(self.user_id.clone()),
+1662                QueryParam::String(self.server_id.clone()),
+1663            ],
+1664        );
+1665
+1666        let cached_items: Vec<CachedItem> = self
+1667            .db_service
+1668            .query_many(query, row_to_cached_item)
+1669            .await
+1670            .map_err(|e| RepoError::Database { message: e })?;
+1671
+1672        let mut items = Vec::new();
+1673        for cached in cached_items {
+1674            let user_data = self.get_user_data(&cached.id).await;
+1675            items.push(Self::cached_item_to_media_item(cached, user_data));
+1676        }
+1677
+1678        Ok(items)
+1679    }
+1680
+1681    async fn get_rediscover_albums(
+1682        &self,
+1683        _parent_id: Option<&str>,
+1684        _limit: Option<usize>,
+1685    ) -> Result<Vec<MediaItem>, RepoError> {
+1686        // "Rediscover" is a discovery feature over the full server library.
+1687        // Offline only holds downloaded items, so there is nothing meaningful
+1688        // to surface here; the hybrid repo serves this from the server instead.
+1689        Ok(Vec::new())
+1690    }
+1691
+1692    async fn get_resume_movies(&self, limit: Option<usize>) -> Result<Vec<MediaItem>, RepoError> {
+1693        let limit_val = limit.unwrap_or(12);
+1694
+1695        // Resume movies are playable items, so simple JOIN with downloads
+1696        let query = Query::with_params(
+1697            format!(
+1698                "SELECT i.id, i.name, i.item_type, i.server_id, i.parent_id, i.library_id,
+1699                        i.overview, i.genres, i.runtime_ticks, i.production_year,
+1700                        i.community_rating, i.official_rating, i.primary_image_tag,
+1701                        i.album_id, i.album_name, i.album_artist, i.artists,
+1702                        i.index_number, i.series_id, i.series_name, i.season_id,
+1703                        i.season_name, i.parent_index_number, i.is_folder, i.premiere_date
+1704                FROM items i
+1705                 JOIN user_data ud ON i.id = ud.item_id
+1706                 INNER JOIN downloads d ON i.id = d.item_id
+1707                 WHERE i.server_id = ? AND ud.user_id = ? AND i.item_type = 'Movie'
+1708                   AND ud.playback_position_ticks > 0 AND ud.is_played = 0
+1709                   AND d.status = 'completed'
+1710                 ORDER BY ud.last_played_at DESC
+1711                 LIMIT {}",
+1712                limit_val
+1713            ),
+1714            vec![
+1715                QueryParam::String(self.server_id.clone()),
+1716                QueryParam::String(self.user_id.clone()),
+1717            ],
+1718        );
+1719
+1720        let cached_items: Vec<CachedItem> = self
+1721            .db_service
+1722            .query_many(query, row_to_cached_item)
+1723            .await
+1724            .map_err(|e| RepoError::Database { message: e })?;
+1725
+1726        let mut items = Vec::new();
+1727        for cached in cached_items {
+1728            let user_data = self.get_user_data(&cached.id).await;
+1729            items.push(Self::cached_item_to_media_item(cached, user_data));
+1730        }
+1731
+1732        Ok(items)
+1733    }
+1734
+1735    async fn get_genres(&self, parent_id: Option<&str>) -> Result<Vec<Genre>, RepoError> {
+1736        // Read the cached server genre catalog (populated by the hybrid repo via
+1737        // save_genres_to_cache). This is the FULL genre list for the library, not
+1738        // just the genres derivable from locally-cached albums — so offline keeps
+1739        // the same variety the server has. library_id NULL is stored as ''.
+1740        let library_id = parent_id.unwrap_or("").to_string();
+1741
+1742        let query = Query::with_params(
+1743            "SELECT id, name, album_count FROM genres WHERE server_id = ? AND library_id = ?",
+1744            vec![
+1745                QueryParam::String(self.server_id.clone()),
+1746                QueryParam::String(library_id),
+1747            ],
+1748        );
+1749
+1750        let genres: Vec<Genre> = self
+1751            .db_service
+1752            .query_many(query, |row| {
+1753                Ok(Genre {
+1754                    id: row.get(0)?,
+1755                    name: row.get(1)?,
+1756                    album_count: row.get::<_, Option<i64>>(2)?.map(|c| c as u32),
+1757                })
+1758            })
+1759            .await
+1760            .map_err(|e| RepoError::Database { message: e })?;
+1761
+1762        Ok(genres)
+1763    }
+1764
+1765    async fn search(
+1766        &self,
+1767        query: &str,
+1768        options: Option<SearchOptions>,
+1769    ) -> Result<SearchResult, RepoError> {
+1770        let opts = options.unwrap_or_default();
+1771        let limit = opts.limit.unwrap_or(20);
+1772
+1773        // Nothing searchable (empty query, or pure punctuation): return an empty
+1774        // result rather than letting FTS5 reject the string and fail the search.
+1775        let Some(fts_query) = build_fts_prefix_query(query) else {
+1776            return Ok(SearchResult {
+1777                items: Vec::new(),
+1778                total_record_count: 0,
+1779            });
+1780        };
+1781
+1782        // Bind the type filter rather than interpolating it: `include_item_types`
+1783        // is settable straight from the frontend (GenericMediaListPage passes it),
+1784        // so a quote in a type must be data, not syntax.
+1785        let type_values: &[String] = opts
+1786            .include_item_types
+1787            .as_deref()
+1788            .filter(|types| !types.is_empty())
+1789            .unwrap_or(&[]);
+1790        let type_filter = if type_values.is_empty() {
+1791            String::new()
+1792        } else {
+1793            let placeholders = vec!["?"; type_values.len()].join(",");
+1794            format!(" AND i.item_type IN ({})", placeholders)
+1795        };
+1796
+1797        // Availability CTE — deliberately identical to the one `get_items` uses,
+1798        // including the `include_catalog_browse()` gate, so search and browse can
+1799        // never disagree about what is visible. Before DR-108 this leg was
+1800        // downloads-only, which meant a user with no downloads got nothing from
+1801        // the local index and every keystroke fell through to the server.
+1802        let catalog_branch = if include_catalog_browse() {
+1803            "UNION
+1804
+1805                -- Synced catalog: fast online search, or the offline
+1806                -- 'Show all server media' view. See set_include_catalog_browse.
+1807                SELECT DISTINCT i.id
+1808                FROM items i
+1809                WHERE i.synced_at IS NOT NULL"
+1810        } else {
+1811            ""
+1812        };
+1813
+1814        let sql = format!(
+1815            "WITH available_items AS (
+1816                -- Playable items with completed downloads
+1817                SELECT DISTINCT i.id
+1818                FROM items i
+1819                INNER JOIN downloads d ON i.id = d.item_id
+1820                WHERE d.status = 'completed'
+1821                  AND i.item_type IN ('Audio', 'Movie', 'Episode')
+1822
+1823                UNION
+1824
+1825                -- Containers with downloaded children
+1826                SELECT DISTINCT i.id
+1827                FROM items i
+1828                INNER JOIN items children ON (children.parent_id = i.id OR children.album_id = i.id OR children.season_id = i.id OR children.series_id = i.id)
+1829                INNER JOIN downloads d ON children.id = d.item_id
+1830                WHERE d.status = 'completed'
+1831                  AND i.item_type IN ('MusicAlbum', 'Series', 'Season', 'BoxSet', 'Folder', 'CollectionFolder')
+1832
+1833                {}
+1834            )
+1835            SELECT i.id, i.name, i.item_type, i.server_id, i.parent_id, i.library_id,
+1836                    i.overview, i.genres, i.runtime_ticks, i.production_year,
+1837                    i.community_rating, i.official_rating, i.primary_image_tag,
+1838                    i.album_id, i.album_name, i.album_artist, i.artists,
+1839                    i.index_number, i.series_id, i.series_name, i.season_id,
+1840                    i.season_name, i.parent_index_number, i.is_folder, i.premiere_date
+1841            FROM items i
+1842             JOIN items_fts fts ON fts.rowid = i.rowid
+1843             INNER JOIN available_items ai ON i.id = ai.id
+1844             WHERE i.server_id = ? AND items_fts MATCH ?{}
+1845             ORDER BY rank
+1846             LIMIT {}",
+1847            catalog_branch, type_filter, limit
+1848        );
+1849
+1850        let mut params = vec![
+1851            QueryParam::String(self.server_id.clone()),
+1852            QueryParam::String(fts_query.clone()),
+1853        ];
+1854        params.extend(type_values.iter().cloned().map(QueryParam::String));
+1855
+1856        let db_query = Query::with_params(sql, params);
+1857
+1858        let cached_items: Vec<CachedItem> = self
+1859            .db_service
+1860            .query_many(db_query, row_to_cached_item)
+1861            .await
+1862            .map_err(|e| RepoError::Database { message: e })?;
+1863
+1864        let mut items = Vec::new();
+1865        for cached in cached_items {
+1866            let user_data = self.get_user_data(&cached.id).await;
+1867            items.push(Self::cached_item_to_media_item(cached, user_data));
+1868        }
+1869
+1870        // People are not rows in `items` — they live in their own table — so
+1871        // they need a second lookup. Only when the scope admits them: an
+1872        // explicit type filter (Music/Movies/TV) never includes People, whereas
+1873        // `SearchScope::All` expands to *no* filter (DR-063), which is exactly
+1874        // the case where the People group should be populated.
+1875        if type_values.is_empty() {
+1876            items.extend(self.search_people(&fts_query, limit).await?);
+1877        }
+1878
+1879        let total_record_count = items.len();
+1880
+1881        Ok(SearchResult {
+1882            items,
+1883            total_record_count,
+1884        })
+1885    }
+1886
+1887    async fn get_playback_info(&self, _item_id: &str) -> Result<PlaybackInfo, RepoError> {
+1888        // Playback info requires server communication for transcoding decisions
+1889        Err(RepoError::Offline)
+1890    }
+1891
+1892    async fn get_audio_stream_url(&self, _item_id: &str) -> Result<String, RepoError> {
+1893        // Cannot get stream URLs while offline - offline tracks use local paths
+1894        Err(RepoError::Offline)
+1895    }
+1896
+1897    async fn get_audio_only_stream_url_for_video(
+1898        &self,
+1899        _item_id: &str,
+1900        _media_source_id: Option<&str>,
+1901        _start_time_seconds: Option<f64>,
+1902        _audio_stream_index: Option<i32>,
+1903    ) -> Result<String, RepoError> {
+1904        // Audio-only transcode requires the server; offline downloads play locally.
+1905        Err(RepoError::Offline)
+1906    }
+1907
+1908    async fn get_live_tv_channels(&self) -> Result<Vec<MediaItem>, RepoError> {
+1909        // Live TV is inherently online-only.
+1910        Err(RepoError::Offline)
+1911    }
+1912
+1913    async fn get_channels(&self) -> Result<SearchResult, RepoError> {
+1914        // Plugin channels are inherently online-only.
+1915        Err(RepoError::Offline)
+1916    }
+1917
+1918    async fn open_live_stream(&self, _item_id: &str) -> Result<LiveStreamInfo, RepoError> {
+1919        // Live streams cannot be opened offline.
+1920        Err(RepoError::Offline)
+1921    }
+1922
+1923    async fn report_playback_start(
+1924        &self,
+1925        _item_id: &str,
+1926        _position_ticks: i64,
+1927    ) -> Result<(), RepoError> {
+1928        // Cannot report to server while offline
+1929        Err(RepoError::Offline)
+1930    }
+1931
+1932    async fn report_playback_progress(
+1933        &self,
+1934        _item_id: &str,
+1935        _position_ticks: i64,
+1936    ) -> Result<(), RepoError> {
+1937        // Cannot report to server while offline
+1938        Err(RepoError::Offline)
+1939    }
+1940
+1941    async fn report_playback_stopped(
+1942        &self,
+1943        _item_id: &str,
+1944        _position_ticks: i64,
+1945    ) -> Result<(), RepoError> {
+1946        // Cannot report to server while offline
+1947        Err(RepoError::Offline)
+1948    }
+1949
+1950    fn get_image_url(
+1951        &self,
+1952        item_id: &str,
+1953        image_type: ImageType,
+1954        options: Option<ImageOptions>,
+1955    ) -> String {
+1956        // Return a placeholder path for offline image retrieval
+1957        // The actual image should be in thumbnail cache
+1958        let type_str = match image_type {
+1959            ImageType::Primary => "Primary",
+1960            ImageType::Backdrop => "Backdrop",
+1961            ImageType::Logo => "Logo",
+1962            ImageType::Thumb => "Thumb",
+1963            ImageType::Banner => "Banner",
+1964        };
+1965
+1966        if let Some(opts) = options {
+1967            if let Some(tag) = opts.tag {
+1968                return format!("offline://{}/{}/{}", item_id, type_str, tag);
+1969            }
+1970        }
+1971
+1972        format!("offline://{}/{}", item_id, type_str)
+1973    }
+1974
+1975    fn get_subtitle_url(
+1976        &self,
+1977        _item_id: &str,
+1978        _media_source_id: &str,
+1979        _stream_index: i32,
+1980        _format: &str,
+1981    ) -> String {
+1982        // Subtitles not available offline
+1983        String::new()
+1984    }
+1985
+1986    fn get_video_download_url(
+1987        &self,
+1988        _item_id: &str,
+1989        _quality: &str,
+1990        _media_source_id: Option<&str>,
+1991        _source_audio_codec: Option<&str>,
+1992    ) -> String {
+1993        // Cannot download while offline
+1994        String::new()
+1995    }
+1996
+1997    async fn mark_favorite(&self, _item_id: &str) -> Result<(), RepoError> {
+1998        // Cannot update server while offline
+1999        Err(RepoError::Offline)
+2000    }
+2001
+2002    async fn unmark_favorite(&self, _item_id: &str) -> Result<(), RepoError> {
+2003        // Cannot update server while offline
+2004        Err(RepoError::Offline)
+2005    }
+2006
+2007    /// Favourites held locally — the ones mirrored from the server by
+2008    /// `save_to_cache` plus anything favourited on this device.
+2009    ///
+2010    /// Gated by the same `available_items` rules as browsing, so with "Show all
+2011    /// server media" off this returns favourites that are actually on the
+2012    /// device rather than the whole favourited catalog (DR-080).
+2013    ///
+2014    /// TRACES: UR-067 | DR-115 | UT-101
+2015    async fn get_favorites(
+2016        &self,
+2017        scope: SearchScope,
+2018        options: Option<GetItemsOptions>,
+2019    ) -> Result<SearchResult, RepoError> {
+2020        let opts = options.unwrap_or_default();
+2021        let limit = opts.limit.unwrap_or(10000);
+2022        let start_index = opts.start_index.unwrap_or(0);
+2023
+2024        // Scope → item types is expanded in Rust (DR-063); `All` yields no
+2025        // filter at all rather than a union.
+2026        let type_filter = match scope.item_types() {
+2027            Some(types) if !types.is_empty() => {
+2028                let placeholders = vec!["?"; types.len()].join(",");
+2029                format!(" AND i.item_type IN ({})", placeholders)
+2030            }
+2031            _ => String::new(),
+2032        };
+2033
+2034        let catalog_branch = if include_catalog_browse() {
+2035            "UNION
+2036
+2037                SELECT DISTINCT i.id
+2038                FROM items i
+2039                WHERE i.synced_at IS NOT NULL"
+2040        } else {
+2041            ""
+2042        };
+2043
+2044        let sql = format!(
+2045            "WITH available_items AS (
+2046                SELECT DISTINCT i.id
+2047                FROM items i
+2048                INNER JOIN downloads d ON i.id = d.item_id
+2049                WHERE d.status = 'completed'
+2050                  AND i.item_type IN ('Audio', 'Movie', 'Episode')
+2051
+2052                UNION
+2053
+2054                SELECT DISTINCT i.id
+2055                FROM items i
+2056                INNER JOIN items children ON (children.parent_id = i.id OR children.album_id = i.id OR children.season_id = i.id OR children.series_id = i.id)
+2057                INNER JOIN downloads d ON children.id = d.item_id
+2058                WHERE d.status = 'completed'
+2059                  AND i.item_type IN ('MusicAlbum', 'Series', 'Season', 'BoxSet', 'Folder', 'CollectionFolder')
+2060
+2061                {catalog_branch}
+2062            )
+2063            SELECT i.id, i.name, i.item_type, i.server_id, i.parent_id, i.library_id, i.overview, i.genres,
+2064                    i.runtime_ticks, i.production_year, i.community_rating, i.official_rating,
+2065                    i.primary_image_tag, i.album_id, i.album_name, i.album_artist, i.artists,
+2066                    i.index_number, i.series_id, i.series_name, i.season_id, i.season_name,
+2067                    i.parent_index_number, i.is_folder, i.premiere_date
+2068            FROM items i
+2069             INNER JOIN available_items ai ON i.id = ai.id
+2070             INNER JOIN user_data ud ON ud.item_id = i.id
+2071             WHERE i.server_id = ?
+2072               AND ud.user_id = ?
+2073               AND ud.is_favorite = 1{}
+2074             ORDER BY i.sort_name ASC, i.name ASC
+2075             LIMIT {} OFFSET {}",
+2076            type_filter, limit, start_index
+2077        );
+2078
+2079        let mut params = vec![
+2080            QueryParam::String(self.server_id.clone()),
+2081            QueryParam::String(self.user_id.clone()),
+2082        ];
+2083        if let Some(types) = scope.item_types() {
+2084            params.extend(types.into_iter().map(QueryParam::String));
+2085        }
+2086
+2087        let cached_items: Vec<CachedItem> = self
+2088            .db_service
+2089            .query_many(Query::with_params(sql, params), row_to_cached_item)
+2090            .await
+2091            .map_err(|e| RepoError::Database { message: e })?;
+2092
+2093        let mut items = Vec::new();
+2094        for cached in cached_items {
+2095            let user_data = self.get_user_data(&cached.id).await;
+2096            items.push(Self::cached_item_to_media_item(cached, user_data));
+2097        }
+2098
+2099        let total_record_count = items.len();
+2100        debug!("[OfflineRepo] Returning {} favourites", total_record_count);
+2101
+2102        Ok(SearchResult {
+2103            items,
+2104            total_record_count,
+2105        })
+2106    }
+2107
+2108    async fn clear_watch_history(&self, _item_id: &str) -> Result<(), RepoError> {
+2109        // Erasing history has to reach the server to be meaningful — clearing
+2110        // it only locally would be silently undone by the next sync.
+2111        Err(RepoError::Offline)
+2112    }
+2113
+2114    async fn mark_played(&self, _item_id: &str) -> Result<(), RepoError> {
+2115        // Offline the local flag is written by `storage_mark_played` and the
+2116        // server half is queued in `sync_queue`; this path has no server.
+2117        Err(RepoError::Offline)
+2118    }
+2119
+2120    async fn get_person(&self, person_id: &str) -> Result<MediaItem, RepoError> {
+2121        let query = Query::with_params(
+2122            "SELECT id, name, overview, primary_image_tag
+2123             FROM people WHERE id = ?",
+2124            vec![QueryParam::String(person_id.to_string())],
+2125        );
+2126
+2127        let person_data = self
+2128            .db_service
+2129            .query_optional(query, |row| {
+2130                Ok((
+2131                    row.get::<_, String>(0)?,
+2132                    row.get::<_, String>(1)?,
+2133                    row.get::<_, Option<String>>(2)?,
+2134                    row.get::<_, Option<String>>(3)?,
+2135                ))
+2136            })
+2137            .await
+2138            .map_err(|e| RepoError::Database { message: e })?
+2139            .ok_or_else(|| RepoError::NotFound {
+2140                message: format!("Person {} not found in cache", person_id),
+2141            })?;
+2142
+2143        Ok(MediaItem {
+2144            id: person_data.0,
+2145            name: person_data.1,
+2146            item_type: "Person".to_string(),
+2147            kind: crate::domain::MediaKind::Person,
+2148            is_folder: false,
+2149            server_id: self.server_id.clone(),
+2150            parent_id: None,
+2151            library_id: None,
+2152            overview: person_data.2,
+2153            genres: None,
+2154            runtime_ticks: None,
+2155            duration_ms: None,
+2156            production_year: None,
+2157            premiere_date: None,
+2158            community_rating: None,
+2159            official_rating: None,
+2160            primary_image_tag: person_data.3.clone(),
+2161            image_id: person_data.3,
+2162            backdrop_image_tags: None,
+2163            parent_backdrop_image_tags: None,
+2164            album_id: None,
+2165            album_name: None,
+2166            album_artist: None,
+2167            artists: None,
+2168            artist_items: None,
+2169            index_number: None,
+2170            series_id: None,
+2171            series_name: None,
+2172            season_id: None,
+2173            season_name: None,
+2174            parent_index_number: None,
+2175            user_data: None,
+2176            media_streams: None,
+2177            media_sources: None,
+2178            people: None,
+2179        })
+2180    }
+2181
+2182    async fn get_items_by_person(
+2183        &self,
+2184        person_id: &str,
+2185        options: Option<GetItemsOptions>,
+2186    ) -> Result<SearchResult, RepoError> {
+2187        let opts = options.unwrap_or_default();
+2188        let limit = opts.limit.unwrap_or(10000); // Match frontend limit
+2189
+2190        // Filter by downloads using CTE
+2191        let query = Query::with_params(
+2192            format!(
+2193                "WITH downloaded_items AS (
+2194                    -- Playable items with completed downloads
+2195                    SELECT DISTINCT i.id
+2196                    FROM items i
+2197                    INNER JOIN downloads d ON i.id = d.item_id
+2198                    WHERE d.status = 'completed'
+2199                      AND i.item_type IN ('Audio', 'Movie', 'Episode')
+2200
+2201                    UNION
+2202
+2203                    -- Containers with downloaded children
+2204                    SELECT DISTINCT i.id
+2205                    FROM items i
+2206                    INNER JOIN items children ON (children.parent_id = i.id OR children.album_id = i.id OR children.season_id = i.id OR children.series_id = i.id)
+2207                    INNER JOIN downloads d ON children.id = d.item_id
+2208                    WHERE d.status = 'completed'
+2209                      AND i.item_type IN ('MusicAlbum', 'Series', 'Season', 'BoxSet', 'Folder', 'CollectionFolder')
+2210                )
+2211                SELECT i.id, i.name, i.item_type, i.server_id, i.parent_id, i.library_id,
+2212                        i.overview, i.genres, i.runtime_ticks, i.production_year,
+2213                        i.community_rating, i.official_rating, i.primary_image_tag,
+2214                        i.album_id, i.album_name, i.album_artist, i.artists,
+2215                        i.index_number, i.series_id, i.series_name, i.season_id,
+2216                        i.season_name, i.parent_index_number, i.is_folder, i.premiere_date
+2217                FROM items i
+2218                 JOIN item_people ip ON i.id = ip.item_id
+2219                 INNER JOIN downloaded_items di ON i.id = di.id
+2220                 WHERE i.server_id = ? AND ip.person_id = ?
+2221                 ORDER BY i.production_year DESC, i.sort_name ASC
+2222                 LIMIT {}", limit
+2223            ),
+2224            vec![
+2225                QueryParam::String(self.server_id.clone()),
+2226                QueryParam::String(person_id.to_string()),
+2227            ],
+2228        );
+2229
+2230        let cached_items: Vec<CachedItem> = self
+2231            .db_service
+2232            .query_many(query, row_to_cached_item)
+2233            .await
+2234            .map_err(|e| RepoError::Database { message: e })?;
+2235
+2236        let mut items = Vec::new();
+2237        for cached in cached_items {
+2238            let user_data = self.get_user_data(&cached.id).await;
+2239            items.push(Self::cached_item_to_media_item(cached, user_data));
+2240        }
+2241
+2242        let total_record_count = items.len();
+2243
+2244        Ok(SearchResult {
+2245            items,
+2246            total_record_count,
+2247        })
+2248    }
+2249
+2250    async fn get_similar_items(
+2251        &self,
+2252        _item_id: &str,
+2253        _limit: Option<usize>,
+2254    ) -> Result<SearchResult, RepoError> {
+2255        // Similar items require server-side computation and are not available offline
+2256        Err(RepoError::Offline)
+2257    }
+2258
+2259    // ===== Playlist Methods =====
+2260
+2261    async fn create_playlist(
+2262        &self,
+2263        name: &str,
+2264        item_ids: &[String],
+2265    ) -> Result<PlaylistCreatedResult, RepoError> {
+2266        let playlist_id = uuid::Uuid::new_v4().to_string();
+2267        let user_id = self.user_id.clone();
+2268        let name = name.to_string();
+2269        let item_ids = item_ids.to_vec();
+2270        let pid = playlist_id.clone();
+2271
+2272        self.db_service
+2273            .transaction(move |tx| {
+2274                use crate::storage::db_service::{Query, QueryParam};
+2275
+2276                tx.execute(Query::with_params(
+2277                    "INSERT INTO playlists (id, user_id, name, is_local) VALUES (?1, ?2, ?3, 1)",
+2278                    vec![QueryParam::String(pid.clone()), QueryParam::String(user_id), QueryParam::String(name)],
+2279                ))?;
+2280
+2281                for (i, item_id) in item_ids.iter().enumerate() {
+2282                    tx.execute(Query::with_params(
+2283                        "INSERT OR IGNORE INTO playlist_items (playlist_id, item_id, sort_order) VALUES (?1, ?2, ?3)",
+2284                        vec![QueryParam::String(pid.clone()), QueryParam::String(item_id.clone()), QueryParam::Int(i as i32)],
+2285                    ))?;
+2286                }
+2287
+2288                Ok(())
+2289            })
+2290            .await
+2291            .map_err(|e| RepoError::Database {
+2292                message: format!("Failed to create playlist: {}", e),
+2293            })?;
+2294
+2295        Ok(PlaylistCreatedResult { id: playlist_id })
+2296    }
+2297
+2298    async fn delete_playlist(&self, playlist_id: &str) -> Result<(), RepoError> {
+2299        let query = Query::with_params(
+2300            "DELETE FROM playlists WHERE id = ?",
+2301            vec![QueryParam::String(playlist_id.to_string())],
+2302        );
+2303        self.db_service
+2304            .execute(query)
+2305            .await
+2306            .map_err(|e| RepoError::Database {
+2307                message: format!("Failed to delete playlist: {}", e),
+2308            })?;
+2309        Ok(())
+2310    }
+2311
+2312    async fn rename_playlist(&self, playlist_id: &str, name: &str) -> Result<(), RepoError> {
+2313        let query = Query::with_params(
+2314            "UPDATE playlists SET name = ?, updated_at = CURRENT_TIMESTAMP WHERE id = ?",
+2315            vec![
+2316                QueryParam::String(name.to_string()),
+2317                QueryParam::String(playlist_id.to_string()),
+2318            ],
+2319        );
+2320        self.db_service
+2321            .execute(query)
+2322            .await
+2323            .map_err(|e| RepoError::Database {
+2324                message: format!("Failed to rename playlist: {}", e),
+2325            })?;
+2326        Ok(())
+2327    }
+2328
+2329    async fn get_playlist_items(&self, playlist_id: &str) -> Result<Vec<PlaylistEntry>, RepoError> {
+2330        let query = Query::with_params(
+2331            "SELECT pi.id, \
+2332                    i.id, i.name, i.item_type, i.server_id, i.parent_id, i.library_id, i.overview, i.genres, \
+2333                    i.runtime_ticks, i.production_year, i.community_rating, i.official_rating, \
+2334                    i.primary_image_tag, i.album_id, i.album_name, i.album_artist, i.artists, \
+2335                    i.index_number, i.series_id, i.series_name, i.season_id, i.season_name, \
+2336                    i.parent_index_number, i.is_folder, i.premiere_date \
+2337             FROM playlist_items pi \
+2338             JOIN items i ON pi.item_id = i.id \
+2339             WHERE pi.playlist_id = ? \
+2340             ORDER BY pi.sort_order ASC",
+2341            vec![QueryParam::String(playlist_id.to_string())],
+2342        );
+2343
+2344        let items = self
+2345            .db_service
+2346            .query_many(query, |row| {
+2347                let entry_id: i64 = row.get(0)?;
+2348                // Columns offset by 1 because first column is pi.id
+2349                let cached = CachedItem {
+2350                    id: row.get(1)?,
+2351                    name: row.get(2)?,
+2352                    item_type: row.get(3)?,
+2353                    server_id: row.get(4)?,
+2354                    parent_id: row.get(5)?,
+2355                    library_id: row.get(6)?,
+2356                    overview: row.get(7)?,
+2357                    genres: row.get(8)?,
+2358                    runtime_ticks: row.get(9)?,
+2359                    production_year: row.get(10)?,
+2360                    community_rating: row.get(11)?,
+2361                    official_rating: row.get(12)?,
+2362                    primary_image_tag: row.get(13)?,
+2363                    backdrop_image_tags: None,
+2364                    parent_backdrop_image_tags: None,
+2365                    album_id: row.get(14)?,
+2366                    album_name: row.get(15)?,
+2367                    album_artist: row.get(16)?,
+2368                    artists: row.get(17)?,
+2369                    index_number: row.get(18)?,
+2370                    series_id: row.get(19)?,
+2371                    series_name: row.get(20)?,
+2372                    season_id: row.get(21)?,
+2373                    season_name: row.get(22)?,
+2374                    parent_index_number: row.get(23)?,
+2375                    is_folder: row.get::<_, Option<i64>>(24)?.unwrap_or(0) != 0,
+2376                    premiere_date: row.get(25)?,
+2377                };
+2378                Ok((entry_id.to_string(), cached))
+2379            })
+2380            .await
+2381            .map_err(|e| RepoError::Database {
+2382                message: format!("Failed to get playlist items: {}", e),
+2383            })?;
+2384
+2385        Ok(items
+2386            .into_iter()
+2387            .map(|(entry_id, cached)| PlaylistEntry {
+2388                playlist_item_id: entry_id,
+2389                item: Self::cached_item_to_media_item(cached, None),
+2390            })
+2391            .collect())
+2392    }
+2393
+2394    async fn add_to_playlist(
+2395        &self,
+2396        playlist_id: &str,
+2397        item_ids: &[String],
+2398    ) -> Result<(), RepoError> {
+2399        // Get current max sort_order
+2400        let max_query = Query::with_params(
+2401            "SELECT COALESCE(MAX(sort_order), -1) FROM playlist_items WHERE playlist_id = ?",
+2402            vec![QueryParam::String(playlist_id.to_string())],
+2403        );
+2404        let max_order: i32 = self
+2405            .db_service
+2406            .query_one(max_query, |row| row.get(0))
+2407            .await
+2408            .unwrap_or(-1);
+2409
+2410        let playlist_id = playlist_id.to_string();
+2411        let item_ids = item_ids.to_vec();
+2412
+2413        self.db_service
+2414            .transaction(move |tx| {
+2415                use crate::storage::db_service::{Query, QueryParam};
+2416
+2417                for (i, item_id) in item_ids.iter().enumerate() {
+2418                    tx.execute(Query::with_params(
+2419                        "INSERT OR IGNORE INTO playlist_items (playlist_id, item_id, sort_order) VALUES (?1, ?2, ?3)",
+2420                        vec![
+2421                            QueryParam::String(playlist_id.clone()),
+2422                            QueryParam::String(item_id.clone()),
+2423                            QueryParam::Int(max_order + 1 + i as i32),
+2424                        ],
+2425                    ))?;
+2426                }
+2427                Ok(())
+2428            })
+2429            .await
+2430            .map_err(|e| RepoError::Database {
+2431                message: format!("Failed to add items to playlist: {}", e),
+2432            })?;
+2433
+2434        Ok(())
+2435    }
+2436
+2437    async fn remove_from_playlist(
+2438        &self,
+2439        playlist_id: &str,
+2440        entry_ids: &[String],
+2441    ) -> Result<(), RepoError> {
+2442        let playlist_id = playlist_id.to_string();
+2443        let entry_ids = entry_ids.to_vec();
+2444
+2445        self.db_service
+2446            .transaction(move |tx| {
+2447                use crate::storage::db_service::{Query, QueryParam};
+2448
+2449                for entry_id in &entry_ids {
+2450                    tx.execute(Query::with_params(
+2451                        "DELETE FROM playlist_items WHERE playlist_id = ? AND id = ?",
+2452                        vec![
+2453                            QueryParam::String(playlist_id.clone()),
+2454                            QueryParam::String(entry_id.clone()),
+2455                        ],
+2456                    ))?;
+2457                }
+2458                Ok(())
+2459            })
+2460            .await
+2461            .map_err(|e| RepoError::Database {
+2462                message: format!("Failed to remove items from playlist: {}", e),
+2463            })?;
+2464
+2465        Ok(())
+2466    }
+2467
+2468    async fn move_playlist_item(
+2469        &self,
+2470        playlist_id: &str,
+2471        item_id: &str,
+2472        new_index: u32,
+2473    ) -> Result<(), RepoError> {
+2474        let playlist_id = playlist_id.to_string();
+2475        let item_id = item_id.to_string();
+2476
+2477        self.db_service
+2478            .transaction(move |tx| {
+2479                use crate::storage::db_service::{Query, QueryParam};
+2480
+2481                // Get all items ordered by sort_order
+2482                let items: Vec<(i64, String)> = tx.query_many(
+2483                    Query::with_params(
+2484                        "SELECT id, item_id FROM playlist_items WHERE playlist_id = ? ORDER BY sort_order",
+2485                        vec![QueryParam::String(playlist_id)],
+2486                    ),
+2487                    |row| Ok((row.get(0)?, row.get(1)?)),
+2488                )?;
+2489
+2490                // Find the item to move
+2491                let old_idx = items.iter().position(|(_, iid)| iid == &item_id);
+2492                if let Some(old_pos) = old_idx {
+2493                    let mut ids = items;
+2494                    let entry = ids.remove(old_pos);
+2495                    let insert_at = (new_index as usize).min(ids.len());
+2496                    ids.insert(insert_at, entry);
+2497
+2498                    // Renumber all sort_orders
+2499                    for (i, (entry_id, _)) in ids.iter().enumerate() {
+2500                        tx.execute(Query::with_params(
+2501                            "UPDATE playlist_items SET sort_order = ? WHERE id = ?",
+2502                            vec![QueryParam::Int(i as i32), QueryParam::Int64(*entry_id)],
+2503                        ))?;
+2504                    }
+2505                }
+2506
+2507                Ok(())
+2508            })
+2509            .await
+2510            .map_err(|e| RepoError::Database {
+2511                message: format!("Failed to move playlist item: {}", e),
+2512            })?;
+2513
+2514        Ok(())
+2515    }
+2516}
+2517
+2518#[cfg(test)]
+2519mod tests {
+2520    // `CATALOG_BROWSE_LOCK` below serialises the tests that flip the
+2521    // process-global `INCLUDE_CATALOG_BROWSE` flag, so its guard is deliberately
+2522    // held across the `.await` of the query under test — that await *is* the
+2523    // critical section. This is not the production deadlock hazard the lint
+2524    // targets: the lock is test-only, uncontended outside these tests, and each
+2525    // `#[tokio::test]` runs on its own single-threaded runtime, so a held guard
+2526    // cannot block another task on the same worker. Restructuring around it
+2527    // would reintroduce the flag race the lock exists to prevent.
+2528    #![allow(clippy::await_holding_lock)]
+2529
+2530    use super::*;
+2531    use crate::storage::db_service::RusqliteService;
+2532    use rusqlite::Connection;
+2533    use std::sync::{Arc, Mutex};
+2534
+2535    /// Helper to create a test database with the necessary schema
+2536    /// `INCLUDE_CATALOG_BROWSE` is a process-global `AtomicBool`, so every test
+2537    /// that flips it must hold this lock — cargo runs tests in parallel threads
+2538    /// and would otherwise let them race, producing intermittent failures that
+2539    /// look like query bugs. Poisoning is recovered rather than propagated: a
+2540    /// panic in one such test should not cascade into unrelated ones.
+2541    static CATALOG_BROWSE_LOCK: std::sync::Mutex<()> = std::sync::Mutex::new(());
+2542
+2543    fn lock_catalog_browse() -> std::sync::MutexGuard<'static, ()> {
+2544        use crate::utils::lock::MutexSafe;
+2545        CATALOG_BROWSE_LOCK.lock_safe()
+2546    }
+2547
+2548    /// TRACES: UR-065 | DR-108 | UT-111
+2549    #[test]
+2550    fn test_build_fts_prefix_query() {
+2551        // Plain input: quoted phrase plus the prefix operator.
+2552        assert_eq!(build_fts_prefix_query("Arr").as_deref(), Some("\"Arr\"*"));
+2553
+2554        // Multiple tokens stay implicitly ANDed; only the last is a prefix, so
+2555        // results still narrow while the user is typing.
+2556        assert_eq!(
+2557            build_fts_prefix_query("parks rec").as_deref(),
+2558            Some("\"parks\" \"rec\"*")
+2559        );
+2560
+2561        // Punctuation is data, not FTS5 syntax. Unquoted, each of these makes
+2562        // MATCH raise a syntax error and the whole search fail.
+2563        for query in ["Bob's Burgers", "Spider-Man", "AC/DC", "Wall-E", "9-1-1"] {
+2564            let built = build_fts_prefix_query(query).expect("should build");
+2565            assert!(
+2566                built.starts_with('"') && built.ends_with("*"),
+2567                "{query:?} produced {built:?}"
+2568            );
+2569        }
+2570
+2571        // An embedded double quote is escaped by doubling, not left to close
+2572        // the phrase early.
+2573        assert_eq!(
+2574            build_fts_prefix_query("say \"hi\"").as_deref(),
+2575            Some("\"say\" \"\"\"hi\"\"\"*")
+2576        );
+2577
+2578        // Nothing searchable => None, so callers skip the query instead of
+2579        // handing FTS5 a string it rejects. `search("")` is a real call site.
+2580        assert_eq!(build_fts_prefix_query(""), None);
+2581        assert_eq!(build_fts_prefix_query("   "), None);
+2582        assert_eq!(build_fts_prefix_query("-"), None);
+2583    }
+2584
+2585    /// An empty query must yield an empty result, not a database error — the
+2586    /// "list all playlists" call site passes one.
+2587    ///
+2588    /// TRACES: UR-065 | DR-108 | UT-111
+2589    #[tokio::test]
+2590    async fn test_search_empty_query_returns_empty_not_error() {
+2591        let db_service = create_test_db();
+2592        let repo = OfflineRepository::new(
+2593            db_service,
+2594            "test-server".to_string(),
+2595            "test-user".to_string(),
+2596        );
+2597
+2598        let result = repo.search("", None).await;
+2599        assert!(
+2600            result.is_ok(),
+2601            "empty query must not error: {:?}",
+2602            result.err()
+2603        );
+2604        assert!(result.unwrap().items.is_empty());
+2605    }
+2606
+2607    fn create_test_db() -> Arc<RusqliteService> {
+2608        let conn = Connection::open_in_memory().unwrap();
+2609
+2610        // Enable foreign key constraints (they're disabled by default in SQLite)
+2611        conn.execute("PRAGMA foreign_keys = ON", []).unwrap();
+2612
+2613        // Create minimal schema for testing
+2614        conn.execute_batch(
+2615            r#"
+2616            CREATE TABLE servers (
+2617                id TEXT PRIMARY KEY,
+2618                name TEXT NOT NULL,
+2619                url TEXT NOT NULL UNIQUE
+2620            );
+2621
+2622            CREATE TABLE items (
+2623                id TEXT PRIMARY KEY,
+2624                server_id TEXT NOT NULL REFERENCES servers(id) ON DELETE CASCADE,
+2625                library_id TEXT,
+2626                parent_id TEXT REFERENCES items(id) ON DELETE CASCADE,
+2627                name TEXT NOT NULL,
+2628                item_type TEXT NOT NULL,
+2629                is_folder INTEGER DEFAULT 0,
+2630                overview TEXT,
+2631                genres TEXT,
+2632                runtime_ticks INTEGER,
+2633                production_year INTEGER,
+2634                premiere_date TEXT,
+2635                community_rating REAL,
+2636                official_rating TEXT,
+2637                primary_image_tag TEXT,
+2638                backdrop_image_tags TEXT,
+2639                album_id TEXT,
+2640                album_name TEXT,
+2641                album_artist TEXT,
+2642                artists TEXT,
+2643                index_number INTEGER,
+2644                series_id TEXT,
+2645                series_name TEXT,
+2646                season_id TEXT,
+2647                season_name TEXT,
+2648                parent_index_number INTEGER,
+2649                synced_at TEXT,
+2650                sort_name TEXT
+2651            );
+2652
+2653            -- Mirrors the real FTS5 index and its triggers (schema.rs migration
+2654            -- 001) so search can be exercised in tests at all.
+2655            CREATE VIRTUAL TABLE items_fts USING fts5(
+2656                name, overview, album_name, album_artist, artists, series_name,
+2657                content='items', content_rowid='rowid'
+2658            );
+2659
+2660            CREATE TRIGGER items_ai AFTER INSERT ON items BEGIN
+2661                INSERT INTO items_fts(rowid, name, overview, album_name, album_artist, artists, series_name)
+2662                VALUES (new.rowid, new.name, new.overview, new.album_name, new.album_artist, new.artists, new.series_name);
+2663            END;
+2664
+2665            CREATE TRIGGER items_ad AFTER DELETE ON items BEGIN
+2666                INSERT INTO items_fts(items_fts, rowid, name, overview, album_name, album_artist, artists, series_name)
+2667                VALUES('delete', old.rowid, old.name, old.overview, old.album_name, old.album_artist, old.artists, old.series_name);
+2668            END;
+2669
+2670            CREATE TRIGGER items_au AFTER UPDATE ON items BEGIN
+2671                INSERT INTO items_fts(items_fts, rowid, name, overview, album_name, album_artist, artists, series_name)
+2672                VALUES('delete', old.rowid, old.name, old.overview, old.album_name, old.album_artist, old.artists, old.series_name);
+2673                INSERT INTO items_fts(rowid, name, overview, album_name, album_artist, artists, series_name)
+2674                VALUES (new.rowid, new.name, new.overview, new.album_name, new.album_artist, new.artists, new.series_name);
+2675            END;
+2676
+2677            CREATE TABLE user_data (
+2678                user_id TEXT NOT NULL,
+2679                item_id TEXT NOT NULL,
+2680                playback_position_ticks INTEGER,
+2681                is_played INTEGER,
+2682                is_favorite INTEGER,
+2683                play_count INTEGER,
+2684                last_played_at TEXT,
+2685                playback_context_type TEXT,
+2686                playback_context_id TEXT,
+2687                synced_at TEXT,
+2688                pending_sync INTEGER DEFAULT 0,
+2689                PRIMARY KEY (user_id, item_id)
+2690            );
+2691
+2692            CREATE TABLE playlists (
+2693                id TEXT PRIMARY KEY,
+2694                user_id TEXT NOT NULL,
+2695                name TEXT NOT NULL,
+2696                is_local INTEGER DEFAULT 0,
+2697                jellyfin_id TEXT,
+2698                created_at TEXT DEFAULT CURRENT_TIMESTAMP,
+2699                updated_at TEXT
+2700            );
+2701
+2702            CREATE TABLE playlist_items (
+2703                id INTEGER PRIMARY KEY AUTOINCREMENT,
+2704                playlist_id TEXT NOT NULL REFERENCES playlists(id) ON DELETE CASCADE,
+2705                item_id TEXT NOT NULL REFERENCES items(id) ON DELETE CASCADE,
+2706                sort_order INTEGER NOT NULL,
+2707                added_at TEXT DEFAULT CURRENT_TIMESTAMP,
+2708                UNIQUE(playlist_id, item_id)
+2709            );
+2710
+2711            CREATE INDEX idx_playlist_items_playlist ON playlist_items(playlist_id, sort_order);
+2712
+2713            CREATE TABLE downloads (
+2714                id INTEGER PRIMARY KEY AUTOINCREMENT,
+2715                item_id TEXT NOT NULL,
+2716                status TEXT NOT NULL,
+2717                file_size INTEGER
+2718            );
+2719
+2720            CREATE TABLE libraries (
+2721                id TEXT PRIMARY KEY,
+2722                server_id TEXT NOT NULL,
+2723                name TEXT NOT NULL,
+2724                collection_type TEXT,
+2725                image_tag TEXT,
+2726                sort_order INTEGER DEFAULT 0,
+2727                synced_at TEXT
+2728            );
+2729
+2730            -- Mirrors migration 009 + the migration 022 FTS index.
+2731            CREATE TABLE people (
+2732                id TEXT PRIMARY KEY,
+2733                server_id TEXT NOT NULL,
+2734                name TEXT NOT NULL,
+2735                overview TEXT,
+2736                primary_image_tag TEXT,
+2737                premiere_date TEXT,
+2738                end_date TEXT,
+2739                synced_at TEXT DEFAULT CURRENT_TIMESTAMP
+2740            );
+2741
+2742            CREATE VIRTUAL TABLE people_fts USING fts5(
+2743                name, overview, content='people', content_rowid='rowid'
+2744            );
+2745
+2746            CREATE TRIGGER people_ai AFTER INSERT ON people BEGIN
+2747                INSERT INTO people_fts(rowid, name, overview)
+2748                VALUES (new.rowid, new.name, new.overview);
+2749            END;
+2750
+2751            CREATE TRIGGER people_ad AFTER DELETE ON people BEGIN
+2752                INSERT INTO people_fts(people_fts, rowid, name, overview)
+2753                VALUES('delete', old.rowid, old.name, old.overview);
+2754            END;
+2755
+2756            CREATE TRIGGER people_au AFTER UPDATE ON people BEGIN
+2757                INSERT INTO people_fts(people_fts, rowid, name, overview)
+2758                VALUES('delete', old.rowid, old.name, old.overview);
+2759                INSERT INTO people_fts(rowid, name, overview)
+2760                VALUES (new.rowid, new.name, new.overview);
+2761            END;
+2762
+2763            CREATE TABLE genres (
+2764                id TEXT NOT NULL,
+2765                server_id TEXT NOT NULL,
+2766                library_id TEXT,
+2767                name TEXT NOT NULL,
+2768                album_count INTEGER,
+2769                synced_at TEXT DEFAULT CURRENT_TIMESTAMP,
+2770                PRIMARY KEY (server_id, library_id, name)
+2771            );
+2772        "#,
+2773        )
+2774        .unwrap();
+2775
+2776        // Insert a test server
+2777        conn.execute(
+2778            "INSERT INTO servers (id, name, url) VALUES ('test-server', 'Test Server', 'http://test')",
+2779            [],
+2780        ).unwrap();
+2781
+2782        Arc::new(RusqliteService::new(Arc::new(Mutex::new(conn))))
+2783    }
+2784
+2785    /// Helper to create a test MediaItem
+2786    fn create_test_item(id: &str, name: &str, parent_id: Option<&str>) -> MediaItem {
+2787        MediaItem {
+2788            id: id.to_string(),
+2789            name: name.to_string(),
+2790            item_type: "Audio".to_string(),
+2791            kind: crate::domain::MediaKind::Track,
+2792            is_folder: false,
+2793            server_id: "test-server".to_string(),
+2794            parent_id: parent_id.map(|s| s.to_string()),
+2795            library_id: None,
+2796            overview: None,
+2797            genres: None,
+2798            runtime_ticks: None,
+2799            duration_ms: None,
+2800            production_year: None,
+2801            premiere_date: None,
+2802            community_rating: None,
+2803            official_rating: None,
+2804            primary_image_tag: None,
+2805            image_id: None,
+2806            backdrop_image_tags: None,
+2807            parent_backdrop_image_tags: None,
+2808            album_id: None,
+2809            album_name: None,
+2810            album_artist: None,
+2811            artists: None,
+2812            artist_items: None,
+2813            index_number: None,
+2814            series_id: None,
+2815            series_name: None,
+2816            season_id: None,
+2817            season_name: None,
+2818            parent_index_number: None,
+2819            user_data: None,
+2820            media_streams: None,
+2821            media_sources: None,
+2822            people: None,
+2823        }
+2824    }
+2825
+2826    #[tokio::test]
+2827    async fn test_save_to_cache_with_missing_parent_fk() {
+2828        let db_service = create_test_db();
+2829
+2830        // Verify FK constraints are actually enabled
+2831        let fk_enabled: i32 = db_service
+2832            .query_one(Query::new("PRAGMA foreign_keys"), |row| row.get(0))
+2833            .await
+2834            .unwrap();
+2835        println!("Foreign keys enabled: {}", fk_enabled);
+2836        assert_eq!(fk_enabled, 1, "Foreign keys should be enabled");
+2837
+2838        let repo = OfflineRepository::new(
+2839            db_service.clone(),
+2840            "test-server".to_string(),
+2841            "test-user".to_string(),
+2842        );
+2843
+2844        // Create items where CHILDREN come BEFORE their PARENTS in the list
+2845        // This simulates the real-world scenario where Jellyfin's API
+2846        // returns items in an arbitrary order (e.g., alphabetically)
+2847        let items = vec![
+2848            // Tracks from Album 1 (album-1 doesn't exist yet!)
+2849            create_test_item("track-1", "Track 1", Some("album-1")),
+2850            create_test_item("track-2", "Track 2", Some("album-1")),
+2851            // Tracks from Album 2 (album-2 doesn't exist yet!)
+2852            create_test_item("track-3", "Track 3", Some("album-2")),
+2853            create_test_item("track-4", "Track 4", Some("album-2")),
+2854            // Albums come later in the list
+2855            create_test_item("album-1", "Album One", Some("library-123")),
+2856            create_test_item("album-2", "Album Two", Some("library-123")),
+2857        ];
+2858
+2859        println!("Attempting to save {} items...", items.len());
+2860        for (i, item) in items.iter().enumerate() {
+2861            println!("  Item {}: {} (parent: {:?})", i, item.id, item.parent_id);
+2862        }
+2863
+2864        // This should fail with the current implementation because:
+2865        // 1. It only creates a stub for "library-123" (the parent_id parameter)
+2866        // 2. When it tries to insert track-1 with parent_id = "album-1",
+2867        //    album-1 doesn't exist yet, causing FK constraint failure
+2868        let result = repo.save_to_cache("library-123", &items).await;
+2869
+2870        // After the fix, this should succeed and preserve parent relationships
+2871        match &result {
+2872            Ok(count) => {
+2873                println!("✓ Saved {} items", count);
+2874                assert_eq!(*count, 6);
+2875
+2876                // Debug: Check all items in the database
+2877                let all_items: Vec<(String, Option<String>)> = db_service
+2878                    .query_many(
+2879                        Query::new("SELECT id, parent_id FROM items ORDER BY id"),
+2880                        |row| Ok((row.get(0)?, row.get(1)?)),
+2881                    )
+2882                    .await
+2883                    .unwrap();
+2884
+2885                println!("\nAll items in database:");
+2886                for (id, parent) in &all_items {
+2887                    println!("  {} -> parent: {:?}", id, parent);
+2888                }
+2889
+2890                // Verify the ACTUAL parent_ids in the database are preserved correctly
+2891                let track1_parent: Option<String> = db_service
+2892                    .query_optional(
+2893                        Query::with_params(
+2894                            "SELECT parent_id FROM items WHERE id = ?",
+2895                            vec![QueryParam::String("track-1".to_string())],
+2896                        ),
+2897                        |row| row.get(0),
+2898                    )
+2899                    .await
+2900                    .unwrap()
+2901                    .flatten();
+2902
+2903                println!("\ntrack-1 parent_id in DB: {:?}", track1_parent);
+2904                println!("track-1 expected parent_id: Some(\"album-1\")");
+2905
+2906                // Verify parent relationships are preserved
+2907                assert_eq!(
+2908                    track1_parent,
+2909                    Some("album-1".to_string()),
+2910                    "track-1 should have parent_id='album-1'"
+2911                );
+2912
+2913                // Verify album-1 has the correct parent too
+2914                let album1_parent: Option<String> = db_service
+2915                    .query_optional(
+2916                        Query::with_params(
+2917                            "SELECT parent_id FROM items WHERE id = ?",
+2918                            vec![QueryParam::String("album-1".to_string())],
+2919                        ),
+2920                        |row| row.get(0),
+2921                    )
+2922                    .await
+2923                    .unwrap()
+2924                    .flatten();
+2925
+2926                assert_eq!(
+2927                    album1_parent,
+2928                    Some("library-123".to_string()),
+2929                    "album-1 should have parent_id='library-123'"
+2930                );
+2931            }
+2932            Err(e) => panic!("Unexpected error: {:?}", e),
+2933        }
+2934    }
+2935
+2936    #[tokio::test]
+2937    async fn test_save_to_cache_simple_case() {
+2938        let db_service = create_test_db();
+2939        let repo = OfflineRepository::new(
+2940            db_service.clone(),
+2941            "test-server".to_string(),
+2942            "test-user".to_string(),
+2943        );
+2944
+2945        // Simple case: all items have the same parent_id as the parameter
+2946        let items = vec![
+2947            create_test_item("item-1", "Item 1", Some("parent-123")),
+2948            create_test_item("item-2", "Item 2", Some("parent-123")),
+2949            create_test_item("item-3", "Item 3", Some("parent-123")),
+2950        ];
+2951
+2952        let result = repo.save_to_cache("parent-123", &items).await;
+2953        assert!(result.is_ok(), "Simple case should work: {:?}", result);
+2954        assert_eq!(result.unwrap(), 3);
+2955    }
+2956
+2957    /// Regression: a MusicAlbum whose tracks link via `album_id` (and have a
+2958    /// NULL `parent_id`, which is how the Jellyfin cache actually stores them)
+2959    /// must be recognized as available offline when a track is downloaded.
+2960    ///
+2961    /// Before the fix, `get_item(album_id)` only matched children by
+2962    /// `children.parent_id = i.id`, so a fully-downloaded album returned
+2963    /// NotFound offline and playback fell through to the (unreachable) server.
+2964    #[tokio::test]
+2965    async fn test_get_item_album_available_via_album_id_link() {
+2966        use crate::storage::db_service::DatabaseService;
+2967        let db_service = create_test_db();
+2968
+2969        for sql in [
+2970            // Album container (no children by parent_id).
+2971            "INSERT INTO items (id, server_id, name, item_type, album_id, parent_id) \
+2972             VALUES ('album-1', 'test-server', 'Hadestown', 'MusicAlbum', NULL, NULL)",
+2973            // Track linked to the album ONLY via album_id, parent_id NULL.
+2974            "INSERT INTO items (id, server_id, name, item_type, album_id, parent_id) \
+2975             VALUES ('track-1', 'test-server', 'Wait For Me', 'Audio', 'album-1', NULL)",
+2976            "INSERT INTO downloads (item_id, status) VALUES ('track-1', 'completed')",
+2977        ] {
+2978            db_service.execute(Query::new(sql)).await.unwrap();
+2979        }
+2980
+2981        let repo = OfflineRepository::new(
+2982            db_service.clone(),
+2983            "test-server".to_string(),
+2984            "test-user".to_string(),
+2985        );
+2986
+2987        // The downloaded track itself resolves offline.
+2988        assert!(
+2989            repo.get_item("track-1").await.is_ok(),
+2990            "downloaded track should be available offline"
+2991        );
+2992
+2993        // The album must also resolve offline because it has a downloaded child
+2994        // linked by album_id (not parent_id).
+2995        let album = repo.get_item("album-1").await;
+2996        assert!(
+2997            album.is_ok(),
+2998            "album with an album_id-linked downloaded track should be available offline, got {:?}",
+2999            album.err()
+3000        );
+3001        assert_eq!(album.unwrap().id, "album-1");
+3002
+3003        // Browsing into the album (get_items) must return its tracks even though
+3004        // they link by album_id and have a NULL parent_id. This is the call
+3005        // play_album_track makes to build the queue.
+3006        let tracks = repo.get_items("album-1", None).await.unwrap();
+3007        assert_eq!(
+3008            tracks.items.len(),
+3009            1,
+3010            "get_items(album_id) should return the track"
+3011        );
+3012        assert_eq!(tracks.items[0].id, "track-1");
+3013    }
+3014
+3015    /// Regression: offline library pages must honor the "Show all server media"
+3016    /// toggle. With `include_catalog_browse` off, `get_items` returns only
+3017    /// downloaded media — not the whole synced catalog. With it on, the full
+3018    /// (synced-but-not-downloaded) catalog is revealed. Fixes the bug where
+3019    /// offline library pages showed every server item regardless of the toggle.
+3020    ///
+3021    /// This is also the backend half of the end-to-end offline-listing scenario
+3022    /// IT-016: toggle off ⇒ downloaded media only; toggle on ⇒ the cached server
+3023    /// catalog is additionally revealed (greyed-out in the UI, distinguished by
+3024    /// the absence of a `downloads` row — see `MediaCard.isServerOnly`).
+3025    ///
+3026    /// TRACES: UR-052 | DR-078 | UT-067, IT-016
+3027    #[tokio::test]
+3028    async fn test_get_items_toggle_gates_synced_catalog() {
+3029        use crate::storage::db_service::DatabaseService;
+3030        let _guard = lock_catalog_browse();
+3031        let db_service = create_test_db();
+3032
+3033        for sql in [
+3034            // Two movies in a library, both merely synced (browsed) — no download.
+3035            "INSERT INTO items (id, server_id, name, item_type, library_id, synced_at) \
+3036             VALUES ('movie-dl', 'test-server', 'Downloaded', 'Movie', 'lib-1', '2026-01-01')",
+3037            "INSERT INTO items (id, server_id, name, item_type, library_id, synced_at) \
+3038             VALUES ('movie-cat', 'test-server', 'CatalogOnly', 'Movie', 'lib-1', '2026-01-01')",
+3039            // Only the first movie is actually downloaded.
+3040            "INSERT INTO downloads (item_id, status) VALUES ('movie-dl', 'completed')",
+3041            // A library row so the library-parent EXISTS clause matches.
+3042            "INSERT INTO libraries (id, server_id, name) VALUES ('lib-1', 'test-server', 'Movies')",
+3043        ] {
+3044            db_service.execute(Query::new(sql)).await.unwrap();
+3045        }
+3046
+3047        let repo = OfflineRepository::new(
+3048            db_service.clone(),
+3049            "test-server".to_string(),
+3050            "test-user".to_string(),
+3051        );
+3052        let opts = Some(GetItemsOptions {
+3053            include_item_types: Some(vec!["Movie".to_string()]),
+3054            ..Default::default()
+3055        });
+3056
+3057        // Toggle OFF: only the downloaded movie is returned.
+3058        set_include_catalog_browse(false);
+3059        let local_only = repo.get_items("lib-1", opts.clone()).await.unwrap();
+3060        let ids: Vec<&str> = local_only.items.iter().map(|i| i.id.as_str()).collect();
+3061        assert_eq!(
+3062            ids,
+3063            vec!["movie-dl"],
+3064            "toggle off should show downloaded media only"
+3065        );
+3066
+3067        // Toggle ON: both the downloaded and the catalog-only movie are returned.
+3068        set_include_catalog_browse(true);
+3069        let full_catalog = repo.get_items("lib-1", opts).await.unwrap();
+3070        let mut ids: Vec<&str> = full_catalog.items.iter().map(|i| i.id.as_str()).collect();
+3071        ids.sort();
+3072        assert_eq!(
+3073            ids,
+3074            vec!["movie-cat", "movie-dl"],
+3075            "toggle on should reveal the full catalog"
+3076        );
+3077
+3078        // Restore default for other tests sharing this process-global flag.
+3079        set_include_catalog_browse(true);
+3080    }
+3081
+3082    /// Searching an actor's name must reach them from the local index, and only
+3083    /// under a scope that admits People — `SearchScope::All`, which expands to
+3084    /// no type filter (DR-063). Before DR-111, `people` had no FTS index at all,
+3085    /// so the People group UR-060 requires could only be filled by the server.
+3086    ///
+3087    /// TRACES: UR-065, UR-060 | DR-111 | UT-114
+3088    #[tokio::test]
+3089    async fn test_search_includes_cached_people() {
+3090        use crate::storage::db_service::DatabaseService;
+3091        let _guard = lock_catalog_browse();
+3092        let db_service = create_test_db();
+3093
+3094        for sql in [
+3095            "INSERT INTO people (id, server_id, name, overview) \
+3096             VALUES ('p1', 'test-server', 'Tilda Swinton', 'Actor')",
+3097            // A movie that also matches, to prove people are added to — not
+3098            // substituted for — item results.
+3099            "INSERT INTO items (id, server_id, name, item_type, synced_at) \
+3100             VALUES ('m1', 'test-server', 'Tilda the Movie', 'Movie', '2026-01-01')",
+3101        ] {
+3102            db_service.execute(Query::new(sql)).await.unwrap();
+3103        }
+3104
+3105        let repo = OfflineRepository::new(
+3106            db_service.clone(),
+3107            "test-server".to_string(),
+3108            "test-user".to_string(),
+3109        );
+3110        set_include_catalog_browse(true);
+3111
+3112        // Unscoped search (SearchScope::All => no type filter) reaches people.
+3113        let all = repo.search("Tilda", None).await.unwrap();
+3114        let mut ids: Vec<&str> = all.items.iter().map(|i| i.id.as_str()).collect();
+3115        ids.sort();
+3116        assert_eq!(ids, vec!["m1", "p1"], "unscoped search must include people");
+3117
+3118        let person = all.items.iter().find(|i| i.id == "p1").unwrap();
+3119        assert_eq!(person.item_type, "Person");
+3120        assert_eq!(person.kind, crate::domain::MediaKind::Person);
+3121
+3122        // A scoped search names item types, and People are never among them.
+3123        let scoped = repo
+3124            .search(
+3125                "Tilda",
+3126                Some(SearchOptions {
+3127                    include_item_types: Some(vec!["Movie".to_string()]),
+3128                    ..Default::default()
+3129                }),
+3130            )
+3131            .await
+3132            .unwrap();
+3133        let ids: Vec<&str> = scoped.items.iter().map(|i| i.id.as_str()).collect();
+3134        assert_eq!(ids, vec!["m1"], "a scoped search must not leak people in");
+3135
+3136        set_include_catalog_browse(true);
+3137    }
+3138
+3139    /// The post-crawl sweep must remove what the server dropped, and nothing
+3140    /// else. Before DR-110 there was no `DELETE FROM items` anywhere in the
+3141    /// codebase, so the local catalog was append-only and media removed
+3142    /// server-side stayed searchable forever.
+3143    ///
+3144    /// TRACES: UR-065 | DR-110 | UT-113
+3145    #[tokio::test]
+3146    async fn test_prune_stale_catalog() {
+3147        use crate::storage::db_service::DatabaseService;
+3148        let db_service = create_test_db();
+3149
+3150        // "old" predates the crawl; "new" is what this crawl just wrote.
+3151        let old = "2026-01-01T00:00:00+00:00";
+3152        let new = "2026-06-01T00:00:00+00:00";
+3153        let cutoff = "2026-03-01T00:00:00+00:00";
+3154
+3155        for sql in [
+3156            // A second server, so the sweep can be shown to stay scoped to one.
+3157            "INSERT INTO servers (id, name, url) \
+3158             VALUES ('other-server', 'Other', 'http://other')"
+3159                .to_string(),
+3160            // Refreshed by the crawl => still on the server => keep.
+3161            format!(
+3162                "INSERT INTO items (id, server_id, name, item_type, synced_at) \
+3163                     VALUES ('keep-fresh', 'test-server', 'Fresh', 'Movie', '{new}')"
+3164            ),
+3165            // Not refreshed => gone from the server => sweep.
+3166            format!(
+3167                "INSERT INTO items (id, server_id, name, item_type, synced_at) \
+3168                     VALUES ('gone', 'test-server', 'Vanished', 'Movie', '{old}')"
+3169            ),
+3170            // Not refreshed, but downloaded => the file is on disk => keep.
+3171            format!(
+3172                "INSERT INTO items (id, server_id, name, item_type, synced_at) \
+3173                     VALUES ('keep-dl', 'test-server', 'Downloaded', 'Movie', '{old}')"
+3174            ),
+3175            "INSERT INTO downloads (item_id, status) VALUES ('keep-dl', 'completed')".to_string(),
+3176            // Not refreshed, but a container whose child is downloaded => keep both.
+3177            format!(
+3178                "INSERT INTO items (id, server_id, name, item_type, synced_at) \
+3179                     VALUES ('keep-album', 'test-server', 'Album', 'MusicAlbum', '{old}')"
+3180            ),
+3181            format!(
+3182                "INSERT INTO items (id, server_id, name, item_type, album_id, synced_at) \
+3183                     VALUES ('keep-track', 'test-server', 'Track', 'Audio', 'keep-album', '{old}')"
+3184            ),
+3185            "INSERT INTO downloads (item_id, status) VALUES ('keep-track', 'completed')"
+3186                .to_string(),
+3187            // A type the crawl never requests => it is never refreshed, so age
+3188            // says nothing about it => keep.
+3189            format!(
+3190                "INSERT INTO items (id, server_id, name, item_type, synced_at) \
+3191                     VALUES ('keep-artist', 'test-server', 'Artist', 'MusicArtist', '{old}')"
+3192            ),
+3193            // Another server's row must be untouched.
+3194            format!(
+3195                "INSERT INTO items (id, server_id, name, item_type, synced_at) \
+3196                     VALUES ('keep-other', 'other-server', 'Elsewhere', 'Movie', '{old}')"
+3197            ),
+3198        ] {
+3199            db_service.execute(Query::new(&sql)).await.unwrap();
+3200        }
+3201
+3202        let repo = OfflineRepository::new(
+3203            db_service.clone(),
+3204            "test-server".to_string(),
+3205            "test-user".to_string(),
+3206        );
+3207
+3208        let crawled_types = vec![
+3209            "Movie".to_string(),
+3210            "MusicAlbum".to_string(),
+3211            "Audio".to_string(),
+3212        ];
+3213        let removed = repo
+3214            .prune_stale_catalog(cutoff, &crawled_types)
+3215            .await
+3216            .unwrap();
+3217        assert_eq!(removed, 1, "only the vanished movie should be swept");
+3218
+3219        let mut surviving: Vec<String> = db_service
+3220            .query_many(Query::new("SELECT id FROM items"), |row| row.get(0))
+3221            .await
+3222            .unwrap();
+3223        surviving.sort();
+3224        assert_eq!(
+3225            surviving,
+3226            vec![
+3227                "keep-album",
+3228                "keep-artist",
+3229                "keep-dl",
+3230                "keep-fresh",
+3231                "keep-other",
+3232                "keep-track",
+3233            ]
+3234        );
+3235
+3236        // An empty type list is a no-op, not a "delete everything".
+3237        assert_eq!(repo.prune_stale_catalog(cutoff, &[]).await.unwrap(), 0);
+3238    }
+3239
+3240    /// Re-caching an item must not append a second FTS entry for it.
+3241    ///
+3242    /// `INSERT OR REPLACE` fires no `AFTER DELETE` trigger unless
+3243    /// `recursive_triggers` is on (it is not — storage/mod.rs sets only
+3244    /// `foreign_keys` and `journal_mode`), so `items_ad` never ran and the old
+3245    /// index row was orphaned; and because `items.id` is a `TEXT PRIMARY KEY`,
+3246    /// the replacement row also took a *fresh rowid* and inserted a second
+3247    /// entry. Every catalog pass therefore appended a duplicate index. Results
+3248    /// stayed correct (the rowid join hides orphans) but `MATCH` degraded
+3249    /// permanently — and once the DR-110 deletion sweep starts freeing rowids,
+3250    /// a reused rowid would collide with a stale orphan and produce a genuine
+3251    /// false positive.
+3252    ///
+3253    /// TRACES: UR-065 | DR-110 | UT-112
+3254    #[tokio::test]
+3255    async fn test_repeated_cache_does_not_duplicate_fts_entries() {
+3256        use crate::storage::db_service::DatabaseService;
+3257        let db_service = create_test_db();
+3258        let repo = OfflineRepository::new(
+3259            db_service.clone(),
+3260            "test-server".to_string(),
+3261            "test-user".to_string(),
+3262        );
+3263
+3264        let items = vec![create_test_item("track-1", "Wait For Me", None)];
+3265
+3266        // Three catalog passes over unchanged content, as happens on every app
+3267        // start.
+3268        for _ in 0..3 {
+3269            repo.save_to_cache("parent-1", &items).await.unwrap();
+3270        }
+3271
+3272        // (`save_to_cache` also inserts a stub row for the parent, so scope this
+3273        // to the item itself.)
+3274        let item_rows: i64 = db_service
+3275            .query_one(
+3276                Query::new("SELECT COUNT(*) FROM items WHERE id = 'track-1'"),
+3277                |row| row.get(0),
+3278            )
+3279            .await
+3280            .unwrap();
+3281        assert_eq!(item_rows, 1, "three passes must leave one item row");
+3282
+3283        let fts_hits: i64 = db_service
+3284            .query_one(
+3285                Query::with_params(
+3286                    "SELECT COUNT(*) FROM items_fts WHERE items_fts MATCH ?",
+3287                    vec![QueryParam::String("\"Wait\"*".to_string())],
+3288                ),
+3289                |row| row.get(0),
+3290            )
+3291            .await
+3292            .unwrap();
+3293        assert_eq!(
+3294            fts_hits, 1,
+3295            "the FTS index must hold one entry per item, not one per sync pass"
+3296        );
+3297    }
+3298
+3299    /// Search must read the *synced catalog*, not only downloaded media —
+3300    /// mirroring `get_items` and gated on the same `include_catalog_browse`
+3301    /// flag. Before DR-108 the cache leg wrapped its FTS query in a
+3302    /// `downloaded_items` CTE requiring `d.status = 'completed'`, so a user with
+3303    /// no downloads got an empty instant result on every keystroke and every
+3304    /// query fell through to a full `Recursive=true` server request.
+3305    ///
+3306    /// TRACES: UR-065 | DR-108 | UT-109
+3307    #[tokio::test]
+3308    async fn test_search_toggle_gates_synced_catalog() {
+3309        use crate::storage::db_service::DatabaseService;
+3310        let _guard = lock_catalog_browse();
+3311        let db_service = create_test_db();
+3312
+3313        for sql in [
+3314            // Two movies, both merely synced (indexed by the catalog crawl).
+3315            "INSERT INTO items (id, server_id, name, item_type, library_id, synced_at) \
+3316             VALUES ('movie-dl', 'test-server', 'Arrival', 'Movie', 'lib-1', '2026-01-01')",
+3317            "INSERT INTO items (id, server_id, name, item_type, library_id, synced_at) \
+3318             VALUES ('movie-cat', 'test-server', 'Arrakis', 'Movie', 'lib-1', '2026-01-01')",
+3319            // Only the first is actually downloaded.
+3320            "INSERT INTO downloads (item_id, status) VALUES ('movie-dl', 'completed')",
+3321        ] {
+3322            db_service.execute(Query::new(sql)).await.unwrap();
+3323        }
+3324
+3325        let repo = OfflineRepository::new(
+3326            db_service.clone(),
+3327            "test-server".to_string(),
+3328            "test-user".to_string(),
+3329        );
+3330
+3331        // Toggle ON (the online case, and the offline "Show all server media"
+3332        // case): the whole synced catalog is searchable.
+3333        set_include_catalog_browse(true);
+3334        let full = repo.search("Arr", None).await.unwrap();
+3335        let mut ids: Vec<&str> = full.items.iter().map(|i| i.id.as_str()).collect();
+3336        ids.sort();
+3337        assert_eq!(
+3338            ids,
+3339            vec!["movie-cat", "movie-dl"],
+3340            "with catalog browse on, search must cover synced-but-not-downloaded items"
+3341        );
+3342
+3343        // Toggle OFF (offline, downloads-only): unchanged from today.
+3344        set_include_catalog_browse(false);
+3345        let local_only = repo.search("Arr", None).await.unwrap();
+3346        let ids: Vec<&str> = local_only.items.iter().map(|i| i.id.as_str()).collect();
+3347        assert_eq!(
+3348            ids,
+3349            vec!["movie-dl"],
+3350            "with catalog browse off, search stays downloads-only"
+3351        );
+3352
+3353        // Restore default for other tests sharing this process-global flag.
+3354        set_include_catalog_browse(true);
+3355    }
+3356
+3357    /// The scope/type filter must be *bound*, not interpolated into the SQL
+3358    /// string. `SearchOptions.include_item_types` is settable from the frontend
+3359    /// (GenericMediaListPage passes it directly), so a value containing a quote
+3360    /// must not be able to alter the query.
+3361    ///
+3362    /// TRACES: UR-065 | DR-108 | UT-110
+3363    #[tokio::test]
+3364    async fn test_search_type_filter_is_parameterised() {
+3365        use crate::storage::db_service::DatabaseService;
+3366        let _guard = lock_catalog_browse();
+3367        let db_service = create_test_db();
+3368
+3369        db_service
+3370            .execute(Query::new(
+3371                "INSERT INTO items (id, server_id, name, item_type, synced_at) \
+3372                 VALUES ('m1', 'test-server', 'Arrival', 'Movie', '2026-01-01')",
+3373            ))
+3374            .await
+3375            .unwrap();
+3376
+3377        let repo = OfflineRepository::new(
+3378            db_service.clone(),
+3379            "test-server".to_string(),
+3380            "test-user".to_string(),
+3381        );
+3382        set_include_catalog_browse(true);
+3383
+3384        // A quote-bearing type must be treated as data: no SQL error, no match.
+3385        let opts = SearchOptions {
+3386            include_item_types: Some(vec!["Movie') OR 1=1 --".to_string()]),
+3387            ..Default::default()
+3388        };
+3389        let result = repo.search("Arr", Some(opts)).await;
+3390        assert!(
+3391            result.is_ok(),
+3392            "a quote in an item type must not break the query: {:?}",
+3393            result.err()
+3394        );
+3395        assert!(
+3396            result.unwrap().items.is_empty(),
+3397            "an injected type filter must not widen the result set"
+3398        );
+3399
+3400        // The legitimate filter still works.
+3401        let opts = SearchOptions {
+3402            include_item_types: Some(vec!["Movie".to_string()]),
+3403            ..Default::default()
+3404        };
+3405        assert_eq!(repo.search("Arr", Some(opts)).await.unwrap().items.len(), 1);
+3406    }
+3407
+3408    /// Regression: TV episodes link to their season/series via `season_id` /
+3409    /// `series_id` (NOT `parent_id`, which is NULL in the cache). A downloaded
+3410    /// episode must make both its Season and Series available offline, and
+3411    /// browsing either container must return the episode.
+3412    #[tokio::test]
+3413    async fn test_get_item_tv_available_via_season_series_link() {
+3414        use crate::storage::db_service::DatabaseService;
+3415        let db_service = create_test_db();
+3416
+3417        for sql in [
+3418            "INSERT INTO items (id, server_id, name, item_type, parent_id) \
+3419             VALUES ('series-1', 'test-server', 'Gilmore Girls', 'Series', NULL)",
+3420            "INSERT INTO items (id, server_id, name, item_type, series_id, parent_id) \
+3421             VALUES ('season-1', 'test-server', 'Season 1', 'Season', 'series-1', NULL)",
+3422            // Episode links to both season and series; parent_id NULL.
+3423            "INSERT INTO items (id, server_id, name, item_type, season_id, series_id, parent_id) \
+3424             VALUES ('ep-1', 'test-server', 'Pilot', 'Episode', 'season-1', 'series-1', NULL)",
+3425            "INSERT INTO downloads (item_id, status) VALUES ('ep-1', 'completed')",
+3426        ] {
+3427            db_service.execute(Query::new(sql)).await.unwrap();
+3428        }
+3429
+3430        let repo = OfflineRepository::new(
+3431            db_service.clone(),
+3432            "test-server".to_string(),
+3433            "test-user".to_string(),
+3434        );
+3435
+3436        assert!(
+3437            repo.get_item("ep-1").await.is_ok(),
+3438            "downloaded episode available offline"
+3439        );
+3440        assert!(
+3441            repo.get_item("season-1").await.is_ok(),
+3442            "season with a season_id-linked downloaded episode should be available offline"
+3443        );
+3444        assert!(
+3445            repo.get_item("series-1").await.is_ok(),
+3446            "series with a series_id-linked downloaded episode should be available offline"
+3447        );
+3448
+3449        // Browsing the season returns the episode.
+3450        let season_items = repo.get_items("season-1", None).await.unwrap();
+3451        assert!(
+3452            season_items.items.iter().any(|i| i.id == "ep-1"),
+3453            "get_items(season_id) should return the episode"
+3454        );
+3455
+3456        // Browsing the series returns the episode (via series_id link).
+3457        let series_items = repo.get_items("series-1", None).await.unwrap();
+3458        assert!(
+3459            series_items.items.iter().any(|i| i.id == "ep-1"),
+3460            "get_items(series_id) should surface the downloaded episode"
+3461        );
+3462    }
+3463
+3464    /// Regression: offline startup must list cached libraries. Previously
+3465    /// `get_libraries` joined on `items.library_id` (always NULL in the cache),
+3466    /// so it returned nothing offline and the app showed no libraries at all.
+3467    /// The list must round-trip through `save_libraries_to_cache`.
+3468    #[tokio::test]
+3469    async fn test_libraries_cache_roundtrip_available_offline() {
+3470        let db_service = create_test_db();
+3471        let repo = OfflineRepository::new(
+3472            db_service.clone(),
+3473            "test-server".to_string(),
+3474            "test-user".to_string(),
+3475        );
+3476
+3477        // Empty cache → no libraries (this is the state that fell through to the
+3478        // server and hung offline).
+3479        assert!(repo.get_libraries().await.unwrap().is_empty());
+3480
+3481        // Simulate the online path persisting the server's library list.
+3482        let server_libs = vec![
+3483            Library::new("music".into(), "Music".into(), "music".into(), None),
+3484            Library::new(
+3485                "movies".into(),
+3486                "Movies".into(),
+3487                "movies".into(),
+3488                Some("tag".into()),
+3489            ),
+3490        ];
+3491        let saved = repo.save_libraries_to_cache(&server_libs).await.unwrap();
+3492        assert_eq!(saved, 2);
+3493
+3494        // Now offline get_libraries returns them without touching the server.
+3495        let offline_libs = repo.get_libraries().await.unwrap();
+3496        let names: Vec<&str> = offline_libs.iter().map(|l| l.name.as_str()).collect();
+3497        assert_eq!(
+3498            names,
+3499            vec!["Music", "Movies"],
+3500            "cached libraries available offline in sort order"
+3501        );
+3502
+3503        // Re-saving is idempotent (INSERT OR REPLACE), not duplicating rows.
+3504        repo.save_libraries_to_cache(&server_libs).await.unwrap();
+3505        assert_eq!(repo.get_libraries().await.unwrap().len(), 2);
+3506    }
+3507
+3508    /// Regression: the music/TV/movie landing pages lost genre variety because
+3509    /// offline `get_genres` derived genres from cached albums only — so the
+3510    /// hybrid cache-first race pinned the list to whatever sparse set the local
+3511    /// albums yielded instead of the server's full catalog. The full genre list
+3512    /// must round-trip through `save_genres_to_cache` and come back scoped by
+3513    /// library.
+3514    #[tokio::test]
+3515    async fn test_genres_cache_roundtrip_scoped_by_library() {
+3516        let db_service = create_test_db();
+3517        let repo = OfflineRepository::new(
+3518            db_service.clone(),
+3519            "test-server".to_string(),
+3520            "test-user".to_string(),
+3521        );
+3522
+3523        // Empty cache → no genres.
+3524        assert!(repo.get_genres(Some("music-lib")).await.unwrap().is_empty());
+3525
+3526        // Simulate the online path persisting the server's full genre catalog.
+3527        let server_genres = vec![
+3528            Genre {
+3529                id: "g1".into(),
+3530                name: "Rock".into(),
+3531                album_count: Some(42),
+3532            },
+3533            Genre {
+3534                id: "g2".into(),
+3535                name: "Jazz".into(),
+3536                album_count: Some(17),
+3537            },
+3538            Genre {
+3539                id: "g3".into(),
+3540                name: "Ambient".into(),
+3541                album_count: None,
+3542            },
+3543        ];
+3544        let saved = repo
+3545            .save_genres_to_cache(Some("music-lib"), &server_genres)
+3546            .await
+3547            .unwrap();
+3548        assert_eq!(saved, 3);
+3549
+3550        // Offline get_genres returns the full set for that library, counts intact.
+3551        let mut offline_genres = repo.get_genres(Some("music-lib")).await.unwrap();
+3552        offline_genres.sort_by(|a, b| a.name.cmp(&b.name));
+3553        let names: Vec<&str> = offline_genres.iter().map(|g| g.name.as_str()).collect();
+3554        assert_eq!(names, vec!["Ambient", "Jazz", "Rock"]);
+3555        let rock = offline_genres.iter().find(|g| g.name == "Rock").unwrap();
+3556        assert_eq!(rock.album_count, Some(42));
+3557
+3558        // Genres are scoped: a different library sees nothing.
+3559        assert!(repo.get_genres(Some("other-lib")).await.unwrap().is_empty());
+3560
+3561        // Re-saving replaces the scope's rows (server removed "Jazz").
+3562        let updated = vec![Genre {
+3563            id: "g1".into(),
+3564            name: "Rock".into(),
+3565            album_count: Some(50),
+3566        }];
+3567        repo.save_genres_to_cache(Some("music-lib"), &updated)
+3568            .await
+3569            .unwrap();
+3570        let after = repo.get_genres(Some("music-lib")).await.unwrap();
+3571        assert_eq!(after.len(), 1, "stale genres removed on refresh");
+3572        assert_eq!(after[0].album_count, Some(50), "counts updated on refresh");
+3573    }
+3574
+3575    // ===== Playlist Tests =====
+3576
+3577    /// Helper to seed items into the DB for playlist tests
+3578    async fn seed_items(repo: &OfflineRepository, ids: &[&str]) {
+3579        let items: Vec<MediaItem> = ids
+3580            .iter()
+3581            .map(|id| create_test_item(id, &format!("Track {}", id), Some("library-1")))
+3582            .collect();
+3583        repo.save_to_cache("library-1", &items).await.unwrap();
+3584    }
+3585
+3586    /// Insert a fully-formed item row of a given type (bypasses save_to_cache's
+3587    /// stub-parent machinery so containers/leaves can be linked precisely).
+3588    async fn insert_item(
+3589        db: &Arc<RusqliteService>,
+3590        id: &str,
+3591        item_type: &str,
+3592        album_id: Option<&str>,
+3593        series_id: Option<&str>,
+3594        season_id: Option<&str>,
+3595    ) {
+3596        db.execute(Query::with_params(
+3597            "INSERT INTO items (id, server_id, name, item_type, album_id, series_id, season_id, synced_at)
+3598             VALUES (?1, 'test-server', ?2, ?3, ?4, ?5, ?6, '2024-01-01')",
+3599            vec![
+3600                QueryParam::String(id.to_string()),
+3601                QueryParam::String(format!("Name {id}")),
+3602                QueryParam::String(item_type.to_string()),
+3603                album_id.map(|s| QueryParam::String(s.to_string())).unwrap_or(QueryParam::Null),
+3604                series_id.map(|s| QueryParam::String(s.to_string())).unwrap_or(QueryParam::Null),
+3605                season_id.map(|s| QueryParam::String(s.to_string())).unwrap_or(QueryParam::Null),
+3606            ],
+3607        ))
+3608        .await
+3609        .unwrap();
+3610    }
+3611
+3612    /// Like `insert_item`, but sets `library_id` — which `get_latest_items`
+3613    /// filters on, so rows without it are invisible to that query.
+3614    async fn insert_library_item(
+3615        db: &Arc<RusqliteService>,
+3616        id: &str,
+3617        item_type: &str,
+3618        library_id: &str,
+3619        album_id: Option<&str>,
+3620    ) {
+3621        db.execute(Query::with_params(
+3622            "INSERT INTO items (id, server_id, library_id, name, item_type, album_id, synced_at)
+3623             VALUES (?1, 'test-server', ?2, ?3, ?4, ?5, '2024-01-01')",
+3624            vec![
+3625                QueryParam::String(id.to_string()),
+3626                QueryParam::String(library_id.to_string()),
+3627                QueryParam::String(format!("Name {id}")),
+3628                QueryParam::String(item_type.to_string()),
+3629                album_id
+3630                    .map(|s| QueryParam::String(s.to_string()))
+3631                    .unwrap_or(QueryParam::Null),
+3632            ],
+3633        ))
+3634        .await
+3635        .unwrap();
+3636    }
+3637
+3638    async fn seed_completed_download(db: &Arc<RusqliteService>, item_id: &str, file_size: i64) {
+3639        db.execute(Query::with_params(
+3640            "INSERT INTO downloads (item_id, status, file_size) VALUES (?1, 'completed', ?2)",
+3641            vec![
+3642                QueryParam::String(item_id.to_string()),
+3643                QueryParam::Int64(file_size),
+3644            ],
+3645        ))
+3646        .await
+3647        .unwrap();
+3648    }
+3649
+3650    async fn seed_library(db: &Arc<RusqliteService>, id: &str, collection_type: &str) {
+3651        db.execute(Query::with_params(
+3652            "INSERT INTO libraries (id, server_id, name, collection_type, sort_order)
+3653             VALUES (?1, 'test-server', ?2, ?3, 0)",
+3654            vec![
+3655                QueryParam::String(id.to_string()),
+3656                QueryParam::String(format!("Lib {id}")),
+3657                QueryParam::String(collection_type.to_string()),
+3658            ],
+3659        ))
+3660        .await
+3661        .unwrap();
+3662    }
+3663
+3664    fn make_repo(db: &Arc<RusqliteService>) -> OfflineRepository {
+3665        OfflineRepository::new(
+3666            db.clone(),
+3667            "test-server".to_string(),
+3668            "test-user".to_string(),
+3669        )
+3670    }
+3671
+3672    /// A newly-synced album appears once in "recently added", not once per track.
+3673    ///
+3674    /// The downloaded-items CTE deliberately matches both the leaves and their
+3675    /// container, which is right for browsing but wrong here: it made a 3-track
+3676    /// album occupy 4 slots in the row. Tracks whose album is itself in the
+3677    /// result are now collapsed into it.
+3678    #[tokio::test]
+3679    async fn test_get_latest_items_collapses_tracks_into_their_album() {
+3680        let db = create_test_db();
+3681        insert_library_item(&db, "album-1", "MusicAlbum", "lib-1", None).await;
+3682        for track in ["track-1", "track-2", "track-3"] {
+3683            insert_library_item(&db, track, "Audio", "lib-1", Some("album-1")).await;
+3684            seed_completed_download(&db, track, 1000).await;
+3685        }
+3686        // A movie has no container, so it must still show up on its own.
+3687        insert_library_item(&db, "movie-1", "Movie", "lib-1", None).await;
+3688        seed_completed_download(&db, "movie-1", 2000).await;
+3689
+3690        let repo = make_repo(&db);
+3691        let latest = repo.get_latest_items("lib-1", Some(16)).await.unwrap();
+3692        let ids: Vec<&str> = latest.iter().map(|i| i.id.as_str()).collect();
+3693
+3694        assert!(
+3695            !ids.iter().any(|id| id.starts_with("track-")),
+3696            "individual tracks must collapse into their album, got: {ids:?}"
+3697        );
+3698        assert!(ids.contains(&"album-1"), "the album itself is listed");
+3699        assert!(ids.contains(&"movie-1"), "containerless items still listed");
+3700    }
+3701
+3702    /// UT: downloaded-only browse returns a downloaded leaf AND its container,
+3703    /// filtered to the requested album parent. A non-downloaded sibling is omitted.
+3704    ///
+3705    /// TRACES: UR-055 | DR-082, DR-083 | UT-072
+3706    #[tokio::test]
+3707    async fn test_get_downloaded_items_returns_leaf_and_container() {
+3708        let db = create_test_db();
+3709        insert_item(&db, "album-1", "MusicAlbum", None, None, None).await;
+3710        insert_item(&db, "track-1", "Audio", Some("album-1"), None, None).await;
+3711        insert_item(&db, "track-2", "Audio", Some("album-1"), None, None).await;
+3712        // track-1 downloaded; track-2 is NOT downloaded.
+3713        seed_completed_download(&db, "track-1", 1000).await;
+3714
+3715        let repo = make_repo(&db);
+3716
+3717        // Browsing the album shows only the downloaded track.
+3718        let in_album = repo.get_downloaded_items("album-1", None).await.unwrap();
+3719        let ids: Vec<&str> = in_album.items.iter().map(|i| i.id.as_str()).collect();
+3720        assert_eq!(ids, vec!["track-1"], "only the downloaded track is listed");
+3721    }
+3722
+3723    /// Regression: browsing a downloaded *library* (top level) lists containers,
+3724    /// not their leaves — a music library shows the album, not the individual
+3725    /// downloaded songs. The leaf is still reachable by drilling into the album.
+3726    ///
+3727    /// TRACES: UR-055 | DR-082, DR-083 | UT-076
+3728    #[tokio::test]
+3729    async fn test_get_downloaded_items_library_lists_albums_not_tracks() {
+3730        let db = create_test_db();
+3731        seed_library(&db, "music-lib", "music").await;
+3732        insert_item(&db, "album-1", "MusicAlbum", None, None, None).await;
+3733        // Tracks link to the album via album_id (parent_id NULL in the cache).
+3734        insert_item(&db, "track-1", "Audio", Some("album-1"), None, None).await;
+3735        insert_item(&db, "track-2", "Audio", Some("album-1"), None, None).await;
+3736        seed_completed_download(&db, "track-1", 1000).await;
+3737        seed_completed_download(&db, "track-2", 1000).await;
+3738
+3739        let repo = make_repo(&db);
+3740
+3741        // Library level: only the album shows, not the two tracks.
+3742        let at_library = repo.get_downloaded_items("music-lib", None).await.unwrap();
+3743        let ids: Vec<&str> = at_library.items.iter().map(|i| i.id.as_str()).collect();
+3744        assert_eq!(
+3745            ids,
+3746            vec!["album-1"],
+3747            "library browse lists the album container, not its tracks"
+3748        );
+3749
+3750        // Drilling into the album still returns the downloaded tracks.
+3751        let in_album = repo.get_downloaded_items("album-1", None).await.unwrap();
+3752        let mut track_ids: Vec<&str> = in_album.items.iter().map(|i| i.id.as_str()).collect();
+3753        track_ids.sort();
+3754        assert_eq!(track_ids, vec!["track-1", "track-2"]);
+3755    }
+3756
+3757    /// Regression: each downloaded library shows **only its own media**.
+3758    ///
+3759    /// Cached items carry no link back to their library (`library_id`/`parent_id`
+3760    /// are NULL — [[offline-libraries-never-cached]]), and the library branch of
+3761    /// the query only asserted that the requested library *exists*, never that
+3762    /// the item belongs to it. So opening any downloaded library listed every
+3763    /// downloaded top-level item on the server: films in the music library,
+3764    /// albums under TV. The library's `collection_type` decides which item types
+3765    /// belong to it, the same mapping `get_downloaded_libraries` already uses.
+3766    ///
+3767    /// TRACES: UR-055 | DR-167 | UT-162
+3768    #[tokio::test]
+3769    async fn test_get_downloaded_items_library_does_not_mix_media_types() {
+3770        let db = create_test_db();
+3771        seed_library(&db, "music-lib", "music").await;
+3772        seed_library(&db, "movie-lib", "movies").await;
+3773        seed_library(&db, "tv-lib", "tvshows").await;
+3774
+3775        insert_item(&db, "album-1", "MusicAlbum", None, None, None).await;
+3776        insert_item(&db, "track-1", "Audio", Some("album-1"), None, None).await;
+3777        insert_item(&db, "movie-1", "Movie", None, None, None).await;
+3778        insert_item(&db, "series-1", "Series", None, None, None).await;
+3779        insert_item(&db, "episode-1", "Episode", None, Some("series-1"), None).await;
+3780
+3781        seed_completed_download(&db, "track-1", 1000).await;
+3782        seed_completed_download(&db, "movie-1", 2000).await;
+3783        seed_completed_download(&db, "episode-1", 3000).await;
+3784
+3785        let repo = make_repo(&db);
+3786
+3787        let music: Vec<String> = repo
+3788            .get_downloaded_items("music-lib", None)
+3789            .await
+3790            .unwrap()
+3791            .items
+3792            .iter()
+3793            .map(|i| i.id.clone())
+3794            .collect();
+3795        assert_eq!(
+3796            music,
+3797            vec!["album-1"],
+3798            "the music library must not list films or series; got {:?}",
+3799            music
+3800        );
+3801
+3802        let movies: Vec<String> = repo
+3803            .get_downloaded_items("movie-lib", None)
+3804            .await
+3805            .unwrap()
+3806            .items
+3807            .iter()
+3808            .map(|i| i.id.clone())
+3809            .collect();
+3810        assert_eq!(
+3811            movies,
+3812            vec!["movie-1"],
+3813            "the movie library must not list albums or series; got {:?}",
+3814            movies
+3815        );
+3816
+3817        let tv: Vec<String> = repo
+3818            .get_downloaded_items("tv-lib", None)
+3819            .await
+3820            .unwrap()
+3821            .items
+3822            .iter()
+3823            .map(|i| i.id.clone())
+3824            .collect();
+3825        assert_eq!(
+3826            tv,
+3827            vec!["series-1"],
+3828            "the TV library must not list albums or films; got {:?}",
+3829            tv
+3830        );
+3831    }
+3832
+3833    /// Regression: a downloaded TV library lists the Series, not its Seasons or
+3834    /// Episodes — the same "individual songs" bug seen for music, for TV. The
+3835    /// season and episode are still reachable by drilling into the series.
+3836    ///
+3837    /// TRACES: UR-055 | DR-082, DR-083 | UT-077
+3838    #[tokio::test]
+3839    async fn test_get_downloaded_items_library_lists_series_not_episodes() {
+3840        let db = create_test_db();
+3841        seed_library(&db, "tv-lib", "tvshows").await;
+3842        insert_item(&db, "series-1", "Series", None, None, None).await;
+3843        // Season links to its series; episode links to both season and series.
+3844        insert_item(&db, "season-1", "Season", None, Some("series-1"), None).await;
+3845        insert_item(
+3846            &db,
+3847            "ep-1",
+3848            "Episode",
+3849            None,
+3850            Some("series-1"),
+3851            Some("season-1"),
+3852        )
+3853        .await;
+3854        seed_completed_download(&db, "ep-1", 4000).await;
+3855
+3856        let repo = make_repo(&db);
+3857
+3858        // Library level: only the series shows.
+3859        let at_library = repo.get_downloaded_items("tv-lib", None).await.unwrap();
+3860        let ids: Vec<&str> = at_library.items.iter().map(|i| i.id.as_str()).collect();
+3861        assert_eq!(
+3862            ids,
+3863            vec!["series-1"],
+3864            "TV library browse lists the series, not seasons/episodes"
+3865        );
+3866
+3867        // Drilling into the series returns its season; into the season, the episode.
+3868        let in_series = repo.get_downloaded_items("series-1", None).await.unwrap();
+3869        assert!(
+3870            in_series.items.iter().any(|i| i.id == "season-1"),
+3871            "series drill returns the season"
+3872        );
+3873        let in_season = repo.get_downloaded_items("season-1", None).await.unwrap();
+3874        assert!(
+3875            in_season.items.iter().any(|i| i.id == "ep-1"),
+3876            "season drill returns the episode"
+3877        );
+3878    }
+3879
+3880    /// A downloaded leaf with no cached container (e.g. a Movie, or a track whose
+3881    /// album isn't in the cache) still surfaces at the library level.
+3882    ///
+3883    /// TRACES: UR-055 | DR-082, DR-083 | UT-078
+3884    #[tokio::test]
+3885    async fn test_get_downloaded_items_library_keeps_orphan_leaves() {
+3886        let db = create_test_db();
+3887        seed_library(&db, "movie-lib", "movies").await;
+3888        insert_item(&db, "movie-1", "Movie", None, None, None).await;
+3889        seed_completed_download(&db, "movie-1", 5000).await;
+3890
+3891        let repo = make_repo(&db);
+3892        let at_library = repo.get_downloaded_items("movie-lib", None).await.unwrap();
+3893        let ids: Vec<&str> = at_library.items.iter().map(|i| i.id.as_str()).collect();
+3894        assert_eq!(
+3895            ids,
+3896            vec!["movie-1"],
+3897            "a downloaded movie with no container shows"
+3898        );
+3899    }
+3900
+3901    /// UT: an empty downloaded-only browse is authoritative — no rows, no error,
+3902    /// regardless of the catalog-browse flag (which the DR-080 fallthrough uses).
+3903    ///
+3904    /// TRACES: UR-055 | DR-082 | UT-073
+3905    #[tokio::test]
+3906    async fn test_get_downloaded_items_empty_is_authoritative() {
+3907        let db = create_test_db();
+3908        insert_item(&db, "album-1", "MusicAlbum", None, None, None).await;
+3909        insert_item(&db, "track-1", "Audio", Some("album-1"), None, None).await;
+3910        // Nothing downloaded, and the catalog-browse flag is ON (online default).
+3911        set_include_catalog_browse(true);
+3912        let repo = make_repo(&db);
+3913
+3914        let result = repo.get_downloaded_items("album-1", None).await.unwrap();
+3915        assert!(
+3916            result.items.is_empty(),
+3917            "empty downloaded browse returns no items even with catalog-browse on"
+3918        );
+3919    }
+3920
+3921    /// UT: only libraries with downloaded content are listed; an empty one is omitted.
+3922    ///
+3923    /// TRACES: UR-055 | DR-082 | UT-074
+3924    #[tokio::test]
+3925    async fn test_get_downloaded_libraries_omits_empty() {
+3926        let db = create_test_db();
+3927        seed_library(&db, "music-lib", "music").await;
+3928        seed_library(&db, "movie-lib", "movies").await;
+3929        insert_item(&db, "track-1", "Audio", None, None, None).await;
+3930        seed_completed_download(&db, "track-1", 500).await;
+3931
+3932        let repo = make_repo(&db);
+3933        let libs = repo.get_downloaded_libraries().await.unwrap();
+3934        let ids: Vec<&str> = libs.iter().map(|l| l.id.as_str()).collect();
+3935        assert_eq!(
+3936            ids,
+3937            vec!["music-lib"],
+3938            "movie library with no downloads omitted"
+3939        );
+3940    }
+3941
+3942    /// UT: disk usage reports a leaf's own size, a container's summed descendants,
+3943    /// and reconciles the device total with the sum of leaves.
+3944    ///
+3945    /// TRACES: UR-056 | DR-085 | UT-075
+3946    #[tokio::test]
+3947    async fn test_download_disk_usage_aggregates_containers() {
+3948        let db = create_test_db();
+3949        insert_item(&db, "album-1", "MusicAlbum", None, None, None).await;
+3950        insert_item(&db, "track-1", "Audio", Some("album-1"), None, None).await;
+3951        insert_item(&db, "track-2", "Audio", Some("album-1"), None, None).await;
+3952        seed_completed_download(&db, "track-1", 1000).await;
+3953        seed_completed_download(&db, "track-2", 2000).await;
+3954
+3955        let repo = make_repo(&db);
+3956        let usage = repo.get_download_disk_usage().await.unwrap();
+3957
+3958        assert_eq!(usage.item_count, 2, "two leaf downloads");
+3959        assert_eq!(
+3960            usage.device_total_bytes, 3000,
+3961            "device total is the leaf sum"
+3962        );
+3963        assert_eq!(usage.sizes.get("track-1"), Some(&1000));
+3964        assert_eq!(
+3965            usage.sizes.get("album-1"),
+3966            Some(&3000),
+3967            "container = sum of children"
+3968        );
+3969        // Both children downloaded ⇒ album is NOT partial.
+3970        assert_eq!(
+3971            usage.partial_containers.get("album-1"),
+3972            None,
+3973            "fully downloaded album is not partial"
+3974        );
+3975        // Device total reconciles with the sum of the listed leaves.
+3976        let leaf_sum: i64 = ["track-1", "track-2"]
+3977            .iter()
+3978            .map(|id| usage.sizes[*id])
+3979            .sum();
+3980        assert_eq!(leaf_sum, usage.device_total_bytes);
+3981    }
+3982
+3983    /// UT: a container with a downloaded child AND a non-downloaded cached child
+3984    /// is flagged partial. TRACES: UR-055 | DR-083 | UT-051
+3985    #[tokio::test]
+3986    async fn test_download_disk_usage_flags_partial_container() {
+3987        let db = create_test_db();
+3988        insert_item(&db, "album-1", "MusicAlbum", None, None, None).await;
+3989        insert_item(&db, "track-1", "Audio", Some("album-1"), None, None).await;
+3990        insert_item(&db, "track-2", "Audio", Some("album-1"), None, None).await;
+3991        // Only track-1 downloaded; track-2 is cached but not downloaded.
+3992        seed_completed_download(&db, "track-1", 1000).await;
+3993
+3994        let repo = make_repo(&db);
+3995        let usage = repo.get_download_disk_usage().await.unwrap();
+3996        assert_eq!(
+3997            usage.partial_containers.get("album-1"),
+3998            Some(&true),
+3999            "album with a missing child is partial"
+4000        );
+4001    }
+4002
+4003    #[tokio::test]
+4004    async fn test_playlist_create_empty() {
+4005        let db_service = create_test_db();
+4006        let repo = OfflineRepository::new(
+4007            db_service.clone(),
+4008            "test-server".to_string(),
+4009            "test-user".to_string(),
+4010        );
+4011
+4012        let result = repo.create_playlist("My Playlist", &[]).await;
+4013        assert!(result.is_ok());
+4014        let created = result.unwrap();
+4015        assert!(
+4016            !created.id.is_empty(),
+4017            "Should return a non-empty playlist ID"
+4018        );
+4019
+4020        // Verify playlist exists in DB
+4021        let name: String = db_service
+4022            .query_one(
+4023                Query::with_params(
+4024                    "SELECT name FROM playlists WHERE id = ?",
+4025                    vec![QueryParam::String(created.id.clone())],
+4026                ),
+4027                |row| row.get(0),
+4028            )
+4029            .await
+4030            .unwrap();
+4031        assert_eq!(name, "My Playlist");
+4032    }
+4033
+4034    #[tokio::test]
+4035    async fn test_playlist_create_with_items() {
+4036        let db_service = create_test_db();
+4037        let repo = OfflineRepository::new(
+4038            db_service.clone(),
+4039            "test-server".to_string(),
+4040            "test-user".to_string(),
+4041        );
+4042        seed_items(&repo, &["t1", "t2", "t3"]).await;
+4043
+4044        let created = repo
+4045            .create_playlist("With Tracks", &["t1".into(), "t2".into(), "t3".into()])
+4046            .await
+4047            .unwrap();
+4048
+4049        let items = repo.get_playlist_items(&created.id).await.unwrap();
+4050        assert_eq!(items.len(), 3);
+4051        assert_eq!(items[0].item.id, "t1");
+4052        assert_eq!(items[1].item.id, "t2");
+4053        assert_eq!(items[2].item.id, "t3");
+4054    }
+4055
+4056    #[tokio::test]
+4057    async fn test_playlist_delete() {
+4058        let db_service = create_test_db();
+4059        let repo = OfflineRepository::new(
+4060            db_service.clone(),
+4061            "test-server".to_string(),
+4062            "test-user".to_string(),
+4063        );
+4064        seed_items(&repo, &["t1"]).await;
+4065
+4066        let created = repo
+4067            .create_playlist("To Delete", &["t1".into()])
+4068            .await
+4069            .unwrap();
+4070
+4071        // Delete it
+4072        repo.delete_playlist(&created.id).await.unwrap();
+4073
+4074        // Verify playlist is gone
+4075        let count: i32 = db_service
+4076            .query_one(
+4077                Query::with_params(
+4078                    "SELECT COUNT(*) FROM playlists WHERE id = ?",
+4079                    vec![QueryParam::String(created.id.clone())],
+4080                ),
+4081                |row| row.get(0),
+4082            )
+4083            .await
+4084            .unwrap();
+4085        assert_eq!(count, 0);
+4086
+4087        // Verify cascade deleted playlist_items
+4088        let item_count: i32 = db_service
+4089            .query_one(
+4090                Query::with_params(
+4091                    "SELECT COUNT(*) FROM playlist_items WHERE playlist_id = ?",
+4092                    vec![QueryParam::String(created.id)],
+4093                ),
+4094                |row| row.get(0),
+4095            )
+4096            .await
+4097            .unwrap();
+4098        assert_eq!(item_count, 0);
+4099    }
+4100
+4101    #[tokio::test]
+4102    async fn test_playlist_rename() {
+4103        let db_service = create_test_db();
+4104        let repo = OfflineRepository::new(
+4105            db_service.clone(),
+4106            "test-server".to_string(),
+4107            "test-user".to_string(),
+4108        );
+4109
+4110        let created = repo.create_playlist("Original Name", &[]).await.unwrap();
+4111        repo.rename_playlist(&created.id, "New Name").await.unwrap();
+4112
+4113        let name: String = db_service
+4114            .query_one(
+4115                Query::with_params(
+4116                    "SELECT name FROM playlists WHERE id = ?",
+4117                    vec![QueryParam::String(created.id)],
+4118                ),
+4119                |row| row.get(0),
+4120            )
+4121            .await
+4122            .unwrap();
+4123        assert_eq!(name, "New Name");
+4124    }
+4125
+4126    #[tokio::test]
+4127    async fn test_playlist_get_items_preserves_order() {
+4128        let db_service = create_test_db();
+4129        let repo = OfflineRepository::new(
+4130            db_service.clone(),
+4131            "test-server".to_string(),
+4132            "test-user".to_string(),
+4133        );
+4134        seed_items(&repo, &["a", "b", "c"]).await;
+4135
+4136        let created = repo
+4137            .create_playlist("Ordered", &["c".into(), "a".into(), "b".into()])
+4138            .await
+4139            .unwrap();
+4140        let items = repo.get_playlist_items(&created.id).await.unwrap();
+4141
+4142        assert_eq!(items.len(), 3);
+4143        // Order should match insertion order: c, a, b
+4144        assert_eq!(items[0].item.id, "c");
+4145        assert_eq!(items[1].item.id, "a");
+4146        assert_eq!(items[2].item.id, "b");
+4147        // Each entry should have a unique playlist_item_id
+4148        assert_ne!(items[0].playlist_item_id, items[1].playlist_item_id);
+4149        assert_ne!(items[1].playlist_item_id, items[2].playlist_item_id);
+4150    }
+4151
+4152    #[tokio::test]
+4153    async fn test_playlist_get_items_empty_playlist() {
+4154        let db_service = create_test_db();
+4155        let repo = OfflineRepository::new(
+4156            db_service.clone(),
+4157            "test-server".to_string(),
+4158            "test-user".to_string(),
+4159        );
+4160
+4161        let created = repo.create_playlist("Empty", &[]).await.unwrap();
+4162        let items = repo.get_playlist_items(&created.id).await.unwrap();
+4163        assert!(items.is_empty());
+4164    }
+4165
+4166    #[tokio::test]
+4167    async fn test_playlist_add_items() {
+4168        let db_service = create_test_db();
+4169        let repo = OfflineRepository::new(
+4170            db_service.clone(),
+4171            "test-server".to_string(),
+4172            "test-user".to_string(),
+4173        );
+4174        seed_items(&repo, &["t1", "t2", "t3"]).await;
+4175
+4176        let created = repo
+4177            .create_playlist("Addable", &["t1".into()])
+4178            .await
+4179            .unwrap();
+4180
+4181        // Add two more tracks
+4182        repo.add_to_playlist(&created.id, &["t2".into(), "t3".into()])
+4183            .await
+4184            .unwrap();
+4185
+4186        let items = repo.get_playlist_items(&created.id).await.unwrap();
+4187        assert_eq!(items.len(), 3);
+4188        assert_eq!(items[0].item.id, "t1");
+4189        assert_eq!(items[1].item.id, "t2");
+4190        assert_eq!(items[2].item.id, "t3");
+4191    }
+4192
+4193    #[tokio::test]
+4194    async fn test_playlist_add_duplicate_items_ignored() {
+4195        let db_service = create_test_db();
+4196        let repo = OfflineRepository::new(
+4197            db_service.clone(),
+4198            "test-server".to_string(),
+4199            "test-user".to_string(),
+4200        );
+4201        seed_items(&repo, &["t1"]).await;
+4202
+4203        let created = repo.create_playlist("Dupes", &["t1".into()]).await.unwrap();
+4204
+4205        // Try to add the same item again
+4206        repo.add_to_playlist(&created.id, &["t1".into()])
+4207            .await
+4208            .unwrap();
+4209
+4210        let items = repo.get_playlist_items(&created.id).await.unwrap();
+4211        assert_eq!(
+4212            items.len(),
+4213            1,
+4214            "Duplicate should be ignored (UNIQUE constraint)"
+4215        );
+4216    }
+4217
+4218    #[tokio::test]
+4219    async fn test_playlist_remove_items() {
+4220        let db_service = create_test_db();
+4221        let repo = OfflineRepository::new(
+4222            db_service.clone(),
+4223            "test-server".to_string(),
+4224            "test-user".to_string(),
+4225        );
+4226        seed_items(&repo, &["t1", "t2", "t3"]).await;
+4227
+4228        let created = repo
+4229            .create_playlist("Removable", &["t1".into(), "t2".into(), "t3".into()])
+4230            .await
+4231            .unwrap();
+4232        let items = repo.get_playlist_items(&created.id).await.unwrap();
+4233        assert_eq!(items.len(), 3);
+4234
+4235        // Remove the middle track by its entry ID
+4236        let entry_id_to_remove = items[1].playlist_item_id.clone();
+4237        repo.remove_from_playlist(&created.id, &[entry_id_to_remove])
+4238            .await
+4239            .unwrap();
+4240
+4241        let items_after = repo.get_playlist_items(&created.id).await.unwrap();
+4242        assert_eq!(items_after.len(), 2);
+4243        assert_eq!(items_after[0].item.id, "t1");
+4244        assert_eq!(items_after[1].item.id, "t3");
+4245    }
+4246
+4247    #[tokio::test]
+4248    async fn test_playlist_move_item_forward() {
+4249        let db_service = create_test_db();
+4250        let repo = OfflineRepository::new(
+4251            db_service.clone(),
+4252            "test-server".to_string(),
+4253            "test-user".to_string(),
+4254        );
+4255        seed_items(&repo, &["a", "b", "c", "d"]).await;
+4256
+4257        let created = repo
+4258            .create_playlist("Reorder", &["a".into(), "b".into(), "c".into(), "d".into()])
+4259            .await
+4260            .unwrap();
+4261
+4262        // Move 'a' (index 0) to index 2: expect b, c, a, d
+4263        repo.move_playlist_item(&created.id, "a", 2).await.unwrap();
+4264
+4265        let items = repo.get_playlist_items(&created.id).await.unwrap();
+4266        let ids: Vec<&str> = items.iter().map(|e| e.item.id.as_str()).collect();
+4267        assert_eq!(ids, vec!["b", "c", "a", "d"]);
+4268    }
+4269
+4270    #[tokio::test]
+4271    async fn test_playlist_move_item_backward() {
+4272        let db_service = create_test_db();
+4273        let repo = OfflineRepository::new(
+4274            db_service.clone(),
+4275            "test-server".to_string(),
+4276            "test-user".to_string(),
+4277        );
+4278        seed_items(&repo, &["a", "b", "c", "d"]).await;
+4279
+4280        let created = repo
+4281            .create_playlist(
+4282                "Reorder2",
+4283                &["a".into(), "b".into(), "c".into(), "d".into()],
+4284            )
+4285            .await
+4286            .unwrap();
+4287
+4288        // Move 'd' (index 3) to index 0: expect d, a, b, c
+4289        repo.move_playlist_item(&created.id, "d", 0).await.unwrap();
+4290
+4291        let items = repo.get_playlist_items(&created.id).await.unwrap();
+4292        let ids: Vec<&str> = items.iter().map(|e| e.item.id.as_str()).collect();
+4293        assert_eq!(ids, vec!["d", "a", "b", "c"]);
+4294    }
+4295
+4296    #[tokio::test]
+4297    async fn test_playlist_move_item_to_end() {
+4298        let db_service = create_test_db();
+4299        let repo = OfflineRepository::new(
+4300            db_service.clone(),
+4301            "test-server".to_string(),
+4302            "test-user".to_string(),
+4303        );
+4304        seed_items(&repo, &["a", "b", "c"]).await;
+4305
+4306        let created = repo
+4307            .create_playlist("MoveEnd", &["a".into(), "b".into(), "c".into()])
+4308            .await
+4309            .unwrap();
+4310
+4311        // Move 'a' to index 99 (beyond end, should clamp): expect b, c, a
+4312        repo.move_playlist_item(&created.id, "a", 99).await.unwrap();
+4313
+4314        let items = repo.get_playlist_items(&created.id).await.unwrap();
+4315        let ids: Vec<&str> = items.iter().map(|e| e.item.id.as_str()).collect();
+4316        assert_eq!(ids, vec!["b", "c", "a"]);
+4317    }
+4318
+4319    #[tokio::test]
+4320    async fn test_playlist_move_nonexistent_item_is_noop() {
+4321        let db_service = create_test_db();
+4322        let repo = OfflineRepository::new(
+4323            db_service.clone(),
+4324            "test-server".to_string(),
+4325            "test-user".to_string(),
+4326        );
+4327        seed_items(&repo, &["a", "b"]).await;
+4328
+4329        let created = repo
+4330            .create_playlist("NoOp", &["a".into(), "b".into()])
+4331            .await
+4332            .unwrap();
+4333
+4334        // Move a nonexistent item - should not error, just no-op
+4335        repo.move_playlist_item(&created.id, "nonexistent", 0)
+4336            .await
+4337            .unwrap();
+4338
+4339        let items = repo.get_playlist_items(&created.id).await.unwrap();
+4340        let ids: Vec<&str> = items.iter().map(|e| e.item.id.as_str()).collect();
+4341        assert_eq!(ids, vec!["a", "b"]);
+4342    }
+4343
+4344    /// Seed two movies and one album, favouriting a subset, for the favourites
+4345    /// query tests below.
+4346    async fn seed_favorites(db_service: &Arc<RusqliteService>) {
+4347        use crate::storage::db_service::DatabaseService;
+4348        for sql in [
+4349            "INSERT INTO items (id, server_id, name, item_type, library_id, synced_at, sort_name) \
+4350             VALUES ('movie-fav', 'test-server', 'Favourite Movie', 'Movie', 'lib-1', '2026-01-01', 'Favourite Movie')",
+4351            "INSERT INTO items (id, server_id, name, item_type, library_id, synced_at, sort_name) \
+4352             VALUES ('movie-plain', 'test-server', 'Ordinary Movie', 'Movie', 'lib-1', '2026-01-01', 'Ordinary Movie')",
+4353            "INSERT INTO items (id, server_id, name, item_type, library_id, synced_at, sort_name) \
+4354             VALUES ('album-fav', 'test-server', 'Favourite Album', 'MusicAlbum', 'lib-2', '2026-01-01', 'Favourite Album')",
+4355            "INSERT INTO libraries (id, server_id, name) VALUES ('lib-1', 'test-server', 'Movies')",
+4356            "INSERT INTO user_data (user_id, item_id, is_favorite) VALUES ('test-user', 'movie-fav', 1)",
+4357            "INSERT INTO user_data (user_id, item_id, is_favorite) VALUES ('test-user', 'album-fav', 1)",
+4358            // Explicitly not a favourite — must never show up.
+4359            "INSERT INTO user_data (user_id, item_id, is_favorite) VALUES ('test-user', 'movie-plain', 0)",
+4360            // Another user's favourite must not leak into this user's list.
+4361            "INSERT INTO user_data (user_id, item_id, is_favorite) VALUES ('other-user', 'movie-plain', 1)",
+4362        ] {
+4363            db_service.execute(Query::new(sql)).await.unwrap();
+4364        }
+4365    }
+4366
+4367    /// UT-101 — the offline favourites query returns only favourites, scoped,
+4368    /// and only for the current user.
+4369    ///
+4370    /// TRACES: UR-067 | DR-115 | UT-101
+4371    #[tokio::test]
+4372    async fn test_get_favorites_returns_only_scoped_favorites() {
+4373        let _guard = lock_catalog_browse();
+4374        set_include_catalog_browse(true);
+4375
+4376        let db_service = create_test_db();
+4377        seed_favorites(&db_service).await;
+4378        let repo = OfflineRepository::new(
+4379            db_service,
+4380            "test-server".to_string(),
+4381            "test-user".to_string(),
+4382        );
+4383
+4384        let all = repo.get_favorites(SearchScope::All, None).await.unwrap();
+4385        let mut ids: Vec<&str> = all.items.iter().map(|i| i.id.as_str()).collect();
+4386        ids.sort();
+4387        assert_eq!(
+4388            ids,
+4389            vec!["album-fav", "movie-fav"],
+4390            "All scope should return every favourite and nothing else"
+4391        );
+4392
+4393        let movies = repo.get_favorites(SearchScope::Movies, None).await.unwrap();
+4394        let ids: Vec<&str> = movies.items.iter().map(|i| i.id.as_str()).collect();
+4395        assert_eq!(ids, vec!["movie-fav"]);
+4396
+4397        let music = repo.get_favorites(SearchScope::Music, None).await.unwrap();
+4398        let ids: Vec<&str> = music.items.iter().map(|i| i.id.as_str()).collect();
+4399        assert_eq!(ids, vec!["album-fav"]);
+4400    }
+4401
+4402    /// With "Show all server media" off, favourites narrow to what is actually
+4403    /// on the device — the same gate browsing obeys (DR-080).
+4404    ///
+4405    /// TRACES: UR-067 | DR-115 | UT-101
+4406    #[tokio::test]
+4407    async fn test_get_favorites_respects_catalog_browse_gate() {
+4408        use crate::storage::db_service::DatabaseService;
+4409        let _guard = lock_catalog_browse();
+4410
+4411        let db_service = create_test_db();
+4412        seed_favorites(&db_service).await;
+4413        // Only the album is downloaded... via a child track, the container case.
+4414        db_service
+4415            .execute(Query::new(
+4416                "INSERT INTO items (id, server_id, name, item_type, album_id, synced_at) \
+4417                 VALUES ('track-1', 'test-server', 'Track', 'Audio', 'album-fav', '2026-01-01')",
+4418            ))
+4419            .await
+4420            .unwrap();
+4421        db_service
+4422            .execute(Query::new(
+4423                "INSERT INTO downloads (item_id, status) VALUES ('track-1', 'completed')",
+4424            ))
+4425            .await
+4426            .unwrap();
+4427
+4428        let repo = OfflineRepository::new(
+4429            db_service,
+4430            "test-server".to_string(),
+4431            "test-user".to_string(),
+4432        );
+4433
+4434        set_include_catalog_browse(false);
+4435        let offline_only = repo.get_favorites(SearchScope::All, None).await.unwrap();
+4436        let ids: Vec<&str> = offline_only.items.iter().map(|i| i.id.as_str()).collect();
+4437        assert_eq!(
+4438            ids,
+4439            vec!["album-fav"],
+4440            "with the gate off, only favourites on the device are listed"
+4441        );
+4442
+4443        set_include_catalog_browse(true);
+4444        let with_catalog = repo.get_favorites(SearchScope::All, None).await.unwrap();
+4445        assert_eq!(with_catalog.items.len(), 2);
+4446    }
+4447
+4448    /// UT-104 — the per-library favourites toggle narrows a normal listing.
+4449    ///
+4450    /// TRACES: UR-067 | DR-116 | UT-104
+4451    #[tokio::test]
+4452    async fn test_get_items_favorites_only_filters_listing() {
+4453        let _guard = lock_catalog_browse();
+4454        set_include_catalog_browse(true);
+4455
+4456        let db_service = create_test_db();
+4457        seed_favorites(&db_service).await;
+4458        let repo = OfflineRepository::new(
+4459            db_service,
+4460            "test-server".to_string(),
+4461            "test-user".to_string(),
+4462        );
+4463
+4464        let unfiltered = repo
+4465            .get_items(
+4466                "lib-1",
+4467                Some(GetItemsOptions {
+4468                    include_item_types: Some(vec!["Movie".to_string()]),
+4469                    ..Default::default()
+4470                }),
+4471            )
+4472            .await
+4473            .unwrap();
+4474        assert_eq!(unfiltered.items.len(), 2, "both movies without the filter");
+4475
+4476        let favourites = repo
+4477            .get_items(
+4478                "lib-1",
+4479                Some(GetItemsOptions {
+4480                    include_item_types: Some(vec!["Movie".to_string()]),
+4481                    favorites_only: Some(true),
+4482                    ..Default::default()
+4483                }),
+4484            )
+4485            .await
+4486            .unwrap();
+4487        let ids: Vec<&str> = favourites.items.iter().map(|i| i.id.as_str()).collect();
+4488        assert_eq!(ids, vec!["movie-fav"]);
+4489    }
+4490
+4491    /// UT-206 — `include_item_types` reaches the listing query as bound
+4492    /// parameters, so a type name can only ever be compared as data.
+4493    ///
+4494    /// Interpolated, the type below closed the `IN (` list and commented out the
+4495    /// rest of the line, leaving `... AND i.item_type IN ('Movie') OR 1=1`, which
+4496    /// is true for every row — the listing then returned the whole cache
+4497    /// regardless of parent or type. Bound, it is just a type name that matches
+4498    /// nothing.
+4499    ///
+4500    /// TRACES: UR-065 | DR-212 | UT-206
+4501    #[tokio::test]
+4502    async fn test_get_items_type_filter_is_bound_not_interpolated() {
+4503        let _guard = lock_catalog_browse();
+4504        set_include_catalog_browse(true);
+4505
+4506        let db_service = create_test_db();
+4507        seed_favorites(&db_service).await;
+4508        let repo = OfflineRepository::new(
+4509            db_service,
+4510            "test-server".to_string(),
+4511            "test-user".to_string(),
+4512        );
+4513
+4514        let injected = repo
+4515            .get_items(
+4516                "lib-1",
+4517                Some(GetItemsOptions {
+4518                    include_item_types: Some(vec!["Movie') OR 1=1 --".to_string()]),
+4519                    ..Default::default()
+4520                }),
+4521            )
+4522            .await
+4523            .expect("a hostile type name must be data, not a broken query");
+4524        assert!(
+4525            injected.items.is_empty(),
+4526            "no cached item has that type, so nothing may come back; got {:?}",
+4527            injected
+4528                .items
+4529                .iter()
+4530                .map(|i| i.id.as_str())
+4531                .collect::<Vec<_>>()
+4532        );
+4533
+4534        // A quote on its own is likewise just a character in a type name.
+4535        let quoted = repo
+4536            .get_items(
+4537                "lib-1",
+4538                Some(GetItemsOptions {
+4539                    include_item_types: Some(vec!["Mo'vie".to_string()]),
+4540                    ..Default::default()
+4541                }),
+4542            )
+4543            .await
+4544            .expect("an embedded quote must not break the query");
+4545        assert!(quoted.items.is_empty());
+4546    }
+4547
+4548    /// UT-206 — binding the type filter must not disturb the positions of the
+4549    /// parameters around it: the parent ids bind before it and the favourites
+4550    /// user id after it. A misordered vec would silently compare `user_id`
+4551    /// against `item_type`, so this asserts the filters still compose.
+4552    ///
+4553    /// TRACES: UR-065, UR-067 | DR-212 | UT-206
+4554    #[tokio::test]
+4555    async fn test_get_items_binds_multiple_types_in_parameter_order() {
+4556        let _guard = lock_catalog_browse();
+4557        set_include_catalog_browse(true);
+4558
+4559        let db_service = create_test_db();
+4560        seed_favorites(&db_service).await;
+4561        let repo = OfflineRepository::new(
+4562            db_service,
+4563            "test-server".to_string(),
+4564            "test-user".to_string(),
+4565        );
+4566
+4567        let both = repo
+4568            .get_items(
+4569                "lib-1",
+4570                Some(GetItemsOptions {
+4571                    include_item_types: Some(vec!["Movie".to_string(), "MusicAlbum".to_string()]),
+4572                    ..Default::default()
+4573                }),
+4574            )
+4575            .await
+4576            .unwrap();
+4577        let mut ids: Vec<&str> = both.items.iter().map(|i| i.id.as_str()).collect();
+4578        ids.sort();
+4579        assert_eq!(ids, vec!["album-fav", "movie-fav", "movie-plain"]);
+4580
+4581        // Two type placeholders *and* the favourites parameter after them.
+4582        let favourites = repo
+4583            .get_items(
+4584                "lib-1",
+4585                Some(GetItemsOptions {
+4586                    include_item_types: Some(vec!["Movie".to_string(), "MusicAlbum".to_string()]),
+4587                    favorites_only: Some(true),
+4588                    ..Default::default()
+4589                }),
+4590            )
+4591            .await
+4592            .unwrap();
+4593        let mut ids: Vec<&str> = favourites.items.iter().map(|i| i.id.as_str()).collect();
+4594        ids.sort();
+4595        assert_eq!(ids, vec!["album-fav", "movie-fav"]);
+4596    }
+4597
+4598    /// UT-102 — caching a server result mirrors its favourite state locally,
+4599    /// but never over a row still waiting to be pushed.
+4600    ///
+4601    /// The second half is the one that matters: favourite something with the
+4602    /// server unreachable, and the next successful browse would otherwise
+4603    /// overwrite it with the server's stale `false` before the drain ever ran.
+4604    ///
+4605    /// TRACES: UR-069 | DR-114 | UT-102
+4606    #[tokio::test]
+4607    async fn test_save_to_cache_mirrors_favorites_without_clobbering_pending() {
+4608        use crate::storage::db_service::DatabaseService;
+4609        let db_service = create_test_db();
+4610        let repo = OfflineRepository::new(
+4611            db_service.clone(),
+4612            "test-server".to_string(),
+4613            "test-user".to_string(),
+4614        );
+4615
+4616        let favourite_flag = |id: &'static str| {
+4617            let db = db_service.clone();
+4618            async move {
+4619                db.query_optional(
+4620                    Query::with_params(
+4621                        "SELECT is_favorite, pending_sync FROM user_data \
+4622                         WHERE user_id = ? AND item_id = ?",
+4623                        vec![
+4624                            QueryParam::String("test-user".to_string()),
+4625                            QueryParam::String(id.to_string()),
+4626                        ],
+4627                    ),
+4628                    |row| Ok((row.get::<_, Option<i32>>(0)?, row.get::<_, Option<i32>>(1)?)),
+4629                )
+4630                .await
+4631                .unwrap()
+4632            }
+4633        };
+4634
+4635        // A server item the user favourited elsewhere.
+4636        let mut favourited = create_test_item("fav-1", "Favourited Elsewhere", None);
+4637        favourited.user_data = Some(UserData {
+4638            is_favorite: Some(true),
+4639            ..Default::default()
+4640        });
+4641        // A server item with no user data at all — must not fabricate a row.
+4642        let untouched = create_test_item("plain-1", "No User Data", None);
+4643
+4644        repo.save_to_cache("parent-1", &[favourited.clone(), untouched])
+4645            .await
+4646            .unwrap();
+4647
+4648        assert_eq!(
+4649            favourite_flag("fav-1").await,
+4650            Some((Some(1), Some(0))),
+4651            "server favourite should be mirrored as synced"
+4652        );
+4653        assert_eq!(
+4654            favourite_flag("plain-1").await,
+4655            None,
+4656            "an item without UserData should not get an invented user_data row"
+4657        );
+4658
+4659        // The user un-favourites it while offline: local write, pending_sync = 1.
+4660        db_service
+4661            .execute(Query::with_params(
+4662                "UPDATE user_data SET is_favorite = 0, pending_sync = 1 \
+4663                 WHERE user_id = ? AND item_id = ?",
+4664                vec![
+4665                    QueryParam::String("test-user".to_string()),
+4666                    QueryParam::String("fav-1".to_string()),
+4667                ],
+4668            ))
+4669            .await
+4670            .unwrap();
+4671
+4672        // The server still reports it as a favourite; caching must not win.
+4673        repo.save_to_cache("parent-1", &[favourited]).await.unwrap();
+4674
+4675        assert_eq!(
+4676            favourite_flag("fav-1").await,
+4677            Some((Some(0), Some(1))),
+4678            "an unsynced local toggle must survive a cache write"
+4679        );
+4680    }
+4681
+4682    /// UT-152 — the server's watch position is mirrored locally, so an item
+4683    /// watched on another device resumes here.
+4684    ///
+4685    /// The resume check reads only the local `user_data` row, and the mirror
+4686    /// previously carried `is_favorite` alone — so a position set on any other
+4687    /// client never reached this device and cross-device resume silently did
+4688    /// nothing. The `pending_sync` guard is the same conflict rule favourites
+4689    /// use: a local position still waiting to be pushed must not be pulled
+4690    /// backwards by the stale value the server is still reporting.
+4691    ///
+4692    /// TRACES: UR-025, UR-069 | DR-155 | UT-152
+4693    #[tokio::test]
+4694    async fn test_save_to_cache_mirrors_playback_position_without_clobbering_pending() {
+4695        use crate::storage::db_service::DatabaseService;
+4696        let db_service = create_test_db();
+4697        let repo = OfflineRepository::new(
+4698            db_service.clone(),
+4699            "test-server".to_string(),
+4700            "test-user".to_string(),
+4701        );
+4702
+4703        let position = |id: &'static str| {
+4704            let db = db_service.clone();
+4705            async move {
+4706                db.query_optional(
+4707                    Query::with_params(
+4708                        "SELECT playback_position_ticks, pending_sync FROM user_data \
+4709                         WHERE user_id = ? AND item_id = ?",
+4710                        vec![
+4711                            QueryParam::String("test-user".to_string()),
+4712                            QueryParam::String(id.to_string()),
+4713                        ],
+4714                    ),
+4715                    |row| Ok((row.get::<_, Option<i64>>(0)?, row.get::<_, Option<i32>>(1)?)),
+4716                )
+4717                .await
+4718                .unwrap()
+4719            }
+4720        };
+4721
+4722        // Watched 20 minutes into this episode on another device.
+4723        let mut watched = create_test_item("ep-1", "Watched Elsewhere", None);
+4724        watched.user_data = Some(UserData {
+4725            playback_position_ticks: Some(12_000_000_000),
+4726            ..Default::default()
+4727        });
+4728        // No user data at all — must not fabricate a position of 0.
+4729        let untouched = create_test_item("ep-2", "No User Data", None);
+4730
+4731        repo.save_to_cache("parent-1", &[watched.clone(), untouched])
+4732            .await
+4733            .unwrap();
+4734
+4735        assert_eq!(
+4736            position("ep-1").await,
+4737            Some((Some(12_000_000_000), Some(0))),
+4738            "the server's position should be mirrored as synced"
+4739        );
+4740        assert_eq!(
+4741            position("ep-2").await,
+4742            None,
+4743            "an item without UserData should not get an invented position"
+4744        );
+4745
+4746        // Watched further here while the server was unreachable: pending_sync = 1.
+4747        db_service
+4748            .execute(Query::with_params(
+4749                "UPDATE user_data SET playback_position_ticks = ?, pending_sync = 1 \
+4750                 WHERE user_id = ? AND item_id = ?",
+4751                vec![
+4752                    QueryParam::Int64(30_000_000_000),
+4753                    QueryParam::String("test-user".to_string()),
+4754                    QueryParam::String("ep-1".to_string()),
+4755                ],
+4756            ))
+4757            .await
+4758            .unwrap();
+4759
+4760        // The server still reports the older position; caching must not win.
+4761        repo.save_to_cache("parent-1", &[watched]).await.unwrap();
+4762
+4763        assert_eq!(
+4764            position("ep-1").await,
+4765            Some((Some(30_000_000_000), Some(1))),
+4766            "an unsynced local position must not be pulled backwards"
+4767        );
+4768    }
+4769
+4770    /// UT-152 — a server item carrying *only* a position (no favourite flag)
+4771    /// still gets mirrored.
+4772    ///
+4773    /// The mirror used to return early whenever `is_favorite` was absent, which
+4774    /// is exactly the shape of an ordinary watched episode: Jellyfin reports
+4775    /// `PlaybackPositionTicks` with no favourite state. That early return is why
+4776    /// the position never landed.
+4777    ///
+4778    /// TRACES: UR-025 | DR-155 | UT-152
+4779    #[tokio::test]
+4780    async fn test_position_is_mirrored_even_when_no_favourite_flag_is_present() {
+4781        use crate::storage::db_service::DatabaseService;
+4782        let db_service = create_test_db();
+4783        let repo = OfflineRepository::new(
+4784            db_service.clone(),
+4785            "test-server".to_string(),
+4786            "test-user".to_string(),
+4787        );
+4788
+4789        let mut watched = create_test_item("ep-3", "Position Only", None);
+4790        watched.user_data = Some(UserData {
+4791            is_favorite: None,
+4792            playback_position_ticks: Some(9_000_000_000),
+4793            ..Default::default()
+4794        });
+4795
+4796        repo.save_to_cache("parent-1", &[watched]).await.unwrap();
+4797
+4798        let stored = db_service
+4799            .query_optional(
+4800                Query::with_params(
+4801                    "SELECT playback_position_ticks FROM user_data \
+4802                     WHERE user_id = ? AND item_id = ?",
+4803                    vec![
+4804                        QueryParam::String("test-user".to_string()),
+4805                        QueryParam::String("ep-3".to_string()),
+4806                    ],
+4807                ),
+4808                |row| row.get::<_, Option<i64>>(0),
+4809            )
+4810            .await
+4811            .unwrap();
+4812
+4813        assert_eq!(
+4814            stored,
+4815            Some(Some(9_000_000_000)),
+4816            "a position with no favourite flag must still be mirrored"
+4817        );
+4818    }
+4819}
+
\ No newline at end of file diff --git a/api/src/jellytau_lib/repository/online.rs.html b/api/src/jellytau_lib/repository/online.rs.html new file mode 100644 index 00000000..2f7f7edd --- /dev/null +++ b/api/src/jellytau_lib/repository/online.rs.html @@ -0,0 +1,3891 @@ +online.rs - sourceSkip to main content

jellytau_lib/repository/
online.rs

1//! TRACES: UR-002, UR-007 | DR-013 | IR-010
+2
+3use async_trait::async_trait;
+4use log::{debug, error, info, warn};
+5use serde::{Deserialize, Serialize};
+6use std::sync::{Arc, RwLock};
+7
+8use super::{types::*, MediaRepository};
+9use crate::connectivity::ConnectivityReporter;
+10use crate::jellyfin::HttpClient;
+11use crate::settings::StreamingQuality;
+12use crate::utils::lock::RwLockSafe;
+13
+14/// The bandwidth ceiling every video stream this process opens is built against.
+15///
+16/// Process-wide rather than a field on [`OnlineRepository`] because it is a user
+17/// preference about *this device's connection*, not about a server session: it
+18/// must survive a repository being rebuilt on re-login, and every URL builder and
+19/// the `PlaybackInfo` negotiation have to agree on it or the cap leaks (the
+20/// negotiation would authorise a direct play the URL builder then never gets to
+21/// constrain). Same shape as `offline::INCLUDE_CATALOG_BROWSE`.
+22///
+23/// Set from `player_set_video_settings` / `player_set_stream_quality`, and
+24/// restored from the database at startup.
+25///
+26/// TRACES: UR-074 | DR-162
+27static STREAMING_QUALITY: RwLock<StreamingQuality> = RwLock::new(StreamingQuality::Original);
+28
+29/// Apply a bandwidth ceiling to every subsequently-opened video stream.
+30///
+31/// Streams already playing keep the bitrate they were opened at — a cap is a
+32/// property of the URL the server is transcoding for, so changing it mid-stream
+33/// requires re-opening at the new quality (`player_set_stream_quality`).
+34///
+35/// TRACES: UR-074 | DR-162
+36pub fn set_streaming_quality(quality: StreamingQuality) {
+37    *STREAMING_QUALITY.write_safe() = quality;
+38}
+39
+40/// The ceiling currently applied to new video streams.
+41///
+42/// TRACES: UR-074 | DR-162
+43pub fn streaming_quality() -> StreamingQuality {
+44    *STREAMING_QUALITY.read_safe()
+45}
+46
+47/// Every request this app makes identifies the same device, so the device id
+48/// alone cannot tell two transcodes of the same item apart — see
+49/// [`begin_video_play_session`].
+50const DEVICE_ID: &str = "jellytau-tauri";
+51
+52/// The `PlaySessionId` of the video transcode most recently opened, so the next
+53/// open can stop it.
+54///
+55/// Process-wide for the same reason as [`STREAMING_QUALITY`]: it describes what
+56/// *this device* currently has running on the server, and must survive the
+57/// repository being rebuilt on re-login.
+58///
+59/// TRACES: UR-074 | DR-162
+60static VIDEO_PLAY_SESSION: RwLock<Option<String>> = RwLock::new(None);
+61
+62/// Claim a transcode identity for a stream about to be opened, returning the new
+63/// `PlaySessionId` and the one it replaces (if any).
+64///
+65/// Jellyfin keys a transcode job by device *and* play session. Without a session
+66/// id every open of the same item on this device looked like the same job, so
+67/// re-opening a stream — a quality switch, a transcoded seek, an audio-track
+68/// switch — left the old ffmpeg running and the server intermittently rejected
+69/// segment requests for the new one (`400` on `hls1/main/0.ts`) while the two
+70/// fought over one transcode path. The caller stops the returned previous
+71/// session before the new stream's segments are fetched.
+72///
+73/// TRACES: UR-074 | DR-177 | UT-173
+74pub fn begin_video_play_session() -> (String, Option<String>) {
+75    let new_session = uuid::Uuid::new_v4().to_string();
+76    let mut current = VIDEO_PLAY_SESSION.write_safe();
+77    let previous = current.replace(new_session.clone());
+78    (new_session, previous)
+79}
+80
+81/// Take ownership of a transcode this process did not build a URL for, returning
+82/// the session it replaces.
+83///
+84/// When `PlaybackInfo` answers with a `TranscodingUrl` the server has already
+85/// started the job and named the session; that id is the only handle on it we
+86/// will ever have. Without adopting it, the first re-open of that stream has no
+87/// previous session to stop and collides with the very job that was playing.
+88///
+89/// TRACES: UR-074 | DR-177 | UT-173
+90pub fn adopt_video_play_session(session_id: String) -> Option<String> {
+91    VIDEO_PLAY_SESSION.write_safe().replace(session_id)
+92}
+93
+94/// A single actor returned by the JRay plugin's "context at time t" endpoint.
+95///
+96/// Mirrors the `actors[]` objects from `GET /Plugins/JRay/Items/{id}/jray?t=`.
+97/// `jellyfin_id` (a Jellyfin Person item GUID) is preferred for navigation;
+98/// the IMDb/TMDb ids are informational fallbacks. Unknown ids are `""`.
+99#[derive(specta::Type, Debug, Clone, Serialize, Deserialize)]
+100pub struct JRayActor {
+101    pub name: String,
+102    #[serde(default)]
+103    pub imdb_id: String,
+104    #[serde(default)]
+105    pub tmdb_id: String,
+106    #[serde(default)]
+107    pub jellyfin_id: String,
+108}
+109
+110/// Envelope returned by the JRay `jray?t=` endpoint. Extra keys (future
+111/// `locations`, `trivia`, …) are ignored so the client tolerates schema growth.
+112#[derive(Debug, Clone, Deserialize)]
+113struct JRayContext {
+114    #[serde(default)]
+115    actors: Vec<JRayActor>,
+116}
+117
+118/// Online repository - fetches data from Jellyfin server via HTTP
+119pub struct OnlineRepository {
+120    http_client: Arc<HttpClient>,
+121    server_url: String,
+122    user_id: String,
+123    access_token: String,
+124    /// Reports the outcome of every server request to the connectivity monitor.
+125    /// This is the source of truth for the offline/online banner. `None` in
+126    /// tests / contexts where connectivity tracking isn't wired up.
+127    connectivity: Option<ConnectivityReporter>,
+128}
+129
+130impl OnlineRepository {
+131    /// The signed-in user these requests are made as. Needed by the favourites
+132    /// drain, which reads this user's queued rows. TRACES: UR-069 | DR-120
+133    pub fn user_id(&self) -> &str {
+134        &self.user_id
+135    }
+136
+137    pub fn new(
+138        http_client: Arc<HttpClient>,
+139        server_url: String,
+140        user_id: String,
+141        access_token: String,
+142    ) -> Self {
+143        Self {
+144            http_client,
+145            server_url,
+146            user_id,
+147            access_token,
+148            connectivity: None,
+149        }
+150    }
+151
+152    /// Attach a connectivity reporter so server outcomes drive the reachability
+153    /// state observed by the UI. See `report_outcome`.
+154    pub fn with_connectivity(mut self, reporter: ConnectivityReporter) -> Self {
+155        self.connectivity = Some(reporter);
+156        self
+157    }
+158
+159    /// Feed a request outcome into the connectivity monitor.
+160    ///
+161    /// Classification (matches docs/architecture/07-connectivity.md):
+162    /// - `Ok` / `Authentication` / `NotFound` / `Server` → the server answered,
+163    ///   so it is reachable → `report_success` (instant recovery).
+164    /// - `Network` → connection-level failure → `report_network_failure`
+165    ///   (subject to the time-window debounce before going offline).
+166    /// - `Database` → not a server signal → ignored.
+167    async fn report_outcome<T>(&self, result: &Result<T, RepoError>) {
+168        let Some(reporter) = &self.connectivity else {
+169            return;
+170        };
+171
+172        match result {
+173            Ok(_)
+174            | Err(RepoError::Authentication { .. })
+175            | Err(RepoError::NotFound { .. })
+176            | Err(RepoError::Server { .. }) => {
+177                reporter.report_success().await;
+178            }
+179            Err(RepoError::Network { message }) => {
+180                reporter.report_network_failure(Some(message.clone())).await;
+181            }
+182            Err(RepoError::Database { .. }) | Err(RepoError::Offline) => {
+183                // Local-side errors (cache failure / already-offline) — not a
+184                // statement about the server's reachability, so ignore them.
+185            }
+186        }
+187    }
+188
+189    /// Build authorization header
+190    fn auth_header(&self) -> String {
+191        HttpClient::build_auth_header(Some(&self.access_token), "jellytau-device")
+192    }
+193
+194    /// Download raw bytes from a URL using the shared authenticated HTTP client.
+195    /// Used by thumbnail cache to download images with proper auth and connection reuse.
+196    pub async fn download_bytes(&self, url: &str) -> Result<Vec<u8>, String> {
+197        let request = self
+198            .http_client
+199            .client
+200            .get(url)
+201            .header("X-Emby-Authorization", self.auth_header())
+202            .build()
+203            .map_err(|e| format!("Failed to build request: {}", e))?;
+204
+205        let response = self
+206            .http_client
+207            .request_with_retry(request)
+208            .await
+209            .map_err(|e| format!("Download failed: {}", e))?;
+210
+211        if !response.status().is_success() {
+212            let status = response.status();
+213            let body = response.text().await.unwrap_or_default();
+214            let body_preview = if body.len() > 200 {
+215                &body[..200]
+216            } else {
+217                &body
+218            };
+219            return Err(format!("HTTP {} ({})", status, body_preview.trim()));
+220        }
+221
+222        response
+223            .bytes()
+224            .await
+225            .map(|b| b.to_vec())
+226            .map_err(|e| format!("Failed to read bytes: {}", e))
+227    }
+228
+229    /// Query the JRay plugin for the actors on screen at time `t` (seconds) in
+230    /// the given item. Returns an empty list when the plugin isn't installed or
+231    /// has no truth data for the item (HTTP 404), so callers can treat "no JRay"
+232    /// and "nobody on screen" identically. Other failures propagate.
+233    pub async fn get_jray_actors(
+234        &self,
+235        item_id: &str,
+236        t: f64,
+237    ) -> Result<Vec<JRayActor>, RepoError> {
+238        let endpoint = format!(
+239            "/Plugins/JRay/Items/{}/jray?t={}",
+240            urlencoding::encode(item_id),
+241            t
+242        );
+243        match self.get_json::<JRayContext>(&endpoint).await {
+244            Ok(context) => Ok(context.actors),
+245            // No plugin / no truth data for this item — not an error to the user.
+246            Err(RepoError::NotFound { .. }) => Ok(Vec::new()),
+247            Err(e) => Err(e),
+248        }
+249    }
+250
+251    /// Make authenticated GET request
+252    async fn get_json<T: for<'de> Deserialize<'de>>(&self, endpoint: &str) -> Result<T, RepoError> {
+253        // Fast-fail when connectivity is known-offline. Without this every request
+254        // still runs the full HTTP retry/backoff cycle (~7s) before giving up,
+255        // which stalls cache-miss paths and makes offline browsing feel janky.
+256        // The offline recovery probe (connectivity monitor) flips us back to
+257        // reachable the moment the server returns, so this never sticks.
+258        if let Some(reporter) = &self.connectivity {
+259            if !reporter.is_reachable().await {
+260                return Err(RepoError::Offline);
+261            }
+262        }
+263
+264        let result = self.get_json_inner(endpoint).await;
+265        self.report_outcome(&result).await;
+266        result
+267    }
+268
+269    async fn get_json_inner<T: for<'de> Deserialize<'de>>(
+270        &self,
+271        endpoint: &str,
+272    ) -> Result<T, RepoError> {
+273        let url = format!("{}{}", self.server_url, endpoint);
+274
+275        let request = self
+276            .http_client
+277            .client
+278            .get(&url)
+279            .header("X-Emby-Authorization", self.auth_header())
+280            .build()
+281            .map_err(|e| RepoError::Network {
+282                message: format!("Failed to build request: {}", e),
+283            })?;
+284
+285        let response = self
+286            .http_client
+287            .request_with_retry(request)
+288            .await
+289            .map_err(|e| RepoError::Network {
+290                message: e.to_string(),
+291            })?;
+292
+293        if !response.status().is_success() {
+294            let status = response.status();
+295            if status.as_u16() == 401 || status.as_u16() == 403 {
+296                return Err(RepoError::Authentication {
+297                    message: format!("HTTP {}", status),
+298                });
+299            } else if status.as_u16() == 404 {
+300                return Err(RepoError::NotFound {
+301                    message: "Resource not found".to_string(),
+302                });
+303            } else {
+304                return Err(RepoError::Server {
+305                    message: format!("HTTP {}", status),
+306                });
+307            }
+308        }
+309
+310        // Get the response text first for better error reporting
+311        let text = response.text().await.map_err(|e| RepoError::Server {
+312            message: format!("Failed to read response: {}", e),
+313        })?;
+314
+315        // Try to deserialize and log the raw JSON on error
+316        serde_json::from_str(&text).map_err(|e| {
+317            error!(
+318                "[OnlineRepo] Failed to deserialize {} response: {}",
+319                endpoint, e
+320            );
+321            error!(
+322                "[OnlineRepo] Response body (first 1000 chars): {}",
+323                if text.len() > 1000 {
+324                    &text[..1000]
+325                } else {
+326                    &text
+327                }
+328            );
+329            RepoError::Server {
+330                message: format!("Failed to parse response: {}", e),
+331            }
+332        })
+333    }
+334
+335    /// Make authenticated POST request
+336    async fn post_json<T: Serialize>(&self, endpoint: &str, body: &T) -> Result<(), RepoError> {
+337        let result = self.post_json_inner(endpoint, body).await;
+338        self.report_outcome(&result).await;
+339        result
+340    }
+341
+342    async fn post_json_inner<T: Serialize>(
+343        &self,
+344        endpoint: &str,
+345        body: &T,
+346    ) -> Result<(), RepoError> {
+347        let url = format!("{}{}", self.server_url, endpoint);
+348
+349        let request = self
+350            .http_client
+351            .client
+352            .post(&url)
+353            .header("Content-Type", "application/json")
+354            .header("X-Emby-Authorization", self.auth_header())
+355            .json(body)
+356            .build()
+357            .map_err(|e| RepoError::Network {
+358                message: format!("Failed to build request: {}", e),
+359            })?;
+360
+361        let response = self
+362            .http_client
+363            .request_with_retry(request)
+364            .await
+365            .map_err(|e| RepoError::Network {
+366                message: e.to_string(),
+367            })?;
+368
+369        if !response.status().is_success() {
+370            let status = response.status();
+371            if status.as_u16() == 401 || status.as_u16() == 403 {
+372                return Err(RepoError::Authentication {
+373                    message: format!("HTTP {}", status),
+374                });
+375            } else {
+376                return Err(RepoError::Server {
+377                    message: format!("HTTP {}", status),
+378                });
+379            }
+380        }
+381
+382        Ok(())
+383    }
+384
+385    /// Make authenticated POST request and return response
+386    async fn post_json_response<T: Serialize, R: for<'de> Deserialize<'de>>(
+387        &self,
+388        endpoint: &str,
+389        body: &T,
+390    ) -> Result<R, RepoError> {
+391        let result = self.post_json_response_inner(endpoint, body).await;
+392        self.report_outcome(&result).await;
+393        result
+394    }
+395
+396    async fn post_json_response_inner<T: Serialize, R: for<'de> Deserialize<'de>>(
+397        &self,
+398        endpoint: &str,
+399        body: &T,
+400    ) -> Result<R, RepoError> {
+401        let url = format!("{}{}", self.server_url, endpoint);
+402
+403        // Log request body for debugging
+404        if let Ok(json) = serde_json::to_string_pretty(body) {
+405            debug!("[HTTP] POST {}", endpoint);
+406            debug!("[HTTP] Request body:\n{}", json);
+407        }
+408
+409        let request = self
+410            .http_client
+411            .client
+412            .post(&url)
+413            .header("Content-Type", "application/json")
+414            .header("X-Emby-Authorization", self.auth_header())
+415            .json(body)
+416            .build()
+417            .map_err(|e| RepoError::Network {
+418                message: format!("Failed to build request: {}", e),
+419            })?;
+420
+421        let response = self
+422            .http_client
+423            .request_with_retry(request)
+424            .await
+425            .map_err(|e| RepoError::Network {
+426                message: e.to_string(),
+427            })?;
+428
+429        if !response.status().is_success() {
+430            let status = response.status();
+431
+432            // Capture response body for error details
+433            let error_body = response
+434                .text()
+435                .await
+436                .unwrap_or_else(|_| "Failed to read error body".to_string());
+437            error!("[HTTP] Error response ({}): {}", status, error_body);
+438
+439            if status.as_u16() == 401 || status.as_u16() == 403 {
+440                return Err(RepoError::Authentication {
+441                    message: format!("HTTP {}: {}", status, error_body),
+442                });
+443            } else if status.as_u16() == 404 {
+444                return Err(RepoError::NotFound {
+445                    message: format!("Resource not found: {}", error_body),
+446                });
+447            } else {
+448                return Err(RepoError::Server {
+449                    message: format!("HTTP {}: {}", status, error_body),
+450                });
+451            }
+452        }
+453
+454        response.json().await.map_err(|e| RepoError::Server {
+455            message: format!("Failed to parse response: {}", e),
+456        })
+457    }
+458
+459    /// Ask the server to tear down a transcode this device started.
+460    ///
+461    /// Best-effort and deliberately un-retried: it runs on the path that opens a
+462    /// replacement stream, so a slow or failed stop must not delay playback. The
+463    /// worst case if it does fail is the job Jellyfin would have reaped on its
+464    /// own idle timer anyway — the new stream still has its own session id, so it
+465    /// no longer collides with the old one.
+466    ///
+467    /// TRACES: UR-074 | DR-177
+468    async fn stop_transcode(&self, play_session_id: &str) {
+469        let url = format!(
+470            "{}/Videos/ActiveEncodings?deviceId={}&playSessionId={}",
+471            self.server_url, DEVICE_ID, play_session_id
+472        );
+473
+474        let request = self
+475            .http_client
+476            .client
+477            .delete(&url)
+478            .header("X-Emby-Authorization", self.auth_header())
+479            .send();
+480
+481        match request.await {
+482            Ok(response) if response.status().is_success() => {
+483                debug!("[Transcode] Stopped previous encoding {}", play_session_id);
+484            }
+485            Ok(response) => {
+486                debug!(
+487                    "[Transcode] Server declined to stop encoding {}: HTTP {}",
+488                    play_session_id,
+489                    response.status()
+490                );
+491            }
+492            Err(e) => {
+493                debug!(
+494                    "[Transcode] Could not stop encoding {}: {}",
+495                    play_session_id, e
+496                );
+497            }
+498        }
+499    }
+500
+501    /// Get a video stream URL (initial play, resume, transcoded seeking,
+502    /// audio-track switching).
+503    ///
+504    /// Returns an HLS master playlist (`/Videos/{id}/master.m3u8`) transcoded to
+505    /// h264/aac. HLS is used rather than a progressive `stream.mp4` because the
+506    /// HTML5 `<video>` element (via HLS.js) starts playing within seconds and can
+507    /// seek within the stream, whereas a progressive MP4 transcode of HEVC source
+508    /// forces the server to transcode the whole file before playback can begin —
+509    /// which manifests as playback never starting.
+510    ///
+511    /// **There is deliberately no start-position parameter.** A playlist covers
+512    /// the whole item and asking for segment N *is* the seek, so a position would
+513    /// be redundant — and actively fatal: Jellyfin builds every segment URI by
+514    /// echoing this playlist's query string into it, while its segment handler
+515    /// rejects `StartTimeTicks > 0` outright (`ArgumentException` → `400`). One
+516    /// resume position here therefore 400s every segment of the stream, which
+517    /// presents as a resumed episode that simply never plays while the same
+518    /// episode from the beginning is fine. Resume by seeking the player once it
+519    /// has loaded. (The progressive `/Audio/universal` builder below has no
+520    /// segments and keeps its `StartTimeTicks`.)
+521    ///
+522    /// The stream is built against the current [`streaming_quality`] ceiling:
+523    /// `MaxStreamingBitrate`/`VideoBitrate`/`AudioBitrate`, plus a `MaxHeight`
+524    /// that suits the budget. `Original` keeps the historical 20/18 Mbps
+525    /// allowance, which is a transcode ceiling rather than a user-facing limit.
+526    ///
+527    /// TRACES: UR-004, UR-074 | DR-140, DR-162, DR-177, DR-181 | UT-130, UT-156, UT-173, UT-182
+528    pub async fn get_video_stream_url(
+529        &self,
+530        item_id: &str,
+531        media_source_id: Option<&str>,
+532        audio_stream_index: Option<i32>,
+533    ) -> Result<String, RepoError> {
+534        let quality = streaming_quality();
+535        // `Original` is uncapped as a *user* setting, but a transcode still needs
+536        // a ceiling to encode against — keep the values this endpoint has always
+537        // used so nothing changes for the default.
+538        let max_bitrate = quality.max_bitrate().unwrap_or(20_000_000);
+539        let video_bitrate = quality.video_bitrate().unwrap_or(18_000_000);
+540
+541        // Claim a distinct transcode identity and retire the one it replaces, so
+542        // the server is never running two jobs for this device at once. Doing it
+543        // here covers every path that re-opens a stream (quality switch,
+544        // transcoded seek, audio-track switch) rather than each remembering to.
+545        let (play_session_id, superseded) = begin_video_play_session();
+546        if let Some(previous) = superseded {
+547            self.stop_transcode(&previous).await;
+548        }
+549
+550        // Build an HLS transcode URL. VideoCodec lists h264 first so the server
+551        // transcodes HEVC/10-bit/unsupported sources to h264 the WebView can decode.
+552        let mut params = vec![
+553            ("api_key", self.access_token.clone()),
+554            ("DeviceId", DEVICE_ID.to_string()),
+555            ("PlaySessionId", play_session_id),
+556            ("VideoCodec", "h264".to_string()),
+557            ("AudioCodec", "aac".to_string()),
+558            ("MaxStreamingBitrate", max_bitrate.to_string()),
+559            ("VideoBitrate", video_bitrate.to_string()),
+560            ("AudioBitrate", quality.audio_bitrate().to_string()),
+561            (
+562                "TranscodingMaxAudioChannels",
+563                super::device_profile::max_audio_channels().to_string(),
+564            ),
+565            ("SegmentContainer", "ts".to_string()),
+566            ("TranscodingContainer", "ts".to_string()),
+567            ("TranscodingProtocol", "hls".to_string()),
+568            // Say "no subtitle" rather than leaving the choice open. An omitted
+569            // index is not neutral: the server then picks the source's own
+570            // default/forced track, and an image-based one can only be delivered
+571            // by burning it into the picture (DR-176). The negotiation already
+572            // sends this sentinel, but most streams are opened by rebuilding
+573            // *this* URL — a quality switch, a transcoded seek, an audio-track
+574            // switch — so it has to hold here too, independently of whatever
+575            // session state the server still holds.
+576            (
+577                "SubtitleStreamIndex",
+578                super::device_profile::playback_subtitle_stream_index().to_string(),
+579            ),
+580        ];
+581
+582        // Scale the picture down to what the budget can carry. Omitted for the
+583        // uncapped steps so the source resolution is preserved.
+584        if let Some(height) = quality.max_height() {
+585            params.push(("MaxHeight", height.to_string()));
+586        }
+587
+588        // Only pin an audio track when the user actually picked one. Jellyfin's
+589        // `MediaStream.Index` is global across *all* streams in a media source, so
+590        // index 0 is the video stream on virtually every file — defaulting to 0
+591        // asks the server to transcode the video stream as the audio track, which
+592        // yields a picture with no sound. Omitting the param lets the server use
+593        // the source's `DefaultAudioStreamIndex`.
+594        if let Some(index) = audio_stream_index {
+595            params.push(("AudioStreamIndex", index.to_string()));
+596        }
+597
+598        if let Some(source_id) = media_source_id {
+599            params.push(("MediaSourceId", source_id.to_string()));
+600        }
+601
+602        // Build query string (values are already safe, no encoding needed)
+603        let query = params
+604            .iter()
+605            .map(|(k, v)| format!("{}={}", k, v))
+606            .collect::<Vec<_>>()
+607            .join("&");
+608
+609        let url = format!(
+610            "{}/Videos/{}/master.m3u8?{}",
+611            self.server_url, item_id, query
+612        );
+613
+614        Ok(url)
+615    }
+616
+617    /// Get an **audio-only** stream URL for a *video* item, for the
+618    /// background-audio handoff (UR-040).
+619    ///
+620    /// TRACES: UR-040 | JA-032, DR-140 | UT-059, UT-130
+621    ///
+622    /// This deliberately targets `/Audio/{id}/universal`, NOT the video stream:
+623    /// the server extracts/transcodes only the item's audio track and streams
+624    /// pure audio bytes — no video frames reach the device, so there is no client
+625    /// video decode while backgrounded. Do NOT "optimize" this to reuse the
+626    /// `/Videos/.../master.m3u8` URL: that would keep the device decoding video,
+627    /// defeating the entire point of the feature.
+628    ///
+629    /// `AudioStreamIndex` carries the user's currently-selected audio track over
+630    /// from the video player; `StartTimeTicks` resumes at the handoff position.
+631    /// `universal` lets the server pick direct-play vs transcode per codec/device.
+632    ///
+633    /// The stream is a **progressive** container (mp3 over plain HTTP), NOT HLS:
+634    /// ExoPlayer plays this natively, whereas an HLS/`ts` transcode on the
+635    /// `/universal` endpoint (no `.m3u8` in the path) fails its progressive
+636    /// loader with `ERROR_CODE_PARSING_CONTAINER_UNSUPPORTED`. mp3 is universally
+637    /// decodable and supports mid-stream `StartTimeTicks`.
+638    pub async fn build_audio_only_stream_url_for_video(
+639        &self,
+640        item_id: &str,
+641        media_source_id: Option<&str>,
+642        start_time_seconds: Option<f64>,
+643        audio_stream_index: Option<i32>,
+644    ) -> Result<String, RepoError> {
+645        let mut params = vec![
+646            ("UserId", self.user_id.clone()),
+647            ("api_key", self.access_token.clone()),
+648            ("DeviceId", DEVICE_ID.to_string()),
+649            // Progressive mp3 over HTTP — ExoPlayer-friendly; no HLS/ts.
+650            ("Container", "mp3".to_string()),
+651            ("AudioCodec", "mp3".to_string()),
+652            ("TranscodingContainer", "mp3".to_string()),
+653            ("TranscodingProtocol", "http".to_string()),
+654            // Audio-only is already far under any video cap, but a user on the
+655            // bottom rungs of the ladder asked for *less traffic*, so take the
+656            // lower of the two rather than always 384 kbps.
+657            // TRACES: UR-074 | DR-162
+658            (
+659                "MaxStreamingBitrate",
+660                streaming_quality().audio_bitrate().min(384_000).to_string(),
+661            ),
+662        ];
+663
+664        // Carry the track over only if one was actually selected — index 0 is the
+665        // video stream, not "the first audio track" (see `get_video_stream_url`).
+666        if let Some(index) = audio_stream_index {
+667            params.push(("AudioStreamIndex", index.to_string()));
+668        }
+669
+670        if let Some(source_id) = media_source_id {
+671            params.push(("MediaSourceId", source_id.to_string()));
+672        }
+673
+674        if let Some(seconds) = start_time_seconds {
+675            let ticks = (seconds * 10_000_000.0) as i64;
+676            params.push(("StartTimeTicks", ticks.to_string()));
+677        }
+678
+679        let query = params
+680            .iter()
+681            .map(|(k, v)| format!("{}={}", k, v))
+682            .collect::<Vec<_>>()
+683            .join("&");
+684
+685        let url = format!("{}/Audio/{}/universal?{}", self.server_url, item_id, query);
+686
+687        Ok(url)
+688    }
+689}
+690
+691// Jellyfin API response types (PascalCase from server)
+692#[derive(Debug, Deserialize)]
+693#[serde(rename_all = "PascalCase")]
+694struct ItemsResponse {
+695    items: Vec<JellyfinItem>,
+696    total_record_count: usize,
+697}
+698
+699/// Jellyfin playlist creation response
+700#[derive(Debug, Deserialize)]
+701#[serde(rename_all = "PascalCase")]
+702struct CreatePlaylistResponse {
+703    id: String,
+704}
+705
+706/// Jellyfin playlist items response — items include PlaylistItemId
+707#[derive(Debug, Deserialize)]
+708#[serde(rename_all = "PascalCase")]
+709#[allow(dead_code)]
+710struct PlaylistItemsResponse {
+711    items: Vec<JellyfinPlaylistItem>,
+712    total_record_count: usize,
+713}
+714
+715/// A playlist item from Jellyfin — wraps a regular item with an entry-scoped ID
+716#[derive(Debug, Deserialize)]
+717#[serde(rename_all = "PascalCase")]
+718struct JellyfinPlaylistItem {
+719    playlist_item_id: String,
+720    #[serde(flatten)]
+721    item: JellyfinItem,
+722}
+723
+724#[derive(Debug, Deserialize)]
+725#[serde(rename_all = "PascalCase")]
+726struct JellyfinItem {
+727    id: String,
+728    name: String,
+729    #[serde(rename = "Type")]
+730    item_type: String,
+731    #[serde(default)]
+732    is_folder: bool,
+733    parent_id: Option<String>,
+734    overview: Option<String>,
+735    genres: Option<Vec<String>>,
+736    production_year: Option<i32>,
+737    premiere_date: Option<String>,
+738    community_rating: Option<f64>,
+739    official_rating: Option<String>,
+740    run_time_ticks: Option<i64>,
+741    image_tags: Option<ImageTags>,
+742    backdrop_image_tags: Option<Vec<String>>,
+743    parent_backdrop_image_tags: Option<Vec<String>>,
+744    album_id: Option<String>,
+745    album: Option<String>,
+746    album_artist: Option<String>,
+747    artists: Option<Vec<String>>,
+748    artist_items: Option<Vec<crate::repository::types::ArtistItem>>,
+749    index_number: Option<i32>,
+750    parent_index_number: Option<i32>,
+751    series_id: Option<String>,
+752    series_name: Option<String>,
+753    season_id: Option<String>,
+754    season_name: Option<String>,
+755    media_streams: Option<Vec<JellyfinMediaStream>>,
+756    media_sources: Option<Vec<JellyfinMediaSource>>,
+757    people: Option<Vec<crate::repository::types::Person>>,
+758    user_data: Option<JellyfinUserData>,
+759}
+760
+761/// Per-user state Jellyfin attaches to an item (favourite, played, resume).
+762///
+763/// Returned on every `/Users/{uid}/Items*` response; we additionally name
+764/// `UserData` in the `Fields=` list so the shape is explicit rather than
+765/// dependent on the server's default field set.
+766///
+767/// `PlaybackPositionTicks` is the server's resume position for the item, and the
+768/// only place it is published — Jellyfin has no per-item "resume position"
+769/// endpoint, so reading `UserData` *is* how a resume point is obtained.
+770///
+771/// TRACES: UR-019, UR-069 | DR-113, JA-013, JA-034 | UT-099
+772#[derive(Debug, Deserialize, Clone)]
+773#[serde(rename_all = "PascalCase")]
+774struct JellyfinUserData {
+775    playback_position_ticks: Option<i64>,
+776    #[serde(rename = "Played")]
+777    is_played: Option<bool>,
+778    is_favorite: Option<bool>,
+779    play_count: Option<i32>,
+780    last_played_date: Option<String>,
+781}
+782
+783impl From<JellyfinUserData> for UserData {
+784    fn from(jf: JellyfinUserData) -> Self {
+785        UserData {
+786            playback_position_ticks: jf.playback_position_ticks,
+787            playback_position_ms: jf.playback_position_ticks.map(crate::domain::ticks_to_ms),
+788            is_played: jf.is_played,
+789            is_favorite: jf.is_favorite,
+790            play_count: jf.play_count,
+791            last_played_date: jf.last_played_date,
+792            playback_context_type: None,
+793            playback_context_id: None,
+794        }
+795    }
+796}
+797
+798/// Build the Jellyfin endpoint for a folder listing.
+799///
+800/// Extracted from `get_items` so the query it produces — in particular the
+801/// favourites filter — can be asserted without standing up an HTTP server.
+802///
+803/// TRACES: UR-007, UR-067 | DR-116 | UT-104
+804fn build_get_items_endpoint(
+805    user_id: &str,
+806    parent_id: &str,
+807    options: Option<&GetItemsOptions>,
+808) -> String {
+809    // Every value below is percent-encoded before it goes into the query
+810    // string, the same way `Genres` and `SearchTerm` already are: these are
+811    // values, not URL syntax, so a space or an `&` in one must not split it
+812    // into another parameter.
+813    //
+814    // TRACES: UR-007 | DR-212 | UT-206
+815    let mut endpoint = format!(
+816        "/Users/{}/Items?ParentId={}",
+817        user_id,
+818        urlencoding::encode(parent_id)
+819    );
+820
+821    if let Some(opts) = options {
+822        if let Some(limit) = opts.limit {
+823            endpoint.push_str(&format!("&Limit={}", limit));
+824        }
+825        if let Some(start_index) = opts.start_index {
+826            endpoint.push_str(&format!("&StartIndex={}", start_index));
+827        }
+828        if let Some(types) = &opts.include_item_types {
+829            // Encode each type, not the joined string: the comma is the
+830            // list separator Jellyfin splits on.
+831            let encoded: Vec<String> = types
+832                .iter()
+833                .map(|t| urlencoding::encode(t).into_owned())
+834                .collect();
+835            endpoint.push_str(&format!("&IncludeItemTypes={}", encoded.join(",")));
+836        }
+837        if let Some(sort_by) = &opts.sort_by {
+838            // SortBy is likewise a comma-delimited list (`hybrid.rs` sends
+839            // "ParentIndexNumber,IndexNumber,SortName"), so encode per field.
+840            let encoded: Vec<String> = sort_by
+841                .split(',')
+842                .map(|field| urlencoding::encode(field).into_owned())
+843                .collect();
+844            endpoint.push_str(&format!("&SortBy={}", encoded.join(",")));
+845        }
+846        if let Some(sort_order) = &opts.sort_order {
+847            endpoint.push_str(&format!("&SortOrder={}", urlencoding::encode(sort_order)));
+848        }
+849        if let Some(recursive) = opts.recursive {
+850            endpoint.push_str(&format!("&Recursive={}", recursive));
+851        }
+852        if let Some(genres) = &opts.genres {
+853            if !genres.is_empty() {
+854                // Genre names may contain spaces/ampersands, so percent-encode each.
+855                let encoded: Vec<String> = genres
+856                    .iter()
+857                    .map(|g| urlencoding::encode(g).into_owned())
+858                    .collect();
+859                endpoint.push_str(&format!("&Genres={}", encoded.join("|")));
+860            }
+861        }
+862        // TRACES: UR-067 | DR-116 | UT-104
+863        if opts.favorites_only == Some(true) {
+864            endpoint.push_str("&Filters=IsFavorite");
+865        }
+866    }
+867
+868    // Request image fields for list views (People only needed in get_item
+869    // detail view). Genres is needed so cached items carry their genres,
+870    // which lets the offline store derive genre lists + per-genre counts.
+871    endpoint
+872        .push_str("&Fields=BackdropImageTags,ParentBackdropImageTags,Genres,PremiereDate,UserData");
+873    endpoint
+874}
+875
+876/// Build the Jellyfin endpoint for a "recently added" listing.
+877///
+878/// `GroupItems=true` is the load-bearing parameter: Jellyfin defaults it to
+879/// `false`, which returns each newly-added *leaf* separately, so importing one
+880/// 14-track album pushed 14 rows into "recently added" and buried everything
+881/// else. With grouping on, the server collapses children into the container
+882/// that was added — an album appears once, while movies (which have no such
+883/// container) are unaffected.
+884///
+885/// Pulled out of `get_latest_items` so the query can be asserted without an
+886/// HTTP server, matching `build_favorites_endpoint`.
+887///
+888/// TRACES: UR-024, UR-034 | IR-024, JA-016
+889fn build_latest_items_endpoint(user_id: &str, parent_id: &str, limit: Option<usize>) -> String {
+890    format!(
+891        "/Users/{}/Items/Latest?ParentId={}&Limit={}&GroupItems=true&Fields=BackdropImageTags,ParentBackdropImageTags,UserData",
+892        user_id,
+893        parent_id,
+894        limit.unwrap_or(16)
+895    )
+896}
+897
+898/// Build the Jellyfin endpoint for a Next Up listing.
+899///
+900/// `EnableResumable=false` is the point of this query: the server default is
+901/// `true`, which makes a partially-watched episode its own series' "next up" —
+902/// the very episode `/Items/Resume` returns — so Continue Watching and Next Up
+903/// end up showing the same cards. Next Up should only ever offer episodes the
+904/// viewer has not started. Servers predating the parameter ignore it, which is
+905/// why the frontend also drops in-progress entries (DR-197).
+906///
+907/// Pulled out of `get_next_up_episodes` so the query can be asserted without an
+908/// HTTP server, matching `build_favorites_endpoint`.
+909///
+910/// TRACES: UR-023, UR-059 | DR-197, JA-014, JA-036 | UT-190, UT-191
+911fn build_next_up_endpoint(user_id: &str, series_id: Option<&str>, limit: Option<usize>) -> String {
+912    let mut endpoint = format!(
+913        "/Shows/NextUp?UserId={}&Limit={}&EnableResumable=false&Fields=BackdropImageTags,ParentBackdropImageTags,UserData",
+914        user_id,
+915        limit.unwrap_or(16)
+916    );
+917
+918    if let Some(sid) = series_id {
+919        endpoint.push_str(&format!("&SeriesId={}", sid));
+920    }
+921
+922    endpoint
+923}
+924
+925/// Build the Jellyfin endpoint for a favourites listing.
+926///
+927/// Pulled out of `get_favorites` so the query can be asserted without an HTTP
+928/// server. `scope` is expanded here — `SearchScope::All` yields `None`, and the
+929/// `IncludeItemTypes` filter is then **omitted entirely** rather than sent as a
+930/// union, which would silently drop every type nobody enumerated (see
+931/// `SearchScope::item_types`).
+932///
+933/// TRACES: UR-067 | DR-115, JA-033 | UT-100
+934fn build_favorites_endpoint(
+935    user_id: &str,
+936    scope: SearchScope,
+937    options: Option<&GetItemsOptions>,
+938) -> String {
+939    let mut endpoint = format!("/Users/{}/Items?Filters=IsFavorite&Recursive=true", user_id);
+940
+941    if let Some(types) = scope.item_types() {
+942        endpoint.push_str(&format!("&IncludeItemTypes={}", types.join(",")));
+943    }
+944
+945    // Jellyfin has no "date favourited", so name order is the only stable sort
+946    // available; callers may still override it.
+947    let sort_by = options
+948        .and_then(|o| o.sort_by.as_deref())
+949        .unwrap_or("SortName");
+950    let sort_order = options
+951        .and_then(|o| o.sort_order.as_deref())
+952        .unwrap_or("Ascending");
+953    endpoint.push_str(&format!("&SortBy={}&SortOrder={}", sort_by, sort_order));
+954
+955    if let Some(limit) = options.and_then(|o| o.limit) {
+956        endpoint.push_str(&format!("&Limit={}", limit));
+957    }
+958    if let Some(start_index) = options.and_then(|o| o.start_index) {
+959        endpoint.push_str(&format!("&StartIndex={}", start_index));
+960    }
+961
+962    endpoint
+963        .push_str("&Fields=BackdropImageTags,ParentBackdropImageTags,Genres,PremiereDate,UserData");
+964    endpoint
+965}
+966
+967// ImageTags from Jellyfin API - can be a HashMap with various image type keys
+968// We use a wrapper to extract just the Primary tag we need
+969#[derive(Debug, Deserialize)]
+970#[serde(untagged)]
+971enum ImageTags {
+972    // Modern format: HashMap
+973    Map(std::collections::HashMap<String, String>),
+974    // Legacy/alternative format: structured
+975    Structured {
+976        #[serde(rename = "Primary")]
+977        primary: Option<String>,
+978    },
+979}
+980
+981impl ImageTags {
+982    fn primary(&self) -> Option<String> {
+983        match self {
+984            ImageTags::Map(map) => map.get("Primary").cloned(),
+985            ImageTags::Structured { primary } => primary.clone(),
+986        }
+987    }
+988}
+989
+990#[derive(Debug, Deserialize, Clone)]
+991#[serde(rename_all = "PascalCase")]
+992struct JellyfinMediaStream {
+993    #[serde(rename = "Type")]
+994    stream_type: String,
+995    codec: Option<String>,
+996    language: Option<String>,
+997    display_title: Option<String>,
+998    index: i32,
+999    is_default: bool,
+1000    #[serde(default)]
+1001    is_forced: bool,
+1002}
+1003
+1004#[derive(Debug, Deserialize, Clone)]
+1005#[serde(rename_all = "PascalCase")]
+1006struct JellyfinMediaSource {
+1007    id: String,
+1008    name: String,
+1009    container: Option<String>,
+1010    size: Option<i64>,
+1011    bitrate: Option<i32>,
+1012    supports_direct_play: bool,
+1013    supports_direct_stream: bool,
+1014    supports_transcoding: bool,
+1015    direct_stream_url: Option<String>,
+1016}
+1017
+1018impl JellyfinItem {
+1019    fn into_media_item(self, server_id: String) -> MediaItem {
+1020        // Extract image tags before consuming self
+1021        let primary_tag = self.image_tags.as_ref().and_then(|tags| tags.primary());
+1022        let backdrop_tags = self.backdrop_image_tags;
+1023
+1024        let kind = crate::domain::kind_from_jellyfin(&self.item_type, self.is_folder);
+1025
+1026        MediaItem {
+1027            id: self.id,
+1028            name: self.name,
+1029            item_type: self.item_type,
+1030            kind,
+1031            is_folder: self.is_folder,
+1032            server_id,
+1033            parent_id: self.parent_id,
+1034            library_id: None, // Not provided by Jellyfin API directly
+1035            overview: self.overview,
+1036            genres: self.genres,
+1037            production_year: self.production_year,
+1038            premiere_date: self.premiere_date,
+1039            community_rating: self.community_rating,
+1040            official_rating: self.official_rating,
+1041            runtime_ticks: self.run_time_ticks,
+1042            duration_ms: self.run_time_ticks.map(crate::domain::ticks_to_ms),
+1043            primary_image_tag: primary_tag.clone(),
+1044            image_id: primary_tag,
+1045            backdrop_image_tags: backdrop_tags,
+1046            parent_backdrop_image_tags: self.parent_backdrop_image_tags,
+1047            album_id: self.album_id,
+1048            album_name: self.album,
+1049            album_artist: self.album_artist,
+1050            artists: self.artists,
+1051            artist_items: self.artist_items,
+1052            index_number: self.index_number,
+1053            parent_index_number: self.parent_index_number,
+1054            series_id: self.series_id,
+1055            series_name: self.series_name,
+1056            season_id: self.season_id,
+1057            season_name: self.season_name,
+1058            // Favourite/played/resume state as the server sees it. TRACES:
+1059            // UR-069 | DR-113, JA-034
+1060            user_data: self.user_data.map(UserData::from),
+1061            media_streams: self.media_streams.map(|streams| {
+1062                streams
+1063                    .into_iter()
+1064                    .map(|s| {
+1065                        let kind = crate::domain::stream_kind_from_jellyfin(&s.stream_type);
+1066                        // Only a subtitle can be a sidecar; asked of anything
+1067                        // else the question has no answer. TRACES: UR-020 |
+1068                        // DR-176 | UT-168
+1069                        let supports_external_delivery =
+1070                            (kind == crate::domain::StreamKind::Subtitle).then(|| {
+1071                                super::device_profile::subtitle_supports_external_delivery(
+1072                                    s.codec.as_deref(),
+1073                                )
+1074                            });
+1075                        crate::repository::types::MediaStream {
+1076                            kind,
+1077                            stream_type: s.stream_type,
+1078                            codec: s.codec,
+1079                            language: s.language,
+1080                            display_title: s.display_title,
+1081                            index: s.index,
+1082                            is_default: s.is_default,
+1083                            is_forced: s.is_forced,
+1084                            supports_external_delivery,
+1085                        }
+1086                    })
+1087                    .collect()
+1088            }),
+1089            media_sources: self.media_sources.map(|sources| {
+1090                sources
+1091                    .into_iter()
+1092                    .map(|s| crate::repository::types::MediaSource {
+1093                        id: s.id,
+1094                        name: s.name,
+1095                        container: s.container,
+1096                        size: s.size,
+1097                        bitrate: s.bitrate,
+1098                        supports_direct_play: s.supports_direct_play,
+1099                        supports_direct_stream: s.supports_direct_stream,
+1100                        supports_transcoding: s.supports_transcoding,
+1101                        direct_stream_url: s.direct_stream_url,
+1102                    })
+1103                    .collect()
+1104            }),
+1105            people: self.people,
+1106        }
+1107    }
+1108}
+1109
+1110#[async_trait]
+1111impl MediaRepository for OnlineRepository {
+1112    async fn get_libraries(&self) -> Result<Vec<Library>, RepoError> {
+1113        #[derive(Debug, Deserialize)]
+1114        #[serde(rename_all = "PascalCase")]
+1115        struct LibrariesResponse {
+1116            items: Vec<JellyfinLibrary>,
+1117        }
+1118
+1119        #[derive(Debug, Deserialize)]
+1120        #[serde(rename_all = "PascalCase")]
+1121        struct JellyfinLibrary {
+1122            id: String,
+1123            name: String,
+1124            collection_type: Option<String>,
+1125            image_tags: Option<ImageTags>,
+1126        }
+1127
+1128        let endpoint = format!("/Users/{}/Views", self.user_id);
+1129        let response: LibrariesResponse = self.get_json(&endpoint).await?;
+1130
+1131        Ok(response
+1132            .items
+1133            .into_iter()
+1134            .map(|lib| {
+1135                Library::new(
+1136                    lib.id,
+1137                    lib.name,
+1138                    lib.collection_type.unwrap_or_else(|| "unknown".to_string()),
+1139                    lib.image_tags.and_then(|tags| tags.primary()),
+1140                )
+1141            })
+1142            .collect())
+1143    }
+1144
+1145    async fn get_items(
+1146        &self,
+1147        parent_id: &str,
+1148        options: Option<GetItemsOptions>,
+1149    ) -> Result<SearchResult, RepoError> {
+1150        let endpoint = build_get_items_endpoint(&self.user_id, parent_id, options.as_ref());
+1151
+1152        let response: ItemsResponse = self.get_json(&endpoint).await?;
+1153
+1154        Ok(SearchResult {
+1155            items: response
+1156                .items
+1157                .into_iter()
+1158                .map(|item| item.into_media_item(self.user_id.clone()))
+1159                .collect(),
+1160            total_record_count: response.total_record_count,
+1161        })
+1162    }
+1163
+1164    /// Fetch one item with every field the detail and player screens need.
+1165    ///
+1166    /// The `Fields=` list is the load-bearing part: Jellyfin omits these unless
+1167    /// they are named. `MediaStreams` is what makes the item's **audio and
+1168    /// subtitle tracks** knowable at all — there is no separate "tracks"
+1169    /// endpoint, so this single call is how the player learns which audio tracks
+1170    /// an item offers (`to_media_item` maps them, and the player's selector
+1171    /// filters them by `kind`). `People` is likewise how **cast and crew** are
+1172    /// obtained.
+1173    ///
+1174    /// TRACES: UR-021, UR-035 | IR-016, IR-022, JA-005, JA-009
+1175    async fn get_item(&self, item_id: &str) -> Result<MediaItem, RepoError> {
+1176        let endpoint = format!("/Users/{}/Items/{}?Fields=BackdropImageTags,ParentBackdropImageTags,People,MediaStreams,MediaSources,PremiereDate,UserData", self.user_id, urlencoding::encode(item_id));
+1177
+1178        let item: JellyfinItem = self.get_json(&endpoint).await?;
+1179        let media_item = item.into_media_item(self.user_id.clone());
+1180
+1181        Ok(media_item)
+1182    }
+1183
+1184    async fn get_latest_items(
+1185        &self,
+1186        parent_id: &str,
+1187        limit: Option<usize>,
+1188    ) -> Result<Vec<MediaItem>, RepoError> {
+1189        let endpoint = build_latest_items_endpoint(&self.user_id, parent_id, limit);
+1190
+1191        let items: Vec<JellyfinItem> = self.get_json(&endpoint).await?;
+1192        Ok(items
+1193            .into_iter()
+1194            .map(|item| item.into_media_item(self.user_id.clone()))
+1195            .collect())
+1196    }
+1197
+1198    /// Continue Watching: the items this user has started and not finished.
+1199    ///
+1200    /// `/Users/{uid}/Items/Resume` is the server-side answer to both "what goes
+1201    /// in the Continue Watching row" and "where was this left off" — each item
+1202    /// carries its own `UserData.PlaybackPositionTicks`, which is why `UserData`
+1203    /// is named in `Fields=` rather than left to the server's default field set.
+1204    ///
+1205    /// TRACES: UR-019, UR-023 | IR-024, JA-013, JA-015
+1206    async fn get_resume_items(
+1207        &self,
+1208        parent_id: Option<&str>,
+1209        limit: Option<usize>,
+1210    ) -> Result<Vec<MediaItem>, RepoError> {
+1211        let limit_str = limit.unwrap_or(16);
+1212        let mut endpoint = format!(
+1213            "/Users/{}/Items/Resume?Limit={}&MediaTypes=Video&Fields=BackdropImageTags,ParentBackdropImageTags,UserData",
+1214            self.user_id, limit_str
+1215        );
+1216
+1217        if let Some(pid) = parent_id {
+1218            endpoint.push_str(&format!("&ParentId={}", pid));
+1219        }
+1220
+1221        let response: ItemsResponse = self.get_json(&endpoint).await?;
+1222        Ok(response
+1223            .items
+1224            .into_iter()
+1225            .map(|item| item.into_media_item(self.user_id.clone()))
+1226            .collect())
+1227    }
+1228
+1229    /// "Next Up": the episode that follows the ones this user has finished,
+1230    /// per series — the Shows-scoped counterpart to Continue Watching.
+1231    ///
+1232    /// TRACES: UR-023, UR-059 | IR-024, JA-014
+1233    async fn get_next_up_episodes(
+1234        &self,
+1235        series_id: Option<&str>,
+1236        limit: Option<usize>,
+1237    ) -> Result<Vec<MediaItem>, RepoError> {
+1238        let endpoint = build_next_up_endpoint(&self.user_id, series_id, limit);
+1239
+1240        let response: ItemsResponse = self.get_json(&endpoint).await?;
+1241        Ok(response
+1242            .items
+1243            .into_iter()
+1244            .map(|item| item.into_media_item(self.user_id.clone()))
+1245            .collect())
+1246    }
+1247
+1248    async fn get_recently_played_audio(
+1249        &self,
+1250        limit: Option<usize>,
+1251    ) -> Result<Vec<MediaItem>, RepoError> {
+1252        let limit_val = limit.unwrap_or(12);
+1253        // Fetch more items to account for grouping reducing the count
+1254        let fetch_limit = limit_val * 3;
+1255        let endpoint = format!(
+1256            "/Users/{}/Items?SortBy=DatePlayed&SortOrder=Descending&IncludeItemTypes=Audio&Limit={}&Recursive=true&Filters=IsPlayed&Fields=BackdropImageTags,ParentBackdropImageTags,UserData",
+1257            self.user_id, fetch_limit
+1258        );
+1259
+1260        let response: ItemsResponse = self.get_json(&endpoint).await?;
+1261        let items: Vec<MediaItem> = response
+1262            .items
+1263            .into_iter()
+1264            .map(|item| item.into_media_item(self.user_id.clone()))
+1265            .collect();
+1266
+1267        debug!("[get_recently_played_audio] Fetched {} items", items.len());
+1268        for item in &items {
+1269            debug!("[get_recently_played_audio] Item: name={}, type={}, album_id={:?}, album_name={:?}",
+1270                item.name, item.item_type, item.album_id, item.album_name);
+1271        }
+1272
+1273        // Group by album - create pseudo-album entries for tracks with same albumId
+1274        use std::collections::BTreeMap;
+1275        let mut album_map: BTreeMap<String, Vec<MediaItem>> = BTreeMap::new();
+1276        let mut ungrouped = Vec::new();
+1277
+1278        for item in items {
+1279            // Use album_id if available, fall back to album_name for grouping
+1280            let group_key = item.album_id.clone().or_else(|| item.album_name.clone());
+1281
+1282            if let Some(key) = group_key {
+1283                debug!(
+1284                    "[get_recently_played_audio] Grouping item '{}' into album '{}'",
+1285                    item.name, key
+1286                );
+1287                album_map.entry(key).or_default().push(item);
+1288            } else {
+1289                debug!(
+1290                    "[get_recently_played_audio] No album_id or album_name for item: '{}'",
+1291                    item.name
+1292                );
+1293                ungrouped.push(item);
+1294            }
+1295        }
+1296
+1297        // Create album entries from grouped tracks
+1298        let mut result: Vec<MediaItem> = album_map
+1299            .into_iter()
+1300            .map(|(album_id, tracks)| {
+1301                let first_track = &tracks[0];
+1302                let most_recent = tracks
+1303                    .iter()
+1304                    .max_by(|a, b| {
+1305                        let date_a = a
+1306                            .user_data
+1307                            .as_ref()
+1308                            .and_then(|ud| ud.last_played_date.as_deref())
+1309                            .unwrap_or("");
+1310                        let date_b = b
+1311                            .user_data
+1312                            .as_ref()
+1313                            .and_then(|ud| ud.last_played_date.as_deref())
+1314                            .unwrap_or("");
+1315                        date_b.cmp(date_a)
+1316                    })
+1317                    .unwrap_or(first_track);
+1318
+1319                MediaItem {
+1320                    id: album_id,
+1321                    name: first_track
+1322                        .album_name
+1323                        .clone()
+1324                        .unwrap_or_else(|| "Unknown Album".to_string()),
+1325                    item_type: "MusicAlbum".to_string(),
+1326                    kind: crate::domain::MediaKind::Album,
+1327                    is_folder: true,
+1328                    server_id: first_track.server_id.clone(),
+1329                    parent_id: None,
+1330                    library_id: None,
+1331                    overview: None,
+1332                    genres: None,
+1333                    production_year: None,
+1334                    premiere_date: None,
+1335                    community_rating: None,
+1336                    official_rating: None,
+1337                    runtime_ticks: None,
+1338                    duration_ms: None,
+1339                    primary_image_tag: first_track.primary_image_tag.clone(),
+1340                    image_id: first_track.primary_image_tag.clone(),
+1341                    backdrop_image_tags: None,
+1342                    parent_backdrop_image_tags: None,
+1343                    album_id: None,
+1344                    album_name: None,
+1345                    album_artist: None,
+1346                    artists: first_track.artists.clone(),
+1347                    artist_items: first_track.artist_items.clone(),
+1348                    index_number: None,
+1349                    parent_index_number: None,
+1350                    series_id: None,
+1351                    series_name: None,
+1352                    season_id: None,
+1353                    season_name: None,
+1354                    user_data: most_recent.user_data.clone(),
+1355                    media_streams: None,
+1356                    media_sources: None,
+1357                    people: None,
+1358                }
+1359            })
+1360            .collect();
+1361
+1362        // Append ungrouped tracks
+1363        result.extend(ungrouped);
+1364
+1365        // Return only the requested limit
+1366        let final_result: Vec<MediaItem> = result.into_iter().take(limit_val).collect();
+1367        debug!(
+1368            "[get_recently_played_audio] Returning {} items after grouping",
+1369            final_result.len()
+1370        );
+1371        for item in &final_result {
+1372            debug!(
+1373                "[get_recently_played_audio] Return: name={}, type={}",
+1374                item.name, item.item_type
+1375            );
+1376        }
+1377        Ok(final_result)
+1378    }
+1379
+1380    async fn get_rediscover_albums(
+1381        &self,
+1382        parent_id: Option<&str>,
+1383        limit: Option<usize>,
+1384    ) -> Result<Vec<MediaItem>, RepoError> {
+1385        let limit_val = limit.unwrap_or(12);
+1386        // Ask Jellyfin for played albums sorted by least-recently played first.
+1387        // Filters=IsPlayed keeps only albums the user has actually listened to,
+1388        // and SortBy=DatePlayed ascending surfaces the ones they've neglected.
+1389        let mut endpoint = format!(
+1390            "/Users/{}/Items?SortBy=DatePlayed&SortOrder=Ascending&IncludeItemTypes=MusicAlbum&Limit={}&Recursive=true&Filters=IsPlayed&Fields=BackdropImageTags,ParentBackdropImageTags,UserData",
+1391            self.user_id, limit_val
+1392        );
+1393
+1394        if let Some(pid) = parent_id {
+1395            endpoint.push_str(&format!("&ParentId={}", pid));
+1396        }
+1397
+1398        let response: ItemsResponse = self.get_json(&endpoint).await?;
+1399        Ok(response
+1400            .items
+1401            .into_iter()
+1402            .map(|item| item.into_media_item(self.user_id.clone()))
+1403            .collect())
+1404    }
+1405
+1406    /// Continue Watching, narrowed to movies — the home screen's movie row and
+1407    /// the movie library's own hero both want the unfinished films without the
+1408    /// episodes mixed in.
+1409    ///
+1410    /// TRACES: UR-019, UR-034 | IR-024, JA-013, JA-015
+1411    async fn get_resume_movies(&self, limit: Option<usize>) -> Result<Vec<MediaItem>, RepoError> {
+1412        let limit_str = limit.unwrap_or(16);
+1413        let endpoint = format!(
+1414            "/Users/{}/Items/Resume?Limit={}&MediaTypes=Video&IncludeItemTypes=Movie&Fields=BackdropImageTags,ParentBackdropImageTags,UserData",
+1415            self.user_id, limit_str
+1416        );
+1417
+1418        let response: ItemsResponse = self.get_json(&endpoint).await?;
+1419        Ok(response
+1420            .items
+1421            .into_iter()
+1422            .map(|item| item.into_media_item(self.user_id.clone()))
+1423            .collect())
+1424    }
+1425
+1426    async fn get_genres(&self, parent_id: Option<&str>) -> Result<Vec<Genre>, RepoError> {
+1427        // Ask Jellyfin to scope counts to albums and include them, so the
+1428        // frontend can rank genres by popularity without probing each one.
+1429        let mut endpoint = format!(
+1430            "/Genres?UserId={}&IncludeItemTypes=MusicAlbum&Recursive=true&Fields=ItemCounts",
+1431            self.user_id
+1432        );
+1433
+1434        if let Some(pid) = parent_id {
+1435            endpoint.push_str(&format!("&ParentId={}", pid));
+1436        }
+1437
+1438        #[derive(Debug, Deserialize)]
+1439        #[serde(rename_all = "PascalCase")]
+1440        struct GenresResponse {
+1441            items: Vec<JellyfinGenre>,
+1442        }
+1443
+1444        #[derive(Debug, Deserialize)]
+1445        #[serde(rename_all = "PascalCase")]
+1446        struct JellyfinGenre {
+1447            id: String,
+1448            name: String,
+1449            // Which count field Jellyfin populates for a genre under
+1450            // Fields=ItemCounts varies by server/version: scoped queries may
+1451            // fill AlbumCount, others only ChildCount. Read whichever is
+1452            // present so ranking still works. Absent on servers that ignore
+1453            // Fields=ItemCounts entirely, so all stay optional.
+1454            album_count: Option<u32>,
+1455            child_count: Option<u32>,
+1456        }
+1457
+1458        let response: GenresResponse = self.get_json(&endpoint).await?;
+1459        let genres: Vec<Genre> = response
+1460            .items
+1461            .into_iter()
+1462            .map(|g| Genre {
+1463                id: g.id,
+1464                name: g.name,
+1465                album_count: g.album_count.or(g.child_count),
+1466            })
+1467            .collect();
+1468
+1469        let with_counts = genres.iter().filter(|g| g.album_count.is_some()).count();
+1470        // TEMP DIAGNOSTIC: dump the first few genres with their counts so we can
+1471        // see whether the server populates any count field. Remove once known.
+1472        log::warn!(
+1473            "get_genres: {} genres, {} carry counts. sample: {:?}",
+1474            genres.len(),
+1475            with_counts,
+1476            genres
+1477                .iter()
+1478                .take(8)
+1479                .map(|g| (g.name.as_str(), g.album_count))
+1480                .collect::<Vec<_>>()
+1481        );
+1482
+1483        Ok(genres)
+1484    }
+1485
+1486    /// Search every library the user can see.
+1487    ///
+1488    /// `Recursive=true` with no `ParentId` is what makes this cross-library
+1489    /// rather than folder-scoped; a caller narrowing the search passes the item
+1490    /// types through `SearchOptions` (already expanded from an opaque
+1491    /// `SearchScope` on this side of the boundary).
+1492    ///
+1493    /// TRACES: UR-008 | IR-010, JA-006
+1494    async fn search(
+1495        &self,
+1496        query: &str,
+1497        options: Option<SearchOptions>,
+1498    ) -> Result<SearchResult, RepoError> {
+1499        let limit = options.as_ref().and_then(|o| o.limit).unwrap_or(50);
+1500        // SearchTerm is arbitrary user input and must be percent-encoded so that
+1501        // spaces, ampersands, etc. don't corrupt the query string (a multi-word
+1502        // search like "Star Wars" would otherwise produce a malformed URL).
+1503        let mut endpoint = format!(
+1504            "/Users/{}/Items?SearchTerm={}&Limit={}&Recursive=true",
+1505            self.user_id,
+1506            urlencoding::encode(query),
+1507            limit
+1508        );
+1509
+1510        if let Some(opts) = options {
+1511            if let Some(types) = opts.include_item_types {
+1512                let encoded_types = types
+1513                    .iter()
+1514                    .map(|t| urlencoding::encode(t).into_owned())
+1515                    .collect::<Vec<_>>()
+1516                    .join(",");
+1517                endpoint.push_str(&format!("&IncludeItemTypes={}", encoded_types));
+1518            }
+1519        }
+1520
+1521        // Request image fields for list views (plus Genres so cached items
+1522        // carry genres for offline genre lists/counts).
+1523        endpoint.push_str(
+1524            "&Fields=BackdropImageTags,ParentBackdropImageTags,Genres,PremiereDate,UserData",
+1525        );
+1526
+1527        let response: ItemsResponse = self.get_json(&endpoint).await?;
+1528        Ok(SearchResult {
+1529            items: response
+1530                .items
+1531                .into_iter()
+1532                .map(|item| item.into_media_item(self.user_id.clone()))
+1533                .collect(),
+1534            total_record_count: response.total_record_count,
+1535        })
+1536    }
+1537
+1538    async fn get_playback_info(&self, item_id: &str) -> Result<PlaybackInfo, RepoError> {
+1539        let endpoint = format!("/Items/{}/PlaybackInfo", urlencoding::encode(item_id));
+1540
+1541        #[derive(Debug, Serialize)]
+1542        #[serde(rename_all = "PascalCase")]
+1543        struct PlaybackInfoRequest {
+1544            user_id: String,
+1545            /// Omitted so the server resolves the source's default audio stream.
+1546            /// Never send 0 here: the index is global across all streams, so 0 is
+1547            /// the video stream and the negotiated source comes back soundless.
+1548            #[serde(skip_serializing_if = "Option::is_none")]
+1549            audio_stream_index: Option<i32>,
+1550            #[serde(skip_serializing_if = "Option::is_none")]
+1551            subtitle_stream_index: Option<i32>,
+1552            start_time_ticks: i64,
+1553            is_playback: bool,
+1554            auto_open_live_stream: bool,
+1555            max_streaming_bitrate: i64,
+1556            #[serde(skip_serializing_if = "Option::is_none")]
+1557            device_profile: Option<DeviceProfile>,
+1558        }
+1559
+1560        #[derive(Debug, Serialize)]
+1561        #[serde(rename_all = "PascalCase")]
+1562        struct DeviceProfile {
+1563            name: String,
+1564            max_streaming_bitrate: i64,
+1565            max_static_bitrate: i64,
+1566            /// Channels the device's audio route can actually voice. Without it
+1567            /// the server may direct-play a 5.1 track to a two-channel sink,
+1568            /// which is silence or inaudible dialogue depending on the device.
+1569            max_audio_channels: String,
+1570            direct_play_profiles: Vec<DirectPlayProfile>,
+1571            transcoding_profiles: Vec<TranscodingProfile>,
+1572            subtitle_profiles: Vec<SubtitleProfile>,
+1573        }
+1574
+1575        #[derive(Debug, Serialize)]
+1576        #[serde(rename_all = "PascalCase")]
+1577        struct DirectPlayProfile {
+1578            #[serde(rename = "Type")]
+1579            profile_type: String,
+1580            container: String,
+1581            #[serde(skip_serializing_if = "Option::is_none")]
+1582            video_codec: Option<String>,
+1583            audio_codec: String,
+1584        }
+1585
+1586        #[derive(Debug, Serialize)]
+1587        #[serde(rename_all = "PascalCase")]
+1588        struct TranscodingProfile {
+1589            #[serde(rename = "Type")]
+1590            profile_type: String,
+1591            context: String,
+1592            protocol: String,
+1593            container: String,
+1594            #[serde(skip_serializing_if = "Option::is_none")]
+1595            video_codec: Option<String>,
+1596            audio_codec: String,
+1597            max_audio_channels: String,
+1598        }
+1599
+1600        #[derive(Debug, Serialize)]
+1601        #[serde(rename_all = "PascalCase")]
+1602        struct SubtitleProfile {
+1603            format: String,
+1604            method: String,
+1605        }
+1606
+1607        #[derive(Debug, Deserialize)]
+1608        #[serde(rename_all = "PascalCase")]
+1609        struct PlaybackInfoResponse {
+1610            media_sources: Vec<MediaSource>,
+1611            play_session_id: String,
+1612        }
+1613
+1614        #[derive(Debug, Deserialize)]
+1615        #[serde(rename_all = "PascalCase")]
+1616        struct MediaSource {
+1617            id: String,
+1618            supports_direct_play: bool,
+1619            supports_transcoding: bool,
+1620            transcoding_url: Option<String>,
+1621            #[serde(default)]
+1622            media_streams: Vec<MediaStream>,
+1623        }
+1624
+1625        #[derive(Debug, Deserialize)]
+1626        #[serde(rename_all = "PascalCase")]
+1627        struct MediaStream {
+1628            #[serde(rename = "Type")]
+1629            stream_type: String,
+1630            #[serde(default)]
+1631            index: i32,
+1632            #[serde(default)]
+1633            codec: Option<String>,
+1634            /// The track the server serves when the client pins none.
+1635            #[serde(default)]
+1636            is_default: bool,
+1637        }
+1638
+1639        // Get detected codecs from Android MediaCodecList or use platform defaults
+1640        #[cfg(target_os = "android")]
+1641        let (video_codecs, audio_codecs) = crate::player::get_detected_codecs()
+1642            .map(|(video, audio, _channels)| (video, audio))
+1643            .unwrap_or_else(|| {
+1644                warn!("[DeviceProfile] Codec detection not complete, using conservative defaults");
+1645                ("h264,hevc".to_string(), "aac,mp3".to_string())
+1646            });
+1647
+1648        // Linux desktop plays video through the WebKitGTK HTML5 <video> element,
+1649        // which cannot reliably decode HEVC/AV1/VP9. Advertise only codecs the
+1650        // WebView can decode so Jellyfin transcodes anything else to h264 HLS.
+1651        // (Audio-only files still direct-play via MPV; these codecs are what
+1652        // both renderers handle, and the audio profile keeps them in full while
+1653        // the video profile is narrowed below.)
+1654        #[cfg(all(not(target_os = "android"), target_os = "linux"))]
+1655        let (video_codecs, audio_codecs) =
+1656            ("h264".to_string(), "aac,mp3,opus,vorbis,flac".to_string());
+1657
+1658        #[cfg(all(not(target_os = "android"), not(target_os = "linux")))]
+1659        let (video_codecs, audio_codecs) = (
+1660            "h264,hevc,vp8,vp9,av1,mpeg4".to_string(),
+1661            "aac,mp3,opus,vorbis,flac".to_string(),
+1662        );
+1663
+1664        // Video plays in a webview <video> element on every platform, which
+1665        // decodes a narrower audio set than the platform does — so the video
+1666        // profile must claim less than the audio-only profile. Without this a
+1667        // Dolby-licensed device advertises eac3, gets a direct play, and shows
+1668        // picture with no sound.
+1669        let video_audio_codecs = super::device_profile::video_audio_codecs(&audio_codecs);
+1670
+1671        info!("[DeviceProfile] Using video codecs: {}", video_codecs);
+1672        info!("[DeviceProfile] Using audio codecs: {}", audio_codecs);
+1673        info!(
+1674            "[DeviceProfile] Audio codecs for video direct play: {}",
+1675            video_audio_codecs
+1676        );
+1677
+1678        // Bound every profile by what the audio route can actually voice, so a
+1679        // multichannel track is downmixed by the server rather than direct-played
+1680        // into a sink that has nowhere to put the extra channels.
+1681        let max_audio_channels = super::device_profile::max_audio_channels().to_string();
+1682        info!("[DeviceProfile] Max audio channels: {}", max_audio_channels);
+1683
+1684        // The user's bandwidth ceiling has to be part of the *negotiation*, not
+1685        // just the transcode URL: `max_static_bitrate` is what makes the server
+1686        // refuse to direct-play a source fatter than the cap, and without it a
+1687        // 30 Mbps remux is handed over untouched and every URL parameter
+1688        // downstream is moot. `Original` keeps the historical "no ceiling"
+1689        // sentinel so the default path negotiates exactly as before.
+1690        //
+1691        // TRACES: UR-074 | DR-162
+1692        let quality = streaming_quality();
+1693        let negotiated_bitrate = quality.max_bitrate().unwrap_or(999_999_999) as i64;
+1694        if let Some(cap) = quality.max_bitrate() {
+1695            info!(
+1696                "[DeviceProfile] Streaming quality cap active: {} ({} bps)",
+1697                quality.label(),
+1698                cap
+1699            );
+1700        }
+1701
+1702        // Create device profile with detected hardware capabilities
+1703        let device_profile = DeviceProfile {
+1704            name: "JellyTau Native Player".to_string(),
+1705            max_streaming_bitrate: negotiated_bitrate,
+1706            max_static_bitrate: negotiated_bitrate,
+1707            max_audio_channels: max_audio_channels.clone(),
+1708            direct_play_profiles: vec![
+1709                DirectPlayProfile {
+1710                    profile_type: "Video".to_string(),
+1711                    container: "mp4,mkv,avi,mov,flv,ts,m2ts,webm,ogv,3gp".to_string(),
+1712                    video_codec: Some(video_codecs.clone()),
+1713                    // The webview decodes this stream, not ExoPlayer/MPV.
+1714                    audio_codec: video_audio_codecs.clone(),
+1715                },
+1716                DirectPlayProfile {
+1717                    profile_type: "Audio".to_string(),
+1718                    container: "mp3,aac,flac,alac,wav,ogg,wma,opus".to_string(),
+1719                    video_codec: None,
+1720                    // Audio-only really is the native player's, so it keeps the
+1721                    // full platform list — narrowing it would transcode music
+1722                    // that plays perfectly well.
+1723                    audio_codec: audio_codecs.clone(),
+1724                },
+1725            ],
+1726            transcoding_profiles: vec![
+1727                TranscodingProfile {
+1728                    profile_type: "Video".to_string(),
+1729                    context: "Streaming".to_string(),
+1730                    protocol: "hls".to_string(),
+1731                    container: "ts".to_string(),
+1732                    video_codec: Some("h264,hevc".to_string()),
+1733                    audio_codec: "aac,mp3".to_string(),
+1734                    max_audio_channels: max_audio_channels.clone(),
+1735                },
+1736                TranscodingProfile {
+1737                    profile_type: "Audio".to_string(),
+1738                    context: "Streaming".to_string(),
+1739                    protocol: "http".to_string(),
+1740                    container: "mp3".to_string(),
+1741                    video_codec: None,
+1742                    audio_codec: "mp3".to_string(),
+1743                    max_audio_channels: max_audio_channels.clone(),
+1744                },
+1745            ],
+1746            subtitle_profiles: super::device_profile::subtitle_profiles()
+1747                .into_iter()
+1748                .map(|(format, method)| SubtitleProfile {
+1749                    format: format.to_string(),
+1750                    method: method.to_string(),
+1751                })
+1752                .collect(),
+1753        };
+1754
+1755        // POST to PlaybackInfo with device profile containing detected codecs
+1756        let request_body = PlaybackInfoRequest {
+1757            user_id: self.user_id.clone(),
+1758            audio_stream_index: None, // Let the server pick the source default
+1759            // Never let the server choose a subtitle track for us. Omitting this
+1760            // makes it honour the source's default/forced flag, and an image-based
+1761            // default (PGS) it cannot send as a sidecar becomes SubtitleMethod=Encode
+1762            // — burn-in, which forces a full video re-encode of a stream that would
+1763            // otherwise be remuxed. The app renders subtitles itself (UR-020).
+1764            //
+1765            // TRACES: UR-020, UR-004 | DR-176 | UT-168
+1766            subtitle_stream_index: Some(super::device_profile::playback_subtitle_stream_index()),
+1767            start_time_ticks: 0,
+1768            is_playback: true,
+1769            auto_open_live_stream: true,
+1770            // The user's cap, or the historical 20 Mbps allowance when uncapped.
+1771            max_streaming_bitrate: quality.max_bitrate().unwrap_or(20_000_000) as i64,
+1772            device_profile: Some(device_profile), // Now sending profile with detected codecs
+1773        };
+1774
+1775        let response: PlaybackInfoResponse =
+1776            self.post_json_response(&endpoint, &request_body).await?;
+1777        let source = response.media_sources.first().ok_or(RepoError::NotFound {
+1778            message: "No media sources available".to_string(),
+1779        })?;
+1780
+1781        // Log available media streams for debugging
+1782        info!(
+1783            "PlaybackInfo MediaSource has {} streams",
+1784            source.media_streams.len()
+1785        );
+1786        for stream in &source.media_streams {
+1787            info!(
+1788                "  Stream type={}, index={}, codec={:?}",
+1789                stream.stream_type, stream.index, stream.codec
+1790            );
+1791        }
+1792
+1793        // Name the tracks we are declining to have the server composite. Burn-in
+1794        // rules out remuxing the video, so a single image-based track can turn a
+1795        // free passthrough into a full re-encode; when that used to happen there
+1796        // was nothing in the log connecting the stall to the subtitle.
+1797        for stream in &source.media_streams {
+1798            if stream.stream_type == "Subtitle" {
+1799                if let Some(codec) = stream.codec.as_deref() {
+1800                    if super::device_profile::subtitle_forces_burn_in(codec) {
+1801                        info!(
+1802                            "  Subtitle index={} ({}) is image-based — not requested; the app renders text tracks itself rather than have the server burn it in (which would force a video re-encode)",
+1803                            stream.index, codec
+1804                        );
+1805                    }
+1806                }
+1807            }
+1808        }
+1809
+1810        // Jellyfin 10.11.5 honours a DirectPlayProfile's container and video codec
+1811        // but ignores its audio codec, so it offers an E-AC-3 track for direct
+1812        // play even though DR-148 advertises only AAC — and the webview renders
+1813        // the picture in silence. Judge the track we would actually be served
+1814        // against what the webview can decode, and override the server's answer.
+1815        let audio_streams: Vec<(Option<&str>, bool)> = source
+1816            .media_streams
+1817            .iter()
+1818            .filter(|stream| stream.stream_type == "Audio")
+1819            .map(|stream| (stream.codec.as_deref(), stream.is_default))
+1820            .collect();
+1821        let audio_forces_transcode = super::device_profile::audio_forces_transcode(&audio_streams);
+1822
+1823        // Use TranscodingUrl from response if available (Streamyfin pattern)
+1824        let stream_url = if let Some(transcoding_url) = &source.transcoding_url {
+1825            // The server started this job and named the session — adopt it, or a
+1826            // later quality switch / seek on this stream has no previous job to
+1827            // stop and ends up contending with the one currently playing.
+1828            if let Some(previous) = adopt_video_play_session(response.play_session_id.clone()) {
+1829                self.stop_transcode(&previous).await;
+1830            }
+1831            // The server built this URL from its *own* subtitle verdict, so it can
+1832            // hand back the burn-in the request above just declined. Strip it: the
+1833            // negotiated answer only holds for the stream we actually open.
+1834            //
+1835            // TRACES: UR-020, UR-004 | DR-176 | UT-168
+1836            format!(
+1837                "{}{}",
+1838                self.server_url,
+1839                super::device_profile::without_server_chosen_subtitle(transcoding_url)
+1840            )
+1841        } else if audio_forces_transcode {
+1842            warn!(
+1843                "[PlaybackInfo] Server offered direct play for audio the webview cannot decode ({:?}) — forcing an HLS transcode",
+1844                audio_streams.first().and_then(|(codec, _)| *codec)
+1845            );
+1846            self.get_video_stream_url(item_id, Some(&source.id), None)
+1847                .await?
+1848        } else {
+1849            // Fall back to direct stream URL. No audioStreamIndex: static=true
+1850            // serves the original file untouched, and pinning index 0 (the video
+1851            // stream) only misleads servers that do honour it.
+1852            format!(
+1853                "{}/Videos/{}/stream?static=true&container=mp4&mediaSourceId={}&deviceId=jellytau&api_key={}&userId={}",
+1854                self.server_url,
+1855                item_id,
+1856                source.id,
+1857                self.access_token,
+1858                self.user_id
+1859            )
+1860        };
+1861
+1862        info!("Final stream URL: {}", stream_url);
+1863
+1864        Ok(PlaybackInfo {
+1865            media_source_id: source.id.clone(),
+1866            play_session_id: response.play_session_id,
+1867            stream_url,
+1868            direct_play: source.supports_direct_play && !audio_forces_transcode,
+1869            needs_transcoding: audio_forces_transcode
+1870                || (!source.supports_direct_play && source.supports_transcoding),
+1871        })
+1872    }
+1873
+1874    async fn get_audio_stream_url(&self, item_id: &str) -> Result<String, RepoError> {
+1875        // Construct direct audio stream URL
+1876        let url = format!(
+1877            "{}/Audio/{}/stream?UserId={}&api_key={}&Static=true",
+1878            self.server_url, item_id, self.user_id, self.access_token
+1879        );
+1880        Ok(url)
+1881    }
+1882
+1883    async fn get_audio_only_stream_url_for_video(
+1884        &self,
+1885        item_id: &str,
+1886        media_source_id: Option<&str>,
+1887        start_time_seconds: Option<f64>,
+1888        audio_stream_index: Option<i32>,
+1889    ) -> Result<String, RepoError> {
+1890        self.build_audio_only_stream_url_for_video(
+1891            item_id,
+1892            media_source_id,
+1893            start_time_seconds,
+1894            audio_stream_index,
+1895        )
+1896        .await
+1897    }
+1898
+1899    async fn get_live_tv_channels(&self) -> Result<Vec<MediaItem>, RepoError> {
+1900        // Live TV channels (broadcast tuners / IPTV M3U). Returned as items with
+1901        // type "TvChannel" — playable via open_live_stream.
+1902        let endpoint = format!(
+1903            "/LiveTv/Channels?UserId={}&Fields=PrimaryImageAspectRatio,Overview&EnableImageTypes=Primary",
+1904            self.user_id
+1905        );
+1906        let response: ItemsResponse = self.get_json(&endpoint).await?;
+1907        Ok(response
+1908            .items
+1909            .into_iter()
+1910            .map(|item| item.into_media_item(self.server_url.clone()))
+1911            .collect())
+1912    }
+1913
+1914    async fn get_channels(&self) -> Result<SearchResult, RepoError> {
+1915        // Root list of plugin "Channels". Drill-down into a channel folder reuses
+1916        // get_items(channel_id, ...).
+1917        let endpoint = format!("/Channels?UserId={}", self.user_id);
+1918        let response: ItemsResponse = self.get_json(&endpoint).await?;
+1919        let total = response.total_record_count;
+1920        let items = response
+1921            .items
+1922            .into_iter()
+1923            .map(|item| item.into_media_item(self.server_url.clone()))
+1924            .collect();
+1925        Ok(SearchResult {
+1926            items,
+1927            total_record_count: total,
+1928        })
+1929    }
+1930
+1931    async fn open_live_stream(&self, item_id: &str) -> Result<LiveStreamInfo, RepoError> {
+1932        // Live channels require a PlaybackInfo call with AutoOpenLiveStream so the
+1933        // server opens the live stream and returns a ready-to-play transcoding URL.
+1934        // We send a minimal request; the server applies its own defaults for live.
+1935        #[derive(Debug, Serialize)]
+1936        #[serde(rename_all = "PascalCase")]
+1937        struct OpenLiveStreamRequest {
+1938            user_id: String,
+1939            #[serde(rename = "AutoOpenLiveStream")]
+1940            auto_open_live_stream: bool,
+1941            is_playback: bool,
+1942            max_streaming_bitrate: u64,
+1943            /// "No subtitle", for the same reason as everywhere else: omitting it
+1944            /// lets the server apply the channel's default track, and broadcast
+1945            /// subtitles are DVB bitmaps — deliverable only by burning them in,
+1946            /// which forces a full re-encode of a stream that is already tight.
+1947            ///
+1948            /// TRACES: UR-020, UR-004 | DR-176 | UT-168
+1949            subtitle_stream_index: i32,
+1950        }
+1951
+1952        #[derive(Debug, Deserialize)]
+1953        #[serde(rename_all = "PascalCase")]
+1954        struct OpenLiveStreamResponse {
+1955            #[serde(default)]
+1956            media_sources: Vec<LiveMediaSource>,
+1957            play_session_id: Option<String>,
+1958        }
+1959
+1960        #[derive(Debug, Deserialize)]
+1961        #[serde(rename_all = "PascalCase")]
+1962        struct LiveMediaSource {
+1963            id: String,
+1964            transcoding_url: Option<String>,
+1965            live_stream_id: Option<String>,
+1966        }
+1967
+1968        let endpoint = format!("/Items/{}/PlaybackInfo", urlencoding::encode(item_id));
+1969        let request = OpenLiveStreamRequest {
+1970            user_id: self.user_id.clone(),
+1971            auto_open_live_stream: true,
+1972            is_playback: true,
+1973            // Live TV is video like any other, so the user's cap applies here
+1974            // too — a channel opened at the source bitrate would walk straight
+1975            // past a limit set for the connection. TRACES: UR-074 | DR-162
+1976            max_streaming_bitrate: streaming_quality().max_bitrate().unwrap_or(20_000_000),
+1977            subtitle_stream_index: super::device_profile::playback_subtitle_stream_index(),
+1978        };
+1979
+1980        let response: OpenLiveStreamResponse = self.post_json_response(&endpoint, &request).await?;
+1981
+1982        let source = response
+1983            .media_sources
+1984            .into_iter()
+1985            .next()
+1986            .ok_or(RepoError::NotFound {
+1987                message: "No live media source returned".to_string(),
+1988            })?;
+1989
+1990        // The transcoding URL is server-relative; make it absolute. If the server
+1991        // did not provide one (rare for live), fall back to the HLS master endpoint.
+1992        let stream_url = match source.transcoding_url {
+1993            // As in `get_playback_info`: the server chose the subtitle in this
+1994            // URL, so decline it here too. TRACES: UR-020 | DR-176 | UT-168
+1995            Some(url) => format!(
+1996                "{}{}",
+1997                self.server_url,
+1998                super::device_profile::without_server_chosen_subtitle(&url)
+1999            ),
+2000            None => format!(
+2001                "{}/Videos/{}/master.m3u8?api_key={}&MediaSourceId={}&LiveStreamId={}&VideoCodec=h264&AudioCodec=aac&TranscodingProtocol=hls&TranscodingContainer=ts&SubtitleStreamIndex={}",
+2002                self.server_url,
+2003                item_id,
+2004                self.access_token,
+2005                source.id,
+2006                source.live_stream_id.clone().unwrap_or_default(),
+2007                super::device_profile::playback_subtitle_stream_index(),
+2008            ),
+2009        };
+2010
+2011        Ok(LiveStreamInfo {
+2012            stream_url,
+2013            play_session_id: response.play_session_id,
+2014            live_stream_id: source.live_stream_id,
+2015            media_source_id: Some(source.id),
+2016        })
+2017    }
+2018
+2019    async fn report_playback_start(
+2020        &self,
+2021        item_id: &str,
+2022        position_ticks: i64,
+2023    ) -> Result<(), RepoError> {
+2024        #[derive(Serialize)]
+2025        #[serde(rename_all = "PascalCase")]
+2026        struct PlaybackStartRequest {
+2027            item_id: String,
+2028            position_ticks: i64,
+2029            play_command: String,
+2030            is_paused: bool,
+2031        }
+2032
+2033        let request = PlaybackStartRequest {
+2034            item_id: item_id.to_string(),
+2035            position_ticks,
+2036            play_command: "PlayNow".to_string(),
+2037            is_paused: false,
+2038        };
+2039
+2040        self.post_json("/Sessions/Playing", &request).await
+2041    }
+2042
+2043    async fn report_playback_progress(
+2044        &self,
+2045        item_id: &str,
+2046        position_ticks: i64,
+2047    ) -> Result<(), RepoError> {
+2048        #[derive(Serialize)]
+2049        #[serde(rename_all = "PascalCase")]
+2050        struct PlaybackProgressRequest {
+2051            item_id: String,
+2052            position_ticks: i64,
+2053            is_paused: bool,
+2054        }
+2055
+2056        let request = PlaybackProgressRequest {
+2057            item_id: item_id.to_string(),
+2058            position_ticks,
+2059            is_paused: false,
+2060        };
+2061
+2062        self.post_json("/Sessions/Playing/Progress", &request).await
+2063    }
+2064
+2065    async fn report_playback_stopped(
+2066        &self,
+2067        item_id: &str,
+2068        position_ticks: i64,
+2069    ) -> Result<(), RepoError> {
+2070        #[derive(Serialize)]
+2071        #[serde(rename_all = "PascalCase")]
+2072        struct PlaybackStoppedRequest {
+2073            item_id: String,
+2074            position_ticks: i64,
+2075        }
+2076
+2077        let request = PlaybackStoppedRequest {
+2078            item_id: item_id.to_string(),
+2079            position_ticks,
+2080        };
+2081
+2082        self.post_json("/Sessions/Playing/Stopped", &request).await
+2083    }
+2084
+2085    fn get_image_url(
+2086        &self,
+2087        item_id: &str,
+2088        image_type: ImageType,
+2089        options: Option<ImageOptions>,
+2090    ) -> String {
+2091        let mut url = format!(
+2092            "{}/Items/{}/Images/{}",
+2093            self.server_url,
+2094            item_id,
+2095            image_type.as_str()
+2096        );
+2097
+2098        // Authentication is handled by X-Emby-Authorization header in download_bytes()
+2099        // Do NOT include api_key here — some Jellyfin servers reject requests when
+2100        // api_key is present but the token doesn't match the expected format.
+2101        let mut params: Vec<String> = Vec::new();
+2102
+2103        if let Some(opts) = options {
+2104            if let Some(width) = opts.max_width {
+2105                params.push(format!("maxWidth={}", width));
+2106            }
+2107            if let Some(height) = opts.max_height {
+2108                params.push(format!("maxHeight={}", height));
+2109            }
+2110            if let Some(quality) = opts.quality {
+2111                params.push(format!("quality={}", quality));
+2112            }
+2113            if let Some(tag) = opts.tag {
+2114                params.push(format!("tag={}", tag));
+2115            }
+2116        }
+2117
+2118        if !params.is_empty() {
+2119            url.push('?');
+2120            url.push_str(&params.join("&"));
+2121        }
+2122
+2123        url
+2124    }
+2125
+2126    fn get_subtitle_url(
+2127        &self,
+2128        item_id: &str,
+2129        media_source_id: &str,
+2130        stream_index: i32,
+2131        format: &str,
+2132    ) -> String {
+2133        format!(
+2134            "{}/Videos/{}/{}/Subtitles/{}/{}",
+2135            self.server_url, item_id, media_source_id, stream_index, format
+2136        )
+2137    }
+2138
+2139    /// TRACES: UR-071 | DR-123
+2140    fn get_video_download_url(
+2141        &self,
+2142        item_id: &str,
+2143        quality: &str,
+2144        media_source_id: Option<&str>,
+2145        source_audio_codec: Option<&str>,
+2146    ) -> String {
+2147        // NOTE: Jellyfin's `/Videos/{id}/download` endpoint is not universally
+2148        // available (returns 404 on many server configs), which silently broke
+2149        // every movie/TV download. Use the progressive `stream.mp4` endpoint
+2150        // instead — it is always present and supports HTTP Range, which the
+2151        // download worker relies on for resume.
+2152        let mut url = format!("{}/Videos/{}/stream.mp4", self.server_url, item_id);
+2153        let mut params = vec![format!("api_key={}", self.access_token)];
+2154
+2155        // Map the frontend quality preset to concrete transcode params. For
+2156        // "original" we request a direct static copy (no transcode) which is
+2157        // byte-range resumable; other presets ask the server to transcode.
+2158        //
+2159        // 🔴 It is `videoBitRate`/`audioBitRate` — **capital R**. Jellyfin binds
+2160        // query keys case-insensitively, so `maxHeight`/`videoCodec` casing is
+2161        // free, but `videoBitrate` (lowercase r) is a *different token*: it
+2162        // fails to bind, is silently dropped, and the requested cap vanishes
+2163        // with no error. That is why every "480p"/"720p" download came back at
+2164        // full original quality. See `Jellyfin.Api` BaseEncodingJobOptions.
+2165        //
+2166        // `allowVideoStreamCopy=false` forces a real re-encode. Without it the
+2167        // server may stream-copy the source when it already satisfies the cap —
+2168        // fine in itself, but it also means a mis-typed cap degrades silently.
+2169        // Note `enableAutoStreamCopy=false` alone does NOT stop a *video* copy;
+2170        // video copy is gated by `allowVideoStreamCopy`.
+2171        match quality {
+2172            "high" => {
+2173                params.push("videoBitRate=8000000".to_string());
+2174                params.push("maxHeight=1080".to_string());
+2175                params.push("audioBitRate=384000".to_string());
+2176                params.push("videoCodec=h264".to_string());
+2177                params.push("audioCodec=aac".to_string());
+2178                params.push("allowVideoStreamCopy=false".to_string());
+2179            }
+2180            "medium" => {
+2181                params.push("videoBitRate=4000000".to_string());
+2182                params.push("maxHeight=720".to_string());
+2183                params.push("audioBitRate=256000".to_string());
+2184                params.push("videoCodec=h264".to_string());
+2185                params.push("audioCodec=aac".to_string());
+2186                params.push("allowVideoStreamCopy=false".to_string());
+2187            }
+2188            "low" => {
+2189                params.push("videoBitRate=1500000".to_string());
+2190                params.push("maxHeight=480".to_string());
+2191                params.push("audioBitRate=128000".to_string());
+2192                params.push("videoCodec=h264".to_string());
+2193                params.push("audioCodec=aac".to_string());
+2194                params.push("allowVideoStreamCopy=false".to_string());
+2195            }
+2196            // "original" (and any unknown value) → direct, resumable copy —
+2197            // unless the audio in that copy is undecodable where the file will
+2198            // be played back. A download is watched with no server in reach, so
+2199            // it has to satisfy the same constraint DR-149 applies to streams:
+2200            // the webview `<video>` element renders video on both platforms and
+2201            // decodes none of AC-3/E-AC-3/DTS/TrueHD. Copying those bytes to
+2202            // disk is what made a downloaded film play offline as picture with
+2203            // no sound while the same film had sound when streamed.
+2204            //
+2205            // Only the *audio* is re-encoded. `allowVideoStreamCopy` keeps an
+2206            // h264 source's picture byte-for-byte, so "original" still means
+2207            // original quality, and no bitrate or resolution cap is added. A
+2208            // source the webview could not have rendered anyway (HEVC) is
+2209            // re-encoded to h264 as a side effect, which is the only form of it
+2210            // that would have played.
+2211            //
+2212            // The cost of the transcode is that the response is no longer
+2213            // range-resumable, which is exactly why this is decided per item
+2214            // rather than applied to every `original` download.
+2215            //
+2216            // TRACES: UR-071, UR-004 | DR-171 | UT-166
+2217            _ => match source_audio_codec {
+2218                Some(codec) if !super::device_profile::webview_can_decode_audio(codec) => {
+2219                    params.push("videoCodec=h264".to_string());
+2220                    params.push("allowVideoStreamCopy=true".to_string());
+2221                    params.push("audioCodec=aac".to_string());
+2222                    params.push("audioBitRate=384000".to_string());
+2223                }
+2224                // Decodable, or unknown: an unknown codec must not provoke a
+2225                // transcode — that would burn server CPU on a guess for files
+2226                // that play perfectly well.
+2227                _ => params.push("Static=true".to_string()),
+2228            },
+2229        }
+2230
+2231        // Add media source ID if provided
+2232        if let Some(source_id) = media_source_id {
+2233            params.push(format!("mediaSourceId={}", source_id));
+2234        }
+2235
+2236        url.push('?');
+2237        url.push_str(&params.join("&"));
+2238
+2239        url
+2240    }
+2241
+2242    async fn mark_favorite(&self, item_id: &str) -> Result<(), RepoError> {
+2243        let endpoint = format!(
+2244            "/Users/{}/FavoriteItems/{}",
+2245            self.user_id,
+2246            urlencoding::encode(item_id)
+2247        );
+2248        self.post_json(&endpoint, &serde_json::json!({})).await
+2249    }
+2250
+2251    /// TRACES: UR-067 | DR-115, JA-033 | UT-100
+2252    async fn get_favorites(
+2253        &self,
+2254        scope: SearchScope,
+2255        options: Option<GetItemsOptions>,
+2256    ) -> Result<SearchResult, RepoError> {
+2257        let endpoint = build_favorites_endpoint(&self.user_id, scope, options.as_ref());
+2258        let response: ItemsResponse = self.get_json(&endpoint).await?;
+2259
+2260        Ok(SearchResult {
+2261            items: response
+2262                .items
+2263                .into_iter()
+2264                .map(|item| item.into_media_item(self.user_id.clone()))
+2265                .collect(),
+2266            total_record_count: response.total_record_count,
+2267        })
+2268    }
+2269
+2270    /// Un-favourite an item: the same `/Users/{uid}/FavoriteItems/{id}` resource
+2271    /// as [`Self::mark_favorite`], removed rather than posted. Written out by
+2272    /// hand rather than through `post_json` because it is the one favourite call
+2273    /// that needs `DELETE`.
+2274    ///
+2275    /// TRACES: UR-017 | JA-018, DR-021
+2276    async fn unmark_favorite(&self, item_id: &str) -> Result<(), RepoError> {
+2277        let endpoint = format!(
+2278            "/Users/{}/FavoriteItems/{}",
+2279            self.user_id,
+2280            urlencoding::encode(item_id)
+2281        );
+2282        let url = format!("{}{}", self.server_url, endpoint);
+2283
+2284        let result = async {
+2285            let request = self
+2286                .http_client
+2287                .client
+2288                .delete(&url)
+2289                .header("X-Emby-Authorization", self.auth_header())
+2290                .build()
+2291                .map_err(|e| RepoError::Network {
+2292                    message: format!("Failed to build request: {}", e),
+2293                })?;
+2294
+2295            let response = self
+2296                .http_client
+2297                .request_with_retry(request)
+2298                .await
+2299                .map_err(|e| RepoError::Network {
+2300                    message: e.to_string(),
+2301                })?;
+2302
+2303            if !response.status().is_success() {
+2304                return Err(RepoError::Server {
+2305                    message: format!("HTTP {}", response.status()),
+2306                });
+2307            }
+2308
+2309            Ok(())
+2310        }
+2311        .await;
+2312
+2313        self.report_outcome(&result).await;
+2314        result
+2315    }
+2316
+2317    /// `DELETE /Users/{userId}/PlayedItems/{itemId}` — Jellyfin's "mark
+2318    /// unplayed", which also zeroes the resume position. On a folder (series,
+2319    /// season) the server applies it recursively to the children.
+2320    ///
+2321    /// TRACES: UR-064 | DR-106, JA-033
+2322    async fn clear_watch_history(&self, item_id: &str) -> Result<(), RepoError> {
+2323        let endpoint = format!(
+2324            "/Users/{}/PlayedItems/{}",
+2325            self.user_id,
+2326            urlencoding::encode(item_id)
+2327        );
+2328        let url = format!("{}{}", self.server_url, endpoint);
+2329
+2330        let result = async {
+2331            let request = self
+2332                .http_client
+2333                .client
+2334                .delete(&url)
+2335                .header("X-Emby-Authorization", self.auth_header())
+2336                .build()
+2337                .map_err(|e| RepoError::Network {
+2338                    message: format!("Failed to build request: {}", e),
+2339                })?;
+2340
+2341            let response = self
+2342                .http_client
+2343                .request_with_retry(request)
+2344                .await
+2345                .map_err(|e| RepoError::Network {
+2346                    message: e.to_string(),
+2347                })?;
+2348
+2349            if !response.status().is_success() {
+2350                return Err(RepoError::Server {
+2351                    message: format!("HTTP {}", response.status()),
+2352                });
+2353            }
+2354
+2355            Ok(())
+2356        }
+2357        .await;
+2358
+2359        self.report_outcome(&result).await;
+2360        result
+2361    }
+2362
+2363    /// `POST /Users/{userId}/PlayedItems/{itemId}` — the mirror image of
+2364    /// `clear_watch_history`.
+2365    ///
+2366    /// TRACES: UR-025 | DR-131 | JA-035
+2367    async fn mark_played(&self, item_id: &str) -> Result<(), RepoError> {
+2368        let endpoint = format!(
+2369            "/Users/{}/PlayedItems/{}",
+2370            self.user_id,
+2371            urlencoding::encode(item_id)
+2372        );
+2373        let url = format!("{}{}", self.server_url, endpoint);
+2374
+2375        let result = async {
+2376            let request = self
+2377                .http_client
+2378                .client
+2379                .post(&url)
+2380                .header("X-Emby-Authorization", self.auth_header())
+2381                .header("Content-Length", "0")
+2382                .build()
+2383                .map_err(|e| RepoError::Network {
+2384                    message: format!("Failed to build request: {}", e),
+2385                })?;
+2386
+2387            let response = self
+2388                .http_client
+2389                .request_with_retry(request)
+2390                .await
+2391                .map_err(|e| RepoError::Network {
+2392                    message: e.to_string(),
+2393                })?;
+2394
+2395            if !response.status().is_success() {
+2396                return Err(RepoError::Server {
+2397                    message: format!("HTTP {}", response.status()),
+2398                });
+2399            }
+2400
+2401            Ok(())
+2402        }
+2403        .await;
+2404
+2405        self.report_outcome(&result).await;
+2406        result
+2407    }
+2408
+2409    /// A single Person item (actor, director, …) by id.
+2410    ///
+2411    /// Jellyfin models people as ordinary items, so this is the plain item
+2412    /// endpoint rather than anything under `/Persons`; the cast entries returned
+2413    /// on an item's `People` field carry the ids this is called with.
+2414    ///
+2415    /// TRACES: UR-035, UR-036 | IR-022, JA-030
+2416    async fn get_person(&self, person_id: &str) -> Result<MediaItem, RepoError> {
+2417        let endpoint = format!(
+2418            "/Users/{}/Items/{}",
+2419            self.user_id,
+2420            urlencoding::encode(person_id)
+2421        );
+2422        let item: JellyfinItem = self.get_json(&endpoint).await?;
+2423        Ok(item.into_media_item(self.user_id.clone()))
+2424    }
+2425
+2426    /// A person's filmography — every item they are credited on.
+2427    ///
+2428    /// TRACES: UR-036 | IR-022, JA-031
+2429    async fn get_items_by_person(
+2430        &self,
+2431        person_id: &str,
+2432        options: Option<GetItemsOptions>,
+2433    ) -> Result<SearchResult, RepoError> {
+2434        let limit = options.as_ref().and_then(|o| o.limit).unwrap_or(100);
+2435
+2436        let mut endpoint = format!(
+2437            "/Users/{}/Items?PersonIds={}&Limit={}&Recursive=true&Fields=BackdropImageTags,ParentBackdropImageTags,UserData",
+2438            self.user_id, person_id, limit
+2439        );
+2440
+2441        // Add item type filtering if specified in options
+2442        if let Some(ref opts) = options {
+2443            if let Some(ref include_types) = opts.include_item_types {
+2444                if !include_types.is_empty() {
+2445                    let types_param = include_types.join(",");
+2446                    endpoint.push_str(&format!("&IncludeItemTypes={}", types_param));
+2447                }
+2448            }
+2449        }
+2450
+2451        let response: ItemsResponse = self.get_json(&endpoint).await?;
+2452        Ok(SearchResult {
+2453            items: response
+2454                .items
+2455                .into_iter()
+2456                .map(|item| item.into_media_item(self.user_id.clone()))
+2457                .collect(),
+2458            total_record_count: response.total_record_count,
+2459        })
+2460    }
+2461
+2462    async fn get_similar_items(
+2463        &self,
+2464        item_id: &str,
+2465        limit: Option<usize>,
+2466    ) -> Result<SearchResult, RepoError> {
+2467        let limit_str = limit.unwrap_or(20);
+2468
+2469        // Try the /Similar endpoint which works for most items
+2470        let endpoint = format!(
+2471            "/Items/{}/Similar?UserId={}&Limit={}&Fields=BackdropImageTags,ParentBackdropImageTags,UserData",
+2472            item_id, self.user_id, limit_str
+2473        );
+2474
+2475        let response: ItemsResponse = self.get_json(&endpoint).await?;
+2476        Ok(SearchResult {
+2477            items: response
+2478                .items
+2479                .into_iter()
+2480                .map(|item| item.into_media_item(self.user_id.clone()))
+2481                .collect(),
+2482            total_record_count: response.total_record_count,
+2483        })
+2484    }
+2485
+2486    // ===== Playlist Methods =====
+2487
+2488    async fn create_playlist(
+2489        &self,
+2490        name: &str,
+2491        item_ids: &[String],
+2492    ) -> Result<PlaylistCreatedResult, RepoError> {
+2493        info!(
+2494            "[OnlineRepo] Creating playlist '{}' with {} items",
+2495            name,
+2496            item_ids.len()
+2497        );
+2498        let body = serde_json::json!({
+2499            "Name": name,
+2500            "Ids": item_ids,
+2501            "MediaType": "Audio",
+2502            "UserId": self.user_id,
+2503        });
+2504        let response: CreatePlaylistResponse = self.post_json_response("/Playlists", &body).await?;
+2505        Ok(PlaylistCreatedResult { id: response.id })
+2506    }
+2507
+2508    async fn delete_playlist(&self, playlist_id: &str) -> Result<(), RepoError> {
+2509        info!("[OnlineRepo] Deleting playlist {}", playlist_id);
+2510        let endpoint = format!("/Items/{}", urlencoding::encode(playlist_id));
+2511        let url = format!("{}{}", self.server_url, endpoint);
+2512
+2513        let request = self
+2514            .http_client
+2515            .client
+2516            .delete(&url)
+2517            .header("X-Emby-Authorization", self.auth_header())
+2518            .build()
+2519            .map_err(|e| RepoError::Network {
+2520                message: format!("Failed to build request: {}", e),
+2521            })?;
+2522
+2523        let response = self
+2524            .http_client
+2525            .request_with_retry(request)
+2526            .await
+2527            .map_err(|e| RepoError::Network {
+2528                message: e.to_string(),
+2529            })?;
+2530
+2531        if !response.status().is_success() {
+2532            return Err(RepoError::Server {
+2533                message: format!("HTTP {}", response.status()),
+2534            });
+2535        }
+2536
+2537        Ok(())
+2538    }
+2539
+2540    async fn rename_playlist(&self, playlist_id: &str, name: &str) -> Result<(), RepoError> {
+2541        info!(
+2542            "[OnlineRepo] Renaming playlist {} to '{}'",
+2543            playlist_id, name
+2544        );
+2545        let endpoint = format!("/Items/{}", urlencoding::encode(playlist_id));
+2546        self.post_json(&endpoint, &serde_json::json!({ "Name": name }))
+2547            .await
+2548    }
+2549
+2550    async fn get_playlist_items(&self, playlist_id: &str) -> Result<Vec<PlaylistEntry>, RepoError> {
+2551        let endpoint = format!(
+2552            "/Playlists/{}/Items?UserId={}&Fields=PrimaryImageTag,Artists,AlbumId,Album,AlbumArtist,RunTimeTicks,ArtistItems&StartIndex=0&Limit=10000",
+2553            playlist_id, self.user_id
+2554        );
+2555
+2556        let response: PlaylistItemsResponse = self.get_json(&endpoint).await?;
+2557        debug!(
+2558            "[OnlineRepo] Got {} playlist items for {}",
+2559            response.items.len(),
+2560            playlist_id
+2561        );
+2562
+2563        Ok(response
+2564            .items
+2565            .into_iter()
+2566            .map(|pi| PlaylistEntry {
+2567                playlist_item_id: pi.playlist_item_id,
+2568                item: pi.item.into_media_item(self.user_id.clone()),
+2569            })
+2570            .collect())
+2571    }
+2572
+2573    async fn add_to_playlist(
+2574        &self,
+2575        playlist_id: &str,
+2576        item_ids: &[String],
+2577    ) -> Result<(), RepoError> {
+2578        info!(
+2579            "[OnlineRepo] Adding {} items to playlist {}",
+2580            item_ids.len(),
+2581            playlist_id
+2582        );
+2583        // Encode each id, not the joined string: the comma separates the list.
+2584        let ids_param = item_ids
+2585            .iter()
+2586            .map(|id| urlencoding::encode(id).into_owned())
+2587            .collect::<Vec<_>>()
+2588            .join(",");
+2589        let endpoint = format!(
+2590            "/Playlists/{}/Items?Ids={}",
+2591            urlencoding::encode(playlist_id),
+2592            ids_param
+2593        );
+2594        self.post_json(&endpoint, &serde_json::json!({})).await
+2595    }
+2596
+2597    async fn remove_from_playlist(
+2598        &self,
+2599        playlist_id: &str,
+2600        entry_ids: &[String],
+2601    ) -> Result<(), RepoError> {
+2602        info!(
+2603            "[OnlineRepo] Removing {} entries from playlist {}",
+2604            entry_ids.len(),
+2605            playlist_id
+2606        );
+2607        let ids_param = entry_ids
+2608            .iter()
+2609            .map(|id| urlencoding::encode(id).into_owned())
+2610            .collect::<Vec<_>>()
+2611            .join(",");
+2612        let endpoint = format!(
+2613            "/Playlists/{}/Items?EntryIds={}",
+2614            urlencoding::encode(playlist_id),
+2615            ids_param
+2616        );
+2617        let url = format!("{}{}", self.server_url, endpoint);
+2618
+2619        let request = self
+2620            .http_client
+2621            .client
+2622            .delete(&url)
+2623            .header("X-Emby-Authorization", self.auth_header())
+2624            .build()
+2625            .map_err(|e| RepoError::Network {
+2626                message: format!("Failed to build request: {}", e),
+2627            })?;
+2628
+2629        let response = self
+2630            .http_client
+2631            .request_with_retry(request)
+2632            .await
+2633            .map_err(|e| RepoError::Network {
+2634                message: e.to_string(),
+2635            })?;
+2636
+2637        if !response.status().is_success() {
+2638            return Err(RepoError::Server {
+2639                message: format!("HTTP {}", response.status()),
+2640            });
+2641        }
+2642
+2643        Ok(())
+2644    }
+2645
+2646    async fn move_playlist_item(
+2647        &self,
+2648        playlist_id: &str,
+2649        item_id: &str,
+2650        new_index: u32,
+2651    ) -> Result<(), RepoError> {
+2652        info!(
+2653            "[OnlineRepo] Moving item {} in playlist {} to index {}",
+2654            item_id, playlist_id, new_index
+2655        );
+2656        let endpoint = format!(
+2657            "/Playlists/{}/Items/{}/Move/{}",
+2658            playlist_id, item_id, new_index
+2659        );
+2660        self.post_json(&endpoint, &serde_json::json!({})).await
+2661    }
+2662}
+2663
+2664#[cfg(test)]
+2665mod tests {
+2666    use super::*;
+2667    use crate::utils::lock::MutexSafe;
+2668    use std::sync::Arc;
+2669
+2670    fn create_test_repository() -> OnlineRepository {
+2671        let http_config = crate::jellyfin::HttpConfig::default();
+2672        let http_client =
+2673            Arc::new(HttpClient::new(http_config).expect("Failed to create HTTP client for test"));
+2674        OnlineRepository::new(
+2675            http_client,
+2676            "https://test.server.com".to_string(),
+2677            "test-user-id".to_string(),
+2678            "test-access-token".to_string(),
+2679        )
+2680    }
+2681
+2682    /// Build a repository wired to a real ConnectivityReporter so we can assert
+2683    /// how `report_outcome` classifies each `RepoError` into reachability.
+2684    /// (No app handle → event emission is a harmless no-op.)
+2685    fn create_test_repository_with_connectivity(
+2686    ) -> (OnlineRepository, crate::connectivity::ConnectivityReporter) {
+2687        let monitor_http = HttpClient::new(crate::jellyfin::HttpConfig::default())
+2688            .expect("Failed to create HTTP client for monitor");
+2689        let monitor = crate::connectivity::ConnectivityMonitor::new(monitor_http);
+2690        let reporter = monitor.reporter();
+2691        let repo = create_test_repository().with_connectivity(reporter.clone());
+2692        (repo, reporter)
+2693    }
+2694
+2695    /// `report_outcome` is the seam between repository traffic and the
+2696    /// connectivity monitor. Verify each `RepoError` variant routes correctly:
+2697    /// - the server answering at all (Ok / 401 / 404 / 5xx) ⇒ reachable
+2698    /// - a network-level failure ⇒ marked unreachable (debounce reduced for test)
+2699    /// - local-side errors (Database / Offline) ⇒ no effect on reachability
+2700    ///
+2701    /// @req-test: UR-002 - Access media when online or offline
+2702    /// @req-test: DR-013 - Repository pattern for online/offline data access
+2703    #[tokio::test]
+2704    async fn test_report_outcome_classifies_server_answered_as_reachable() {
+2705        let (repo, reporter) = create_test_repository_with_connectivity();
+2706
+2707        // Drive offline first so we can observe "recover to reachable".
+2708        for err in [
+2709            RepoError::Authentication {
+2710                message: "401".into(),
+2711            },
+2712            RepoError::NotFound {
+2713                message: "404".into(),
+2714            },
+2715            RepoError::Server {
+2716                message: "500".into(),
+2717            },
+2718        ] {
+2719            reporter.mark_unreachable_for_test().await;
+2720            assert!(!reporter.is_reachable().await, "precondition: offline");
+2721
+2722            let result: Result<(), RepoError> = Err(err);
+2723            repo.report_outcome(&result).await;
+2724
+2725            assert!(
+2726                reporter.is_reachable().await,
+2727                "a server that answers should be reported reachable"
+2728            );
+2729        }
+2730
+2731        // Ok should also report reachable.
+2732        reporter.mark_unreachable_for_test().await;
+2733        let ok: Result<(), RepoError> = Ok(());
+2734        repo.report_outcome(&ok).await;
+2735        assert!(reporter.is_reachable().await, "Ok ⇒ reachable");
+2736    }
+2737
+2738    /// Local-side errors must NOT flip reachability — they say nothing about the
+2739    /// server.
+2740    #[tokio::test]
+2741    async fn test_report_outcome_ignores_local_errors() {
+2742        let (repo, reporter) = create_test_repository_with_connectivity();
+2743
+2744        // Force offline, then a Database/Offline error must leave it offline
+2745        // (not falsely report reachable).
+2746        reporter.mark_unreachable_for_test().await;
+2747        for err in [
+2748            RepoError::Database {
+2749                message: "cache".into(),
+2750            },
+2751            RepoError::Offline,
+2752        ] {
+2753            let result: Result<(), RepoError> = Err(err);
+2754            repo.report_outcome(&result).await;
+2755            assert!(
+2756                !reporter.is_reachable().await,
+2757                "local-side error must not change reachability"
+2758            );
+2759        }
+2760    }
+2761
+2762    /// When connectivity is known-offline, `get_json` must fast-fail with
+2763    /// `RepoError::Offline` instead of running the full HTTP retry cycle (~7s).
+2764    /// This is what keeps offline browsing snappy. `test.server.com` is
+2765    /// unroutable, so if the guard were absent this would hang on retries; the
+2766    /// assertion returning promptly with `Offline` proves the short-circuit.
+2767    #[tokio::test]
+2768    async fn test_get_json_fast_fails_when_offline() {
+2769        let (repo, reporter) = create_test_repository_with_connectivity();
+2770        reporter.mark_unreachable_for_test().await;
+2771        assert!(!reporter.is_reachable().await, "precondition: offline");
+2772
+2773        let result: Result<serde_json::Value, RepoError> = repo.get_json("/System/Info").await;
+2774        assert!(
+2775            matches!(result, Err(RepoError::Offline)),
+2776            "known-offline get_json should return Offline immediately, got {:?}",
+2777            result
+2778        );
+2779    }
+2780
+2781    /// A network error routes through the debounced path. A single failure stays
+2782    /// online (debounce window not yet elapsed).
+2783    #[tokio::test]
+2784    async fn test_report_outcome_network_error_is_debounced() {
+2785        let (repo, reporter) = create_test_repository_with_connectivity();
+2786        assert!(reporter.is_reachable().await, "starts online");
+2787
+2788        let result: Result<(), RepoError> = Err(RepoError::Network {
+2789            message: "timeout".into(),
+2790        });
+2791        repo.report_outcome(&result).await;
+2792
+2793        assert!(
+2794            reporter.is_reachable().await,
+2795            "a single network failure stays online (debounced)"
+2796        );
+2797    }
+2798
+2799    #[tokio::test]
+2800    async fn test_get_audio_stream_url_formats_correctly() {
+2801        let repo = create_test_repository();
+2802        let item_id = "test-track-123";
+2803
+2804        let result = repo.get_audio_stream_url(item_id).await;
+2805
+2806        assert!(result.is_ok());
+2807        let url = result.unwrap();
+2808        assert_eq!(
+2809            url,
+2810            "https://test.server.com/Audio/test-track-123/stream?UserId=test-user-id&api_key=test-access-token&Static=true"
+2811        );
+2812    }
+2813
+2814    /// Serialises every test whose expectations depend on the process-wide
+2815    /// streaming ceiling, and restores the uncapped default afterwards — without
+2816    /// it, a capped test running concurrently changes what an uncapped one sees.
+2817    ///
+2818    /// TRACES: UR-074 | DR-162
+2819    static QUALITY_LOCK: std::sync::Mutex<()> = std::sync::Mutex::new(());
+2820
+2821    struct QualityFixture(#[allow(dead_code)] std::sync::MutexGuard<'static, ()>);
+2822
+2823    impl QualityFixture {
+2824        fn set(quality: StreamingQuality) -> Self {
+2825            let guard = QUALITY_LOCK.lock_safe();
+2826            set_streaming_quality(quality);
+2827            Self(guard)
+2828        }
+2829    }
+2830
+2831    impl Drop for QualityFixture {
+2832        fn drop(&mut self) {
+2833            set_streaming_quality(StreamingQuality::Original);
+2834        }
+2835    }
+2836
+2837    /// A cap has to reach the transcode URL as all four of its parts: the total
+2838    /// ceiling, the split between video and audio, and the resolution the budget
+2839    /// can carry. Capping only `MaxStreamingBitrate` would leave the server
+2840    /// encoding 1080p into 2 Mbps.
+2841    ///
+2842    /// TRACES: UR-074 | DR-162 | UT-156
+2843    #[tokio::test]
+2844    async fn test_video_stream_url_applies_bitrate_cap() {
+2845        let _fixture = QualityFixture::set(StreamingQuality::Mbps2);
+2846        let repo = create_test_repository();
+2847
+2848        let url = repo
+2849            .get_video_stream_url("vid-1", None, None)
+2850            .await
+2851            .unwrap();
+2852
+2853        assert!(url.contains("MaxStreamingBitrate=2000000"), "url: {url}");
+2854        // 2 Mbps total less the 192 kbps audio share — the two must not sum to
+2855        // more than the cap the user asked for.
+2856        assert!(url.contains("VideoBitrate=1808000"), "url: {url}");
+2857        assert!(url.contains("AudioBitrate=192000"), "url: {url}");
+2858        assert!(url.contains("MaxHeight=720"), "url: {url}");
+2859    }
+2860
+2861    /// The uncapped default must keep the exact transcode allowance this
+2862    /// endpoint has always used, and must not start constraining resolution.
+2863    ///
+2864    /// TRACES: UR-074 | DR-162 | UT-156
+2865    #[tokio::test]
+2866    async fn test_video_stream_url_uncapped_keeps_legacy_allowance() {
+2867        let _fixture = QualityFixture::set(StreamingQuality::Original);
+2868        let repo = create_test_repository();
+2869
+2870        let url = repo
+2871            .get_video_stream_url("vid-1", None, None)
+2872            .await
+2873            .unwrap();
+2874
+2875        assert!(url.contains("MaxStreamingBitrate=20000000"), "url: {url}");
+2876        assert!(url.contains("VideoBitrate=18000000"), "url: {url}");
+2877        assert!(url.contains("AudioBitrate=384000"), "url: {url}");
+2878        assert!(
+2879            !url.contains("MaxHeight"),
+2880            "uncapped must not scale the picture down: {url}"
+2881        );
+2882    }
+2883
+2884    /// The background-audio handoff is already cheap, but someone who capped the
+2885    /// connection at 720 kbps asked for less traffic than its fixed 384 kbps.
+2886    ///
+2887    /// TRACES: UR-040, UR-074 | DR-162 | UT-156
+2888    #[tokio::test]
+2889    async fn test_audio_only_stream_url_takes_the_lower_of_cap_and_default() {
+2890        {
+2891            let _fixture = QualityFixture::set(StreamingQuality::Kbps720);
+2892            let repo = create_test_repository();
+2893            let url = repo
+2894                .get_audio_only_stream_url_for_video("vid-1", None, None, None)
+2895                .await
+2896                .unwrap();
+2897            assert!(url.contains("MaxStreamingBitrate=96000"), "url: {url}");
+2898        }
+2899
+2900        let _fixture = QualityFixture::set(StreamingQuality::Original);
+2901        let repo = create_test_repository();
+2902        let url = repo
+2903            .get_audio_only_stream_url_for_video("vid-1", None, None, None)
+2904            .await
+2905            .unwrap();
+2906        assert!(url.contains("MaxStreamingBitrate=384000"), "url: {url}");
+2907    }
+2908
+2909    /// Transcoded video must be an HLS master playlist, not a progressive
+2910    /// `stream.mp4`: a progressive transcode of an HEVC source makes the server
+2911    /// convert the whole file before serving a byte, which presents as playback
+2912    /// that never starts. The chosen source and audio track ride along with it.
+2913    ///
+2914    /// This is the surviving half of the old
+2915    /// `test_get_video_stream_url_returns_hls_with_position`, whose other half
+2916    /// asserted the `StartTimeTicks` that DR-181 removed — the position now
+2917    /// belongs to a seek after load, never to this URL, so the assertion for it
+2918    /// is gone rather than inverted (its inverse is UT-182's own test).
+2919    ///
+2920    /// TRACES: UR-004 | DR-140, DR-181 | UT-130
+2921    #[tokio::test]
+2922    async fn test_get_video_stream_url_returns_an_hls_master_playlist() {
+2923        let _fixture = QualityFixture::set(StreamingQuality::Original);
+2924        let repo = create_test_repository();
+2925
+2926        let url = repo
+2927            .get_video_stream_url("vid-1", Some("source-1"), Some(1))
+2928            .await
+2929            .unwrap();
+2930
+2931        assert!(
+2932            url.starts_with("https://test.server.com/Videos/vid-1/master.m3u8?"),
+2933            "expected HLS master playlist, got: {url}"
+2934        );
+2935        assert!(url.contains("VideoCodec=h264"));
+2936        assert!(url.contains("MediaSourceId=source-1"));
+2937        assert!(url.contains("AudioStreamIndex=1"));
+2938        assert!(!url.contains("stream.mp4"));
+2939    }
+2940
+2941    /// Resuming a transcoded video played nothing at all: every segment came back
+2942    /// `400`, hls.js exhausted its retries and gave up. Starting the same episode
+2943    /// from the beginning was fine.
+2944    ///
+2945    /// Jellyfin builds each segment URI by echoing the *master playlist's* query
+2946    /// string into it (`CreateMainPlaylistRequest(… Request.QueryString …)`), and
+2947    /// its segment handler opens with
+2948    ///
+2949    /// ```csharp
+2950    /// if ((streamingRequest.StartTimeTicks ?? 0) > 0)
+2951    ///     throw new ArgumentException("StartTimeTicks is not allowed.");
+2952    /// ```
+2953    ///
+2954    /// so a resume position put on the playlist is copied onto every
+2955    /// `hls1/main/N.ts` and makes all of them 400. `> 0` is exactly why playing
+2956    /// from the beginning survived.
+2957    ///
+2958    /// HLS does not need the parameter: the playlist spans the whole item, and
+2959    /// asking for segment N *is* the seek — the server transcodes from there. So
+2960    /// the position never belongs in this URL; the player seeks after load. The
+2961    /// sibling progressive `/Audio/universal` builder is a different endpoint with
+2962    /// no segments, and keeps its `StartTimeTicks`.
+2963    ///
+2964    /// TRACES: UR-004, UR-074 | DR-181 | UT-182
+2965    #[tokio::test]
+2966    async fn test_video_stream_url_never_carries_start_time_ticks() {
+2967        let _fixture = QualityFixture::set(StreamingQuality::Original);
+2968        let repo = create_test_repository();
+2969
+2970        let url = repo
+2971            .get_video_stream_url("vid-1", Some("source-1"), Some(1))
+2972            .await
+2973            .unwrap();
+2974
+2975        assert!(
+2976            !url.contains("StartTimeTicks"),
+2977            "an HLS playlist must never carry StartTimeTicks — the server copies it \
+2978             onto every segment URI and then rejects each one with 400: {url}"
+2979        );
+2980    }
+2981
+2982    #[tokio::test]
+2983    async fn test_get_video_stream_url_omits_position_when_absent() {
+2984        let _fixture = QualityFixture::set(StreamingQuality::Original);
+2985        let repo = create_test_repository();
+2986
+2987        let url = repo
+2988            .get_video_stream_url("vid-1", None, None)
+2989            .await
+2990            .unwrap();
+2991
+2992        assert!(url.starts_with("https://test.server.com/Videos/vid-1/master.m3u8?"));
+2993        assert!(!url.contains("StartTimeTicks"));
+2994        assert!(!url.contains("MediaSourceId"));
+2995        // With no track chosen, the param must be OMITTED so the server picks the
+2996        // source's DefaultAudioStreamIndex. `MediaStream.Index` is global across
+2997        // all streams of a source, so index 0 is the *video* stream on virtually
+2998        // every file — sending it asks for a "audio track" that has no audio.
+2999        assert!(
+3000            !url.contains("AudioStreamIndex"),
+3001            "must not pin an audio index when none was chosen: {url}"
+3002        );
+3003    }
+3004
+3005    /// Jellyfin keys a transcode job by device *and* play session. Every stream
+3006    /// this app opened used the same `DeviceId` and no `PlaySessionId`, so
+3007    /// re-opening the same item — what a mid-playback quality switch, a
+3008    /// transcoded seek and an audio-track switch all do — handed the server a
+3009    /// second job it could not tell apart from the one still running. Observed
+3010    /// on-device: the new playlist is served, then `hls1/main/0.ts` 400s
+3011    /// intermittently while the two jobs fight over the same transcode path, and
+3012    /// playback stalls.
+3013    ///
+3014    /// TRACES: UR-074 | DR-177 | UT-173
+3015    #[tokio::test]
+3016    async fn test_video_stream_url_carries_a_play_session_id() {
+3017        let _fixture = QualityFixture::set(StreamingQuality::Original);
+3018        let repo = create_test_repository();
+3019
+3020        let url = repo
+3021            .get_video_stream_url("vid-1", None, None)
+3022            .await
+3023            .unwrap();
+3024
+3025        assert!(
+3026            url.contains("PlaySessionId="),
+3027            "every transcode must be openable as its own job: {url}"
+3028        );
+3029    }
+3030
+3031    /// Naming no subtitle stream is not the same as asking for none. The server
+3032    /// fills the gap with the source's own default/forced track, and an
+3033    /// image-based one (PGS/DVD/DVB) can only be delivered by painting it into
+3034    /// the picture — the burn-in of DR-176, arriving through the URL rather than
+3035    /// through the negotiation.
+3036    ///
+3037    /// The negotiation already sends the sentinel, but it is not what opens most
+3038    /// streams: a quality switch, a transcoded seek and an audio-track switch all
+3039    /// build this URL again, on their own. Saying it here too makes "no subtitle"
+3040    /// a property of the request instead of something inherited from whatever
+3041    /// session state the server happens to still hold.
+3042    ///
+3043    /// TRACES: UR-020, UR-004 | DR-176 | UT-168
+3044    #[tokio::test]
+3045    async fn test_video_stream_url_asks_for_no_subtitle_stream() {
+3046        let _fixture = QualityFixture::set(StreamingQuality::Original);
+3047        let repo = create_test_repository();
+3048
+3049        let url = repo
+3050            .get_video_stream_url("vid-1", Some("source-1"), Some(1))
+3051            .await
+3052            .unwrap();
+3053
+3054        assert!(
+3055            url.contains("SubtitleStreamIndex=-1"),
+3056            "the stream URL must ask for no subtitle, not leave the choice open: {url}"
+3057        );
+3058    }
+3059
+3060    /// The picker must not offer a subtitle the app cannot draw. Image-based
+3061    /// tracks are bitmaps: the only way to show one is to have the server
+3062    /// composite it, which is exactly what DR-176 stopped asking for. Selecting
+3063    /// one was therefore a control that could not do anything — so the verdict
+3064    /// travels with the stream, decided here where the codec vocabulary lives.
+3065    ///
+3066    /// TRACES: UR-020 | DR-176 | UT-168
+3067    #[test]
+3068    fn test_media_streams_carry_whether_the_app_can_render_them() {
+3069        let item: JellyfinItem = serde_json::from_value(serde_json::json!({
+3070            "Id": "ep-1",
+3071            "Name": "Partings",
+3072            "Type": "Episode",
+3073            "MediaStreams": [
+3074                { "Type": "Video", "Index": 0, "Codec": "hevc", "IsDefault": true },
+3075                { "Type": "Audio", "Index": 1, "Codec": "eac3", "IsDefault": true },
+3076                { "Type": "Subtitle", "Index": 2, "Codec": "PGSSUB", "IsDefault": true },
+3077                { "Type": "Subtitle", "Index": 3, "Codec": "subrip", "IsDefault": false },
+3078                { "Type": "Subtitle", "Index": 4, "Codec": null, "IsDefault": false },
+3079            ],
+3080        }))
+3081        .expect("fixture must deserialize");
+3082
+3083        let streams = item.into_media_item("server-1".to_string()).media_streams;
+3084        let streams = streams.expect("the item carries streams");
+3085        let deliverable = |index: i32| {
+3086            streams
+3087                .iter()
+3088                .find(|s| s.index == index)
+3089                .unwrap_or_else(|| panic!("stream {index} missing"))
+3090                .supports_external_delivery
+3091        };
+3092
+3093        // The bitmap track the server would have had to burn in.
+3094        assert_eq!(deliverable(2), Some(false));
+3095        // Text: fetched as WebVTT and drawn by the app itself.
+3096        assert_eq!(deliverable(3), Some(true));
+3097        // A subtitle whose format the server did not name could be anything;
+3098        // offering it risks a dead control, so it is not offered.
+3099        assert_eq!(deliverable(4), Some(false));
+3100        // Meaningless for anything that is not a subtitle — and said as `None`
+3101        // rather than as a `false` a reader could mistake for a verdict.
+3102        assert_eq!(deliverable(0), None);
+3103        assert_eq!(deliverable(1), None);
+3104    }
+3105
+3106    /// The session id is what makes two opens *distinguishable*, so a fresh one
+3107    /// per open is the whole point — and the open must report the id it replaced
+3108    /// so the caller can stop that job instead of leaving it running.
+3109    ///
+3110    /// TRACES: UR-074 | DR-177 | UT-173
+3111    #[test]
+3112    fn test_each_stream_open_gets_a_fresh_session_and_reports_the_previous() {
+3113        let _lock = QUALITY_LOCK.lock_safe();
+3114
+3115        let (first, _) = begin_video_play_session();
+3116        let (second, replaced) = begin_video_play_session();
+3117
+3118        assert_ne!(first, second, "each open needs its own job identity");
+3119        assert_eq!(
+3120            replaced,
+3121            Some(first),
+3122            "the open must hand back the job it superseded so it can be stopped"
+3123        );
+3124
+3125        // A server-started transcode (PlaybackInfo answered with a TranscodingUrl)
+3126        // has to become the current session too — otherwise the first switch on
+3127        // that stream stops nothing and collides with what is playing.
+3128        let replaced_by_adoption = adopt_video_play_session("server-named-session".to_string());
+3129        assert_eq!(replaced_by_adoption, Some(second));
+3130
+3131        let (_, after_adoption) = begin_video_play_session();
+3132        assert_eq!(
+3133            after_adoption,
+3134            Some("server-named-session".to_string()),
+3135            "the adopted job must be the one the next open stops"
+3136        );
+3137    }
+3138
+3139    #[tokio::test]
+3140    async fn test_get_audio_only_stream_url_for_video_carries_track_and_position() {
+3141        // TRACES: UR-040 | JA-032 | UT-059
+3142        // Background-audio handoff must request an audio-only stream (no video
+3143        // decode) that resumes at the current position and keeps the selected
+3144        // audio track.
+3145        let repo = create_test_repository();
+3146
+3147        let url = repo
+3148            .get_audio_only_stream_url_for_video("vid-1", Some("source-1"), Some(193.0), Some(2))
+3149            .await
+3150            .unwrap();
+3151
+3152        assert!(
+3153            url.starts_with("https://test.server.com/Audio/vid-1/universal?"),
+3154            "expected audio-only universal endpoint, got: {url}"
+3155        );
+3156        // Must NOT be a video stream (no client video decode in background).
+3157        assert!(
+3158            !url.contains("/Videos/"),
+3159            "url must not hit the video endpoint: {url}"
+3160        );
+3161        assert!(
+3162            !url.contains("master.m3u8"),
+3163            "url must not be a video HLS playlist: {url}"
+3164        );
+3165        assert!(url.contains("AudioStreamIndex=2"));
+3166        assert!(url.contains("MediaSourceId=source-1"));
+3167        // 193.0 seconds * 10_000_000 ticks/sec
+3168        assert!(url.contains("StartTimeTicks=1930000000"), "url: {url}");
+3169        // Progressive mp3 over HTTP — NOT HLS/ts, or ExoPlayer's progressive
+3170        // loader fails with ERROR_CODE_PARSING_CONTAINER_UNSUPPORTED.
+3171        assert!(url.contains("TranscodingProtocol=http"), "url: {url}");
+3172        assert!(url.contains("TranscodingContainer=mp3"), "url: {url}");
+3173        assert!(
+3174            !url.contains("TranscodingProtocol=hls"),
+3175            "url must not be HLS: {url}"
+3176        );
+3177        assert!(!url.contains("Container=ts"), "url must not be ts: {url}");
+3178    }
+3179
+3180    #[tokio::test]
+3181    async fn test_get_audio_only_stream_url_for_video_omits_position_when_absent() {
+3182        // TRACES: UR-040 | JA-032 | UT-059
+3183        let repo = create_test_repository();
+3184
+3185        let url = repo
+3186            .get_audio_only_stream_url_for_video("vid-1", None, None, None)
+3187            .await
+3188            .unwrap();
+3189
+3190        assert!(url.starts_with("https://test.server.com/Audio/vid-1/universal?"));
+3191        assert!(!url.contains("StartTimeTicks"));
+3192        assert!(!url.contains("MediaSourceId"));
+3193        // Same as the video path: omit rather than pin index 0 (the video stream),
+3194        // and let the server fall back to the source's default audio stream.
+3195        assert!(
+3196            !url.contains("AudioStreamIndex"),
+3197            "must not pin an audio index when none was chosen: {url}"
+3198        );
+3199    }
+3200
+3201    #[tokio::test]
+3202    async fn test_get_audio_stream_url_with_special_characters() {
+3203        let repo = create_test_repository();
+3204        let item_id = "track-with-special-chars-!@#";
+3205
+3206        let result = repo.get_audio_stream_url(item_id).await;
+3207
+3208        assert!(result.is_ok());
+3209        let url = result.unwrap();
+3210        assert!(url.contains("track-with-special-chars-!@#"));
+3211        assert!(url.starts_with("https://test.server.com/Audio/"));
+3212    }
+3213
+3214    #[test]
+3215    fn test_image_tags_deserialize_hashmap_format() {
+3216        // Test modern HashMap format with Primary tag
+3217        let json = r#"{"Primary":"abc123","Banner":"def456","Backdrop":"ghi789"}"#;
+3218        let result: Result<ImageTags, _> = serde_json::from_str(json);
+3219
+3220        assert!(result.is_ok());
+3221        let tags = result.unwrap();
+3222        assert_eq!(tags.primary(), Some("abc123".to_string()));
+3223    }
+3224
+3225    #[test]
+3226    fn test_image_tags_deserialize_structured_format() {
+3227        // Test legacy structured format with Primary field
+3228        let json = r#"{"Primary":"xyz789"}"#;
+3229        let result: Result<ImageTags, _> = serde_json::from_str(json);
+3230
+3231        assert!(result.is_ok());
+3232        let tags = result.unwrap();
+3233        assert_eq!(tags.primary(), Some("xyz789".to_string()));
+3234    }
+3235
+3236    #[test]
+3237    fn test_image_tags_deserialize_missing_primary() {
+3238        // Test HashMap without Primary tag
+3239        let json = r#"{"Banner":"def456","Backdrop":"ghi789"}"#;
+3240        let result: Result<ImageTags, _> = serde_json::from_str(json);
+3241
+3242        assert!(result.is_ok());
+3243        let tags = result.unwrap();
+3244        assert_eq!(tags.primary(), None);
+3245    }
+3246
+3247    #[test]
+3248    fn test_image_tags_deserialize_empty_map() {
+3249        // Test empty HashMap
+3250        let json = r#"{}"#;
+3251        let result: Result<ImageTags, _> = serde_json::from_str(json);
+3252
+3253        assert!(result.is_ok());
+3254        let tags = result.unwrap();
+3255        assert_eq!(tags.primary(), None);
+3256    }
+3257
+3258    // ===== Video download URL (real impl) =====
+3259    //
+3260    // These exercise the PRODUCTION `OnlineRepository::get_video_download_url`,
+3261    // not a mock. A prior mock in online_integration_test.rs used the correct
+3262    // `stream.mp4` endpoint while the real impl shipped `/Videos/{id}/download`,
+3263    // which returns 404 on real servers and silently broke every movie/TV
+3264    // download. Assert the real builder targets the resumable stream endpoint.
+3265    //
+3266    // @req-test: DR-013 - Repository pattern for online/offline data access
+3267
+3268    #[test]
+3269    fn test_video_download_url_uses_stream_not_download_endpoint() {
+3270        let repo = create_test_repository();
+3271        let url = repo.get_video_download_url("item123", "original", None, None);
+3272
+3273        // Must NOT use the /download endpoint (404 on real servers).
+3274        assert!(
+3275            !url.contains("/download"),
+3276            "download URL must not use the broken /Videos/{{id}}/download endpoint: {url}"
+3277        );
+3278        // Must use the progressive, range-resumable stream endpoint.
+3279        assert!(
+3280            url.contains("/Videos/item123/stream.mp4"),
+3281            "download URL must target /Videos/{{id}}/stream.mp4: {url}"
+3282        );
+3283        assert!(url.contains("api_key=test-access-token"), "url: {url}");
+3284    }
+3285
+3286    #[test]
+3287    fn test_video_download_url_original_is_static_direct_copy() {
+3288        let repo = create_test_repository();
+3289        let url = repo.get_video_download_url("item123", "original", None, None);
+3290
+3291        // "original" must request a direct static copy (byte-range resumable),
+3292        // with no transcode params.
+3293        assert!(url.contains("Static=true"), "url: {url}");
+3294        assert!(
+3295            !url.contains("videoBitRate"),
+3296            "original must not transcode: {url}"
+3297        );
+3298        assert!(
+3299            !url.contains("maxHeight"),
+3300            "original must not transcode: {url}"
+3301        );
+3302    }
+3303
+3304    #[test]
+3305    fn test_video_download_url_quality_presets_transcode() {
+3306        let repo = create_test_repository();
+3307
+3308        for (quality, height) in [("high", "1080"), ("medium", "720"), ("low", "480")] {
+3309            let url = repo.get_video_download_url("item123", quality, None, None);
+3310            assert!(
+3311                url.contains("/Videos/item123/stream.mp4"),
+3312                "{quality} must use stream.mp4: {url}"
+3313            );
+3314            assert!(
+3315                url.contains("videoBitRate="),
+3316                "{quality} must set bitrate: {url}"
+3317            );
+3318            assert!(
+3319                url.contains(&format!("maxHeight={height}")),
+3320                "{quality} must cap height at {height}: {url}"
+3321            );
+3322            assert!(url.contains("videoCodec=h264"), "{quality}: {url}");
+3323            // Transcoded presets must not also ask for a static copy.
+3324            assert!(
+3325                !url.contains("Static=true"),
+3326                "{quality} must not be Static: {url}"
+3327            );
+3328        }
+3329    }
+3330
+3331    /// The bitrate params are spelled `videoBitRate`/`audioBitRate` — **capital
+3332    /// R**. Jellyfin binds query keys case-insensitively, so this is not a
+3333    /// casing preference: `videoBitrate` is a *different token* that fails to
+3334    /// bind and is silently discarded, taking the user's quality cap with it.
+3335    /// Nothing errors — the download just returns the full-size original, which
+3336    /// is exactly how this bug went unnoticed.
+3337    #[test]
+3338    fn test_video_download_url_bitrate_params_use_capital_r_spelling() {
+3339        let repo = create_test_repository();
+3340
+3341        for quality in ["high", "medium", "low"] {
+3342            let url = repo.get_video_download_url("item123", quality, None, None);
+3343
+3344            assert!(
+3345                url.contains("videoBitRate="),
+3346                "{quality} must spell it videoBitRate (capital R): {url}"
+3347            );
+3348            assert!(
+3349                url.contains("audioBitRate="),
+3350                "{quality} must spell it audioBitRate (capital R): {url}"
+3351            );
+3352
+3353            // The lowercase-r spellings never bind — they must not appear at
+3354            // all, or the cap is silently dropped by the server.
+3355            assert!(
+3356                !url.contains("videoBitrate="),
+3357                "{quality} emits the unbindable lowercase-r spelling: {url}"
+3358            );
+3359            assert!(
+3360                !url.contains("audioBitrate="),
+3361                "{quality} emits the unbindable lowercase-r spelling: {url}"
+3362            );
+3363        }
+3364    }
+3365
+3366    /// A correctly-spelled cap is still only *conditionally* honored: the server
+3367    /// may stream-copy the source when it already satisfies the cap. Video copy
+3368    /// is gated by `allowVideoStreamCopy` (NOT `enableAutoStreamCopy`, which
+3369    /// only governs audio), so the transcode presets must disable it to
+3370    /// guarantee a real re-encode at the requested bitrate.
+3371    #[test]
+3372    fn test_video_download_url_transcode_presets_forbid_video_stream_copy() {
+3373        let repo = create_test_repository();
+3374
+3375        for quality in ["high", "medium", "low"] {
+3376            let url = repo.get_video_download_url("item123", quality, None, None);
+3377            assert!(
+3378                url.contains("allowVideoStreamCopy=false"),
+3379                "{quality} must forbid video stream copy: {url}"
+3380            );
+3381        }
+3382
+3383        // "original" is a deliberate direct copy — it must NOT disable copying.
+3384        let original = repo.get_video_download_url("item123", "original", None, None);
+3385        assert!(
+3386            !original.contains("allowVideoStreamCopy=false"),
+3387            "original must remain a direct copy: {original}"
+3388        );
+3389    }
+3390
+3391    /// A downloaded file is played with no server in reach, so `original`
+3392    /// quality cannot mean "copy whatever the source holds" when the source
+3393    /// holds audio this device cannot decode.
+3394    ///
+3395    /// `Static=true` hands back the source bytes untouched, E-AC-3/AC-3/DTS
+3396    /// track included, and video plays through the webview `<video>` element on
+3397    /// both platforms — which decodes none of them. Streaming already knows this
+3398    /// (DR-149 forces a transcode over the server's own direct-play offer); the
+3399    /// download path did not, so a downloaded film played offline as picture with
+3400    /// no sound while the very same film had sound when streamed.
+3401    ///
+3402    /// TRACES: UR-071, UR-004 | DR-171 | UT-166
+3403    #[test]
+3404    fn test_video_download_url_original_transcodes_undecodable_audio() {
+3405        let repo = create_test_repository();
+3406
+3407        for codec in ["eac3", "ac3", "dts", "truehd", "EAC3"] {
+3408            let url = repo.get_video_download_url("item123", "original", None, Some(codec));
+3409            assert!(
+3410                !url.contains("Static=true"),
+3411                "{codec} cannot be decoded here, so the source must not be copied verbatim: {url}"
+3412            );
+3413            assert!(
+3414                url.contains("audioCodec=aac"),
+3415                "{codec} must be re-encoded to aac on the way down: {url}"
+3416            );
+3417            // "Original" still has to mean original picture: the video stream is
+3418            // copied when it can be, so no bitrate or resolution cap appears.
+3419            assert!(
+3420                url.contains("allowVideoStreamCopy=true"),
+3421                "the video stream must still be copied where possible: {url}"
+3422            );
+3423            assert!(
+3424                !url.contains("videoBitRate") && !url.contains("maxHeight"),
+3425                "original must not degrade the picture to fix the audio: {url}"
+3426            );
+3427        }
+3428    }
+3429
+3430    /// The converse, and the reason the policy is per-item rather than blanket:
+3431    /// audio that plays here keeps the byte-exact, range-resumable copy that the
+3432    /// download worker's resume depends on.
+3433    ///
+3434    /// TRACES: UR-071 | DR-171 | UT-166
+3435    #[test]
+3436    fn test_video_download_url_original_keeps_static_copy_for_playable_audio() {
+3437        let repo = create_test_repository();
+3438
+3439        for codec in ["aac", "mp3", "opus", "vorbis", "flac", "AAC"] {
+3440            let url = repo.get_video_download_url("item123", "original", None, Some(codec));
+3441            assert!(
+3442                url.contains("Static=true"),
+3443                "{codec} plays here — the download must stay a direct copy: {url}"
+3444            );
+3445            assert!(
+3446                !url.contains("audioCodec="),
+3447                "{codec} needs no transcode: {url}"
+3448            );
+3449        }
+3450
+3451        // Unknown codec: the policy only ever *adds* a transcode, so an item we
+3452        // could not look up behaves exactly as it did before.
+3453        let unknown = repo.get_video_download_url("item123", "original", None, None);
+3454        assert!(unknown.contains("Static=true"), "url: {unknown}");
+3455    }
+3456
+3457    /// The explicit quality presets already transcode audio to AAC, so the
+3458    /// policy has nothing to add — and must not start overriding a chosen cap.
+3459    ///
+3460    /// TRACES: UR-071 | DR-171 | UT-166
+3461    #[test]
+3462    fn test_video_download_url_presets_ignore_the_audio_policy() {
+3463        let repo = create_test_repository();
+3464
+3465        for quality in ["high", "medium", "low"] {
+3466            let with = repo.get_video_download_url("item123", quality, None, Some("eac3"));
+3467            let without = repo.get_video_download_url("item123", quality, None, None);
+3468            assert_eq!(with, without, "{quality} must not vary with source audio");
+3469            assert!(with.contains("audioCodec=aac"), "url: {with}");
+3470        }
+3471    }
+3472
+3473    #[test]
+3474    fn test_video_download_url_passes_media_source_id() {
+3475        let repo = create_test_repository();
+3476        let url = repo.get_video_download_url("item123", "original", Some("src-42"), None);
+3477        assert!(url.contains("mediaSourceId=src-42"), "url: {url}");
+3478    }
+3479
+3480    #[test]
+3481    fn test_jellyfin_item_deserialize_with_image_tags() {
+3482        // Test full JellyfinItem deserialization with ImageTags
+3483        let json = r#"{
+3484            "Id": "album123",
+3485            "Name": "Test Album",
+3486            "Type": "MusicAlbum",
+3487            "ImageTags": {"Primary": "tag123"},
+3488            "ArtistItems": [
+3489                {"Id": "artist1", "Name": "Artist One"},
+3490                {"Id": "artist2", "Name": "Artist Two"}
+3491            ]
+3492        }"#;
+3493
+3494        let result: Result<JellyfinItem, _> = serde_json::from_str(json);
+3495        assert!(result.is_ok());
+3496
+3497        let item = result.unwrap();
+3498        assert_eq!(item.id, "album123");
+3499        assert_eq!(item.name, "Test Album");
+3500        assert_eq!(item.item_type, "MusicAlbum");
+3501        assert!(item.image_tags.is_some());
+3502        assert_eq!(
+3503            item.image_tags.unwrap().primary(),
+3504            Some("tag123".to_string())
+3505        );
+3506    }
+3507
+3508    /// UT-100 — the favourites endpoint asks the server for favourites, scoped.
+3509    ///
+3510    /// TRACES: UR-067 | DR-115, JA-033 | UT-100
+3511    #[test]
+3512    fn test_build_favorites_endpoint_scopes_and_filters() {
+3513        let movies = build_favorites_endpoint("u1", SearchScope::Movies, None);
+3514        assert!(movies.starts_with("/Users/u1/Items?Filters=IsFavorite&Recursive=true"));
+3515        assert!(movies.contains("&IncludeItemTypes=Movie"));
+3516        // Jellyfin has no favourite timestamp, so name order is the default.
+3517        assert!(movies.contains("&SortBy=SortName&SortOrder=Ascending"));
+3518        // Hearts must render on the returned cards.
+3519        assert!(movies.contains("UserData"));
+3520
+3521        // Tv covers both the show and any individually favourited episode.
+3522        let tv = build_favorites_endpoint("u1", SearchScope::Tv, None);
+3523        assert!(tv.contains("&IncludeItemTypes=Series,Episode"));
+3524
+3525        let music = build_favorites_endpoint("u1", SearchScope::Music, None);
+3526        assert!(music.contains("&IncludeItemTypes=MusicAlbum,MusicArtist,Audio,Playlist"));
+3527    }
+3528
+3529    /// `All` must omit the type filter entirely rather than send a union, which
+3530    /// would silently drop every type nobody enumerated.
+3531    ///
+3532    /// TRACES: UR-067 | DR-115 | UT-100
+3533    #[test]
+3534    fn test_build_favorites_endpoint_all_scope_omits_type_filter() {
+3535        let all = build_favorites_endpoint("u1", SearchScope::All, None);
+3536        assert!(!all.contains("IncludeItemTypes"));
+3537    }
+3538
+3539    /// Paging and an explicit sort still reach the server.
+3540    ///
+3541    /// TRACES: UR-067 | DR-115 | UT-100
+3542    #[test]
+3543    fn test_build_favorites_endpoint_honours_paging_and_sort() {
+3544        let endpoint = build_favorites_endpoint(
+3545            "u1",
+3546            SearchScope::All,
+3547            Some(&GetItemsOptions {
+3548                limit: Some(20),
+3549                start_index: Some(40),
+3550                sort_by: Some("Random".to_string()),
+3551                sort_order: Some("Descending".to_string()),
+3552                ..Default::default()
+3553            }),
+3554        );
+3555        assert!(endpoint.contains("&Limit=20"));
+3556        assert!(endpoint.contains("&StartIndex=40"));
+3557        assert!(endpoint.contains("&SortBy=Random&SortOrder=Descending"));
+3558    }
+3559
+3560    /// UT-104 — the in-library favourites toggle reaches the server as
+3561    /// `Filters=IsFavorite`, and is absent unless asked for.
+3562    ///
+3563    /// TRACES: UR-067 | DR-116 | UT-104
+3564    #[test]
+3565    fn test_get_items_endpoint_applies_favorites_only() {
+3566        let plain = build_get_items_endpoint("u1", "lib-1", None);
+3567        assert!(!plain.contains("Filters=IsFavorite"));
+3568
+3569        let filtered = build_get_items_endpoint(
+3570            "u1",
+3571            "lib-1",
+3572            Some(&GetItemsOptions {
+3573                favorites_only: Some(true),
+3574                include_item_types: Some(vec!["Movie".to_string()]),
+3575                ..Default::default()
+3576            }),
+3577        );
+3578        assert!(filtered.contains("&Filters=IsFavorite"));
+3579        // Composes with the filters already there rather than replacing them.
+3580        assert!(filtered.contains("&IncludeItemTypes=Movie"));
+3581        assert!(filtered.contains("ParentId=lib-1"));
+3582
+3583        // Explicitly false is not a request to filter.
+3584        let off = build_get_items_endpoint(
+3585            "u1",
+3586            "lib-1",
+3587            Some(&GetItemsOptions {
+3588                favorites_only: Some(false),
+3589                ..Default::default()
+3590            }),
+3591        );
+3592        assert!(!off.contains("Filters=IsFavorite"));
+3593    }
+3594
+3595    /// UT-206 — the values this endpoint builder puts in the query string are
+3596    /// percent-encoded, like `Genres` and `SearchTerm` already are.
+3597    ///
+3598    /// Unencoded, a value carrying `&` or `=` splits into an extra query
+3599    /// parameter (a parent id containing a space produced a malformed URL
+3600    /// outright), so the request the server sees is not the one that was built.
+3601    ///
+3602    /// TRACES: UR-007 | DR-212 | UT-206
+3603    #[test]
+3604    fn test_get_items_endpoint_encodes_query_values() {
+3605        let endpoint = build_get_items_endpoint(
+3606            "u1",
+3607            "lib 1&Filters=IsFavorite",
+3608            Some(&GetItemsOptions {
+3609                include_item_types: Some(vec!["Movie&x=1".to_string()]),
+3610                sort_by: Some("Sort Name".to_string()),
+3611                sort_order: Some("Ascending&y=2".to_string()),
+3612                ..Default::default()
+3613            }),
+3614        );
+3615        assert!(
+3616            endpoint.contains("ParentId=lib%201%26Filters%3DIsFavorite"),
+3617            "{endpoint}"
+3618        );
+3619        assert!(
+3620            endpoint.contains("&IncludeItemTypes=Movie%26x%3D1"),
+3621            "{endpoint}"
+3622        );
+3623        assert!(endpoint.contains("&SortBy=Sort%20Name"), "{endpoint}");
+3624        assert!(
+3625            endpoint.contains("&SortOrder=Ascending%26y%3D2"),
+3626            "{endpoint}"
+3627        );
+3628        // Nothing smuggled in as a parameter of its own.
+3629        assert!(!endpoint.contains("&Filters=IsFavorite"), "{endpoint}");
+3630        assert!(!endpoint.contains("&x=1"), "{endpoint}");
+3631        assert!(!endpoint.contains("&y=2"), "{endpoint}");
+3632    }
+3633
+3634    /// The separators inside a list parameter must survive encoding: Jellyfin
+3635    /// splits `SortBy` and `IncludeItemTypes` on commas, and `hybrid.rs` sends
+3636    /// "ParentIndexNumber,IndexNumber,SortName" to order episodes.
+3637    ///
+3638    /// TRACES: UR-007 | DR-212 | UT-206
+3639    #[test]
+3640    fn test_get_items_endpoint_keeps_list_separators() {
+3641        let endpoint = build_get_items_endpoint(
+3642            "u1",
+3643            "lib-1",
+3644            Some(&GetItemsOptions {
+3645                sort_by: Some("ParentIndexNumber,IndexNumber,SortName".to_string()),
+3646                include_item_types: Some(vec!["Movie".to_string(), "Series".to_string()]),
+3647                ..Default::default()
+3648            }),
+3649        );
+3650        assert!(
+3651            endpoint.contains("&SortBy=ParentIndexNumber,IndexNumber,SortName"),
+3652            "{endpoint}"
+3653        );
+3654        assert!(
+3655            endpoint.contains("&IncludeItemTypes=Movie,Series"),
+3656            "{endpoint}"
+3657        );
+3658        // A plain GUID parent id is unchanged by encoding.
+3659        assert!(endpoint.contains("ParentId=lib-1"), "{endpoint}");
+3660    }
+3661
+3662    /// A newly-added album must arrive as one entry, not one per track.
+3663    ///
+3664    /// Jellyfin's `/Items/Latest` defaults to `GroupItems=false`, which returns
+3665    /// every new Audio track individually — so ripping a 14-track album filled
+3666    /// the whole "recently added" row with that one album. `GroupItems=true`
+3667    /// makes the server collapse children into their parent container.
+3668    #[test]
+3669    fn test_latest_items_endpoint_groups_children_into_containers() {
+3670        let endpoint = build_latest_items_endpoint("u1", "lib-1", Some(16));
+3671
+3672        assert!(
+3673            endpoint.contains("GroupItems=true"),
+3674            "latest items must be grouped so an album counts once, got: {}",
+3675            endpoint
+3676        );
+3677        assert!(endpoint.contains("ParentId=lib-1"));
+3678        assert!(endpoint.contains("Limit=16"));
+3679    }
+3680
+3681    /// UT-190 — Next Up asks the server to leave resumable episodes out.
+3682    ///
+3683    /// Jellyfin's `/Shows/NextUp` defaults `EnableResumable=true`, which returns
+3684    /// the *in-progress* episode as a series' next up — exactly the episode
+3685    /// `/Items/Resume` already returns, so Continue Watching and Next Up render
+3686    /// the same cards.
+3687    ///
+3688    /// TRACES: UR-059 | DR-197, JA-036 | UT-190
+3689    #[test]
+3690    fn test_build_next_up_endpoint_excludes_resumable() {
+3691        let endpoint = build_next_up_endpoint("u1", None, Some(12));
+3692
+3693        assert!(
+3694            endpoint.contains("EnableResumable=false"),
+3695            "next up must exclude in-progress episodes, got: {}",
+3696            endpoint
+3697        );
+3698        assert!(endpoint.contains("UserId=u1"));
+3699        assert!(endpoint.contains("Limit=12"));
+3700        assert!(
+3701            !endpoint.contains("SeriesId"),
+3702            "no series filter when none was requested, got: {}",
+3703            endpoint
+3704        );
+3705    }
+3706
+3707    /// UT-191 — a per-series Next Up query keeps the series filter.
+3708    ///
+3709    /// TRACES: UR-059 | DR-197 | UT-191
+3710    #[test]
+3711    fn test_build_next_up_endpoint_scopes_to_series() {
+3712        let endpoint = build_next_up_endpoint("u1", Some("series-a"), None);
+3713
+3714        assert!(endpoint.contains("SeriesId=series-a"));
+3715        assert!(endpoint.contains("EnableResumable=false"));
+3716        assert!(
+3717            endpoint.contains("Limit=16"),
+3718            "default limit, got: {}",
+3719            endpoint
+3720        );
+3721    }
+3722
+3723    /// UT-099 — a Jellyfin item's `UserData` reaches `MediaItem.user_data`.
+3724    ///
+3725    /// Before DR-113 this mapping was hardcoded to `None`, so nothing outside
+3726    /// the mini player could know an item was favourited.
+3727    ///
+3728    /// TRACES: UR-069 | DR-113, JA-034 | UT-099
+3729    #[test]
+3730    fn test_jellyfin_item_maps_user_data_favorite() {
+3731        let json = r#"{
+3732            "Id": "movie123",
+3733            "Name": "Test Movie",
+3734            "Type": "Movie",
+3735            "UserData": {
+3736                "PlaybackPositionTicks": 6000000000,
+3737                "Played": false,
+3738                "IsFavorite": true,
+3739                "PlayCount": 2,
+3740                "LastPlayedDate": "2026-08-01T12:00:00Z"
+3741            }
+3742        }"#;
+3743
+3744        let item: JellyfinItem = serde_json::from_str(json).expect("item should deserialize");
+3745        let media = item.into_media_item("server1".to_string());
+3746
+3747        let user_data = media.user_data.expect("user data should be mapped");
+3748        assert_eq!(user_data.is_favorite, Some(true));
+3749        assert_eq!(user_data.is_played, Some(false));
+3750        assert_eq!(user_data.play_count, Some(2));
+3751        assert_eq!(user_data.playback_position_ticks, Some(6_000_000_000));
+3752        // Ticks are converted for the frontend, which never divides them itself.
+3753        assert_eq!(user_data.playback_position_ms, Some(600_000));
+3754    }
+3755
+3756    /// An item without `UserData` still maps — the field is optional, and every
+3757    /// non-user-scoped endpoint omits it.
+3758    ///
+3759    /// TRACES: UR-069 | DR-113 | UT-099
+3760    #[test]
+3761    fn test_jellyfin_item_without_user_data_maps_to_none() {
+3762        let json = r#"{"Id": "x", "Name": "No User Data", "Type": "Movie"}"#;
+3763
+3764        let item: JellyfinItem = serde_json::from_str(json).expect("item should deserialize");
+3765        let media = item.into_media_item("server1".to_string());
+3766
+3767        assert!(media.user_data.is_none());
+3768    }
+3769
+3770    #[test]
+3771    fn test_jellyfin_item_deserialize_with_artist_items() {
+3772        // Test that ArtistItems with PascalCase fields deserialize correctly
+3773        let json = r#"{
+3774            "Id": "track123",
+3775            "Name": "Test Track",
+3776            "Type": "Audio",
+3777            "ArtistItems": [
+3778                {"Id": "artist1", "Name": "Bob Dylan"},
+3779                {"Id": "artist2", "Name": "Johnny Cash"}
+3780            ]
+3781        }"#;
+3782
+3783        let result: Result<JellyfinItem, _> = serde_json::from_str(json);
+3784        assert!(result.is_ok());
+3785
+3786        let item = result.unwrap();
+3787        let artist_items = item.artist_items.expect("Expected artist items");
+3788        assert_eq!(artist_items.len(), 2);
+3789        assert_eq!(artist_items[0].id, "artist1");
+3790        assert_eq!(artist_items[0].name, "Bob Dylan");
+3791        assert_eq!(artist_items[1].id, "artist2");
+3792        assert_eq!(artist_items[1].name, "Johnny Cash");
+3793    }
+3794
+3795    #[test]
+3796    fn test_jellyfin_item_to_media_item_conversion() {
+3797        // Test conversion from JellyfinItem to MediaItem preserves image tags
+3798        let json = r#"{
+3799            "Id": "album456",
+3800            "Name": "Love and Theft",
+3801            "Type": "MusicAlbum",
+3802            "ImageTags": {"Primary": "7ebab4f6a80cd09d"},
+3803            "Artists": ["Bob Dylan"],
+3804            "ArtistItems": [{"Id": "0b2a6e969a27f22aba97f9f0e69fa849", "Name": "Bob Dylan"}],
+3805            "RunTimeTicks": 33900137190
+3806        }"#;
+3807
+3808        let jellyfin_item: JellyfinItem = serde_json::from_str(json).expect("Failed to parse");
+3809        let media_item = jellyfin_item.into_media_item("test-server-id".to_string());
+3810
+3811        assert_eq!(media_item.id, "album456");
+3812        assert_eq!(media_item.name, "Love and Theft");
+3813        assert_eq!(media_item.item_type, "MusicAlbum");
+3814        assert_eq!(
+3815            media_item.primary_image_tag,
+3816            Some("7ebab4f6a80cd09d".to_string())
+3817        );
+3818        assert_eq!(media_item.server_id, "test-server-id");
+3819    }
+3820
+3821    #[test]
+3822    fn test_items_response_deserialize() {
+3823        // Test full ItemsResponse with multiple items
+3824        let json = r#"{
+3825            "Items": [
+3826                {
+3827                    "Id": "item1",
+3828                    "Name": "Item One",
+3829                    "Type": "MusicAlbum",
+3830                    "ImageTags": {"Primary": "tag1"}
+3831                },
+3832                {
+3833                    "Id": "item2",
+3834                    "Name": "Item Two",
+3835                    "Type": "Audio",
+3836                    "ImageTags": {"Primary": "tag2"}
+3837                }
+3838            ],
+3839            "TotalRecordCount": 2
+3840        }"#;
+3841
+3842        let result: Result<ItemsResponse, _> = serde_json::from_str(json);
+3843        assert!(result.is_ok());
+3844
+3845        let response = result.unwrap();
+3846        assert_eq!(response.total_record_count, 2);
+3847        assert_eq!(response.items.len(), 2);
+3848        assert_eq!(response.items[0].id, "item1");
+3849        assert_eq!(response.items[1].id, "item2");
+3850    }
+3851
+3852    #[test]
+3853    fn test_search_term_is_url_encoded() {
+3854        // A multi-word query (and one with a reserved character) must be
+3855        // percent-encoded before being placed in the SearchTerm query param,
+3856        // otherwise the request URL is malformed and search returns nothing.
+3857        assert_eq!(urlencoding::encode("Star Wars"), "Star%20Wars");
+3858        assert_eq!(urlencoding::encode("Tom & Jerry"), "Tom%20%26%20Jerry");
+3859    }
+3860
+3861    #[test]
+3862    fn test_jray_context_deserializes_actors() {
+3863        // The jray?t= envelope as documented in the JRay truth file format.
+3864        let json = r#"{
+3865            "actors": [
+3866                { "name": "Tom Hanks", "imdb_id": "nm0000158", "tmdb_id": "31", "jellyfin_id": "abc123-guid" }
+3867            ]
+3868        }"#;
+3869        let ctx: JRayContext = serde_json::from_str(json).expect("should parse");
+3870        assert_eq!(ctx.actors.len(), 1);
+3871        assert_eq!(ctx.actors[0].name, "Tom Hanks");
+3872        assert_eq!(ctx.actors[0].jellyfin_id, "abc123-guid");
+3873    }
+3874
+3875    #[test]
+3876    fn test_jray_context_ignores_unknown_keys_and_missing_ids() {
+3877        // Future fields (locations/trivia) must be ignored, and absent id keys
+3878        // must default to "" rather than failing to parse.
+3879        let json = r#"{
+3880            "actors": [ { "name": "Extra" } ],
+3881            "locations": ["Beach"],
+3882            "trivia": "filmed in 1994"
+3883        }"#;
+3884        let ctx: JRayContext = serde_json::from_str(json).expect("should tolerate extra keys");
+3885        assert_eq!(ctx.actors.len(), 1);
+3886        assert_eq!(ctx.actors[0].name, "Extra");
+3887        assert_eq!(ctx.actors[0].imdb_id, "");
+3888        assert_eq!(ctx.actors[0].jellyfin_id, "");
+3889    }
+3890}
+
\ No newline at end of file diff --git a/api/src/jellytau_lib/repository/series_progress.rs.html b/api/src/jellytau_lib/repository/series_progress.rs.html new file mode 100644 index 00000000..28cda857 --- /dev/null +++ b/api/src/jellytau_lib/repository/series_progress.rs.html @@ -0,0 +1,498 @@ +series_progress.rs - sourceSkip to main content

jellytau_lib/repository/
series_progress.rs

1//! Where a viewer is in a TV series.
+2//!
+3//! This is domain policy, not presentation: it encodes what Jellyfin's user-data
+4//! means ("in progress", "played") and what Jellyfin's season numbering means
+5//! (season 0 is specials). The frontend asks for *the* current episode and
+6//! renders it; it does not get to decide what "current" means.
+7//!
+8//! Split into a pure half (`pick_current_episode`, `sort_series_order`) and an
+9//! I/O half (`fetch_series_episodes`, `resolve_current_episode`) so the policy
+10//! can be unit-tested without standing up a repository.
+11//!
+12//! TRACES: UR-062 | DR-101
+13
+14use super::{GetItemsOptions, MediaItem, MediaRepository, RepoError};
+15
+16/// Jellyfin files specials under season 0.
+17const SPECIALS_SEASON: i32 = 0;
+18
+19/// Below this fraction watched, a position is a false start rather than
+20/// progress — the same threshold the resume dialog uses.
+21const MIN_PROGRESS_FRACTION: f64 = 0.01;
+22
+23/// Above this fraction watched, an episode is effectively finished; resuming it
+24/// would drop the viewer into the closing credits.
+25const MAX_PROGRESS_FRACTION: f64 = 0.95;
+26
+27/// Sort key for a season number. Specials sort *after* every numbered season:
+28/// a viewer works through S1, S2, … and only then the extras, so season 0 must
+29/// not lead just because `0 < 1`.
+30fn season_rank(season: Option<i32>) -> i64 {
+31    match season {
+32        Some(SPECIALS_SEASON) => i64::MAX,
+33        Some(n) => n as i64,
+34        None => i64::MAX - 1,
+35    }
+36}
+37
+38/// Order episodes as the series is watched: season ascending, then episode,
+39/// specials last.
+40pub fn sort_series_order(episodes: &mut [MediaItem]) {
+41    episodes.sort_by(|a, b| {
+42        season_rank(a.parent_index_number)
+43            .cmp(&season_rank(b.parent_index_number))
+44            .then(
+45                a.index_number
+46                    .unwrap_or(0)
+47                    .cmp(&b.index_number.unwrap_or(0)),
+48            )
+49    });
+50}
+51
+52/// Is this episode genuinely part-watched (not a false start, not finished)?
+53fn is_in_progress(item: &MediaItem) -> bool {
+54    let Some(user_data) = item.user_data.as_ref() else {
+55        return false;
+56    };
+57    if user_data.is_played.unwrap_or(false) {
+58        return false;
+59    }
+60
+61    let position_ms = user_data
+62        .playback_position_ms
+63        .or_else(|| user_data.playback_position_ticks.map(|t| t / 10_000))
+64        .unwrap_or(0);
+65    if position_ms <= 0 {
+66        return false;
+67    }
+68
+69    // Without a duration we cannot tell "2 minutes in" from "2 minutes left",
+70    // so any recorded position counts as progress.
+71    let Some(duration_ms) = item.duration_ms.filter(|d| *d > 0) else {
+72        return true;
+73    };
+74
+75    let fraction = position_ms as f64 / duration_ms as f64;
+76    (MIN_PROGRESS_FRACTION..MAX_PROGRESS_FRACTION).contains(&fraction)
+77}
+78
+79fn is_played(item: &MediaItem) -> bool {
+80    item.user_data
+81        .as_ref()
+82        .and_then(|u| u.is_played)
+83        .unwrap_or(false)
+84}
+85
+86fn belongs_to_series(item: &MediaItem, series_id: &str) -> bool {
+87    item.series_id.as_deref() == Some(series_id)
+88}
+89
+90/// The episode a viewer should land on when they open `series_id`.
+91///
+92/// Order of preference, and why:
+93///
+94/// 1. **An episode in progress.** That is literally where playback stopped;
+95///    Next Up would skip past it. On a tie the earliest in series order wins, so
+96///    a viewer who dipped into a later episode still returns to the one they are
+97///    working through.
+98/// 2. **The server's Next Up** for this series — it accounts for watch history
+99///    we do not cache locally.
+100/// 3. **The episode after the furthest-watched one**, falling back to the first
+101///    unwatched episode when nothing has been watched or the series is finished.
+102///    This is the offline path: `OfflineRepository::get_next_up_episodes`
+103///    returns an empty vec, so without this rung the whole feature would be
+104///    online-only. It deliberately does *not* return the first unwatched
+105///    episode outright — an unwatched episode behind the viewer's furthest
+106///    point was skipped on purpose, and sending them back to it is the bug
+107///    DR-101 was reopened for.
+108/// 4. **The first episode**, so a never-watched series opens on its premiere
+109///    rather than on nothing.
+110///
+111/// `next_up` / `resume` entries are honoured even when absent from `episodes`
+112/// (the season fan-out can miss an id the server returns), but only when they
+113/// belong to this series.
+114pub fn pick_current_episode(
+115    series_id: &str,
+116    episodes: &[MediaItem],
+117    next_up: &[MediaItem],
+118    resume: &[MediaItem],
+119) -> Option<MediaItem> {
+120    // 1. In progress — prefer a match inside the ordered episode list so the
+121    //    "earliest in series order" tie-break is meaningful; fall back to the
+122    //    resume feed for an episode the fan-out missed.
+123    if let Some(found) = episodes.iter().find(|e| is_in_progress(e)) {
+124        return Some(found.clone());
+125    }
+126    if let Some(found) = resume
+127        .iter()
+128        .find(|e| belongs_to_series(e, series_id) && is_in_progress(e))
+129    {
+130        return Some(found.clone());
+131    }
+132
+133    // 2. Next Up for this series.
+134    if let Some(found) = next_up
+135        .iter()
+136        .find(|e| e.series_id.is_none() || belongs_to_series(e, series_id))
+137    {
+138        // Prefer the copy from `episodes` when we have one: it carries the
+139        // user-data and images the list already fetched.
+140        let matched = episodes.iter().find(|e| e.id == found.id);
+141        return Some(matched.unwrap_or(found).clone());
+142    }
+143
+144    // 3. The episode after the furthest-watched one. Not simply the first
+145    //    unwatched: a viewer who skipped the pilot but is deep into season 3
+146    //    must not be dragged back to S1E1. An earlier gap is a deliberate skip;
+147    //    where they stopped is the *last* thing they watched.
+148    if let Some(furthest) = episodes.iter().rposition(is_played) {
+149        if let Some(found) = episodes.get(furthest + 1) {
+150            return Some(found.clone());
+151        }
+152    }
+153
+154    // Nothing watched yet (or the furthest-watched episode is the finale):
+155    // the first unwatched episode in series order.
+156    if let Some(found) = episodes.iter().find(|e| !is_played(e)) {
+157        return Some(found.clone());
+158    }
+159
+160    // 4. First episode — a fully-watched series reopens at the start.
+161    episodes.first().cloned()
+162}
+163
+164/// Every episode of a series, in series order.
+165///
+166/// Jellyfin hangs episodes off season folders, except for "flat" series whose
+167/// children are episodes directly. Both shapes are provider vocabulary, so the
+168/// fan-out and the fallback live here rather than in the frontend.
+169pub async fn fetch_series_episodes(
+170    repo: &dyn MediaRepository,
+171    series_id: &str,
+172) -> Result<Vec<MediaItem>, RepoError> {
+173    let children = repo.get_items(series_id, list_options()).await?;
+174
+175    let mut episodes: Vec<MediaItem> = Vec::new();
+176    for season in children.items.iter().filter(|i| is_season(i)) {
+177        // One failing season must not blank the whole show.
+178        match repo.get_items(&season.id, list_options()).await {
+179            Ok(result) => episodes.extend(result.items.into_iter().filter(is_episode)),
+180            Err(e) => {
+181                log::warn!(
+182                    "[series] season {} of {} failed to load: {:?}",
+183                    season.id,
+184                    series_id,
+185                    e
+186                );
+187            }
+188        }
+189    }
+190
+191    // Flat series: the children *are* the episodes.
+192    if episodes.is_empty() {
+193        episodes.extend(children.items.into_iter().filter(is_episode));
+194    }
+195
+196    sort_series_order(&mut episodes);
+197    Ok(episodes)
+198}
+199
+200/// Resolve the current episode, fetching everything the policy needs.
+201///
+202/// Next Up and resume are best-effort: offline they fail or come back empty, and
+203/// `pick_current_episode` has fallbacks for exactly that.
+204pub async fn resolve_current_episode(
+205    repo: &dyn MediaRepository,
+206    series_id: &str,
+207) -> Result<Option<MediaItem>, RepoError> {
+208    let episodes = fetch_series_episodes(repo, series_id).await?;
+209
+210    let next_up = repo
+211        .get_next_up_episodes(Some(series_id), Some(1))
+212        .await
+213        .unwrap_or_default();
+214    let resume = repo
+215        .get_resume_items(Some(series_id), Some(10))
+216        .await
+217        .unwrap_or_default();
+218
+219    Ok(pick_current_episode(
+220        series_id, &episodes, &next_up, &resume,
+221    ))
+222}
+223
+224fn list_options() -> Option<GetItemsOptions> {
+225    Some(GetItemsOptions {
+226        limit: Some(500),
+227        ..Default::default()
+228    })
+229}
+230
+231fn is_season(item: &MediaItem) -> bool {
+232    item.item_type == "Season" || matches!(item.kind, crate::domain::MediaKind::Season)
+233}
+234
+235fn is_episode(item: &MediaItem) -> bool {
+236    item.item_type == "Episode" || matches!(item.kind, crate::domain::MediaKind::Episode)
+237}
+238
+239#[cfg(test)]
+240mod tests {
+241    use super::*;
+242    use crate::repository::UserData;
+243
+244    const SERIES: &str = "series-1";
+245
+246    fn episode(id: &str, season: i32, number: i32) -> MediaItem {
+247        MediaItem {
+248            id: id.to_string(),
+249            name: format!("S{season}E{number}"),
+250            item_type: "Episode".to_string(),
+251            series_id: Some(SERIES.to_string()),
+252            parent_index_number: Some(season),
+253            index_number: Some(number),
+254            duration_ms: Some(1_000_000),
+255            ..Default::default()
+256        }
+257    }
+258
+259    fn watched(mut item: MediaItem) -> MediaItem {
+260        item.user_data = Some(UserData {
+261            is_played: Some(true),
+262            ..Default::default()
+263        });
+264        item
+265    }
+266
+267    fn in_progress(mut item: MediaItem, fraction: f64) -> MediaItem {
+268        let duration = item.duration_ms.unwrap_or(1_000_000) as f64;
+269        item.user_data = Some(UserData {
+270            is_played: Some(false),
+271            playback_position_ms: Some((duration * fraction) as i64),
+272            ..Default::default()
+273        });
+274        item
+275    }
+276
+277    fn season(n: i32, count: i32) -> Vec<MediaItem> {
+278        (1..=count)
+279            .map(|i| episode(&format!("s{n}e{i}"), n, i))
+280            .collect()
+281    }
+282
+283    #[test]
+284    fn sorts_by_season_then_episode() {
+285        let mut eps = vec![
+286            episode("b", 2, 1),
+287            episode("d", 1, 10),
+288            episode("a", 1, 2),
+289            episode("c", 2, 2),
+290        ];
+291        sort_series_order(&mut eps);
+292        let ids: Vec<&str> = eps.iter().map(|e| e.id.as_str()).collect();
+293        assert_eq!(ids, ["a", "d", "b", "c"]);
+294    }
+295
+296    #[test]
+297    fn sorts_specials_after_numbered_seasons() {
+298        let mut eps = vec![episode("special", 0, 1), episode("premiere", 1, 1)];
+299        sort_series_order(&mut eps);
+300        let ids: Vec<&str> = eps.iter().map(|e| e.id.as_str()).collect();
+301        assert_eq!(ids, ["premiere", "special"]);
+302    }
+303
+304    #[test]
+305    fn picks_the_in_progress_episode_over_next_up() {
+306        let mut eps = season(1, 5);
+307        eps[0] = watched(eps[0].clone());
+308        eps[1] = in_progress(eps[1].clone(), 0.4);
+309        // The server would send us past it; the half-watched episode wins.
+310        let next_up = vec![episode("s1e3", 1, 3)];
+311
+312        let current = pick_current_episode(SERIES, &eps, &next_up, &[]).unwrap();
+313        assert_eq!(current.id, "s1e2");
+314    }
+315
+316    #[test]
+317    fn picks_the_earliest_in_progress_episode() {
+318        let mut eps = season(1, 5);
+319        eps[1] = in_progress(eps[1].clone(), 0.3);
+320        eps[3] = in_progress(eps[3].clone(), 0.5);
+321
+322        let current = pick_current_episode(SERIES, &eps, &[], &[]).unwrap();
+323        assert_eq!(current.id, "s1e2");
+324    }
+325
+326    #[test]
+327    fn ignores_a_false_start_and_a_finished_episode() {
+328        let mut eps = season(1, 5);
+329        eps[0] = watched(eps[0].clone());
+330        eps[1] = in_progress(eps[1].clone(), 0.001); // barely started
+331        eps[2] = in_progress(eps[2].clone(), 0.99); // effectively over
+332
+333        // Neither counts as progress, so Next Up decides.
+334        let next_up = vec![episode("s1e4", 1, 4)];
+335        let current = pick_current_episode(SERIES, &eps, &next_up, &[]).unwrap();
+336        assert_eq!(current.id, "s1e4");
+337    }
+338
+339    #[test]
+340    fn falls_back_to_next_up_when_nothing_is_in_progress() {
+341        let eps = season(1, 5);
+342        let next_up = vec![episode("s1e3", 1, 3)];
+343
+344        let current = pick_current_episode(SERIES, &eps, &next_up, &[]).unwrap();
+345        assert_eq!(current.id, "s1e3");
+346    }
+347
+348    #[test]
+349    fn next_up_from_another_series_is_ignored() {
+350        let eps = season(1, 3);
+351        let mut foreign = episode("other-show-ep", 1, 1);
+352        foreign.series_id = Some("series-2".to_string());
+353
+354        let current = pick_current_episode(SERIES, &eps, &[foreign], &[]).unwrap();
+355        assert_eq!(current.id, "s1e1");
+356    }
+357
+358    /// The offline path: `OfflineRepository::get_next_up_episodes` returns an
+359    /// empty vec, so the first unwatched episode has to carry the feature.
+360    #[test]
+361    fn falls_back_to_first_unwatched_when_next_up_is_empty() {
+362        let mut eps = [season(1, 3), season(2, 3)].concat();
+363        for ep in eps.iter_mut().take(4) {
+364            *ep = watched(ep.clone());
+365        }
+366
+367        let current = pick_current_episode(SERIES, &eps, &[], &[]).unwrap();
+368        assert_eq!(current.id, "s2e2");
+369    }
+370
+371    /// A viewer deep in season 3 who never watched the pilot must not be sent
+372    /// back to it: the gap was a skip, not the place they stopped.
+373    #[test]
+374    fn resumes_after_the_furthest_watched_episode_not_the_first_gap() {
+375        let mut eps = [season(1, 4), season(2, 4), season(3, 4)].concat();
+376        for ep in eps.iter_mut() {
+377            // Everything through S3E3 watched, except the never-watched pilot.
+378            let watched_through = ep.parent_index_number < Some(3) || ep.index_number <= Some(3);
+379            if watched_through && ep.id != "s1e1" {
+380                *ep = watched(ep.clone());
+381            }
+382        }
+383
+384        let current = pick_current_episode(SERIES, &eps, &[], &[]).unwrap();
+385        assert_eq!(current.id, "s3e4");
+386    }
+387
+388    /// The furthest-watched episode being a finale must still roll into the
+389    /// next season rather than stopping the series.
+390    #[test]
+391    fn resumes_into_the_next_season_after_a_skipped_earlier_episode() {
+392        let mut eps = [season(1, 3), season(2, 3)].concat();
+393        for ep in eps.iter_mut() {
+394            if ep.parent_index_number == Some(1) && ep.id != "s1e1" {
+395                *ep = watched(ep.clone());
+396            }
+397        }
+398
+399        let current = pick_current_episode(SERIES, &eps, &[], &[]).unwrap();
+400        assert_eq!(current.id, "s2e1");
+401    }
+402
+403    /// Specials sort last, so watching one must not mark the series finished
+404    /// while numbered episodes remain.
+405    #[test]
+406    fn a_watched_special_does_not_end_the_series() {
+407        let mut eps = [season(1, 3), vec![episode("s0e1", 0, 1)]].concat();
+408        sort_series_order(&mut eps);
+409        for ep in eps.iter_mut() {
+410            if ep.id == "s1e1" || ep.id == "s0e1" {
+411                *ep = watched(ep.clone());
+412            }
+413        }
+414
+415        let current = pick_current_episode(SERIES, &eps, &[], &[]).unwrap();
+416        assert_eq!(current.id, "s1e2");
+417    }
+418
+419    #[test]
+420    fn crosses_a_season_boundary_when_a_season_is_finished() {
+421        let mut eps = [season(1, 3), season(2, 3)].concat();
+422        for ep in eps.iter_mut().take(3) {
+423            *ep = watched(ep.clone());
+424        }
+425
+426        let current = pick_current_episode(SERIES, &eps, &[], &[]).unwrap();
+427        assert_eq!(current.id, "s2e1");
+428    }
+429
+430    #[test]
+431    fn a_never_watched_series_opens_on_its_premiere() {
+432        let eps = [season(2, 3), season(1, 3)].concat();
+433        let mut ordered = eps.clone();
+434        sort_series_order(&mut ordered);
+435
+436        let current = pick_current_episode(SERIES, &ordered, &[], &[]).unwrap();
+437        assert_eq!(current.id, "s1e1");
+438    }
+439
+440    #[test]
+441    fn a_fully_watched_series_reopens_at_the_start() {
+442        let eps: Vec<MediaItem> = season(1, 3).into_iter().map(watched).collect();
+443
+444        let current = pick_current_episode(SERIES, &eps, &[], &[]).unwrap();
+445        assert_eq!(current.id, "s1e1");
+446    }
+447
+448    #[test]
+449    fn honours_a_resume_entry_missing_from_the_episode_list() {
+450        // Season fan-out returned nothing usable, but the resume feed knows.
+451        let resume = vec![in_progress(episode("s3e7", 3, 7), 0.5)];
+452
+453        let current = pick_current_episode(SERIES, &[], &[], &resume).unwrap();
+454        assert_eq!(current.id, "s3e7");
+455    }
+456
+457    #[test]
+458    fn resume_entries_from_other_series_are_ignored() {
+459        let mut foreign = in_progress(episode("other", 1, 1), 0.5);
+460        foreign.series_id = Some("series-2".to_string());
+461
+462        assert!(pick_current_episode(SERIES, &[], &[], &[foreign]).is_none());
+463    }
+464
+465    #[test]
+466    fn a_series_with_no_episodes_has_no_current_episode() {
+467        assert!(pick_current_episode(SERIES, &[], &[], &[]).is_none());
+468    }
+469
+470    #[test]
+471    fn an_episode_without_a_duration_still_counts_as_in_progress() {
+472        let mut ep = episode("s1e2", 1, 2);
+473        ep.duration_ms = None;
+474        ep.user_data = Some(UserData {
+475            is_played: Some(false),
+476            playback_position_ms: Some(120_000),
+477            ..Default::default()
+478        });
+479
+480        let current = pick_current_episode(SERIES, &[episode("s1e1", 1, 1), ep], &[], &[]).unwrap();
+481        assert_eq!(current.id, "s1e2");
+482    }
+483
+484    #[test]
+485    fn legacy_tick_positions_still_register_as_progress() {
+486        let mut ep = episode("s1e2", 1, 2);
+487        ep.user_data = Some(UserData {
+488            is_played: Some(false),
+489            // 400_000 ms expressed in Jellyfin ticks, no ms field.
+490            playback_position_ticks: Some(400_000 * 10_000),
+491            ..Default::default()
+492        });
+493
+494        let current = pick_current_episode(SERIES, &[episode("s1e1", 1, 1), ep], &[], &[]).unwrap();
+495        assert_eq!(current.id, "s1e2");
+496    }
+497}
+
\ No newline at end of file diff --git a/api/src/jellytau_lib/repository/types.rs.html b/api/src/jellytau_lib/repository/types.rs.html new file mode 100644 index 00000000..6d31b6dc --- /dev/null +++ b/api/src/jellytau_lib/repository/types.rs.html @@ -0,0 +1,1133 @@ +types.rs - sourceSkip to main content

jellytau_lib/repository/
types.rs

1use serde::{Deserialize, Serialize};
+2
+3/// Error types for repository operations
+4#[derive(specta::Type, Debug, Clone, Serialize, Deserialize)]
+5#[serde(tag = "type", rename_all = "lowercase")]
+6pub enum RepoError {
+7    Network { message: String },
+8    Authentication { message: String },
+9    NotFound { message: String },
+10    Server { message: String },
+11    Database { message: String },
+12    Offline,
+13}
+14
+15impl std::fmt::Display for RepoError {
+16    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
+17        match self {
+18            RepoError::Network { message } => write!(f, "Network error: {}", message),
+19            RepoError::Authentication { message } => write!(f, "Authentication error: {}", message),
+20            RepoError::NotFound { message } => write!(f, "Not found: {}", message),
+21            RepoError::Server { message } => write!(f, "Server error: {}", message),
+22            RepoError::Database { message } => write!(f, "Database error: {}", message),
+23            RepoError::Offline => write!(f, "Offline - no server connection"),
+24        }
+25    }
+26}
+27
+28impl std::error::Error for RepoError {}
+29
+30/// Library (media collection)
+31#[derive(specta::Type, Debug, Clone, Serialize, Deserialize)]
+32#[serde(rename_all = "camelCase")]
+33pub struct Library {
+34    pub id: String,
+35    pub name: String,
+36    pub collection_type: String,
+37    #[serde(skip_serializing_if = "Option::is_none")]
+38    pub image_tag: Option<String>,
+39    /// The favourites scope this library's contents fall under, or `None` for a
+40    /// library kind favourites does not carve up (Live TV, channels, books…).
+41    ///
+42    /// Derived here rather than in the UI: which collection type maps to which
+43    /// scope is Jellyfin vocabulary, and the frontend must not hold a
+44    /// collection-type → category table any more than an item-type one. See
+45    /// `SearchScope::for_collection_type`.
+46    ///
+47    /// TRACES: UR-075 | DR-175
+48    #[serde(default, skip_serializing_if = "Option::is_none")]
+49    pub favorites_scope: Option<SearchScope>,
+50}
+51
+52impl Library {
+53    /// Build a library, deriving everything that follows from its collection
+54    /// type. Prefer this over the struct literal so a new derived field cannot
+55    /// be forgotten at one of the construction sites.
+56    pub fn new(
+57        id: String,
+58        name: String,
+59        collection_type: String,
+60        image_tag: Option<String>,
+61    ) -> Self {
+62        let favorites_scope = SearchScope::for_collection_type(&collection_type);
+63        Self {
+64            id,
+65            name,
+66            collection_type,
+67            image_tag,
+68            favorites_scope,
+69        }
+70    }
+71}
+72
+73/// User-specific data for an item (playback state, favorites, etc.)
+74#[derive(specta::Type, Debug, Clone, Serialize, Deserialize, Default)]
+75#[serde(rename_all = "camelCase")]
+76pub struct UserData {
+77    /// Legacy Jellyfin resume position in ticks. Being replaced by
+78    /// `playback_position_ms`; dual-carried while the frontend migrates
+79    /// (docs/specs/frontend-domain-model.md). New code should read the ms field.
+80    #[serde(skip_serializing_if = "Option::is_none")]
+81    pub playback_position_ticks: Option<i64>,
+82    /// Resume position in milliseconds — the neutral replacement for
+83    /// `playback_position_ticks`. Populated from ticks by the mapping; the
+84    /// frontend never divides ticks itself.
+85    #[serde(default, skip_serializing_if = "Option::is_none")]
+86    pub playback_position_ms: Option<i64>,
+87    #[serde(skip_serializing_if = "Option::is_none")]
+88    pub is_played: Option<bool>,
+89    #[serde(skip_serializing_if = "Option::is_none")]
+90    pub is_favorite: Option<bool>,
+91    #[serde(skip_serializing_if = "Option::is_none")]
+92    pub play_count: Option<i32>,
+93    #[serde(skip_serializing_if = "Option::is_none")]
+94    pub last_played_date: Option<String>,
+95    #[serde(skip_serializing_if = "Option::is_none")]
+96    pub playback_context_type: Option<String>,
+97    #[serde(skip_serializing_if = "Option::is_none")]
+98    pub playback_context_id: Option<String>,
+99}
+100
+101/// Artist item with ID and name (for clickable artist links)
+102#[derive(specta::Type, Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
+103#[serde(rename_all = "camelCase")]
+104pub struct ArtistItem {
+105    #[serde(alias = "Id")]
+106    pub id: String,
+107    #[serde(alias = "Name")]
+108    pub name: String,
+109}
+110
+111/// Person (cast/crew member) - for movies, series, and episodes
+112#[derive(specta::Type, Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
+113#[serde(rename_all = "camelCase")]
+114pub struct Person {
+115    /// Deserializes from API's "Id" field (PascalCase), serializes as "id" (camelCase to frontend)
+116    #[serde(alias = "Id")]
+117    #[serde(default)]
+118    pub id: String,
+119    /// Deserializes from API's "Name" field (PascalCase), serializes as "name" (camelCase to frontend)
+120    #[serde(alias = "Name")]
+121    #[serde(default)]
+122    pub name: String,
+123    /// Person type from Jellyfin API (Actor, Director, Writer, etc.)
+124    /// Deserializes from API's "Type" field (PascalCase), serializes as "type" (camelCase to frontend)
+125    #[serde(rename = "type")]
+126    #[serde(alias = "Type")]
+127    #[serde(default)]
+128    pub person_type: String,
+129    /// Deserializes from API's "Role" field (PascalCase), serializes as "role" (camelCase to frontend)
+130    #[serde(alias = "Role")]
+131    #[serde(skip_serializing_if = "Option::is_none")]
+132    pub role: Option<String>,
+133    /// Deserializes from API's "PrimaryImageTag" field (PascalCase), serializes as "primaryImageTag" (camelCase to frontend)
+134    #[serde(alias = "PrimaryImageTag")]
+135    #[serde(skip_serializing_if = "Option::is_none")]
+136    pub primary_image_tag: Option<String>,
+137}
+138
+139/// Media item
+140#[derive(specta::Type, Debug, Clone, Serialize, Deserialize, Default)]
+141#[serde(rename_all = "camelCase")]
+142pub struct MediaItem {
+143    pub id: String,
+144    pub name: String,
+145    /// Legacy Jellyfin item-type string (`"Audio"`, `"MusicAlbum"`, …).
+146    ///
+147    /// Dual-carry migration (docs/specs/frontend-domain-model.md): `kind` below
+148    /// is the neutral replacement. This field stays while the frontend migrates
+149    /// off it, then is removed in a later phase. New Rust code should read
+150    /// `kind`, not this.
+151    #[serde(rename = "type")]
+152    pub item_type: String,
+153    /// Provider-neutral classification — the replacement for `item_type`.
+154    /// Populated by the Jellyfin mapping; defaults to `Other` for the handful of
+155    /// construction sites that have not been migrated yet.
+156    #[serde(default)]
+157    pub kind: crate::domain::MediaKind,
+158    /// Whether this item is a folder/container (vs a playable leaf). Used to
+159    /// decide whether a channel item drills into a list or plays directly.
+160    #[serde(default)]
+161    pub is_folder: bool,
+162    pub server_id: String,
+163    #[serde(skip_serializing_if = "Option::is_none")]
+164    pub parent_id: Option<String>,
+165    #[serde(skip_serializing_if = "Option::is_none")]
+166    pub library_id: Option<String>,
+167    #[serde(skip_serializing_if = "Option::is_none")]
+168    pub overview: Option<String>,
+169    #[serde(skip_serializing_if = "Option::is_none")]
+170    pub genres: Option<Vec<String>>,
+171    #[serde(skip_serializing_if = "Option::is_none")]
+172    pub production_year: Option<i32>,
+173    /// ISO-8601 release/air date (Jellyfin `PremiereDate`). Used to sort
+174    /// podcast episodes by release date.
+175    #[serde(default, skip_serializing_if = "Option::is_none")]
+176    pub premiere_date: Option<String>,
+177    #[serde(skip_serializing_if = "Option::is_none")]
+178    pub community_rating: Option<f64>,
+179    #[serde(skip_serializing_if = "Option::is_none")]
+180    pub official_rating: Option<String>,
+181    /// Legacy Jellyfin duration in ticks (100 ns units). Being replaced by
+182    /// `duration_ms`; dual-carried while the frontend migrates
+183    /// (docs/specs/frontend-domain-model.md). New code should read `duration_ms`.
+184    #[serde(skip_serializing_if = "Option::is_none")]
+185    #[serde(rename = "runTimeTicks")]
+186    pub runtime_ticks: Option<i64>,
+187    /// Duration in milliseconds — the neutral replacement for `runtime_ticks`.
+188    /// Ticks never reach the frontend; this does.
+189    #[serde(default, skip_serializing_if = "Option::is_none")]
+190    pub duration_ms: Option<i64>,
+191    /// Legacy Jellyfin primary image tag. Being replaced by `image_id`;
+192    /// dual-carried while the frontend migrates. New code should read `image_id`.
+193    #[serde(skip_serializing_if = "Option::is_none")]
+194    pub primary_image_tag: Option<String>,
+195    /// Neutral image identifier the frontend resolves to a URL via the image
+196    /// command — the replacement for `primary_image_tag`. Same value today
+197    /// (Jellyfin's tag is the id); the rename removes the provider term.
+198    #[serde(default, skip_serializing_if = "Option::is_none")]
+199    pub image_id: Option<String>,
+200    #[serde(skip_serializing_if = "Option::is_none")]
+201    pub backdrop_image_tags: Option<Vec<String>>,
+202    #[serde(skip_serializing_if = "Option::is_none")]
+203    pub parent_backdrop_image_tags: Option<Vec<String>>,
+204    #[serde(skip_serializing_if = "Option::is_none")]
+205    pub album_id: Option<String>,
+206    #[serde(skip_serializing_if = "Option::is_none")]
+207    pub album_name: Option<String>,
+208    #[serde(skip_serializing_if = "Option::is_none")]
+209    pub album_artist: Option<String>,
+210    #[serde(skip_serializing_if = "Option::is_none")]
+211    pub artists: Option<Vec<String>>,
+212    #[serde(skip_serializing_if = "Option::is_none")]
+213    pub artist_items: Option<Vec<ArtistItem>>,
+214    #[serde(skip_serializing_if = "Option::is_none")]
+215    pub index_number: Option<i32>,
+216    #[serde(skip_serializing_if = "Option::is_none")]
+217    pub parent_index_number: Option<i32>,
+218    #[serde(skip_serializing_if = "Option::is_none")]
+219    pub series_id: Option<String>,
+220    #[serde(skip_serializing_if = "Option::is_none")]
+221    pub series_name: Option<String>,
+222    #[serde(skip_serializing_if = "Option::is_none")]
+223    pub season_id: Option<String>,
+224    #[serde(skip_serializing_if = "Option::is_none")]
+225    pub season_name: Option<String>,
+226    #[serde(skip_serializing_if = "Option::is_none")]
+227    pub user_data: Option<UserData>,
+228    #[serde(skip_serializing_if = "Option::is_none")]
+229    pub media_streams: Option<Vec<MediaStream>>,
+230    #[serde(skip_serializing_if = "Option::is_none")]
+231    pub media_sources: Option<Vec<MediaSource>>,
+232    #[serde(skip_serializing_if = "Option::is_none")]
+233    pub people: Option<Vec<Person>>,
+234}
+235
+236/// Media stream information (audio, video, subtitle tracks)
+237#[derive(specta::Type, Debug, Clone, Serialize, Deserialize)]
+238#[serde(rename_all = "camelCase")]
+239pub struct MediaStream {
+240    /// Legacy Jellyfin stream type string ("Audio"/"Video"/"Subtitle"). Being
+241    /// replaced by `kind`; dual-carried while the frontend migrates.
+242    #[serde(rename = "type")]
+243    pub stream_type: String,
+244    /// Provider-neutral stream classification — replaces `stream_type`.
+245    #[serde(default)]
+246    pub kind: crate::domain::StreamKind,
+247    #[serde(skip_serializing_if = "Option::is_none")]
+248    pub codec: Option<String>,
+249    #[serde(skip_serializing_if = "Option::is_none")]
+250    pub language: Option<String>,
+251    #[serde(skip_serializing_if = "Option::is_none")]
+252    pub display_title: Option<String>,
+253    pub index: i32,
+254    pub is_default: bool,
+255    pub is_forced: bool,
+256    /// Whether this stream can reach the app as a sidecar it renders itself.
+257    ///
+258    /// `None` for anything that is not a subtitle — the question does not apply,
+259    /// and `false` there would read like a verdict. For a subtitle it is the
+260    /// difference between a track the app can draw and one only the server could
+261    /// have shown, by burning it into the picture (DR-176) — which this app never
+262    /// asks it to do. The vocabulary of *which formats those are* stays in Rust;
+263    /// the frontend only reads the answer.
+264    ///
+265    /// TRACES: UR-020 | DR-176 | UT-168
+266    #[serde(default)]
+267    pub supports_external_delivery: Option<bool>,
+268}
+269
+270/// Media source information
+271#[derive(specta::Type, Debug, Clone, Serialize, Deserialize)]
+272#[serde(rename_all = "camelCase")]
+273pub struct MediaSource {
+274    pub id: String,
+275    pub name: String,
+276    #[serde(skip_serializing_if = "Option::is_none")]
+277    pub container: Option<String>,
+278    #[serde(skip_serializing_if = "Option::is_none")]
+279    pub size: Option<i64>,
+280    #[serde(skip_serializing_if = "Option::is_none")]
+281    pub bitrate: Option<i32>,
+282    pub supports_direct_play: bool,
+283    pub supports_direct_stream: bool,
+284    pub supports_transcoding: bool,
+285    #[serde(skip_serializing_if = "Option::is_none")]
+286    pub direct_stream_url: Option<String>,
+287}
+288
+289/// Search result with pagination
+290#[derive(specta::Type, Debug, Clone, Serialize, Deserialize)]
+291#[serde(rename_all = "camelCase")]
+292pub struct SearchResult {
+293    pub items: Vec<MediaItem>,
+294    pub total_record_count: usize,
+295}
+296
+297/// On-disk usage of downloaded content, for the Downloads surface.
+298///
+299/// `sizes` maps an item id (leaf *or* container) to its bytes on disk: a leaf's
+300/// own file size, a container's summed downloaded descendants. `device_total_bytes`
+301/// and `item_count` are the headline figures for the Downloaded surface top bar.
+302///
+303/// TRACES: UR-056 | DR-085
+304#[derive(specta::Type, Debug, Clone, Serialize, Deserialize)]
+305#[serde(rename_all = "camelCase")]
+306pub struct DownloadDiskUsage {
+307    /// item id → bytes on disk (leaf's own size, or a container's subtotal).
+308    pub sizes: std::collections::HashMap<String, i64>,
+309    /// Container id → true when it is only *partially* downloaded (has cached
+310    /// children that are not downloaded). Absent/false ⇒ fully downloaded. Lets
+311    /// the Downloaded surface badge partial vs. full containers.
+312    pub partial_containers: std::collections::HashMap<String, bool>,
+313    /// Sum of all downloaded leaf sizes — the device total.
+314    pub device_total_bytes: i64,
+315    /// Number of downloaded leaf items (not containers).
+316    pub item_count: u32,
+317}
+318
+319/// Options for querying items
+320#[derive(specta::Type, Debug, Clone, Serialize, Deserialize, Default)]
+321#[serde(rename_all = "camelCase")]
+322pub struct GetItemsOptions {
+323    #[serde(skip_serializing_if = "Option::is_none")]
+324    pub start_index: Option<usize>,
+325    #[serde(skip_serializing_if = "Option::is_none")]
+326    pub limit: Option<usize>,
+327    #[serde(skip_serializing_if = "Option::is_none")]
+328    pub sort_by: Option<String>,
+329    #[serde(skip_serializing_if = "Option::is_none")]
+330    pub sort_order: Option<String>,
+331    #[serde(skip_serializing_if = "Option::is_none")]
+332    pub include_item_types: Option<Vec<String>>,
+333    #[serde(skip_serializing_if = "Option::is_none")]
+334    pub recursive: Option<bool>,
+335    #[serde(skip_serializing_if = "Option::is_none")]
+336    pub fields: Option<Vec<String>>,
+337    #[serde(skip_serializing_if = "Option::is_none")]
+338    pub genres: Option<Vec<String>>,
+339    /// Restrict the listing to favourited items. Backs the per-library
+340    /// favourites toggle; composes with every other filter here.
+341    ///
+342    /// TRACES: UR-067 | DR-116 | UT-104
+343    #[serde(skip_serializing_if = "Option::is_none")]
+344    pub favorites_only: Option<bool>,
+345}
+346
+347/// An opaque search scope the frontend selects; Rust owns what it *means*.
+348///
+349/// The expansion table below is Jellyfin domain vocabulary: it changes when
+350/// Jellyfin adds or renames an item type, never when the UI is redesigned. It
+351/// previously lived in the frontend (`searchScope.ts`), which is the boundary
+352/// leak documented in docs/specs/scoped-search-boundary.md. The frontend now
+353/// sends the enum and never names an item type in connection with search.
+354///
+355/// TRACES: UR-049 | DR-063
+356#[derive(specta::Type, Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
+357#[serde(rename_all = "camelCase")]
+358pub enum SearchScope {
+359    All,
+360    Music,
+361    Movies,
+362    Tv,
+363}
+364
+365impl SearchScope {
+366    /// The Jellyfin item types this scope requests, or `None` for `All`.
+367    ///
+368    /// `All` returns `None` rather than the union of every listed type on
+369    /// purpose: an explicit `includeItemTypes` list filters out anything not
+370    /// named in it, so a union would silently drop People, folders and any type
+371    /// nobody enumerated. Callers must omit the filter entirely on `None`.
+372    ///
+373    /// TRACES: UR-049 | DR-063
+374    pub fn item_types(self) -> Option<Vec<String>> {
+375        match self {
+376            SearchScope::All => None,
+377            SearchScope::Music => Some(
+378                ["MusicAlbum", "MusicArtist", "Audio", "Playlist"]
+379                    .into_iter()
+380                    .map(String::from)
+381                    .collect(),
+382            ),
+383            SearchScope::Movies => Some(vec!["Movie".to_string()]),
+384            SearchScope::Tv => Some(
+385                ["Series", "Episode"]
+386                    .into_iter()
+387                    .map(String::from)
+388                    .collect(),
+389            ),
+390        }
+391    }
+392
+393    /// The scope a library of this Jellyfin `CollectionType` belongs to, or
+394    /// `None` when its contents are not something favourites are browsed by.
+395    ///
+396    /// Same reasoning as `item_types`: this table is Jellyfin vocabulary and
+397    /// changes when Jellyfin renames a collection type, not when the library
+398    /// page is redesigned — so it lives here rather than in the UI that renders
+399    /// a per-library favourites tile.
+400    ///
+401    /// `All` is never returned: it is the *absence* of a category, offered
+402    /// alongside the libraries rather than derived from one.
+403    ///
+404    /// TRACES: UR-075 | DR-175 | UT-161
+405    pub fn for_collection_type(collection_type: &str) -> Option<SearchScope> {
+406        match collection_type {
+407            "movies" => Some(SearchScope::Movies),
+408            "tvshows" => Some(SearchScope::Tv),
+409            "music" => Some(SearchScope::Music),
+410            _ => None,
+411        }
+412    }
+413}
+414
+415/// Options for search queries
+416#[derive(specta::Type, Debug, Clone, Serialize, Deserialize, Default)]
+417#[serde(rename_all = "camelCase")]
+418pub struct SearchOptions {
+419    #[serde(skip_serializing_if = "Option::is_none")]
+420    pub limit: Option<usize>,
+421    #[serde(skip_serializing_if = "Option::is_none")]
+422    pub include_item_types: Option<Vec<String>>,
+423    #[serde(skip_serializing_if = "Option::is_none")]
+424    pub search_term: Option<String>,
+425    /// Opaque scope selected by the UI. When set it **wins** over
+426    /// `include_item_types`, which remains for the non-search `get_items`
+427    /// callers that legitimately request a single concrete type.
+428    #[serde(skip_serializing_if = "Option::is_none")]
+429    pub scope: Option<SearchScope>,
+430}
+431
+432impl SearchOptions {
+433    /// Expand `scope` into `include_item_types` in place.
+434    ///
+435    /// Call this once, in the search command, *before* dispatching to the
+436    /// cache and server paths — both already honour `include_item_types`, and
+437    /// resolving in one place keeps online and offline results identical.
+438    ///
+439    /// TRACES: UR-049 | DR-063
+440    pub fn resolve_scope(&mut self) {
+441        if let Some(scope) = self.scope {
+442            // `All` yields None, which clears the filter — the correct
+443            // behaviour, not an omission.
+444            self.include_item_types = scope.item_types();
+445        }
+446    }
+447}
+448
+449/// Playback information
+450#[derive(specta::Type, Debug, Clone, Serialize, Deserialize)]
+451#[serde(rename_all = "camelCase")]
+452pub struct PlaybackInfo {
+453    pub media_source_id: String,
+454    pub play_session_id: String,
+455    pub stream_url: String,
+456    pub direct_play: bool,
+457    pub needs_transcoding: bool,
+458}
+459
+460/// Live stream information returned from opening a Live TV / channel stream.
+461///
+462/// Unlike on-demand video, a live channel must be "opened" before it can be
+463/// streamed; the server returns a transcoding URL (already absolute) plus a
+464/// `live_stream_id` that can later be used to close the stream.
+465#[derive(specta::Type, Debug, Clone, Serialize, Deserialize)]
+466#[serde(rename_all = "camelCase")]
+467pub struct LiveStreamInfo {
+468    pub stream_url: String,
+469    pub play_session_id: Option<String>,
+470    pub live_stream_id: Option<String>,
+471    pub media_source_id: Option<String>,
+472}
+473
+474/// Genre
+475#[derive(specta::Type, Debug, Clone, Serialize, Deserialize)]
+476#[serde(rename_all = "camelCase")]
+477pub struct Genre {
+478    pub id: String,
+479    pub name: String,
+480    /// Number of albums tagged with this genre, when the backend can supply it
+481    /// (online only). Lets the frontend rank/pick genres without probing each
+482    /// one. `None` when unknown (e.g. offline).
+483    pub album_count: Option<u32>,
+484}
+485
+486/// Image type
+487#[derive(specta::Type, Debug, Clone, Serialize, Deserialize)]
+488pub enum ImageType {
+489    Primary,
+490    Backdrop,
+491    Banner,
+492    Thumb,
+493    Logo,
+494}
+495
+496impl ImageType {
+497    pub fn as_str(&self) -> &str {
+498        match self {
+499            ImageType::Primary => "Primary",
+500            ImageType::Backdrop => "Backdrop",
+501            ImageType::Banner => "Banner",
+502            ImageType::Thumb => "Thumb",
+503            ImageType::Logo => "Logo",
+504        }
+505    }
+506}
+507
+508/// Image options
+509#[derive(specta::Type, Debug, Clone, Serialize, Deserialize, Default)]
+510#[serde(rename_all = "camelCase")]
+511pub struct ImageOptions {
+512    #[serde(skip_serializing_if = "Option::is_none")]
+513    pub max_width: Option<u32>,
+514    #[serde(skip_serializing_if = "Option::is_none")]
+515    pub max_height: Option<u32>,
+516    #[serde(skip_serializing_if = "Option::is_none")]
+517    pub quality: Option<u32>,
+518    #[serde(skip_serializing_if = "Option::is_none")]
+519    pub tag: Option<String>,
+520}
+521
+522/// Trait for checking if data has meaningful content
+523pub trait MeaningfulContent {
+524    fn has_content(&self) -> bool;
+525}
+526
+527impl MeaningfulContent for Vec<Library> {
+528    fn has_content(&self) -> bool {
+529        !self.is_empty()
+530    }
+531}
+532
+533impl MeaningfulContent for Vec<MediaItem> {
+534    fn has_content(&self) -> bool {
+535        !self.is_empty()
+536    }
+537}
+538
+539impl MeaningfulContent for SearchResult {
+540    fn has_content(&self) -> bool {
+541        !self.items.is_empty()
+542    }
+543}
+544
+545impl MeaningfulContent for MediaItem {
+546    fn has_content(&self) -> bool {
+547        true // A single item always has content if it exists
+548    }
+549}
+550
+551impl MeaningfulContent for Vec<Genre> {
+552    fn has_content(&self) -> bool {
+553        !self.is_empty()
+554    }
+555}
+556
+557impl MeaningfulContent for PlaybackInfo {
+558    fn has_content(&self) -> bool {
+559        !self.stream_url.is_empty()
+560    }
+561}
+562
+563/// Playlist entry — wraps a MediaItem with the Jellyfin PlaylistItemId
+564/// needed for remove/reorder operations (distinct from the media item's ID)
+565///
+566/// @req: UR-014 - Make and edit playlists of music that sync back to Jellyfin
+567#[derive(specta::Type, Debug, Clone, Serialize, Deserialize)]
+568#[serde(rename_all = "camelCase")]
+569pub struct PlaylistEntry {
+570    /// The playlist-scoped entry ID (Jellyfin's PlaylistItemId)
+571    pub playlist_item_id: String,
+572    /// The underlying media item
+573    #[serde(flatten)]
+574    pub item: MediaItem,
+575}
+576
+577/// Result of creating a playlist
+578///
+579/// @req: JA-019 - Get/create/update playlists
+580#[derive(specta::Type, Debug, Clone, Serialize, Deserialize)]
+581#[serde(rename_all = "camelCase")]
+582pub struct PlaylistCreatedResult {
+583    pub id: String,
+584}
+585
+586impl MeaningfulContent for Vec<PlaylistEntry> {
+587    fn has_content(&self) -> bool {
+588        !self.is_empty()
+589    }
+590}
+591
+592impl MeaningfulContent for PlaylistCreatedResult {
+593    fn has_content(&self) -> bool {
+594        !self.id.is_empty()
+595    }
+596}
+597
+598#[cfg(test)]
+599mod search_scope_tests {
+600    use super::*;
+601
+602    /// Music expands to the four Jellyfin types that make up the category.
+603    ///
+604    /// This table is the domain vocabulary that used to live in the frontend
+605    /// (`searchScope.ts`'s `SCOPE_ITEM_TYPES`) — the boundary leak that
+606    /// docs/specs/scoped-search-boundary.md was written about.
+607    ///
+608    /// @req-test: UT-089 - SearchScope expands to Jellyfin item types
+609    #[test]
+610    fn music_scope_expands_to_music_item_types() {
+611        assert_eq!(
+612            SearchScope::Music.item_types(),
+613            Some(vec![
+614                "MusicAlbum".to_string(),
+615                "MusicArtist".to_string(),
+616                "Audio".to_string(),
+617                "Playlist".to_string(),
+618            ])
+619        );
+620    }
+621
+622    /// @req-test: UT-089 - SearchScope expands to Jellyfin item types
+623    #[test]
+624    fn movies_scope_expands_to_movie_only() {
+625        assert_eq!(
+626            SearchScope::Movies.item_types(),
+627            Some(vec!["Movie".to_string()])
+628        );
+629    }
+630
+631    /// @req-test: UT-089 - SearchScope expands to Jellyfin item types
+632    #[test]
+633    fn tv_scope_expands_to_series_and_episode() {
+634        assert_eq!(
+635            SearchScope::Tv.item_types(),
+636            Some(vec!["Series".to_string(), "Episode".to_string()])
+637        );
+638    }
+639
+640    /// `All` must send NO filter — not the union of the other scopes.
+641    ///
+642    /// Sending a union would silently drop every type nobody enumerated
+643    /// (Person, folders, …), which an explicit `includeItemTypes` list filters
+644    /// out. This is why `item_types()` returns Option rather than Vec.
+645    ///
+646    /// @req-test: UT-090 - All scope sends no item-type filter
+647    #[test]
+648    fn all_scope_sends_no_filter() {
+649        assert_eq!(SearchScope::All.item_types(), None);
+650    }
+651
+652    /// Scope wins over an explicitly supplied include_item_types.
+653    ///
+654    /// @req-test: UT-091 - Scope takes precedence over include_item_types
+655    #[test]
+656    fn resolve_scope_overrides_include_item_types() {
+657        let mut options = SearchOptions {
+658            include_item_types: Some(vec!["Movie".to_string()]),
+659            scope: Some(SearchScope::Music),
+660            ..Default::default()
+661        };
+662        options.resolve_scope();
+663
+664        assert_eq!(
+665            options.include_item_types,
+666            Some(vec![
+667                "MusicAlbum".to_string(),
+668                "MusicArtist".to_string(),
+669                "Audio".to_string(),
+670                "Playlist".to_string(),
+671            ])
+672        );
+673    }
+674
+675    /// `All` clears any include_item_types so no filter reaches the query.
+676    ///
+677    /// @req-test: UT-090 - All scope sends no item-type filter
+678    #[test]
+679    fn resolve_all_scope_clears_include_item_types() {
+680        let mut options = SearchOptions {
+681            include_item_types: Some(vec!["Movie".to_string()]),
+682            scope: Some(SearchScope::All),
+683            ..Default::default()
+684        };
+685        options.resolve_scope();
+686
+687        assert_eq!(options.include_item_types, None);
+688    }
+689
+690    /// With no scope set, include_item_types passes through untouched — the
+691    /// non-search `getItems` callers rely on this.
+692    ///
+693    /// @req-test: UT-091 - Scope takes precedence over include_item_types
+694    #[test]
+695    fn resolve_without_scope_preserves_include_item_types() {
+696        let mut options = SearchOptions {
+697            include_item_types: Some(vec!["MusicAlbum".to_string()]),
+698            scope: None,
+699            ..Default::default()
+700        };
+701        options.resolve_scope();
+702
+703        assert_eq!(
+704            options.include_item_types,
+705            Some(vec!["MusicAlbum".to_string()])
+706        );
+707    }
+708
+709    /// The frontend sends the enum as camelCase over IPC.
+710    ///
+711    /// @req-test: UT-089 - SearchScope expands to Jellyfin item types
+712    #[test]
+713    fn scope_deserializes_from_camel_case() {
+714        let options: SearchOptions =
+715            serde_json::from_str(r#"{"scope": "music", "limit": 10}"#).unwrap();
+716        assert!(matches!(options.scope, Some(SearchScope::Music)));
+717
+718        let all: SearchOptions = serde_json::from_str(r#"{"scope": "all"}"#).unwrap();
+719        assert!(matches!(all.scope, Some(SearchScope::All)));
+720    }
+721
+722    /// TRACES: DR-175 | UT-161
+723    #[test]
+724    fn test_collection_type_maps_to_its_favorites_scope() {
+725        assert_eq!(
+726            SearchScope::for_collection_type("movies"),
+727            Some(SearchScope::Movies)
+728        );
+729        assert_eq!(
+730            SearchScope::for_collection_type("tvshows"),
+731            Some(SearchScope::Tv)
+732        );
+733        assert_eq!(
+734            SearchScope::for_collection_type("music"),
+735            Some(SearchScope::Music)
+736        );
+737    }
+738
+739    /// A library kind favourites are not browsed by gets no tile at all, rather
+740    /// than one that opens an unfiltered list. `All` is never derived from a
+741    /// library — it is the cross-library entry offered beside them.
+742    ///
+743    /// TRACES: DR-175 | UT-161
+744    #[test]
+745    fn test_uncategorised_collection_types_have_no_favorites_scope() {
+746        for collection_type in ["livetv", "channels", "boxsets", "books", "unknown", ""] {
+747            assert_eq!(
+748                SearchScope::for_collection_type(collection_type),
+749                None,
+750                "{collection_type} should not carry a favourites scope"
+751            );
+752        }
+753    }
+754
+755    /// TRACES: DR-175 | UT-161
+756    #[test]
+757    fn test_library_carries_its_favorites_scope_to_the_frontend() {
+758        let music = Library::new("1".into(), "Music".into(), "music".into(), None);
+759        assert_eq!(music.favorites_scope, Some(SearchScope::Music));
+760
+761        let json = serde_json::to_value(&music).unwrap();
+762        assert_eq!(json["favoritesScope"], "music");
+763
+764        // A library with no scope omits the field rather than sending null.
+765        let livetv = Library::new("2".into(), "Live TV".into(), "livetv".into(), None);
+766        let json = serde_json::to_value(&livetv).unwrap();
+767        assert!(json.get("favoritesScope").is_none());
+768    }
+769}
+770
+771#[cfg(test)]
+772mod tests {
+773    use super::*;
+774
+775    #[test]
+776    fn test_artist_item_deserialize_pascal_case() {
+777        // Test that ArtistItem correctly deserializes PascalCase JSON from Jellyfin API
+778        let json = r#"{"Id": "artist123", "Name": "Bob Dylan"}"#;
+779        let result: Result<ArtistItem, _> = serde_json::from_str(json);
+780
+781        assert!(result.is_ok());
+782        let artist = result.unwrap();
+783        assert_eq!(artist.id, "artist123");
+784        assert_eq!(artist.name, "Bob Dylan");
+785    }
+786
+787    #[test]
+788    fn test_artist_item_deserialize_array() {
+789        // Test deserializing array of ArtistItems (common in API responses)
+790        let json = r#"[
+791            {"Id": "artist1", "Name": "Bob Dylan"},
+792            {"Id": "artist2", "Name": "Johnny Cash"}
+793        ]"#;
+794        let result: Result<Vec<ArtistItem>, _> = serde_json::from_str(json);
+795
+796        assert!(result.is_ok());
+797        let artists = result.unwrap();
+798        assert_eq!(artists.len(), 2);
+799        assert_eq!(artists[0].id, "artist1");
+800        assert_eq!(artists[0].name, "Bob Dylan");
+801        assert_eq!(artists[1].id, "artist2");
+802        assert_eq!(artists[1].name, "Johnny Cash");
+803    }
+804
+805    #[test]
+806    fn test_artist_item_serialize() {
+807        // ArtistItem serializes to camelCase for the frontend; it still accepts
+808        // Jellyfin's PascalCase on deserialize via #[serde(alias = ...)].
+809        let artist = ArtistItem {
+810            id: "test-id".to_string(),
+811            name: "Test Artist".to_string(),
+812        };
+813
+814        let json = serde_json::to_string(&artist).expect("Failed to serialize");
+815        assert!(json.contains(r#""id":"test-id""#));
+816        assert!(json.contains(r#""name":"Test Artist""#));
+817
+818        let from_pascal: ArtistItem =
+819            serde_json::from_str(r#"{"Id":"x","Name":"Y"}"#).expect("Failed to deserialize");
+820        assert_eq!(from_pascal.id, "x");
+821        assert_eq!(from_pascal.name, "Y");
+822    }
+823
+824    #[test]
+825    fn test_media_item_with_primary_image_tag() {
+826        // Test that MediaItem correctly handles primary_image_tag
+827        let json = r#"{
+828            "id": "item123",
+829            "name": "Test Item",
+830            "type": "MusicAlbum",
+831            "serverId": "server1",
+832            "primaryImageTag": "tag123"
+833        }"#;
+834
+835        let result: Result<MediaItem, _> = serde_json::from_str(json);
+836        assert!(result.is_ok());
+837
+838        let item = result.unwrap();
+839        assert_eq!(item.id, "item123");
+840        assert_eq!(item.name, "Test Item");
+841        assert_eq!(item.primary_image_tag, Some("tag123".to_string()));
+842    }
+843
+844    #[test]
+845    fn test_media_item_with_artists() {
+846        // Test MediaItem with artists array
+847        let json = r#"{
+848            "id": "track1",
+849            "name": "Test Track",
+850            "type": "Audio",
+851            "serverId": "server1",
+852            "artists": ["Artist 1", "Artist 2"]
+853        }"#;
+854
+855        let result: Result<MediaItem, _> = serde_json::from_str(json);
+856        assert!(result.is_ok());
+857
+858        let item = result.unwrap();
+859        let artists = item.artists.expect("Expected artists");
+860        assert_eq!(artists.len(), 2);
+861        assert_eq!(artists[0], "Artist 1");
+862        assert_eq!(artists[1], "Artist 2");
+863    }
+864
+865    #[test]
+866    fn test_search_result_meaningful_content() {
+867        // Test MeaningfulContent trait for SearchResult
+868        let empty_result = SearchResult {
+869            items: vec![],
+870            total_record_count: 0,
+871        };
+872        assert!(!empty_result.has_content());
+873
+874        let non_empty_result = SearchResult {
+875            items: vec![MediaItem {
+876                id: "1".to_string(),
+877                name: "Test".to_string(),
+878                item_type: "Audio".to_string(),
+879                kind: crate::domain::MediaKind::Track,
+880                is_folder: false,
+881                server_id: "server1".to_string(),
+882                parent_id: None,
+883                library_id: None,
+884                overview: None,
+885                genres: None,
+886                production_year: None,
+887                premiere_date: None,
+888                community_rating: None,
+889                official_rating: None,
+890                runtime_ticks: None,
+891                duration_ms: None,
+892                primary_image_tag: None,
+893                image_id: None,
+894                backdrop_image_tags: None,
+895                parent_backdrop_image_tags: None,
+896                album_id: None,
+897                album_name: None,
+898                album_artist: None,
+899                artists: None,
+900                artist_items: None,
+901                index_number: None,
+902                parent_index_number: None,
+903                series_id: None,
+904                series_name: None,
+905                season_id: None,
+906                season_name: None,
+907                user_data: None,
+908                media_streams: None,
+909                media_sources: None,
+910                people: None,
+911            }],
+912            total_record_count: 1,
+913        };
+914        assert!(non_empty_result.has_content());
+915    }
+916
+917    #[test]
+918    fn test_person_deserialize_complete() {
+919        // Test that Person deserializes correctly with all fields (PascalCase from Jellyfin API)
+920        let json = r#"{
+921            "Id": "person123",
+922            "Name": "Tom Hanks",
+923            "Type": "Actor",
+924            "Role": "Lead Actor",
+925            "PrimaryImageTag": "tag456"
+926        }"#;
+927
+928        let result: Result<Person, _> = serde_json::from_str(json);
+929        assert!(result.is_ok(), "Failed to deserialize: {:?}", result.err());
+930
+931        let person = result.unwrap();
+932        assert_eq!(person.id, "person123");
+933        assert_eq!(person.name, "Tom Hanks");
+934        assert_eq!(person.person_type, "Actor");
+935        assert_eq!(person.role, Some("Lead Actor".to_string()));
+936        assert_eq!(person.primary_image_tag, Some("tag456".to_string()));
+937
+938        // Verify serialization uses camelCase for frontend
+939        let serialized = serde_json::to_string(&person).expect("Failed to serialize");
+940        assert!(
+941            serialized.contains(r#""type":"Actor""#),
+942            "Serialized form should use 'type' not 'Type'"
+943        );
+944        assert!(serialized.contains(r#""id":"person123""#));
+945        assert!(serialized.contains(r#""primaryImageTag":"tag456""#));
+946    }
+947
+948    #[test]
+949    fn test_person_deserialize_minimal() {
+950        // Test that Person deserializes with missing optional fields (uses defaults)
+951        let json = r#"{
+952            "Id": "person456",
+953            "Name": "Meryl Streep",
+954            "Type": "Actress"
+955        }"#;
+956
+957        let result: Result<Person, _> = serde_json::from_str(json);
+958        assert!(result.is_ok());
+959
+960        let person = result.unwrap();
+961        assert_eq!(person.id, "person456");
+962        assert_eq!(person.name, "Meryl Streep");
+963        assert_eq!(person.person_type, "Actress");
+964        assert_eq!(person.role, None);
+965        assert_eq!(person.primary_image_tag, None);
+966    }
+967
+968    #[test]
+969    fn test_person_array_deserialize() {
+970        // Test deserializing array of Person objects (common in Jellyfin API)
+971        let json = r#"[
+972            {"Id": "actor1", "Name": "Actor One", "Type": "Actor"},
+973            {"Id": "director1", "Name": "Director One", "Type": "Director", "Role": "Director"}
+974        ]"#;
+975
+976        let result: Result<Vec<Person>, _> = serde_json::from_str(json);
+977        assert!(result.is_ok());
+978
+979        let people = result.unwrap();
+980        assert_eq!(people.len(), 2);
+981        assert_eq!(people[0].name, "Actor One");
+982        assert_eq!(people[1].person_type, "Director");
+983        assert_eq!(people[1].role, Some("Director".to_string()));
+984    }
+985
+986    #[test]
+987    fn test_media_item_with_people() {
+988        // Test that MediaItem correctly deserializes with people array (from API in PascalCase)
+989        let json = r#"{
+990            "id": "movie1",
+991            "name": "Test Movie",
+992            "type": "Movie",
+993            "serverId": "server1",
+994            "people": [
+995                {"Id": "actor1", "Name": "John Doe", "Type": "Actor"},
+996                {"Id": "director1", "Name": "Jane Smith", "Type": "Director"}
+997            ]
+998        }"#;
+999
+1000        let result: Result<MediaItem, _> = serde_json::from_str(json);
+1001        assert!(result.is_ok());
+1002
+1003        let item = result.unwrap();
+1004        let people = item.people.as_ref().expect("Expected people array");
+1005        assert_eq!(people.len(), 2);
+1006        assert_eq!(people[0].name, "John Doe");
+1007        assert_eq!(people[0].person_type, "Actor");
+1008        assert_eq!(people[1].person_type, "Director");
+1009
+1010        // Verify that when serialized to frontend, it uses camelCase
+1011        let serialized = serde_json::to_string(&item).expect("Failed to serialize");
+1012        let re_parsed: serde_json::Value =
+1013            serde_json::from_str(&serialized).expect("Failed to parse serialized");
+1014        let people_array = re_parsed["people"]
+1015            .as_array()
+1016            .expect("people should be array");
+1017        assert!(
+1018            people_array[0].get("type").is_some(),
+1019            "Serialized person should have 'type' field"
+1020        );
+1021        assert_eq!(people_array[0]["type"].as_str().unwrap(), "Actor");
+1022    }
+1023
+1024    #[test]
+1025    fn test_playlist_entry_serialization() {
+1026        let entry = PlaylistEntry {
+1027            playlist_item_id: "entry-abc-123".to_string(),
+1028            item: MediaItem {
+1029                id: "track1".to_string(),
+1030                name: "Test Track".to_string(),
+1031                item_type: "Audio".to_string(),
+1032                kind: crate::domain::MediaKind::Track,
+1033                is_folder: false,
+1034                server_id: "server1".to_string(),
+1035                parent_id: None,
+1036                library_id: None,
+1037                overview: None,
+1038                genres: None,
+1039                production_year: None,
+1040                premiere_date: None,
+1041                community_rating: None,
+1042                official_rating: None,
+1043                runtime_ticks: None,
+1044                duration_ms: None,
+1045                primary_image_tag: None,
+1046                image_id: None,
+1047                backdrop_image_tags: None,
+1048                parent_backdrop_image_tags: None,
+1049                album_id: None,
+1050                album_name: None,
+1051                album_artist: None,
+1052                artists: Some(vec!["Artist One".to_string()]),
+1053                artist_items: None,
+1054                index_number: None,
+1055                parent_index_number: None,
+1056                series_id: None,
+1057                series_name: None,
+1058                season_id: None,
+1059                season_name: None,
+1060                user_data: None,
+1061                media_streams: None,
+1062                media_sources: None,
+1063                people: None,
+1064            },
+1065        };
+1066
+1067        let json = serde_json::to_string(&entry).expect("Failed to serialize");
+1068        // playlistItemId is camelCase
+1069        assert!(json.contains(r#""playlistItemId":"entry-abc-123""#));
+1070        // Flattened MediaItem fields appear at top level
+1071        assert!(json.contains(r#""id":"track1""#));
+1072        assert!(json.contains(r#""name":"Test Track""#));
+1073        assert!(json.contains(r#""type":"Audio""#));
+1074    }
+1075
+1076    #[test]
+1077    fn test_playlist_created_result_serialization() {
+1078        let result = PlaylistCreatedResult {
+1079            id: "playlist-new-123".to_string(),
+1080        };
+1081        let json = serde_json::to_string(&result).expect("Failed to serialize");
+1082        assert!(json.contains(r#""id":"playlist-new-123""#));
+1083    }
+1084
+1085    #[test]
+1086    fn test_playlist_entry_meaningful_content() {
+1087        let empty: Vec<PlaylistEntry> = vec![];
+1088        assert!(!empty.has_content());
+1089
+1090        let non_empty = vec![PlaylistEntry {
+1091            playlist_item_id: "e1".to_string(),
+1092            item: MediaItem {
+1093                id: "1".to_string(),
+1094                name: "Track".to_string(),
+1095                item_type: "Audio".to_string(),
+1096                kind: crate::domain::MediaKind::Track,
+1097                is_folder: false,
+1098                server_id: "s1".to_string(),
+1099                parent_id: None,
+1100                library_id: None,
+1101                overview: None,
+1102                genres: None,
+1103                production_year: None,
+1104                premiere_date: None,
+1105                community_rating: None,
+1106                official_rating: None,
+1107                runtime_ticks: None,
+1108                duration_ms: None,
+1109                primary_image_tag: None,
+1110                image_id: None,
+1111                backdrop_image_tags: None,
+1112                parent_backdrop_image_tags: None,
+1113                album_id: None,
+1114                album_name: None,
+1115                album_artist: None,
+1116                artists: None,
+1117                artist_items: None,
+1118                index_number: None,
+1119                parent_index_number: None,
+1120                series_id: None,
+1121                series_name: None,
+1122                season_id: None,
+1123                season_name: None,
+1124                user_data: None,
+1125                media_streams: None,
+1126                media_sources: None,
+1127                people: None,
+1128            },
+1129        }];
+1130        assert!(non_empty.has_content());
+1131    }
+1132}
+
\ No newline at end of file diff --git a/api/src/jellytau_lib/session_poller/mod.rs.html b/api/src/jellytau_lib/session_poller/mod.rs.html new file mode 100644 index 00000000..5db03934 --- /dev/null +++ b/api/src/jellytau_lib/session_poller/mod.rs.html @@ -0,0 +1,370 @@ +mod.rs - sourceSkip to main content

jellytau_lib/session_poller/
mod.rs

1//! Session polling manager for remote playback control.
+2//!
+3//! Manages background polling of Jellyfin sessions with dynamic frequency adjustment
+4//! based on playback mode and UI state. Eliminates duplicate pollers across browser tabs.
+5//!
+6//! TRACES: UR-010 | JA-021
+7
+8use crate::utils::lock::{MutexSafe, RwLockSafe};
+9use log::{debug, info, warn};
+10use std::sync::atomic::{AtomicBool, AtomicU64, Ordering};
+11use std::sync::{Arc, Mutex, RwLock};
+12use std::thread;
+13use std::time::Duration;
+14
+15use crate::jellyfin::JellyfinClient;
+16use crate::playback_mode::{PlaybackMode, PlaybackModeManager};
+17use crate::player::PlayerEventEmitter;
+18
+19/// Hint for adjusting poll frequency based on UI state
+20#[derive(Debug, Clone, Copy, PartialEq, Eq)]
+21pub enum PollingHint {
+22    /// CastButton is active and needs frequent updates (500ms)
+23    CastActive,
+24    /// CastButton is in discovery mode (15s)
+25    CastDiscovery,
+26    /// No special hint, use mode-based frequency (default)
+27    Normal,
+28}
+29
+30/// Manages background polling of Jellyfin sessions
+31pub struct SessionPollerManager {
+32    jellyfin_client: Arc<Mutex<Option<JellyfinClient>>>,
+33    playback_mode_manager: Arc<PlaybackModeManager>,
+34    event_emitter: Arc<Mutex<Option<Arc<dyn PlayerEventEmitter>>>>,
+35    /// Optional connectivity reporter. The session poller is the one piece of
+36    /// server traffic that runs continuously even when the user is idle (not
+37    /// browsing the library), so feeding its poll outcomes into the reporter is
+38    /// what lets the app detect going offline — and, crucially, recover when the
+39    /// server returns — without any user interaction. Repository traffic alone
+40    /// can't do this because it only happens while browsing.
+41    connectivity_reporter: Arc<Mutex<Option<crate::connectivity::ConnectivityReporter>>>,
+42
+43    // Polling state
+44    is_running: Arc<AtomicBool>,
+45    current_hint: Arc<RwLock<PollingHint>>,
+46    current_interval_ms: Arc<AtomicU64>,
+47
+48    // Thread handle (for cleanup)
+49    thread_handle: Arc<Mutex<Option<thread::JoinHandle<()>>>>,
+50}
+51
+52impl SessionPollerManager {
+53    /// Create a new SessionPollerManager
+54    pub fn new(
+55        jellyfin_client: Arc<Mutex<Option<JellyfinClient>>>,
+56        playback_mode_manager: Arc<PlaybackModeManager>,
+57    ) -> Self {
+58        Self {
+59            jellyfin_client,
+60            playback_mode_manager,
+61            event_emitter: Arc::new(Mutex::new(None)),
+62            connectivity_reporter: Arc::new(Mutex::new(None)),
+63            is_running: Arc::new(AtomicBool::new(false)),
+64            current_hint: Arc::new(RwLock::new(PollingHint::Normal)),
+65            current_interval_ms: Arc::new(AtomicU64::new(10000)), // Default 10s
+66            thread_handle: Arc::new(Mutex::new(None)),
+67        }
+68    }
+69
+70    /// Set event emitter for broadcasting session updates
+71    pub fn set_event_emitter(&self, emitter: Arc<dyn PlayerEventEmitter>) {
+72        *self.event_emitter.lock_safe() = Some(emitter);
+73    }
+74
+75    /// Wire the connectivity reporter so each poll outcome updates reachability.
+76    /// A successful poll recovers the app to online instantly; sustained poll
+77    /// failures flip it offline (subject to the reporter's debounce window).
+78    pub fn set_connectivity_reporter(&self, reporter: crate::connectivity::ConnectivityReporter) {
+79        *self.connectivity_reporter.lock_safe() = Some(reporter);
+80    }
+81
+82    /// Start the background polling thread
+83    pub fn start(&self) {
+84        if self.is_running.swap(true, Ordering::Relaxed) {
+85            warn!("[SessionPoller] Already running, ignoring start request");
+86            return;
+87        }
+88
+89        info!("[SessionPoller] Starting session polling");
+90
+91        // Clone Arc references for the thread
+92        let client = self.jellyfin_client.clone();
+93        let mode_manager = self.playback_mode_manager.clone();
+94        let emitter = self.event_emitter.clone();
+95        let connectivity_reporter = self.connectivity_reporter.clone();
+96        let is_running = self.is_running.clone();
+97        let hint = self.current_hint.clone();
+98        let interval_ms = self.current_interval_ms.clone();
+99
+100        let handle = thread::spawn(move || {
+101            // Create Tokio runtime for async operations in this thread
+102            let rt = tokio::runtime::Runtime::new().unwrap();
+103
+104            while is_running.load(Ordering::Relaxed) {
+105                // Calculate poll interval based on mode and hint
+106                let new_interval =
+107                    Self::calculate_interval(&mode_manager.get_mode(), *hint.read_safe());
+108
+109                interval_ms.store(new_interval, Ordering::Relaxed);
+110
+111                debug!("[SessionPoller] Polling with interval: {}ms", new_interval);
+112
+113                // Fetch sessions. `had_client` distinguishes "server didn't
+114                // answer" from "no client configured" so we only feed real
+115                // request outcomes into the connectivity reporter.
+116                let (sessions_result, had_client) = rt.block_on(async {
+117                    let client_opt = client.lock_safe().clone();
+118                    match client_opt {
+119                        Some(c) => (c.get_sessions().await, true),
+120                        None => {
+121                            debug!("[SessionPoller] Jellyfin client not configured, skipping poll");
+122                            (Ok(Vec::new()), false)
+123                        }
+124                    }
+125                });
+126
+127                // Drive the connectivity reporter from this poll's outcome. This
+128                // is what recovers the app to online when the server returns
+129                // while the user is idle, and detects going offline when no
+130                // library browsing is happening. See connectivity/mod.rs.
+131                if had_client {
+132                    if let Some(reporter) = connectivity_reporter.lock_safe().clone() {
+133                        rt.block_on(async {
+134                            match &sessions_result {
+135                                Ok(_) => reporter.report_success().await,
+136                                Err(e) => reporter.report_network_failure(Some(e.clone())).await,
+137                            }
+138                        });
+139                    }
+140                }
+141
+142                // Emit event if successful
+143                match sessions_result {
+144                    Ok(sessions) => {
+145                        debug!("[SessionPoller] Fetched {} sessions", sessions.len());
+146
+147                        // In remote (cast) mode, mirror the remote session's
+148                        // now-playing onto the Android lockscreen. The local
+149                        // ExoPlayer is idle while casting, so without this the
+150                        // lockscreen shows stale local metadata and a frozen
+151                        // scrubber. Driving it here (native poll thread) rather
+152                        // than from the WebView keeps it live even when the
+153                        // screen is locked and JS timers are throttled.
+154                        if let PlaybackMode::Remote { session_id } = mode_manager.get_mode() {
+155                            Self::push_remote_lockscreen(&sessions, &session_id);
+156                        }
+157
+158                        if let Some(em) = emitter.lock_safe().as_ref() {
+159                            em.emit(crate::player::PlayerStatusEvent::SessionsUpdated { sessions });
+160                        }
+161                    }
+162                    Err(e) => {
+163                        warn!("[SessionPoller] Failed to fetch sessions: {}", e);
+164                    }
+165                }
+166
+167                // Sleep for the calculated interval
+168                thread::sleep(Duration::from_millis(new_interval));
+169            }
+170
+171            info!("[SessionPoller] Polling thread stopped");
+172        });
+173
+174        *self.thread_handle.lock_safe() = Some(handle);
+175    }
+176
+177    /// Stop the polling thread
+178    pub fn stop(&self) {
+179        info!("[SessionPoller] Stopping session polling");
+180        self.is_running.store(false, Ordering::Relaxed);
+181
+182        // Join the thread if possible (don't block indefinitely)
+183        if let Some(handle) = self.thread_handle.lock_safe().take() {
+184            let _ = handle.join();
+185        }
+186    }
+187
+188    /// Set UI hint for polling frequency adjustment
+189    pub fn set_polling_hint(&self, hint: PollingHint) {
+190        debug!("[SessionPoller] Setting polling hint: {:?}", hint);
+191        *self.current_hint.write_safe() = hint;
+192    }
+193
+194    /// Push the remote session's now-playing onto the Android lockscreen.
+195    ///
+196    /// Looks up the active remote session by id and forwards its title/artist/
+197    /// album, duration and position to the media notification. Silently does
+198    /// nothing if the session isn't found or has no now-playing item (e.g. the
+199    /// remote stopped) - the next state change will refresh it.
+200    fn push_remote_lockscreen(sessions: &[crate::jellyfin::client::SessionInfo], session_id: &str) {
+201        // 100ns Jellyfin ticks -> milliseconds.
+202        const TICKS_PER_MS: i64 = 10_000;
+203
+204        let Some(session) = sessions
+205            .iter()
+206            .find(|s| s.id.as_deref() == Some(session_id))
+207        else {
+208            return;
+209        };
+210
+211        let Some(now_playing) = session.now_playing_item.as_ref() else {
+212            return;
+213        };
+214
+215        let title = now_playing.name.clone().unwrap_or_default();
+216        let artist = now_playing
+217            .artists
+218            .as_ref()
+219            .map(|a| a.join(", "))
+220            .filter(|s| !s.is_empty())
+221            .or_else(|| now_playing.album_artist.clone())
+222            .unwrap_or_default();
+223        let album = now_playing.album.clone();
+224        let duration_ms = now_playing.run_time_ticks.unwrap_or(0) / TICKS_PER_MS;
+225
+226        let (position_ms, is_playing) = session
+227            .play_state
+228            .as_ref()
+229            .map(|ps| {
+230                (
+231                    ps.position_ticks.unwrap_or(0) / TICKS_PER_MS,
+232                    !ps.is_paused.unwrap_or(false),
+233                )
+234            })
+235            .unwrap_or((0, false));
+236
+237        let meta = crate::player::LockscreenMetadata {
+238            title,
+239            artist,
+240            album,
+241            duration_ms,
+242            position_ms,
+243            is_playing,
+244        };
+245
+246        if let Err(e) = crate::player::update_lockscreen_metadata(&meta) {
+247            warn!(
+248                "[SessionPoller] Failed to update lockscreen metadata: {}",
+249                e
+250            );
+251        }
+252    }
+253
+254    /// Calculate polling interval based on mode and hint
+255    fn calculate_interval(mode: &PlaybackMode, hint: PollingHint) -> u64 {
+256        match hint {
+257            PollingHint::CastActive => 500,      // Very fast for active control
+258            PollingHint::CastDiscovery => 15000, // Slow discovery
+259            PollingHint::Normal => {
+260                match mode {
+261                    PlaybackMode::Remote { .. } => 2000, // Fast in remote mode
+262                    PlaybackMode::Local | PlaybackMode::Idle => 10000, // Default
+263                }
+264            }
+265        }
+266    }
+267
+268    /// Manually trigger a poll (for frontend refresh button)
+269    pub async fn poll_now(&self) -> Result<Vec<crate::jellyfin::client::SessionInfo>, String> {
+270        let client = self
+271            .jellyfin_client
+272            .lock_safe()
+273            .clone()
+274            .ok_or("Jellyfin client not configured")?;
+275
+276        client.get_sessions().await
+277    }
+278}
+279
+280impl Drop for SessionPollerManager {
+281    fn drop(&mut self) {
+282        self.stop();
+283    }
+284}
+285
+286#[cfg(test)]
+287mod tests {
+288    use super::*;
+289
+290    /// Test polling interval calculation for different modes and hints
+291    #[test]
+292    fn test_calculate_interval() {
+293        // CastActive hint should always be 500ms regardless of mode
+294        assert_eq!(
+295            SessionPollerManager::calculate_interval(&PlaybackMode::Idle, PollingHint::CastActive),
+296            500
+297        );
+298        assert_eq!(
+299            SessionPollerManager::calculate_interval(&PlaybackMode::Local, PollingHint::CastActive),
+300            500
+301        );
+302        assert_eq!(
+303            SessionPollerManager::calculate_interval(
+304                &PlaybackMode::Remote {
+305                    session_id: "test".to_string()
+306                },
+307                PollingHint::CastActive
+308            ),
+309            500
+310        );
+311
+312        // CastDiscovery hint should always be 15s regardless of mode
+313        assert_eq!(
+314            SessionPollerManager::calculate_interval(
+315                &PlaybackMode::Idle,
+316                PollingHint::CastDiscovery
+317            ),
+318            15000
+319        );
+320        assert_eq!(
+321            SessionPollerManager::calculate_interval(
+322                &PlaybackMode::Local,
+323                PollingHint::CastDiscovery
+324            ),
+325            15000
+326        );
+327        assert_eq!(
+328            SessionPollerManager::calculate_interval(
+329                &PlaybackMode::Remote {
+330                    session_id: "test".to_string()
+331                },
+332                PollingHint::CastDiscovery
+333            ),
+334            15000
+335        );
+336
+337        // Normal hint should depend on mode
+338        // Idle and Local modes -> 10s
+339        assert_eq!(
+340            SessionPollerManager::calculate_interval(&PlaybackMode::Idle, PollingHint::Normal),
+341            10000
+342        );
+343        assert_eq!(
+344            SessionPollerManager::calculate_interval(&PlaybackMode::Local, PollingHint::Normal),
+345            10000
+346        );
+347        // Remote mode -> 2s
+348        assert_eq!(
+349            SessionPollerManager::calculate_interval(
+350                &PlaybackMode::Remote {
+351                    session_id: "test".to_string()
+352                },
+353                PollingHint::Normal
+354            ),
+355            2000
+356        );
+357    }
+358
+359    /// Test PollingHint enum equality
+360    #[test]
+361    fn test_polling_hint_equality() {
+362        assert_eq!(PollingHint::Normal, PollingHint::Normal);
+363        assert_eq!(PollingHint::CastActive, PollingHint::CastActive);
+364        assert_eq!(PollingHint::CastDiscovery, PollingHint::CastDiscovery);
+365
+366        assert_ne!(PollingHint::Normal, PollingHint::CastActive);
+367        assert_ne!(PollingHint::CastActive, PollingHint::CastDiscovery);
+368    }
+369}
+
\ No newline at end of file diff --git a/api/src/jellytau_lib/settings.rs.html b/api/src/jellytau_lib/settings.rs.html new file mode 100644 index 00000000..15e37685 --- /dev/null +++ b/api/src/jellytau_lib/settings.rs.html @@ -0,0 +1,730 @@ +settings.rs - sourceSkip to main content

jellytau_lib/
settings.rs

1//! TRACES: UR-023, UR-027, UR-031, UR-032, UR-033 | DR-030, DR-034, DR-035, DR-036, DR-048, IR-020
+2
+3use serde::{Deserialize, Serialize};
+4
+5/// Volume normalization levels matching Spotify's presets
+6#[derive(specta::Type, Debug, Clone, Copy, PartialEq, Serialize, Deserialize, Default)]
+7#[serde(rename_all = "lowercase")]
+8pub enum VolumeLevel {
+9    /// Louder output (-11 LUFS)
+10    Loud,
+11    /// Default level (-14 LUFS)
+12    #[default]
+13    Normal,
+14    /// Quieter output (-23 LUFS)
+15    Quiet,
+16}
+17
+18impl VolumeLevel {
+19    /// Get the target LUFS value for this volume level
+20    pub fn target_lufs(&self) -> f32 {
+21        match self {
+22            VolumeLevel::Loud => -11.0,
+23            VolumeLevel::Normal => -14.0,
+24            VolumeLevel::Quiet => -23.0,
+25        }
+26    }
+27}
+28
+29/// Centre frequencies (Hz) of the fixed 10-band ISO equalizer. The band count
+30/// and layout are a property of the audio engine, not the UI — presets and the
+31/// MPV filter are defined against these bands. See
+32/// docs/architecture/05-platform-backends.md ("The equalizer, and where its
+33/// vocabulary lives").
+34///
+35/// TRACES: UR-027 | DR-030, IR-020
+36pub const EQ_BANDS: [f32; 10] = [
+37    31.0, 62.0, 125.0, 250.0, 500.0, 1000.0, 2000.0, 4000.0, 8000.0, 16000.0,
+38];
+39/// Minimum per-band gain in dB.
+40pub const EQ_GAIN_MIN: f32 = -12.0;
+41/// Maximum per-band gain in dB.
+42pub const EQ_GAIN_MAX: f32 = 12.0;
+43
+44/// Built-in equalizer presets. A preset *is* a gain curve defined by the band
+45/// layout above (a domain concept), not a mere label — the curve numbers live
+46/// in Rust so the frontend never encodes the taxonomy.
+47///
+48/// TRACES: UR-027 | DR-030
+49#[derive(specta::Type, Debug, Clone, Copy, PartialEq, Serialize, Deserialize)]
+50#[serde(rename_all = "camelCase")]
+51pub enum EqPreset {
+52    Flat,
+53    Rock,
+54    Pop,
+55    Jazz,
+56    Classical,
+57    BassBoost,
+58    TrebleBoost,
+59    Vocal,
+60}
+61
+62impl EqPreset {
+63    /// All presets, for enumerating the curve table across the IPC boundary.
+64    pub const ALL: [EqPreset; 8] = [
+65        EqPreset::Flat,
+66        EqPreset::Rock,
+67        EqPreset::Pop,
+68        EqPreset::Jazz,
+69        EqPreset::Classical,
+70        EqPreset::BassBoost,
+71        EqPreset::TrebleBoost,
+72        EqPreset::Vocal,
+73    ];
+74
+75    /// The 10-band gain curve (dB) for this preset, one entry per [`EQ_BANDS`].
+76    /// Curves are conservative (within ±8 dB) so presets stack safely with the
+77    /// player volume. Bands: 31 62 125 250 500 1k 2k 4k 8k 16k.
+78    pub fn gains(&self) -> [f32; 10] {
+79        match self {
+80            EqPreset::Flat => [0.0; 10],
+81            EqPreset::Rock => [5.0, 4.0, 3.0, 1.0, -1.0, -1.0, 1.0, 3.0, 4.0, 5.0],
+82            EqPreset::Pop => [-1.0, 0.0, 2.0, 4.0, 5.0, 4.0, 2.0, 0.0, -1.0, -1.0],
+83            EqPreset::Jazz => [3.0, 2.0, 1.0, 2.0, -1.0, -1.0, 0.0, 1.0, 2.0, 3.0],
+84            EqPreset::Classical => [4.0, 3.0, 2.0, 1.0, -1.0, -1.0, 0.0, 2.0, 3.0, 4.0],
+85            EqPreset::BassBoost => [7.0, 6.0, 5.0, 3.0, 1.0, 0.0, 0.0, 0.0, 0.0, 0.0],
+86            EqPreset::TrebleBoost => [0.0, 0.0, 0.0, 0.0, 0.0, 1.0, 3.0, 5.0, 6.0, 7.0],
+87            EqPreset::Vocal => [-2.0, -1.0, 0.0, 2.0, 4.0, 5.0, 4.0, 2.0, 0.0, -1.0],
+88        }
+89    }
+90}
+91
+92/// Audio playback settings
+93#[derive(specta::Type, Debug, Clone, Serialize, Deserialize)]
+94#[serde(rename_all = "camelCase")]
+95pub struct AudioSettings {
+96    /// Crossfade duration in seconds (0 = disabled, max 12)
+97    pub crossfade_duration: f32,
+98    /// Enable gapless playback between tracks
+99    pub gapless_playback: bool,
+100    /// Enable volume normalization
+101    pub normalize_volume: bool,
+102    /// Target volume level for normalization
+103    pub volume_level: VolumeLevel,
+104    /// Enable the graphic equalizer. When false, no EQ filter is applied.
+105    #[serde(default)]
+106    pub equalizer_enabled: bool,
+107    /// Per-band gains in dB, one per [`EQ_BANDS`]. Normalised to 10 entries and
+108    /// clamped to [`EQ_GAIN_MIN`, `EQ_GAIN_MAX`] via [`Self::with_equalizer_normalised`].
+109    #[serde(default = "default_eq_bands")]
+110    pub equalizer_bands: Vec<f32>,
+111}
+112
+113/// Flat 10-band curve — the default equalizer state.
+114fn default_eq_bands() -> Vec<f32> {
+115    vec![0.0; EQ_BANDS.len()]
+116}
+117
+118impl Default for AudioSettings {
+119    fn default() -> Self {
+120        Self {
+121            crossfade_duration: 0.0,
+122            gapless_playback: true,
+123            normalize_volume: false,
+124            volume_level: VolumeLevel::Normal,
+125            equalizer_enabled: false,
+126            equalizer_bands: default_eq_bands(),
+127        }
+128    }
+129}
+130
+131impl AudioSettings {
+132    /// Clamp crossfade duration to valid range (0-12 seconds)
+133    pub fn with_crossfade_clamped(mut self) -> Self {
+134        self.crossfade_duration = self.crossfade_duration.clamp(0.0, 12.0);
+135        self
+136    }
+137
+138    /// Normalise the equalizer band vector to exactly [`EQ_BANDS`]`.len()`
+139    /// entries (pad with 0 dB / truncate) and clamp each gain to the valid
+140    /// range. Guards against malformed persisted or IPC input.
+141    ///
+142    /// TRACES: UR-027 | DR-030
+143    pub fn with_equalizer_normalised(mut self) -> Self {
+144        let n = EQ_BANDS.len();
+145        self.equalizer_bands.resize(n, 0.0);
+146        for g in &mut self.equalizer_bands {
+147            *g = g.clamp(EQ_GAIN_MIN, EQ_GAIN_MAX);
+148        }
+149        self
+150    }
+151}
+152
+153/// A ceiling on how much bandwidth a *video* stream may consume.
+154///
+155/// A quality step is a bundle of concrete transcode parameters — total stream
+156/// ceiling, the audio share of it, and the resolution that ceiling can carry —
+157/// not just a label. Those numbers are Jellyfin encoding domain vocabulary, so
+158/// they live here and the frontend only ever names a variant; the labels the
+159/// picker shows are served over IPC by `player_get_streaming_qualities`.
+160///
+161/// The ladder is deliberately expressed in bandwidth rather than resolution: it
+162/// exists to fit a connection, and the resolution cap is chosen *from* the
+163/// bitrate so the encoder does not spend a small budget on pixels it cannot
+164/// afford. See docs/architecture/01-rust-backend.md ("Streaming quality
+165/// ladder").
+166///
+167/// TRACES: UR-074 | DR-162
+168#[derive(specta::Type, Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
+169#[serde(rename_all = "camelCase")]
+170pub enum StreamingQuality {
+171    /// No client-imposed cap — the server may direct-play the source as-is.
+172    #[default]
+173    Original,
+174    Mbps20,
+175    Mbps10,
+176    Mbps8,
+177    Mbps4,
+178    Mbps2,
+179    Mbps1,
+180    Kbps720,
+181}
+182
+183impl StreamingQuality {
+184    /// The ladder, highest first, for enumerating across the IPC boundary.
+185    pub const ALL: [StreamingQuality; 8] = [
+186        StreamingQuality::Original,
+187        StreamingQuality::Mbps20,
+188        StreamingQuality::Mbps10,
+189        StreamingQuality::Mbps8,
+190        StreamingQuality::Mbps4,
+191        StreamingQuality::Mbps2,
+192        StreamingQuality::Mbps1,
+193        StreamingQuality::Kbps720,
+194    ];
+195
+196    /// Total bits per second the stream may use (video + audio), or `None` for
+197    /// the uncapped `Original`.
+198    ///
+199    /// This is the number that goes to `PlaybackInfo` as `MaxStreamingBitrate`
+200    /// and into the device profile. Sending it there — not just on the transcode
+201    /// URL — is what makes the cap real: a stream the server decides to *direct
+202    /// play* is served at the source file's own bitrate, and no URL parameter
+203    /// afterwards can reduce it.
+204    pub fn max_bitrate(&self) -> Option<u64> {
+205        match self {
+206            StreamingQuality::Original => None,
+207            StreamingQuality::Mbps20 => Some(20_000_000),
+208            StreamingQuality::Mbps10 => Some(10_000_000),
+209            StreamingQuality::Mbps8 => Some(8_000_000),
+210            StreamingQuality::Mbps4 => Some(4_000_000),
+211            StreamingQuality::Mbps2 => Some(2_000_000),
+212            StreamingQuality::Mbps1 => Some(1_000_000),
+213            StreamingQuality::Kbps720 => Some(720_000),
+214        }
+215    }
+216
+217    /// Bits per second allotted to the audio track.
+218    ///
+219    /// The value shrinks with the ladder because at the bottom rungs a fixed
+220    /// 384 kbps would be a third of the entire budget.
+221    pub fn audio_bitrate(&self) -> u64 {
+222        match self {
+223            StreamingQuality::Original
+224            | StreamingQuality::Mbps20
+225            | StreamingQuality::Mbps10
+226            | StreamingQuality::Mbps8 => 384_000,
+227            StreamingQuality::Mbps4 => 256_000,
+228            StreamingQuality::Mbps2 => 192_000,
+229            StreamingQuality::Mbps1 => 128_000,
+230            StreamingQuality::Kbps720 => 96_000,
+231        }
+232    }
+233
+234    /// Bits per second allotted to the video track: the total minus the audio
+235    /// share, so the two together honour [`max_bitrate`](Self::max_bitrate)
+236    /// rather than overshooting it by the size of the audio track.
+237    pub fn video_bitrate(&self) -> Option<u64> {
+238        self.max_bitrate()
+239            .map(|total| total.saturating_sub(self.audio_bitrate()))
+240    }
+241
+242    /// Resolution ceiling that suits the bitrate, or `None` to leave the source
+243    /// resolution alone. Scaling down is what keeps a small budget looking like
+244    /// clean video instead of blocky 1080p.
+245    pub fn max_height(&self) -> Option<u32> {
+246        match self {
+247            // 20 Mbps carries 4K, so it caps bandwidth without capping pixels.
+248            StreamingQuality::Original | StreamingQuality::Mbps20 => None,
+249            StreamingQuality::Mbps10 | StreamingQuality::Mbps8 => Some(1080),
+250            StreamingQuality::Mbps4 | StreamingQuality::Mbps2 => Some(720),
+251            StreamingQuality::Mbps1 => Some(480),
+252            StreamingQuality::Kbps720 => Some(360),
+253        }
+254    }
+255
+256    /// Human label for the picker. Lives in Rust with the numbers it describes,
+257    /// so the two cannot drift apart.
+258    pub fn label(&self) -> &'static str {
+259        match self {
+260            StreamingQuality::Original => "Original",
+261            StreamingQuality::Mbps20 => "20 Mbps",
+262            StreamingQuality::Mbps10 => "10 Mbps",
+263            StreamingQuality::Mbps8 => "8 Mbps",
+264            StreamingQuality::Mbps4 => "4 Mbps",
+265            StreamingQuality::Mbps2 => "2 Mbps",
+266            StreamingQuality::Mbps1 => "1 Mbps",
+267            StreamingQuality::Kbps720 => "720 kbps",
+268        }
+269    }
+270
+271    /// Secondary line for the picker: what the cap means in practice.
+272    pub fn detail(&self) -> &'static str {
+273        match self {
+274            StreamingQuality::Original => "No limit — highest quality",
+275            StreamingQuality::Mbps20 => "Up to 4K",
+276            StreamingQuality::Mbps10 => "1080p, high quality",
+277            StreamingQuality::Mbps8 => "1080p",
+278            StreamingQuality::Mbps4 => "720p",
+279            StreamingQuality::Mbps2 => "720p, reduced",
+280            StreamingQuality::Mbps1 => "480p",
+281            StreamingQuality::Kbps720 => "360p — slowest connections",
+282        }
+283    }
+284}
+285
+286/// Video playback settings
+287#[derive(specta::Type, Debug, Clone, Serialize, Deserialize)]
+288#[serde(rename_all = "camelCase")]
+289pub struct VideoSettings {
+290    /// Enable auto-play of next episode (with countdown)
+291    pub auto_play_next_episode: bool,
+292    /// Countdown duration in seconds before auto-play (5-30 seconds)
+293    pub auto_play_countdown_seconds: u32,
+294    /// Maximum number of episodes to auto-play consecutively (0 = unlimited)
+295    #[serde(default)]
+296    pub auto_play_max_episodes: u32,
+297    /// Bandwidth ceiling applied to every video stream.
+298    ///
+299    /// `#[serde(default)]` so settings JSON persisted before this field existed
+300    /// loads as the previous behaviour (uncapped).
+301    ///
+302    /// TRACES: UR-074 | DR-162
+303    #[serde(default)]
+304    pub streaming_quality: StreamingQuality,
+305}
+306
+307impl Default for VideoSettings {
+308    fn default() -> Self {
+309        Self {
+310            auto_play_next_episode: true,
+311            auto_play_countdown_seconds: 10,
+312            auto_play_max_episodes: 0,
+313            streaming_quality: StreamingQuality::Original,
+314        }
+315    }
+316}
+317
+318impl VideoSettings {
+319    /// Clamp countdown duration to valid range (5-30 seconds)
+320    pub fn with_countdown_clamped(mut self) -> Self {
+321        self.auto_play_countdown_seconds = self.auto_play_countdown_seconds.clamp(5, 30);
+322        self
+323    }
+324}
+325
+326/// Library browsing preferences.
+327///
+328/// Currently a single list: the folders (or whole libraries) the user has asked
+329/// to keep out of browsing. It is a *list of ids*, never names — names are
+330/// unstable, locale-dependent and non-unique, and the hardcoded name filter this
+331/// setting replaced broke on exactly that. What the ids then hide is decided in
+332/// `repository::exclusions`; this struct is only how the choice is carried and
+333/// persisted.
+334///
+335/// The default is an empty list: nobody inherits another user's folder layout.
+336///
+337/// TRACES: UR-076 | DR-209
+338#[derive(specta::Type, Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
+339#[serde(rename_all = "camelCase")]
+340pub struct LibrarySettings {
+341    /// Stable item ids of the folders/libraries hidden from browsing.
+342    ///
+343    /// `#[serde(default)]` so settings JSON persisted before this field existed
+344    /// loads as the previous behaviour (nothing hidden).
+345    #[serde(default)]
+346    pub excluded_item_ids: Vec<String>,
+347}
+348
+349impl LibrarySettings {
+350    /// Drop blanks and duplicates from the id list.
+351    ///
+352    /// Applied on the way in from IPC and on the way out of the database, so a
+353    /// hand-edited or half-written value cannot make the list grow without bound
+354    /// or carry an empty id (which would match nothing but still be shown as a
+355    /// selection in the picker).
+356    ///
+357    /// TRACES: UR-076 | DR-209
+358    pub fn sanitised(mut self) -> Self {
+359        let mut seen: Vec<String> = Vec::with_capacity(self.excluded_item_ids.len());
+360        for id in self.excluded_item_ids.drain(..) {
+361            let id = id.trim().to_string();
+362            if id.is_empty() || seen.contains(&id) {
+363                continue;
+364            }
+365            seen.push(id);
+366        }
+367        self.excluded_item_ids = seen;
+368        self
+369    }
+370}
+371
+372/// Serialise `AudioSettings` into the JSON payload handed to the Android player
+373/// over JNI.
+374///
+375/// Sanitises first (crossfade clamped, band vector normalised) so a malformed
+376/// vector can never reach the Kotlin parser. JSON is used rather than a wide JNI
+377/// signature so that adding a field does not change the method signature — the
+378/// same approach `load()` already uses for subtitles.
+379///
+380/// The emitted keys are camelCase (serde) and `volumeLevel` is lowercase; the
+381/// Kotlin side matches on those literals. Both are pinned by tests.
+382///
+383/// TRACES: UR-027, UR-032, UR-033 | DR-030, DR-035, DR-036
+384pub fn audio_settings_jni_payload(settings: &AudioSettings) -> Result<String, serde_json::Error> {
+385    let sanitised = settings
+386        .clone()
+387        .with_crossfade_clamped()
+388        .with_equalizer_normalised();
+389    serde_json::to_string(&sanitised)
+390}
+391
+392#[cfg(test)]
+393mod tests {
+394    use super::*;
+395
+396    /// The JNI payload must sanitise before serialising: an over-long crossfade
+397    /// is clamped and a wrong-length band vector is normalised to EQ_BANDS.len().
+398    /// Sending raw values would let a malformed vector reach the Kotlin parser.
+399    ///
+400    /// TRACES: UR-027, UR-032, UR-033 | DR-030, DR-035, DR-036 | UT-AUDIO-JNI-1
+401    #[test]
+402    fn test_audio_settings_jni_payload_is_sanitised() {
+403        let settings = AudioSettings {
+404            crossfade_duration: 30.0,
+405            equalizer_bands: vec![20.0, -30.0],
+406            ..AudioSettings::default()
+407        };
+408
+409        let json = audio_settings_jni_payload(&settings).expect("serialises");
+410        let v: serde_json::Value = serde_json::from_str(&json).expect("valid JSON");
+411
+412        assert_eq!(v["crossfadeDuration"], 12.0, "crossfade clamped to 12s");
+413
+414        let bands = v["equalizerBands"].as_array().expect("bands array");
+415        assert_eq!(bands.len(), EQ_BANDS.len(), "band vector normalised to 10");
+416        assert_eq!(bands[0], EQ_GAIN_MAX as f64, "gain clamped to +12dB");
+417        assert_eq!(bands[1], EQ_GAIN_MIN as f64, "gain clamped to -12dB");
+418    }
+419
+420    /// The Kotlin side parses these exact keys. camelCase is what serde emits
+421    /// for AudioSettings; a rename here silently breaks the Android parser,
+422    /// which is why the contract is pinned by a test rather than by convention.
+423    ///
+424    /// TRACES: UR-027, UR-032, UR-033 | DR-030, DR-035, DR-036 | UT-AUDIO-JNI-2
+425    #[test]
+426    fn test_audio_settings_jni_payload_key_contract() {
+427        let json = audio_settings_jni_payload(&AudioSettings::default()).expect("serialises");
+428        let v: serde_json::Value = serde_json::from_str(&json).expect("valid JSON");
+429
+430        for key in [
+431            "crossfadeDuration",
+432            "gaplessPlayback",
+433            "normalizeVolume",
+434            "volumeLevel",
+435            "equalizerEnabled",
+436            "equalizerBands",
+437        ] {
+438            assert!(v.get(key).is_some(), "JNI payload must carry `{key}`");
+439        }
+440
+441        // VolumeLevel is #[serde(rename_all = "lowercase")]; Kotlin matches on
+442        // these literals.
+443        assert_eq!(v["volumeLevel"], "normal");
+444    }
+445
+446    #[test]
+447    fn test_default_settings() {
+448        let settings = AudioSettings::default();
+449        assert_eq!(settings.crossfade_duration, 0.0);
+450        assert!(settings.gapless_playback);
+451        assert!(!settings.normalize_volume);
+452        assert_eq!(settings.volume_level, VolumeLevel::Normal);
+453        // Equalizer defaults: disabled and flat.
+454        assert!(!settings.equalizer_enabled);
+455        assert_eq!(settings.equalizer_bands, vec![0.0; EQ_BANDS.len()]);
+456    }
+457
+458    /// EQ presets each return one gain per band; Flat is all zeros.
+459    ///
+460    /// TRACES: UR-027 | DR-030 | UT-079
+461    #[test]
+462    fn test_eq_preset_curves() {
+463        for preset in EqPreset::ALL {
+464            assert_eq!(
+465                preset.gains().len(),
+466                EQ_BANDS.len(),
+467                "preset {:?} must have one gain per band",
+468                preset
+469            );
+470            // Every preset stays within the advertised gain range.
+471            for g in preset.gains() {
+472                assert!(
+473                    (EQ_GAIN_MIN..=EQ_GAIN_MAX).contains(&g),
+474                    "preset {:?} gain {} out of range",
+475                    preset,
+476                    g
+477                );
+478            }
+479        }
+480        assert_eq!(EqPreset::Flat.gains(), [0.0; 10]);
+481        // Bass boost lifts the low bands and leaves the top flat.
+482        let bass = EqPreset::BassBoost.gains();
+483        assert!(bass[0] > 0.0 && bass[9] == 0.0);
+484    }
+485
+486    /// `with_equalizer_normalised` clamps out-of-range gains and forces the
+487    /// band vector to exactly EQ_BANDS.len() (pad short, truncate long).
+488    ///
+489    /// TRACES: UR-027 | DR-030 | UT-080
+490    #[test]
+491    fn test_eq_normalisation() {
+492        // Out-of-range gains are clamped.
+493        let s = AudioSettings {
+494            equalizer_bands: vec![100.0, -100.0, 3.0],
+495            ..Default::default()
+496        }
+497        .with_equalizer_normalised();
+498        assert_eq!(s.equalizer_bands.len(), EQ_BANDS.len());
+499        assert_eq!(s.equalizer_bands[0], EQ_GAIN_MAX);
+500        assert_eq!(s.equalizer_bands[1], EQ_GAIN_MIN);
+501        assert_eq!(s.equalizer_bands[2], 3.0);
+502        // Short vector padded with 0 dB.
+503        assert_eq!(s.equalizer_bands[9], 0.0);
+504
+505        // Over-long vector truncated.
+506        let long = AudioSettings {
+507            equalizer_bands: vec![1.0; 20],
+508            ..Default::default()
+509        }
+510        .with_equalizer_normalised();
+511        assert_eq!(long.equalizer_bands.len(), EQ_BANDS.len());
+512    }
+513
+514    /// Old persisted JSON without the EQ fields loads as disabled + flat.
+515    ///
+516    /// TRACES: UR-027 | DR-030 | UT-081
+517    #[test]
+518    fn test_audio_settings_eq_backward_compat() {
+519        let json = r#"{"crossfadeDuration":0.0,"gaplessPlayback":true,"normalizeVolume":false,"volumeLevel":"normal"}"#;
+520        let parsed: AudioSettings = serde_json::from_str(json).unwrap();
+521        assert!(!parsed.equalizer_enabled);
+522        assert_eq!(parsed.equalizer_bands, vec![0.0; EQ_BANDS.len()]);
+523    }
+524
+525    /// EQ fields serialize as camelCase and round-trip.
+526    ///
+527    /// TRACES: UR-027 | DR-030 | UT-082
+528    #[test]
+529    fn test_audio_settings_eq_serialization() {
+530        let settings = AudioSettings {
+531            equalizer_enabled: true,
+532            equalizer_bands: EqPreset::Rock.gains().to_vec(),
+533            ..Default::default()
+534        };
+535        let json = serde_json::to_string(&settings).unwrap();
+536        assert!(json.contains("\"equalizerEnabled\":true"));
+537        assert!(json.contains("\"equalizerBands\":"));
+538
+539        let parsed: AudioSettings = serde_json::from_str(&json).unwrap();
+540        assert!(parsed.equalizer_enabled);
+541        assert_eq!(parsed.equalizer_bands, EqPreset::Rock.gains().to_vec());
+542    }
+543
+544    #[test]
+545    fn test_volume_level_lufs() {
+546        assert_eq!(VolumeLevel::Loud.target_lufs(), -11.0);
+547        assert_eq!(VolumeLevel::Normal.target_lufs(), -14.0);
+548        assert_eq!(VolumeLevel::Quiet.target_lufs(), -23.0);
+549    }
+550
+551    #[test]
+552    fn test_crossfade_clamping() {
+553        let settings = AudioSettings {
+554            crossfade_duration: 20.0,
+555            ..Default::default()
+556        }
+557        .with_crossfade_clamped();
+558        assert_eq!(settings.crossfade_duration, 12.0);
+559
+560        let settings = AudioSettings {
+561            crossfade_duration: -5.0,
+562            ..Default::default()
+563        }
+564        .with_crossfade_clamped();
+565        assert_eq!(settings.crossfade_duration, 0.0);
+566    }
+567
+568    #[test]
+569    fn test_settings_serialization() {
+570        let settings = AudioSettings {
+571            crossfade_duration: 5.0,
+572            gapless_playback: true,
+573            normalize_volume: true,
+574            volume_level: VolumeLevel::Loud,
+575            ..Default::default()
+576        };
+577
+578        let json = serde_json::to_string(&settings).unwrap();
+579        assert!(json.contains("\"crossfadeDuration\":5.0"));
+580        assert!(json.contains("\"gaplessPlayback\":true"));
+581        assert!(json.contains("\"normalizeVolume\":true"));
+582        assert!(json.contains("\"volumeLevel\":\"loud\""));
+583
+584        let parsed: AudioSettings = serde_json::from_str(&json).unwrap();
+585        assert_eq!(parsed.crossfade_duration, 5.0);
+586        assert_eq!(parsed.volume_level, VolumeLevel::Loud);
+587    }
+588
+589    #[test]
+590    fn test_video_default_settings() {
+591        let settings = VideoSettings::default();
+592        assert!(settings.auto_play_next_episode);
+593        assert_eq!(settings.auto_play_countdown_seconds, 10);
+594        assert_eq!(settings.auto_play_max_episodes, 0);
+595    }
+596
+597    #[test]
+598    fn test_video_countdown_clamping() {
+599        let settings = VideoSettings {
+600            auto_play_countdown_seconds: 60,
+601            ..Default::default()
+602        }
+603        .with_countdown_clamped();
+604        assert_eq!(settings.auto_play_countdown_seconds, 30);
+605
+606        let settings = VideoSettings {
+607            auto_play_countdown_seconds: 2,
+608            ..Default::default()
+609        }
+610        .with_countdown_clamped();
+611        assert_eq!(settings.auto_play_countdown_seconds, 5);
+612    }
+613
+614    #[test]
+615    fn test_video_settings_serialization() {
+616        let settings = VideoSettings {
+617            auto_play_next_episode: false,
+618            auto_play_countdown_seconds: 15,
+619            auto_play_max_episodes: 5,
+620            streaming_quality: StreamingQuality::Mbps4,
+621        };
+622
+623        let json = serde_json::to_string(&settings).unwrap();
+624        assert!(json.contains("\"autoPlayNextEpisode\":false"));
+625        assert!(json.contains("\"autoPlayCountdownSeconds\":15"));
+626        assert!(json.contains("\"autoPlayMaxEpisodes\":5"));
+627        assert!(json.contains("\"streamingQuality\":\"mbps4\""));
+628
+629        let parsed: VideoSettings = serde_json::from_str(&json).unwrap();
+630        assert!(!parsed.auto_play_next_episode);
+631        assert_eq!(parsed.auto_play_countdown_seconds, 15);
+632        assert_eq!(parsed.auto_play_max_episodes, 5);
+633    }
+634
+635    #[test]
+636    fn test_video_settings_backward_compat() {
+637        // Old JSON without auto_play_max_episodes field
+638        let json = r#"{"autoPlayNextEpisode":true,"autoPlayCountdownSeconds":10}"#;
+639        let parsed: VideoSettings = serde_json::from_str(json).unwrap();
+640        assert!(parsed.auto_play_next_episode);
+641        assert_eq!(parsed.auto_play_countdown_seconds, 10);
+642        assert_eq!(parsed.auto_play_max_episodes, 0);
+643        // Settings persisted before the cap existed must load as uncapped —
+644        // inventing a limit for an upgrading user would silently degrade their
+645        // picture with no setting having been changed.
+646        assert_eq!(parsed.streaming_quality, StreamingQuality::Original);
+647    }
+648
+649    /// The whole point of a step is the number of bits it promises not to
+650    /// exceed, so video + audio must fit inside the total — a video bitrate set
+651    /// to the full cap would overshoot it by the size of the audio track.
+652    ///
+653    /// TRACES: UR-074 | DR-162 | UT-157
+654    #[test]
+655    fn test_streaming_quality_budget_is_internally_consistent() {
+656        for quality in StreamingQuality::ALL {
+657            let Some(total) = quality.max_bitrate() else {
+658                assert_eq!(
+659                    quality,
+660                    StreamingQuality::Original,
+661                    "only Original may be uncapped"
+662                );
+663                assert!(quality.video_bitrate().is_none());
+664                assert!(quality.max_height().is_none());
+665                continue;
+666            };
+667
+668            let video = quality.video_bitrate().expect("a capped step caps video");
+669            assert_eq!(
+670                video + quality.audio_bitrate(),
+671                total,
+672                "{:?}: video + audio must equal the cap",
+673                quality
+674            );
+675            assert!(
+676                video > 0,
+677                "{:?}: audio must not consume the budget",
+678                quality
+679            );
+680            assert!(!quality.label().is_empty());
+681            assert!(!quality.detail().is_empty());
+682        }
+683    }
+684
+685    /// The ladder is presented to the user as descending, and the resolution cap
+686    /// must fall with it — a lower bitrate paired with a higher resolution would
+687    /// spend the smaller budget on more pixels, which is backwards.
+688    ///
+689    /// TRACES: UR-074 | DR-162 | UT-157
+690    #[test]
+691    fn test_streaming_quality_ladder_descends() {
+692        let steps = StreamingQuality::ALL;
+693        for pair in steps.windows(2) {
+694            let (higher, lower) = (pair[0], pair[1]);
+695            let higher_bitrate = higher.max_bitrate().unwrap_or(u64::MAX);
+696            let lower_bitrate = lower.max_bitrate().unwrap_or(u64::MAX);
+697            assert!(
+698                higher_bitrate > lower_bitrate,
+699                "{:?} must sit above {:?}",
+700                higher,
+701                lower
+702            );
+703            assert!(
+704                higher.max_height().unwrap_or(u32::MAX) >= lower.max_height().unwrap_or(u32::MAX),
+705                "{:?} must not cap resolution below {:?}",
+706                higher,
+707                lower
+708            );
+709            assert!(higher.audio_bitrate() >= lower.audio_bitrate());
+710        }
+711    }
+712
+713    /// The persisted form is the serde token, and it must survive a round trip —
+714    /// a rename here silently resets everyone's saved cap to uncapped.
+715    ///
+716    /// TRACES: UR-074 | DR-162 | UT-157
+717    #[test]
+718    fn test_streaming_quality_round_trips_through_json() {
+719        for quality in StreamingQuality::ALL {
+720            let json = serde_json::to_string(&quality).expect("serialises");
+721            let parsed: StreamingQuality = serde_json::from_str(&json).expect("parses back");
+722            assert_eq!(parsed, quality);
+723        }
+724        assert_eq!(
+725            serde_json::to_string(&StreamingQuality::Mbps10).unwrap(),
+726            "\"mbps10\""
+727        );
+728    }
+729}
+
\ No newline at end of file diff --git a/api/src/jellytau_lib/storage/db_service.rs.html b/api/src/jellytau_lib/storage/db_service.rs.html new file mode 100644 index 00000000..65eafd75 --- /dev/null +++ b/api/src/jellytau_lib/storage/db_service.rs.html @@ -0,0 +1,414 @@ +db_service.rs - sourceSkip to main content

jellytau_lib/storage/
db_service.rs

1//! Database service abstraction layer
+2//!
+3//! This module provides an async database interface that abstracts away
+4//! the underlying database implementation. This makes it easy to:
+5//! - Switch between sync (rusqlite) and async (tokio-rusqlite) implementations
+6//! - Prevent blocking the async runtime with synchronous database calls
+7//! - Test with different database backends
+8//! - Migrate to other database systems in the future
+9
+10use async_trait::async_trait;
+11use rusqlite::{params_from_iter, Connection, Result as SqliteResult, Row};
+12use std::sync::{Arc, Mutex};
+13
+14/// Database query result type
+15pub type DbResult<T> = Result<T, String>;
+16
+17/// Represents a database query that can be executed
+18#[derive(Clone)]
+19pub struct Query {
+20    pub sql: String,
+21    pub params: Vec<QueryParam>,
+22}
+23
+24/// Query parameter types supported by the database
+25#[derive(Clone, Debug)]
+26pub enum QueryParam {
+27    String(String),
+28    Int(i32),
+29    Int64(i64),
+30    Float(f64),
+31    #[allow(dead_code)]
+32    Bool(bool),
+33    Null,
+34}
+35
+36impl Query {
+37    pub fn new(sql: impl Into<String>) -> Self {
+38        Self {
+39            sql: sql.into(),
+40            params: Vec::new(),
+41        }
+42    }
+43
+44    pub fn with_params(sql: impl Into<String>, params: Vec<QueryParam>) -> Self {
+45        Self {
+46            sql: sql.into(),
+47            params,
+48        }
+49    }
+50}
+51
+52/// Database service trait - abstraction over database operations
+53#[async_trait]
+54pub trait DatabaseService: Send + Sync {
+55    /// Execute a query that doesn't return results (INSERT, UPDATE, DELETE)
+56    async fn execute(&self, query: Query) -> DbResult<usize>;
+57
+58    /// Execute a batch of SQL statements (for migrations)
+59    #[allow(dead_code)]
+60    async fn execute_batch(&self, sql: &str) -> DbResult<()>;
+61
+62    /// Query a single row
+63    async fn query_one<T, F>(&self, query: Query, mapper: F) -> DbResult<T>
+64    where
+65        T: Send + 'static,
+66        F: Fn(&Row) -> SqliteResult<T> + Send + 'static;
+67
+68    /// Query a single optional row
+69    async fn query_optional<T, F>(&self, query: Query, mapper: F) -> DbResult<Option<T>>
+70    where
+71        T: Send + 'static,
+72        F: Fn(&Row) -> SqliteResult<T> + Send + 'static;
+73
+74    /// Query multiple rows
+75    async fn query_many<T, F>(&self, query: Query, mapper: F) -> DbResult<Vec<T>>
+76    where
+77        T: Send + 'static,
+78        F: Fn(&Row) -> SqliteResult<T> + Send + 'static;
+79
+80    /// Run a transaction with multiple operations
+81    async fn transaction<F, T>(&self, f: F) -> DbResult<T>
+82    where
+83        F: FnOnce(&mut Transaction) -> DbResult<T> + Send + 'static,
+84        T: Send + 'static;
+85
+86    /// Get the row ID of the most recent successful INSERT
+87    async fn last_insert_rowid(&self) -> DbResult<i64>;
+88}
+89
+90/// Transaction handle for batching multiple operations
+91pub struct Transaction<'a> {
+92    conn: &'a Connection,
+93}
+94
+95impl<'a> Transaction<'a> {
+96    pub fn new(conn: &'a Connection) -> Self {
+97        Self { conn }
+98    }
+99
+100    pub fn execute(&mut self, query: Query) -> DbResult<usize> {
+101        execute_query(self.conn, query)
+102    }
+103
+104    pub fn query_many<T, F>(&self, query: Query, mapper: F) -> DbResult<Vec<T>>
+105    where
+106        F: Fn(&Row) -> SqliteResult<T>,
+107    {
+108        query_many(self.conn, query, mapper)
+109    }
+110}
+111
+112/// Rusqlite-based database service implementation
+113///
+114/// This implementation wraps synchronous rusqlite operations in tokio::task::spawn_blocking
+115/// to prevent blocking the async runtime.
+116pub struct RusqliteService {
+117    conn: Arc<Mutex<Connection>>,
+118}
+119
+120impl RusqliteService {
+121    pub fn new(conn: Arc<Mutex<Connection>>) -> Self {
+122        Self { conn }
+123    }
+124}
+125
+126#[async_trait]
+127impl DatabaseService for RusqliteService {
+128    async fn execute(&self, query: Query) -> DbResult<usize> {
+129        let conn = Arc::clone(&self.conn);
+130        tokio::task::spawn_blocking(move || {
+131            let conn = conn
+132                .lock()
+133                .map_err(|e| format!("Failed to lock connection: {}", e))?;
+134            execute_query(&conn, query)
+135        })
+136        .await
+137        .map_err(|e| format!("Task join error: {}", e))?
+138    }
+139
+140    async fn execute_batch(&self, sql: &str) -> DbResult<()> {
+141        let conn = Arc::clone(&self.conn);
+142        let sql = sql.to_string();
+143        tokio::task::spawn_blocking(move || {
+144            let conn = conn
+145                .lock()
+146                .map_err(|e| format!("Failed to lock connection: {}", e))?;
+147            conn.execute_batch(&sql)
+148                .map_err(|e| format!("Execute batch failed: {}", e))
+149        })
+150        .await
+151        .map_err(|e| format!("Task join error: {}", e))?
+152    }
+153
+154    async fn query_one<T, F>(&self, query: Query, mapper: F) -> DbResult<T>
+155    where
+156        T: Send + 'static,
+157        F: Fn(&Row) -> SqliteResult<T> + Send + 'static,
+158    {
+159        let conn = Arc::clone(&self.conn);
+160        tokio::task::spawn_blocking(move || {
+161            let conn = conn
+162                .lock()
+163                .map_err(|e| format!("Failed to lock connection: {}", e))?;
+164            query_one(&conn, query, mapper)
+165        })
+166        .await
+167        .map_err(|e| format!("Task join error: {}", e))?
+168    }
+169
+170    async fn query_optional<T, F>(&self, query: Query, mapper: F) -> DbResult<Option<T>>
+171    where
+172        T: Send + 'static,
+173        F: Fn(&Row) -> SqliteResult<T> + Send + 'static,
+174    {
+175        let conn = Arc::clone(&self.conn);
+176        tokio::task::spawn_blocking(move || {
+177            let conn = conn
+178                .lock()
+179                .map_err(|e| format!("Failed to lock connection: {}", e))?;
+180            query_optional(&conn, query, mapper)
+181        })
+182        .await
+183        .map_err(|e| format!("Task join error: {}", e))?
+184    }
+185
+186    async fn query_many<T, F>(&self, query: Query, mapper: F) -> DbResult<Vec<T>>
+187    where
+188        T: Send + 'static,
+189        F: Fn(&Row) -> SqliteResult<T> + Send + 'static,
+190    {
+191        let conn = Arc::clone(&self.conn);
+192        tokio::task::spawn_blocking(move || {
+193            let conn = conn
+194                .lock()
+195                .map_err(|e| format!("Failed to lock connection: {}", e))?;
+196            query_many(&conn, query, mapper)
+197        })
+198        .await
+199        .map_err(|e| format!("Task join error: {}", e))?
+200    }
+201
+202    async fn transaction<F, T>(&self, f: F) -> DbResult<T>
+203    where
+204        F: FnOnce(&mut Transaction) -> DbResult<T> + Send + 'static,
+205        T: Send + 'static,
+206    {
+207        let conn = Arc::clone(&self.conn);
+208        tokio::task::spawn_blocking(move || {
+209            let conn = conn
+210                .lock()
+211                .map_err(|e| format!("Failed to lock connection: {}", e))?;
+212
+213            conn.execute("BEGIN TRANSACTION", [])
+214                .map_err(|e| format!("Failed to begin transaction: {}", e))?;
+215
+216            let mut transaction = Transaction::new(&conn);
+217            let result = f(&mut transaction);
+218
+219            match result {
+220                Ok(value) => {
+221                    conn.execute("COMMIT", [])
+222                        .map_err(|e| format!("Failed to commit transaction: {}", e))?;
+223                    Ok(value)
+224                }
+225                Err(e) => {
+226                    conn.execute("ROLLBACK", [])
+227                        .map_err(|e| format!("Failed to rollback transaction: {}", e))?;
+228                    Err(e)
+229                }
+230            }
+231        })
+232        .await
+233        .map_err(|e| format!("Task join error: {}", e))?
+234    }
+235
+236    async fn last_insert_rowid(&self) -> DbResult<i64> {
+237        let conn = Arc::clone(&self.conn);
+238        tokio::task::spawn_blocking(move || {
+239            let conn = conn
+240                .lock()
+241                .map_err(|e| format!("Failed to lock connection: {}", e))?;
+242            Ok(conn.last_insert_rowid())
+243        })
+244        .await
+245        .map_err(|e| format!("Task join error: {}", e))?
+246    }
+247}
+248
+249// Helper functions for executing queries synchronously
+250
+251fn execute_query(conn: &Connection, query: Query) -> DbResult<usize> {
+252    let params = convert_params(&query.params);
+253    conn.execute(&query.sql, params_from_iter(params.iter()))
+254        .map_err(|e| format!("Execute failed: {}", e))
+255}
+256
+257fn query_one<T, F>(conn: &Connection, query: Query, mapper: F) -> DbResult<T>
+258where
+259    F: Fn(&Row) -> SqliteResult<T>,
+260{
+261    let params = convert_params(&query.params);
+262    conn.query_row(&query.sql, params_from_iter(params.iter()), mapper)
+263        .map_err(|e| format!("Query one failed: {}", e))
+264}
+265
+266fn query_optional<T, F>(conn: &Connection, query: Query, mapper: F) -> DbResult<Option<T>>
+267where
+268    F: Fn(&Row) -> SqliteResult<T>,
+269{
+270    match query_one(conn, query, mapper) {
+271        Ok(value) => Ok(Some(value)),
+272        Err(e) if e.contains("Query returned no rows") || e.contains("QueryReturnedNoRows") => {
+273            Ok(None)
+274        }
+275        Err(e) => Err(e),
+276    }
+277}
+278
+279fn query_many<T, F>(conn: &Connection, query: Query, mapper: F) -> DbResult<Vec<T>>
+280where
+281    F: Fn(&Row) -> SqliteResult<T>,
+282{
+283    let params = convert_params(&query.params);
+284    let mut stmt = conn
+285        .prepare(&query.sql)
+286        .map_err(|e| format!("Prepare failed: {}", e))?;
+287
+288    let rows = stmt
+289        .query_map(params_from_iter(params.iter()), mapper)
+290        .map_err(|e| format!("Query map failed: {}", e))?;
+291
+292    rows.collect::<SqliteResult<Vec<T>>>()
+293        .map_err(|e| format!("Collect failed: {}", e))
+294}
+295
+296/// Convert QueryParam to rusqlite::types::Value
+297fn convert_params(params: &[QueryParam]) -> Vec<rusqlite::types::Value> {
+298    params
+299        .iter()
+300        .map(|p| match p {
+301            QueryParam::String(s) => rusqlite::types::Value::Text(s.clone()),
+302            QueryParam::Int(i) => rusqlite::types::Value::Integer(*i as i64),
+303            QueryParam::Int64(i) => rusqlite::types::Value::Integer(*i),
+304            QueryParam::Float(f) => rusqlite::types::Value::Real(*f),
+305            QueryParam::Bool(b) => rusqlite::types::Value::Integer(if *b { 1 } else { 0 }),
+306            QueryParam::Null => rusqlite::types::Value::Null,
+307        })
+308        .collect()
+309}
+310
+311// TRACES: UR-002, UR-012 | DR-012 | UT-014, UT-015, UT-016, UT-017, UT-018, UT-019, UT-020, UT-021, UT-022, UT-023, UT-025
+312#[cfg(test)]
+313mod tests {
+314    use super::*;
+315
+316    #[tokio::test]
+317    async fn test_execute_query() {
+318        let conn = Connection::open_in_memory().unwrap();
+319        conn.execute_batch("CREATE TABLE test (id INTEGER PRIMARY KEY, name TEXT)")
+320            .unwrap();
+321
+322        let service = RusqliteService::new(Arc::new(Mutex::new(conn)));
+323
+324        let query = Query::with_params(
+325            "INSERT INTO test (name) VALUES (?)",
+326            vec![QueryParam::String("Alice".to_string())],
+327        );
+328
+329        let rows = service.execute(query).await.unwrap();
+330        assert_eq!(rows, 1);
+331    }
+332
+333    #[tokio::test]
+334    async fn test_query_one() {
+335        let conn = Connection::open_in_memory().unwrap();
+336        conn.execute_batch("CREATE TABLE test (id INTEGER PRIMARY KEY, name TEXT)")
+337            .unwrap();
+338        conn.execute("INSERT INTO test (name) VALUES ('Bob')", [])
+339            .unwrap();
+340
+341        let service = RusqliteService::new(Arc::new(Mutex::new(conn)));
+342
+343        let query = Query::new("SELECT name FROM test WHERE id = 1");
+344        let name: String = service.query_one(query, |row| row.get(0)).await.unwrap();
+345
+346        assert_eq!(name, "Bob");
+347    }
+348
+349    #[tokio::test]
+350    async fn test_query_many() {
+351        let conn = Connection::open_in_memory().unwrap();
+352        conn.execute_batch("CREATE TABLE test (id INTEGER PRIMARY KEY, name TEXT)")
+353            .unwrap();
+354        conn.execute("INSERT INTO test (name) VALUES ('Alice')", [])
+355            .unwrap();
+356        conn.execute("INSERT INTO test (name) VALUES ('Bob')", [])
+357            .unwrap();
+358
+359        let service = RusqliteService::new(Arc::new(Mutex::new(conn)));
+360
+361        let query = Query::new("SELECT name FROM test ORDER BY id");
+362        let names: Vec<String> = service.query_many(query, |row| row.get(0)).await.unwrap();
+363
+364        assert_eq!(names, vec!["Alice", "Bob"]);
+365    }
+366
+367    #[tokio::test]
+368    async fn test_query_optional() {
+369        let conn = Connection::open_in_memory().unwrap();
+370        conn.execute_batch("CREATE TABLE test (id INTEGER PRIMARY KEY, name TEXT)")
+371            .unwrap();
+372
+373        let service = RusqliteService::new(Arc::new(Mutex::new(conn)));
+374
+375        let query = Query::new("SELECT name FROM test WHERE id = 999");
+376        let result: Option<String> = service
+377            .query_optional(query, |row| row.get(0))
+378            .await
+379            .unwrap();
+380
+381        assert_eq!(result, None);
+382    }
+383
+384    #[tokio::test]
+385    async fn test_transaction() {
+386        let conn = Connection::open_in_memory().unwrap();
+387        conn.execute_batch("CREATE TABLE test (id INTEGER PRIMARY KEY, name TEXT)")
+388            .unwrap();
+389
+390        let service = RusqliteService::new(Arc::new(Mutex::new(conn)));
+391
+392        let result = service
+393            .transaction(|tx| {
+394                tx.execute(Query::with_params(
+395                    "INSERT INTO test (name) VALUES (?)",
+396                    vec![QueryParam::String("Alice".to_string())],
+397                ))?;
+398                tx.execute(Query::with_params(
+399                    "INSERT INTO test (name) VALUES (?)",
+400                    vec![QueryParam::String("Bob".to_string())],
+401                ))?;
+402                Ok(())
+403            })
+404            .await;
+405
+406        assert!(result.is_ok());
+407
+408        // Verify both rows were inserted
+409        let query = Query::new("SELECT COUNT(*) FROM test");
+410        let count: i32 = service.query_one(query, |row| row.get(0)).await.unwrap();
+411        assert_eq!(count, 2);
+412    }
+413}
+
\ No newline at end of file diff --git a/api/src/jellytau_lib/storage/mod.rs.html b/api/src/jellytau_lib/storage/mod.rs.html new file mode 100644 index 00000000..89043bc0 --- /dev/null +++ b/api/src/jellytau_lib/storage/mod.rs.html @@ -0,0 +1,766 @@ +mod.rs - sourceSkip to main content

jellytau_lib/storage/
mod.rs

1//! Offline storage module using SQLite
+2//!
+3//! Provides local caching of Jellyfin metadata, download management,
+4//! and offline mutation queue for sync-back operations.
+5
+6pub mod db_service;
+7pub mod models;
+8pub mod schema;
+9
+10use crate::utils::lock::MutexSafe;
+11use std::path::PathBuf;
+12use std::sync::{Arc, Mutex};
+13
+14use log::{debug, error, info};
+15use rusqlite::{Connection, Result as SqliteResult};
+16
+17pub use db_service::{DatabaseService, RusqliteService};
+18use schema::MIGRATIONS;
+19
+20/// Database connection wrapper with thread-safe access
+21pub struct Database {
+22    conn: Arc<Mutex<Connection>>,
+23    path: PathBuf,
+24}
+25
+26impl Database {
+27    /// Open or create the database at a specific path
+28    pub fn open(path: &PathBuf) -> SqliteResult<Self> {
+29        // Ensure parent directory exists
+30        if let Some(parent) = path.parent() {
+31            std::fs::create_dir_all(parent).ok();
+32        }
+33
+34        let conn = Connection::open(path)?;
+35
+36        // Enable foreign keys
+37        conn.execute_batch("PRAGMA foreign_keys = ON;")?;
+38
+39        // Enable WAL mode for better concurrent access
+40        conn.execute_batch("PRAGMA journal_mode = WAL;")?;
+41
+42        let db = Self {
+43            conn: Arc::new(Mutex::new(conn)),
+44            path: path.clone(),
+45        };
+46
+47        // Run migrations
+48        db.migrate()?;
+49
+50        Ok(db)
+51    }
+52
+53    /// Open an in-memory database (for testing)
+54    #[cfg(test)]
+55    pub fn open_in_memory() -> SqliteResult<Self> {
+56        let conn = Connection::open_in_memory()?;
+57
+58        // Enable foreign keys
+59        conn.execute_batch("PRAGMA foreign_keys = ON;")?;
+60
+61        let db = Self {
+62            conn: Arc::new(Mutex::new(conn)),
+63            path: PathBuf::from(":memory:"),
+64        };
+65
+66        // Run migrations
+67        db.migrate()?;
+68
+69        Ok(db)
+70    }
+71
+72    /// Get connection (for testing)
+73    #[cfg(test)]
+74    pub fn connection(&self) -> Arc<Mutex<Connection>> {
+75        Arc::clone(&self.conn)
+76    }
+77
+78    /// Run all pending migrations
+79    pub fn migrate(&self) -> SqliteResult<()> {
+80        info!("Starting database migrations...");
+81        let conn = self.conn.lock_safe();
+82
+83        // Create migrations table if it doesn't exist
+84        debug!("Creating _migrations table if it doesn't exist...");
+85        match conn.execute(
+86            "CREATE TABLE IF NOT EXISTS _migrations (
+87                id INTEGER PRIMARY KEY,
+88                name TEXT NOT NULL UNIQUE,
+89                applied_at TEXT DEFAULT CURRENT_TIMESTAMP
+90            )",
+91            [],
+92        ) {
+93            Ok(_) => debug!("_migrations table ready"),
+94            Err(e) => {
+95                error!("Failed to create _migrations table: {}", e);
+96                return Err(e);
+97            }
+98        }
+99
+100        // Get applied migrations
+101        debug!("Querying applied migrations...");
+102        let mut stmt = conn.prepare("SELECT name FROM _migrations")?;
+103        let applied: Vec<String> = stmt
+104            .query_map([], |row: &rusqlite::Row| row.get(0))?
+105            .filter_map(|r| r.ok())
+106            .collect();
+107        debug!("Found {} applied migrations", applied.len());
+108
+109        // Apply pending migrations
+110        for (name, sql) in MIGRATIONS {
+111            if !applied.contains(&name.to_string()) {
+112                info!("Applying migration: {}", name);
+113                match conn.execute_batch(sql) {
+114                    Ok(_) => {
+115                        info!("Successfully applied migration: {}", name);
+116                        match conn.execute("INSERT INTO _migrations (name) VALUES (?1)", [name]) {
+117                            Ok(_) => debug!("Recorded migration: {}", name),
+118                            Err(e) => {
+119                                error!("Failed to record migration {}: {}", name, e);
+120                                return Err(e);
+121                            }
+122                        }
+123                    }
+124                    Err(e) => {
+125                        error!("Failed to apply migration {}: {}", name, e);
+126                        return Err(e);
+127                    }
+128                }
+129            } else {
+130                debug!("Skipping already applied migration: {}", name);
+131            }
+132        }
+133
+134        info!("All migrations completed successfully");
+135        Ok(())
+136    }
+137
+138    /// Get a database service for async-safe operations
+139    ///
+140    /// This wraps all blocking database operations in spawn_blocking to prevent
+141    /// freezing the async runtime.
+142    pub fn service(&self) -> RusqliteService {
+143        RusqliteService::new(Arc::clone(&self.conn))
+144    }
+145
+146    /// Get the database file path
+147    pub fn path(&self) -> &PathBuf {
+148        &self.path
+149    }
+150
+151    /// Get database file size in bytes
+152    pub fn file_size(&self) -> Option<u64> {
+153        std::fs::metadata(&self.path).ok().map(|m| m.len())
+154    }
+155}
+156
+157// TRACES: UR-002, UR-012, UR-019, UR-025 | DR-012 | UT-014, UT-015, UT-016, UT-017, UT-018, UT-019, UT-020, UT-021, UT-022, UT-023, UT-025
+158#[cfg(test)]
+159mod tests {
+160    use super::*;
+161    use rusqlite::params;
+162
+163    #[test]
+164    fn test_open_in_memory() {
+165        let db = Database::open_in_memory().unwrap();
+166        assert_eq!(db.path().to_str(), Some(":memory:"));
+167    }
+168
+169    #[test]
+170    fn test_migrations_run() {
+171        let db = Database::open_in_memory().unwrap();
+172        let conn = db.connection();
+173        let conn = conn.lock_safe();
+174
+175        // Check that tables exist
+176        let mut stmt = conn
+177            .prepare("SELECT name FROM sqlite_master WHERE type='table' AND name='items'")
+178            .unwrap();
+179        let exists: Option<String> = stmt.query_row([], |row: &rusqlite::Row| row.get(0)).ok();
+180        assert!(exists.is_some());
+181    }
+182
+183    #[test]
+184    fn test_all_tables_created() {
+185        let db = Database::open_in_memory().unwrap();
+186        let conn = db.connection();
+187        let conn = conn.lock_safe();
+188
+189        let expected_tables = [
+190            "servers",
+191            "users",
+192            "libraries",
+193            "items",
+194            "media_streams",
+195            "user_data",
+196            "downloads",
+197            "sync_queue",
+198            "thumbnails",
+199            "playlists",
+200            "playlist_items",
+201            "genres",
+202        ];
+203
+204        for table in expected_tables {
+205            let exists: Option<String> = conn
+206                .query_row(
+207                    "SELECT name FROM sqlite_master WHERE type='table' AND name=?1",
+208                    [table],
+209                    |row: &rusqlite::Row| row.get(0),
+210                )
+211                .ok();
+212            assert!(exists.is_some(), "Table '{}' should exist", table);
+213        }
+214    }
+215
+216    #[test]
+217    fn test_fts_table_created() {
+218        let db = Database::open_in_memory().unwrap();
+219        let conn = db.connection();
+220        let conn = conn.lock_safe();
+221
+222        let exists: Option<String> = conn
+223            .query_row(
+224                "SELECT name FROM sqlite_master WHERE type='table' AND name='items_fts'",
+225                [],
+226                |row: &rusqlite::Row| row.get(0),
+227            )
+228            .ok();
+229        assert!(exists.is_some(), "FTS table 'items_fts' should exist");
+230    }
+231
+232    #[test]
+233    fn test_server_crud() {
+234        let db = Database::open_in_memory().unwrap();
+235        let conn = db.connection();
+236        let conn = conn.lock_safe();
+237
+238        // Insert a server
+239        conn.execute(
+240            "INSERT INTO servers (id, name, url, version) VALUES (?1, ?2, ?3, ?4)",
+241            params!["server1", "My Server", "http://localhost:8096", "10.8.0"],
+242        )
+243        .unwrap();
+244
+245        // Read it back
+246        let (name, url): (String, String) = conn
+247            .query_row(
+248                "SELECT name, url FROM servers WHERE id = ?1",
+249                ["server1"],
+250                |row: &rusqlite::Row| Ok((row.get(0)?, row.get(1)?)),
+251            )
+252            .unwrap();
+253        assert_eq!(name, "My Server");
+254        assert_eq!(url, "http://localhost:8096");
+255
+256        // Update it
+257        conn.execute(
+258            "UPDATE servers SET name = ?1 WHERE id = ?2",
+259            params!["Updated Server", "server1"],
+260        )
+261        .unwrap();
+262
+263        let name: String = conn
+264            .query_row(
+265                "SELECT name FROM servers WHERE id = ?1",
+266                ["server1"],
+267                |row: &rusqlite::Row| row.get(0),
+268            )
+269            .unwrap();
+270        assert_eq!(name, "Updated Server");
+271
+272        // Delete it
+273        conn.execute("DELETE FROM servers WHERE id = ?1", ["server1"])
+274            .unwrap();
+275
+276        let count: i32 = conn
+277            .query_row("SELECT COUNT(*) FROM servers", [], |row: &rusqlite::Row| {
+278                row.get(0)
+279            })
+280            .unwrap();
+281        assert_eq!(count, 0);
+282    }
+283
+284    #[test]
+285    fn test_user_crud() {
+286        let db = Database::open_in_memory().unwrap();
+287        let conn = db.connection();
+288        let conn = conn.lock_safe();
+289
+290        // Create a server first (foreign key)
+291        conn.execute(
+292            "INSERT INTO servers (id, name, url) VALUES (?1, ?2, ?3)",
+293            params!["server1", "Test Server", "http://localhost:8096"],
+294        )
+295        .unwrap();
+296
+297        // Insert a user
+298        conn.execute(
+299            "INSERT INTO users (id, server_id, username, is_active)
+300             VALUES (?1, ?2, ?3, ?4)",
+301            params!["user1", "server1", "admin", 1],
+302        )
+303        .unwrap();
+304
+305        // Read it back
+306        let (username, is_active): (String, i32) = conn
+307            .query_row(
+308                "SELECT username, is_active FROM users WHERE id = ?1",
+309                ["user1"],
+310                |row: &rusqlite::Row| Ok((row.get(0)?, row.get(1)?)),
+311            )
+312            .unwrap();
+313        assert_eq!(username, "admin");
+314        assert_eq!(is_active, 1);
+315
+316        // Update is_active
+317        conn.execute("UPDATE users SET is_active = 0 WHERE id = ?1", ["user1"])
+318            .unwrap();
+319
+320        let is_active: i32 = conn
+321            .query_row(
+322                "SELECT is_active FROM users WHERE id = ?1",
+323                ["user1"],
+324                |row: &rusqlite::Row| row.get(0),
+325            )
+326            .unwrap();
+327        assert_eq!(is_active, 0);
+328    }
+329
+330    #[test]
+331    fn test_cascade_delete_server_removes_users() {
+332        let db = Database::open_in_memory().unwrap();
+333        let conn = db.connection();
+334        let conn = conn.lock_safe();
+335
+336        // Create server and user
+337        conn.execute(
+338            "INSERT INTO servers (id, name, url) VALUES (?1, ?2, ?3)",
+339            params!["server1", "Test Server", "http://localhost:8096"],
+340        )
+341        .unwrap();
+342
+343        conn.execute(
+344            "INSERT INTO users (id, server_id, username) VALUES (?1, ?2, ?3)",
+345            params!["user1", "server1", "admin"],
+346        )
+347        .unwrap();
+348
+349        // Verify user exists
+350        let count: i32 = conn
+351            .query_row(
+352                "SELECT COUNT(*) FROM users WHERE server_id = ?1",
+353                ["server1"],
+354                |row: &rusqlite::Row| row.get(0),
+355            )
+356            .unwrap();
+357        assert_eq!(count, 1);
+358
+359        // Delete server
+360        conn.execute("DELETE FROM servers WHERE id = ?1", ["server1"])
+361            .unwrap();
+362
+363        // User should be deleted via CASCADE
+364        let count: i32 = conn
+365            .query_row("SELECT COUNT(*) FROM users", [], |row: &rusqlite::Row| {
+366                row.get(0)
+367            })
+368            .unwrap();
+369        assert_eq!(count, 0);
+370    }
+371
+372    #[test]
+373    fn test_item_insert_and_fts_search() {
+374        let db = Database::open_in_memory().unwrap();
+375        let conn = db.connection();
+376        let conn = conn.lock_safe();
+377
+378        // Create server first
+379        conn.execute(
+380            "INSERT INTO servers (id, name, url) VALUES (?1, ?2, ?3)",
+381            params!["server1", "Test Server", "http://localhost:8096"],
+382        )
+383        .unwrap();
+384
+385        // Insert an item
+386        conn.execute(
+387            "INSERT INTO items (id, server_id, name, item_type, overview, album_name, artists)
+388             VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7)",
+389            params![
+390                "item1",
+391                "server1",
+392                "Bohemian Rhapsody",
+393                "Audio",
+394                "A legendary rock song",
+395                "A Night at the Opera",
+396                "[\"Queen\"]"
+397            ],
+398        )
+399        .unwrap();
+400
+401        // Search via FTS
+402        let mut stmt = conn
+403            .prepare(
+404                "SELECT i.name FROM items i
+405                 JOIN items_fts ON i.rowid = items_fts.rowid
+406                 WHERE items_fts MATCH ?1",
+407            )
+408            .unwrap();
+409
+410        // Search by song name
+411        let result: Option<String> = stmt
+412            .query_row(["Bohemian"], |row: &rusqlite::Row| row.get(0))
+413            .ok();
+414        assert_eq!(result, Some("Bohemian Rhapsody".to_string()));
+415
+416        // Search by album name
+417        let result: Option<String> = stmt
+418            .query_row(["Opera"], |row: &rusqlite::Row| row.get(0))
+419            .ok();
+420        assert_eq!(result, Some("Bohemian Rhapsody".to_string()));
+421
+422        // Search by artist
+423        let result: Option<String> = stmt
+424            .query_row(["Queen"], |row: &rusqlite::Row| row.get(0))
+425            .ok();
+426        assert_eq!(result, Some("Bohemian Rhapsody".to_string()));
+427    }
+428
+429    #[test]
+430    fn test_user_data_playback_position() {
+431        let db = Database::open_in_memory().unwrap();
+432        let conn = db.connection();
+433        let conn = conn.lock_safe();
+434
+435        // Setup: server, user, item
+436        conn.execute(
+437            "INSERT INTO servers (id, name, url) VALUES (?1, ?2, ?3)",
+438            params!["server1", "Test", "http://localhost"],
+439        )
+440        .unwrap();
+441
+442        conn.execute(
+443            "INSERT INTO users (id, server_id, username) VALUES (?1, ?2, ?3)",
+444            params!["user1", "server1", "admin"],
+445        )
+446        .unwrap();
+447
+448        conn.execute(
+449            "INSERT INTO items (id, server_id, name, item_type) VALUES (?1, ?2, ?3, ?4)",
+450            params!["item1", "server1", "Test Movie", "Movie"],
+451        )
+452        .unwrap();
+453
+454        // Insert user data with playback position
+455        conn.execute(
+456            "INSERT INTO user_data (user_id, item_id, playback_position_ticks, is_played)
+457             VALUES (?1, ?2, ?3, ?4)",
+458            params!["user1", "item1", 12345678900_i64, 0],
+459        )
+460        .unwrap();
+461
+462        // Read back
+463        let (position, is_played): (i64, i32) = conn
+464            .query_row(
+465                "SELECT playback_position_ticks, is_played FROM user_data
+466                 WHERE user_id = ?1 AND item_id = ?2",
+467                ["user1", "item1"],
+468                |row: &rusqlite::Row| Ok((row.get(0)?, row.get(1)?)),
+469            )
+470            .unwrap();
+471        assert_eq!(position, 12345678900);
+472        assert_eq!(is_played, 0);
+473
+474        // Update to mark as played
+475        conn.execute(
+476            "UPDATE user_data SET is_played = 1, playback_position_ticks = 0
+477             WHERE user_id = ?1 AND item_id = ?2",
+478            ["user1", "item1"],
+479        )
+480        .unwrap();
+481
+482        let is_played: i32 = conn
+483            .query_row(
+484                "SELECT is_played FROM user_data WHERE user_id = ?1 AND item_id = ?2",
+485                ["user1", "item1"],
+486                |row: &rusqlite::Row| row.get(0),
+487            )
+488            .unwrap();
+489        assert_eq!(is_played, 1);
+490    }
+491
+492    #[test]
+493    fn test_sync_queue_operations() {
+494        let db = Database::open_in_memory().unwrap();
+495        let conn = db.connection();
+496        let conn = conn.lock_safe();
+497
+498        // Setup
+499        conn.execute(
+500            "INSERT INTO servers (id, name, url) VALUES (?1, ?2, ?3)",
+501            params!["server1", "Test", "http://localhost"],
+502        )
+503        .unwrap();
+504
+505        conn.execute(
+506            "INSERT INTO users (id, server_id, username) VALUES (?1, ?2, ?3)",
+507            params!["user1", "server1", "admin"],
+508        )
+509        .unwrap();
+510
+511        // Queue a sync operation
+512        conn.execute(
+513            "INSERT INTO sync_queue (user_id, operation, item_id, payload, status)
+514             VALUES (?1, ?2, ?3, ?4, ?5)",
+515            params![
+516                "user1",
+517                "mark_favorite",
+518                "item123",
+519                r#"{"favorite": true}"#,
+520                "pending"
+521            ],
+522        )
+523        .unwrap();
+524
+525        // Get pending operations
+526        let mut stmt = conn
+527            .prepare("SELECT operation, item_id FROM sync_queue WHERE status = 'pending'")
+528            .unwrap();
+529
+530        let ops: Vec<(String, String)> = stmt
+531            .query_map([], |row: &rusqlite::Row| Ok((row.get(0)?, row.get(1)?)))
+532            .unwrap()
+533            .filter_map(|r| r.ok())
+534            .collect();
+535
+536        assert_eq!(ops.len(), 1);
+537        assert_eq!(ops[0].0, "mark_favorite");
+538        assert_eq!(ops[0].1, "item123");
+539
+540        // Mark as completed
+541        conn.execute(
+542            "UPDATE sync_queue SET status = 'completed' WHERE item_id = ?1",
+543            ["item123"],
+544        )
+545        .unwrap();
+546
+547        let pending_count: i32 = conn
+548            .query_row(
+549                "SELECT COUNT(*) FROM sync_queue WHERE status = 'pending'",
+550                [],
+551                |row: &rusqlite::Row| row.get(0),
+552            )
+553            .unwrap();
+554        assert_eq!(pending_count, 0);
+555    }
+556
+557    #[test]
+558    fn test_downloads_table() {
+559        let db = Database::open_in_memory().unwrap();
+560        let conn = db.connection();
+561        let conn = conn.lock_safe();
+562
+563        // Setup
+564        conn.execute(
+565            "INSERT INTO servers (id, name, url) VALUES (?1, ?2, ?3)",
+566            params!["server1", "Test", "http://localhost"],
+567        )
+568        .unwrap();
+569
+570        conn.execute(
+571            "INSERT INTO users (id, server_id, username) VALUES (?1, ?2, ?3)",
+572            params!["user1", "server1", "admin"],
+573        )
+574        .unwrap();
+575
+576        conn.execute(
+577            "INSERT INTO items (id, server_id, name, item_type) VALUES (?1, ?2, ?3, ?4)",
+578            params!["item1", "server1", "Test Song", "Audio"],
+579        )
+580        .unwrap();
+581
+582        // Queue a download
+583        conn.execute(
+584            "INSERT INTO downloads (item_id, user_id, file_path, status, progress)
+585             VALUES (?1, ?2, ?3, ?4, ?5)",
+586            params!["item1", "user1", "/data/downloads/test.mp3", "pending", 0.0],
+587        )
+588        .unwrap();
+589
+590        // Update progress
+591        conn.execute(
+592            "UPDATE downloads SET status = 'downloading', progress = 0.5
+593             WHERE item_id = ?1",
+594            ["item1"],
+595        )
+596        .unwrap();
+597
+598        let (status, progress): (String, f64) = conn
+599            .query_row(
+600                "SELECT status, progress FROM downloads WHERE item_id = ?1",
+601                ["item1"],
+602                |row: &rusqlite::Row| Ok((row.get(0)?, row.get(1)?)),
+603            )
+604            .unwrap();
+605        assert_eq!(status, "downloading");
+606        assert!((progress - 0.5).abs() < 0.001);
+607
+608        // Complete download
+609        conn.execute(
+610            "UPDATE downloads SET status = 'completed', progress = 1.0
+611             WHERE item_id = ?1",
+612            ["item1"],
+613        )
+614        .unwrap();
+615
+616        let status: String = conn
+617            .query_row(
+618                "SELECT status FROM downloads WHERE item_id = ?1",
+619                ["item1"],
+620                |row: &rusqlite::Row| row.get(0),
+621            )
+622            .unwrap();
+623        assert_eq!(status, "completed");
+624    }
+625
+626    #[test]
+627    fn test_migrations_idempotent() {
+628        let db = Database::open_in_memory().unwrap();
+629
+630        // Run migrations again - should not fail
+631        let result = db.migrate();
+632        assert!(result.is_ok());
+633
+634        // Tables should still exist
+635        let conn = db.connection();
+636        let conn = conn.lock_safe();
+637
+638        let count: i32 = conn
+639            .query_row(
+640                "SELECT COUNT(*) FROM sqlite_master WHERE type='table' AND name='items'",
+641                [],
+642                |row: &rusqlite::Row| row.get(0),
+643            )
+644            .unwrap();
+645        assert_eq!(count, 1);
+646    }
+647
+648    #[test]
+649    fn test_global_active_user_deactivation() {
+650        let db = Database::open_in_memory().unwrap();
+651        let conn = db.connection();
+652        let conn = conn.lock_safe();
+653
+654        // Create two servers
+655        conn.execute(
+656            "INSERT INTO servers (id, name, url) VALUES (?1, ?2, ?3)",
+657            params!["server1", "Server 1", "http://server1.com"],
+658        )
+659        .unwrap();
+660
+661        conn.execute(
+662            "INSERT INTO servers (id, name, url) VALUES (?1, ?2, ?3)",
+663            params!["server2", "Server 2", "http://server2.com"],
+664        )
+665        .unwrap();
+666
+667        // Create users on different servers
+668        conn.execute(
+669            "INSERT INTO users (id, server_id, username, is_active, last_login_at)
+670             VALUES (?1, ?2, ?3, 1, '2024-01-01 10:00:00')",
+671            params!["user1", "server1", "admin"],
+672        )
+673        .unwrap();
+674
+675        conn.execute(
+676            "INSERT INTO users (id, server_id, username, is_active, last_login_at)
+677             VALUES (?1, ?2, ?3, 1, '2024-01-01 11:00:00')",
+678            params!["user2", "server2", "admin"],
+679        )
+680        .unwrap();
+681
+682        // Initially both users are active (simulating the old bug)
+683        let active_count: i32 = conn
+684            .query_row(
+685                "SELECT COUNT(*) FROM users WHERE is_active = 1",
+686                [],
+687                |row: &rusqlite::Row| row.get(0),
+688            )
+689            .unwrap();
+690        assert_eq!(active_count, 2);
+691
+692        // Now simulate setting user1 as active (global deactivation)
+693        conn.execute("UPDATE users SET is_active = 0", []).unwrap();
+694        conn.execute(
+695            "UPDATE users SET is_active = 1, last_login_at = CURRENT_TIMESTAMP WHERE id = ?1",
+696            ["user1"],
+697        )
+698        .unwrap();
+699
+700        // Only one user should be active now
+701        let active_count: i32 = conn
+702            .query_row(
+703                "SELECT COUNT(*) FROM users WHERE is_active = 1",
+704                [],
+705                |row: &rusqlite::Row| row.get(0),
+706            )
+707            .unwrap();
+708        assert_eq!(active_count, 1);
+709
+710        // And it should be user1
+711        let active_user: String = conn
+712            .query_row(
+713                "SELECT id FROM users WHERE is_active = 1",
+714                [],
+715                |row: &rusqlite::Row| row.get(0),
+716            )
+717            .unwrap();
+718        assert_eq!(active_user, "user1");
+719    }
+720
+721    #[test]
+722    fn test_active_session_query_ordering() {
+723        let db = Database::open_in_memory().unwrap();
+724        let conn = db.connection();
+725        let conn = conn.lock_safe();
+726
+727        // Create server
+728        conn.execute(
+729            "INSERT INTO servers (id, name, url) VALUES (?1, ?2, ?3)",
+730            params!["server1", "Test Server", "http://localhost:8096"],
+731        )
+732        .unwrap();
+733
+734        // Create users with different login times
+735        conn.execute(
+736            "INSERT INTO users (id, server_id, username, is_active, last_login_at)
+737             VALUES (?1, ?2, ?3, 0, '2024-01-01 10:00:00')",
+738            params!["user1", "server1", "old_user"],
+739        )
+740        .unwrap();
+741
+742        conn.execute(
+743            "INSERT INTO users (id, server_id, username, is_active, last_login_at)
+744             VALUES (?1, ?2, ?3, 1, '2024-01-01 12:00:00')",
+745            params!["user2", "server1", "recent_user"],
+746        )
+747        .unwrap();
+748
+749        // Query for active user ordered by last_login_at DESC (simulating storage_get_active_session)
+750        let (user_id, username): (String, String) = conn
+751            .query_row(
+752                "SELECT u.id, u.username FROM users u
+753                 JOIN servers s ON u.server_id = s.id
+754                 WHERE u.is_active = 1
+755                 ORDER BY u.last_login_at DESC
+756                 LIMIT 1",
+757                [],
+758                |row: &rusqlite::Row| Ok((row.get(0)?, row.get(1)?)),
+759            )
+760            .unwrap();
+761
+762        assert_eq!(user_id, "user2");
+763        assert_eq!(username, "recent_user");
+764    }
+765}
+
\ No newline at end of file diff --git a/api/src/jellytau_lib/storage/models.rs.html b/api/src/jellytau_lib/storage/models.rs.html new file mode 100644 index 00000000..0eef66ad --- /dev/null +++ b/api/src/jellytau_lib/storage/models.rs.html @@ -0,0 +1,134 @@ +models.rs - sourceSkip to main content

jellytau_lib/storage/
models.rs

1//! Database model structs
+2
+3use chrono::{DateTime, Utc};
+4use serde::{Deserialize, Serialize};
+5
+6/// Playlist item entry
+7#[allow(dead_code)]
+8#[derive(Debug, Clone, Serialize, Deserialize)]
+9pub struct PlaylistItem {
+10    pub id: Option<i64>,
+11    pub playlist_id: String,
+12    pub item_id: String,
+13    pub sort_order: i32,
+14    pub added_at: Option<DateTime<Utc>>,
+15}
+16
+17#[cfg(test)]
+18mod tests {
+19    use super::*;
+20
+21    #[test]
+22    fn test_playlist_item_creation() {
+23        let item = PlaylistItem {
+24            id: Some(1),
+25            playlist_id: "playlist-123".to_string(),
+26            item_id: "item-456".to_string(),
+27            sort_order: 0,
+28            added_at: None,
+29        };
+30
+31        assert_eq!(item.id, Some(1));
+32        assert_eq!(item.playlist_id, "playlist-123");
+33        assert_eq!(item.item_id, "item-456");
+34        assert_eq!(item.sort_order, 0);
+35        assert!(item.added_at.is_none());
+36    }
+37
+38    #[test]
+39    fn test_playlist_item_without_id() {
+40        let item = PlaylistItem {
+41            id: None,
+42            playlist_id: "playlist-789".to_string(),
+43            item_id: "item-999".to_string(),
+44            sort_order: 5,
+45            added_at: None,
+46        };
+47
+48        assert!(item.id.is_none());
+49        assert_eq!(item.sort_order, 5);
+50    }
+51
+52    #[test]
+53    fn test_playlist_item_serialization() {
+54        let item = PlaylistItem {
+55            id: Some(42),
+56            playlist_id: "pl-001".to_string(),
+57            item_id: "it-001".to_string(),
+58            sort_order: 10,
+59            added_at: None,
+60        };
+61
+62        let json = serde_json::to_string(&item);
+63        assert!(json.is_ok());
+64        let serialized = json.unwrap();
+65        assert!(serialized.contains("pl-001"));
+66        assert!(serialized.contains("it-001"));
+67        assert!(serialized.contains("10"));
+68    }
+69
+70    #[test]
+71    fn test_playlist_item_clone() {
+72        let item = PlaylistItem {
+73            id: Some(99),
+74            playlist_id: "clone-playlist".to_string(),
+75            item_id: "clone-item".to_string(),
+76            sort_order: 3,
+77            added_at: None,
+78        };
+79
+80        let cloned = item.clone();
+81        assert_eq!(item.id, cloned.id);
+82        assert_eq!(item.playlist_id, cloned.playlist_id);
+83        assert_eq!(item.item_id, cloned.item_id);
+84        assert_eq!(item.sort_order, cloned.sort_order);
+85    }
+86
+87    #[test]
+88    fn test_playlist_item_with_timestamp() {
+89        let now = Utc::now();
+90        let item = PlaylistItem {
+91            id: Some(100),
+92            playlist_id: "time-playlist".to_string(),
+93            item_id: "time-item".to_string(),
+94            sort_order: 7,
+95            added_at: Some(now),
+96        };
+97
+98        assert!(item.added_at.is_some());
+99        assert_eq!(item.added_at, Some(now));
+100    }
+101
+102    #[test]
+103    fn test_playlist_item_multiple_sort_orders() {
+104        let sort_orders = vec![0, 1, 5, 10, 100, 999];
+105
+106        for order in sort_orders {
+107            let item = PlaylistItem {
+108                id: Some(1),
+109                playlist_id: "pl".to_string(),
+110                item_id: "it".to_string(),
+111                sort_order: order,
+112                added_at: None,
+113            };
+114
+115            assert_eq!(item.sort_order, order);
+116        }
+117    }
+118
+119    #[test]
+120    fn test_playlist_item_debug() {
+121        let item = PlaylistItem {
+122            id: Some(200),
+123            playlist_id: "debug-pl".to_string(),
+124            item_id: "debug-it".to_string(),
+125            sort_order: 15,
+126            added_at: None,
+127        };
+128
+129        let debug_str = format!("{:?}", item);
+130        assert!(debug_str.contains("PlaylistItem"));
+131        assert!(debug_str.contains("debug-pl"));
+132    }
+133}
+
\ No newline at end of file diff --git a/api/src/jellytau_lib/storage/schema.rs.html b/api/src/jellytau_lib/storage/schema.rs.html new file mode 100644 index 00000000..f41b8f97 --- /dev/null +++ b/api/src/jellytau_lib/storage/schema.rs.html @@ -0,0 +1,822 @@ +schema.rs - sourceSkip to main content

jellytau_lib/storage/
schema.rs

1//! Database schema and migrations
+2//!
+3//! TRACES: UR-002 | DR-012 | IR-013
+4
+5/// List of migrations to apply in order.
+6/// Each migration is a tuple of (name, sql).
+7pub const MIGRATIONS: &[(&str, &str)] = &[
+8    ("001_initial_schema", MIGRATION_001),
+9    ("002_remove_access_token", MIGRATION_002),
+10    ("003_relax_user_data_constraints", MIGRATION_003),
+11    ("004_enhance_downloads", MIGRATION_004),
+12    ("005_relax_downloads_fk", MIGRATION_005),
+13    ("006_downloads_metadata", MIGRATION_006),
+14    ("007_cache_metadata", MIGRATION_007),
+15    ("008_video_downloads", MIGRATION_008),
+16    ("009_people_tables", MIGRATION_009),
+17    ("010_playback_context", MIGRATION_010),
+18    ("011_user_player_settings", MIGRATION_011),
+19    ("012_download_source", MIGRATION_012),
+20    ("013_downloads_item_status_index", MIGRATION_013),
+21    ("014_series_audio_preferences", MIGRATION_014),
+22    ("015_device_id", MIGRATION_015),
+23    ("016_autoplay_max_episodes", MIGRATION_016),
+24    ("017_downloads_resume_url", MIGRATION_017),
+25    ("018_items_is_folder", MIGRATION_018),
+26    ("019_genres_cache", MIGRATION_019),
+27    ("020_items_season_index", MIGRATION_020),
+28    ("021_rebuild_items_fts", MIGRATION_021),
+29    ("022_people_fts", MIGRATION_022),
+30    ("023_downloads_expiry", MIGRATION_023),
+31];
+32
+33/// Initial schema migration
+34const MIGRATION_001: &str = r#"
+35-- Jellyfin servers the user has connected to
+36CREATE TABLE IF NOT EXISTS servers (
+37    id TEXT PRIMARY KEY,
+38    name TEXT NOT NULL,
+39    url TEXT NOT NULL UNIQUE,
+40    version TEXT,
+41    created_at TEXT DEFAULT CURRENT_TIMESTAMP,
+42    last_connected_at TEXT
+43);
+44
+45-- User accounts on Jellyfin servers
+46CREATE TABLE IF NOT EXISTS users (
+47    id TEXT PRIMARY KEY,
+48    server_id TEXT NOT NULL REFERENCES servers(id) ON DELETE CASCADE,
+49    username TEXT NOT NULL,
+50    access_token TEXT,
+51    is_active INTEGER DEFAULT 0,
+52    created_at TEXT DEFAULT CURRENT_TIMESTAMP,
+53    last_login_at TEXT,
+54    UNIQUE(server_id, username)
+55);
+56
+57-- Libraries/views from Jellyfin
+58CREATE TABLE IF NOT EXISTS libraries (
+59    id TEXT PRIMARY KEY,
+60    server_id TEXT NOT NULL REFERENCES servers(id) ON DELETE CASCADE,
+61    name TEXT NOT NULL,
+62    collection_type TEXT,
+63    image_tag TEXT,
+64    sort_order INTEGER DEFAULT 0,
+65    synced_at TEXT,
+66    UNIQUE(server_id, id)
+67);
+68
+69-- Media items (movies, shows, episodes, albums, songs, artists)
+70CREATE TABLE IF NOT EXISTS items (
+71    id TEXT PRIMARY KEY,
+72    server_id TEXT NOT NULL REFERENCES servers(id) ON DELETE CASCADE,
+73    library_id TEXT REFERENCES libraries(id) ON DELETE SET NULL,
+74    parent_id TEXT REFERENCES items(id) ON DELETE CASCADE,
+75
+76    -- Core metadata
+77    name TEXT NOT NULL,
+78    sort_name TEXT,
+79    original_title TEXT,
+80    item_type TEXT NOT NULL,  -- Movie, Series, Episode, MusicAlbum, Audio, MusicArtist, etc.
+81
+82    -- Media info
+83    overview TEXT,
+84    tagline TEXT,
+85    genres TEXT,  -- JSON array
+86    tags TEXT,    -- JSON array
+87    studios TEXT, -- JSON array
+88
+89    -- For episodes
+90    series_id TEXT,
+91    series_name TEXT,
+92    season_id TEXT,
+93    season_name TEXT,
+94    index_number INTEGER,        -- Episode number
+95    parent_index_number INTEGER, -- Season number
+96
+97    -- For music
+98    album_id TEXT,
+99    album_name TEXT,
+100    album_artist TEXT,
+101    artists TEXT,  -- JSON array
+102
+103    -- Dates
+104    premiere_date TEXT,
+105    production_year INTEGER,
+106    date_created TEXT,
+107
+108    -- Runtime (ticks)
+109    runtime_ticks INTEGER,
+110
+111    -- Images
+112    primary_image_tag TEXT,
+113    backdrop_image_tags TEXT,  -- JSON array
+114
+115    -- Ratings
+116    community_rating REAL,
+117    official_rating TEXT,
+118
+119    -- Sync metadata
+120    synced_at TEXT,
+121    etag TEXT,
+122
+123    UNIQUE(server_id, id)
+124);
+125
+126-- Full-text search index for items
+127CREATE VIRTUAL TABLE IF NOT EXISTS items_fts USING fts5(
+128    name,
+129    overview,
+130    album_name,
+131    album_artist,
+132    artists,
+133    series_name,
+134    content='items',
+135    content_rowid='rowid'
+136);
+137
+138-- Triggers to keep FTS index in sync
+139CREATE TRIGGER IF NOT EXISTS items_ai AFTER INSERT ON items BEGIN
+140    INSERT INTO items_fts(rowid, name, overview, album_name, album_artist, artists, series_name)
+141    VALUES (new.rowid, new.name, new.overview, new.album_name, new.album_artist, new.artists, new.series_name);
+142END;
+143
+144CREATE TRIGGER IF NOT EXISTS items_ad AFTER DELETE ON items BEGIN
+145    INSERT INTO items_fts(items_fts, rowid, name, overview, album_name, album_artist, artists, series_name)
+146    VALUES('delete', old.rowid, old.name, old.overview, old.album_name, old.album_artist, old.artists, old.series_name);
+147END;
+148
+149CREATE TRIGGER IF NOT EXISTS items_au AFTER UPDATE ON items BEGIN
+150    INSERT INTO items_fts(items_fts, rowid, name, overview, album_name, album_artist, artists, series_name)
+151    VALUES('delete', old.rowid, old.name, old.overview, old.album_name, old.album_artist, old.artists, old.series_name);
+152    INSERT INTO items_fts(rowid, name, overview, album_name, album_artist, artists, series_name)
+153    VALUES (new.rowid, new.name, new.overview, new.album_name, new.album_artist, new.artists, new.series_name);
+154END;
+155
+156-- Media streams (audio/subtitle tracks)
+157CREATE TABLE IF NOT EXISTS media_streams (
+158    id INTEGER PRIMARY KEY AUTOINCREMENT,
+159    item_id TEXT NOT NULL REFERENCES items(id) ON DELETE CASCADE,
+160    stream_index INTEGER NOT NULL,
+161    stream_type TEXT NOT NULL,  -- Audio, Subtitle, Video
+162    codec TEXT,
+163    language TEXT,
+164    display_title TEXT,
+165    is_default INTEGER DEFAULT 0,
+166    is_forced INTEGER DEFAULT 0,
+167    is_external INTEGER DEFAULT 0,
+168    path TEXT,  -- For external subtitles
+169    UNIQUE(item_id, stream_index)
+170);
+171
+172-- User-specific data (watch progress, favorites)
+173CREATE TABLE IF NOT EXISTS user_data (
+174    id INTEGER PRIMARY KEY AUTOINCREMENT,
+175    user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
+176    item_id TEXT NOT NULL REFERENCES items(id) ON DELETE CASCADE,
+177
+178    -- Playback state
+179    playback_position_ticks INTEGER DEFAULT 0,
+180    play_count INTEGER DEFAULT 0,
+181    is_played INTEGER DEFAULT 0,
+182    is_favorite INTEGER DEFAULT 0,
+183
+184    -- Timestamps
+185    last_played_at TEXT,
+186
+187    -- Sync status
+188    synced_at TEXT,
+189    pending_sync INTEGER DEFAULT 0,  -- 1 if local changes need sync
+190
+191    UNIQUE(user_id, item_id)
+192);
+193
+194-- Downloaded media files
+195CREATE TABLE IF NOT EXISTS downloads (
+196    id INTEGER PRIMARY KEY AUTOINCREMENT,
+197    item_id TEXT NOT NULL REFERENCES items(id) ON DELETE CASCADE,
+198    user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
+199
+200    -- File info
+201    file_path TEXT NOT NULL,
+202    file_size INTEGER,
+203    mime_type TEXT,
+204
+205    -- Download state
+206    status TEXT DEFAULT 'pending',  -- pending, downloading, completed, failed, paused
+207    progress REAL DEFAULT 0,        -- 0.0 to 1.0
+208
+209    -- Transcoding options used
+210    bitrate INTEGER,
+211    container TEXT,
+212
+213    -- Timestamps
+214    queued_at TEXT DEFAULT CURRENT_TIMESTAMP,
+215    started_at TEXT,
+216    completed_at TEXT,
+217
+218    -- Error tracking
+219    error_message TEXT,
+220    retry_count INTEGER DEFAULT 0,
+221
+222    UNIQUE(item_id, user_id)
+223);
+224
+225-- Offline mutation queue (changes to sync back to server)
+226CREATE TABLE IF NOT EXISTS sync_queue (
+227    id INTEGER PRIMARY KEY AUTOINCREMENT,
+228    user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
+229
+230    -- Operation details
+231    operation TEXT NOT NULL,  -- mark_played, mark_favorite, update_progress, etc.
+232    item_id TEXT,
+233    payload TEXT,  -- JSON data for the operation
+234
+235    -- Queue state
+236    status TEXT DEFAULT 'pending',  -- pending, processing, completed, failed
+237    retry_count INTEGER DEFAULT 0,
+238
+239    -- Timestamps
+240    created_at TEXT DEFAULT CURRENT_TIMESTAMP,
+241    processed_at TEXT,
+242
+243    -- Error tracking
+244    error_message TEXT
+245);
+246
+247-- Cached thumbnails
+248CREATE TABLE IF NOT EXISTS thumbnails (
+249    id INTEGER PRIMARY KEY AUTOINCREMENT,
+250    item_id TEXT NOT NULL REFERENCES items(id) ON DELETE CASCADE,
+251    image_type TEXT NOT NULL,  -- Primary, Backdrop, Thumb, Logo, etc.
+252    image_tag TEXT NOT NULL,
+253    file_path TEXT NOT NULL,
+254    width INTEGER,
+255    height INTEGER,
+256    cached_at TEXT DEFAULT CURRENT_TIMESTAMP,
+257    UNIQUE(item_id, image_type, image_tag)
+258);
+259
+260-- User playlists (local + synced)
+261CREATE TABLE IF NOT EXISTS playlists (
+262    id TEXT PRIMARY KEY,
+263    user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
+264    name TEXT NOT NULL,
+265    is_local INTEGER DEFAULT 0,  -- 1 for local-only playlists
+266    jellyfin_id TEXT,            -- NULL for local-only
+267    created_at TEXT DEFAULT CURRENT_TIMESTAMP,
+268    updated_at TEXT
+269);
+270
+271-- Playlist items
+272CREATE TABLE IF NOT EXISTS playlist_items (
+273    id INTEGER PRIMARY KEY AUTOINCREMENT,
+274    playlist_id TEXT NOT NULL REFERENCES playlists(id) ON DELETE CASCADE,
+275    item_id TEXT NOT NULL REFERENCES items(id) ON DELETE CASCADE,
+276    sort_order INTEGER NOT NULL,
+277    added_at TEXT DEFAULT CURRENT_TIMESTAMP,
+278    UNIQUE(playlist_id, item_id)
+279);
+280
+281-- Indexes for common queries
+282CREATE INDEX IF NOT EXISTS idx_items_server ON items(server_id);
+283CREATE INDEX IF NOT EXISTS idx_items_library ON items(library_id);
+284CREATE INDEX IF NOT EXISTS idx_items_parent ON items(parent_id);
+285CREATE INDEX IF NOT EXISTS idx_items_type ON items(item_type);
+286CREATE INDEX IF NOT EXISTS idx_items_album ON items(album_id);
+287CREATE INDEX IF NOT EXISTS idx_items_series ON items(series_id);
+288CREATE INDEX IF NOT EXISTS idx_items_season ON items(season_id);
+289CREATE INDEX IF NOT EXISTS idx_user_data_user ON user_data(user_id);
+290CREATE INDEX IF NOT EXISTS idx_user_data_item ON user_data(item_id);
+291CREATE INDEX IF NOT EXISTS idx_downloads_status ON downloads(status);
+292CREATE INDEX IF NOT EXISTS idx_sync_queue_status ON sync_queue(status);
+293CREATE INDEX IF NOT EXISTS idx_thumbnails_item ON thumbnails(item_id);
+294"#;
+295
+296/// Migration to remove access_token column from users table
+297/// Tokens are now stored in the system keyring (or encrypted file fallback)
+298const MIGRATION_002: &str = r#"
+299-- Remove access_token column from users table
+300-- Tokens are now stored in secure storage (system keyring)
+301
+302-- SQLite doesn't support DROP COLUMN in older versions, so we recreate the table
+303CREATE TABLE IF NOT EXISTS users_new (
+304    id TEXT PRIMARY KEY,
+305    server_id TEXT NOT NULL REFERENCES servers(id) ON DELETE CASCADE,
+306    username TEXT NOT NULL,
+307    is_active INTEGER DEFAULT 0,
+308    created_at TEXT DEFAULT CURRENT_TIMESTAMP,
+309    last_login_at TEXT,
+310    UNIQUE(server_id, username)
+311);
+312
+313-- Copy existing data (excluding access_token)
+314INSERT OR IGNORE INTO users_new (id, server_id, username, is_active, created_at, last_login_at)
+315SELECT id, server_id, username, is_active, created_at, last_login_at FROM users;
+316
+317-- Drop old table and rename new one
+318DROP TABLE IF EXISTS users;
+319ALTER TABLE users_new RENAME TO users;
+320"#;
+321
+322/// Migration to relax foreign key constraints on user_data table
+323/// Allows tracking playback progress for items not yet synced to local database
+324const MIGRATION_003: &str = r#"
+325-- Recreate user_data table without foreign key constraint on item_id
+326-- This allows tracking playback progress for items that haven't been synced locally yet
+327
+328CREATE TABLE IF NOT EXISTS user_data_new (
+329    id INTEGER PRIMARY KEY AUTOINCREMENT,
+330    user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
+331    item_id TEXT NOT NULL,  -- No foreign key constraint - item may not be synced yet
+332
+333    -- Playback state
+334    playback_position_ticks INTEGER DEFAULT 0,
+335    play_count INTEGER DEFAULT 0,
+336    is_played INTEGER DEFAULT 0,
+337    is_favorite INTEGER DEFAULT 0,
+338
+339    -- Timestamps
+340    last_played_at TEXT,
+341
+342    -- Sync status
+343    synced_at TEXT,
+344    pending_sync INTEGER DEFAULT 0,  -- 1 if local changes need sync
+345
+346    UNIQUE(user_id, item_id)
+347);
+348
+349-- Copy existing data
+350INSERT OR IGNORE INTO user_data_new (id, user_id, item_id, playback_position_ticks, play_count, is_played, is_favorite, last_played_at, synced_at, pending_sync)
+351SELECT id, user_id, item_id, playback_position_ticks, play_count, is_played, is_favorite, last_played_at, synced_at, pending_sync FROM user_data;
+352
+353-- Drop old table and rename new one
+354DROP TABLE IF EXISTS user_data;
+355ALTER TABLE user_data_new RENAME TO user_data;
+356
+357-- Recreate index
+358CREATE INDEX IF NOT EXISTS idx_user_data_user ON user_data(user_id);
+359CREATE INDEX IF NOT EXISTS idx_user_data_item ON user_data(item_id);
+360"#;
+361
+362/// Migration to enhance downloads table with priority and bytes_downloaded
+363const MIGRATION_004: &str = r#"
+364-- Add priority column for queue ordering
+365ALTER TABLE downloads ADD COLUMN priority INTEGER DEFAULT 0;
+366
+367-- Add bytes_downloaded for resume support
+368ALTER TABLE downloads ADD COLUMN bytes_downloaded INTEGER DEFAULT 0;
+369
+370-- Create index for efficient queue processing (priority DESC, FIFO within same priority)
+371CREATE INDEX IF NOT EXISTS idx_downloads_queue
+372  ON downloads(status, priority DESC, queued_at ASC)
+373  WHERE status IN ('pending', 'downloading');
+374"#;
+375
+376/// Migration to relax foreign key constraint on downloads.item_id
+377/// Allows downloading items that haven't been synced to local database yet
+378const MIGRATION_005: &str = r#"
+379-- Recreate downloads table without foreign key constraint on item_id
+380-- This allows downloading items that haven't been synced locally yet
+381
+382CREATE TABLE IF NOT EXISTS downloads_new (
+383    id INTEGER PRIMARY KEY AUTOINCREMENT,
+384    item_id TEXT NOT NULL,  -- No foreign key constraint - item may not be synced yet
+385    user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
+386
+387    -- File info
+388    file_path TEXT NOT NULL,
+389    file_size INTEGER,
+390    mime_type TEXT,
+391
+392    -- Download state
+393    status TEXT DEFAULT 'pending',  -- pending, downloading, completed, failed, paused
+394    progress REAL DEFAULT 0,        -- 0.0 to 1.0
+395
+396    -- Transcoding options used
+397    bitrate INTEGER,
+398    container TEXT,
+399
+400    -- Timestamps
+401    queued_at TEXT DEFAULT CURRENT_TIMESTAMP,
+402    started_at TEXT,
+403    completed_at TEXT,
+404
+405    -- Error tracking
+406    error_message TEXT,
+407    retry_count INTEGER DEFAULT 0,
+408
+409    -- Priority and progress tracking (from migration 004)
+410    priority INTEGER DEFAULT 0,
+411    bytes_downloaded INTEGER DEFAULT 0,
+412
+413    UNIQUE(item_id, user_id)
+414);
+415
+416-- Copy existing data
+417INSERT OR IGNORE INTO downloads_new (
+418    id, item_id, user_id, file_path, file_size, mime_type, status, progress,
+419    bitrate, container, queued_at, started_at, completed_at, error_message,
+420    retry_count, priority, bytes_downloaded
+421)
+422SELECT
+423    id, item_id, user_id, file_path, file_size, mime_type, status, progress,
+424    bitrate, container, queued_at, started_at, completed_at, error_message,
+425    retry_count, priority, bytes_downloaded
+426FROM downloads;
+427
+428-- Drop old table and rename new one
+429DROP TABLE IF EXISTS downloads;
+430ALTER TABLE downloads_new RENAME TO downloads;
+431
+432-- Recreate indexes
+433CREATE INDEX IF NOT EXISTS idx_downloads_status ON downloads(status);
+434CREATE INDEX IF NOT EXISTS idx_downloads_queue
+435  ON downloads(status, priority DESC, queued_at ASC)
+436  WHERE status IN ('pending', 'downloading');
+437"#;
+438
+439/// Migration to store item metadata directly in downloads table
+440/// This eliminates dependency on items table being synced and fixes UUID display issues
+441const MIGRATION_006: &str = r#"
+442-- Add columns to store item metadata directly in downloads
+443-- This ensures correct display even when items aren't synced locally
+444ALTER TABLE downloads ADD COLUMN item_name TEXT;
+445ALTER TABLE downloads ADD COLUMN artist_name TEXT;
+446ALTER TABLE downloads ADD COLUMN album_name TEXT;
+447"#;
+448
+449/// Migration to enhance thumbnail caching with LRU eviction support
+450/// - Relaxes foreign key constraint on item_id (allows caching for items not yet synced)
+451/// - Adds last_accessed for LRU eviction
+452/// - Adds file_size for cache limit tracking
+453/// - Creates cache_settings table for configurable limits
+454const MIGRATION_007: &str = r#"
+455-- Recreate thumbnails table without foreign key constraint on item_id
+456-- and add LRU eviction support columns
+457CREATE TABLE IF NOT EXISTS thumbnails_new (
+458    id INTEGER PRIMARY KEY AUTOINCREMENT,
+459    item_id TEXT NOT NULL,  -- No foreign key constraint - item may not be synced yet
+460    image_type TEXT NOT NULL,  -- Primary, Backdrop, Thumb, Logo, etc.
+461    image_tag TEXT NOT NULL,
+462    file_path TEXT NOT NULL,
+463    width INTEGER,
+464    height INTEGER,
+465    file_size INTEGER DEFAULT 0,  -- Size in bytes for cache limit tracking
+466    cached_at TEXT DEFAULT CURRENT_TIMESTAMP,
+467    last_accessed TEXT DEFAULT CURRENT_TIMESTAMP,  -- For LRU eviction
+468    UNIQUE(item_id, image_type, image_tag)
+469);
+470
+471-- Copy existing data (if any)
+472INSERT OR IGNORE INTO thumbnails_new (id, item_id, image_type, image_tag, file_path, width, height, cached_at)
+473SELECT id, item_id, image_type, image_tag, file_path, width, height, cached_at FROM thumbnails;
+474
+475-- Drop old table and rename new one
+476DROP TABLE IF EXISTS thumbnails;
+477ALTER TABLE thumbnails_new RENAME TO thumbnails;
+478
+479-- Create indexes for efficient queries
+480CREATE INDEX IF NOT EXISTS idx_thumbnails_item ON thumbnails(item_id);
+481CREATE INDEX IF NOT EXISTS idx_thumbnails_lru ON thumbnails(last_accessed ASC);
+482
+483-- Cache settings table for configurable limits
+484CREATE TABLE IF NOT EXISTS cache_settings (
+485    key TEXT PRIMARY KEY,
+486    value TEXT NOT NULL,
+487    updated_at TEXT DEFAULT CURRENT_TIMESTAMP
+488);
+489
+490-- Insert default settings
+491INSERT OR IGNORE INTO cache_settings (key, value) VALUES ('image_cache_limit_bytes', '1073741824');  -- 1GB default
+492INSERT OR IGNORE INTO cache_settings (key, value) VALUES ('image_cache_enabled', 'true');
+493"#;
+494
+495/// Migration to add video download support and item pinning
+496/// - Adds video-specific metadata columns to downloads (series/episode info, quality preset)
+497/// - Adds media_type to distinguish audio vs video downloads
+498/// - Adds is_pinned column to items table for protecting metadata from cache clear
+499const MIGRATION_008: &str = r#"
+500-- Add video-specific metadata columns to downloads
+501ALTER TABLE downloads ADD COLUMN series_name TEXT;
+502ALTER TABLE downloads ADD COLUMN season_name TEXT;
+503ALTER TABLE downloads ADD COLUMN episode_number INTEGER;
+504ALTER TABLE downloads ADD COLUMN season_number INTEGER;
+505ALTER TABLE downloads ADD COLUMN quality_preset TEXT DEFAULT 'original';
+506ALTER TABLE downloads ADD COLUMN media_type TEXT DEFAULT 'audio';
+507
+508-- Add pinning support to items table
+509-- Pinned items are protected from cache clear operations
+510ALTER TABLE items ADD COLUMN is_pinned INTEGER DEFAULT 0;
+511
+512-- Index for efficiently finding pinned items
+513CREATE INDEX IF NOT EXISTS idx_items_pinned ON items(is_pinned) WHERE is_pinned = 1;
+514
+515-- Index for efficiently querying downloads by series
+516CREATE INDEX IF NOT EXISTS idx_downloads_series ON downloads(series_name) WHERE series_name IS NOT NULL;
+517
+518-- Index for filtering by media type
+519CREATE INDEX IF NOT EXISTS idx_downloads_media_type ON downloads(media_type);
+520"#;
+521
+522/// Migration to add people/cast caching support
+523/// - Creates people table for caching actor/director/writer/etc info
+524/// - Creates item_people junction table for many-to-many relationships
+525/// - Adds indexes for efficient queries
+526const MIGRATION_009: &str = r#"
+527-- People table for caching cast/crew members
+528CREATE TABLE IF NOT EXISTS people (
+529    id TEXT PRIMARY KEY,
+530    server_id TEXT NOT NULL,
+531    name TEXT NOT NULL,
+532    overview TEXT,
+533    primary_image_tag TEXT,
+534    premiere_date TEXT,      -- Birth date
+535    end_date TEXT,           -- Death date
+536    synced_at TEXT DEFAULT CURRENT_TIMESTAMP,
+537    UNIQUE(server_id, id)
+538);
+539
+540-- Item-Person association table (many-to-many)
+541-- Stores which people appear in which items, along with role info
+542CREATE TABLE IF NOT EXISTS item_people (
+543    id INTEGER PRIMARY KEY AUTOINCREMENT,
+544    item_id TEXT NOT NULL,
+545    person_id TEXT NOT NULL,
+546    server_id TEXT NOT NULL,
+547    person_type TEXT NOT NULL,  -- Actor, Director, Writer, Producer, Composer, etc.
+548    role TEXT,                   -- Character name for actors
+549    sort_order INTEGER DEFAULT 0,
+550    synced_at TEXT DEFAULT CURRENT_TIMESTAMP,
+551    UNIQUE(item_id, person_id, person_type)
+552);
+553
+554-- Indexes for efficient queries
+555CREATE INDEX IF NOT EXISTS idx_people_server ON people(server_id);
+556CREATE INDEX IF NOT EXISTS idx_people_name ON people(name);
+557CREATE INDEX IF NOT EXISTS idx_item_people_item ON item_people(item_id);
+558CREATE INDEX IF NOT EXISTS idx_item_people_person ON item_people(person_id);
+559CREATE INDEX IF NOT EXISTS idx_item_people_type ON item_people(person_type);
+560"#;
+561
+562/// Migration to add playback context tracking
+563/// - Adds playback_context_type column to track if user played a container or single item
+564/// - Adds playback_context_id column to store the container ID (album/playlist)
+565/// - Adds index for efficient recently played queries
+566const MIGRATION_010: &str = r#"
+567-- Add playback context tracking to user_data
+568-- Tracks whether user played a container (album/playlist) or single item
+569ALTER TABLE user_data ADD COLUMN playback_context_type TEXT;
+570ALTER TABLE user_data ADD COLUMN playback_context_id TEXT;
+571
+572-- Index for efficient recently played queries
+573CREATE INDEX IF NOT EXISTS idx_user_data_last_played
+574  ON user_data(user_id, last_played_at DESC)
+575  WHERE last_played_at IS NOT NULL;
+576"#;
+577
+578/// Migration to add user-specific player settings
+579/// - Creates user_player_settings table for autoplay and audio preferences
+580/// - Note: Sleep timer state is NOT persisted (cancelled on app close)
+581/// - Autoplay settings control next episode behavior
+582/// - Audio settings for crossfade, gapless playback, and volume normalization
+583const MIGRATION_011: &str = r#"
+584-- User-specific player settings (autoplay and audio settings)
+585-- Sleep timer is NOT persisted here (maintained in-memory only)
+586CREATE TABLE IF NOT EXISTS user_player_settings (
+587    user_id TEXT PRIMARY KEY REFERENCES users(id) ON DELETE CASCADE,
+588
+589    -- Autoplay settings
+590    autoplay_next_episode INTEGER DEFAULT 1,  -- 1 = enabled, 0 = disabled
+591    autoplay_countdown_seconds INTEGER DEFAULT 10,  -- 5-30 seconds
+592
+593    -- Audio settings (crossfade, normalization)
+594    crossfade_duration REAL DEFAULT 0.0,  -- 0-12 seconds
+595    gapless_playback INTEGER DEFAULT 1,
+596    normalize_volume INTEGER DEFAULT 0,
+597    volume_level TEXT DEFAULT 'normal',  -- 'loud', 'normal', 'quiet'
+598
+599    updated_at TEXT DEFAULT CURRENT_TIMESTAMP
+600);
+601
+602-- Index for efficient user settings lookup
+603CREATE INDEX IF NOT EXISTS idx_user_player_settings_user ON user_player_settings(user_id);
+604"#;
+605
+606/// Migration to track download source (user-initiated vs auto-cached)
+607/// - Adds download_source column to distinguish manual downloads from auto-caching
+608/// - Enables color-coded UI display
+609const MIGRATION_012: &str = r#"
+610-- Add download source tracking
+611-- Values: 'user' (explicit download), 'auto' (smart cache/queue precache)
+612ALTER TABLE downloads ADD COLUMN download_source TEXT DEFAULT 'user';
+613
+614-- Index for filtering by source
+615CREATE INDEX IF NOT EXISTS idx_downloads_source ON downloads(download_source);
+616"#;
+617
+618/// Migration to add composite index for offline mode filtering
+619/// - Adds index on (item_id, status) for efficient JOIN queries in OfflineRepository
+620/// - Significantly improves performance when filtering items by download status
+621const MIGRATION_013: &str = r#"
+622-- Add composite index for offline mode filtering
+623-- This speeds up queries that join items with downloads to show only downloaded content
+624CREATE INDEX IF NOT EXISTS idx_downloads_item_status ON downloads(item_id, status);
+625"#;
+626
+627/// Migration to add series audio track preferences
+628/// - Stores user's preferred audio track per series
+629/// - Matches tracks by display title and language across episodes
+630/// - Falls back to default track if preferred track not found
+631const MIGRATION_014: &str = r#"
+632-- Series-specific audio track preferences
+633-- When user changes audio track for an episode, remember preference for the series
+634CREATE TABLE IF NOT EXISTS series_audio_preferences (
+635    user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
+636    series_id TEXT NOT NULL,
+637    server_id TEXT NOT NULL,
+638
+639    -- Audio track info for matching across episodes
+640    audio_track_display_title TEXT,
+641    audio_track_language TEXT,
+642    audio_track_index INTEGER,
+643
+644    updated_at TEXT DEFAULT CURRENT_TIMESTAMP,
+645
+646    PRIMARY KEY (user_id, series_id, server_id)
+647);
+648
+649-- Index for efficient lookups
+650CREATE INDEX IF NOT EXISTS idx_series_audio_prefs_user_series
+651  ON series_audio_preferences(user_id, series_id);
+652"#;
+653
+654/// Migration to add device ID storage
+655/// - Creates app_settings table for app-wide configuration (device ID, etc.)
+656/// - Device ID is generated once and persisted for Jellyfin server identification
+657const MIGRATION_015: &str = r#"
+658-- App-wide settings table for device ID and other app-level configuration
+659-- Device ID is a unique identifier for this app installation
+660-- Required for Jellyfin server communication and session tracking
+661CREATE TABLE IF NOT EXISTS app_settings (
+662    key TEXT PRIMARY KEY,
+663    value TEXT NOT NULL,
+664    updated_at TEXT DEFAULT CURRENT_TIMESTAMP
+665);
+666
+667-- Create index for efficient lookups (though key is already primary key)
+668CREATE INDEX IF NOT EXISTS idx_app_settings_key ON app_settings(key);
+669"#;
+670
+671/// Migration to add autoplay episode limit setting
+672/// - Adds autoplay_max_episodes column to user_player_settings
+673/// - 0 = unlimited (default), any positive value limits consecutive auto-plays
+674const MIGRATION_016: &str = r#"
+675ALTER TABLE user_player_settings
+676ADD COLUMN autoplay_max_episodes INTEGER DEFAULT 0;
+677"#;
+678
+679/// Migration to persist the resolved stream URL and target directory on each
+680/// download row. This lets the backend queue pump start a pending download by
+681/// itself (replaying the stored URL) once a concurrency slot frees up, instead
+682/// of relying on the frontend to re-issue every queued item.
+683const MIGRATION_017: &str = r#"
+684-- Resolved download source URL and on-disk target directory, captured when the
+685-- download is enqueued. Nullable: pre-existing rows and rows enqueued without a
+686-- URL simply won't be auto-started by the pump.
+687ALTER TABLE downloads ADD COLUMN stream_url TEXT;
+688ALTER TABLE downloads ADD COLUMN target_dir TEXT;
+689"#;
+690
+691/// Migration to record whether a cached item is a folder/container vs a playable
+692/// leaf. Needed so channel items (which can be either) route to the player or to
+693/// a browse list correctly. Existing cached rows predate the column and have an
+694/// unknown folder flag, so we force a refresh by clearing their `synced_at`,
+695/// causing the hybrid repository to re-fetch them from the server on next browse.
+696const MIGRATION_018: &str = r#"
+697ALTER TABLE items ADD COLUMN is_folder INTEGER DEFAULT 0;
+698
+699-- Force re-fetch of all cached items so is_folder is populated from the server.
+700UPDATE items SET synced_at = NULL;
+701"#;
+702
+703/// Migration to cache the full server genre catalog. Previously genres were
+704/// derived on the fly from cached albums, which meant offline (and the hybrid
+705/// cache-first race) only ever saw genres for the handful of locally-cached
+706/// albums — collapsing the variety on the music/TV/movie landing pages. This
+707/// table stores the complete genre list per library so offline has the real
+708/// catalog and cache-first routing returns the right thing.
+709const MIGRATION_019: &str = r#"
+710CREATE TABLE IF NOT EXISTS genres (
+711    id TEXT NOT NULL,
+712    server_id TEXT NOT NULL,
+713    library_id TEXT,
+714    name TEXT NOT NULL,
+715    album_count INTEGER,
+716    synced_at TEXT DEFAULT CURRENT_TIMESTAMP,
+717    PRIMARY KEY (server_id, library_id, name)
+718);
+719
+720CREATE INDEX IF NOT EXISTS idx_genres_scope ON genres(server_id, library_id);
+721"#;
+722
+723/// Migration to index `items.season_id`.
+724///
+725/// Episodes link to their season via `season_id` (parent_id is NULL in the
+726/// cache). The container-rollup queries used by the Downloaded browse and the
+727/// disk-usage aggregation join `children.season_id = c.id`, which without this
+728/// index degrades to an unindexable scan — a large synced catalog then makes
+729/// the Downloaded page hang ("Loading your downloads…"). `parent_id`,
+730/// `album_id`, and `series_id` were already indexed; this closes the gap.
+731const MIGRATION_020: &str = r#"
+732CREATE INDEX IF NOT EXISTS idx_items_season ON items(season_id);
+733"#;
+734
+735/// Discard and rebuild the FTS index from the `items` table.
+736///
+737/// Until DR-110, `save_to_cache` used `INSERT OR REPLACE INTO items`. REPLACE
+738/// deletes the conflicting row and inserts a new one, but SQLite only fires
+739/// `AFTER DELETE` triggers on that implicit delete when `recursive_triggers` is
+740/// enabled — it is not (storage/mod.rs sets only `foreign_keys` and
+741/// `journal_mode`), so `items_ad` never ran and the old index row was orphaned.
+742/// Worse, `items.id` is a `TEXT PRIMARY KEY`, so the replacement row also took a
+743/// *fresh rowid* and `items_ai` appended a second entry. Every catalog pass
+744/// therefore left another duplicate behind, and existing installs carry one
+745/// stale entry per item per sync since the database was created.
+746///
+747/// This was invisible in results — the `JOIN items_fts fts ON fts.rowid =
+748/// i.rowid` drops rowids that no longer exist — but it degrades `MATCH`
+749/// permanently, and it becomes a *correctness* problem the moment rowids are
+750/// freed and reused: a new item landing on a freed rowid inherits the orphan's
+751/// index entry and matches queries for the deleted item's title. The DR-110
+752/// deletion sweep frees rowids, so this rebuild must run before it.
+753///
+754/// `'rebuild'` is the FTS5 command for exactly this: it truncates the index and
+755/// repopulates it from the external content table.
+756///
+757/// TRACES: UR-065 | DR-110
+758const MIGRATION_021: &str = r#"
+759INSERT INTO items_fts(items_fts) VALUES('rebuild');
+760"#;
+761
+762/// Full-text index over `people`, mirroring `items_fts`.
+763///
+764/// People live in their own table (migration 009) rather than in `items`, and
+765/// had no FTS index at all — so the People group UR-060 requires could only ever
+766/// be filled by the server leg of search. With the local index now answering
+767/// first, an actor's name has to be findable offline too.
+768///
+769/// TRACES: UR-065, UR-060 | DR-111
+770const MIGRATION_022: &str = r#"
+771CREATE VIRTUAL TABLE IF NOT EXISTS people_fts USING fts5(
+772    name,
+773    overview,
+774    content='people',
+775    content_rowid='rowid'
+776);
+777
+778CREATE TRIGGER IF NOT EXISTS people_ai AFTER INSERT ON people BEGIN
+779    INSERT INTO people_fts(rowid, name, overview)
+780    VALUES (new.rowid, new.name, new.overview);
+781END;
+782
+783CREATE TRIGGER IF NOT EXISTS people_ad AFTER DELETE ON people BEGIN
+784    INSERT INTO people_fts(people_fts, rowid, name, overview)
+785    VALUES('delete', old.rowid, old.name, old.overview);
+786END;
+787
+788CREATE TRIGGER IF NOT EXISTS people_au AFTER UPDATE ON people BEGIN
+789    INSERT INTO people_fts(people_fts, rowid, name, overview)
+790    VALUES('delete', old.rowid, old.name, old.overview);
+791    INSERT INTO people_fts(rowid, name, overview)
+792    VALUES (new.rowid, new.name, new.overview);
+793END;
+794
+795-- Backfill for rows cached before this index existed.
+796INSERT INTO people_fts(people_fts) VALUES('rebuild');
+797"#;
+798
+799/// Give temporary downloads a life limit.
+800///
+801/// A cache entry is not a different kind of object from a download — it is a
+802/// download with a shorter life. Modelling it as one `downloads` row with an
+803/// expiry (rather than a parallel cache store) means there is a single storage
+804/// accounting, a single eviction path, and no way for a cache and a download
+805/// library to disagree about what is on disk.
+806///
+807/// `expires_at` is NULL for permanent rows, which is every row that exists
+808/// today: `download_source` defaults to `'user'`, and a user's own download
+809/// never expires. Only `'auto'` rows get a timestamp, and they are reclaimed by
+810/// whichever comes first — the expiry passing, or LRU eviction under space
+811/// pressure (DR-126).
+812///
+813/// TRACES: UR-071 | DR-127
+814const MIGRATION_023: &str = r#"
+815ALTER TABLE downloads ADD COLUMN expires_at TEXT;
+816
+817-- Reclaim scans filter on expiry among 'auto' rows; index both so the sweep
+818-- stays cheap as the cache tier grows.
+819CREATE INDEX IF NOT EXISTS idx_downloads_expiry
+820    ON downloads(download_source, expires_at);
+821"#;
+
\ No newline at end of file diff --git a/api/src/jellytau_lib/thumbnail/cache.rs.html b/api/src/jellytau_lib/thumbnail/cache.rs.html new file mode 100644 index 00000000..83b56a26 --- /dev/null +++ b/api/src/jellytau_lib/thumbnail/cache.rs.html @@ -0,0 +1,791 @@ +cache.rs - sourceSkip to main content

jellytau_lib/thumbnail/
cache.rs

1//! Thumbnail cache manager with LRU eviction
+2
+3use log::error;
+4use std::path::{Path, PathBuf};
+5use std::sync::Arc;
+6use std::sync::Mutex;
+7
+8use crate::storage::db_service::{DatabaseService, Query, QueryParam, RusqliteService};
+9
+10/// Configuration for the thumbnail cache
+11#[derive(Debug, Clone)]
+12pub struct CacheConfig {
+13    /// Maximum cache size in bytes (0 = unlimited)
+14    pub max_size_bytes: u64,
+15    /// Subdirectory name for cached thumbnails
+16    pub cache_subdir: String,
+17    /// Whether caching is enabled
+18    pub enabled: bool,
+19}
+20
+21impl Default for CacheConfig {
+22    fn default() -> Self {
+23        Self {
+24            max_size_bytes: 1024 * 1024 * 1024, // 1GB
+25            cache_subdir: "thumbnails".to_string(),
+26            enabled: true,
+27        }
+28    }
+29}
+30
+31/// Make one part of a cache filename safe to put in a path.
+32///
+33/// Every part of the name comes from the caller — the item id and image type are
+34/// taken verbatim from Jellyfin JSON — so none of them may contribute a path
+35/// separator or a `..`. The rule is the one the image tag has always used
+36/// (non-alphanumerics become `_`), applied to all three parts, so values that
+37/// were already safe keep producing exactly the filename they did before.
+38///
+39/// TRACES: | DR-210 | UT-204
+40fn safe_component(value: &str) -> String {
+41    value.replace(|c: char| !c.is_alphanumeric(), "_")
+42}
+43
+44/// Thumbnail cache with LRU eviction
+45pub struct ThumbnailCache {
+46    config: Arc<Mutex<CacheConfig>>,
+47    cache_dir: PathBuf,
+48}
+49
+50impl ThumbnailCache {
+51    /// Create a new thumbnail cache
+52    pub fn new(app_data_dir: PathBuf, config: CacheConfig) -> Self {
+53        let cache_dir = app_data_dir.join(&config.cache_subdir);
+54
+55        // Create cache directory if it doesn't exist
+56        if let Err(e) = std::fs::create_dir_all(&cache_dir) {
+57            error!("Failed to create thumbnail cache directory: {}", e);
+58        }
+59
+60        Self {
+61            config: Arc::new(Mutex::new(config)),
+62            cache_dir,
+63        }
+64    }
+65
+66    /// Resolve a cache filename against the cache directory, refusing anything
+67    /// that lands outside it.
+68    ///
+69    /// `..` is folded away lexically rather than through `canonicalize`, so a
+70    /// file that does not exist yet still resolves — the same approach as
+71    /// `media_server::resolve_path`. `safe_component` should already have made an
+72    /// escape impossible; this is the check at the point of use.
+73    ///
+74    /// TRACES: | DR-210 | UT-204
+75    fn resolve_in_cache_dir(&self, filename: &str) -> Result<PathBuf, String> {
+76        let mut resolved = self.cache_dir.clone();
+77        for part in Path::new(filename).components() {
+78            match part {
+79                std::path::Component::ParentDir => {
+80                    resolved.pop();
+81                }
+82                std::path::Component::CurDir => {}
+83                other => resolved.push(other),
+84            }
+85        }
+86
+87        if resolved.starts_with(&self.cache_dir) {
+88            Ok(resolved)
+89        } else {
+90            Err("Thumbnail path escapes the cache directory".to_string())
+91        }
+92    }
+93
+94    /// Check if caching is enabled
+95    pub fn is_enabled(&self) -> bool {
+96        self.config.lock().map(|c| c.enabled).unwrap_or(true)
+97    }
+98
+99    /// Get cached thumbnail path, or None if not cached
+100    /// Updates last_accessed timestamp for LRU tracking
+101    pub async fn get_cached_path(
+102        &self,
+103        db: Arc<RusqliteService>,
+104        item_id: &str,
+105        image_type: &str,
+106        tag: &str,
+107    ) -> Option<PathBuf> {
+108        // Primary lookup: exact (item_id, image_type, tag) match.
+109        let exact = Query::with_params(
+110            "SELECT file_path FROM thumbnails
+111             WHERE item_id = ? AND image_type = ? AND image_tag = ?",
+112            vec![
+113                QueryParam::String(item_id.to_string()),
+114                QueryParam::String(image_type.to_string()),
+115                QueryParam::String(tag.to_string()),
+116            ],
+117        );
+118
+119        if let Ok(Some(path_str)) = db
+120            .query_optional(exact, |row| row.get::<_, String>(0))
+121            .await
+122        {
+123            let path = PathBuf::from(&path_str);
+124            if path.exists() {
+125                self.touch(&db, item_id, image_type, Some(tag)).await;
+126                return Some(path);
+127            }
+128            // File gone — drop the stale row and fall through to the tag-agnostic
+129            // lookup below (another cached image for this item may still exist).
+130            let _ = db
+131                .execute(Query::with_params(
+132                    "DELETE FROM thumbnails WHERE item_id = ? AND image_type = ? AND image_tag = ?",
+133                    vec![
+134                        QueryParam::String(item_id.to_string()),
+135                        QueryParam::String(image_type.to_string()),
+136                        QueryParam::String(tag.to_string()),
+137                    ],
+138                ))
+139                .await;
+140        }
+141
+142        // Fallback: any cached image for this item + type, newest first. The
+143        // `image_tag` is a cache-busting version, and callers don't always pass
+144        // the same tag the image was cached under — e.g. the mini player asks for
+145        // the album image using the *track's* primary_image_tag. Ignoring the tag
+146        // here lets those still resolve offline instead of hitting the server.
+147        let any_tag = Query::with_params(
+148            "SELECT file_path FROM thumbnails
+149             WHERE item_id = ? AND image_type = ?
+150             ORDER BY cached_at DESC LIMIT 1",
+151            vec![
+152                QueryParam::String(item_id.to_string()),
+153                QueryParam::String(image_type.to_string()),
+154            ],
+155        );
+156
+157        let path_str: String = db.query_optional(any_tag, |row| row.get(0)).await.ok()??;
+158        let path = PathBuf::from(&path_str);
+159        if path.exists() {
+160            self.touch(&db, item_id, image_type, None).await;
+161            Some(path)
+162        } else {
+163            None
+164        }
+165    }
+166
+167    /// Update `last_accessed` for LRU tracking. When `tag` is `Some`, scope to
+168    /// that exact row; when `None`, touch every row for the item + type.
+169    async fn touch(
+170        &self,
+171        db: &Arc<RusqliteService>,
+172        item_id: &str,
+173        image_type: &str,
+174        tag: Option<&str>,
+175    ) {
+176        let query = match tag {
+177            Some(tag) => Query::with_params(
+178                "UPDATE thumbnails SET last_accessed = CURRENT_TIMESTAMP
+179                 WHERE item_id = ? AND image_type = ? AND image_tag = ?",
+180                vec![
+181                    QueryParam::String(item_id.to_string()),
+182                    QueryParam::String(image_type.to_string()),
+183                    QueryParam::String(tag.to_string()),
+184                ],
+185            ),
+186            None => Query::with_params(
+187                "UPDATE thumbnails SET last_accessed = CURRENT_TIMESTAMP
+188                 WHERE item_id = ? AND image_type = ?",
+189                vec![
+190                    QueryParam::String(item_id.to_string()),
+191                    QueryParam::String(image_type.to_string()),
+192                ],
+193            ),
+194        };
+195        let _ = db.execute(query).await;
+196    }
+197
+198    /// Save thumbnail to cache
+199    ///
+200    /// TRACES: | DR-210 | UT-204
+201    // The arguments are the cache key (item/type/tag) plus the payload and its
+202    // dimensions — all independent scalars borrowed from the caller. A parameter
+203    // struct would only move the same list one level down.
+204    #[allow(clippy::too_many_arguments)]
+205    pub async fn save_thumbnail(
+206        &self,
+207        db: Arc<RusqliteService>,
+208        item_id: &str,
+209        image_type: &str,
+210        tag: &str,
+211        data: &[u8],
+212        width: Option<i32>,
+213        height: Option<i32>,
+214    ) -> Result<PathBuf, String> {
+215        if !self.is_enabled() {
+216            return Err("Thumbnail caching is disabled".to_string());
+217        }
+218
+219        // Generate safe filename. The database keeps the *raw* key below, so the
+220        // lookup in `get_cached_path` still matches what the caller asks for; only
+221        // the on-disk name is sanitised, and the row records where it landed.
+222        let filename = format!(
+223            "{}_{}_{}.jpg",
+224            safe_component(item_id),
+225            safe_component(image_type),
+226            safe_component(tag)
+227        );
+228        let file_path = self.resolve_in_cache_dir(&filename)?;
+229
+230        // Ensure we have space (evict LRU items if needed)
+231        self.ensure_space(db.clone(), data.len() as u64).await?;
+232
+233        // Write file to disk
+234        std::fs::write(&file_path, data).map_err(|e| format!("Failed to write file: {}", e))?;
+235
+236        // Insert/update database entry
+237        let query = Query::with_params(
+238            "INSERT INTO thumbnails (item_id, image_type, image_tag, file_path, width, height, file_size, last_accessed, cached_at)
+239             VALUES (?, ?, ?, ?, ?, ?, ?, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP)
+240             ON CONFLICT(item_id, image_type, image_tag) DO UPDATE SET
+241                file_path = excluded.file_path,
+242                width = excluded.width,
+243                height = excluded.height,
+244                file_size = excluded.file_size,
+245                last_accessed = CURRENT_TIMESTAMP",
+246            vec![
+247                QueryParam::String(item_id.to_string()),
+248                QueryParam::String(image_type.to_string()),
+249                QueryParam::String(tag.to_string()),
+250                QueryParam::String(file_path.to_string_lossy().to_string()),
+251                width.map(QueryParam::Int).unwrap_or(QueryParam::Null),
+252                height.map(QueryParam::Int).unwrap_or(QueryParam::Null),
+253                QueryParam::Int64(data.len() as i64),
+254            ],
+255        );
+256
+257        db.execute(query)
+258            .await
+259            .map_err(|e| format!("Failed to save to database: {}", e))?;
+260
+261        Ok(file_path)
+262    }
+263
+264    /// Ensure there's enough space by evicting LRU items if needed
+265    async fn ensure_space(
+266        &self,
+267        db: Arc<RusqliteService>,
+268        needed_bytes: u64,
+269    ) -> Result<(), String> {
+270        let max_size = {
+271            let config = self.config.lock().map_err(|e| e.to_string())?;
+272            config.max_size_bytes
+273        };
+274
+275        if max_size == 0 {
+276            return Ok(()); // Unlimited
+277        }
+278
+279        let current_size = self.get_cache_size(db.clone()).await;
+280
+281        if current_size + needed_bytes <= max_size {
+282            return Ok(()); // Enough space
+283        }
+284
+285        // Need to evict LRU items
+286        let to_free = (current_size + needed_bytes).saturating_sub(max_size);
+287        self.evict_lru(db, to_free).await
+288    }
+289
+290    /// Evict least recently used items to free up space
+291    async fn evict_lru(&self, db: Arc<RusqliteService>, to_free: u64) -> Result<(), String> {
+292        let mut freed: u64 = 0;
+293
+294        // Get items ordered by last_accessed (oldest first)
+295        let query = Query::new(
+296            "SELECT id, file_path, file_size FROM thumbnails
+297             ORDER BY last_accessed ASC",
+298        );
+299
+300        let items: Vec<(i64, String, i64)> = db
+301            .query_many(query, |row| Ok((row.get(0)?, row.get(1)?, row.get(2)?)))
+302            .await
+303            .map_err(|e| e.to_string())?;
+304
+305        for (id, path, size) in items {
+306            if freed >= to_free {
+307                break;
+308            }
+309
+310            // Delete file from disk
+311            let _ = std::fs::remove_file(&path);
+312
+313            // Delete from database
+314            let delete_query = Query::with_params(
+315                "DELETE FROM thumbnails WHERE id = ?",
+316                vec![QueryParam::Int64(id)],
+317            );
+318            let _ = db.execute(delete_query).await;
+319
+320            freed += size as u64;
+321        }
+322
+323        Ok(())
+324    }
+325
+326    /// Get current total cache size in bytes
+327    pub async fn get_cache_size(&self, db: Arc<RusqliteService>) -> u64 {
+328        let query = Query::new("SELECT COALESCE(SUM(file_size), 0) FROM thumbnails");
+329
+330        db.query_one(query, |row| row.get::<_, i64>(0))
+331            .await
+332            .unwrap_or(0) as u64
+333    }
+334
+335    /// Get count of cached items
+336    pub async fn get_item_count(&self, db: Arc<RusqliteService>) -> i64 {
+337        let query = Query::new("SELECT COUNT(*) FROM thumbnails");
+338
+339        db.query_one(query, |row| row.get(0)).await.unwrap_or(0)
+340    }
+341
+342    /// Get the current cache limit in bytes
+343    pub async fn get_limit(&self, db: Arc<RusqliteService>) -> u64 {
+344        let query = Query::with_params(
+345            "SELECT value FROM cache_settings WHERE key = ?",
+346            vec![QueryParam::String("image_cache_limit_bytes".to_string())],
+347        );
+348
+349        db.query_optional(query, |row| row.get::<_, String>(0))
+350            .await
+351            .ok()
+352            .flatten()
+353            .and_then(|s| s.parse().ok())
+354            .unwrap_or(1024 * 1024 * 1024) // 1GB default
+355    }
+356
+357    /// Set the cache limit in bytes
+358    pub async fn set_limit(
+359        &self,
+360        db: Arc<RusqliteService>,
+361        limit_bytes: u64,
+362    ) -> Result<(), String> {
+363        // Update database setting
+364        let query = Query::with_params(
+365            "INSERT OR REPLACE INTO cache_settings (key, value, updated_at)
+366             VALUES (?, ?, CURRENT_TIMESTAMP)",
+367            vec![
+368                QueryParam::String("image_cache_limit_bytes".to_string()),
+369                QueryParam::String(limit_bytes.to_string()),
+370            ],
+371        );
+372
+373        db.execute(query)
+374            .await
+375            .map_err(|e| format!("Failed to update setting: {}", e))?;
+376
+377        // Update in-memory config
+378        if let Ok(mut config) = self.config.lock() {
+379            config.max_size_bytes = limit_bytes;
+380        }
+381
+382        // If new limit is lower, evict to comply
+383        let current_size = self.get_cache_size(db.clone()).await;
+384        if limit_bytes > 0 && current_size > limit_bytes {
+385            let to_free = current_size - limit_bytes;
+386            self.evict_lru(db, to_free).await?;
+387        }
+388
+389        Ok(())
+390    }
+391
+392    /// Clear all cached thumbnails
+393    pub async fn clear_cache(&self, db: Arc<RusqliteService>) -> Result<(), String> {
+394        // Get all file paths
+395        let query = Query::new("SELECT file_path FROM thumbnails");
+396
+397        let paths: Vec<String> = db
+398            .query_many(query, |row| row.get(0))
+399            .await
+400            .map_err(|e| e.to_string())?;
+401
+402        // Delete files from disk
+403        for path in paths {
+404            let _ = std::fs::remove_file(&path);
+405        }
+406
+407        // Clear database
+408        let delete_query = Query::new("DELETE FROM thumbnails");
+409        db.execute(delete_query)
+410            .await
+411            .map_err(|e| format!("Failed to clear database: {}", e))?;
+412
+413        Ok(())
+414    }
+415
+416    /// Delete cached thumbnail for a specific item
+417    pub async fn delete_item(&self, db: Arc<RusqliteService>, item_id: &str) -> Result<(), String> {
+418        // Get file paths for this item
+419        let query = Query::with_params(
+420            "SELECT file_path FROM thumbnails WHERE item_id = ?",
+421            vec![QueryParam::String(item_id.to_string())],
+422        );
+423
+424        let paths: Vec<String> = db
+425            .query_many(query, |row| row.get(0))
+426            .await
+427            .map_err(|e| e.to_string())?;
+428
+429        // Delete files
+430        for path in paths {
+431            let _ = std::fs::remove_file(&path);
+432        }
+433
+434        // Delete from database
+435        let delete_query = Query::with_params(
+436            "DELETE FROM thumbnails WHERE item_id = ?",
+437            vec![QueryParam::String(item_id.to_string())],
+438        );
+439        db.execute(delete_query)
+440            .await
+441            .map_err(|e| format!("Failed to delete from database: {}", e))?;
+442
+443        Ok(())
+444    }
+445}
+446
+447#[cfg(test)]
+448mod tests {
+449    use super::*;
+450    use crate::storage::db_service::RusqliteService;
+451    use rusqlite::Connection;
+452    use std::sync::{Arc, Mutex};
+453    use tempfile::TempDir;
+454
+455    fn setup_test_db() -> (Arc<RusqliteService>, TempDir) {
+456        let temp_dir = TempDir::new().unwrap();
+457        let conn = Connection::open_in_memory().unwrap();
+458
+459        // Create tables
+460        conn.execute(
+461            "CREATE TABLE thumbnails (
+462                id INTEGER PRIMARY KEY AUTOINCREMENT,
+463                item_id TEXT NOT NULL,
+464                image_type TEXT NOT NULL,
+465                image_tag TEXT NOT NULL,
+466                file_path TEXT NOT NULL,
+467                width INTEGER,
+468                height INTEGER,
+469                file_size INTEGER DEFAULT 0,
+470                cached_at TEXT DEFAULT CURRENT_TIMESTAMP,
+471                last_accessed TEXT DEFAULT CURRENT_TIMESTAMP,
+472                UNIQUE(item_id, image_type, image_tag)
+473            )",
+474            [],
+475        )
+476        .unwrap();
+477
+478        conn.execute(
+479            "CREATE TABLE cache_settings (
+480                key TEXT PRIMARY KEY,
+481                value TEXT NOT NULL,
+482                updated_at TEXT DEFAULT CURRENT_TIMESTAMP
+483            )",
+484            [],
+485        )
+486        .unwrap();
+487
+488        let db_service = Arc::new(RusqliteService::new(Arc::new(Mutex::new(conn))));
+489        (db_service, temp_dir)
+490    }
+491
+492    #[test]
+493    fn test_cache_creation() {
+494        let temp_dir = TempDir::new().unwrap();
+495        let cache = ThumbnailCache::new(temp_dir.path().to_path_buf(), CacheConfig::default());
+496
+497        assert!(cache.cache_dir.exists());
+498        assert!(cache.is_enabled());
+499    }
+500
+501    #[tokio::test]
+502    async fn test_save_and_get_thumbnail() {
+503        let (conn, temp_dir) = setup_test_db();
+504        let cache = ThumbnailCache::new(temp_dir.path().to_path_buf(), CacheConfig::default());
+505
+506        // Save a thumbnail
+507        let data = b"fake image data";
+508        let path = cache
+509            .save_thumbnail(
+510                conn.clone(),
+511                "item1",
+512                "Primary",
+513                "tag1",
+514                data,
+515                Some(100),
+516                Some(100),
+517            )
+518            .await
+519            .unwrap();
+520
+521        assert!(path.exists());
+522
+523        // Get cached path
+524        let cached = cache
+525            .get_cached_path(conn.clone(), "item1", "Primary", "tag1")
+526            .await;
+527        assert!(cached.is_some());
+528        assert_eq!(cached.unwrap(), path);
+529    }
+530
+531    #[tokio::test]
+532    async fn test_cache_miss() {
+533        let (conn, temp_dir) = setup_test_db();
+534        let cache = ThumbnailCache::new(temp_dir.path().to_path_buf(), CacheConfig::default());
+535
+536        let cached = cache
+537            .get_cached_path(conn.clone(), "nonexistent", "Primary", "tag1")
+538            .await;
+539        assert!(cached.is_none());
+540    }
+541
+542    #[tokio::test]
+543    async fn test_lru_eviction() {
+544        let (conn, temp_dir) = setup_test_db();
+545        let config = CacheConfig {
+546            max_size_bytes: 100, // Very small limit
+547            ..Default::default()
+548        };
+549        let cache = ThumbnailCache::new(temp_dir.path().to_path_buf(), config);
+550
+551        // Add items that exceed limit
+552        let data = vec![0u8; 60];
+553        cache
+554            .save_thumbnail(conn.clone(), "item1", "Primary", "tag1", &data, None, None)
+555            .await
+556            .unwrap();
+557
+558        // Second item should trigger eviction
+559        cache
+560            .save_thumbnail(conn.clone(), "item2", "Primary", "tag2", &data, None, None)
+561            .await
+562            .unwrap();
+563
+564        // First item should be evicted
+565        let cached = cache
+566            .get_cached_path(conn.clone(), "item1", "Primary", "tag1")
+567            .await;
+568        assert!(cached.is_none());
+569
+570        // Second item should exist
+571        let cached = cache
+572            .get_cached_path(conn.clone(), "item2", "Primary", "tag2")
+573            .await;
+574        assert!(cached.is_some());
+575    }
+576
+577    #[tokio::test]
+578    async fn test_clear_cache() {
+579        let (conn, temp_dir) = setup_test_db();
+580        let cache = ThumbnailCache::new(temp_dir.path().to_path_buf(), CacheConfig::default());
+581
+582        let data = b"fake image data";
+583        cache
+584            .save_thumbnail(conn.clone(), "item1", "Primary", "tag1", data, None, None)
+585            .await
+586            .unwrap();
+587        cache
+588            .save_thumbnail(conn.clone(), "item2", "Primary", "tag2", data, None, None)
+589            .await
+590            .unwrap();
+591
+592        assert_eq!(cache.get_item_count(conn.clone()).await, 2);
+593
+594        cache.clear_cache(conn.clone()).await.unwrap();
+595
+596        assert_eq!(cache.get_item_count(conn.clone()).await, 0);
+597        assert_eq!(cache.get_cache_size(conn.clone()).await, 0);
+598    }
+599
+600    #[tokio::test]
+601    async fn test_set_limit() {
+602        let (conn, temp_dir) = setup_test_db();
+603        let cache = ThumbnailCache::new(temp_dir.path().to_path_buf(), CacheConfig::default());
+604
+605        // Save some thumbnails
+606        let data = vec![0u8; 50];
+607        cache
+608            .save_thumbnail(conn.clone(), "item1", "Primary", "tag1", &data, None, None)
+609            .await
+610            .unwrap();
+611        cache
+612            .save_thumbnail(conn.clone(), "item2", "Primary", "tag2", &data, None, None)
+613            .await
+614            .unwrap();
+615
+616        // Set a limit smaller than current size
+617        cache.set_limit(conn.clone(), 60).await.unwrap();
+618
+619        // Some items should be evicted
+620        let size = cache.get_cache_size(conn.clone()).await;
+621        assert!(size <= 60);
+622    }
+623
+624    /// A traversal-style `item_id` must not steer a cache write out of the cache
+625    /// directory. The id reaches `save_thumbnail` verbatim from Jellyfin JSON, so
+626    /// it is not ours to trust.
+627    ///
+628    /// TRACES: | DR-210 | UT-204
+629    #[tokio::test]
+630    async fn test_save_thumbnail_confines_traversal_item_id() {
+631        let (conn, temp_dir) = setup_test_db();
+632        let cache = ThumbnailCache::new(temp_dir.path().to_path_buf(), CacheConfig::default());
+633
+634        let result = cache
+635            .save_thumbnail(
+636                conn.clone(),
+637                "../evil",
+638                "Primary",
+639                "tag1",
+640                b"fake image data",
+641                None,
+642                None,
+643            )
+644            .await;
+645
+646        // Where `../evil` lands if `..` is honoured: the cache dir's parent.
+647        let escaped = temp_dir.path().join("evil_Primary_tag1.jpg");
+648        assert!(
+649            !escaped.exists(),
+650            "wrote outside the cache directory: {}",
+651            escaped.display()
+652        );
+653
+654        // Refusing is acceptable; succeeding is too, as long as it stayed inside.
+655        if let Ok(path) = result {
+656            assert!(
+657                path.starts_with(&cache.cache_dir) && !path.to_string_lossy().contains(".."),
+658                "returned a path outside the cache directory: {}",
+659                path.display()
+660            );
+661            assert!(path.exists());
+662        }
+663    }
+664
+665    /// `Path::join` discards the base when handed an absolute path, so an
+666    /// absolute `item_id` would otherwise pick the write location outright.
+667    ///
+668    /// TRACES: | DR-210 | UT-204
+669    #[tokio::test]
+670    async fn test_save_thumbnail_confines_absolute_item_id() {
+671        let (conn, temp_dir) = setup_test_db();
+672        let outside = TempDir::new().unwrap();
+673        let cache = ThumbnailCache::new(temp_dir.path().to_path_buf(), CacheConfig::default());
+674
+675        let absolute_id = outside.path().join("evil").to_string_lossy().to_string();
+676        let result = cache
+677            .save_thumbnail(
+678                conn.clone(),
+679                &absolute_id,
+680                "Primary",
+681                "tag1",
+682                b"fake image data",
+683                None,
+684                None,
+685            )
+686            .await;
+687
+688        let escaped = outside.path().join("evil_Primary_tag1.jpg");
+689        assert!(
+690            !escaped.exists(),
+691            "wrote outside the cache directory: {}",
+692            escaped.display()
+693        );
+694
+695        if let Ok(path) = result {
+696            assert!(
+697                path.starts_with(&cache.cache_dir),
+698                "returned a path outside the cache directory: {}",
+699                path.display()
+700            );
+701        }
+702    }
+703
+704    /// `image_type` is equally unsanitised, and equally caller-supplied.
+705    ///
+706    /// TRACES: | DR-210 | UT-204
+707    #[tokio::test]
+708    async fn test_save_thumbnail_confines_traversal_image_type() {
+709        let (conn, temp_dir) = setup_test_db();
+710        let cache = ThumbnailCache::new(temp_dir.path().to_path_buf(), CacheConfig::default());
+711
+712        let path = cache
+713            .save_thumbnail(
+714                conn.clone(),
+715                "item1",
+716                "../Primary",
+717                "tag1",
+718                b"fake image data",
+719                None,
+720                None,
+721            )
+722            .await
+723            .expect("a malformed image_type should be sanitised, not break caching");
+724
+725        // The file belongs directly in the cache dir — no separator from the
+726        // image type may survive into the filename.
+727        assert_eq!(path.parent(), Some(cache.cache_dir.as_path()));
+728        assert!(path.exists());
+729    }
+730
+731    /// Ids, types and tags that were already filesystem-safe — the overwhelming
+732    /// majority — keep producing exactly the filename they did before, so
+733    /// sanitising does not orphan existing cache entries.
+734    ///
+735    /// TRACES: | DR-210 | UT-204
+736    #[tokio::test]
+737    async fn test_save_thumbnail_filename_unchanged_for_safe_values() {
+738        let (conn, temp_dir) = setup_test_db();
+739        let cache = ThumbnailCache::new(temp_dir.path().to_path_buf(), CacheConfig::default());
+740
+741        let path = cache
+742            .save_thumbnail(
+743                conn.clone(),
+744                "a1b2c3d4e5f60718293a4b5c6d7e8f90",
+745                "Primary",
+746                "abcdef0123456789",
+747                b"fake image data",
+748                None,
+749                None,
+750            )
+751            .await
+752            .unwrap();
+753
+754        assert_eq!(
+755            path,
+756            cache
+757                .cache_dir
+758                .join("a1b2c3d4e5f60718293a4b5c6d7e8f90_Primary_abcdef0123456789.jpg")
+759        );
+760    }
+761
+762    /// Sanitising the filename must not desynchronise the write path from the
+763    /// read path: the database keeps the raw key and the resolved path, so a
+764    /// lookup after a save still finds the file that was written.
+765    ///
+766    /// TRACES: | DR-210 | UT-204
+767    #[tokio::test]
+768    async fn test_traversal_item_id_still_round_trips() {
+769        let (conn, temp_dir) = setup_test_db();
+770        let cache = ThumbnailCache::new(temp_dir.path().to_path_buf(), CacheConfig::default());
+771
+772        let saved = cache
+773            .save_thumbnail(
+774                conn.clone(),
+775                "../evil",
+776                "Primary",
+777                "tag1",
+778                b"fake image data",
+779                None,
+780                None,
+781            )
+782            .await
+783            .unwrap();
+784
+785        let cached = cache
+786            .get_cached_path(conn.clone(), "../evil", "Primary", "tag1")
+787            .await;
+788        assert_eq!(cached, Some(saved));
+789    }
+790}
+
\ No newline at end of file diff --git a/api/src/jellytau_lib/thumbnail/mod.rs.html b/api/src/jellytau_lib/thumbnail/mod.rs.html new file mode 100644 index 00000000..cffe4fb1 --- /dev/null +++ b/api/src/jellytau_lib/thumbnail/mod.rs.html @@ -0,0 +1,42 @@ +mod.rs - sourceSkip to main content

jellytau_lib/thumbnail/
mod.rs

1//! Thumbnail caching with LRU eviction
+2//!
+3//! This module handles caching image thumbnails from Jellyfin servers with:
+4//! - Lazy caching (cache as viewed)
+5//! - LRU (Least Recently Used) eviction when storage limit reached
+6//! - Configurable storage limits
+7//! - SQLite-backed cache metadata
+8
+9pub mod cache;
+10pub mod worker;
+11
+12pub use cache::{CacheConfig, ThumbnailCache};
+13pub use worker::ThumbnailWorker;
+14
+15/// Statistics about the thumbnail cache
+16#[derive(specta::Type, Debug, Clone, serde::Serialize, serde::Deserialize)]
+17#[serde(rename_all = "camelCase")]
+18pub struct ThumbnailCacheStats {
+19    pub total_size_bytes: u64,
+20    pub item_count: i64,
+21    pub limit_bytes: u64,
+22}
+23
+24#[cfg(test)]
+25mod tests {
+26    use super::*;
+27
+28    #[test]
+29    fn test_stats_serialization() {
+30        let stats = ThumbnailCacheStats {
+31            total_size_bytes: 1024,
+32            item_count: 10,
+33            limit_bytes: 1073741824,
+34        };
+35
+36        let json = serde_json::to_string(&stats).unwrap();
+37        assert!(json.contains("\"totalSizeBytes\":1024"));
+38        assert!(json.contains("\"itemCount\":10"));
+39        assert!(json.contains("\"limitBytes\":1073741824"));
+40    }
+41}
+
\ No newline at end of file diff --git a/api/src/jellytau_lib/thumbnail/worker.rs.html b/api/src/jellytau_lib/thumbnail/worker.rs.html new file mode 100644 index 00000000..1b7e9283 --- /dev/null +++ b/api/src/jellytau_lib/thumbnail/worker.rs.html @@ -0,0 +1,123 @@ +worker.rs - sourceSkip to main content

jellytau_lib/thumbnail/
worker.rs

1//! Thumbnail download worker
+2
+3use std::time::Duration;
+4
+5/// Worker for downloading thumbnails from Jellyfin server
+6pub struct ThumbnailWorker {
+7    client: reqwest::Client,
+8}
+9
+10impl ThumbnailWorker {
+11    /// Create a new thumbnail worker
+12    pub fn new() -> Self {
+13        let client = reqwest::Client::builder()
+14            .timeout(Duration::from_secs(30))
+15            .https_only(true)
+16            .build()
+17            .expect("Failed to create HTTP client");
+18
+19        Self { client }
+20    }
+21
+22    /// Download a thumbnail from URL
+23    pub async fn download(&self, url: &str) -> Result<Vec<u8>, ThumbnailDownloadError> {
+24        let response = self
+25            .client
+26            .get(url)
+27            .send()
+28            .await
+29            .map_err(|e| ThumbnailDownloadError::Network(e.to_string()))?;
+30
+31        let status = response.status();
+32        if !status.is_success() {
+33            return Err(ThumbnailDownloadError::Http(status.as_u16()));
+34        }
+35
+36        let bytes = response
+37            .bytes()
+38            .await
+39            .map_err(|e| ThumbnailDownloadError::Network(e.to_string()))?;
+40
+41        Ok(bytes.to_vec())
+42    }
+43
+44    /// Download with retry logic
+45    pub async fn download_with_retry(
+46        &self,
+47        url: &str,
+48        max_retries: u32,
+49    ) -> Result<Vec<u8>, ThumbnailDownloadError> {
+50        let mut last_error = ThumbnailDownloadError::Network("No attempts made".to_string());
+51
+52        for attempt in 0..=max_retries {
+53            match self.download(url).await {
+54                Ok(data) => return Ok(data),
+55                Err(e) if e.is_retryable() && attempt < max_retries => {
+56                    last_error = e;
+57                    // Simple exponential backoff: 100ms, 200ms, 400ms
+58                    let delay = Duration::from_millis(100 * (1 << attempt));
+59                    tokio::time::sleep(delay).await;
+60                }
+61                Err(e) => return Err(e),
+62            }
+63        }
+64
+65        Err(last_error)
+66    }
+67}
+68
+69impl Default for ThumbnailWorker {
+70    fn default() -> Self {
+71        Self::new()
+72    }
+73}
+74
+75/// Errors that can occur during thumbnail download
+76#[derive(Debug)]
+77pub enum ThumbnailDownloadError {
+78    Network(String),
+79    Http(u16),
+80}
+81
+82impl ThumbnailDownloadError {
+83    /// Check if this error is retryable
+84    pub fn is_retryable(&self) -> bool {
+85        match self {
+86            ThumbnailDownloadError::Network(_) => true,
+87            ThumbnailDownloadError::Http(status) => *status >= 500,
+88        }
+89    }
+90}
+91
+92impl std::fmt::Display for ThumbnailDownloadError {
+93    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
+94        match self {
+95            ThumbnailDownloadError::Network(msg) => write!(f, "Network error: {}", msg),
+96            ThumbnailDownloadError::Http(status) => write!(f, "HTTP error: {}", status),
+97        }
+98    }
+99}
+100
+101impl std::error::Error for ThumbnailDownloadError {}
+102
+103#[cfg(test)]
+104mod tests {
+105    use super::*;
+106
+107    #[test]
+108    fn test_error_retryable() {
+109        assert!(ThumbnailDownloadError::Network("timeout".to_string()).is_retryable());
+110        assert!(ThumbnailDownloadError::Http(500).is_retryable());
+111        assert!(ThumbnailDownloadError::Http(503).is_retryable());
+112        assert!(!ThumbnailDownloadError::Http(404).is_retryable());
+113        assert!(!ThumbnailDownloadError::Http(400).is_retryable());
+114    }
+115
+116    #[test]
+117    fn test_worker_creation() {
+118        let _worker = ThumbnailWorker::new();
+119        // Just verify it doesn't panic
+120        let _default = ThumbnailWorker::default();
+121    }
+122}
+
\ No newline at end of file diff --git a/api/src/jellytau_lib/utils/conversions.rs.html b/api/src/jellytau_lib/utils/conversions.rs.html new file mode 100644 index 00000000..eb758fab --- /dev/null +++ b/api/src/jellytau_lib/utils/conversions.rs.html @@ -0,0 +1,224 @@ +conversions.rs - sourceSkip to main content

jellytau_lib/utils/
conversions.rs

1//! Unit conversion and formatting utilities
+2//!
+3//! This module provides centralized conversion functions for:
+4//! - Jellyfin tick-to-seconds conversions
+5//! - Volume normalization (0-1 vs 0-100 ranges)
+6//! - Time formatting for UI display
+7//!
+8//! These utilities eliminate magic numbers and duplicate conversion logic
+9//! across the codebase.
+10
+11/// Number of Jellyfin ticks per second (10 million)
+12///
+13/// Jellyfin uses "ticks" for time values where 10,000,000 ticks = 1 second.
+14/// This follows the .NET TimeSpan.Ticks convention.
+15pub const TICKS_PER_SECOND: i64 = 10_000_000;
+16
+17/// Convert seconds to Jellyfin ticks
+18///
+19/// # Arguments
+20/// * `seconds` - Time in seconds (e.g., 90.5 for 1 minute 30.5 seconds)
+21///
+22/// # Returns
+23/// Time in Jellyfin ticks (will be rounded down to nearest tick)
+24///
+25/// # Example
+26/// ```
+27/// # use jellytau_lib::utils::conversions::*;
+28/// let ticks = seconds_to_ticks(1.5); // 15,000,000 ticks
+29/// ```
+30#[inline]
+31pub fn seconds_to_ticks(seconds: f64) -> i64 {
+32    (seconds * TICKS_PER_SECOND as f64) as i64
+33}
+34
+35/// Convert Jellyfin ticks to seconds
+36///
+37/// # Arguments
+38/// * `ticks` - Time in Jellyfin ticks
+39///
+40/// # Returns
+41/// Time in seconds as floating point
+42///
+43/// # Example
+44/// ```
+45/// # use jellytau_lib::utils::conversions::*;
+46/// let seconds = ticks_to_seconds(15_000_000); // 1.5 seconds
+47/// ```
+48#[inline]
+49pub fn ticks_to_seconds(ticks: i64) -> f64 {
+50    ticks as f64 / TICKS_PER_SECOND as f64
+51}
+52
+53/// Convert percentage volume (0-100) to normalized (0.0-1.0)
+54///
+55/// Used when receiving volume from Jellyfin remote sessions or UI controls.
+56/// Values outside the 0-100 range are clamped.
+57///
+58/// # Arguments
+59/// * `percent` - Volume as percentage (0 to 100)
+60///
+61/// # Returns
+62/// Normalized volume (0.0 to 1.0)
+63///
+64/// # Example
+65/// ```
+66/// # use jellytau_lib::utils::conversions::*;
+67/// let normalized = percent_to_volume(75.0); // 0.75
+68/// ```
+69#[inline]
+70pub fn percent_to_volume(percent: f64) -> f64 {
+71    percent.clamp(0.0, 100.0) / 100.0
+72}
+73
+74/// Convert normalized volume (0.0-1.0) to percentage (0-100)
+75///
+76/// Used when sending volume to Jellyfin remote sessions, MPV, or ExoPlayer.
+77/// Values outside the 0.0-1.0 range are clamped.
+78///
+79/// # Arguments
+80/// * `volume` - Normalized volume (0.0 to 1.0)
+81///
+82/// # Returns
+83/// Volume as percentage (0 to 100), floored to integer value
+84///
+85/// # Example
+86/// ```
+87/// # use jellytau_lib::utils::conversions::*;
+88/// let percent = volume_to_percent(0.75); // 75.0
+89/// ```
+90#[inline]
+91#[cfg_attr(not(target_os = "linux"), allow(dead_code))]
+92pub fn volume_to_percent(volume: f64) -> f64 {
+93    (volume.clamp(0.0, 1.0) * 100.0).floor()
+94}
+95
+96/// Format time in seconds to MM:SS display string
+97///
+98/// # Arguments
+99/// * `seconds` - Time in seconds
+100///
+101/// # Returns
+102/// Formatted string like "3:45" or "12:09"
+103///
+104/// # Example
+105/// ```
+106/// # use jellytau_lib::utils::conversions::*;
+107/// let formatted = format_time(225.0); // "3:45"
+108/// ```
+109pub fn format_time(seconds: f64) -> String {
+110    let mins = (seconds / 60.0).floor() as i64;
+111    let secs = (seconds % 60.0).floor() as i64;
+112    format!("{}:{:02}", mins, secs)
+113}
+114
+115/// Format time in seconds to HH:MM:SS or MM:SS display string
+116///
+117/// Automatically chooses format based on duration:
+118/// - Less than 1 hour: Returns MM:SS format
+119/// - 1 hour or more: Returns HH:MM:SS format
+120///
+121/// # Arguments
+122/// * `seconds` - Time in seconds
+123///
+124/// # Returns
+125/// Formatted string like "1:23:45" or "3:45"
+126///
+127/// # Example
+128/// ```
+129/// # use jellytau_lib::utils::conversions::*;
+130/// let short = format_time_long(225.0);  // "3:45"
+131/// let long = format_time_long(5025.0);  // "1:23:45"
+132/// ```
+133pub fn format_time_long(seconds: f64) -> String {
+134    let hours = (seconds / 3600.0).floor() as i64;
+135    let mins = ((seconds % 3600.0) / 60.0).floor() as i64;
+136    let secs = (seconds % 60.0).floor() as i64;
+137
+138    if hours > 0 {
+139        format!("{}:{:02}:{:02}", hours, mins, secs)
+140    } else {
+141        format!("{}:{:02}", mins, secs)
+142    }
+143}
+144
+145/// Calculate progress percentage from position and duration
+146///
+147/// # Arguments
+148/// * `position` - Current position in seconds
+149/// * `duration` - Total duration in seconds
+150///
+151/// # Returns
+152/// Progress as percentage (0.0 to 100.0), or 0.0 if duration is invalid
+153///
+154/// # Example
+155/// ```
+156/// # use jellytau_lib::utils::conversions::*;
+157/// let progress = calculate_progress(45.0, 180.0); // 25.0
+158/// ```
+159pub fn calculate_progress(position: f64, duration: f64) -> f64 {
+160    if duration <= 0.0 {
+161        return 0.0;
+162    }
+163    ((position / duration) * 100.0).clamp(0.0, 100.0)
+164}
+165
+166#[cfg(test)]
+167mod tests {
+168    use super::*;
+169
+170    #[test]
+171    fn test_tick_conversion() {
+172        assert_eq!(seconds_to_ticks(1.0), 10_000_000);
+173        assert_eq!(seconds_to_ticks(0.5), 5_000_000);
+174        assert_eq!(ticks_to_seconds(10_000_000), 1.0);
+175        assert_eq!(ticks_to_seconds(5_000_000), 0.5);
+176    }
+177
+178    #[test]
+179    fn test_volume_conversion() {
+180        assert_eq!(percent_to_volume(100.0), 1.0);
+181        assert_eq!(percent_to_volume(50.0), 0.5);
+182        assert_eq!(percent_to_volume(0.0), 0.0);
+183        assert_eq!(volume_to_percent(1.0), 100.0);
+184        assert_eq!(volume_to_percent(0.5), 50.0);
+185        assert_eq!(volume_to_percent(0.0), 0.0);
+186    }
+187
+188    #[test]
+189    fn test_volume_clamping() {
+190        assert_eq!(percent_to_volume(150.0), 1.0);
+191        assert_eq!(percent_to_volume(-10.0), 0.0);
+192        assert_eq!(volume_to_percent(1.5), 100.0);
+193        assert_eq!(volume_to_percent(-0.5), 0.0);
+194    }
+195
+196    #[test]
+197    fn test_time_formatting() {
+198        assert_eq!(format_time(0.0), "0:00");
+199        assert_eq!(format_time(59.0), "0:59");
+200        assert_eq!(format_time(60.0), "1:00");
+201        assert_eq!(format_time(125.0), "2:05");
+202        assert_eq!(format_time(3661.0), "61:01");
+203    }
+204
+205    #[test]
+206    fn test_time_formatting_long() {
+207        assert_eq!(format_time_long(0.0), "0:00");
+208        assert_eq!(format_time_long(59.0), "0:59");
+209        assert_eq!(format_time_long(3599.0), "59:59");
+210        assert_eq!(format_time_long(3600.0), "1:00:00");
+211        assert_eq!(format_time_long(3661.0), "1:01:01");
+212        assert_eq!(format_time_long(7384.0), "2:03:04");
+213    }
+214
+215    #[test]
+216    fn test_progress_calculation() {
+217        assert_eq!(calculate_progress(0.0, 100.0), 0.0);
+218        assert_eq!(calculate_progress(50.0, 100.0), 50.0);
+219        assert_eq!(calculate_progress(100.0, 100.0), 100.0);
+220        assert_eq!(calculate_progress(25.0, 0.0), 0.0); // Invalid duration
+221        assert_eq!(calculate_progress(150.0, 100.0), 100.0); // Clamped
+222    }
+223}
+
\ No newline at end of file diff --git a/api/src/jellytau_lib/utils/diagnostics.rs.html b/api/src/jellytau_lib/utils/diagnostics.rs.html new file mode 100644 index 00000000..b1001d62 --- /dev/null +++ b/api/src/jellytau_lib/utils/diagnostics.rs.html @@ -0,0 +1,400 @@ +diagnostics.rs - sourceSkip to main content

jellytau_lib/utils/
diagnostics.rs

1//! Credential redaction and crash capture for diagnostic logs.
+2//!
+3//! TRACES: UR-078 | DR-218
+4//!
+5//! ## Why redaction lives here and not at the export
+6//!
+7//! A diagnostic bundle is something a user attaches to a public bug report. If a
+8//! Jellyfin access token can reach it, this feature is a credential-disclosure
+9//! bug with a friendly button on it.
+10//!
+11//! So [`redact`] runs in the log *formatter* — the token never reaches disk —
+12//! and again over every line the exporter copies, which covers files written by
+13//! an older build that lacked the formatter pass. Redacting only at export would
+14//! leave the secret sitting in a file on the device, which is exactly the thing
+15//! we are trying not to do.
+16//!
+17//! ## What is deliberately NOT redacted
+18//!
+19//! Server host, item ids, filenames and paths inside the app's own directories
+20//! all stay. They are not secrets and they are the entire diagnostic value of a
+21//! log: a bundle scrubbed of them is one nobody can debug anything from.
+22
+23use std::borrow::Cow;
+24
+25/// Replacement for a redacted value.
+26pub const REDACTED: &str = "[REDACTED]";
+27
+28/// Query-string parameters whose value is a credential.
+29///
+30/// Jellyfin accepts the API key under several spellings depending on the
+31/// endpoint and client generation, and this codebase has emitted more than one
+32/// of them over time.
+33const SECRET_QUERY_KEYS: &[&str] = &["api_key", "apikey", "x-emby-token", "accesstoken"];
+34
+35/// Header names whose value is a credential.
+36const SECRET_HEADERS: &[&str] = &[
+37    "x-emby-token",
+38    "x-mediabrowser-token",
+39    "authorization",
+40    "x-emby-authorization",
+41];
+42
+43/// JSON keys whose value is a credential.
+44const SECRET_JSON_KEYS: &[&str] = &["accesstoken", "password", "token"];
+45
+46/// Strip credentials from one log line.
+47///
+48/// Idempotent: redacting an already-redacted line changes nothing, which matters
+49/// because the exporter may re-process a file the formatter already cleaned.
+50pub fn redact(line: &str) -> String {
+51    let mut out = redact_query_params(line);
+52    out = redact_headers(&out);
+53    out = redact_json_values(&out);
+54    out = redact_emby_auth(&out);
+55    out
+56}
+57
+58/// `?api_key=abc&x=1` -> `?api_key=[REDACTED]&x=1`
+59///
+60/// The value ends at the first character that cannot be part of one: `&`
+61/// separates parameters, and whitespace/quotes mean the URL itself ended.
+62fn redact_query_params(line: &str) -> String {
+63    let mut result = String::with_capacity(line.len());
+64    let lower = line.to_ascii_lowercase();
+65    let bytes = line.as_bytes();
+66    let mut i = 0;
+67
+68    while i < bytes.len() {
+69        let mut matched = None;
+70        for key in SECRET_QUERY_KEYS {
+71            // A key only counts when it is preceded by ? or & (or starts the
+72            // line), so a *word* like "token" inside prose is left alone.
+73            if lower[i..].starts_with(key) {
+74                let prev = if i == 0 { None } else { Some(bytes[i - 1]) };
+75                let is_param_start = matches!(prev, None | Some(b'?') | Some(b'&'));
+76                let after = i + key.len();
+77                if is_param_start && after < bytes.len() && bytes[after] == b'=' {
+78                    matched = Some((*key, after + 1));
+79                    break;
+80                }
+81            }
+82        }
+83
+84        match matched {
+85            Some((key, value_start)) => {
+86                result.push_str(&line[i..i + key.len()]);
+87                result.push('=');
+88                result.push_str(REDACTED);
+89                let mut end = value_start;
+90                while end < bytes.len()
+91                    && !matches!(bytes[end], b'&' | b' ' | b'"' | b'\'' | b'\t' | b')')
+92                {
+93                    end += 1;
+94                }
+95                i = end;
+96            }
+97            None => {
+98                // Advance one whole char, not one byte: a UTF-8 boundary split
+99                // would panic on the slice above.
+100                let ch = line[i..].chars().next().unwrap_or('\0');
+101                result.push(ch);
+102                i += ch.len_utf8();
+103            }
+104        }
+105    }
+106
+107    result
+108}
+109
+110/// `X-Emby-Token: abc` -> `X-Emby-Token: [REDACTED]`
+111///
+112/// Scans forward from a cursor rather than recursing on the rewritten string.
+113/// The obvious recursive version does not terminate: the replacement keeps the
+114/// header *name*, so the next call finds the same header again and recurses
+115/// until the stack is gone. A test provoked exactly that.
+116fn redact_headers(line: &str) -> String {
+117    let mut out = String::with_capacity(line.len());
+118    let mut rest = line;
+119
+120    'outer: loop {
+121        let lower = rest.to_ascii_lowercase();
+122
+123        // Earliest header match in what remains, so several headers on one line
+124        // are handled left to right.
+125        let mut best: Option<(usize, usize)> = None;
+126        for header in SECRET_HEADERS {
+127            let needle = format!("{header}:");
+128            if let Some(pos) = lower.find(&needle) {
+129                let candidate = (pos, needle.len());
+130                if best.is_none_or(|(best_pos, _)| pos < best_pos) {
+131                    best = Some(candidate);
+132                }
+133            }
+134        }
+135
+136        let Some((pos, needle_len)) = best else {
+137            break 'outer;
+138        };
+139
+140        let value_start = pos + needle_len;
+141        // The value runs to the next comma or the end of the line: reqwest's
+142        // debug output prints several headers comma-separated on one line.
+143        let value_end = rest[value_start..]
+144            .find(',')
+145            .map_or(rest.len(), |c| value_start + c);
+146
+147        out.push_str(&rest[..value_start]);
+148        out.push(' ');
+149        out.push_str(REDACTED);
+150
+151        // Continue strictly *after* the value just handled -- this is what makes
+152        // the loop terminate.
+153        rest = &rest[value_end..];
+154    }
+155
+156    out.push_str(rest);
+157    out
+158}
+159
+160/// `"AccessToken":"abc"` -> `"AccessToken":"[REDACTED]"`
+161fn redact_json_values(line: &str) -> String {
+162    let mut out = Cow::Borrowed(line);
+163
+164    for key in SECRET_JSON_KEYS {
+165        loop {
+166            let lower = out.to_ascii_lowercase();
+167            let pattern = format!("\"{key}\"");
+168            let Some(key_pos) = lower.find(&pattern) else {
+169                break;
+170            };
+171
+172            // Find the opening quote of the value after the colon.
+173            let after_key = key_pos + pattern.len();
+174            let Some(colon_rel) = out[after_key..].find(':') else {
+175                break;
+176            };
+177            let value_region = after_key + colon_rel + 1;
+178            let Some(open_rel) = out[value_region..].find('"') else {
+179                break;
+180            };
+181            let open = value_region + open_rel;
+182            let Some(close_rel) = out[open + 1..].find('"') else {
+183                break;
+184            };
+185            let close = open + 1 + close_rel;
+186
+187            // Already redacted: stop, or this loops forever.
+188            if &out[open + 1..close] == REDACTED {
+189                break;
+190            }
+191
+192            let mut replaced = String::with_capacity(out.len());
+193            replaced.push_str(&out[..open + 1]);
+194            replaced.push_str(REDACTED);
+195            replaced.push_str(&out[close..]);
+196            out = Cow::Owned(replaced);
+197        }
+198    }
+199
+200    out.into_owned()
+201}
+202
+203/// `MediaBrowser Token="abc"` -> `MediaBrowser Token="[REDACTED]"`
+204///
+205/// Jellyfin's own auth header format, which is not JSON and not a query param.
+206fn redact_emby_auth(line: &str) -> String {
+207    let lower = line.to_ascii_lowercase();
+208    let Some(pos) = lower.find("token=\"") else {
+209        return line.to_string();
+210    };
+211    let open = pos + "token=\"".len();
+212    let Some(close_rel) = line[open..].find('"') else {
+213        return line.to_string();
+214    };
+215    let close = open + close_rel;
+216    if &line[open..close] == REDACTED {
+217        return line.to_string();
+218    }
+219    let mut out = String::with_capacity(line.len());
+220    out.push_str(&line[..open]);
+221    out.push_str(REDACTED);
+222    out.push_str(&line[close..]);
+223    out
+224}
+225
+226/// Reduce a server URL to scheme and host.
+227///
+228/// The host is diagnostic (is it https? a LAN address? a reverse proxy?); the
+229/// path and any query on it are not, and a configured URL has been seen to carry
+230/// a token.
+231pub fn redact_server_url(url: &str) -> String {
+232    let Some(scheme_end) = url.find("://") else {
+233        return REDACTED.to_string();
+234    };
+235    let after_scheme = scheme_end + 3;
+236    let host_end = url[after_scheme..]
+237        .find('/')
+238        .map_or(url.len(), |slash| after_scheme + slash);
+239    // Credentials embedded as user:pass@host must not survive.
+240    let host = &url[after_scheme..host_end];
+241    let host = host.rsplit('@').next().unwrap_or(host);
+242    format!("{}://{}", &url[..scheme_end], host)
+243}
+244
+245/// Install a panic hook that records the panic through `log::error!` before the
+246/// default hook runs.
+247///
+248/// # Why it chains rather than replaces
+249///
+250/// `utils::lock` installs a silencing hook around its own tests, which
+251/// deliberately provoke poisoned locks. Replacing the current hook here would
+252/// make that test output scream about panics it is intentionally causing — and,
+253/// more importantly, replacing whatever hook is present is how you lose the
+254/// backtrace the runtime would otherwise print.
+255pub fn install_panic_hook() {
+256    let previous = std::panic::take_hook();
+257    std::panic::set_hook(Box::new(move |info| {
+258        // The payload is very often the formatted message of a `panic!`, so it
+259        // goes through redaction like any other line: a panic inside the HTTP
+260        // layer can carry a URL.
+261        let payload = panic_payload_string(info);
+262        let location = info
+263            .location()
+264            .map(|l| format!("{}:{}", l.file(), l.line()))
+265            .unwrap_or_else(|| "unknown location".to_string());
+266
+267        log::error!("PANIC at {location}: {}", redact(&payload));
+268        log::error!("backtrace:\n{}", std::backtrace::Backtrace::force_capture());
+269
+270        previous(info);
+271    }));
+272}
+273
+274/// Extract a printable message from a panic payload.
+275fn panic_payload_string(info: &std::panic::PanicHookInfo<'_>) -> String {
+276    let payload = info.payload();
+277    if let Some(s) = payload.downcast_ref::<&str>() {
+278        (*s).to_string()
+279    } else if let Some(s) = payload.downcast_ref::<String>() {
+280        s.clone()
+281    } else {
+282        "non-string panic payload".to_string()
+283    }
+284}
+285
+286#[cfg(test)]
+287mod tests {
+288    use super::*;
+289
+290    #[test]
+291    fn redacts_api_key_query_parameter() {
+292        let line = "GET https://media.example.com/Items?api_key=abc123def&Limit=50";
+293        let out = redact(line);
+294        assert!(!out.contains("abc123def"), "token survived: {out}");
+295        assert!(out.contains("api_key=[REDACTED]"));
+296        // The rest of the URL is what makes the line worth keeping.
+297        assert!(out.contains("media.example.com"));
+298        assert!(out.contains("Limit=50"));
+299    }
+300
+301    #[test]
+302    fn redacts_every_spelling_of_the_key_parameter() {
+303        for key in ["api_key", "ApiKey", "X-Emby-Token", "AccessToken"] {
+304            let line = format!("https://h/Items?{key}=SECRETVALUE&x=1");
+305            let out = redact(&line);
+306            assert!(!out.contains("SECRETVALUE"), "{key} survived: {out}");
+307            assert!(out.contains("x=1"), "{key} ate the next parameter: {out}");
+308        }
+309    }
+310
+311    #[test]
+312    fn redacts_auth_headers() {
+313        let out = redact("request headers: X-Emby-Token: abc123, Accept: application/json");
+314        assert!(!out.contains("abc123"), "{out}");
+315        // A following header must survive -- the value stops at the comma.
+316        assert!(out.contains("Accept: application/json"), "{out}");
+317    }
+318
+319    #[test]
+320    fn redacts_authorization_header() {
+321        let out = redact("Authorization: Bearer verysecrettoken");
+322        assert!(!out.contains("verysecrettoken"), "{out}");
+323    }
+324
+325    #[test]
+326    fn redacts_json_access_token() {
+327        let out = redact(r#"login response {"User":{"Name":"duncan"},"AccessToken":"abc123"}"#);
+328        assert!(!out.contains("abc123"), "{out}");
+329        // The username is not a credential and is diagnostic.
+330        assert!(out.contains("duncan"), "{out}");
+331    }
+332
+333    #[test]
+334    fn redacts_the_emby_auth_header_form() {
+335        let line = r#"MediaBrowser Client="JellyTau", Token="abc123xyz""#;
+336        let out = redact(line);
+337        assert!(!out.contains("abc123xyz"), "{out}");
+338        assert!(out.contains("JellyTau"), "{out}");
+339    }
+340
+341    #[test]
+342    fn is_idempotent() {
+343        // The exporter re-processes files the formatter already cleaned; a
+344        // second pass must not corrupt them or loop.
+345        let once = redact("https://h/Items?api_key=abc&z=1");
+346        let twice = redact(&once);
+347        assert_eq!(once, twice);
+348    }
+349
+350    #[test]
+351    fn leaves_ordinary_lines_untouched() {
+352        let line = "player: advancing to next episode (item 4f2a, position 0)";
+353        assert_eq!(redact(line), line);
+354    }
+355
+356    #[test]
+357    fn does_not_redact_the_word_token_in_prose() {
+358        // "token" appears in comments and messages constantly. Only a real
+359        // parameter or header assignment should trigger.
+360        let line = "refreshing the access token because the session expired";
+361        assert_eq!(redact(line), line);
+362    }
+363
+364    #[test]
+365    fn handles_multibyte_characters_without_panicking() {
+366        // The scanner walks bytes; a naive implementation slices mid-character.
+367        let line = "playing “Où est le café” from https://h/Items?api_key=abc";
+368        let out = redact(line);
+369        assert!(!out.contains("abc"), "{out}");
+370        assert!(out.contains("café"), "{out}");
+371    }
+372
+373    #[test]
+374    fn server_url_keeps_scheme_and_host_only() {
+375        assert_eq!(
+376            redact_server_url("https://media.example.com/jellyfin?api_key=abc"),
+377            "https://media.example.com"
+378        );
+379        assert_eq!(
+380            redact_server_url("http://192.168.1.10:8096/"),
+381            "http://192.168.1.10:8096"
+382        );
+383    }
+384
+385    #[test]
+386    fn server_url_drops_embedded_credentials() {
+387        // http://user:password@host is a valid URL and has been pasted into
+388        // server-address fields before.
+389        assert_eq!(
+390            redact_server_url("https://duncan:hunter2@media.example.com/"),
+391            "https://media.example.com"
+392        );
+393    }
+394
+395    #[test]
+396    fn server_url_without_a_scheme_is_refused_rather_than_guessed() {
+397        assert_eq!(redact_server_url("media.example.com"), REDACTED);
+398    }
+399}
+
\ No newline at end of file diff --git a/api/src/jellytau_lib/utils/lock.rs.html b/api/src/jellytau_lib/utils/lock.rs.html new file mode 100644 index 00000000..2306d72f --- /dev/null +++ b/api/src/jellytau_lib/utils/lock.rs.html @@ -0,0 +1,112 @@ +lock.rs - sourceSkip to main content

jellytau_lib/utils/
lock.rs

1//! Poison-tolerant locking helpers.
+2//!
+3//! `std::sync::Mutex` and `RwLock` become *poisoned* if a thread panics while
+4//! holding the guard. After that, every `.lock().unwrap()` / `.read().unwrap()`
+5//! / `.write().unwrap()` panics as well — so a single failure can cascade into
+6//! an unrecoverable crash. That is a real risk for stateful subsystems like the
+7//! player, whose locks are touched from many background threads (the MPV event
+8//! loop, sleep/autoplay timers, JNI callbacks, the session poller).
+9//!
+10//! These extension traits recover the guard from a poisoned lock instead of
+11//! panicking. The data behind a poisoned lock may be in an unexpected state,
+12//! but for this application's state (queues, settings, flags) recovering and
+13//! continuing is far preferable to taking down playback entirely.
+14//!
+15//! Use `lock_safe()` / `read_safe()` / `write_safe()` in place of
+16//! `.lock().unwrap()` / `.read().unwrap()` / `.write().unwrap()`.
+17//!
+18//! Note: these apply only to `std::sync` primitives. `tokio::sync::Mutex` does
+19//! not poison, so async code keeps using `.lock().await` unchanged.
+20
+21use std::sync::{Mutex, MutexGuard, PoisonError, RwLock, RwLockReadGuard, RwLockWriteGuard};
+22
+23/// Poison-tolerant locking for [`std::sync::Mutex`].
+24pub trait MutexSafe<T: ?Sized> {
+25    /// Lock the mutex, recovering the inner guard if the lock was poisoned.
+26    fn lock_safe(&self) -> MutexGuard<'_, T>;
+27}
+28
+29impl<T: ?Sized> MutexSafe<T> for Mutex<T> {
+30    fn lock_safe(&self) -> MutexGuard<'_, T> {
+31        self.lock().unwrap_or_else(PoisonError::into_inner)
+32    }
+33}
+34
+35/// Poison-tolerant locking for [`std::sync::RwLock`].
+36pub trait RwLockSafe<T: ?Sized> {
+37    /// Acquire a read guard, recovering it if the lock was poisoned.
+38    fn read_safe(&self) -> RwLockReadGuard<'_, T>;
+39    /// Acquire a write guard, recovering it if the lock was poisoned.
+40    fn write_safe(&self) -> RwLockWriteGuard<'_, T>;
+41}
+42
+43impl<T: ?Sized> RwLockSafe<T> for RwLock<T> {
+44    fn read_safe(&self) -> RwLockReadGuard<'_, T> {
+45        self.read().unwrap_or_else(PoisonError::into_inner)
+46    }
+47
+48    fn write_safe(&self) -> RwLockWriteGuard<'_, T> {
+49        self.write().unwrap_or_else(PoisonError::into_inner)
+50    }
+51}
+52
+53#[cfg(test)]
+54mod tests {
+55    use super::*;
+56    use std::sync::Arc;
+57    use std::thread;
+58
+59    /// Run `f` with the panic hook suppressed so deliberately-poisoning panics
+60    /// don't spam the test output.
+61    fn without_panic_noise<R>(f: impl FnOnce() -> R) -> R {
+62        let prev = std::panic::take_hook();
+63        std::panic::set_hook(Box::new(|_| {}));
+64        let result = f();
+65        std::panic::set_hook(prev);
+66        result
+67    }
+68
+69    #[test]
+70    fn lock_safe_recovers_from_poison() {
+71        let mutex = Arc::new(Mutex::new(0u32));
+72        let poisoner = Arc::clone(&mutex);
+73
+74        without_panic_noise(|| {
+75            let _ = thread::spawn(move || {
+76                let mut guard = poisoner.lock().unwrap();
+77                *guard = 42;
+78                panic!("poison the mutex while holding the guard");
+79            })
+80            .join();
+81        });
+82
+83        // The mutex is now poisoned: the normal path would panic.
+84        assert!(mutex.lock().is_err());
+85
+86        // lock_safe recovers the guard with the last written value intact.
+87        let guard = mutex.lock_safe();
+88        assert_eq!(*guard, 42);
+89    }
+90
+91    #[test]
+92    fn rwlock_safe_recovers_from_poison() {
+93        let lock = Arc::new(RwLock::new(0u32));
+94        let poisoner = Arc::clone(&lock);
+95
+96        without_panic_noise(|| {
+97            let _ = thread::spawn(move || {
+98                let mut guard = poisoner.write().unwrap();
+99                *guard = 7;
+100                panic!("poison the rwlock while holding the write guard");
+101            })
+102            .join();
+103        });
+104
+105        assert!(lock.write().is_err());
+106        assert_eq!(*lock.read_safe(), 7);
+107
+108        *lock.write_safe() = 9;
+109        assert_eq!(*lock.read_safe(), 9);
+110    }
+111}
+
\ No newline at end of file diff --git a/api/src/jellytau_lib/utils/mod.rs.html b/api/src/jellytau_lib/utils/mod.rs.html new file mode 100644 index 00000000..cea8f4e0 --- /dev/null +++ b/api/src/jellytau_lib/utils/mod.rs.html @@ -0,0 +1,4 @@ +mod.rs - sourceSkip to main content

jellytau_lib/utils/
mod.rs

1pub mod conversions;
+2pub mod diagnostics;
+3pub mod lock;
+
\ No newline at end of file diff --git a/api/static.files/COPYRIGHT-685a456c.txt b/api/static.files/COPYRIGHT-685a456c.txt new file mode 100644 index 00000000..0c295b4b --- /dev/null +++ b/api/static.files/COPYRIGHT-685a456c.txt @@ -0,0 +1,72 @@ +# REUSE-IgnoreStart + +These documentation pages include resources by third parties. This copyright +file applies only to those resources. The following third party resources are +included, and carry their own copyright notices and license terms: + +* Fira (FiraSans-Regular.woff2, FiraSans-Medium.woff2, + FiraMono-Regular.woff2, FiraMono-Medium.woff2): + + Copyright (c) 2014, Mozilla Foundation https://mozilla.org/ + with Reserved Font Name < Fira >. + + Copyright (c) 2014, Telefonica S.A. + + Licensed under the SIL Open Font License, Version 1.1. + See Fira-LICENSE.txt. + +* rustdoc.css, main.js, and playpen.js: + + Copyright 2015 The Rust Developers. + Licensed under the Apache License, Version 2.0 (see LICENSE-APACHE.txt) or + the MIT license (LICENSE-MIT.txt) at your option. + +* normalize.css: + + Copyright (c) Nicolas Gallagher and Jonathan Neal. + Licensed under the MIT license (see LICENSE-MIT.txt). + +* Source Code Pro (SourceCodePro-Regular.ttf.woff2, + SourceCodePro-Semibold.ttf.woff2, SourceCodePro-It.ttf.woff2): + + Copyright 2010, 2012 Adobe Systems Incorporated (http://www.adobe.com/), + with Reserved Font Name 'Source'. All Rights Reserved. Source is a trademark + of Adobe Systems Incorporated in the United States and/or other countries. + + Licensed under the SIL Open Font License, Version 1.1. + See SourceCodePro-LICENSE.txt. + +* Source Serif 4 (SourceSerif4-Regular.ttf.woff2, SourceSerif4-Bold.ttf.woff2, + SourceSerif4-It.ttf.woff2, SourceSerif4-Semibold.ttf.woff2): + + Copyright 2014-2021 Adobe (http://www.adobe.com/), with Reserved Font Name + 'Source'. All Rights Reserved. Source is a trademark of Adobe in the United + States and/or other countries. + + Licensed under the SIL Open Font License, Version 1.1. + See SourceSerif4-LICENSE.md. + +* Nanum Barun Gothic Font (NanumBarunGothic.woff2) + + Copyright 2010, NAVER Corporation (http://www.nhncorp.com) + with Reserved Font Name Nanum, Naver Nanum, NanumGothic, Naver NanumGothic, + NanumMyeongjo, Naver NanumMyeongjo, NanumBrush, Naver NanumBrush, NanumPen, + Naver NanumPen, Naver NanumGothicEco, NanumGothicEco, + Naver NanumMyeongjoEco, NanumMyeongjoEco, Naver NanumGothicLight, + NanumGothicLight, NanumBarunGothic, Naver NanumBarunGothic. + + https://hangeul.naver.com/2017/nanum + https://github.com/hiun/NanumBarunGothic + + Licensed under the SIL Open Font License, Version 1.1. + See NanumBarunGothic-LICENSE.txt. + +* Rust logos (rust-logo.svg, favicon.svg, favicon-32x32.png) + + Copyright 2025 Rust Foundation. + Licensed under the Creative Commons Attribution license (CC-BY). + https://rustfoundation.org/policy/rust-trademark-policy/ + +This copyright file is intended to be distributed with rustdoc output. + +# REUSE-IgnoreEnd diff --git a/api/static.files/Fira-LICENSE-05ab6dbd.txt b/api/static.files/Fira-LICENSE-05ab6dbd.txt new file mode 100644 index 00000000..d7e9c149 --- /dev/null +++ b/api/static.files/Fira-LICENSE-05ab6dbd.txt @@ -0,0 +1,98 @@ +// REUSE-IgnoreStart + +Digitized data copyright (c) 2012-2015, The Mozilla Foundation and Telefonica S.A. +with Reserved Font Name < Fira >, + +This Font Software is licensed under the SIL Open Font License, Version 1.1. +This license is copied below, and is also available with a FAQ at: +http://scripts.sil.org/OFL + + +----------------------------------------------------------- +SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007 +----------------------------------------------------------- + +PREAMBLE +The goals of the Open Font License (OFL) are to stimulate worldwide +development of collaborative font projects, to support the font creation +efforts of academic and linguistic communities, and to provide a free and +open framework in which fonts may be shared and improved in partnership +with others. + +The OFL allows the licensed fonts to be used, studied, modified and +redistributed freely as long as they are not sold by themselves. The +fonts, including any derivative works, can be bundled, embedded, +redistributed and/or sold with any software provided that any reserved +names are not used by derivative works. The fonts and derivatives, +however, cannot be released under any other type of license. The +requirement for fonts to remain under this license does not apply +to any document created using the fonts or their derivatives. + +DEFINITIONS +"Font Software" refers to the set of files released by the Copyright +Holder(s) under this license and clearly marked as such. This may +include source files, build scripts and documentation. + +"Reserved Font Name" refers to any names specified as such after the +copyright statement(s). + +"Original Version" refers to the collection of Font Software components as +distributed by the Copyright Holder(s). + +"Modified Version" refers to any derivative made by adding to, deleting, +or substituting -- in part or in whole -- any of the components of the +Original Version, by changing formats or by porting the Font Software to a +new environment. + +"Author" refers to any designer, engineer, programmer, technical +writer or other person who contributed to the Font Software. + +PERMISSION & CONDITIONS +Permission is hereby granted, free of charge, to any person obtaining +a copy of the Font Software, to use, study, copy, merge, embed, modify, +redistribute, and sell modified and unmodified copies of the Font +Software, subject to the following conditions: + +1) Neither the Font Software nor any of its individual components, +in Original or Modified Versions, may be sold by itself. + +2) Original or Modified Versions of the Font Software may be bundled, +redistributed and/or sold with any software, provided that each copy +contains the above copyright notice and this license. These can be +included either as stand-alone text files, human-readable headers or +in the appropriate machine-readable metadata fields within text or +binary files as long as those fields can be easily viewed by the user. + +3) No Modified Version of the Font Software may use the Reserved Font +Name(s) unless explicit written permission is granted by the corresponding +Copyright Holder. This restriction only applies to the primary font name as +presented to the users. + +4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font +Software shall not be used to promote, endorse or advertise any +Modified Version, except to acknowledge the contribution(s) of the +Copyright Holder(s) and the Author(s) or with their explicit written +permission. + +5) The Font Software, modified or unmodified, in part or in whole, +must be distributed entirely under this license, and must not be +distributed under any other license. The requirement for fonts to +remain under this license does not apply to any document created +using the Font Software. + +TERMINATION +This license becomes null and void if any of the above conditions are +not met. + +DISCLAIMER +THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF +MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT +OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE +COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL +DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM +OTHER DEALINGS IN THE FONT SOFTWARE. + +// REUSE-IgnoreEnd diff --git a/api/static.files/FiraMono-Medium-86f75c8c.woff2 b/api/static.files/FiraMono-Medium-86f75c8c.woff2 new file mode 100644 index 00000000..610e9b20 Binary files /dev/null and b/api/static.files/FiraMono-Medium-86f75c8c.woff2 differ diff --git a/api/static.files/FiraMono-Regular-87c26294.woff2 b/api/static.files/FiraMono-Regular-87c26294.woff2 new file mode 100644 index 00000000..9fa44b7c Binary files /dev/null and b/api/static.files/FiraMono-Regular-87c26294.woff2 differ diff --git a/api/static.files/FiraSans-Italic-81dc35de.woff2 b/api/static.files/FiraSans-Italic-81dc35de.woff2 new file mode 100644 index 00000000..3f63664f Binary files /dev/null and b/api/static.files/FiraSans-Italic-81dc35de.woff2 differ diff --git a/api/static.files/FiraSans-Medium-e1aa3f0a.woff2 b/api/static.files/FiraSans-Medium-e1aa3f0a.woff2 new file mode 100644 index 00000000..7a1e5fc5 Binary files /dev/null and b/api/static.files/FiraSans-Medium-e1aa3f0a.woff2 differ diff --git a/api/static.files/FiraSans-MediumItalic-ccf7e434.woff2 b/api/static.files/FiraSans-MediumItalic-ccf7e434.woff2 new file mode 100644 index 00000000..2d08f9f7 Binary files /dev/null and b/api/static.files/FiraSans-MediumItalic-ccf7e434.woff2 differ diff --git a/api/static.files/FiraSans-Regular-0fe48ade.woff2 b/api/static.files/FiraSans-Regular-0fe48ade.woff2 new file mode 100644 index 00000000..e766e06c Binary files /dev/null and b/api/static.files/FiraSans-Regular-0fe48ade.woff2 differ diff --git a/api/static.files/LICENSE-APACHE-a60eea81.txt b/api/static.files/LICENSE-APACHE-a60eea81.txt new file mode 100644 index 00000000..16fe87b0 --- /dev/null +++ b/api/static.files/LICENSE-APACHE-a60eea81.txt @@ -0,0 +1,201 @@ + Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + +TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + +1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + +2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + +3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + +4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + +5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + +6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + +7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + +8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + +9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + +END OF TERMS AND CONDITIONS + +APPENDIX: How to apply the Apache License to your work. + + To apply the Apache License to your work, attach the following + boilerplate notice, with the fields enclosed by brackets "[]" + replaced with your own identifying information. (Don't include + the brackets!) The text should be enclosed in the appropriate + comment syntax for the file format. We also recommend that a + file or class name and description of purpose be included on the + same "printed page" as the copyright notice for easier + identification within third-party archives. + +Copyright [yyyy] [name of copyright owner] + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. diff --git a/api/static.files/LICENSE-MIT-23f18e03.txt b/api/static.files/LICENSE-MIT-23f18e03.txt new file mode 100644 index 00000000..31aa7938 --- /dev/null +++ b/api/static.files/LICENSE-MIT-23f18e03.txt @@ -0,0 +1,23 @@ +Permission is hereby granted, free of charge, to any +person obtaining a copy of this software and associated +documentation files (the "Software"), to deal in the +Software without restriction, including without +limitation the rights to use, copy, modify, merge, +publish, distribute, sublicense, and/or sell copies of +the Software, and to permit persons to whom the Software +is furnished to do so, subject to the following +conditions: + +The above copyright notice and this permission notice +shall be included in all copies or substantial portions +of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF +ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED +TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A +PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT +SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY +CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION +OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR +IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER +DEALINGS IN THE SOFTWARE. diff --git a/api/static.files/NanumBarunGothic-13b3dcba.ttf.woff2 b/api/static.files/NanumBarunGothic-13b3dcba.ttf.woff2 new file mode 100644 index 00000000..1866ad4b Binary files /dev/null and b/api/static.files/NanumBarunGothic-13b3dcba.ttf.woff2 differ diff --git a/api/static.files/NanumBarunGothic-LICENSE-a37d393b.txt b/api/static.files/NanumBarunGothic-LICENSE-a37d393b.txt new file mode 100644 index 00000000..4b3edc29 --- /dev/null +++ b/api/static.files/NanumBarunGothic-LICENSE-a37d393b.txt @@ -0,0 +1,103 @@ +// REUSE-IgnoreStart + +Copyright (c) 2010, NAVER Corporation (https://www.navercorp.com/), + +with Reserved Font Name Nanum, Naver Nanum, NanumGothic, Naver NanumGothic, +NanumMyeongjo, Naver NanumMyeongjo, NanumBrush, Naver NanumBrush, NanumPen, +Naver NanumPen, Naver NanumGothicEco, NanumGothicEco, Naver NanumMyeongjoEco, +NanumMyeongjoEco, Naver NanumGothicLight, NanumGothicLight, NanumBarunGothic, +Naver NanumBarunGothic, NanumSquareRound, NanumBarunPen, MaruBuri + +This Font Software is licensed under the SIL Open Font License, Version 1.1. +This license is copied below, and is also available with a FAQ at: +http://scripts.sil.org/OFL + + +----------------------------------------------------------- +SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007 +----------------------------------------------------------- + +PREAMBLE +The goals of the Open Font License (OFL) are to stimulate worldwide +development of collaborative font projects, to support the font creation +efforts of academic and linguistic communities, and to provide a free and +open framework in which fonts may be shared and improved in partnership +with others. + +The OFL allows the licensed fonts to be used, studied, modified and +redistributed freely as long as they are not sold by themselves. The +fonts, including any derivative works, can be bundled, embedded, +redistributed and/or sold with any software provided that any reserved +names are not used by derivative works. The fonts and derivatives, +however, cannot be released under any other type of license. The +requirement for fonts to remain under this license does not apply +to any document created using the fonts or their derivatives. + +DEFINITIONS +"Font Software" refers to the set of files released by the Copyright +Holder(s) under this license and clearly marked as such. This may +include source files, build scripts and documentation. + +"Reserved Font Name" refers to any names specified as such after the +copyright statement(s). + +"Original Version" refers to the collection of Font Software components as +distributed by the Copyright Holder(s). + +"Modified Version" refers to any derivative made by adding to, deleting, +or substituting -- in part or in whole -- any of the components of the +Original Version, by changing formats or by porting the Font Software to a +new environment. + +"Author" refers to any designer, engineer, programmer, technical +writer or other person who contributed to the Font Software. + +PERMISSION & CONDITIONS +Permission is hereby granted, free of charge, to any person obtaining +a copy of the Font Software, to use, study, copy, merge, embed, modify, +redistribute, and sell modified and unmodified copies of the Font +Software, subject to the following conditions: + +1) Neither the Font Software nor any of its individual components, +in Original or Modified Versions, may be sold by itself. + +2) Original or Modified Versions of the Font Software may be bundled, +redistributed and/or sold with any software, provided that each copy +contains the above copyright notice and this license. These can be +included either as stand-alone text files, human-readable headers or +in the appropriate machine-readable metadata fields within text or +binary files as long as those fields can be easily viewed by the user. + +3) No Modified Version of the Font Software may use the Reserved Font +Name(s) unless explicit written permission is granted by the corresponding +Copyright Holder. This restriction only applies to the primary font name as +presented to the users. + +4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font +Software shall not be used to promote, endorse or advertise any +Modified Version, except to acknowledge the contribution(s) of the +Copyright Holder(s) and the Author(s) or with their explicit written +permission. + +5) The Font Software, modified or unmodified, in part or in whole, +must be distributed entirely under this license, and must not be +distributed under any other license. The requirement for fonts to +remain under this license does not apply to any document created +using the Font Software. + +TERMINATION +This license becomes null and void if any of the above conditions are +not met. + +DISCLAIMER +THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF +MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT +OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE +COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL +DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM +OTHER DEALINGS IN THE FONT SOFTWARE. + +// REUSE-IgnoreEnd diff --git a/api/static.files/SourceCodePro-It-fc8b9304.ttf.woff2 b/api/static.files/SourceCodePro-It-fc8b9304.ttf.woff2 new file mode 100644 index 00000000..462c34ef Binary files /dev/null and b/api/static.files/SourceCodePro-It-fc8b9304.ttf.woff2 differ diff --git a/api/static.files/SourceCodePro-LICENSE-67f54ca7.txt b/api/static.files/SourceCodePro-LICENSE-67f54ca7.txt new file mode 100644 index 00000000..0d2941e1 --- /dev/null +++ b/api/static.files/SourceCodePro-LICENSE-67f54ca7.txt @@ -0,0 +1,97 @@ +// REUSE-IgnoreStart + +Copyright 2010, 2012 Adobe Systems Incorporated (http://www.adobe.com/), with Reserved Font Name 'Source'. All Rights Reserved. Source is a trademark of Adobe Systems Incorporated in the United States and/or other countries. + +This Font Software is licensed under the SIL Open Font License, Version 1.1. + +This license is copied below, and is also available with a FAQ at: http://scripts.sil.org/OFL + + +----------------------------------------------------------- +SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007 +----------------------------------------------------------- + +PREAMBLE +The goals of the Open Font License (OFL) are to stimulate worldwide +development of collaborative font projects, to support the font creation +efforts of academic and linguistic communities, and to provide a free and +open framework in which fonts may be shared and improved in partnership +with others. + +The OFL allows the licensed fonts to be used, studied, modified and +redistributed freely as long as they are not sold by themselves. The +fonts, including any derivative works, can be bundled, embedded, +redistributed and/or sold with any software provided that any reserved +names are not used by derivative works. The fonts and derivatives, +however, cannot be released under any other type of license. The +requirement for fonts to remain under this license does not apply +to any document created using the fonts or their derivatives. + +DEFINITIONS +"Font Software" refers to the set of files released by the Copyright +Holder(s) under this license and clearly marked as such. This may +include source files, build scripts and documentation. + +"Reserved Font Name" refers to any names specified as such after the +copyright statement(s). + +"Original Version" refers to the collection of Font Software components as +distributed by the Copyright Holder(s). + +"Modified Version" refers to any derivative made by adding to, deleting, +or substituting -- in part or in whole -- any of the components of the +Original Version, by changing formats or by porting the Font Software to a +new environment. + +"Author" refers to any designer, engineer, programmer, technical +writer or other person who contributed to the Font Software. + +PERMISSION & CONDITIONS +Permission is hereby granted, free of charge, to any person obtaining +a copy of the Font Software, to use, study, copy, merge, embed, modify, +redistribute, and sell modified and unmodified copies of the Font +Software, subject to the following conditions: + +1) Neither the Font Software nor any of its individual components, +in Original or Modified Versions, may be sold by itself. + +2) Original or Modified Versions of the Font Software may be bundled, +redistributed and/or sold with any software, provided that each copy +contains the above copyright notice and this license. These can be +included either as stand-alone text files, human-readable headers or +in the appropriate machine-readable metadata fields within text or +binary files as long as those fields can be easily viewed by the user. + +3) No Modified Version of the Font Software may use the Reserved Font +Name(s) unless explicit written permission is granted by the corresponding +Copyright Holder. This restriction only applies to the primary font name as +presented to the users. + +4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font +Software shall not be used to promote, endorse or advertise any +Modified Version, except to acknowledge the contribution(s) of the +Copyright Holder(s) and the Author(s) or with their explicit written +permission. + +5) The Font Software, modified or unmodified, in part or in whole, +must be distributed entirely under this license, and must not be +distributed under any other license. The requirement for fonts to +remain under this license does not apply to any document created +using the Font Software. + +TERMINATION +This license becomes null and void if any of the above conditions are +not met. + +DISCLAIMER +THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF +MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT +OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE +COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL +DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM +OTHER DEALINGS IN THE FONT SOFTWARE. + +// REUSE-IgnoreEnd diff --git a/api/static.files/SourceCodePro-Regular-8badfe75.ttf.woff2 b/api/static.files/SourceCodePro-Regular-8badfe75.ttf.woff2 new file mode 100644 index 00000000..10b558e0 Binary files /dev/null and b/api/static.files/SourceCodePro-Regular-8badfe75.ttf.woff2 differ diff --git a/api/static.files/SourceCodePro-Semibold-aa29a496.ttf.woff2 b/api/static.files/SourceCodePro-Semibold-aa29a496.ttf.woff2 new file mode 100644 index 00000000..5ec64eef Binary files /dev/null and b/api/static.files/SourceCodePro-Semibold-aa29a496.ttf.woff2 differ diff --git a/api/static.files/SourceSerif4-Bold-6d4fd4c0.ttf.woff2 b/api/static.files/SourceSerif4-Bold-6d4fd4c0.ttf.woff2 new file mode 100644 index 00000000..181a07f6 Binary files /dev/null and b/api/static.files/SourceSerif4-Bold-6d4fd4c0.ttf.woff2 differ diff --git a/api/static.files/SourceSerif4-It-ca3b17ed.ttf.woff2 b/api/static.files/SourceSerif4-It-ca3b17ed.ttf.woff2 new file mode 100644 index 00000000..2ae08a7b Binary files /dev/null and b/api/static.files/SourceSerif4-It-ca3b17ed.ttf.woff2 differ diff --git a/api/static.files/SourceSerif4-LICENSE-a2cfd9d5.md b/api/static.files/SourceSerif4-LICENSE-a2cfd9d5.md new file mode 100644 index 00000000..175fa4f4 --- /dev/null +++ b/api/static.files/SourceSerif4-LICENSE-a2cfd9d5.md @@ -0,0 +1,98 @@ + + +Copyright 2014-2021 Adobe (http://www.adobe.com/), with Reserved Font Name 'Source'. All Rights Reserved. Source is a trademark of Adobe in the United States and/or other countries. +Copyright 2014 - 2023 Adobe (http://www.adobe.com/), with Reserved Font Name ‘Source’. All Rights Reserved. Source is a trademark of Adobe in the United States and/or other countries. + +This Font Software is licensed under the SIL Open Font License, Version 1.1. + +This license is copied below, and is also available with a FAQ at: http://scripts.sil.org/OFL + + +----------------------------------------------------------- +SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007 +----------------------------------------------------------- + +PREAMBLE +The goals of the Open Font License (OFL) are to stimulate worldwide +development of collaborative font projects, to support the font creation +efforts of academic and linguistic communities, and to provide a free and +open framework in which fonts may be shared and improved in partnership +with others. + +The OFL allows the licensed fonts to be used, studied, modified and +redistributed freely as long as they are not sold by themselves. The +fonts, including any derivative works, can be bundled, embedded, +redistributed and/or sold with any software provided that any reserved +names are not used by derivative works. The fonts and derivatives, +however, cannot be released under any other type of license. The +requirement for fonts to remain under this license does not apply +to any document created using the fonts or their derivatives. + +DEFINITIONS +"Font Software" refers to the set of files released by the Copyright +Holder(s) under this license and clearly marked as such. This may +include source files, build scripts and documentation. + +"Reserved Font Name" refers to any names specified as such after the +copyright statement(s). + +"Original Version" refers to the collection of Font Software components as +distributed by the Copyright Holder(s). + +"Modified Version" refers to any derivative made by adding to, deleting, +or substituting -- in part or in whole -- any of the components of the +Original Version, by changing formats or by porting the Font Software to a +new environment. + +"Author" refers to any designer, engineer, programmer, technical +writer or other person who contributed to the Font Software. + +PERMISSION & CONDITIONS +Permission is hereby granted, free of charge, to any person obtaining +a copy of the Font Software, to use, study, copy, merge, embed, modify, +redistribute, and sell modified and unmodified copies of the Font +Software, subject to the following conditions: + +1) Neither the Font Software nor any of its individual components, +in Original or Modified Versions, may be sold by itself. + +2) Original or Modified Versions of the Font Software may be bundled, +redistributed and/or sold with any software, provided that each copy +contains the above copyright notice and this license. These can be +included either as stand-alone text files, human-readable headers or +in the appropriate machine-readable metadata fields within text or +binary files as long as those fields can be easily viewed by the user. + +3) No Modified Version of the Font Software may use the Reserved Font +Name(s) unless explicit written permission is granted by the corresponding +Copyright Holder. This restriction only applies to the primary font name as +presented to the users. + +4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font +Software shall not be used to promote, endorse or advertise any +Modified Version, except to acknowledge the contribution(s) of the +Copyright Holder(s) and the Author(s) or with their explicit written +permission. + +5) The Font Software, modified or unmodified, in part or in whole, +must be distributed entirely under this license, and must not be +distributed under any other license. The requirement for fonts to +remain under this license does not apply to any document created +using the Font Software. + +TERMINATION +This license becomes null and void if any of the above conditions are +not met. + +DISCLAIMER +THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF +MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT +OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE +COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL +DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM +OTHER DEALINGS IN THE FONT SOFTWARE. + + diff --git a/api/static.files/SourceSerif4-Regular-6b053e98.ttf.woff2 b/api/static.files/SourceSerif4-Regular-6b053e98.ttf.woff2 new file mode 100644 index 00000000..0263fc30 Binary files /dev/null and b/api/static.files/SourceSerif4-Regular-6b053e98.ttf.woff2 differ diff --git a/api/static.files/SourceSerif4-Semibold-457a13ac.ttf.woff2 b/api/static.files/SourceSerif4-Semibold-457a13ac.ttf.woff2 new file mode 100644 index 00000000..dd55f4e9 Binary files /dev/null and b/api/static.files/SourceSerif4-Semibold-457a13ac.ttf.woff2 differ diff --git a/api/static.files/favicon-044be391.svg b/api/static.files/favicon-044be391.svg new file mode 100644 index 00000000..8b34b511 --- /dev/null +++ b/api/static.files/favicon-044be391.svg @@ -0,0 +1,24 @@ + + + + + diff --git a/api/static.files/favicon-32x32-eab170b8.png b/api/static.files/favicon-32x32-eab170b8.png new file mode 100644 index 00000000..0670c4da Binary files /dev/null and b/api/static.files/favicon-32x32-eab170b8.png differ diff --git a/api/static.files/main-fcd733ba.js b/api/static.files/main-fcd733ba.js new file mode 100644 index 00000000..0c85d06f --- /dev/null +++ b/api/static.files/main-fcd733ba.js @@ -0,0 +1,24 @@ +"use strict";window.RUSTDOC_TOOLTIP_HOVER_MS=300;window.RUSTDOC_TOOLTIP_HOVER_EXIT_MS=450;function resourcePath(basename,extension){return getVar("root-path")+basename+getVar("resource-suffix")+extension;}function hideMain(){addClass(document.getElementById(MAIN_ID),"hidden");const toggle=document.getElementById("toggle-all-docs");if(toggle){toggle.setAttribute("disabled","disabled");}}function showMain(){const main=document.getElementById(MAIN_ID);if(!main){return;}removeClass(main,"hidden");const mainHeading=main.querySelector(".main-heading");if(mainHeading&&window.searchState.rustdocToolbar){if(window.searchState.rustdocToolbar.parentElement){window.searchState.rustdocToolbar.parentElement.removeChild(window.searchState.rustdocToolbar,);}mainHeading.appendChild(window.searchState.rustdocToolbar);}const toggle=document.getElementById("toggle-all-docs");if(toggle){toggle.removeAttribute("disabled");}}window.rootPath=getVar("root-path");window.currentCrate=getVar("current-crate");function getVirtualKey(ev){if("key"in ev&&typeof ev.key!=="undefined"){return ev.key;}const c=ev.charCode||ev.keyCode;if(c===27){return"Escape";}return String.fromCharCode(c);}const MAIN_ID="main-content";const ALTERNATIVE_DISPLAY_ID="alternative-display";const NOT_DISPLAYED_ID="not-displayed";function getNakedUrl(){return window.location.href.split("?")[0].split("#")[0];}function insertAfter(newNode,referenceNode){referenceNode.parentNode.insertBefore(newNode,referenceNode.nextSibling);}function getOrCreateSection(id,classes){let el=document.getElementById(id);if(!el){el=document.createElement("section");el.id=id;el.className=classes;insertAfter(el,document.getElementById(MAIN_ID));}return el;}function getAlternativeDisplayElem(){return getOrCreateSection(ALTERNATIVE_DISPLAY_ID,"content hidden");}function getNotDisplayedElem(){return getOrCreateSection(NOT_DISPLAYED_ID,"hidden");}function switchDisplayedElement(elemToDisplay){const el=getAlternativeDisplayElem();if(el.children.length>0){getNotDisplayedElem().appendChild(el.firstElementChild);}if(elemToDisplay===null){addClass(el,"hidden");showMain();return;}el.appendChild(elemToDisplay);hideMain();removeClass(el,"hidden");const mainHeading=elemToDisplay.querySelector(".main-heading");if(mainHeading&&window.searchState.rustdocToolbar){if(window.searchState.rustdocToolbar.parentElement){window.searchState.rustdocToolbar.parentElement.removeChild(window.searchState.rustdocToolbar,);}mainHeading.appendChild(window.searchState.rustdocToolbar);}}function browserSupportsHistoryApi(){return window.history&&typeof window.history.pushState==="function";}function preLoadCss(cssUrl){const link=document.createElement("link");link.href=cssUrl;link.rel="preload";link.as="style";document.getElementsByTagName("head")[0].appendChild(link);}(function(){const isHelpPage=window.location.pathname.endsWith("/help.html");function loadScript(url,errorCallback){const script=document.createElement("script");script.src=url;if(errorCallback!==undefined){script.onerror=errorCallback;}document.head.append(script);}onEachLazy(document.querySelectorAll(".settings-menu"),settingsMenu=>{settingsMenu.querySelector("a").onclick=event=>{if(event.ctrlKey||event.altKey||event.metaKey){return;}window.hideAllModals(false);addClass(settingsMenu,"rotate");event.preventDefault();loadScript(getVar("static-root-path")+getVar("settings-js"));setTimeout(()=>{const themes=getVar("themes").split(",");for(const theme of themes){if(theme!==""){preLoadCss(getVar("root-path")+theme+".css");}}},0);};});window.searchState={rustdocToolbar:document.querySelector("rustdoc-toolbar"),loadingText:"Loading search results...",inputElement:()=>{let el=document.getElementsByClassName("search-input")[0];if(!el){const out=nonnull(nonnull(window.searchState.outputElement()).parentElement);const hdr=document.createElement("div");hdr.className="main-heading search-results-main-heading";const params=window.searchState.getQueryStringParams();const autofocusParam=params.search===""?"autofocus":"";hdr.innerHTML=`
`;out.insertBefore(hdr,window.searchState.outputElement());el=document.getElementsByClassName("search-input")[0];}if(el instanceof HTMLInputElement){return el;}return null;},containerElement:()=>{let el=document.getElementById("search");if(!el){el=document.createElement("section");el.id="search";getNotDisplayedElem().appendChild(el);}return el;},outputElement:()=>{const container=window.searchState.containerElement();if(!container){return null;}let el=container.querySelector(".search-out");if(!el){el=document.createElement("div");el.className="search-out";container.appendChild(el);}return el;},title:document.title,titleBeforeSearch:document.title,timeout:null,currentTab:0,focusedByTab:[null,null,null],clearInputTimeout:()=>{if(window.searchState.timeout!==null){clearTimeout(window.searchState.timeout);window.searchState.timeout=null;}},isDisplayed:()=>{const container=window.searchState.containerElement();if(!container){return false;}return!!container.parentElement&&container.parentElement.id===ALTERNATIVE_DISPLAY_ID;},focus:()=>{const inputElement=window.searchState.inputElement();window.searchState.showResults();if(inputElement){inputElement.focus();requestAnimationFrame(()=>inputElement.focus());}},defocus:()=>{nonnull(window.searchState.inputElement()).blur();},toggle:()=>{if(window.searchState.isDisplayed()){window.searchState.defocus();window.searchState.hideResults();}else{window.searchState.focus();}},showResults:()=>{document.title=window.searchState.title;if(window.searchState.isDisplayed()){return;}const search=window.searchState.containerElement();switchDisplayedElement(search);const btn=document.querySelector("#search-button a");if(browserSupportsHistoryApi()&&btn instanceof HTMLAnchorElement&&window.searchState.getQueryStringParams().search===undefined){history.pushState(null,"",btn.href);}const btnLabel=document.querySelector("#search-button a span.label");if(btnLabel){btnLabel.innerHTML="Exit";}},removeQueryParameters:()=>{document.title=window.searchState.titleBeforeSearch;if(browserSupportsHistoryApi()){history.replaceState(null,"",getNakedUrl()+window.location.hash);}},hideResults:()=>{switchDisplayedElement(null);window.searchState.removeQueryParameters();const btnLabel=document.querySelector("#search-button a span.label");if(btnLabel){btnLabel.innerHTML="Search";}},getQueryStringParams:()=>{const params={};window.location.search.substring(1).split("&").map(s=>{const pair=s.split("=").map(x=>x.replace(/\+/g," "));params[decodeURIComponent(pair[0])]=typeof pair[1]==="undefined"?null:decodeURIComponent(pair[1]);});return params;},setup:()=>{let searchLoaded=false;const search_input=window.searchState.inputElement();if(!search_input){return;}function sendSearchForm(){document.getElementsByClassName("search-form")[0].submit();}function loadSearch(){if(!searchLoaded){searchLoaded=true;window.rr_=data=>{window.searchIndex=data;};if(!window.StringdexOnload){window.StringdexOnload=[];}window.StringdexOnload.push(()=>{loadScript(getVar("static-root-path")+getVar("search-js"),sendSearchForm,);});loadScript(getVar("static-root-path")+getVar("stringdex-js"),sendSearchForm);loadScript(resourcePath("search.index/root",".js"),sendSearchForm);}}search_input.addEventListener("focus",()=>{loadSearch();});const btn=document.getElementById("search-button");if(btn){btn.onclick=event=>{if(event.ctrlKey||event.altKey||event.metaKey){return;}event.preventDefault();window.searchState.toggle();loadSearch();};}if(browserSupportsHistoryApi()){const previousTitle=document.title;window.addEventListener("popstate",e=>{const params=window.searchState.getQueryStringParams();document.title=previousTitle;const inputElement=window.searchState.inputElement();if(params.search!==undefined&&inputElement!==null){loadSearch();inputElement.value=params.search;e.preventDefault();window.searchState.showResults();if(params.search===""){window.searchState.focus();}}else{window.searchState.hideResults();}});}window.onpageshow=()=>{const inputElement=window.searchState.inputElement();const qSearch=window.searchState.getQueryStringParams().search;if(qSearch!==undefined&&inputElement!==null){if(inputElement.value===""){inputElement.value=qSearch;}window.searchState.showResults();if(qSearch===""){loadSearch();window.searchState.focus();}}else{window.searchState.hideResults();}};const params=window.searchState.getQueryStringParams();if(params.search!==undefined){window.searchState.setLoadingSearch();loadSearch();}},setLoadingSearch:()=>{const search=window.searchState.outputElement();nonnull(search).innerHTML="

"+window.searchState.loadingText+"

";window.searchState.showResults();},descShards:new Map(),loadDesc:async function({descShard,descIndex}){if(descShard.promise===null){descShard.promise=new Promise((resolve,reject)=>{descShard.resolve=resolve;const ds=descShard;const fname=`${ds.crate}-desc-${ds.shard}-`;const url=resourcePath(`search.desc/${descShard.crate}/${fname}`,".js",);loadScript(url,reject);});}const list=await descShard.promise;return list[descIndex];},loadedDescShard:function(crate,shard,data){this.descShards.get(crate)[shard].resolve(data.split("\n"));},};const toggleAllDocsId="toggle-all-docs";let savedHash="";function handleHashes(ev){if(ev!==null&&window.searchState.isDisplayed()&&ev.newURL){switchDisplayedElement(null);const hash=ev.newURL.slice(ev.newURL.indexOf("#")+1);if(browserSupportsHistoryApi()){history.replaceState(null,"",getNakedUrl()+window.location.search+"#"+hash);}const elem=document.getElementById(hash);if(elem){elem.scrollIntoView();}}const pageId=window.location.hash.replace(/^#/,"");if(savedHash!==pageId){savedHash=pageId;if(pageId!==""){expandSection(pageId);}}if(savedHash.startsWith("impl-")){const splitAt=savedHash.indexOf("/");if(splitAt!==-1){const implId=savedHash.slice(0,splitAt);const assocId=savedHash.slice(splitAt+1);const implElems=document.querySelectorAll(`details > summary > section[id^="${implId}"]`,);onEachLazy(implElems,implElem=>{const numbered=/^(.+?)-([0-9]+)$/.exec(implElem.id);if(implElem.id!==implId&&(!numbered||numbered[1]!==implId)){return false;}return onEachLazy(implElem.parentElement.parentElement.querySelectorAll(`[id^="${assocId}"]`),item=>{const numbered=/^(.+?)-([0-9]+)$/.exec(item.id);if(item.id===assocId||(numbered&&numbered[1]===assocId)){openParentDetails(item);item.scrollIntoView();setTimeout(()=>{window.location.replace("#"+item.id);},0);return true;}},);});}}}function onHashChange(ev){hideSidebar();handleHashes(ev);}function openParentDetails(elem){while(elem){if(elem instanceof HTMLDetailsElement){elem.open=true;}elem=elem.parentElement;}}function expandSection(id){openParentDetails(document.getElementById(id));}function handleEscape(ev){window.searchState.clearInputTimeout();window.searchState.hideResults();ev.preventDefault();window.searchState.defocus();window.hideAllModals(true);}function handleShortcut(ev){const disableShortcuts=getSettingValue("disable-shortcuts")==="true";if(ev.ctrlKey||ev.altKey||ev.metaKey||disableShortcuts){return;}if(document.activeElement&&document.activeElement instanceof HTMLInputElement&&document.activeElement.type!=="checkbox"&&document.activeElement.type!=="radio"){switch(getVirtualKey(ev)){case"Escape":handleEscape(ev);break;}}else{switch(getVirtualKey(ev)){case"Escape":handleEscape(ev);break;case"s":case"S":case"/":ev.preventDefault();window.searchState.focus();break;case"+":case"=":ev.preventDefault();expandAllDocs();break;case"-":ev.preventDefault();collapseAllDocs(false);break;case"_":ev.preventDefault();collapseAllDocs(true);break;case"?":showHelp();break;default:break;}}}document.addEventListener("keypress",handleShortcut);document.addEventListener("keydown",handleShortcut);function addSidebarItems(){if(!window.SIDEBAR_ITEMS){return;}const sidebar=document.getElementById("rustdoc-modnav");function block(shortty,id,longty){const filtered=window.SIDEBAR_ITEMS[shortty];if(!filtered){return;}const modpath=hasClass(document.querySelector(".rustdoc"),"mod")?"../":"";const h3=document.createElement("h3");h3.innerHTML=`${longty}`;const ul=document.createElement("ul");ul.className="block "+shortty;for(const item of filtered){const[name,isMacro]=Array.isArray(item)?[item[0],true]:[item,false];let path;if(shortty==="mod"){path=`${modpath}${name}/index.html`;}else if(!isMacro){path=`${modpath}${shortty}.${name}.html`;}else{path=`${modpath}macro.${name}.html`;}let current_page=document.location.href.toString();if(current_page.endsWith("/")){current_page+="index.html";}const link=document.createElement("a");link.href=path;link.textContent=name;const li=document.createElement("li");if(link.href===current_page){li.classList.add("current");}li.appendChild(link);ul.appendChild(li);}sidebar.appendChild(h3);sidebar.appendChild(ul);}if(sidebar){block("primitive","primitives","Primitive Types");block("mod","modules","Modules");block("macro","macros","Macros");block("struct","structs","Structs");block("enum","enums","Enums");block("constant","constants","Constants");block("static","static","Statics");block("trait","traits","Traits");block("fn","functions","Functions");block("type","types","Type Aliases");block("union","unions","Unions");block("foreigntype","foreign-types","Foreign Types");block("keyword","keywords","Keywords");block("attribute","attribute-docs","Attributes");block("attr","attributes","Attribute Macros");block("derive","derives","Derive Macros");block("traitalias","trait-aliases","Trait Aliases");}}window.register_implementors=imp=>{function implementorsElems(id){const elem=document.getElementById(id);return[elem,elem?elem.querySelector(".negative-marker"):null];}const implementors=implementorsElems("implementors-list");const syntheticImplementors=implementorsElems("synthetic-implementors-list");const inlined_types=new Set();const TEXT_IDX=0;const IS_NEG_IDX=1;const SYNTHETIC_IDX=2;const TYPES_IDX=3;if(syntheticImplementors[0]){onEachLazy(syntheticImplementors[0].getElementsByClassName("impl"),el=>{const aliases=el.getAttribute("data-aliases");if(!aliases){return;}aliases.split(",").forEach(alias=>{inlined_types.add(alias);});});}let currentNbImpls=implementors[0].getElementsByClassName("impl").length;const traitName=document.querySelector(".main-heading h1 > .trait").textContent;const baseIdName="impl-"+traitName+"-";const libs=Object.getOwnPropertyNames(imp);const script=document.querySelector("script[data-ignore-extern-crates]");const ignoreExternCrates=new Set((script?script.getAttribute("data-ignore-extern-crates"):"").split(","),);for(const lib of libs){if(lib===window.currentCrate||ignoreExternCrates.has(lib)){continue;}const structs=imp[lib];struct_loop:for(const struct of structs){const list=struct[SYNTHETIC_IDX]?syntheticImplementors:implementors;if(struct[SYNTHETIC_IDX]){for(const struct_type of struct[TYPES_IDX]){if(inlined_types.has(struct_type)){continue struct_loop;}inlined_types.add(struct_type);}}const code=document.createElement("h3");code.innerHTML=struct[TEXT_IDX];addClass(code,"code-header");onEachLazy(code.getElementsByTagName("a"),elem=>{const href=elem.getAttribute("href");if(href&&!href.startsWith("#")&&!/^(?:[a-z+]+:)?\/\//.test(href)){elem.setAttribute("href",window.rootPath+href);}});const currentId=baseIdName+currentNbImpls;const anchor=document.createElement("a");anchor.href="#"+currentId;addClass(anchor,"anchor");const display=document.createElement("div");display.id=currentId;addClass(display,"impl");display.appendChild(anchor);display.appendChild(code);if(struct[IS_NEG_IDX]){list[1].before(display);}else{list[0].appendChild(display);}currentNbImpls+=1;}}if(implementors[0]){implementors[0].classList.add("loaded");}if(syntheticImplementors[0]){syntheticImplementors[0].classList.add("loaded");}};if(window.pending_implementors){window.register_implementors(window.pending_implementors);}window.register_type_impls=imp=>{if(!imp||!imp[window.currentCrate]){return;}window.pending_type_impls=undefined;const idMap=new Map();let implementations=document.getElementById("implementations-list");let trait_implementations=document.getElementById("trait-implementations-list");let trait_implementations_header=document.getElementById("trait-implementations");const script=document.querySelector("script[data-self-path]");const selfPath=script?script.getAttribute("data-self-path"):null;const mainContent=nonnull(document.querySelector("#main-content"));const sidebarSection=nonnull(document.querySelector(".sidebar section"));let methods=document.querySelector(".sidebar .block.method");let associatedTypes=document.querySelector(".sidebar .block.associatedtype");let associatedConstants=document.querySelector(".sidebar .block.associatedconstant");let sidebarTraitList=document.querySelector(".sidebar .block.trait-implementation");for(const impList of imp[nonnull(window.currentCrate)]){const types=impList.slice(2);const text=impList[0];const traitName=impList[1];const isTrait=typeof traitName==="string";if(types.indexOf(selfPath)===-1){continue;}let outputList=isTrait?trait_implementations:implementations;if(outputList===null){const outputListName=isTrait?"Trait Implementations":"Implementations";const outputListId=isTrait?"trait-implementations-list":"implementations-list";const outputListHeaderId=isTrait?"trait-implementations":"implementations";const outputListHeader=document.createElement("h2");outputListHeader.id=outputListHeaderId;outputListHeader.innerText=outputListName;outputList=document.createElement("div");outputList.id=outputListId;if(isTrait){const link=document.createElement("a");link.href=`#${outputListHeaderId}`;link.innerText="Trait Implementations";const h=document.createElement("h3");h.appendChild(link);trait_implementations=outputList;trait_implementations_header=outputListHeader;sidebarSection.appendChild(h);sidebarTraitList=document.createElement("ul");sidebarTraitList.className="block trait-implementation";sidebarSection.appendChild(sidebarTraitList);mainContent.appendChild(outputListHeader);mainContent.appendChild(outputList);}else{implementations=outputList;if(trait_implementations){mainContent.insertBefore(outputListHeader,trait_implementations_header);mainContent.insertBefore(outputList,trait_implementations_header);}else{mainContent.appendChild(outputListHeader);mainContent.appendChild(outputList);}}}const template=document.createElement("template");template.innerHTML=text;onEachLazy(template.content.querySelectorAll("a"),elem=>{const href=elem.getAttribute("href");if(href&&!href.startsWith("#")&&!/^(?:[a-z+]+:)?\/\//.test(href)){elem.setAttribute("href",window.rootPath+href);}});onEachLazy(template.content.querySelectorAll("[id]"),el=>{let i=0;if(idMap.has(el.id)){i=idMap.get(el.id);}else if(document.getElementById(el.id)){i=1;while(document.getElementById(`${el.id}-${2 * i}`)){i=2*i;}while(document.getElementById(`${el.id}-${i}`)){i+=1;}}if(i!==0){const oldHref=`#${el.id}`;const newHref=`#${el.id}-${i}`;el.id=`${el.id}-${i}`;onEachLazy(template.content.querySelectorAll("a[href]"),link=>{if(link.getAttribute("href")===oldHref){link.href=newHref;}});}idMap.set(el.id,i+1);});const templateAssocItems=template.content.querySelectorAll("section.tymethod, "+"section.method, section.associatedtype, section.associatedconstant");if(isTrait){const li=document.createElement("li");const a=document.createElement("a");a.href=`#${nonnull(template.content.querySelector(".impl")).id}`;a.textContent=traitName;li.appendChild(a);sidebarTraitList.append(li);}else{onEachLazy(templateAssocItems,item=>{let block=hasClass(item,"associatedtype")?associatedTypes:(hasClass(item,"associatedconstant")?associatedConstants:(methods));if(!block){const blockTitle=hasClass(item,"associatedtype")?"Associated Types":(hasClass(item,"associatedconstant")?"Associated Constants":("Methods"));const blockClass=hasClass(item,"associatedtype")?"associatedtype":(hasClass(item,"associatedconstant")?"associatedconstant":("method"));const blockHeader=document.createElement("h3");const blockLink=document.createElement("a");blockLink.href="#implementations";blockLink.innerText=blockTitle;blockHeader.appendChild(blockLink);block=document.createElement("ul");block.className=`block ${blockClass}`;const insertionReference=methods||sidebarTraitList;if(insertionReference){const insertionReferenceH=insertionReference.previousElementSibling;sidebarSection.insertBefore(blockHeader,insertionReferenceH);sidebarSection.insertBefore(block,insertionReferenceH);}else{sidebarSection.appendChild(blockHeader);sidebarSection.appendChild(block);}if(hasClass(item,"associatedtype")){associatedTypes=block;}else if(hasClass(item,"associatedconstant")){associatedConstants=block;}else{methods=block;}}const li=document.createElement("li");const a=document.createElement("a");a.innerText=item.id.split("-")[0].split(".")[1];a.href=`#${item.id}`;li.appendChild(a);block.appendChild(li);});}outputList.appendChild(template.content);}for(const list of[methods,associatedTypes,associatedConstants,sidebarTraitList]){if(!list){continue;}const newChildren=Array.prototype.slice.call(list.children);newChildren.sort((a,b)=>{const aI=a.innerText;const bI=b.innerText;return aIbI?1:0;});list.replaceChildren(...newChildren);}};if(window.pending_type_impls){window.register_type_impls(window.pending_type_impls);}function addSidebarCrates(){if(!window.ALL_CRATES){return;}const sidebarElems=document.getElementById("rustdoc-modnav");if(!sidebarElems){return;}const h3=document.createElement("h3");h3.innerHTML="Crates";const ul=document.createElement("ul");ul.className="block crate";for(const crate of window.ALL_CRATES){const link=document.createElement("a");link.href=window.rootPath+crate+"/index.html";link.textContent=crate;const li=document.createElement("li");if(window.rootPath!=="./"&&crate===window.currentCrate){li.className="current";}li.appendChild(link);ul.appendChild(li);}sidebarElems.appendChild(h3);sidebarElems.appendChild(ul);}function expandAllDocs(){const innerToggle=document.getElementById(toggleAllDocsId);removeClass(innerToggle,"will-expand");onEachLazy(document.getElementsByClassName("toggle"),e=>{if(!hasClass(e,"type-contents-toggle")&&!hasClass(e,"more-examples-toggle")){e.open=true;}});innerToggle.children[0].innerText="Summary";}function collapseAllDocs(collapseImpls){const innerToggle=document.getElementById(toggleAllDocsId);addClass(innerToggle,"will-expand");onEachLazy(document.getElementsByClassName("toggle"),e=>{if((collapseImpls||e.parentNode.id!=="implementations-list")||(!hasClass(e,"implementors-toggle")&&!hasClass(e,"type-contents-toggle"))){e.open=false;}});innerToggle.children[0].innerText="Show all";}function toggleAllDocs(ev){const innerToggle=document.getElementById(toggleAllDocsId);if(!innerToggle){return;}if(hasClass(innerToggle,"will-expand")){expandAllDocs();}else{collapseAllDocs(ev!==undefined&&ev.shiftKey);}}(function(){const toggles=document.getElementById(toggleAllDocsId);if(toggles){toggles.onclick=toggleAllDocs;}const hideMethodDocs=getSettingValue("auto-hide-method-docs")==="true";const hideImplementations=getSettingValue("auto-hide-trait-implementations")==="true";const hideLargeItemContents=getSettingValue("auto-hide-large-items")!=="false";function setImplementorsTogglesOpen(id,open){const list=document.getElementById(id);if(list!==null){onEachLazy(list.getElementsByClassName("implementors-toggle"),e=>{e.open=open;});}}if(hideImplementations){setImplementorsTogglesOpen("trait-implementations-list",false);setImplementorsTogglesOpen("blanket-implementations-list",false);}onEachLazy(document.getElementsByClassName("toggle"),e=>{if(!hideLargeItemContents&&hasClass(e,"type-contents-toggle")){e.open=true;}if(hideMethodDocs&&hasClass(e,"method-toggle")){e.open=false;}});}());window.rustdoc_add_line_numbers_to_examples=()=>{function generateLine(nb){return`${nb}`;}onEachLazy(document.querySelectorAll(".rustdoc:not(.src) :not(.scraped-example) > .example-wrap > pre > code",),code=>{if(hasClass(code.parentElement.parentElement,"hide-lines")){removeClass(code.parentElement.parentElement,"hide-lines");return;}const lines=code.innerHTML.split("\n");const digits=(lines.length+"").length;code.innerHTML=lines.map((line,index)=>generateLine(index+1)+line).join("\n");addClass(code.parentElement.parentElement,`digits-${digits}`);});};window.rustdoc_remove_line_numbers_from_examples=()=>{onEachLazy(document.querySelectorAll(".rustdoc:not(.src) :not(.scraped-example) > .example-wrap"),x=>addClass(x,"hide-lines"),);};if(getSettingValue("line-numbers")==="true"){window.rustdoc_add_line_numbers_to_examples();}function showSidebar(){window.hideAllModals(false);const sidebar=document.getElementsByClassName("sidebar")[0];addClass(sidebar,"shown");}function hideSidebar(){const sidebar=document.getElementsByClassName("sidebar")[0];removeClass(sidebar,"shown");}window.addEventListener("resize",()=>{if(window.CURRENT_TOOLTIP_ELEMENT){const base=window.CURRENT_TOOLTIP_ELEMENT.TOOLTIP_BASE;const force_visible=base.TOOLTIP_FORCE_VISIBLE;hideTooltip(false);if(force_visible){showTooltip(base);base.TOOLTIP_FORCE_VISIBLE=true;}}});const mainElem=document.getElementById(MAIN_ID);if(mainElem){mainElem.addEventListener("click",hideSidebar);}onEachLazy(document.querySelectorAll("a[href^='#']"),el=>{el.addEventListener("click",()=>{expandSection(el.hash.slice(1));hideSidebar();});});onEachLazy(document.querySelectorAll(".toggle > summary:not(.hideme)"),el=>{el.addEventListener("click",e=>{if(!e.target.matches("summary, a, a *")){e.preventDefault();}});});function showTooltip(e){const notable_ty=e.getAttribute("data-notable-ty");if(!window.NOTABLE_TRAITS&¬able_ty){const data=document.getElementById("notable-traits-data");if(data){window.NOTABLE_TRAITS=JSON.parse(data.innerText);}else{throw new Error("showTooltip() called with notable without any notable traits!");}}if(window.CURRENT_TOOLTIP_ELEMENT&&window.CURRENT_TOOLTIP_ELEMENT.TOOLTIP_BASE===e){clearTooltipHoverTimeout(window.CURRENT_TOOLTIP_ELEMENT);return;}window.hideAllModals(false);const wrapper=Object.assign(document.createElement("div"),{TOOLTIP_BASE:e});if(notable_ty){wrapper.innerHTML="
"+window.NOTABLE_TRAITS[notable_ty]+"
";}else{const ttl=e.getAttribute("title");if(ttl!==null){e.setAttribute("data-title",ttl);e.removeAttribute("title");}const dttl=e.getAttribute("data-title");if(dttl!==null){const titleContent=document.createElement("div");titleContent.className="content";titleContent.appendChild(document.createTextNode(dttl));wrapper.appendChild(titleContent);}}wrapper.className="tooltip popover";const focusCatcher=document.createElement("div");focusCatcher.setAttribute("tabindex","0");focusCatcher.onfocus=hideTooltip;wrapper.appendChild(focusCatcher);const pos=e.getBoundingClientRect();wrapper.style.top=(pos.top+window.scrollY+pos.height)+"px";wrapper.style.left=0;wrapper.style.right="auto";wrapper.style.visibility="hidden";document.body.appendChild(wrapper);const wrapperPos=wrapper.getBoundingClientRect();const finalPos=pos.left+window.scrollX-wrapperPos.width+24;if(finalPos>0){wrapper.style.left=finalPos+"px";}else{wrapper.style.setProperty("--popover-arrow-offset",(wrapperPos.right-pos.right+4)+"px",);}wrapper.style.visibility="";window.CURRENT_TOOLTIP_ELEMENT=wrapper;clearTooltipHoverTimeout(window.CURRENT_TOOLTIP_ELEMENT);wrapper.onpointerenter=ev=>{if(ev.pointerType!=="mouse"){return;}clearTooltipHoverTimeout(e);};wrapper.onpointerleave=ev=>{if(ev.pointerType!=="mouse"||!(ev.relatedTarget instanceof HTMLElement)){return;}if(!e.TOOLTIP_FORCE_VISIBLE&&!e.contains(ev.relatedTarget)){setTooltipHoverTimeout(e,false);addClass(wrapper,"fade-out");}};}function setTooltipHoverTimeout(element,show){clearTooltipHoverTimeout(element);if(!show&&!window.CURRENT_TOOLTIP_ELEMENT){return;}if(show&&window.CURRENT_TOOLTIP_ELEMENT){return;}if(window.CURRENT_TOOLTIP_ELEMENT&&window.CURRENT_TOOLTIP_ELEMENT.TOOLTIP_BASE!==element){return;}element.TOOLTIP_HOVER_TIMEOUT=setTimeout(()=>{if(show){showTooltip(element);}else if(!element.TOOLTIP_FORCE_VISIBLE){hideTooltip(false);}},show?window.RUSTDOC_TOOLTIP_HOVER_MS:window.RUSTDOC_TOOLTIP_HOVER_EXIT_MS);}function clearTooltipHoverTimeout(element){if(element.TOOLTIP_HOVER_TIMEOUT!==undefined){removeClass(window.CURRENT_TOOLTIP_ELEMENT,"fade-out");clearTimeout(element.TOOLTIP_HOVER_TIMEOUT);delete element.TOOLTIP_HOVER_TIMEOUT;}}function tooltipBlurHandler(event){if(window.CURRENT_TOOLTIP_ELEMENT&&!window.CURRENT_TOOLTIP_ELEMENT.contains(document.activeElement)&&!window.CURRENT_TOOLTIP_ELEMENT.contains(event.relatedTarget)&&!window.CURRENT_TOOLTIP_ELEMENT.TOOLTIP_BASE.contains(document.activeElement)&&!window.CURRENT_TOOLTIP_ELEMENT.TOOLTIP_BASE.contains(event.relatedTarget)){setTimeout(()=>hideTooltip(false),0);}}function hideTooltip(focus){if(window.CURRENT_TOOLTIP_ELEMENT){if(window.CURRENT_TOOLTIP_ELEMENT.TOOLTIP_BASE.TOOLTIP_FORCE_VISIBLE){if(focus){window.CURRENT_TOOLTIP_ELEMENT.TOOLTIP_BASE.focus();}window.CURRENT_TOOLTIP_ELEMENT.TOOLTIP_BASE.TOOLTIP_FORCE_VISIBLE=false;}document.body.removeChild(window.CURRENT_TOOLTIP_ELEMENT);clearTooltipHoverTimeout(window.CURRENT_TOOLTIP_ELEMENT);window.CURRENT_TOOLTIP_ELEMENT=undefined;}}onEachLazy(document.getElementsByClassName("tooltip"),e=>{e.onclick=()=>{e.TOOLTIP_FORCE_VISIBLE=e.TOOLTIP_FORCE_VISIBLE?false:true;if(window.CURRENT_TOOLTIP_ELEMENT&&!e.TOOLTIP_FORCE_VISIBLE){hideTooltip(true);}else{showTooltip(e);window.CURRENT_TOOLTIP_ELEMENT.setAttribute("tabindex","0");window.CURRENT_TOOLTIP_ELEMENT.focus();window.CURRENT_TOOLTIP_ELEMENT.onblur=tooltipBlurHandler;}return false;};e.onpointerenter=ev=>{if(ev.pointerType!=="mouse"){return;}setTooltipHoverTimeout(e,true);};e.onpointermove=ev=>{if(ev.pointerType!=="mouse"){return;}setTooltipHoverTimeout(e,true);};e.onpointerleave=ev=>{if(ev.pointerType!=="mouse"){return;}if(!e.TOOLTIP_FORCE_VISIBLE&&window.CURRENT_TOOLTIP_ELEMENT&&!window.CURRENT_TOOLTIP_ELEMENT.contains(ev.relatedTarget)){setTooltipHoverTimeout(e,false);addClass(window.CURRENT_TOOLTIP_ELEMENT,"fade-out");}};});const sidebar_menu_toggle=document.getElementsByClassName("sidebar-menu-toggle")[0];if(sidebar_menu_toggle){sidebar_menu_toggle.addEventListener("click",()=>{const sidebar=document.getElementsByClassName("sidebar")[0];if(!hasClass(sidebar,"shown")){showSidebar();}else{hideSidebar();}});}function helpBlurHandler(event){const isInPopover=onEachLazy(document.querySelectorAll(".settings-menu, .help-menu"),menu=>{return menu.contains(document.activeElement)||menu.contains(event.relatedTarget);},);if(!isInPopover){window.hidePopoverMenus();}}function buildHelpMenu(){const book_info=document.createElement("span");const drloChannel=`https://doc.rust-lang.org/${getVar("channel")}`;book_info.className="top";book_info.innerHTML=`You can find more information in \ +the rustdoc book.`;const shortcuts=[["?","Show this help dialog"],["S / /","Focus the search field"],["↑","Move up in search results"],["↓","Move down in search results"],["← / →","Switch result tab (when results focused)"],["⏎","Go to active search result"],["+ / =","Expand all sections"],["-","Collapse all sections"],["_","Collapse all sections, including impl blocks"],].map(x=>"
"+x[0].split(" ").map((y,index)=>((index&1)===0?""+y+"":" "+y+" ")).join("")+"
"+x[1]+"
").join("");const div_shortcuts=document.createElement("div");addClass(div_shortcuts,"shortcuts");div_shortcuts.innerHTML="

Keyboard Shortcuts

"+shortcuts+"
";const infos=[`For a full list of all search features, take a look \ + here.`,"Prefix searches with a type followed by a colon (e.g., fn:) to \ + restrict the search to a given item kind.","Accepted kinds are: fn, mod, struct, \ + enum, trait, type, macro, \ + and constant.","Search functions by type signature (e.g., vec -> usize or \ + -> vec or String, enum:Cow -> bool)","You can look for items with an exact name by putting double quotes around \ + your request: \"string\"",`Look for functions that accept or return \ + slices and \ + arrays by writing square \ + brackets (e.g., -> [u8] or [] -> Option)`,"Look for items inside another one by searching for a path: vec::Vec",].map(x=>"

"+x+"

").join("");const div_infos=document.createElement("div");addClass(div_infos,"infos");div_infos.innerHTML="

Search Tricks

"+infos;const rustdoc_version=document.createElement("span");rustdoc_version.className="bottom";const rustdoc_version_code=document.createElement("code");rustdoc_version_code.innerText="rustdoc "+getVar("rustdoc-version");rustdoc_version.appendChild(rustdoc_version_code);const container=document.createElement("div");if(!isHelpPage){container.className="popover";}container.id="help";const side_by_side=document.createElement("div");side_by_side.className="side-by-side";side_by_side.appendChild(div_shortcuts);side_by_side.appendChild(div_infos);const content=document.createElement("div");content.className="content";content.appendChild(book_info);content.appendChild(side_by_side);content.appendChild(rustdoc_version);container.appendChild(content);if(isHelpPage){const help_section=document.createElement("section");help_section.appendChild(container);nonnull(document.getElementById("main-content")).appendChild(help_section);}else{onEachLazy(document.getElementsByClassName("help-menu"),menu=>{if(menu.offsetWidth!==0){menu.appendChild(container);container.onblur=helpBlurHandler;menu.onblur=helpBlurHandler;menu.children[0].onblur=helpBlurHandler;return true;}});}return container;}window.hideAllModals=switchFocus=>{hideSidebar();window.hidePopoverMenus();hideTooltip(switchFocus);};window.hidePopoverMenus=()=>{onEachLazy(document.querySelectorAll(".settings-menu .popover"),elem=>{elem.style.display="none";});onEachLazy(document.querySelectorAll(".help-menu .popover"),elem=>{elem.parentElement.removeChild(elem);});};function showHelp(){window.hideAllModals(false);onEachLazy(document.querySelectorAll(".help-menu a"),menu=>{if(menu.offsetWidth!==0){menu.focus();return true;}});buildHelpMenu();}if(isHelpPage){buildHelpMenu();}else{onEachLazy(document.querySelectorAll(".help-menu > a"),helpLink=>{helpLink.addEventListener("click",event=>{if(event.ctrlKey||event.altKey||event.metaKey){return;}event.preventDefault();if(document.getElementById("help")){window.hidePopoverMenus();}else{showHelp();}},);});}addSidebarItems();addSidebarCrates();onHashChange(null);window.addEventListener("hashchange",onHashChange);window.searchState.setup();}());(function(){const SIDEBAR_MIN=100;const SIDEBAR_MAX=500;const RUSTDOC_MOBILE_BREAKPOINT=700;const BODY_MIN=400;const SIDEBAR_VANISH_THRESHOLD=SIDEBAR_MIN/2;let sidebarButton=document.getElementById("sidebar-button");const body=document.querySelector(".main-heading");if(!sidebarButton&&body){sidebarButton=document.createElement("div");sidebarButton.id="sidebar-button";const path=`${window.rootPath}${window.currentCrate}/all.html`;sidebarButton.innerHTML=``;body.insertBefore(sidebarButton,body.firstChild);}if(sidebarButton){sidebarButton.addEventListener("click",e=>{removeClass(document.documentElement,"hide-sidebar");updateLocalStorage("hide-sidebar","false");if(window.rustdocToggleSrcSidebar){window.rustdocToggleSrcSidebar();}e.preventDefault();});}let currentPointerId=null;let desiredSidebarSize=null;let pendingSidebarResizingFrame=false;const resizer=document.querySelector(".sidebar-resizer");const sidebar=document.querySelector(".sidebar");if(!resizer||!sidebar){return;}const isSrcPage=hasClass(document.body,"src");const hideSidebar=function(){if(isSrcPage){window.rustdocCloseSourceSidebar();updateLocalStorage("src-sidebar-width",null);document.documentElement.style.removeProperty("--src-sidebar-width");sidebar.style.removeProperty("--src-sidebar-width");resizer.style.removeProperty("--src-sidebar-width");}else{addClass(document.documentElement,"hide-sidebar");updateLocalStorage("hide-sidebar","true");updateLocalStorage("desktop-sidebar-width",null);document.documentElement.style.removeProperty("--desktop-sidebar-width");sidebar.style.removeProperty("--desktop-sidebar-width");resizer.style.removeProperty("--desktop-sidebar-width");}};const showSidebar=function(){if(isSrcPage){window.rustdocShowSourceSidebar();}else{removeClass(document.documentElement,"hide-sidebar");updateLocalStorage("hide-sidebar","false");}};const changeSidebarSize=function(size){if(isSrcPage){updateLocalStorage("src-sidebar-width",size.toString());sidebar.style.setProperty("--src-sidebar-width",size+"px");resizer.style.setProperty("--src-sidebar-width",size+"px");}else{updateLocalStorage("desktop-sidebar-width",size.toString());sidebar.style.setProperty("--desktop-sidebar-width",size+"px");resizer.style.setProperty("--desktop-sidebar-width",size+"px");}};const isSidebarHidden=function(){return isSrcPage?!hasClass(document.documentElement,"src-sidebar-expanded"):hasClass(document.documentElement,"hide-sidebar");};const resize=function(e){if(currentPointerId===null||currentPointerId!==e.pointerId){return;}e.preventDefault();const pos=e.clientX-3;if(pos=SIDEBAR_MIN){if(isSidebarHidden()){showSidebar();}const constrainedPos=Math.min(pos,window.innerWidth-BODY_MIN,SIDEBAR_MAX);changeSidebarSize(constrainedPos);desiredSidebarSize=constrainedPos;if(pendingSidebarResizingFrame!==false){clearTimeout(pendingSidebarResizingFrame);}pendingSidebarResizingFrame=setTimeout(()=>{if(currentPointerId===null||pendingSidebarResizingFrame===false){return;}pendingSidebarResizingFrame=false;document.documentElement.style.setProperty("--resizing-sidebar-width",desiredSidebarSize+"px",);},100);}};window.addEventListener("resize",()=>{if(window.innerWidth=(window.innerWidth-BODY_MIN)){changeSidebarSize(window.innerWidth-BODY_MIN);}else if(desiredSidebarSize!==null&&desiredSidebarSize>SIDEBAR_MIN){changeSidebarSize(desiredSidebarSize);}});const stopResize=function(e){if(currentPointerId===null){return;}if(e){e.preventDefault();}desiredSidebarSize=sidebar.getBoundingClientRect().width;removeClass(resizer,"active");window.removeEventListener("pointermove",resize,false);window.removeEventListener("pointerup",stopResize,false);removeClass(document.documentElement,"sidebar-resizing");document.documentElement.style.removeProperty("--resizing-sidebar-width");if(resizer.releasePointerCapture){resizer.releasePointerCapture(currentPointerId);currentPointerId=null;}};const initResize=function(e){if(currentPointerId!==null||e.altKey||e.ctrlKey||e.metaKey||e.button!==0){return;}if(resizer.setPointerCapture){resizer.setPointerCapture(e.pointerId);if(!resizer.hasPointerCapture(e.pointerId)){resizer.releasePointerCapture(e.pointerId);return;}currentPointerId=e.pointerId;}window.hideAllModals(false);e.preventDefault();window.addEventListener("pointermove",resize,false);window.addEventListener("pointercancel",stopResize,false);window.addEventListener("pointerup",stopResize,false);addClass(resizer,"active");addClass(document.documentElement,"sidebar-resizing");const pos=e.clientX-sidebar.offsetLeft-3;document.documentElement.style.setProperty("--resizing-sidebar-width",pos+"px");desiredSidebarSize=null;};resizer.addEventListener("pointerdown",initResize,false);}());(function(){function copyContentToClipboard(content){if(content===null){return;}const el=document.createElement("textarea");el.value=content;el.setAttribute("readonly","");el.style.position="absolute";el.style.left="-9999px";document.body.appendChild(el);el.select();document.execCommand("copy");document.body.removeChild(el);}function copyButtonAnimation(button){button.classList.add("clicked");if(button.reset_button_timeout!==undefined){clearTimeout(button.reset_button_timeout);}button.reset_button_timeout=setTimeout(()=>{button.reset_button_timeout=undefined;button.classList.remove("clicked");},1000);}const but=document.getElementById("copy-path");if(!but){return;}but.onclick=()=>{let path="";const heading=document.getElementById(MAIN_ID).querySelector(".main-heading");if(heading){const breadcrumbs=heading.querySelector(".rustdoc-breadcrumbs");if(breadcrumbs){path=breadcrumbs.innerText;if(path.length>0){path+="::";}}path+=heading.querySelector("h1 > span").innerText;copyContentToClipboard(path);copyButtonAnimation(but);}};function copyCode(codeElem){if(!codeElem){return;}let content="";for(const node of codeElem.childNodes){if(node instanceof HTMLElement&&node.hasAttribute("data-nosnippet")){continue;}content+=node.textContent;}copyContentToClipboard(content);}function getExampleWrap(event){const target=event.target;if(target instanceof HTMLElement){let elem=target;while(elem!==null&&!hasClass(elem,"example-wrap")){if(elem===document.body||elem.tagName==="A"||elem.tagName==="BUTTON"||hasClass(elem,"docblock")){return null;}elem=elem.parentElement;}return elem;}else{return null;}}function addCopyButton(event){const elem=getExampleWrap(event);if(elem===null){return;}elem.removeEventListener("mouseover",addCopyButton);const parent=document.createElement("div");parent.className="button-holder";const runButton=elem.querySelector(".test-arrow");if(runButton!==null){parent.appendChild(runButton);}elem.appendChild(parent);const copyButton=document.createElement("button");copyButton.className="copy-button";copyButton.title="Copy code to clipboard";copyButton.addEventListener("click",()=>{copyCode(elem.querySelector("pre > code"));copyButtonAnimation(copyButton);});parent.appendChild(copyButton);if(!elem.parentElement||!elem.parentElement.classList.contains("scraped-example")||!window.updateScrapedExample){return;}const scrapedWrapped=elem.parentElement;window.updateScrapedExample(scrapedWrapped,parent);}function showHideCodeExampleButtons(event){const elem=getExampleWrap(event);if(elem===null){return;}let buttons=elem.querySelector(".button-holder");if(buttons===null){addCopyButton(event);buttons=elem.querySelector(".button-holder");if(buttons===null){return;}}buttons.classList.toggle("keep-visible");}onEachLazy(document.querySelectorAll(".docblock .example-wrap"),elem=>{elem.addEventListener("mouseover",addCopyButton);elem.addEventListener("click",showHideCodeExampleButtons);});}());(function(){document.body.addEventListener("copy",event=>{let target=nonnull(event.target);let isInsideCode=false;while(target&&target!==document.body){if(target.tagName==="CODE"){isInsideCode=true;break;}target=target.parentElement;}if(!isInsideCode){return;}const selection=nonnull(document.getSelection());const text=Array.from({length:selection.rangeCount},(_,i)=>{const fragment=selection.getRangeAt(i).cloneContents();fragment.querySelectorAll("[data-nosnippet]").forEach(el=>el.remove());return fragment.textContent;}).join("");nonnull(event.clipboardData).setData("text/plain",text);event.preventDefault();});}()); \ No newline at end of file diff --git a/api/static.files/normalize-9960930a.css b/api/static.files/normalize-9960930a.css new file mode 100644 index 00000000..469959f1 --- /dev/null +++ b/api/static.files/normalize-9960930a.css @@ -0,0 +1,2 @@ + /*! normalize.css v8.0.1 | MIT License | github.com/necolas/normalize.css */ +html{line-height:1.15;-webkit-text-size-adjust:100%}body{margin:0}main{display:block}h1{font-size:2em;margin:0.67em 0}hr{box-sizing:content-box;height:0;overflow:visible}pre{font-family:monospace,monospace;font-size:1em}a{background-color:transparent}abbr[title]{border-bottom:none;text-decoration:underline;text-decoration:underline dotted}b,strong{font-weight:bolder}code,kbd,samp{font-family:monospace,monospace;font-size:1em}small{font-size:80%}sub,sup{font-size:75%;line-height:0;position:relative;vertical-align:baseline}sub{bottom:-0.25em}sup{top:-0.5em}img{border-style:none}button,input,optgroup,select,textarea{font-family:inherit;font-size:100%;line-height:1.15;margin:0}button,input{overflow:visible}button,select{text-transform:none}[type="button"],[type="reset"],[type="submit"],button{-webkit-appearance:button}[type="button"]::-moz-focus-inner,[type="reset"]::-moz-focus-inner,[type="submit"]::-moz-focus-inner,button::-moz-focus-inner{border-style:none;padding:0}[type="button"]:-moz-focusring,[type="reset"]:-moz-focusring,[type="submit"]:-moz-focusring,button:-moz-focusring{outline:1px dotted ButtonText}fieldset{padding:0.35em 0.75em 0.625em}legend{box-sizing:border-box;color:inherit;display:table;max-width:100%;padding:0;white-space:normal}progress{vertical-align:baseline}textarea{overflow:auto}[type="checkbox"],[type="radio"]{box-sizing:border-box;padding:0}[type="number"]::-webkit-inner-spin-button,[type="number"]::-webkit-outer-spin-button{height:auto}[type="search"]{-webkit-appearance:textfield;outline-offset:-2px}[type="search"]::-webkit-search-decoration{-webkit-appearance:none}::-webkit-file-upload-button{-webkit-appearance:button;font:inherit}details{display:block}summary{display:list-item}template{display:none}[hidden]{display:none} \ No newline at end of file diff --git a/api/static.files/noscript-f7c3ffd8.css b/api/static.files/noscript-f7c3ffd8.css new file mode 100644 index 00000000..02e93a5e --- /dev/null +++ b/api/static.files/noscript-f7c3ffd8.css @@ -0,0 +1 @@ + #main-content .attributes{margin-left:0 !important;}#copy-path,#sidebar-button,.sidebar-resizer{display:none !important;}nav.sub{display:none;}.src .sidebar{display:none;}.notable-traits{display:none;}#synthetic-implementors-list:not(.loaded),#implementors-list:not(.loaded){display:block;}:root,:root:not([data-theme]){--main-background-color:white;--main-color:black;--settings-input-color:#2196f3;--settings-input-border-color:#717171;--settings-button-color:#000;--settings-button-border-focus:#717171;--sidebar-background-color:#f5f5f5;--sidebar-background-color-hover:#e0e0e0;--sidebar-border-color:#ddd;--code-block-background-color:#f5f5f5;--scrollbar-track-background-color:#dcdcdc;--scrollbar-thumb-background-color:rgba(36,37,39,0.6);--scrollbar-color:rgba(36,37,39,0.6) #d9d9d9;--headings-border-bottom-color:#ddd;--border-color:#e0e0e0;--button-background-color:#fff;--right-side-color:grey;--code-attribute-color:#999;--toggles-color:#999;--toggle-filter:none;--mobile-sidebar-menu-filter:none;--search-input-focused-border-color:#66afe9;--copy-path-button-color:#999;--copy-path-img-filter:invert(50%);--copy-path-img-hover-filter:invert(35%);--code-example-button-color:#7f7f7f;--code-example-button-hover-color:#595959;--settings-menu-filter:invert(50%);--settings-menu-hover-filter:invert(35%);--codeblock-error-hover-color:rgb(255,0,0);--codeblock-error-color:rgba(255,0,0,.5);--codeblock-ignore-hover-color:rgb(255,142,0);--codeblock-ignore-color:rgba(255,142,0,.6);--warning-border-color:#ff8e00;--type-link-color:#ad378a;--trait-link-color:#6e4fc9;--assoc-item-link-color:#3873ad;--function-link-color:#ad7c37;--macro-link-color:#068000;--keyword-link-color:#3873ad;--attribute-link-color:#3873ad;--mod-link-color:#3873ad;--link-color:#3873ad;--sidebar-link-color:#356da4;--sidebar-current-link-background-color:#fff;--search-result-link-focus-background-color:#ccc;--search-result-border-color:#aaa3;--search-color:#000;--search-error-code-background-color:#d0cccc;--search-results-alias-color:#000;--search-results-grey-color:#999;--search-tab-title-count-color:#888;--search-tab-button-not-selected-border-top-color:#e6e6e6;--search-tab-button-not-selected-background:#e6e6e6;--search-tab-button-selected-border-top-color:#0089ff;--search-tab-button-selected-background:#fff;--stab-background-color:#fff5d6;--stab-code-color:#000;--code-highlight-kw-color:#8959a8;--code-highlight-kw-2-color:#4271ae;--code-highlight-lifetime-color:#b76514;--code-highlight-prelude-color:#4271ae;--code-highlight-prelude-val-color:#c82829;--code-highlight-number-color:#718c00;--code-highlight-string-color:#718c00;--code-highlight-literal-color:#c82829;--code-highlight-attribute-color:#c82829;--code-highlight-self-color:#c82829;--code-highlight-macro-color:#3e999f;--code-highlight-question-mark-color:#ff9011;--code-highlight-comment-color:#8e908c;--code-highlight-doc-comment-color:#4d4d4c;--src-line-numbers-span-color:#c67e2d;--src-line-number-highlighted-background-color:#fdffd3;--target-background-color:#fdffd3;--target-border-color:#ad7c37;--kbd-color:#000;--kbd-background:#fafbfc;--kbd-box-shadow-color:#c6cbd1;--rust-logo-filter:initial;--crate-search-div-filter:invert(100%) sepia(0%) saturate(4223%) hue-rotate(289deg) brightness(114%) contrast(76%);--crate-search-div-hover-filter:invert(44%) sepia(18%) saturate(23%) hue-rotate(317deg) brightness(96%) contrast(93%);--crate-search-hover-border:#717171;--src-sidebar-background-selected:#fff;--src-sidebar-background-hover:#e0e0e0;--table-alt-row-background-color:#f5f5f5;--codeblock-link-background:#eee;--scrape-example-toggle-line-background:#ccc;--scrape-example-toggle-line-hover-background:#999;--scrape-example-code-line-highlight:#fcffd6;--scrape-example-code-line-highlight-focus:#f6fdb0;--scrape-example-help-border-color:#555;--scrape-example-help-color:#333;--scrape-example-help-hover-border-color:#000;--scrape-example-help-hover-color:#000;--scrape-example-code-wrapper-background-start:rgba(255,255,255,1);--scrape-example-code-wrapper-background-end:rgba(255,255,255,0);--sidebar-resizer-hover:hsl(207,90%,66%);--sidebar-resizer-active:hsl(207,90%,54%);}@media (prefers-color-scheme:dark){:root,:root:not([data-theme]){--main-background-color:#353535;--main-color:#ddd;--settings-input-color:#2196f3;--settings-input-border-color:#999;--settings-button-color:#000;--settings-button-border-focus:#ffb900;--sidebar-background-color:#505050;--sidebar-background-color-hover:#676767;--sidebar-border-color:#2A2A2A;--code-block-background-color:#2A2A2A;--scrollbar-track-background-color:#717171;--scrollbar-thumb-background-color:rgba(32,34,37,.6);--scrollbar-color:rgba(32,34,37,.6) #5a5a5a;--headings-border-bottom-color:#d2d2d2;--border-color:#e0e0e0;--button-background-color:#f0f0f0;--right-side-color:#d0d0d0;--code-attribute-color:#999;--toggles-color:#999;--toggle-filter:invert(100%);--mobile-sidebar-menu-filter:invert(100%);--search-input-focused-border-color:#008dfd;--copy-path-button-color:#999;--copy-path-img-filter:invert(50%);--copy-path-img-hover-filter:invert(65%);--code-example-button-color:#7f7f7f;--code-example-button-hover-color:#a5a5a5;--codeblock-error-hover-color:rgb(255,0,0);--codeblock-error-color:rgba(255,0,0,.5);--codeblock-ignore-hover-color:rgb(255,142,0);--codeblock-ignore-color:rgba(255,142,0,.6);--warning-border-color:#ff8e00;--type-link-color:#2dbfb8;--trait-link-color:#b78cf2;--assoc-item-link-color:#d2991d;--function-link-color:#2bab63;--macro-link-color:#09bd00;--keyword-link-color:#d2991d;--attribute-link-color:#d2991d;--mod-link-color:#d2991d;--link-color:#d2991d;--sidebar-link-color:#fdbf35;--sidebar-current-link-background-color:#444;--search-result-link-focus-background-color:#616161;--search-result-border-color:#aaa3;--search-color:#111;--search-error-code-background-color:#484848;--search-results-alias-color:#fff;--search-results-grey-color:#ccc;--search-tab-title-count-color:#888;--search-tab-button-not-selected-border-top-color:#252525;--search-tab-button-not-selected-background:#252525;--search-tab-button-selected-border-top-color:#0089ff;--search-tab-button-selected-background:#353535;--settings-menu-filter:invert(50%);--settings-menu-hover-filter:invert(65%);--stab-background-color:#314559;--stab-code-color:#e6e1cf;--code-highlight-kw-color:#ab8ac1;--code-highlight-kw-2-color:#769acb;--code-highlight-lifetime-color:#d97f26;--code-highlight-prelude-color:#769acb;--code-highlight-prelude-val-color:#ee6868;--code-highlight-number-color:#83a300;--code-highlight-string-color:#83a300;--code-highlight-literal-color:#ee6868;--code-highlight-attribute-color:#ee6868;--code-highlight-self-color:#ee6868;--code-highlight-macro-color:#3e999f;--code-highlight-question-mark-color:#ff9011;--code-highlight-comment-color:#8d8d8b;--code-highlight-doc-comment-color:#8ca375;--src-line-numbers-span-color:#3b91e2;--src-line-number-highlighted-background-color:#0a042f;--target-background-color:#494a3d;--target-border-color:#bb7410;--kbd-color:#000;--kbd-background:#fafbfc;--kbd-box-shadow-color:#c6cbd1;--rust-logo-filter:drop-shadow(1px 0 0px #fff) drop-shadow(0 1px 0 #fff) drop-shadow(-1px 0 0 #fff) drop-shadow(0 -1px 0 #fff);--crate-search-div-filter:invert(94%) sepia(0%) saturate(721%) hue-rotate(255deg) brightness(90%) contrast(90%);--crate-search-div-hover-filter:invert(69%) sepia(60%) saturate(6613%) hue-rotate(184deg) brightness(100%) contrast(91%);--crate-search-hover-border:#2196f3;--src-sidebar-background-selected:#333;--src-sidebar-background-hover:#444;--table-alt-row-background-color:#2a2a2a;--codeblock-link-background:#333;--scrape-example-toggle-line-background:#999;--scrape-example-toggle-line-hover-background:#c5c5c5;--scrape-example-code-line-highlight:#5b3b01;--scrape-example-code-line-highlight-focus:#7c4b0f;--scrape-example-help-border-color:#aaa;--scrape-example-help-color:#eee;--scrape-example-help-hover-border-color:#fff;--scrape-example-help-hover-color:#fff;--scrape-example-code-wrapper-background-start:rgba(53,53,53,1);--scrape-example-code-wrapper-background-end:rgba(53,53,53,0);--sidebar-resizer-hover:hsl(207,30%,54%);--sidebar-resizer-active:hsl(207,90%,54%);}} \ No newline at end of file diff --git a/api/static.files/rust-logo-9a9549ea.svg b/api/static.files/rust-logo-9a9549ea.svg new file mode 100644 index 00000000..62424d8f --- /dev/null +++ b/api/static.files/rust-logo-9a9549ea.svg @@ -0,0 +1,61 @@ + + + diff --git a/api/static.files/rustdoc-17e0aaed.css b/api/static.files/rustdoc-17e0aaed.css new file mode 100644 index 00000000..f9cfd8d3 --- /dev/null +++ b/api/static.files/rustdoc-17e0aaed.css @@ -0,0 +1,86 @@ + :root{--nav-sub-mobile-padding:8px;--search-typename-width:6.75rem;--desktop-sidebar-width:200px;--src-sidebar-width:300px;--desktop-sidebar-z-index:100;--sidebar-elems-left-padding:24px;--popover-top-margin:7px;--clipboard-image:url('data:image/svg+xml,\ +\ +\ +');--copy-path-height:34px;--copy-path-width:33px;--checkmark-image:url('data:image/svg+xml,\ +\ +');--button-left-margin:4px;--button-border-radius:2px;--toolbar-button-border-radius:6px;--code-block-border-radius:6px;--impl-items-indent:0.3em;--docblock-indent:24px;--font-family:"Source Serif 4",NanumBarunGothic,serif;--font-family-code:"Source Code Pro",monospace;--line-number-padding:4px;--line-number-right-margin:20px;--prev-arrow-image:url('data:image/svg+xml,');--next-arrow-image:url('data:image/svg+xml,');--expand-arrow-image:url('data:image/svg+xml,');--collapse-arrow-image:url('data:image/svg+xml,');--hamburger-image:url('data:image/svg+xml,\ + ');}:root.sans-serif-fonts{--font-family:"Fira Sans",sans-serif;--font-family-code:"Fira Mono",monospace;}@font-face {font-family:'Fira Sans';font-style:normal;font-weight:400;src:local('Fira Sans'),url("FiraSans-Regular-0fe48ade.woff2") format("woff2");font-display:swap;}@font-face {font-family:'Fira Sans';font-style:italic;font-weight:400;src:local('Fira Sans Italic'),url("FiraSans-Italic-81dc35de.woff2") format("woff2");font-display:swap;}@font-face {font-family:'Fira Sans';font-style:normal;font-weight:500;src:local('Fira Sans Medium'),url("FiraSans-Medium-e1aa3f0a.woff2") format("woff2");font-display:swap;}@font-face {font-family:'Fira Sans';font-style:italic;font-weight:500;src:local('Fira Sans Medium Italic'),url("FiraSans-MediumItalic-ccf7e434.woff2") format("woff2");font-display:swap;}@font-face {font-family:'Fira Mono';font-style:normal;font-weight:400;src:local('Fira Mono'),url("FiraMono-Regular-87c26294.woff2") format("woff2");font-display:swap;}@font-face {font-family:'Fira Mono';font-style:normal;font-weight:500;src:local('Fira Mono Medium'),url("FiraMono-Medium-86f75c8c.woff2") format("woff2");font-display:swap;}@font-face {font-family:'Source Serif 4';font-style:normal;font-weight:400;src:local('Source Serif 4'),url("SourceSerif4-Regular-6b053e98.ttf.woff2") format("woff2");font-display:swap;}@font-face {font-family:'Source Serif 4';font-style:italic;font-weight:400;src:local('Source Serif 4 Italic'),url("SourceSerif4-It-ca3b17ed.ttf.woff2") format("woff2");font-display:swap;}@font-face {font-family:'Source Serif 4';font-style:normal;font-weight:500;src:local('Source Serif 4 Semibold'),url("SourceSerif4-Semibold-457a13ac.ttf.woff2") format("woff2");font-display:swap;}@font-face {font-family:'Source Serif 4';font-style:normal;font-weight:700;src:local('Source Serif 4 Bold'),url("SourceSerif4-Bold-6d4fd4c0.ttf.woff2") format("woff2");font-display:swap;}@font-face {font-family:'Source Code Pro';font-style:normal;font-weight:400;src:url("SourceCodePro-Regular-8badfe75.ttf.woff2") format("woff2");font-display:swap;}@font-face {font-family:'Source Code Pro';font-style:italic;font-weight:400;src:url("SourceCodePro-It-fc8b9304.ttf.woff2") format("woff2");font-display:swap;}@font-face {font-family:'Source Code Pro';font-style:normal;font-weight:600;src:url("SourceCodePro-Semibold-aa29a496.ttf.woff2") format("woff2");font-display:swap;}@font-face {font-family:'NanumBarunGothic';src:url("NanumBarunGothic-13b3dcba.ttf.woff2") format("woff2");font-display:swap;unicode-range:U+AC00-D7AF,U+1100-11FF,U+3130-318F,U+A960-A97F,U+D7B0-D7FF;}*{box-sizing:border-box;}body{font:1rem/1.5 var(--font-family);margin:0;position:relative;overflow-wrap:break-word;overflow-wrap:anywhere;font-feature-settings:"kern","liga";background-color:var(--main-background-color);color:var(--main-color);}.skip-main-content{position:absolute;left:0;top:-100%;z-index:1000;padding:0.5rem 1rem;background-color:var(--main-background-color);color:var(--main-color);text-decoration:none;font-weight:500;border-bottom-right-radius:4px;outline:2px solid var(--search-input-focused-border-color);}.skip-main-content:focus{top:0;}h1{font-size:1.5rem;}h2{font-size:1.375rem;}h3{font-size:1.25rem;}h1,h2,h3,h4,h5,h6{font-weight:500;}h1,h2,h3,h4{margin:25px 0 15px 0;padding-bottom:6px;}.docblock h3,.docblock h4,h5,h6{margin:15px 0 5px 0;}.docblock>h2:first-child,.docblock>h3:first-child,.docblock>h4:first-child,.docblock>h5:first-child,.docblock>h6:first-child{margin-top:0;}.main-heading h1{margin:0;padding:0;grid-area:main-heading-h1;overflow-wrap:break-word;overflow-wrap:anywhere;}.main-heading{position:relative;display:grid;grid-template-areas:"main-heading-breadcrumbs main-heading-breadcrumbs" "main-heading-h1 main-heading-toolbar" "main-heading-sub-heading main-heading-toolbar";grid-template-columns:minmax(105px,1fr) minmax(0,max-content);grid-template-rows:minmax(25px,min-content) min-content min-content;padding-bottom:6px;margin-bottom:15px;}.search-results-main-heading{grid-template-areas:"main-heading-breadcrumbs main-heading-placeholder" "main-heading-breadcrumbs main-heading-toolbar " "main-heading-h1 main-heading-toolbar ";}.search-results-main-heading nav.sub{grid-area:main-heading-h1;align-items:end;margin:4px 0 8px 0;}.rustdoc-breadcrumbs{grid-area:main-heading-breadcrumbs;line-height:1.25;padding-top:5px;position:relative;z-index:1;}.search-switcher{grid-area:main-heading-breadcrumbs;line-height:1.5;display:flex;color:var(--main-color);align-items:baseline;white-space:nowrap;padding-top:8px;min-height:34px;}.rustdoc-breadcrumbs a{padding:5px 0 7px;}.content h2,.top-doc .docblock>h3,.top-doc .docblock>h4{border-bottom:1px solid var(--headings-border-bottom-color);}h1,h2{line-height:1.25;padding-top:3px;padding-bottom:9px;}h3.code-header{font-size:1.125rem;}h4.code-header{font-size:1rem;}.code-header{font-weight:600;margin:0;padding:0;white-space:pre-wrap;}.structfield,.sub-variant-field{margin:0.6em 0;}#crate-search,h1,h2,h3,h4,h5,h6,.sidebar,rustdoc-topbar,.search-input,.search-results .result-name,.item-table dt>a,.out-of-band,.sub-heading,span.since,a.src,rustdoc-toolbar,summary.hideme,.scraped-example-list,.rustdoc-breadcrumbs,.search-switcher,ul.all-items,.deprecated-count{font-family:"Fira Sans",Arial,NanumBarunGothic,sans-serif;}#toggle-all-docs,a.anchor,.section-header a,#src-sidebar a,.rust a,.sidebar h2 a,.sidebar h3 a,rustdoc-topbar h2 a,h1 a,.search-results a,.search-results li,.stab,.result-name i{color:var(--main-color);}span.enum,a.enum,span.struct,a.struct,span.union,a.union,span.primitive,a.primitive,span.type,a.type,span.foreigntype,a.foreigntype{color:var(--type-link-color);}span.trait,a.trait,span.traitalias,a.traitalias{color:var(--trait-link-color);}span.associatedtype,a.associatedtype,span.constant,a.constant,span.static,a.static{color:var(--assoc-item-link-color);}span.fn,a.fn,span.method,a.method,span.tymethod,a.tymethod{color:var(--function-link-color);}span.attr,a.attr,span.derive,a.derive,span.macro,a.macro{color:var(--macro-link-color);}span.mod,a.mod{color:var(--mod-link-color);}span.keyword,a.keyword{color:var(--keyword-link-color);}span.attribute,a.attribute{color:var(--attribute-link-color);}a{color:var(--link-color);text-decoration:none;}ol,ul{padding-left:24px;}ul ul,ol ul,ul ol,ol ol{margin-bottom:.625em;}p,.docblock>.warning{margin:0 0 .75em 0;}p:last-child,.docblock>.warning:last-child{margin:0;}button{padding:1px 6px;cursor:pointer;}button#toggle-all-docs{padding:0;background:none;border:none;-webkit-appearance:none;opacity:1;}.rustdoc{display:flex;flex-direction:row;flex-wrap:nowrap;}main{position:relative;flex-grow:1;padding:10px 15px 40px 45px;min-width:0;}.src main{padding:15px;}.width-limiter{max-width:960px;margin-right:auto;}details:not(.toggle) summary{margin-bottom:.6em;}code,pre,.code-header,.type-signature{font-family:var(--font-family-code);}.docblock code,.item-table dd code{border-radius:3px;padding:0 0.125em;}.docblock pre code,.item-table dd pre code{padding:0;}pre{padding:14px;line-height:1.5;}pre.item-decl{overflow-x:auto;}.item-decl .type-contents-toggle{contain:initial;}.src .content pre{padding:20px;padding-left:16px;}img{max-width:100%;}.logo-container{line-height:0;display:block;}.rust-logo{filter:var(--rust-logo-filter);}.sidebar{font-size:0.875rem;flex:0 0 var(--desktop-sidebar-width);width:var(--desktop-sidebar-width);overflow-y:scroll;overscroll-behavior:contain;position:sticky;height:100vh;top:0;left:0;z-index:var(--desktop-sidebar-z-index);border-right:solid 1px var(--sidebar-border-color);}.rustdoc.src .sidebar{flex-basis:50px;width:50px;overflow-x:hidden;overflow-y:hidden;}.hide-sidebar .sidebar,.hide-sidebar .sidebar-resizer{display:none;}.sidebar-resizer{touch-action:none;width:9px;cursor:ew-resize;z-index:calc(var(--desktop-sidebar-z-index) + 1);position:fixed;height:100%;left:var(--desktop-sidebar-width);display:flex;align-items:center;justify-content:flex-start;color:var(--right-side-color);}.sidebar-resizer::before{content:"";border-right:dotted 2px currentColor;width:2px;height:12px;}.sidebar-resizer::after{content:"";border-right:dotted 2px currentColor;width:2px;height:16px;}.rustdoc.src .sidebar-resizer{left:49px;}.src-sidebar-expanded .src .sidebar-resizer{left:var(--src-sidebar-width);}.sidebar-resizing{-moz-user-select:none;-webkit-user-select:none;-ms-user-select:none;user-select:none;}.sidebar-resizing *{cursor:ew-resize !important;}.sidebar-resizing .sidebar{position:fixed;border-right:solid 2px var(--sidebar-resizer-active);}.sidebar-resizing>body{padding-left:var(--resizing-sidebar-width);}.sidebar-resizer:hover,.sidebar-resizer:active,.sidebar-resizer:focus,.sidebar-resizer.active{width:10px;margin:0;left:calc(var(--desktop-sidebar-width) - 1px);border-left:solid 1px var(--sidebar-resizer-hover);color:var(--sidebar-resizer-hover);}.src-sidebar-expanded .rustdoc.src .sidebar-resizer:hover,.src-sidebar-expanded .rustdoc.src .sidebar-resizer:active,.src-sidebar-expanded .rustdoc.src .sidebar-resizer:focus,.src-sidebar-expanded .rustdoc.src .sidebar-resizer.active{left:calc(var(--src-sidebar-width) - 1px);}@media (pointer:coarse){.sidebar-resizer{display:none !important;}.sidebar{border-right:none;}}.sidebar-resizer.active{padding:0 140px;width:calc(140px + 140px + 9px + 2px);margin-left:-140px;border-left:none;color:var(--sidebar-resizer-active);}.sidebar,rustdoc-topbar,.sidebar-menu-toggle,#src-sidebar{background-color:var(--sidebar-background-color);}.src .sidebar>*{visibility:hidden;}.src-sidebar-expanded .src .sidebar{overflow-y:auto;flex-basis:var(--src-sidebar-width);width:var(--src-sidebar-width);}.src-sidebar-expanded .src .sidebar>*{visibility:visible;}#all-types{margin-top:1em;}*{scrollbar-width:initial;scrollbar-color:var(--scrollbar-color);}.sidebar{scrollbar-width:thin;scrollbar-color:var(--scrollbar-color);}::-webkit-scrollbar{width:12px;}.sidebar::-webkit-scrollbar{width:8px;}::-webkit-scrollbar-track{-webkit-box-shadow:inset 0;background-color:var(--scrollbar-track-background-color);}.sidebar::-webkit-scrollbar-track{background-color:var(--scrollbar-track-background-color);}::-webkit-scrollbar-thumb,.sidebar::-webkit-scrollbar-thumb{background-color:var(--scrollbar-thumb-background-color);}.hidden{display:none !important;}.logo-container>img{height:48px;width:48px;}ul.block,.block li,.block ul{padding:0;margin:0;list-style:none;}.block ul a{padding-left:1rem;}.sidebar-elems a,.sidebar>h2 a{display:block;padding:0.25rem;margin-right:0.25rem;border-left:solid var(--sidebar-elems-left-padding) transparent;margin-left:calc(-0.25rem - var(--sidebar-elems-left-padding));background-clip:border-box;}.hide-toc #rustdoc-toc,.hide-toc .in-crate{display:none;}.hide-modnav #rustdoc-modnav{display:none;}.sidebar h2{text-wrap:balance;overflow-wrap:anywhere;padding:0;margin:0.7rem 0;}.sidebar h3{text-wrap:balance;overflow-wrap:anywhere;font-size:1.125rem;padding:0;margin:0;}.sidebar-elems,.sidebar>.version,.sidebar>h2{padding-left:var(--sidebar-elems-left-padding);}.sidebar a{color:var(--sidebar-link-color);}.sidebar .current,.sidebar .current a,.sidebar-crate a.logo-container:hover+h2 a,.sidebar a:hover:not(.logo-container){background-color:var(--sidebar-current-link-background-color);}.sidebar-elems .block{margin-bottom:2em;}.sidebar-elems .block li a{white-space:nowrap;text-overflow:ellipsis;overflow:hidden;}.sidebar-crate{display:flex;align-items:center;justify-content:center;margin:14px 32px 1rem;row-gap:10px;column-gap:32px;flex-wrap:wrap;}.sidebar-crate h2{flex-grow:1;margin:0 -8px;align-self:start;}.sidebar-crate .logo-container{margin:0 calc(-16px - var(--sidebar-elems-left-padding));padding:0 var(--sidebar-elems-left-padding);text-align:center;}.sidebar-crate .logo-container img{margin-top:-16px;border-top:solid 16px transparent;box-sizing:content-box;position:relative;background-clip:border-box;z-index:1;}.sidebar-crate h2 a{display:block;border-left:solid var(--sidebar-elems-left-padding) transparent;background-clip:border-box;margin:0 calc(-24px + 0.25rem) 0 calc(-0.2rem - var(--sidebar-elems-left-padding));padding:calc((16px - 0.57rem ) / 2 ) 0.25rem;padding-left:0.2rem;}.sidebar-crate h2 .version{display:block;font-weight:normal;font-size:1rem;overflow-wrap:break-word;}.sidebar-crate+.version{margin-top:-1rem;margin-bottom:1rem;}rustdoc-topbar{display:none;}.rustdoc .example-wrap{display:flex;position:relative;margin-bottom:10px;}.rustdoc .example-wrap>pre,.rustdoc .scraped-example .src-line-numbers,.rustdoc .scraped-example .src-line-numbers>pre{border-radius:6px;}.rustdoc .scraped-example{position:relative;}.rustdoc .example-wrap:last-child{margin-bottom:0px;}.rustdoc .example-wrap pre{margin:0;flex-grow:1;}.scraped-example:not(.expanded) .example-wrap{max-height:calc(1.5em * 5 + 10px);}.more-scraped-examples .scraped-example:not(.expanded) .example-wrap{max-height:calc(1.5em * 10 + 10px);}.rustdoc:not(.src) .scraped-example:not(.expanded) .src-line-numbers,.rustdoc:not(.src) .scraped-example:not(.expanded) .src-line-numbers>pre,.rustdoc:not(.src) .scraped-example:not(.expanded) pre.rust{padding-bottom:0;overflow:auto hidden;}.rustdoc:not(.src) .scraped-example .src-line-numbers{padding-top:0;}.rustdoc:not(.src) .scraped-example.expanded .src-line-numbers{padding-bottom:0;}.rustdoc:not(.src) .example-wrap pre{overflow:auto;}.example-wrap code{position:relative;}.example-wrap pre code span{display:inline;}.example-wrap.digits-1{--example-wrap-digits-count:1ch;}.example-wrap.digits-2{--example-wrap-digits-count:2ch;}.example-wrap.digits-3{--example-wrap-digits-count:3ch;}.example-wrap.digits-4{--example-wrap-digits-count:4ch;}.example-wrap.digits-5{--example-wrap-digits-count:5ch;}.example-wrap.digits-6{--example-wrap-digits-count:6ch;}.example-wrap.digits-7{--example-wrap-digits-count:7ch;}.example-wrap.digits-8{--example-wrap-digits-count:8ch;}.example-wrap.digits-9{--example-wrap-digits-count:9ch;}.example-wrap .expansion{position:relative;display:inline;}.example-wrap .expansion>input{display:block;position:absolute;appearance:none;content:'↕';left:-20px;top:0;border:1px solid var(--border-color);border-radius:4px;cursor:pointer;color:var(--main-color);padding:0 2px;line-height:20px;}.example-wrap .expansion>input::after{content:"↕";}.example-wrap .expansion .expanded{display:none;color:var(--main-color);}.example-wrap .expansion>input:checked~.expanded,.example-wrap .expansion>input:checked~* .expanded{display:inherit;}.example-wrap .expansion>input:checked~.original,.example-wrap .expansion>input:checked~* .original{display:none;}.example-wrap [data-nosnippet]{width:calc(var(--example-wrap-digits-count) + var(--line-number-padding) * 2);}.example-wrap:not(.hide-lines) pre>code{padding-left:calc(var(--example-wrap-digits-count) + var(--line-number-padding) * 2 + var(--line-number-right-margin));}.src .example-wrap .expansion [data-nosnippet]{position:initial;margin-left:calc((var(--example-wrap-digits-count) + var(--line-number-padding) * 2 + var(--line-number-right-margin)) * -1);}.example-wrap [data-nosnippet]{color:var(--src-line-numbers-span-color);text-align:right;display:inline-block;margin-right:var(--line-number-right-margin);-moz-user-select:none;-webkit-user-select:none;-ms-user-select:none;user-select:none;padding:0 var(--line-number-padding);position:absolute;left:0;}.example-wrap pre>code{position:relative;display:block;}:root.word-wrap-source-code .example-wrap pre>code{word-break:break-all;white-space:pre-wrap;}:root.word-wrap-source-code .example-wrap pre>code *{word-break:break-all;}.example-wrap [data-nosnippet]:target{border-right:none;}.example-wrap .line-highlighted[data-nosnippet]{background-color:var(--src-line-number-highlighted-background-color);}.example-wrap.hide-lines [data-nosnippet]{display:none;}.search-loading{text-align:center;}.item-table dd{overflow-wrap:break-word;overflow-wrap:anywhere;}.docblock :not(pre)>code,.item-table dd code{white-space:pre-wrap;}.top-doc .docblock h2{font-size:1.375rem;}.top-doc .docblock h3{font-size:1.25rem;}.top-doc .docblock h4,.top-doc .docblock h5{font-size:1.125rem;}.top-doc .docblock h6{font-size:1rem;}.docblock h5{font-size:1rem;}.docblock h6{font-size:0.875rem;}.docblock{margin-left:var(--docblock-indent);position:relative;}.docblock>:not(.more-examples-toggle):not(.example-wrap){max-width:100%;overflow-x:auto;}.sub-heading{font-size:1rem;flex-grow:0;grid-area:main-heading-sub-heading;line-height:1.25;padding-bottom:4px;}.main-heading rustdoc-toolbar,.main-heading .out-of-band{grid-area:main-heading-toolbar;}rustdoc-toolbar{display:flex;flex-direction:row;flex-wrap:nowrap;min-height:60px;}.docblock code,.item-table dd code,pre,.rustdoc.src .example-wrap,.example-wrap .src-line-numbers{background-color:var(--code-block-background-color);border-radius:var(--code-block-border-radius);text-decoration:inherit;}#main-content{position:relative;outline:none;}.docblock table{margin:.5em 0;border-collapse:collapse;}.docblock table td,.docblock table th{padding:.5em;border:1px solid var(--border-color);}.docblock table tbody tr:nth-child(2n){background:var(--table-alt-row-background-color);}.docblock .stab,.item-table dd .stab,.docblock p code{display:inline-block;}.docblock li{margin-bottom:.4em;}.docblock li p:not(:last-child){margin-bottom:.3em;}div.where{white-space:pre-wrap;font-size:0.875rem;}.item-info{display:block;margin-left:var(--docblock-indent);}.impl-items>.item-info{margin-left:calc(var(--docblock-indent) + var(--impl-items-indent));}#synthetic-implementors-list:not(.loaded),#implementors-list:not(.loaded){display:none;}.item-info code{font-size:0.875rem;}#main-content>.item-info{margin-left:0;}nav.sub{flex-grow:1;flex-flow:row nowrap;display:flex;align-items:start;margin-top:4px;}.search-form{position:relative;display:flex;height:34px;flex-grow:1;}.src nav.sub{margin:0 0 -10px 0;}.section-header{display:block;position:relative;}.section-header:hover>.anchor,.impl:hover>.anchor,.trait-impl:hover>.anchor,.variant:hover>.anchor{display:initial;}.anchor{--anchor-link-shift:0.5em;display:none;position:absolute;left:calc(var(--anchor-link-shift) * -1);padding-right:var(--anchor-link-shift);background:none !important;}.anchor.field{left:-5px;}.section-header>.anchor{left:-15px;padding-right:8px;}h2.section-header>.anchor{padding-right:6px;}a.doc-anchor{color:var(--main-color);display:none;position:absolute;left:-17px;padding-right:10px;padding-left:3px;}*:hover>.doc-anchor{display:block;}.top-doc>.docblock>*:first-child>.doc-anchor{display:none !important;}.main-heading a:hover,.example-wrap .rust a:hover:not([data-nosnippet]),.all-items a:hover,.docblock a:not(.scrape-help):not(.tooltip):hover:not(.doc-anchor),.item-table dd a:not(.scrape-help):not(.tooltip):hover,.item-info a{text-decoration:underline;}.crate.block li.current a{font-weight:500;}table,.item-table{overflow-wrap:break-word;}.item-table{padding:0;margin:0;width:100%;}.item-table>dt{padding-right:1.25rem;}.item-table>dd{margin-inline-start:0;margin-left:0;}#crate-search-div{position:relative;min-width:0;margin-top:-1px;}#crate-search{padding:0 23px 0 4px;max-width:100%;text-overflow:ellipsis;border:1px solid var(--border-color);border-radius:4px;outline:none;cursor:pointer;-moz-appearance:none;-webkit-appearance:none;text-indent:0.01px;background-color:var(--main-background-color);color:inherit;line-height:1.5;font-weight:500;}#crate-search:hover,#crate-search:focus{border-color:var(--crate-search-hover-border);}#crate-search-div::after{pointer-events:none;width:100%;height:100%;position:absolute;top:0;left:0;content:"";background-repeat:no-repeat;background-size:20px;background-position:calc(100% - 2px) 56%;background-image:url('data:image/svg+xml, \ + ');filter:var(--crate-search-div-filter);}#crate-search-div:hover::after,#crate-search-div:focus-within::after{filter:var(--crate-search-div-hover-filter);}#crate-search>option{font-size:1rem;}.search-input{-webkit-appearance:none;outline:none;border:1px solid var(--border-color);border-radius:2px;padding:8px;font-size:1rem;flex-grow:1;background-color:var(--button-background-color);color:var(--search-color);max-width:100%;}.search-input:focus{border-color:var(--search-input-focused-border-color);}.search-results{display:none;}.search-results.active{display:block;margin:0;padding:0;}.search-results>a{display:grid;grid-template-areas:"search-result-name search-result-desc" "search-result-type-signature search-result-type-signature";grid-template-columns:.6fr .4fr;margin-left:2px;margin-right:2px;border-bottom:1px solid var(--search-result-border-color);column-gap:1em;}.search-results>a>div.desc{white-space:nowrap;text-overflow:ellipsis;overflow:hidden;grid-area:search-result-desc;}.search-results a:hover,.search-results a:focus{background-color:var(--search-result-link-focus-background-color);}.search-results .result-name{display:flex;align-items:center;justify-content:start;grid-area:search-result-name;}.search-results .result-name .alias{color:var(--search-results-alias-color);}.search-results .result-name .grey{color:var(--search-results-grey-color);}.search-results .result-name .typename{color:var(--search-results-grey-color);font-size:0.875rem;width:var(--search-typename-width);}.search-results .result-name .path{word-break:break-all;max-width:calc(100% - var(--search-typename-width));display:inline-block;}.search-results .result-name .path>*{display:inline;}.search-results .type-signature{grid-area:search-result-type-signature;white-space:pre-wrap;}.popover{position:absolute;top:100%;right:0;z-index:calc(var(--desktop-sidebar-z-index) + 1);margin-top:var(--popover-top-margin);border-radius:3px;border:1px solid var(--border-color);background-color:var(--main-background-color);color:var(--main-color);--popover-arrow-offset:11px;}.popover::before{content:'';position:absolute;right:var(--popover-arrow-offset);border:solid var(--border-color);border-width:1px 1px 0 0;background-color:var(--main-background-color);padding:4px;transform:rotate(-45deg);top:-5px;}.setting-line{margin:1.2em 0.6em;}.setting-radio input,.setting-check input{margin-right:0.3em;height:1.2rem;width:1.2rem;border:2px solid var(--settings-input-border-color);outline:none;-webkit-appearance:none;cursor:pointer;}.setting-radio input{border-radius:50%;}.setting-radio span,.setting-check span{padding-bottom:1px;}.setting-radio{margin-top:0.1em;margin-bottom:0.1em;min-width:3.8em;padding:0.3em;display:inline-flex;align-items:center;cursor:pointer;}.setting-radio+.setting-radio{margin-left:0.5em;}.setting-check{margin-right:20px;display:flex;align-items:center;cursor:pointer;}.setting-check input{flex-shrink:0;}.setting-radio input:checked{box-shadow:inset 0 0 0 3px var(--main-background-color);background-color:var(--settings-input-color);}.setting-check input:checked{background-color:var(--settings-input-color);border-width:1px;content:url('data:image/svg+xml,\ + \ + ');}.setting-radio input:focus,.setting-check input:focus{box-shadow:0 0 1px 1px var(--settings-input-color);}.setting-radio input:checked:focus{box-shadow:inset 0 0 0 3px var(--main-background-color),0 0 2px 2px var(--settings-input-color);}.setting-radio input:hover,.setting-check input:hover{border-color:var(--settings-input-color) !important;}#settings.popover{--popover-arrow-offset:196px;top:calc(100% - 16px);}#help.popover{max-width:600px;--popover-arrow-offset:115px;top:calc(100% - 16px);}#help dt{float:left;clear:left;margin-right:0.5rem;}#help dd{margin-bottom:0.5rem;}#help span.top,#help span.bottom{text-align:center;display:block;font-size:1.125rem;padding:0 0.5rem;text-wrap-style:balance;}#help span.top{margin:10px 0;border-bottom:1px solid var(--border-color);padding-bottom:4px;margin-bottom:6px;}#help span.bottom{clear:both;border-top:1px solid var(--border-color);}.side-by-side{display:flex;margin-bottom:20px;}.side-by-side>div{width:50%;padding:0 20px 0 17px;}.item-info .stab{display:block;padding:3px;margin-bottom:5px;}.item-table dt .stab{margin-left:0.3125em;}.stab{padding:0 2px;font-size:0.875rem;font-weight:normal;color:var(--main-color);background-color:var(--stab-background-color);width:fit-content;border-radius:3px;display:inline;vertical-align:baseline;}.stab.portability>code{background:none;color:var(--stab-code-color);}.stab .emoji,.item-info .stab::before{font-size:1.25rem;}.stab .emoji{margin-right:0.3rem;}.item-info .stab::before{content:"\0";width:0;display:inline-block;color:transparent;}.emoji{text-shadow:1px 0 0 black,-1px 0 0 black,0 1px 0 black,0 -1px 0 black;}.since{font-weight:normal;font-size:initial;}.rightside{padding-left:12px;float:right;}.rightside:not(a),.out-of-band,.sub-heading,rustdoc-toolbar{color:var(--right-side-color);}pre.rust{tab-size:4;-moz-tab-size:4;}pre.rust .kw{color:var(--code-highlight-kw-color);}pre.rust .kw-2{color:var(--code-highlight-kw-2-color);}pre.rust .lifetime{color:var(--code-highlight-lifetime-color);}pre.rust .prelude-ty{color:var(--code-highlight-prelude-color);}pre.rust .prelude-val{color:var(--code-highlight-prelude-val-color);}pre.rust .string{color:var(--code-highlight-string-color);}pre.rust .number{color:var(--code-highlight-number-color);}pre.rust .bool-val{color:var(--code-highlight-literal-color);}pre.rust .self{color:var(--code-highlight-self-color);}pre.rust .attr{color:var(--code-highlight-attribute-color);}pre.rust .macro,pre.rust .macro-nonterminal{color:var(--code-highlight-macro-color);}pre.rust .question-mark{font-weight:bold;color:var(--code-highlight-question-mark-color);}pre.rust .comment{color:var(--code-highlight-comment-color);}pre.rust .doccomment{color:var(--code-highlight-doc-comment-color);}.rustdoc.src .example-wrap pre.rust a:not([data-nosnippet]){background:var(--codeblock-link-background);}.example-wrap.compile_fail,.example-wrap.should_panic{border-left:2px solid var(--codeblock-error-color);}.ignore.example-wrap{border-left:2px solid var(--codeblock-ignore-color);}.example-wrap.compile_fail:hover,.example-wrap.should_panic:hover{border-left:2px solid var(--codeblock-error-hover-color);}.example-wrap.ignore:hover{border-left:2px solid var(--codeblock-ignore-hover-color);}.example-wrap.compile_fail .tooltip,.example-wrap.should_panic .tooltip{color:var(--codeblock-error-color);}.example-wrap.ignore .tooltip{color:var(--codeblock-ignore-color);}.example-wrap.compile_fail:hover .tooltip,.example-wrap.should_panic:hover .tooltip{color:var(--codeblock-error-hover-color);}.example-wrap.ignore:hover .tooltip{color:var(--codeblock-ignore-hover-color);}.example-wrap .tooltip{position:absolute;display:block;left:-25px;top:5px;margin:0;line-height:1;}.example-wrap.compile_fail .tooltip,.example-wrap.should_panic .tooltip,.example-wrap.ignore .tooltip{font-weight:bold;font-size:1.25rem;}.content .docblock .warning{border-left:2px solid var(--warning-border-color);padding:14px;position:relative;overflow-x:visible !important;}.content .docblock .warning::before{color:var(--warning-border-color);content:"ⓘ";position:absolute;left:-25px;top:5px;font-weight:bold;font-size:1.25rem;}.top-doc>.docblock>.warning:first-child::before{top:20px;}.example-wrap>a.test-arrow,.example-wrap .button-holder{visibility:hidden;position:absolute;top:4px;right:4px;z-index:1;}a.test-arrow{height:var(--copy-path-height);padding:6px 4px 0 11px;}a.test-arrow::before{content:url('data:image/svg+xml,');}.example-wrap .button-holder{display:flex;}@media not (pointer:coarse){.example-wrap:hover>a.test-arrow,.example-wrap:hover>.button-holder{visibility:visible;}}.example-wrap .button-holder.keep-visible{visibility:visible;}.example-wrap .button-holder>*{background:var(--main-background-color);cursor:pointer;border-radius:var(--button-border-radius);height:var(--copy-path-height);width:var(--copy-path-width);border:0;color:var(--code-example-button-color);}.example-wrap .button-holder>*:hover{color:var(--code-example-button-hover-color);}.example-wrap .button-holder>*:not(:first-child){margin-left:var(--button-left-margin);}.example-wrap .button-holder .copy-button{padding:2px 0 0 4px;}.example-wrap .button-holder .copy-button::before,.example-wrap .test-arrow::before,.example-wrap .button-holder .prev::before,.example-wrap .button-holder .next::before,.example-wrap .button-holder .expand::before{filter:var(--copy-path-img-filter);}.example-wrap .button-holder .copy-button::before{content:var(--clipboard-image);}.example-wrap .button-holder .copy-button:hover::before,.example-wrap .test-arrow:hover::before{filter:var(--copy-path-img-hover-filter);}.example-wrap .button-holder .copy-button.clicked::before{content:var(--checkmark-image);padding-right:5px;}.example-wrap .button-holder .prev,.example-wrap .button-holder .next,.example-wrap .button-holder .expand{line-height:0px;}.example-wrap .button-holder .prev::before{content:var(--prev-arrow-image);}.example-wrap .button-holder .next::before{content:var(--next-arrow-image);}.example-wrap .button-holder .expand::before{content:var(--expand-arrow-image);}.example-wrap .button-holder .expand.collapse::before{content:var(--collapse-arrow-image);}.code-attribute{font-weight:300;color:var(--code-attribute-color);}.item-spacer{width:100%;height:12px;display:block;}.main-heading span.since::before{content:"Since ";}.sub-variant h4{font-size:1rem;font-weight:400;margin-top:0;margin-bottom:0;}.sub-variant{margin-left:24px;margin-bottom:40px;}.sub-variant>.sub-variant-field{margin-left:24px;}@keyframes targetfadein{from{background-color:var(--main-background-color);}10%{background-color:var(--target-border-color);}to{background-color:var(--target-background-color);}}:target:not([data-nosnippet]){background-color:var(--target-background-color);border-right:3px solid var(--target-border-color);}a.tooltip{font-family:var(--font-family);}.code-header a.tooltip{color:inherit;margin-right:15px;position:relative;}.code-header a.tooltip:hover{color:var(--link-color);}a.tooltip:hover::after{position:absolute;top:calc(100% - 10px);left:-15px;right:-15px;height:20px;content:"\00a0";}@media not (prefers-reduced-motion){:target{animation:0.65s cubic-bezier(0,0,0.1,1.0) 0.1s targetfadein;}.fade-out{opacity:0;transition:opacity 0.45s cubic-bezier(0,0,0.1,1.0);}}.popover.tooltip .content{margin:0.25em 0.5em;}.popover.tooltip .content pre,.popover.tooltip .content code{background:transparent;margin:0;padding:0;font-size:1.25rem;white-space:pre-wrap;}.popover.tooltip .content>h3:first-child{margin:0 0 5px 0;}.search-failed{text-align:center;margin-top:20px;display:none;}.search-failed.active{display:block;}.search-failed>ul{text-align:left;max-width:570px;margin-left:auto;margin-right:auto;}#search-tabs{margin-top:0.25rem;display:flex;flex-direction:row;gap:1px;margin-bottom:4px;}#search-tabs button{text-align:center;font-size:1.125rem;border:0;border-top:2px solid;flex:1;line-height:1.5;color:inherit;}#search-tabs button:not(.selected){background-color:var(--search-tab-button-not-selected-background);border-top-color:var(--search-tab-button-not-selected-border-top-color);}#search-tabs button:hover,#search-tabs button.selected{background-color:var(--search-tab-button-selected-background);border-top-color:var(--search-tab-button-selected-border-top-color);}#search-tabs .count{font-size:1rem;font-variant-numeric:tabular-nums;color:var(--search-tab-title-count-color);position:relative;}#search-tabs .count.loading{color:transparent;}.search-form.loading::after{width:18px;height:18px;border-radius:18px;content:url('data:image/svg+xml,\ + \ + \ + \ + \ + \ + \ + \ + \ + \ + ');position:absolute;right:8px;top:8px;filter:var(--settings-menu-filter);}#search .error code{border-radius:3px;background-color:var(--search-error-code-background-color);}.search-corrections{font-weight:normal;}#src-sidebar{width:100%;overflow:auto;}#src-sidebar div.files>a:hover,details.dir-entry summary:hover,#src-sidebar div.files>a:focus,details.dir-entry summary:focus{background-color:var(--src-sidebar-background-hover);}#src-sidebar div.files>a.selected{background-color:var(--src-sidebar-background-selected);}.src-sidebar-title{position:sticky;top:0;display:flex;padding:8px 8px 0 48px;margin-bottom:7px;background:var(--sidebar-background-color);border-bottom:1px solid var(--border-color);}#search-button,.settings-menu,.help-menu,button#toggle-all-docs{margin-left:var(--button-left-margin);display:flex;line-height:1.25;min-width:14px;}#sidebar-button{display:none;line-height:0;}.hide-sidebar #sidebar-button,.src #sidebar-button{display:flex;margin-right:4px;position:fixed;margin-top:25px;left:6px;height:34px;width:34px;z-index:calc(var(--desktop-sidebar-z-index) + 1);}.hide-sidebar #sidebar-button{left:6px;background-color:var(--main-background-color);}.src #sidebar-button{margin-top:0;top:8px;left:8px;border-color:var(--border-color);}.hide-sidebar .src #sidebar-button{position:static;}#search-button>a,.settings-menu>a,.help-menu>a,#sidebar-button>a,button#toggle-all-docs{display:flex;align-items:center;justify-content:center;flex-direction:column;}#search-button>a,.settings-menu>a,.help-menu>a,button#toggle-all-docs{border:1px solid transparent;border-radius:var(--button-border-radius);color:var(--main-color);}#search-button>a,.settings-menu>a,.help-menu>a,button#toggle-all-docs{width:80px;border-radius:var(--toolbar-button-border-radius);}#search-button>a,.settings-menu>a,.help-menu>a{min-width:0;}#sidebar-button>a{border:solid 1px transparent;border-radius:var(--button-border-radius);background-color:var(--button-background-color);width:33px;}.src #sidebar-button>a{background-color:var(--sidebar-background-color);border-color:var(--border-color);}#search-button>a:hover,#search-button>a:focus-visible,.settings-menu>a:hover,.settings-menu>a:focus-visible,.help-menu>a:hover,#help-menu>a:focus-visible,#sidebar-button>a:hover,#sidebar-button>a:focus-visible,#copy-path:hover,#copy-path:focus-visible,button#toggle-all-docs:hover,button#toggle-all-docs:focus-visible{border-color:var(--settings-button-border-focus);text-decoration:none;}#search-button>a::before{content:url('data:image/svg+xml,\ + \ + Search\ + ');width:18px;height:18px;filter:var(--settings-menu-filter);}.settings-menu>a::before{content:url('data:image/svg+xml,\ + \ + ');width:18px;height:18px;filter:var(--settings-menu-filter);}button#toggle-all-docs::before{content:url('data:image/svg+xml,\ + ');width:18px;height:18px;filter:var(--settings-menu-filter);}.help-menu>a::before{content:url('data:image/svg+xml,\ + \ + \ + \ + \ + ');width:18px;height:18px;filter:var(--settings-menu-filter);}.help-menu>a{width:74px;}.help-menu>a>.label{padding-right:1px;}#toggle-all-docs:not(.will-expand)>.label{padding-left:1px;}#search-button>a::before,button#toggle-all-docs::before,.help-menu>a::before,.settings-menu>a::before{filter:var(--settings-menu-filter);margin:8px;}@media not (pointer:coarse){#search-button>a:hover::before,button#toggle-all-docs:hover::before,.help-menu>a:hover::before,.settings-menu>a:hover::before{filter:var(--settings-menu-hover-filter);}}button[disabled]#toggle-all-docs{opacity:0.25;border:solid 1px var(--main-background-color);background-size:cover;}button[disabled]#toggle-all-docs:hover{border:solid 1px var(--main-background-color);cursor:not-allowed;}rustdoc-toolbar span.label{font-size:1rem;flex-grow:1;padding-bottom:4px;}#sidebar-button>a::before{content:url('data:image/svg+xml,\ + \ + \ + ');width:22px;height:22px;}#copy-path{color:var(--copy-path-button-color);background:var(--main-background-color);height:var(--copy-path-height);width:var(--copy-path-width);margin-left:10px;padding:0;padding-left:2px;border:solid 1px transparent;border-radius:var(--button-border-radius);font-size:0;}#copy-path::before{filter:var(--copy-path-img-filter);content:var(--clipboard-image);}#copy-path:hover::before{filter:var(--copy-path-img-hover-filter);}#copy-path.clicked::before{content:var(--checkmark-image);}@keyframes rotating{from{transform:rotate(0deg);}to{transform:rotate(360deg);}}.settings-menu.rotate>a img{animation:rotating 2s linear infinite;}kbd{display:inline-block;padding:3px 5px;font:15px monospace;line-height:10px;vertical-align:middle;border:solid 1px var(--border-color);border-radius:3px;color:var(--kbd-color);background-color:var(--kbd-background);box-shadow:inset 0 -1px 0 var(--kbd-box-shadow-color);}ul.all-items>li{list-style:none;}details.dir-entry{padding-left:4px;}details.dir-entry>summary{margin:0 0 0 -4px;padding:0 0 0 4px;cursor:pointer;}details.dir-entry div.folders,details.dir-entry div.files{padding-left:23px;}details.dir-entry a{display:block;}details.toggle{contain:layout;position:relative;}details.big-toggle{contain:inline-size;}details.toggle>summary.hideme{cursor:pointer;font-size:1rem;}details.toggle>summary{list-style:none;outline:none;}details.toggle>summary::-webkit-details-marker,details.toggle>summary::marker{display:none;}details.toggle>summary.hideme>span{margin-left:9px;}details.toggle>summary::before{background:url('data:image/svg+xml,\ + ');content:"";cursor:pointer;width:16px;height:16px;display:inline-block;vertical-align:middle;opacity:.5;filter:var(--toggle-filter);}details.toggle>summary.hideme>span,.more-examples-toggle summary,.more-examples-toggle .hide-more{color:var(--toggles-color);}details.toggle>summary::after{content:"Expand";overflow:hidden;width:0;height:0;position:absolute;}details.toggle>summary.hideme::after{content:"";}details.toggle>summary:focus::before,details.toggle>summary:hover::before{opacity:1;}details.toggle>summary:focus-visible::before{outline:1px dotted #000;outline-offset:1px;}details.non-exhaustive{margin-bottom:8px;}details.toggle>summary.hideme::before{position:relative;}details.toggle>summary:not(.hideme)::before{position:absolute;left:-24px;top:4px;}.impl-items>details.toggle>summary:not(.hideme)::before,#main-content>.methods>details.toggle>summary:not(.hideme)::before{position:absolute;left:-24px;}.impl-items>*:not(.item-info),.implementors-toggle>.docblock,#main-content>.methods>:not(.item-info),.impl>.item-info,.impl>.docblock,.impl+.docblock{margin-left:var(--impl-items-indent);}details.big-toggle>summary:not(.hideme)::before{left:-34px;top:9px;}details.toggle[open] >summary.hideme{position:absolute;}details.toggle[open] >summary.hideme>span{display:none;}details.toggle[open] >summary::before{background:url('data:image/svg+xml,\ + ');}details.toggle[open] >summary::after{content:"Collapse";}details.toggle:not([open])>summary .docblock{max-height:calc(1.5em + 0.75em);overflow-y:hidden;}details.toggle:not([open])>summary .docblock>:first-child{max-width:100%;overflow:hidden;width:fit-content;white-space:nowrap;position:relative;padding-right:1em;}details.toggle:not([open])>summary .docblock>:first-child::after{content:"…";position:absolute;right:0;top:0;bottom:0;z-index:1;background-color:var(--main-background-color);font:1rem/1.5 "Source Serif 4",NanumBarunGothic,serif;padding-left:0.2em;}details.toggle:not([open])>summary .docblock>div:first-child::after{padding-top:calc(1.5em + 0.75em - 1.2rem);}details.toggle>summary .docblock{margin-top:0.75em;}.docblock summary>*{display:inline-block;}.docblock>.example-wrap:first-child .tooltip{margin-top:16px;}.src #sidebar-button>a::before,.sidebar-menu-toggle::before{content:var(--hamburger-image);opacity:0.75;filter:var(--mobile-sidebar-menu-filter);}.src #sidebar-button>a:hover{background:var(--main-background-color);}.sidebar-menu-toggle:hover::before,.sidebar-menu-toggle:active::before,.sidebar-menu-toggle:focus::before{opacity:1;}@media (max-width:850px){#search-tabs .count{display:block;}.side-by-side{flex-direction:column-reverse;}.side-by-side>div{width:auto;}.main-heading{grid-template-areas:"main-heading-breadcrumbs main-heading-toolbar" "main-heading-h1 main-heading-toolbar" "main-heading-sub-heading main-heading-toolbar";}.search-results-main-heading{display:grid;grid-template-areas:"main-heading-breadcrumbs main-heading-toolbar" "main-heading-breadcrumbs main-heading-toolbar" "main-heading-h1 main-heading-toolbar";}rustdoc-toolbar{margin-top:-10px;display:grid;grid-template-areas:"x settings help" "search summary summary";grid-template-rows:35px 1fr;}.search-results-main-heading rustdoc-toolbar{display:grid;grid-template-areas:"settings help" "search search";}.search-results-main-heading #toggle-all-docs{display:none;}rustdoc-toolbar .settings-menu span.label,rustdoc-toolbar .help-menu span.label{display:none;}rustdoc-toolbar .settings-menu{grid-area:settings;}rustdoc-toolbar .help-menu{grid-area:help;}rustdoc-toolbar .settings-menu{grid-area:settings;}rustdoc-toolbar #search-button{grid-area:search;}rustdoc-toolbar #toggle-all-docs{grid-area:summary;}rustdoc-toolbar .settings-menu,rustdoc-toolbar .help-menu{height:35px;}rustdoc-toolbar .settings-menu>a,rustdoc-toolbar .help-menu>a{border-radius:2px;text-align:center;width:34px;padding:5px 0;}rustdoc-toolbar .settings-menu>a:before,rustdoc-toolbar .help-menu>a:before{margin:0 4px;}#settings.popover{top:16px;--popover-arrow-offset:58px;}#help.popover{top:16px;--popover-arrow-offset:16px;}}.hide-deprecated-items dt.deprecated,.hide-deprecated-items dt.deprecated+dd,.hide-deprecated-items .deprecated,.hide-deprecated-items .deprecated+.item-info{display:none;}.deprecated-count{display:none;}.hide-deprecated-items .deprecated-count:not(:empty){display:block;margin:10px 0;}@media (max-width:700px){:root{--impl-items-indent:0.7em;--topbar-height:45px;}*[id]{scroll-margin-top:var(--topbar-height);}#copy-path{width:0;visibility:hidden;}rustdoc-topbar span.label,html:not(.hide-sidebar) .rustdoc:not(.src) rustdoc-toolbar .settings-menu>a,html:not(.hide-sidebar) .rustdoc:not(.src) rustdoc-toolbar .help-menu>a{display:none;}rustdoc-topbar .settings-menu>a,rustdoc-topbar .help-menu>a{width:33px;line-height:0;}rustdoc-topbar .settings-menu>a:hover,rustdoc-topbar .help-menu>a:hover{border:none;background:var(--main-background-color);border-radius:0;}#settings.popover,#help.popover{top:32px;height:calc(100vh - var(--topbar-height) + var(--popover-top-margin) - 1px);}#settings.popover{--popover-arrow-offset:48px;}#help.popover{--popover-arrow-offset:12px;}#settings.popover .settings,#help.popover .content{overflow-y:scroll;height:100%;}.rustdoc{display:block;}html:not(.hide-sidebar) main{padding-left:15px;padding-top:0px;}.sidebar .logo-container,.sidebar .location,.sidebar-resizer{display:none;}.sidebar{position:fixed;top:var(--topbar-height);left:-1000px;z-index:11;height:calc(100vh - var(--topbar-height));border-right:none;width:100%;}.sidebar-elems .block li a{white-space:wrap;}.src main,.rustdoc.src .sidebar{top:0;padding:0;height:100vh;border:0;}html .src main{padding:18px 0;}.src .search-form{margin-left:40px;}.src .main-heading{margin-left:8px;}.hide-sidebar .search-form{margin-left:32px;}.hide-sidebar .src .search-form{margin-left:0;}.sidebar.shown,.src-sidebar-expanded .src .sidebar,.rustdoc:not(.src) .sidebar:focus-within{left:0;}rustdoc-topbar>h2{padding-bottom:0;margin:auto;overflow:hidden;font-size:24px;white-space:nowrap;text-overflow:ellipsis;text-align:center;}rustdoc-topbar .logo-container>img{max-width:35px;max-height:35px;margin:5px 0 5px 20px;}rustdoc-topbar{display:flex;flex-direction:row;position:sticky;z-index:10;height:var(--topbar-height);width:100%;left:0;top:0;}.hide-sidebar rustdoc-topbar{display:none;}.sidebar-menu-toggle{width:41px;min-width:41px;border:none;line-height:0;}.hide-sidebar .sidebar-menu-toggle{display:none;}.sidebar-elems{margin-top:1em;}.anchor{display:none !important;}#main-content>details.toggle>summary::before,#main-content>div>details.toggle>summary::before{left:-11px;}#sidebar-button>a::before{content:url('data:image/svg+xml,\ + \ + \ + \ + \ + \ + \ + ');width:22px;height:22px;}.sidebar-menu-toggle:hover{background:var(--main-background-color);}.search-results>a,.search-results>a>div{display:block;}.search-results>a{padding:5px 0px;}.search-results>a>div.desc,.item-table dd{padding-left:2em;}.search-results .result-name{display:block;}.search-results .result-name .typename{width:initial;margin-right:0;}.search-results .result-name .typename,.search-results .result-name .path{display:inline;}.src-sidebar-expanded .src .sidebar{position:fixed;max-width:100vw;width:100vw;}.src .src-sidebar-title{padding-top:0;}details.implementors-toggle:not(.top-doc)>summary{margin-left:10px;}.impl-items>details.toggle>summary:not(.hideme)::before,#main-content>.methods>details.toggle>summary:not(.hideme)::before{left:-20px;}summary>.item-info{margin-left:10px;}.impl-items>.item-info{margin-left:calc(var(--impl-items-indent) + 10px);}.src nav.sub{margin:0 0 -25px 0;padding:var(--nav-sub-mobile-padding);}html:not(.src-sidebar-expanded) .src #sidebar-button>a{background-color:var(--main-background-color);}html:not(.src-sidebar-expanded) .src #sidebar-button>a:hover,html:not(.src-sidebar-expanded) .src #sidebar-button>a:focus-visible{background-color:var(--sidebar-background-color);}}@media (min-width:701px){.scraped-example-title{position:absolute;z-index:10;background:var(--main-background-color);bottom:8px;right:5px;padding:2px 4px;box-shadow:0 0 4px var(--main-background-color);}.item-table:not(.reexports){display:grid;grid-template-columns:33% 67%;}.item-table>dt,.item-table>dd{overflow-wrap:anywhere;}.item-table>dt{grid-column-start:1;}.item-table>dd{grid-column-start:2;}}@media print{:root{--docblock-indent:0;}nav.sidebar,nav.sub,.out-of-band,a.src,#copy-path,details.toggle[open] >summary::before,details.toggle>summary::before,details.toggle.top-doc>summary{display:none;}main{padding:10px;}}@media (max-width:464px){:root{--docblock-indent:12px;}.docblock code{overflow-wrap:break-word;overflow-wrap:anywhere;}nav.sub{flex-direction:column;}.search-form{align-self:stretch;}}.variant,.implementors-toggle>summary,.impl,#implementors-list>.docblock,.impl-items>section,.impl-items>.toggle>summary,.methods>section,.methods>.toggle>summary{margin-bottom:0.75em;}.negative-marker{display:none;}.variants>.docblock,.implementors-toggle>.docblock,.impl-items>.toggle[open]:not(:last-child),.methods>.toggle[open]:not(:last-child),.implementors-toggle[open]:not(:last-child){margin-bottom:2em;}#trait-implementations-list .impl-items>.toggle:not(:last-child),#synthetic-implementations-list .impl-items>.toggle:not(:last-child),#blanket-implementations-list .impl-items>.toggle:not(:last-child){margin-bottom:1em;}.scraped-example-list .scrape-help{margin-left:10px;padding:0 4px;font-weight:normal;font-size:12px;position:relative;bottom:1px;border:1px solid var(--scrape-example-help-border-color);border-radius:50px;color:var(--scrape-example-help-color);}.scraped-example-list .scrape-help:hover{border-color:var(--scrape-example-help-hover-border-color);color:var(--scrape-example-help-hover-color);}.scraped-example:not(.expanded) .example-wrap::before,.scraped-example:not(.expanded) .example-wrap::after{content:" ";width:100%;height:5px;position:absolute;z-index:1;}.scraped-example:not(.expanded) .example-wrap::before{top:0;background:linear-gradient(to bottom,var(--scrape-example-code-wrapper-background-start),var(--scrape-example-code-wrapper-background-end));}.scraped-example:not(.expanded) .example-wrap::after{bottom:0;background:linear-gradient(to top,var(--scrape-example-code-wrapper-background-start),var(--scrape-example-code-wrapper-background-end));}.scraped-example:not(.expanded){width:100%;overflow-y:hidden;margin-bottom:0;}.scraped-example:not(.expanded){overflow-x:hidden;}.scraped-example .rust span.highlight{background:var(--scrape-example-code-line-highlight);}.scraped-example .rust span.highlight.focus{background:var(--scrape-example-code-line-highlight-focus);}.more-examples-toggle{max-width:calc(100% + 25px);margin-top:10px;margin-left:-25px;}.more-examples-toggle .hide-more{margin-left:25px;cursor:pointer;}.more-scraped-examples{margin-left:25px;position:relative;}.toggle-line{position:absolute;top:5px;bottom:0;right:calc(100% + 10px);padding:0 4px;cursor:pointer;}.toggle-line-inner{min-width:2px;height:100%;background:var(--scrape-example-toggle-line-background);}.toggle-line:hover .toggle-line-inner{background:var(--scrape-example-toggle-line-hover-background);}.more-scraped-examples .scraped-example,.example-links{margin-top:20px;}.more-scraped-examples .scraped-example:first-child{margin-top:5px;}.example-links ul{margin-bottom:0;}:root[data-theme="light"],:root:not([data-theme]){--main-background-color:white;--main-color:black;--settings-input-color:#2196f3;--settings-input-border-color:#717171;--settings-button-color:#000;--settings-button-border-focus:#717171;--sidebar-background-color:#f5f5f5;--sidebar-background-color-hover:#e0e0e0;--sidebar-border-color:#ddd;--code-block-background-color:#f5f5f5;--scrollbar-track-background-color:#dcdcdc;--scrollbar-thumb-background-color:rgba(36,37,39,0.6);--scrollbar-color:rgba(36,37,39,0.6) #d9d9d9;--headings-border-bottom-color:#ddd;--border-color:#e0e0e0;--button-background-color:#fff;--right-side-color:grey;--code-attribute-color:#999;--toggles-color:#999;--toggle-filter:none;--mobile-sidebar-menu-filter:none;--search-input-focused-border-color:#66afe9;--copy-path-button-color:#999;--copy-path-img-filter:invert(50%);--copy-path-img-hover-filter:invert(35%);--code-example-button-color:#7f7f7f;--code-example-button-hover-color:#595959;--settings-menu-filter:invert(50%);--settings-menu-hover-filter:invert(35%);--codeblock-error-hover-color:rgb(255,0,0);--codeblock-error-color:rgba(255,0,0,.5);--codeblock-ignore-hover-color:rgb(255,142,0);--codeblock-ignore-color:rgba(255,142,0,.6);--warning-border-color:#ff8e00;--type-link-color:#ad378a;--trait-link-color:#6e4fc9;--assoc-item-link-color:#3873ad;--function-link-color:#ad7c37;--macro-link-color:#068000;--keyword-link-color:#3873ad;--attribute-link-color:#3873ad;--mod-link-color:#3873ad;--link-color:#3873ad;--sidebar-link-color:#356da4;--sidebar-current-link-background-color:#fff;--search-result-link-focus-background-color:#ccc;--search-result-border-color:#aaa3;--search-color:#000;--search-error-code-background-color:#d0cccc;--search-results-alias-color:#000;--search-results-grey-color:#999;--search-tab-title-count-color:#888;--search-tab-button-not-selected-border-top-color:#e6e6e6;--search-tab-button-not-selected-background:#e6e6e6;--search-tab-button-selected-border-top-color:#0089ff;--search-tab-button-selected-background:#fff;--stab-background-color:#fff5d6;--stab-code-color:#000;--code-highlight-kw-color:#8959a8;--code-highlight-kw-2-color:#4271ae;--code-highlight-lifetime-color:#b76514;--code-highlight-prelude-color:#4271ae;--code-highlight-prelude-val-color:#c82829;--code-highlight-number-color:#718c00;--code-highlight-string-color:#718c00;--code-highlight-literal-color:#c82829;--code-highlight-attribute-color:#c82829;--code-highlight-self-color:#c82829;--code-highlight-macro-color:#3e999f;--code-highlight-question-mark-color:#ff9011;--code-highlight-comment-color:#8e908c;--code-highlight-doc-comment-color:#4d4d4c;--src-line-numbers-span-color:#c67e2d;--src-line-number-highlighted-background-color:#fdffd3;--target-background-color:#fdffd3;--target-border-color:#ad7c37;--kbd-color:#000;--kbd-background:#fafbfc;--kbd-box-shadow-color:#c6cbd1;--rust-logo-filter:initial;--crate-search-div-filter:invert(100%) sepia(0%) saturate(4223%) hue-rotate(289deg) brightness(114%) contrast(76%);--crate-search-div-hover-filter:invert(44%) sepia(18%) saturate(23%) hue-rotate(317deg) brightness(96%) contrast(93%);--crate-search-hover-border:#717171;--src-sidebar-background-selected:#fff;--src-sidebar-background-hover:#e0e0e0;--table-alt-row-background-color:#f5f5f5;--codeblock-link-background:#eee;--scrape-example-toggle-line-background:#ccc;--scrape-example-toggle-line-hover-background:#999;--scrape-example-code-line-highlight:#fcffd6;--scrape-example-code-line-highlight-focus:#f6fdb0;--scrape-example-help-border-color:#555;--scrape-example-help-color:#333;--scrape-example-help-hover-border-color:#000;--scrape-example-help-hover-color:#000;--scrape-example-code-wrapper-background-start:rgba(255,255,255,1);--scrape-example-code-wrapper-background-end:rgba(255,255,255,0);--sidebar-resizer-hover:hsl(207,90%,66%);--sidebar-resizer-active:hsl(207,90%,54%);}:root[data-theme="dark"]{--main-background-color:#353535;--main-color:#ddd;--settings-input-color:#2196f3;--settings-input-border-color:#999;--settings-button-color:#000;--settings-button-border-focus:#ffb900;--sidebar-background-color:#505050;--sidebar-background-color-hover:#676767;--sidebar-border-color:#999;--code-block-background-color:#2A2A2A;--scrollbar-track-background-color:#717171;--scrollbar-thumb-background-color:rgba(32,34,37,.6);--scrollbar-color:rgba(32,34,37,.6) #5a5a5a;--headings-border-bottom-color:#d2d2d2;--border-color:#e0e0e0;--button-background-color:#f0f0f0;--right-side-color:#d0d0d0;--code-attribute-color:#999;--toggles-color:#999;--toggle-filter:invert(100%);--mobile-sidebar-menu-filter:invert(100%);--search-input-focused-border-color:#008dfd;--copy-path-button-color:#999;--copy-path-img-filter:invert(50%);--copy-path-img-hover-filter:invert(65%);--code-example-button-color:#7f7f7f;--code-example-button-hover-color:#a5a5a5;--codeblock-error-hover-color:rgb(255,0,0);--codeblock-error-color:rgba(255,0,0,.5);--codeblock-ignore-hover-color:rgb(255,142,0);--codeblock-ignore-color:rgba(255,142,0,.6);--warning-border-color:#ff8e00;--type-link-color:#2dbfb8;--trait-link-color:#b78cf2;--assoc-item-link-color:#d2991d;--function-link-color:#2bab63;--macro-link-color:#09bd00;--keyword-link-color:#d2991d;--attribute-link-color:#d2991d;--mod-link-color:#d2991d;--link-color:#d2991d;--sidebar-link-color:#fdbf35;--sidebar-current-link-background-color:#444;--search-result-link-focus-background-color:#616161;--search-result-border-color:#aaa3;--search-color:#111;--search-error-code-background-color:#484848;--search-results-alias-color:#fff;--search-results-grey-color:#ccc;--search-tab-title-count-color:#888;--search-tab-button-not-selected-border-top-color:#252525;--search-tab-button-not-selected-background:#252525;--search-tab-button-selected-border-top-color:#0089ff;--search-tab-button-selected-background:#353535;--settings-menu-filter:invert(50%);--settings-menu-hover-filter:invert(65%);--stab-background-color:#314559;--stab-code-color:#e6e1cf;--code-highlight-kw-color:#ab8ac1;--code-highlight-kw-2-color:#769acb;--code-highlight-lifetime-color:#d97f26;--code-highlight-prelude-color:#769acb;--code-highlight-prelude-val-color:#ee6868;--code-highlight-number-color:#83a300;--code-highlight-string-color:#83a300;--code-highlight-literal-color:#ee6868;--code-highlight-attribute-color:#ee6868;--code-highlight-self-color:#ee6868;--code-highlight-macro-color:#3e999f;--code-highlight-question-mark-color:#ff9011;--code-highlight-comment-color:#8d8d8b;--code-highlight-doc-comment-color:#8ca375;--src-line-numbers-span-color:#3b91e2;--src-line-number-highlighted-background-color:#0a042f;--target-background-color:#494a3d;--target-border-color:#bb7410;--kbd-color:#000;--kbd-background:#fafbfc;--kbd-box-shadow-color:#c6cbd1;--rust-logo-filter:drop-shadow(1px 0 0px #fff) drop-shadow(0 1px 0 #fff) drop-shadow(-1px 0 0 #fff) drop-shadow(0 -1px 0 #fff);--crate-search-div-filter:invert(94%) sepia(0%) saturate(721%) hue-rotate(255deg) brightness(90%) contrast(90%);--crate-search-div-hover-filter:invert(69%) sepia(60%) saturate(6613%) hue-rotate(184deg) brightness(100%) contrast(91%);--crate-search-hover-border:#2196f3;--src-sidebar-background-selected:#333;--src-sidebar-background-hover:#444;--table-alt-row-background-color:#2a2a2a;--codeblock-link-background:#333;--scrape-example-toggle-line-background:#999;--scrape-example-toggle-line-hover-background:#c5c5c5;--scrape-example-code-line-highlight:#5b3b01;--scrape-example-code-line-highlight-focus:#7c4b0f;--scrape-example-help-border-color:#aaa;--scrape-example-help-color:#eee;--scrape-example-help-hover-border-color:#fff;--scrape-example-help-hover-color:#fff;--scrape-example-code-wrapper-background-start:rgba(53,53,53,1);--scrape-example-code-wrapper-background-end:rgba(53,53,53,0);--sidebar-resizer-hover:hsl(207,30%,54%);--sidebar-resizer-active:hsl(207,90%,54%);}:root[data-theme="ayu"]{--main-background-color:#0f1419;--main-color:#c5c5c5;--settings-input-color:#ffb454;--settings-input-border-color:#999;--settings-button-color:#fff;--settings-button-border-focus:#e0e0e0;--sidebar-background-color:#14191f;--sidebar-background-color-hover:rgba(70,70,70,0.33);--sidebar-border-color:#5c6773;--code-block-background-color:#191f26;--scrollbar-track-background-color:transparent;--scrollbar-thumb-background-color:#5c6773;--scrollbar-color:#5c6773 #24292f;--headings-border-bottom-color:#5c6773;--border-color:#5c6773;--button-background-color:#141920;--right-side-color:grey;--code-attribute-color:#999;--toggles-color:#999;--toggle-filter:invert(100%);--mobile-sidebar-menu-filter:invert(100%);--search-input-focused-border-color:#5c6773;--copy-path-button-color:#fff;--copy-path-img-filter:invert(70%);--copy-path-img-hover-filter:invert(100%);--code-example-button-color:#b2b2b2;--code-example-button-hover-color:#fff;--codeblock-error-hover-color:rgb(255,0,0);--codeblock-error-color:rgba(255,0,0,.5);--codeblock-ignore-hover-color:rgb(255,142,0);--codeblock-ignore-color:rgba(255,142,0,.6);--warning-border-color:#ff8e00;--type-link-color:#ffa0a5;--trait-link-color:#39afd7;--assoc-item-link-color:#39afd7;--function-link-color:#fdd687;--macro-link-color:#a37acc;--keyword-link-color:#39afd7;--attribute-link-color:#39afd7;--mod-link-color:#39afd7;--link-color:#39afd7;--sidebar-link-color:#53b1db;--sidebar-current-link-background-color:transparent;--search-result-link-focus-background-color:#3c3c3c;--search-result-border-color:#aaa3;--search-color:#fff;--search-error-code-background-color:#4f4c4c;--search-results-alias-color:#c5c5c5;--search-results-grey-color:#999;--search-tab-title-count-color:#888;--search-tab-button-not-selected-border-top-color:none;--search-tab-button-not-selected-background:transparent !important;--search-tab-button-selected-border-top-color:none;--search-tab-button-selected-background:#141920 !important;--settings-menu-filter:invert(70%);--settings-menu-hover-filter:invert(100%);--stab-background-color:#314559;--stab-code-color:#e6e1cf;--code-highlight-kw-color:#ff7733;--code-highlight-kw-2-color:#ff7733;--code-highlight-lifetime-color:#ff7733;--code-highlight-prelude-color:#69f2df;--code-highlight-prelude-val-color:#ff7733;--code-highlight-number-color:#b8cc52;--code-highlight-string-color:#b8cc52;--code-highlight-literal-color:#ff7733;--code-highlight-attribute-color:#e6e1cf;--code-highlight-self-color:#36a3d9;--code-highlight-macro-color:#a37acc;--code-highlight-question-mark-color:#ff9011;--code-highlight-comment-color:#788797;--code-highlight-doc-comment-color:#a1ac88;--src-line-numbers-span-color:#5c6773;--src-line-number-highlighted-background-color:rgba(255,236,164,0.06);--target-background-color:rgba(255,236,164,0.06);--target-border-color:rgba(255,180,76,0.85);--kbd-color:#c5c5c5;--kbd-background:#314559;--kbd-box-shadow-color:#5c6773;--rust-logo-filter:drop-shadow(1px 0 0px #fff) drop-shadow(0 1px 0 #fff) drop-shadow(-1px 0 0 #fff) drop-shadow(0 -1px 0 #fff);--crate-search-div-filter:invert(41%) sepia(12%) saturate(487%) hue-rotate(171deg) brightness(94%) contrast(94%);--crate-search-div-hover-filter:invert(98%) sepia(12%) saturate(81%) hue-rotate(343deg) brightness(113%) contrast(76%);--crate-search-hover-border:#e0e0e0;--src-sidebar-background-selected:#14191f;--src-sidebar-background-hover:#14191f;--table-alt-row-background-color:#191f26;--codeblock-link-background:#333;--scrape-example-toggle-line-background:#999;--scrape-example-toggle-line-hover-background:#c5c5c5;--scrape-example-code-line-highlight:#5b3b01;--scrape-example-code-line-highlight-focus:#7c4b0f;--scrape-example-help-border-color:#aaa;--scrape-example-help-color:#eee;--scrape-example-help-hover-border-color:#fff;--scrape-example-help-hover-color:#fff;--scrape-example-code-wrapper-background-start:rgba(15,20,25,1);--scrape-example-code-wrapper-background-end:rgba(15,20,25,0);--sidebar-resizer-hover:hsl(34,50%,33%);--sidebar-resizer-active:hsl(34,100%,66%);}:root[data-theme="ayu"] h1,:root[data-theme="ayu"] h2,:root[data-theme="ayu"] h3,:root[data-theme="ayu"] h4,:where(:root[data-theme="ayu"]) h1 a,:root[data-theme="ayu"] .sidebar h2 a,:root[data-theme="ayu"] .sidebar h3 a{color:#fff;}:root[data-theme="ayu"] .docblock code{color:#ffb454;}:root[data-theme="ayu"] .docblock a>code{color:#39AFD7 !important;}:root[data-theme="ayu"] .code-header,:root[data-theme="ayu"] .docblock pre>code,:root[data-theme="ayu"] pre,:root[data-theme="ayu"] pre>code,:root[data-theme="ayu"] .item-info code,:root[data-theme="ayu"] .rustdoc.source .example-wrap{color:#e6e1cf;}:root[data-theme="ayu"] .sidebar .current,:root[data-theme="ayu"] .sidebar .current a,:root[data-theme="ayu"] .sidebar a:hover,:root[data-theme="ayu"] #src-sidebar div.files>a:hover,:root[data-theme="ayu"] details.dir-entry summary:hover,:root[data-theme="ayu"] #src-sidebar div.files>a:focus,:root[data-theme="ayu"] details.dir-entry summary:focus,:root[data-theme="ayu"] #src-sidebar div.files>a.selected{color:#ffb44c;}:root[data-theme="ayu"] .sidebar-elems .location{color:#ff7733;}:root[data-theme="ayu"] a[data-nosnippet].line-highlighted{color:#708090;padding-right:7px;border-right:1px solid #ffb44c;}:root[data-theme="ayu"] .search-results a:hover,:root[data-theme="ayu"] .search-results a:focus{color:#fff !important;background-color:#3c3c3c;}:root[data-theme="ayu"] .search-results a{color:#0096cf;}:root[data-theme="ayu"] .search-results a div.desc{color:#c5c5c5;}:root[data-theme="ayu"] .result-name .primitive>i,:root[data-theme="ayu"] .result-name .keyword>i{color:#788797;}:root[data-theme="ayu"] #search-tabs>button.selected{border-bottom:1px solid #ffb44c !important;border-top:none;}:root[data-theme="ayu"] #search-tabs>button:not(.selected){border:none;background-color:transparent !important;}:root[data-theme="ayu"] #search-tabs>button:hover{border-bottom:1px solid rgba(242,151,24,0.3);}:root[data-theme="ayu"] .settings-menu>a img,:root[data-theme="ayu"] #sidebar-button>a::before{filter:invert(100);} \ No newline at end of file diff --git a/api/static.files/scrape-examples-2bbcccac.js b/api/static.files/scrape-examples-2bbcccac.js new file mode 100644 index 00000000..c90f297f --- /dev/null +++ b/api/static.files/scrape-examples-2bbcccac.js @@ -0,0 +1 @@ +"use strict";(function(){const DEFAULT_MAX_LINES=5;const HIDDEN_MAX_LINES=10;function scrollToLoc(elt,loc,isHidden){const lines=elt.querySelectorAll("[data-nosnippet]");let scrollOffset;const maxLines=isHidden?HIDDEN_MAX_LINES:DEFAULT_MAX_LINES;if(loc[1]-loc[0]>maxLines){const line=Math.max(0,loc[0]-1);scrollOffset=lines[line].offsetTop;}else{const halfHeight=elt.offsetHeight/2;const offsetTop=lines[loc[0]].offsetTop;const lastLine=lines[loc[1]];const offsetBot=lastLine.offsetTop+lastLine.offsetHeight;const offsetMid=(offsetTop+offsetBot)/2;scrollOffset=offsetMid-halfHeight;}nonnull(lines[0].parentElement).scrollTo(0,scrollOffset);nonnull(elt.querySelector(".rust")).scrollTo(0,scrollOffset);}function createScrapeButton(parent,className,content){const button=document.createElement("button");button.className=className;button.title=content;parent.insertBefore(button,parent.firstChild);return button;}window.updateScrapedExample=(example,buttonHolder)=>{let locIndex=0;const highlights=Array.prototype.slice.call(example.querySelectorAll(".highlight"));const link=nonnull(example.querySelector(".scraped-example-title a"));let expandButton=null;if(!example.classList.contains("expanded")){expandButton=createScrapeButton(buttonHolder,"expand","Show all");}const isHidden=nonnull(example.parentElement).classList.contains("more-scraped-examples");const locs=example.locs;if(locs.length>1){const next=createScrapeButton(buttonHolder,"next","Next usage");const prev=createScrapeButton(buttonHolder,"prev","Previous usage");const onChangeLoc=changeIndex=>{removeClass(highlights[locIndex],"focus");changeIndex();scrollToLoc(example,locs[locIndex][0],isHidden);addClass(highlights[locIndex],"focus");const url=locs[locIndex][1];const title=locs[locIndex][2];link.href=url;link.innerHTML=title;};prev.addEventListener("click",()=>{onChangeLoc(()=>{locIndex=(locIndex-1+locs.length)%locs.length;});});next.addEventListener("click",()=>{onChangeLoc(()=>{locIndex=(locIndex+1)%locs.length;});});}if(expandButton){expandButton.addEventListener("click",()=>{if(hasClass(example,"expanded")){removeClass(example,"expanded");removeClass(expandButton,"collapse");expandButton.title="Show all";scrollToLoc(example,locs[0][0],isHidden);}else{addClass(example,"expanded");addClass(expandButton,"collapse");expandButton.title="Show single example";}});}};function setupLoc(example,isHidden){const locs_str=nonnull(example.attributes.getNamedItem("data-locs")).textContent;const locs=JSON.parse(nonnull(nonnull(locs_str)));example.locs=locs;scrollToLoc(example,locs[0][0],isHidden);}const firstExamples=document.querySelectorAll(".scraped-example-list > .scraped-example");onEachLazy(firstExamples,el=>setupLoc(el,false));onEachLazy(document.querySelectorAll(".more-examples-toggle"),toggle=>{onEachLazy(toggle.querySelectorAll(".toggle-line, .hide-more"),button=>{button.addEventListener("click",()=>{toggle.open=false;});});const moreExamples=toggle.querySelectorAll(".scraped-example");toggle.querySelector("summary").addEventListener("click",()=>{setTimeout(()=>{onEachLazy(moreExamples,el=>setupLoc(el,true));});},{once:true});});})(); \ No newline at end of file diff --git a/api/static.files/search-fd9372ac.js b/api/static.files/search-fd9372ac.js new file mode 100644 index 00000000..d1c3ea6b --- /dev/null +++ b/api/static.files/search-fd9372ac.js @@ -0,0 +1,5 @@ +"use strict";const initSearch=async function(Stringdex,RoaringBitmap,hooks){if(!Array.prototype.toSpliced){Array.prototype.toSpliced=function(){const me=this.slice();Array.prototype.splice.apply(me,arguments);return me;};}async function onEachBtwnAsync(arr,func,funcBtwn){let skipped=true;for(const value of arr){if(!skipped){funcBtwn(value);}skipped=await func(value);}}const yieldToBrowser=typeof window!=="undefined"&&window.requestIdleCallback?function(){return new Promise((resolve,_reject)=>{window.requestIdleCallback(resolve);});}:function(){return new Promise((resolve,_reject)=>{setTimeout(resolve,0);});};const timeout=function(ms){return new Promise((resolve,_reject)=>{setTimeout(resolve,ms);});};if(!Promise.withResolvers){Promise.withResolvers=()=>{let resolve,reject;const promise=new Promise((res,rej)=>{resolve=res;reject=rej;});return{promise,resolve,reject};};}const itemTypes=Object.freeze({keyword:0,primitive:1,mod:2,externcrate:3,import:4,struct:5,enum:6,fn:7,type:8,static:9,trait:10,impl:11,tymethod:12,method:13,structfield:14,variant:15,macro:16,associatedtype:17,constant:18,associatedconstant:19,union:20,foreigntype:21,existential:22,attr:23,derive:24,traitalias:25,generic:26,attribute:27,decl_macro_attribute:28,decl_macro_derive:29,});const itemTypesName=Array.from(Object.keys(itemTypes));const itemParents=new Map([[itemTypes.associatedconstant,itemTypes.constant],[itemTypes.method,itemTypes.fn],[itemTypes.tymethod,itemTypes.fn],[itemTypes.primitive,itemTypes.type],[itemTypes.associatedtype,itemTypes.type],[itemTypes.traitalias,itemTypes.trait],[itemTypes.attr,itemTypes.macro],[itemTypes.derive,itemTypes.macro],[itemTypes.externcrate,itemTypes.import],]);const ROOT_PATH=typeof window!=="undefined"?window.rootPath:"../";const UNBOXING_LIMIT=5;const REGEX_IDENT=/\p{ID_Continue}+/uy;const REGEX_INVALID_TYPE_FILTER=/[^a-z]/ui;const MAX_RESULTS=200;const NO_TYPE_FILTER=-1;const DEPRECATED_COUNT_SELECTOR="deprecated-count";const editDistanceState={current:[],prev:[],prevPrev:[],calculate:function calculate(a,b,limit){if(a.lengthlimit){return limit+1;}while(b.length>0&&b[0]===a[0]){a=a.substring(1);b=b.substring(1);}while(b.length>0&&b[b.length-1]===a[a.length-1]){a=a.substring(0,a.length-1);b=b.substring(0,b.length-1);}if(b.length===0){return minDist;}const aLength=a.length;const bLength=b.length;for(let i=0;i<=bLength;++i){this.current[i]=0;this.prev[i]=i;this.prevPrev[i]=Number.MAX_VALUE;}for(let i=1;i<=aLength;++i){this.current[0]=i;const aIdx=i-1;for(let j=1;j<=bLength;++j){const bIdx=j-1;const substitutionCost=a[aIdx]===b[bIdx]?0:1;this.current[j]=Math.min(this.prev[j]+1,this.current[j-1]+1,this.prev[j-1]+substitutionCost,);if((i>1)&&(j>1)&&(a[aIdx]===b[bIdx-1])&&(a[aIdx-1]===b[bIdx])){this.current[j]=Math.min(this.current[j],this.prevPrev[j-2]+1,);}}const prevPrevTmp=this.prevPrev;this.prevPrev=this.prev;this.prev=this.current;this.current=prevPrevTmp;}const distance=this.prev[bLength];return distance<=limit?distance:(limit+1);},};function editDistance(a,b,limit){return editDistanceState.calculate(a,b,limit);}function isEndCharacter(c){return"=,>-])".indexOf(c)!==-1;}function isFnLikeTy(ty){return ty===itemTypes.fn||ty===itemTypes.method||ty===itemTypes.tymethod;}function isSeparatorCharacter(c){return c===","||c==="=";}function isReturnArrow(parserState){return parserState.userQuery.slice(parserState.pos,parserState.pos+2)==="->";}function skipWhitespace(parserState){while(parserState.pos0){const c=parserState.userQuery[pos-1];if(c===lookingFor){return true;}else if(c!==" "){break;}pos-=1;}return false;}function isLastElemGeneric(elems,parserState){return(elems.length>0&&elems[elems.length-1].generics.length>0)||prevIs(parserState,">");}function getFilteredNextElem(query,parserState,elems,isInGenerics){const start=parserState.pos;if(parserState.userQuery[parserState.pos]===":"&&!isPathStart(parserState)){throw["Expected type filter before ",":"];}getNextElem(query,parserState,elems,isInGenerics);if(parserState.userQuery[parserState.pos]===":"&&!isPathStart(parserState)){if(parserState.typeFilter!==null){throw["Unexpected ",":"," (expected path after type filter ",parserState.typeFilter+":",")",];}if(elems.length===0){throw["Expected type filter before ",":"];}else if(query.literalSearch){throw["Cannot use quotes on type filter"];}const typeFilterElem=elems.pop();checkExtraTypeFilterCharacters(start,parserState);parserState.typeFilter=typeFilterElem.normalizedPathLast;parserState.pos+=1;parserState.totalElems-=1;query.literalSearch=false;getNextElem(query,parserState,elems,isInGenerics);}}function getItemsBefore(query,parserState,elems,endChar){let foundStopChar=true;let foundSeparator=false;const oldTypeFilter=parserState.typeFilter;parserState.typeFilter=null;const oldIsInBinding=parserState.isInBinding;parserState.isInBinding=null;let hofParameters=null;let extra="";if(endChar===">"){extra="<";}else if(endChar==="]"){extra="[";}else if(endChar===")"){extra="(";}else if(endChar===""){extra="->";}else{extra=endChar;}while(parserState.pos"," after ","="];}hofParameters=[...elems];elems.length=0;parserState.pos+=2;foundStopChar=true;foundSeparator=false;continue;}else if(c===" "){parserState.pos+=1;continue;}else if(isSeparatorCharacter(c)){parserState.pos+=1;foundStopChar=true;foundSeparator=true;continue;}else if(c===":"&&isPathStart(parserState)){throw["Unexpected ","::",": paths cannot start with ","::"];}else if(isEndCharacter(c)){throw["Unexpected ",c," after ",extra];}if(!foundStopChar){let extra=[];if(isLastElemGeneric(query.elems,parserState)){extra=[" after ",">"];}else if(prevIs(parserState,"\"")){throw["Cannot have more than one element if you use quotes"];}if(endChar!==""){throw["Expected ",",",", ","=",", or ",endChar,...extra,", found ",c,];}throw["Expected ",","," or ","=",...extra,", found ",c,];}const posBefore=parserState.pos;getFilteredNextElem(query,parserState,elems,endChar!=="");if(endChar!==""&&parserState.pos>=parserState.length){throw["Unclosed ",extra];}if(posBefore===parserState.pos){parserState.pos+=1;}foundStopChar=false;}if(parserState.pos>=parserState.length&&endChar!==""){throw["Unclosed ",extra];}parserState.pos+=1;if(hofParameters){foundSeparator=false;if([...elems,...hofParameters].some(x=>x.bindingName)||parserState.isInBinding){throw["Unexpected ","="," within ","->"];}const hofElem=makePrimitiveElement("->",{generics:hofParameters,bindings:new Map([["output",[...elems]]]),typeFilter:null,});elems.length=0;elems[0]=hofElem;}parserState.typeFilter=oldTypeFilter;parserState.isInBinding=oldIsInBinding;return{foundSeparator};}function getNextElem(query,parserState,elems,isInGenerics){const generics=[];const handleRefOrPtr=(chr,name)=>{if(parserState.typeFilter!==null&&parserState.typeFilter!=="primitive"){throw["Invalid search type: primitive ",chr," and ",parserState.typeFilter," both specified",];}parserState.typeFilter=null;parserState.pos+=1;let c=parserState.userQuery[parserState.pos];while(c===" "&&parserState.pos"){generics[0].typeFilter=typeFilter;elems.push(generics[0]);}else{if(typeFilter!==null&&typeFilter!=="primitive"){throw["Invalid search type: primitive ",name," and ",typeFilter," both specified",];}parserState.totalElems+=1;if(isInGenerics){parserState.genericsElems+=1;}elems.push(makePrimitiveElement(name,{bindingName,generics}));}}else if(parserState.userQuery[parserState.pos]==="&"){handleRefOrPtr("&","reference");}else if(parserState.userQuery[parserState.pos]==="*"){handleRefOrPtr("*","pointer");}else{const isStringElem=parserState.userQuery[start]==="\"";if(isStringElem){start+=1;getStringElem(query,parserState,isInGenerics);end=parserState.pos-1;}else{end=getIdentEndPosition(parserState);}if(parserState.pos=end){throw["Found generics without a path"];}parserState.pos+=1;getItemsBefore(query,parserState,generics,">");}else if(parserState.pos=end){throw["Found generics without a path"];}if(parserState.isInBinding){throw["Unexpected ","("," after ","="];}parserState.pos+=1;const typeFilter=parserState.typeFilter;parserState.typeFilter=null;getItemsBefore(query,parserState,generics,")");skipWhitespace(parserState);if(isReturnArrow(parserState)){parserState.pos+=2;skipWhitespace(parserState);getFilteredNextElem(query,parserState,generics,isInGenerics);generics[generics.length-1].bindingName=makePrimitiveElement("output");}else{generics.push(makePrimitiveElement(null,{bindingName:makePrimitiveElement("output"),typeFilter:null,}));}parserState.typeFilter=typeFilter;}if(isStringElem){skipWhitespace(parserState);}if(start>=end&&generics.length===0){return;}if(parserState.userQuery[parserState.pos]==="="){if(parserState.isInBinding){throw["Cannot write ","="," twice in a binding"];}if(!isInGenerics){throw["Type parameter ","="," must be within generics list"];}const name=parserState.userQuery.slice(start,end).trim();if(name==="!"){throw["Type parameter ","="," key cannot be ","!"," never type"];}if(name.includes("!")){throw["Type parameter ","="," key cannot be ","!"," macro"];}if(name.includes("::")){throw["Type parameter ","="," key cannot contain ","::"," path"];}if(name.includes(":")){throw["Type parameter ","="," key cannot contain ",":"," type"];}parserState.isInBinding={name,generics};}else{elems.push(createQueryElement(query,parserState,parserState.userQuery.slice(start,end),generics,isInGenerics,),);}}}function checkExtraTypeFilterCharacters(start,parserState){const query=parserState.userQuery.slice(start,parserState.pos).trim();const match=query.match(REGEX_INVALID_TYPE_FILTER);if(match){throw["Unexpected ",match[0]," in type filter (before ",":",")",];}}function createQueryElement(query,parserState,name,generics,isInGenerics){const path=name.trim();if(path.length===0&&generics.length===0){throw["Unexpected ",parserState.userQuery[parserState.pos]];}if(query.literalSearch&&parserState.totalElems-parserState.genericsElems>0){throw["Cannot have more than one element if you use quotes"];}const typeFilter=parserState.typeFilter;parserState.typeFilter=null;if(name.trim()==="!"){if(typeFilter!==null&&typeFilter!=="primitive"){throw["Invalid search type: primitive never type ","!"," and ",typeFilter," both specified",];}if(generics.length!==0){throw["Never type ","!"," does not accept generic parameters",];}const bindingName=parserState.isInBinding;parserState.isInBinding=null;return makePrimitiveElement("never",{bindingName});}const quadcolon=/::\s*::/.exec(path);if(path.startsWith("::")){throw["Paths cannot start with ","::"];}else if(quadcolon!==null){throw["Unexpected ",quadcolon[0]];}const pathSegments=path.split(/(?:::\s*)|(?:\s+(?:::\s*)?)/).map(x=>x.toLowerCase());if(pathSegments.length===0||(pathSegments.length===1&&pathSegments[0]==="")){if(generics.length>0||prevIs(parserState,">")){throw["Found generics without a path"];}else{throw["Unexpected ",parserState.userQuery[parserState.pos]];}}for(const[i,pathSegment]of pathSegments.entries()){if(pathSegment==="!"){if(i!==0){throw["Never type ","!"," is not associated item"];}pathSegments[i]="never";}}parserState.totalElems+=1;if(isInGenerics){parserState.genericsElems+=1;}const bindingName=parserState.isInBinding;parserState.isInBinding=null;const bindings=new Map();const pathLast=pathSegments[pathSegments.length-1];return{name:name.trim(),id:null,fullPath:pathSegments,pathWithoutLast:pathSegments.slice(0,pathSegments.length-1),pathLast,normalizedPathLast:pathLast.replace(/_/g,""),generics:generics.filter(gen=>{if(gen.bindingName!==null&&gen.bindingName.name!==null){if(gen.name!==null){gen.bindingName.generics.unshift(gen);}bindings.set(gen.bindingName.name.toLowerCase().replace(/_/g,""),gen.bindingName.generics,);return false;}return true;}),bindings,typeFilter,bindingName,};}function makePrimitiveElement(name,extra){return Object.assign({name,id:null,fullPath:[name],pathWithoutLast:[],pathLast:name,normalizedPathLast:name,generics:[],bindings:new Map(),typeFilter:"primitive",bindingName:null,},extra);}function getStringElem(query,parserState,isInGenerics){if(isInGenerics){throw["Unexpected ","\""," in generics"];}else if(query.literalSearch){throw["Cannot have more than one literal search element"];}else if(parserState.totalElems-parserState.genericsElems>0){throw["Cannot use literal search when there is more than one element"];}parserState.pos+=1;const start=parserState.pos;const end=getIdentEndPosition(parserState);if(parserState.pos>=parserState.length){throw["Unclosed ","\""];}else if(parserState.userQuery[end]!=="\""){throw["Unexpected ",parserState.userQuery[end]," in a string element"];}else if(start===end){throw["Cannot have empty string element"];}parserState.pos+=1;query.literalSearch=true;}function getIdentEndPosition(parserState){let afterIdent=consumeIdent(parserState);let end=parserState.pos;let macroExclamation=-1;while(parserState.pos0){throw["Unexpected ",c," after ",parserState.userQuery[parserState.pos-1]," (not a valid identifier)"];}else{throw["Unexpected ",c," (not a valid identifier)"];}parserState.pos+=1;afterIdent=consumeIdent(parserState);end=parserState.pos;}if(macroExclamation!==-1){if(parserState.typeFilter===null){parserState.typeFilter="macro";}else if(parserState.typeFilter!=="macro"){throw["Invalid search type: macro ","!"," and ",parserState.typeFilter," both specified",];}end=macroExclamation;}return end;}function isSpecialStartCharacter(c){return"<\"".indexOf(c)!==-1;}function isPathStart(parserState){return parserState.userQuery.slice(parserState.pos,parserState.pos+2)==="::";}function consumeIdent(parserState){REGEX_IDENT.lastIndex=parserState.pos;const match=parserState.userQuery.match(REGEX_IDENT);if(match){parserState.pos+=match[0].length;return true;}return false;}function isPathSeparator(c){return c===":"||c===" ";}function removeIdxListAsc(a,idxList){if(idxList.length===0){return;}let removed=0;let i=idxList[0];let nextToRemove=idxList[0];while(i>1];this.offset+=1;this.elemCount+=1;return sign?-value:value;}next(){const c=this.string.charCodeAt(this.offset);if(c>=48&&c<64){this.offset+=1;return this.backrefQueue[c-48];}if(c===96){this.offset+=1;return this.cons(0);}const result=this.cons(this.decode());this.backrefQueue.unshift(result);if(this.backrefQueue.length>16){this.backrefQueue.pop();}return result;}}const EMPTY_STRING_ARRAY=[];const EMPTY_GENERICS_ARRAY=[];const EMPTY_BINDINGS_ARRAY=[];const EMPTY_BINDINGS_MAP=new Map();function itemTypeFromName(typename){if(typename===null){return NO_TYPE_FILTER;}const index=itemTypes[typename];if(index===undefined){throw["Unknown type filter ",typename];}return index;}class DocSearch{constructor(rootPath,database){this.rootPath=rootPath;this.database=database;this.utf8decoder=new TextDecoder();this.TYPES_POOL=new Map();}getTypeNameIds(){if(this.typeNameIds){return this.typeNameIds;}const nn=this.database.getData("normalizedName");if(!nn){return{typeNameIdOfOutput:-1,typeNameIdOfFnPtr:-1,typeNameIdOfFn:-1,typeNameIdOfFnMut:-1,typeNameIdOfFnOnce:-1,typeNameIdOfArray:-1,typeNameIdOfSlice:-1,typeNameIdOfArrayOrSlice:-1,typeNameIdOfTuple:-1,typeNameIdOfUnit:-1,typeNameIdOfTupleOrUnit:-1,typeNameIdOfReference:-1,typeNameIdOfPointer:-1,typeNameIdOfHof:-1,typeNameIdOfNever:-1,};}return this.getTypeNameIdsAsync(nn);}async getTypeNameIdsAsync(nn){const[output,fn,fnMut,fnOnce,hof,array,slice,arrayOrSlice,tuple,unit,tupleOrUnit,reference,pointer,never,]=await Promise.all([nn.search("output"),nn.search("fn"),nn.search("fnmut"),nn.search("fnonce"),nn.search("->"),nn.search("array"),nn.search("slice"),nn.search("[]"),nn.search("tuple"),nn.search("unit"),nn.search("()"),nn.search("reference"),nn.search("pointer"),nn.search("never"),]);const first=async(trie,ty,modulePath)=>{if(trie){for(const id of trie.matches().entries()){const pathData=await this.getPathData(id);if(pathData&&pathData.ty===ty&&pathData.modulePath===modulePath){return id;}}}return-1;};const typeNameIdOfOutput=await first(output,itemTypes.associatedtype,"");const typeNameIdOfFnPtr=await first(fn,itemTypes.primitive,"");const typeNameIdOfFn=await first(fn,itemTypes.trait,"core::ops");const typeNameIdOfFnMut=await first(fnMut,itemTypes.trait,"core::ops");const typeNameIdOfFnOnce=await first(fnOnce,itemTypes.trait,"core::ops");const typeNameIdOfArray=await first(array,itemTypes.primitive,"");const typeNameIdOfSlice=await first(slice,itemTypes.primitive,"");const typeNameIdOfArrayOrSlice=await first(arrayOrSlice,itemTypes.primitive,"");const typeNameIdOfTuple=await first(tuple,itemTypes.primitive,"");const typeNameIdOfUnit=await first(unit,itemTypes.primitive,"");const typeNameIdOfTupleOrUnit=await first(tupleOrUnit,itemTypes.primitive,"");const typeNameIdOfReference=await first(reference,itemTypes.primitive,"");const typeNameIdOfPointer=await first(pointer,itemTypes.primitive,"");const typeNameIdOfHof=await first(hof,itemTypes.primitive,"");const typeNameIdOfNever=await first(never,itemTypes.primitive,"");this.typeNameIds={typeNameIdOfOutput,typeNameIdOfFnPtr,typeNameIdOfFn,typeNameIdOfFnMut,typeNameIdOfFnOnce,typeNameIdOfArray,typeNameIdOfSlice,typeNameIdOfArrayOrSlice,typeNameIdOfTuple,typeNameIdOfUnit,typeNameIdOfTupleOrUnit,typeNameIdOfReference,typeNameIdOfPointer,typeNameIdOfHof,typeNameIdOfNever,};return this.typeNameIds;}static parseQuery(userQuery){function newParsedQuery(userQuery){return{userQuery,elems:[],returned:[],foundElems:0,totalElems:0,literalSearch:false,hasReturnArrow:false,error:null,correction:null,proposeCorrectionFrom:null,proposeCorrectionTo:null,typeFingerprint:new Uint32Array(4),};}function parseInput(query,parserState){let foundStopChar=true;while(parserState.pos"){if(isReturnArrow(parserState)){query.hasReturnArrow=true;break;}throw["Unexpected ",c," (did you mean ","->","?)"];}else if(parserState.pos>0){throw["Unexpected ",c," after ",parserState.userQuery[parserState.pos-1]];}throw["Unexpected ",c];}else if(c===" "){skipWhitespace(parserState);continue;}if(!foundStopChar){let extra=EMPTY_STRING_ARRAY;if(isLastElemGeneric(query.elems,parserState)){extra=[" after ",">"];}else if(prevIs(parserState,"\"")){throw["Cannot have more than one element if you use quotes"];}if(parserState.typeFilter!==null){throw["Expected ",","," or ","->",...extra,", found ",c,];}throw["Expected ",",",", ",":"," or ","->",...extra,", found ",c,];}const before=query.elems.length;getFilteredNextElem(query,parserState,query.elems,false);if(query.elems.length===before){parserState.pos+=1;}foundStopChar=false;}if(parserState.typeFilter!==null){throw["Unexpected ",":"," (expected path after type filter ",parserState.typeFilter+":",")",];}while(parserState.pos{const ty=itemTypeFromName(elem.typeFilter);if(ty===itemTypes.generic&&elem.generics.length!==0){throw["Generic type parameter ",elem.name," does not accept generic parameters",];}for(const generic of elem.generics){checkTypeFilter(generic);}for(const constraints of elem.bindings.values()){for(const constraint of constraints){checkTypeFilter(constraint);}}};for(const elem of query.elems){checkTypeFilter(elem);}for(const elem of query.returned){checkTypeFilter(elem);}}catch(err){query=newParsedQuery(userQuery);if(Array.isArray(err)&&err.every(elem=>typeof elem==="string")){query.error=err;}else{throw err;}return query;}if(!query.literalSearch){query.literalSearch=parserState.totalElems>1;}query.foundElems=query.elems.length+query.returned.length;query.totalElems=parserState.totalElems;return query;}async getName(id){const ni=this.database.getData("name");if(!ni){return null;}const name=await ni.at(id);return name===undefined||name===null?null:this.utf8decoder.decode(name);}async getDesc(id){const di=this.database.getData("desc");if(!di){return null;}const desc=await di.at(id);return desc===undefined||desc===null?null:this.utf8decoder.decode(desc);}async getAliasTarget(id){const ai=this.database.getData("alias");if(!ai){return null;}const bytes=await ai.at(id);if(bytes===undefined||bytes===null||bytes.length===0){return null;}else{const encoded=this.utf8decoder.decode(bytes);const decoded=JSON.parse(encoded);return decoded;}}async getEntryData(id){const ei=this.database.getData("entry");if(!ei){return null;}const encoded=this.utf8decoder.decode(await ei.at(id));if(encoded===""||encoded===undefined||encoded===null){return null;}const raw=JSON.parse(encoded);const item={krate:raw[0],ty:raw[1],modulePath:raw[2]===0?null:raw[2]-1,exactModulePath:raw[3]===0?null:raw[3]-1,parent:raw[4]===0?null:raw[4]-1,traitParent:raw[5]===0?null:raw[5]-1,deprecated:raw[6]===1?true:false,unstable:raw[7]===1?true:false,associatedItemDisambiguatorOrExternCrateUrl:raw.length===8?null:raw[8],forceMacroHref:false,};if(item.ty===itemTypes.decl_macro_attribute||item.ty===itemTypes.decl_macro_derive){item.ty-=5;item.forceMacroHref=true;}return item;}async getPathData(id){const pi=this.database.getData("path");if(!pi){return null;}const encoded=this.utf8decoder.decode(await pi.at(id));if(encoded===""||encoded===undefined||encoded===null){return null;}const raw=JSON.parse(encoded);return{ty:raw[0],modulePath:raw[1],exactModulePath:raw[2]===0||raw[2]===undefined?raw[1]:raw[2],};}async getFunctionData(id){const fi=this.database.getData("function");if(!fi){return null;}const encoded=this.utf8decoder.decode(await fi.at(id));if(encoded===""||encoded===undefined||encoded===null){return null;}const raw=JSON.parse(encoded);const parser=new VlqHexDecoder(raw[0],async functionSearchType=>{if(typeof functionSearchType==="number"){return null;}const INPUTS_DATA=0;const OUTPUT_DATA=1;let inputs_;let output_;if(typeof functionSearchType[INPUTS_DATA]==="number"){inputs_=Promise.all([this.buildItemSearchType(functionSearchType[INPUTS_DATA]),]);}else{inputs_=this.buildItemSearchTypeAll(functionSearchType[INPUTS_DATA]);}if(functionSearchType.length>1){if(typeof functionSearchType[OUTPUT_DATA]==="number"){output_=Promise.all([this.buildItemSearchType(functionSearchType[OUTPUT_DATA]),]);}else{output_=this.buildItemSearchTypeAll(functionSearchType[OUTPUT_DATA]);}}else{output_=Promise.resolve(EMPTY_GENERICS_ARRAY);}const where_clause_=[];const l=functionSearchType.length;for(let i=2;i{const[name,path]=entry!==null&&entry[field]!==null?await Promise.all([this.getName(entry[field]),this.getPathData(entry[field])]):[null,null];if(name!==null&&path!==null){return{name,path};}return null;};const[moduleName,modulePathData,exactModuleName,exactModulePathData,parent,traitParent,crateOrNull,]=await Promise.all([entry&&entry.modulePath!==null?this.getName(entry.modulePath):null,entry&&entry.modulePath!==null?this.getPathData(entry.modulePath):null,entry&&entry.exactModulePath!==null?this.getName(entry.exactModulePath):null,entry&&entry.exactModulePath!==null?this.getPathData(entry.exactModulePath):null,buildParentLike("parent"),buildParentLike("traitParent"),entry?this.getName(entry.krate):"",]);const crate=crateOrNull===null?"":crateOrNull;const name=name_===null?"":name_;const normalizedName=(name.indexOf("_")===-1?name:name.replace(/_/g,"")).toLowerCase();const modulePath=modulePathData===null||moduleName===null?"":(modulePathData.modulePath===""?moduleName:`${modulePathData.modulePath}::${moduleName}`);return{id,crate,ty:entry?entry.ty:nonnull(path).ty,name,normalizedName,modulePath,exactModulePath:exactModulePathData===null||exactModuleName===null?modulePath:(exactModulePathData.exactModulePath===""?exactModuleName:`${exactModulePathData.exactModulePath}::${exactModuleName}`),entry,path,functionData,deprecated:entry?entry.deprecated:false,unstable:entry?entry.unstable:false,parent,traitParent,};}async buildItemSearchTypeAll(types){return types&&types.length>0?await Promise.all(types.map(type=>this.buildItemSearchType(type))):EMPTY_GENERICS_ARRAY;}async buildItemSearchType(type){const PATH_INDEX_DATA=0;const GENERICS_DATA=1;const BINDINGS_DATA=2;let id,generics;let bindings;if(typeof type==="number"){id=type;generics=EMPTY_GENERICS_ARRAY;bindings=EMPTY_BINDINGS_MAP;}else{id=type[PATH_INDEX_DATA];generics=await this.buildItemSearchTypeAll(type[GENERICS_DATA]);if(type[BINDINGS_DATA]&&type[BINDINGS_DATA].length>0){bindings=new Map((await Promise.all(type[BINDINGS_DATA].map(async binding=>{const[assocType,constraints]=binding;const[k,v]=await Promise.all([this.buildItemSearchType(assocType).then(t=>t.id),this.buildItemSearchTypeAll(constraints),]);return k===null?EMPTY_BINDINGS_ARRAY:[[k,v]];},))).flat());}else{bindings=EMPTY_BINDINGS_MAP;}}let result;if(id<0){result={id,name:"",ty:itemTypes.generic,path:null,exactPath:null,generics,bindings,unboxFlag:true,};}else if(id===0){result={id:null,name:"",ty:itemTypes.generic,path:null,exactPath:null,generics,bindings,unboxFlag:true,};}else{const[name,path,type]=await Promise.all([this.getName(id-1),this.getPathData(id-1),this.getTypeData(id-1),]);if(path===undefined||path===null||type===undefined||type===null){return{id:null,name:"",ty:itemTypes.generic,path:null,exactPath:null,generics,bindings,unboxFlag:true,};}result={id:id-1,name,ty:path.ty,path:path.modulePath,exactPath:path.exactModulePath===null?path.modulePath:path.exactModulePath,generics,bindings,unboxFlag:type.searchUnbox,};}const cr=this.TYPES_POOL.get(result.id);if(cr){if(cr.generics.length===result.generics.length&&cr.generics!==result.generics&&cr.generics.every((x,i)=>result.generics[i]===x)){result.generics=cr.generics;}if(cr.bindings.size===result.bindings.size&&cr.bindings!==result.bindings){let ok=true;for(const[k,v]of cr.bindings.entries()){const v2=result.bindings.get(k);if(!v2){ok=false;break;}if(v!==v2&&v.length===v2.length&&v.every((x,i)=>v2[i]===x)){result.bindings.set(k,v);}else if(v!==v2){ok=false;break;}}if(ok){result.bindings=cr.bindings;}}if(cr.ty===result.ty&&cr.path===result.path&&cr.bindings===result.bindings&&cr.generics===result.generics&&cr.ty===result.ty&&cr.name===result.name&&cr.unboxFlag===result.unboxFlag){return cr;}}this.TYPES_POOL.set(result.id,result);return result;}async execQuery(parsedQuery,filterCrates,currentCrate){const queryLen=parsedQuery.elems.reduce((acc,next)=>acc+next.pathLast.length,0)+parsedQuery.returned.reduce((acc,next)=>acc+next.pathLast.length,0);const maxEditDistance=Math.floor(queryLen/3);const buildHrefAndPath=item=>{let displayPath;let href;let traitPath=null;const type=item.entry&&item.entry.forceMacroHref?"macro":itemTypesName[item.ty];const name=item.name;let path=item.modulePath;let exactPath=item.exactModulePath;if(type==="mod"){displayPath=path+"::";href=this.rootPath+path.replace(/::/g,"/")+"/"+name+"/index.html";}else if(type==="import"){displayPath=item.modulePath+"::";href=this.rootPath+item.modulePath.replace(/::/g,"/")+"/index.html#reexport."+name;}else if(type==="primitive"||type==="keyword"||type==="attribute"){displayPath="";exactPath="";href=this.rootPath+path.replace(/::/g,"/")+"/"+type+"."+name+".html";}else if(type==="externcrate"){displayPath="";let base=this.rootPath+name;if(item.entry&&item.entry.associatedItemDisambiguatorOrExternCrateUrl){base=item.entry.associatedItemDisambiguatorOrExternCrateUrl;}href=base+"/index.html";}else if(item.parent){const myparent=item.parent;let anchor=type+"."+name;const parentType=itemTypesName[myparent.path.ty];let pageType=parentType;let pageName=myparent.name;exactPath=`${myparent.path.exactModulePath}::${myparent.name}`;if(parentType==="primitive"){displayPath=myparent.name+"::";exactPath=myparent.name;}else if(type==="structfield"&&parentType==="variant"){const enumNameIdx=item.modulePath.lastIndexOf("::");const enumName=item.modulePath.substr(enumNameIdx+2);path=item.modulePath.substr(0,enumNameIdx);displayPath=path+"::"+enumName+"::"+myparent.name+"::";anchor="variant."+myparent.name+".field."+name;pageType="enum";pageName=enumName;}else{displayPath=path+"::"+myparent.name+"::";}if(item.entry&&item.entry.associatedItemDisambiguatorOrExternCrateUrl!==null){anchor=item.entry.associatedItemDisambiguatorOrExternCrateUrl+"/"+anchor;}href=this.rootPath+path.replace(/::/g,"/")+"/"+pageType+"."+pageName+".html#"+anchor;}else{displayPath=item.modulePath+"::";href=this.rootPath+item.modulePath.replace(/::/g,"/")+"/"+type+"."+name+".html";}if(item.traitParent){const tparent=item.traitParent;traitPath=`${tparent.path.exactModulePath}::${tparent.name}::${name}`;}return[displayPath,href,`${exactPath}::${name}`,traitPath];};function pathSplitter(path){const tmp=""+path.replace(/::/g,"::");if(tmp.endsWith("")){return tmp.slice(0,tmp.length-6);}return tmp;}const formatDisplayTypeSignature=async(obj,typeInfo,elems,returned)=>{const typeNameIds=await this.getTypeNameIds();const objType=obj.type;if(!objType){return{type:[],mappedNames:new Map(),whereClause:new Map()};}let fnInputs=null;let fnOutput=null;let mgens=null;if(typeInfo!=="elems"&&typeInfo!=="returned"){fnInputs=unifyFunctionTypes(objType.inputs,elems,objType.where_clause,null,mgensScratch=>{fnOutput=unifyFunctionTypes(objType.output,returned,objType.where_clause,mgensScratch,mgensOut=>{mgens=mgensOut;return true;},0,typeNameIds,);return!!fnOutput;},0,typeNameIds,);}else{const highlighted=unifyFunctionTypes(typeInfo==="elems"?objType.inputs:objType.output,typeInfo==="elems"?elems:returned,objType.where_clause,null,mgensOut=>{mgens=mgensOut;return true;},0,typeNameIds,);if(typeInfo==="elems"){fnInputs=highlighted;}else{fnOutput=highlighted;}}if(!fnInputs){fnInputs=objType.inputs;}if(!fnOutput){fnOutput=objType.output;}const mappedNames=new Map();const whereClause=new Map();const fnParamNames=obj.paramNames||[];const queryParamNames=[];const remapQuery=queryElem=>{if(queryElem.id!==null&&queryElem.id<0){queryParamNames[-1-queryElem.id]=queryElem.name;}if(queryElem.generics.length>0){queryElem.generics.forEach(remapQuery);}if(queryElem.bindings.size>0){[...queryElem.bindings.values()].flat().forEach(remapQuery);}};elems.forEach(remapQuery);returned.forEach(remapQuery);const pushText=(fnType,result)=>{if(!!(result.length%2)===!!fnType.highlighted){result.push("");}else if(result.length===0&&!!fnType.highlighted){result.push("");result.push("");}result[result.length-1]+=fnType.name;};const writeHof=async(fnType,result)=>{const hofOutput=fnType.bindings.get(typeNameIds.typeNameIdOfOutput)||[];const hofInputs=fnType.generics;pushText(fnType,result);pushText({name:" (",highlighted:false},result);let needsComma=false;for(const fnType of hofInputs){if(needsComma){pushText({name:", ",highlighted:false},result);}needsComma=true;await writeFn(fnType,result);}pushText({name:hofOutput.length===0?")":") -> ",highlighted:false,},result);if(hofOutput.length>1){pushText({name:"(",highlighted:false},result);}needsComma=false;for(const fnType of hofOutput){if(needsComma){pushText({name:", ",highlighted:false},result);}needsComma=true;await writeFn(fnType,result);}if(hofOutput.length>1){pushText({name:")",highlighted:false},result);}};const writeSpecialPrimitive=async(fnType,result)=>{if(fnType.id===typeNameIds.typeNameIdOfArray||fnType.id===typeNameIds.typeNameIdOfSlice||fnType.id===typeNameIds.typeNameIdOfTuple||fnType.id===typeNameIds.typeNameIdOfUnit){const[ob,sb]=fnType.id===typeNameIds.typeNameIdOfArray||fnType.id===typeNameIds.typeNameIdOfSlice?["[","]"]:["(",")"];pushText({name:ob,highlighted:fnType.highlighted},result);await onEachBtwnAsync(fnType.generics,nested=>writeFn(nested,result),()=>pushText({name:", ",highlighted:false},result),);pushText({name:sb,highlighted:fnType.highlighted},result);return true;}else if(fnType.id===typeNameIds.typeNameIdOfReference){pushText({name:"&",highlighted:fnType.highlighted},result);let prevHighlighted=false;await onEachBtwnAsync(fnType.generics,async value=>{prevHighlighted=!!value.highlighted;await writeFn(value,result);},value=>pushText({name:" ",highlighted:prevHighlighted&&value.highlighted,},result),);return true;}else if(fnType.id===typeNameIds.typeNameIdOfPointer){pushText({name:"*",highlighted:fnType.highlighted},result);if(fnType.generics.length<2){pushText({name:"const ",highlighted:fnType.highlighted},result);}let prevHighlighted=false;await onEachBtwnAsync(fnType.generics,async value=>{prevHighlighted=!!value.highlighted;await writeFn(value,result);},value=>pushText({name:" ",highlighted:prevHighlighted&&value.highlighted,},result),);return true;}else if(fnType.id===typeNameIds.typeNameIdOfFn||fnType.id===typeNameIds.typeNameIdOfFnMut||fnType.id===typeNameIds.typeNameIdOfFnOnce||fnType.id===typeNameIds.typeNameIdOfFnPtr){await writeHof(fnType,result);return true;}else if(fnType.id===typeNameIds.typeNameIdOfNever){pushText({name:"!",highlighted:fnType.highlighted},result);return true;}return false;};const writeFn=async(fnType,result)=>{if(fnType.id!==null&&fnType.id<0){if(fnParamNames[-1-fnType.id]===""){const generics=fnType.generics.length>0?fnType.generics:objType.where_clause[-1-fnType.id];for(const nested of generics){await writeFn(nested,result);}return;}else if(mgens){for(const[queryId,fnId]of mgens){if(fnId===fnType.id){mappedNames.set(queryParamNames[-1-queryId],fnParamNames[-1-fnType.id],);}}}pushText({name:fnParamNames[-1-fnType.id],highlighted:!!fnType.highlighted,},result);const where=[];await onEachBtwnAsync(fnType.generics,nested=>writeFn(nested,where),()=>pushText({name:" + ",highlighted:false},where),);if(where.length>0){whereClause.set(fnParamNames[-1-fnType.id],where);}}else{if(fnType.ty===itemTypes.primitive){if(await writeSpecialPrimitive(fnType,result)){return;}}else if(fnType.ty===itemTypes.trait&&(fnType.id===typeNameIds.typeNameIdOfFn||fnType.id===typeNameIds.typeNameIdOfFnMut||fnType.id===typeNameIds.typeNameIdOfFnOnce||fnType.id===typeNameIds.typeNameIdOfFnPtr)){await writeHof(fnType,result);return;}else if(fnType.name===""&&fnType.bindings.size===0&&fnType.generics.length!==0){pushText({name:"impl ",highlighted:false},result);if(fnType.generics.length>1){pushText({name:"(",highlighted:false},result);}await onEachBtwnAsync(fnType.generics,value=>writeFn(value,result),()=>pushText({name:", ",highlighted:false},result),);if(fnType.generics.length>1){pushText({name:")",highlighted:false},result);}return;}pushText(fnType,result);let hasBindings=false;if(fnType.bindings.size>0){await onEachBtwnAsync(await Promise.all([...fnType.bindings.entries()].map(async([key,values])=>[await this.getName(key),values],)),async([name,values])=>{if(values.length===1&&values[0].id<0&&`${fnType.name}::${name}`===fnParamNames[-1-values[0].id]){for(const value of values){await writeFn(value,[]);}return true;}if(!hasBindings){hasBindings=true;pushText({name:"<",highlighted:false},result);}pushText({name,highlighted:false},result);pushText({name:values.length!==1?"=(":"=",highlighted:false,},result);await onEachBtwnAsync(values||[],value=>writeFn(value,result),()=>pushText({name:" + ",highlighted:false},result),);if(values.length!==1){pushText({name:")",highlighted:false},result);}},()=>pushText({name:", ",highlighted:false},result),);}if(fnType.generics.length>0){pushText({name:hasBindings?", ":"<",highlighted:false},result);}await onEachBtwnAsync(fnType.generics,value=>writeFn(value,result),()=>pushText({name:", ",highlighted:false},result),);if(hasBindings||fnType.generics.length>0){pushText({name:">",highlighted:false},result);}}};const type=[];await onEachBtwnAsync(fnInputs,fnType=>writeFn(fnType,type),()=>pushText({name:", ",highlighted:false},type),);pushText({name:" -> ",highlighted:false},type);await onEachBtwnAsync(fnOutput,fnType=>writeFn(fnType,type),()=>pushText({name:", ",highlighted:false},type),);return{type,mappedNames,whereClause};};const transformResults=(results,typeInfo,duplicates)=>{const out=[];const traitImplIdxMap=new Map();for(const result of results){const item=result.item;if(item.id!==-1){const res=buildHrefAndPath(item);const obj=Object.assign({parent:item.parent?{path:item.parent.path.modulePath,exactPath:item.parent.path.exactModulePath||item.parent.path.modulePath,name:item.parent.name,ty:item.parent.path.ty,}:undefined,type:item.functionData&&item.functionData.functionSignature?item.functionData.functionSignature:undefined,paramNames:item.functionData&&item.functionData.paramNames?item.functionData.paramNames:undefined,dist:result.dist,path_dist:result.path_dist,index:result.index,desc:this.getDesc(result.id),item,displayPath:pathSplitter(res[0]),fullPath:"",traitPath:null,href:"",displayTypeSignature:null,},result);let ty=obj.item.ty;if(ty===itemTypes.tymethod){ty=itemTypes.method;}obj.fullPath=res[2]+"|"+ty;if(res[3]){obj.traitPath=res[3]+"|"+obj.item.ty;}if(duplicates.has(obj.fullPath)){continue;}if(obj.traitPath&&duplicates.has(obj.traitPath)){continue;}if(obj.item.ty===itemTypes.import&&duplicates.has(res[2])){continue;}if(duplicates.has(res[2]+"|"+itemTypes.import)){continue;}duplicates.add(obj.fullPath);duplicates.add(res[2]);if(typeInfo!==null){obj.displayTypeSignature=formatDisplayTypeSignature(obj,typeInfo,result.elems,result.returned,);}obj.href=res[1];if(obj.traitPath){let list=traitImplIdxMap.get(obj.traitPath);if(list===undefined){list=[];}list.push(out.length);traitImplIdxMap.set(obj.traitPath,list);}else{const toRemoveList=traitImplIdxMap.get(obj.fullPath);if(toRemoveList){removeIdxListAsc(out,toRemoveList);}traitImplIdxMap.delete(obj.fullPath);}out.push(obj);if(out.length>=MAX_RESULTS){break;}}}return out;};const sortAndTransformResults=async function*(results,typeInfo,preferredCrate,duplicates){const userQuery=parsedQuery.userQuery;const normalizedUserQuery=parsedQuery.userQuery.toLowerCase();const isMixedCase=normalizedUserQuery!==userQuery;const result_list=[];for(const result of results.values()){if(!result){continue;}const item=result.item;if(filterCrates!==null&&item.crate!==filterCrates){continue;}if(item){result_list.push(result);}else{continue;}}result_list.sort((aaa,bbb)=>{const aai=aaa.item;const bbi=bbb.item;const ap=aai.modulePath!==undefined?aai.modulePath:"";const bp=bbi.modulePath!==undefined?bbi.modulePath:"";let a;let b;if(typeInfo===null){if(isMixedCase){a=Number(aai.name!==userQuery);b=Number(bbi.name!==userQuery);if(a!==b){return a-b;}}a=Number(aai.normalizedName!==normalizedUserQuery);b=Number(bbi.normalizedName!==normalizedUserQuery);if(a!==b){return a-b;}a=Number(aaa.index<0);b=Number(bbb.index<0);if(a!==b){return a-b;}if(parsedQuery.elems[0]&&parsedQuery.elems[0].pathWithoutLast.length!==0){a=Number(aaa.path_dist);b=Number(bbb.path_dist);if(a!==b){return a-b;}a=ap.length+(aai.parent?aai.parent.name.length+2:0);b=bp.length+(bbi.parent?bbi.parent.name.length+2:0);if(a!==b){return a-b;}}}a=Number(aaa.index);b=Number(bbb.index);if(a!==b){return a-b;}a=Number(aaa.dist);b=Number(bbb.dist);if(a!==b){return a-b;}a=Number(aaa.is_alias);b=Number(bbb.is_alias);if(a!==b){return a-b;}a=Number(aai.deprecated);b=Number(bbi.deprecated);if(a!==b){return a-b;}a=Number(aai.unstable);b=Number(bbi.unstable);if(a!==b){return a-b;}a=Number(aai.crate!==preferredCrate);b=Number(bbi.crate!==preferredCrate);if(a!==b){return a-b;}a=Number(aai.normalizedName.length);b=Number(bbi.normalizedName.length);if(a!==b){return a-b;}const aw=aai.normalizedName;const bw=bbi.normalizedName;if(aw!==bw){return(aw>bw?+1:-1);}const di=this.database.getData("desc");if(di){a=Number(di.isEmpty(aaa.id));b=Number(di.isEmpty(bbb.id));if(a!==b){return a-b;}}a=Number(aai.ty);b=Number(bbi.ty);if(a!==b){return a-b;}if(ap!==bp){return(ap>bp?+1:-1);}return 0;});const transformed_result_list=transformResults(result_list,typeInfo,duplicates);yield*transformed_result_list;return transformed_result_list.length;}.bind(this);function unifyFunctionTypes(fnTypesIn,queryElems,whereClause,mgensIn,solutionCb,unboxingDepth,typeNameIds,){if(unboxingDepth>=UNBOXING_LIMIT){return null;}const mgens=mgensIn===null?null:new Map(mgensIn);if(queryElems.length===0){return solutionCb(mgens)?fnTypesIn:null;}if(!fnTypesIn||fnTypesIn.length===0){return null;}const ql=queryElems.length;const fl=fnTypesIn.length;if(ql===1&&queryElems[0].generics.length===0&&queryElems[0].bindings.size===0){const queryElem=queryElems[0];for(const[i,fnType]of fnTypesIn.entries()){if(!unifyFunctionTypeIsMatchCandidate(fnType,queryElem,mgens,typeNameIds,)){continue;}if(fnType.id!==null&&fnType.id<0&&queryElem.id!==null&&queryElem.id<0){if(mgens&&mgens.has(queryElem.id)&&mgens.get(queryElem.id)!==fnType.id){continue;}const mgensScratch=new Map(mgens);mgensScratch.set(queryElem.id,fnType.id);if(!solutionCb||solutionCb(mgensScratch)){const highlighted=[...fnTypesIn];highlighted[i]=Object.assign({highlighted:true,},fnType,{generics:whereClause[-1-fnType.id],});return highlighted;}}else if(solutionCb(mgens?new Map(mgens):null)){const highlighted=[...fnTypesIn];highlighted[i]=Object.assign({highlighted:true,},fnType,{generics:unifyGenericTypes(fnType.generics,queryElem.generics,whereClause,mgens?new Map(mgens):null,solutionCb,unboxingDepth,typeNameIds,)||fnType.generics,});return highlighted;}}for(const[i,fnType]of fnTypesIn.entries()){if(!unifyFunctionTypeIsUnboxCandidate(fnType,queryElem,whereClause,mgens,unboxingDepth+1,typeNameIds,)){continue;}if(fnType.id!==null&&fnType.id<0){const highlightedGenerics=unifyFunctionTypes(whereClause[(-fnType.id)-1],queryElems,whereClause,mgens,solutionCb,unboxingDepth+1,typeNameIds,);if(highlightedGenerics){const highlighted=[...fnTypesIn];highlighted[i]=Object.assign({highlighted:true,},fnType,{generics:highlightedGenerics,});return highlighted;}}else{const highlightedGenerics=unifyFunctionTypes([...Array.from(fnType.bindings.values()).flat(),...fnType.generics],queryElems,whereClause,mgens?new Map(mgens):null,solutionCb,unboxingDepth+1,typeNameIds,);if(highlightedGenerics){const highlighted=[...fnTypesIn];highlighted[i]=Object.assign({},fnType,{generics:highlightedGenerics,bindings:new Map([...fnType.bindings.entries()].map(([k,v])=>{return[k,highlightedGenerics.splice(0,v.length)];})),});return highlighted;}}}return null;}const fnTypes=fnTypesIn.slice();const flast=fl-1;const qlast=ql-1;const queryElem=queryElems[qlast];let queryElemsTmp=null;for(let i=flast;i>=0;i-=1){const fnType=fnTypes[i];if(!unifyFunctionTypeIsMatchCandidate(fnType,queryElem,mgens,typeNameIds,)){continue;}let mgensScratch;if(fnType.id!==null&&queryElem.id!==null&&fnType.id<0){mgensScratch=new Map(mgens);if(mgensScratch.has(queryElem.id)&&mgensScratch.get(queryElem.id)!==fnType.id){continue;}mgensScratch.set(queryElem.id,fnType.id);}else{mgensScratch=mgens;}fnTypes[i]=fnTypes[flast];fnTypes.length=flast;if(!queryElemsTmp){queryElemsTmp=queryElems.slice(0,qlast);}let unifiedGenerics=[];let unifiedGenericsMgens=null;const passesUnification=unifyFunctionTypes(fnTypes,queryElemsTmp,whereClause,mgensScratch,mgensScratch=>{if(fnType.generics.length===0&&queryElem.generics.length===0&&fnType.bindings.size===0&&queryElem.bindings.size===0){return solutionCb(mgensScratch);}const solution=unifyFunctionTypeCheckBindings(fnType,queryElem,whereClause,mgensScratch,unboxingDepth,typeNameIds,);if(!solution){return false;}const simplifiedGenerics=solution.simplifiedGenerics;for(const simplifiedMgens of solution.mgens){unifiedGenerics=unifyGenericTypes(simplifiedGenerics,queryElem.generics,whereClause,simplifiedMgens,solutionCb,unboxingDepth,typeNameIds,);if(unifiedGenerics!==null){unifiedGenericsMgens=simplifiedMgens;return true;}}return false;},unboxingDepth,typeNameIds,);if(passesUnification){passesUnification.length=fl;passesUnification[flast]=passesUnification[i];passesUnification[i]=Object.assign({},fnType,{highlighted:true,generics:unifiedGenerics,bindings:new Map([...fnType.bindings.entries()].map(([k,v])=>{return[k,queryElem.bindings.has(k)?unifyFunctionTypes(v,queryElem.bindings.get(k),whereClause,unifiedGenericsMgens,solutionCb,unboxingDepth,typeNameIds,):unifiedGenerics.splice(0,v.length)];})),});return passesUnification;}fnTypes[flast]=fnTypes[i];fnTypes[i]=fnType;fnTypes.length=fl;}for(let i=flast;i>=0;i-=1){const fnType=fnTypes[i];if(!unifyFunctionTypeIsUnboxCandidate(fnType,queryElem,whereClause,mgens,unboxingDepth+1,typeNameIds,)){continue;}const generics=fnType.id!==null&&fnType.id<0?whereClause[(-fnType.id)-1]:fnType.generics;const bindings=fnType.bindings?Array.from(fnType.bindings.values()).flat():[];const passesUnification=unifyFunctionTypes(fnTypes.toSpliced(i,1,...bindings,...generics),queryElems,whereClause,mgens,solutionCb,unboxingDepth+1,typeNameIds,);if(passesUnification){const highlightedGenerics=passesUnification.slice(i,i+generics.length+bindings.length,);const highlightedFnType=Object.assign({},fnType,{generics:highlightedGenerics,bindings:new Map([...fnType.bindings.entries()].map(([k,v])=>{return[k,highlightedGenerics.splice(0,v.length)];})),});return passesUnification.toSpliced(i,generics.length+bindings.length,highlightedFnType,);}}return null;}function unifyGenericTypes(fnTypesIn,queryElems,whereClause,mgensIn,solutionCb,unboxingDepth,typeNameIds,){if(unboxingDepth>=UNBOXING_LIMIT){return null;}const mgens=mgensIn===null?null:new Map(mgensIn);if(queryElems.length===0){return solutionCb(mgens)?fnTypesIn:null;}if(!fnTypesIn||fnTypesIn.length===0){return null;}const fnType=fnTypesIn[0];const queryElem=queryElems[0];if(unifyFunctionTypeIsMatchCandidate(fnType,queryElem,mgens,typeNameIds,)){if(fnType.id!==null&&fnType.id<0&&queryElem.id!==null&&queryElem.id<0){if(!mgens||!mgens.has(queryElem.id)||mgens.get(queryElem.id)===fnType.id){const mgensScratch=new Map(mgens);mgensScratch.set(queryElem.id,fnType.id);const fnTypesRemaining=unifyGenericTypes(fnTypesIn.slice(1),queryElems.slice(1),whereClause,mgensScratch,solutionCb,unboxingDepth,typeNameIds,);if(fnTypesRemaining){const highlighted=[fnType,...fnTypesRemaining];highlighted[0]=Object.assign({highlighted:true,},fnType,{generics:whereClause[-1-fnType.id],});return highlighted;}}}else{let unifiedGenerics;const fnTypesRemaining=unifyGenericTypes(fnTypesIn.slice(1),queryElems.slice(1),whereClause,mgens,mgensScratch=>{const solution=unifyFunctionTypeCheckBindings(fnType,queryElem,whereClause,mgensScratch,unboxingDepth,typeNameIds,);if(!solution){return false;}const simplifiedGenerics=solution.simplifiedGenerics;for(const simplifiedMgens of solution.mgens){unifiedGenerics=unifyGenericTypes(simplifiedGenerics,queryElem.generics,whereClause,simplifiedMgens,solutionCb,unboxingDepth,typeNameIds,);if(unifiedGenerics!==null){return true;}}},unboxingDepth,typeNameIds,);if(fnTypesRemaining){const highlighted=[fnType,...fnTypesRemaining];highlighted[0]=Object.assign({highlighted:true,},fnType,{generics:unifiedGenerics||fnType.generics,});return highlighted;}}}if(unifyFunctionTypeIsUnboxCandidate(fnType,queryElem,whereClause,mgens,unboxingDepth+1,typeNameIds,)){let highlightedRemaining;if(fnType.id!==null&&fnType.id<0){const highlightedGenerics=unifyFunctionTypes(whereClause[(-fnType.id)-1],[queryElem],whereClause,mgens,mgensScratch=>{const hl=unifyGenericTypes(fnTypesIn.slice(1),queryElems.slice(1),whereClause,mgensScratch,solutionCb,unboxingDepth,typeNameIds,);if(hl){highlightedRemaining=hl;}return hl;},unboxingDepth+1,typeNameIds,);if(highlightedGenerics){return[Object.assign({highlighted:true,},fnType,{generics:highlightedGenerics,}),...highlightedRemaining];}}else{const highlightedGenerics=unifyGenericTypes([...Array.from(fnType.bindings.values()).flat(),...fnType.generics,],[queryElem],whereClause,mgens,mgensScratch=>{const hl=unifyGenericTypes(fnTypesIn.slice(1),queryElems.slice(1),whereClause,mgensScratch,solutionCb,unboxingDepth,typeNameIds,);if(hl){highlightedRemaining=hl;}return hl;},unboxingDepth+1,typeNameIds,);if(highlightedGenerics){return[Object.assign({},fnType,{generics:highlightedGenerics,bindings:new Map([...fnType.bindings.entries()].map(([k,v])=>{return[k,highlightedGenerics.splice(0,v.length)];})),}),...highlightedRemaining];}}}return null;}const unifyFunctionTypeIsMatchCandidate=(fnType,queryElem,mgensIn,typeNameIds)=>{if(!typePassesFilter(queryElem.typeFilter,fnType.ty)){return false;}if(fnType.id!==null&&fnType.id<0&&queryElem.id!==null&&queryElem.id<0){if(mgensIn&&mgensIn.has(queryElem.id)&&mgensIn.get(queryElem.id)!==fnType.id){return false;}return true;}else{if(queryElem.id===typeNameIds.typeNameIdOfArrayOrSlice&&(fnType.id===typeNameIds.typeNameIdOfSlice||fnType.id===typeNameIds.typeNameIdOfArray)){}else if(queryElem.id===typeNameIds.typeNameIdOfTupleOrUnit&&(fnType.id===typeNameIds.typeNameIdOfTuple||fnType.id===typeNameIds.typeNameIdOfUnit)){}else if(queryElem.id===typeNameIds.typeNameIdOfHof&&(fnType.id===typeNameIds.typeNameIdOfFn||fnType.id===typeNameIds.typeNameIdOfFnMut||fnType.id===typeNameIds.typeNameIdOfFnOnce||fnType.id===typeNameIds.typeNameIdOfFnPtr)){}else if(fnType.id!==queryElem.id||queryElem.id===null){return false;}if((fnType.generics.length+fnType.bindings.size)===0&&queryElem.generics.length!==0){return false;}if(fnType.bindings.size0){const fnTypePath=fnType.path!==undefined&&fnType.path!==null?fnType.path.split("::"):[];if(queryElemPathLength>fnTypePath.length){return false;}let i=0;for(const path of fnTypePath){if(path===queryElem.pathWithoutLast[i]){i+=1;if(i>=queryElemPathLength){break;}}}if(i0){let mgensSolutionSet=[mgensIn];for(const[name,constraints]of queryElem.bindings.entries()){if(mgensSolutionSet.length===0){return false;}if(!fnType.bindings.has(name)){return false;}const fnTypeBindings=fnType.bindings.get(name);mgensSolutionSet=mgensSolutionSet.flatMap(mgens=>{const newSolutions=[];unifyFunctionTypes(fnTypeBindings,constraints,whereClause,mgens,newMgens=>{newSolutions.push(newMgens);return false;},unboxingDepth,typeNameIds,);return newSolutions;});}if(mgensSolutionSet.length===0){return false;}const binds=Array.from(fnType.bindings.entries()).flatMap(entry=>{const[name,constraints]=entry;if(queryElem.bindings.has(name)){return[];}else{return constraints;}});if(simplifiedGenerics.length>0){simplifiedGenerics=[...binds,...simplifiedGenerics];}else{simplifiedGenerics=binds;}return{simplifiedGenerics,mgens:mgensSolutionSet};}return{simplifiedGenerics,mgens:[mgensIn]};}function unifyFunctionTypeIsUnboxCandidate(fnType,queryElem,whereClause,mgens,unboxingDepth,typeNameIds,){if(unboxingDepth>=UNBOXING_LIMIT){return false;}if(fnType.id!==null&&fnType.id<0){if(!whereClause){return false;}return checkIfInList(whereClause[(-fnType.id)-1],queryElem,whereClause,mgens,unboxingDepth,typeNameIds,);}else if(fnType.unboxFlag&&(fnType.generics.length>0||fnType.bindings.size>0)){const simplifiedGenerics=[...fnType.generics,...Array.from(fnType.bindings.values()).flat(),];return checkIfInList(simplifiedGenerics,queryElem,whereClause,mgens,unboxingDepth,typeNameIds,);}return false;}function containsTypeFromQuery(elems,list,where_clause,typeNameIds){if(!list)return false;for(const ty of elems){if(ty.id!==null&&ty.id<0){continue;}if(checkIfInList(list,ty,where_clause,null,0,typeNameIds)){return true;}}return false;}function checkIfInList(list,elem,whereClause,mgens,unboxingDepth,typeNameIds){for(const entry of list){if(checkType(entry,elem,whereClause,mgens,unboxingDepth,typeNameIds)){return true;}}return false;}const checkType=(row,elem,whereClause,mgens,unboxingDepth,typeNameIds)=>{if(unboxingDepth>=UNBOXING_LIMIT){return false;}if(row.id!==null&&elem.id!==null&&row.id>0&&elem.id>0&&elem.pathWithoutLast.length===0&&row.generics.length===0&&elem.generics.length===0&&row.bindings.size===0&&elem.bindings.size===0&&elem.id!==typeNameIds.typeNameIdOfArrayOrSlice&&elem.id!==typeNameIds.typeNameIdOfHof&&elem.id!==typeNameIds.typeNameIdOfTupleOrUnit){return row.id===elem.id&&typePassesFilter(elem.typeFilter,row.ty);}else{return unifyFunctionTypes([row],[elem],whereClause,mgens,()=>true,unboxingDepth,typeNameIds,);}};const checkTypeMgensForConflict=mgens=>{if(!mgens){return true;}const fnTypes=new Set();for(const[_qid,fid]of mgens){if(fnTypes.has(fid)){return false;}fnTypes.add(fid);}return true;};function checkPath(contains,path){if(contains.length===0){return 0;}const maxPathEditDistance=parsedQuery.literalSearch?0:Math.floor(contains.reduce((acc,next)=>acc+next.length,0)/3,);let ret_dist=maxPathEditDistance+1;const length=path.length;const clength=contains.length;pathiter:for(let i=length-clength;i>=0;i-=1){let dist_total=0;for(let x=0;xmaxPathEditDistance){continue pathiter;}dist_total+=dist;}}ret_dist=Math.min(ret_dist,Math.round(dist_total/clength));}return ret_dist>maxPathEditDistance?null:ret_dist;}function checkRowPath(contains,row){if(contains.length===0){return 0;}const path=row.modulePath.split("::");if(row.parent&&row.parent.name){path.push(row.parent.name.toLowerCase());}return checkPath(contains,path);}function typePassesFilter(filter,type){if(filter<=NO_TYPE_FILTER||filter===type)return true;return filter===itemParents.get(type);}const innerRunNameQuery=async function*(currentCrate){const index=this.database.getData("normalizedName");if(!index){return;}const idDuplicates=new Set();const pathDuplicates=new Set();let count=0;const prefixResults=[];const normalizedUserQuery=parsedQuery.userQuery.replace(/[_"]/g,"").toLowerCase();const handleAlias=async(name,alias,dist,index)=>{const item=nonnull(await this.getRow(alias,false));const path_dist=name.includes(" ")||parsedQuery.elems.length===0?0:checkRowPath(parsedQuery.elems[0].pathWithoutLast,item);if(path_dist===null){return null;}return{id:alias,dist,path_dist,index,alias:name,is_alias:true,elems:[],returned:[],item,};};const flush=async function*(data){const satr=sortAndTransformResults(await Promise.all(data),null,currentCrate,pathDuplicates,);data.length=0;for await(const processed of satr){yield processed;count+=1;if((count&0x7F)===0){await yieldToBrowser();}if(count>=MAX_RESULTS){return true;}}return false;};const aliasResults=await index.search(normalizedUserQuery);if(aliasResults){for(const id of aliasResults.matches().entries()){const[name,alias]=await Promise.all([this.getName(id),this.getAliasTarget(id),]);if(name!==null&&alias!==null&&!idDuplicates.has(id)&&name.replace(/[_"]/g,"").toLowerCase()===normalizedUserQuery){prefixResults.push(handleAlias(name,alias,0,0));idDuplicates.add(id);}}}if(parsedQuery.error!==null||parsedQuery.elems.length===0){yield*flush(prefixResults);return;}const elem=parsedQuery.elems[0];const typeFilter=itemTypeFromName(elem.typeFilter);const handleNameSearch=async id=>{const row=await this.getRow(id,false);if(!row||!row.entry){return null;}if(!typePassesFilter(typeFilter,row.ty)||(filterCrates!==null&&row.crate!==filterCrates)){return null;}let pathDist=0;if(elem.fullPath.length>1){pathDist=checkRowPath(elem.pathWithoutLast,row);if(pathDist===null){return null;}}if(parsedQuery.literalSearch){return row.name.toLowerCase()===elem.pathLast?{id,dist:0,path_dist:0,index:0,elems:[],returned:[],is_alias:false,item:row,}:null;}else{return{id,dist:editDistance(row.normalizedName,elem.normalizedPathLast,maxEditDistance,),path_dist:pathDist,index:row.normalizedName.indexOf(elem.normalizedPathLast),elems:[],returned:[],is_alias:false,item:row,};}};if(elem.normalizedPathLast===""){const l=index.length;for(let id=0;id{let i=0;const l=idx.length;while(i{if(!elem){return empty_postings_list;}const typeFilter=itemTypeFromName(elem.typeFilter);const[searchResults,upla,uplb]=await Promise.all([index.search(elem.normalizedPathLast),unpackPostingsListAll(elem.generics,polarity),unpackPostingsListBindings(elem.bindings,polarity),]);const typePromises=[];if(typeFilter!==itemTypes.generic&&searchResults){for(const id of searchResults.matches().entries()){typePromises.push(Promise.all([this.getName(id),this.getTypeData(id),this.getPathData(id),]).then(([name,typeData,pathData])=>[id,name,typeData,pathData]));}}const types=(await Promise.all(typePromises)).filter(([_id,name,ty,path])=>name!==null&&name.toLowerCase()===elem.pathLast&&ty&&!ty[polarity].every(bitmap=>{return bitmap.isEmpty();})&&path&&path.ty!==itemTypes.associatedtype&&(elem.pathWithoutLast.length===0||checkPath(elem.pathWithoutLast,path.modulePath.split("::"),)===0),);if(types.length===0){const areGenericsAllowed=typeFilter===itemTypes.generic||(typeFilter===-1&&(parsedQuery.totalElems>1||parsedQuery.hasReturnArrow)&&elem.pathWithoutLast.length===0&&elem.generics.length===0&&elem.bindings.size===0);if(typeFilter!==itemTypes.generic&&(elem.name.length>=3||!areGenericsAllowed)){let chosenName=null;let chosenType=[];let chosenPath=[];let chosenId=[];let chosenDist=Number.MAX_SAFE_INTEGER;const levResults=index.searchLev(elem.normalizedPathLast);for await(const searchResults of levResults){for(const id of searchResults.matches().entries()){const[name,ty,path]=await Promise.all([this.getName(id),this.getTypeData(id),this.getPathData(id),]);if(name!==null&&ty!==null&&path!==null&&!ty[polarity].every(bitmap=>{return bitmap.isEmpty();})&&path.ty!==itemTypes.associatedtype){let dist=editDistance(name,elem.pathLast,maxEditDistance,);if(elem.pathWithoutLast.length!==0){const pathDist=checkPath(elem.pathWithoutLast,path.modulePath.split("::"),);dist+=pathDist===null?Number.MAX_SAFE_INTEGER:pathDist;}if(name===chosenName){chosenId.push(id);chosenType.push(ty);chosenPath.push(path);}else if(dist{const p1=!pathData1?"":pathData1.modulePath;const p2=!pathData2?"":pathData2.modulePath;const n1=name1===null?"":name1;const n2=name2===null?"":name2;if(p1.length!==p2.length){return p1.length>p2.length?+1:-1;}if(n1.length!==n2.length){return n1.length>n2.length?+1:-1;}if(n1!==n2){return n1>n2?+1:-1;}if(p1!==p2){return p1>p2?+1:-1;}return 0;});const results=[];for(const[id,_name,typeData]of types){if(!typeData||typeData[polarity].every(bitmap=>{return bitmap.isEmpty();})){continue;}for(const{invertedIndex:genericsIdx,queryElem:generics}of upla){for(const{invertedIndex:bindingsIdx,queryElem:bindings}of uplb){results.push({invertedIndex:intersectInvertedIndexes(typeData[polarity],genericsIdx,bindingsIdx,),queryElem:{name:elem.name,id,typeFilter,generics,bindings,fullPath:elem.fullPath,pathLast:elem.pathLast,normalizedPathLast:elem.normalizedPathLast,pathWithoutLast:elem.pathWithoutLast,},});if((results.length&0x7F)===0){await yieldToBrowser();}}}}return results;};const unpackPostingsListAll=async(elems,polarity)=>{if(!elems||elems.length===0){return nested_everything_postings_list;}const[firstPostingsList,remainingAll]=await Promise.all([unpackPostingsList(elems[0],polarity),unpackPostingsListAll(elems.slice(1),polarity),]);const results=[];for(const{invertedIndex:firstIdx,queryElem:firstElem,}of firstPostingsList){for(const{invertedIndex:remainingIdx,queryElem:remainingElems,}of remainingAll){results.push({invertedIndex:intersectInvertedIndexes(firstIdx,remainingIdx),queryElem:[firstElem,...remainingElems],});if((results.length&0x7F)===0){await yieldToBrowser();}}}return results;};const unpackPostingsListBindings=async(elems,polarity)=>{if(!elems){return[{invertedIndex:everything_inverted_index,queryElem:new Map(),}];}const firstKey=elems.keys().next().value;if(firstKey===undefined){return[{invertedIndex:everything_inverted_index,queryElem:new Map(),}];}const firstList=elems.get(firstKey);if(firstList===undefined){return[{invertedIndex:everything_inverted_index,queryElem:new Map(),}];}elems.delete(firstKey);const[firstKeyIds,firstPostingsList,remainingAll]=await Promise.all([index.search(firstKey),unpackPostingsListAll(firstList,polarity),unpackPostingsListBindings(elems,polarity),]);if(!firstKeyIds){elems.set(firstKey,firstList);return[{invertedIndex:empty_inverted_index,queryElem:new Map(),}];}const results=[];for(const keyId of firstKeyIds.matches().entries()){for(const{invertedIndex:firstIdx,queryElem:firstElem,}of firstPostingsList){for(const{invertedIndex:remainingIdx,queryElem:remainingElems,}of remainingAll){const elems=new Map(remainingElems);elems.set(keyId,firstElem);results.push({invertedIndex:intersectInvertedIndexes(firstIdx,remainingIdx),queryElem:elems,});if((results.length&0x7F)===0){await yieldToBrowser();}}}}elems.set(firstKey,firstList);if(results.length===0){return[{invertedIndex:empty_inverted_index,queryElem:new Map(),}];}return results;};const[allInputs,allOutput,typeNameIds]=await Promise.all([unpackPostingsListAll(inputs,"invertedFunctionInputsIndex"),unpackPostingsListAll(output,"invertedFunctionOutputIndex"),this.getTypeNameIds(),]);let checkCounter=0;const queryPlan=[];for(const{invertedIndex:inputsIdx,queryElem:inputs}of allInputs){for(const{invertedIndex:outputIdx,queryElem:output}of allOutput){const invertedIndex=intersectInvertedIndexes(inputsIdx,outputIdx);for(const[size,bitmap]of invertedIndex.entries()){checkCounter+=1;if((checkCounter&0x7F)===0){await yieldToBrowser();}if(!queryPlan[size]){queryPlan[size]=[];}queryPlan[size].push({bitmap,inputs,output,});}}}const resultPromises=[];const dedup=new Set();let resultCounter=0;const isReturnTypeQuery=inputs.length===0;const pushToBottom=[];plan:for(const queryStep of queryPlan){for(const{bitmap,inputs,output}of queryStep){for(const id of bitmap.entries()){checkCounter+=1;if((checkCounter&0x7F)===0){await yieldToBrowser();}resultPromises.push(this.getFunctionData(id).then(async fnData=>{if(!fnData||!fnData.functionSignature){return null;}checkCounter+=1;if((checkCounter&0x7F)===0){await yieldToBrowser();}const functionSignature=fnData.functionSignature;if(!unifyFunctionTypes(functionSignature.inputs,inputs,functionSignature.where_clause,null,mgens=>{return!!unifyFunctionTypes(functionSignature.output,output,functionSignature.where_clause,mgens,checkTypeMgensForConflict,0,typeNameIds,);},0,typeNameIds,)){return null;}const item=await this.getRow(id,true);if(!item){return null;}const result={id,dist:fnData.elemCount,path_dist:0,index:-1,elems:inputs,returned:output,is_alias:false,item,};const entry=item.entry;if((entry&&!isFnLikeTy(entry.ty))||(isReturnTypeQuery&&functionSignature&&containsTypeFromQuery(output,functionSignature.inputs,functionSignature.where_clause,typeNameIds,))){pushToBottom.push(result);return null;}return result;}));}}for await(const result of sortAndTransformResults(await Promise.all(resultPromises),typeInfo,currentCrate,dedup,)){if(resultCounter>=MAX_RESULTS){break plan;}yield result;resultCounter+=1;}resultPromises.length=0;}if(resultCounter>=MAX_RESULTS){return;}for await(const result of sortAndTransformResults(await Promise.all(pushToBottom),typeInfo,currentCrate,dedup,)){if(resultCounter>=MAX_RESULTS){break;}yield result;resultCounter+=1;}}.bind(this);if(parsedQuery.foundElems===1&&!parsedQuery.hasReturnArrow){const{promise:donePromise,resolve:doneResolve,reject:doneReject,}=Promise.withResolvers();const doneTimeout=timeout(250);return{"in_args":(async function*(){await Promise.race([donePromise,doneTimeout]);yield*innerRunTypeQuery(parsedQuery.elems,[],"elems",currentCrate);})(),"returned":(async function*(){await Promise.race([donePromise,doneTimeout]);yield*innerRunTypeQuery([],parsedQuery.elems,"returned",currentCrate);})(),"others":(async function*(){try{yield*innerRunNameQuery(currentCrate);doneResolve(null);}catch(e){doneReject(e);throw e;}})(),"query":parsedQuery,};}else if(parsedQuery.error!==null||parsedQuery.foundElems===0){const others=parsedQuery.userQuery.length===0?(async function*(){})():innerRunNameQuery(currentCrate);return{"in_args":(async function*(){})(),"returned":(async function*(){})(),"others":others,"query":parsedQuery,};}else{const typeInfo=parsedQuery.elems.length===0?"returned":(parsedQuery.returned.length===0?"elems":"sig");return{"in_args":(async function*(){})(),"returned":(async function*(){})(),"others":innerRunTypeQuery(parsedQuery.elems,parsedQuery.returned,typeInfo,currentCrate,),"query":parsedQuery,};}}}let docSearch;const longItemTypes=["keyword","primitive type","module","extern crate","re-export","struct","enum","function","type alias","static","trait","","trait method","method","struct field","enum variant","macro","assoc type","constant","assoc const","union","foreign type","existential type","attribute macro","derive macro","trait alias","","attribute","","",];let currentResults;function printTab(nb){let iter=0;let foundCurrentTab=false;let foundCurrentResultSet=false;onEachLazy(document.getElementById("search-tabs").childNodes,elem=>{if(nb===iter){addClass(elem,"selected");foundCurrentTab=true;onEachLazy(document.querySelectorAll(".search-form",),form=>{if(hasClass(elem.firstElementChild,"loading")){addClass(form,"loading");}else{removeClass(form,"loading");}});}else{removeClass(elem,"selected");}iter+=1;});const isTypeSearch=(nb>0||iter===1);iter=0;onEachLazy(document.getElementById("results").childNodes,elem=>{if(nb===iter){addClass(elem,"active");foundCurrentResultSet=true;}else{removeClass(elem,"active");}iter+=1;});if(foundCurrentTab&&foundCurrentResultSet){searchState.currentTab=nb;const correctionsElem=document.getElementsByClassName("search-corrections");if(isTypeSearch){removeClass(correctionsElem[0],"hidden");}else{addClass(correctionsElem[0],"hidden");}}else if(nb!==0){printTab(0);}}function buildUrl(search,filterCrates){let extra="?search="+encodeURIComponent(search);if(filterCrates!==null){extra+="&filter-crate="+encodeURIComponent(filterCrates);}return getNakedUrl()+extra+window.location.hash;}function getFilterCrates(){const elem=document.getElementById("crate-search");if(elem&&elem.value!=="all crates"){return elem.value;}return null;}function nextTab(direction){const next=(searchState.currentTab+direction+3)%searchState.focusedByTab.length;window.searchState.focusedByTab[searchState.currentTab]=document.activeElement;printTab(next);focusSearchResult();}function focusSearchResult(){const target=searchState.focusedByTab[searchState.currentTab]||document.querySelectorAll(".search-results.active a").item(0)||document.querySelectorAll("#search-tabs button").item(searchState.currentTab);searchState.focusedByTab[searchState.currentTab]=null;if(target&&target instanceof HTMLElement){target.focus();}}async function addTab(results,query,display,finishedCallback,isTypeSearch){const extraClass=display?" active":"";let output=document.createElement("ul");output.className="search-results "+extraClass;const deprecatedCountElem=document.createElement("span");deprecatedCountElem.className=DEPRECATED_COUNT_SELECTOR;output.appendChild(deprecatedCountElem);let count=0;let deprecatedCount=0;const descList=[];const addNextResultToOutput=async obj=>{count+=1;const name=obj.item.name;const type=itemTypesName[obj.item.ty];const longType=longItemTypes[obj.item.ty];const typeName=longType.length!==0?`${longType}`:"?";const link=document.createElement("a");link.className="result-"+type;if(obj.item.deprecated){link.className+=" deprecated";deprecatedCount+=1;const plural=deprecatedCount>1?"s":"";deprecatedCountElem.innerText=`${deprecatedCount} deprecated item${plural} hidden by setting`;}link.href=obj.href;const resultName=document.createElement("span");resultName.className="result-name";resultName.insertAdjacentHTML("beforeend",`${typeName}`);link.appendChild(resultName);let alias=" ";if(obj.alias!==undefined){alias=`
\ +${obj.alias} - see \ +
`;}resultName.insertAdjacentHTML("beforeend",`
${alias}\ +${obj.displayPath}${name}\ +
`);const description=document.createElement("div");description.className="desc";obj.desc.then(desc=>{if(desc!==null){description.insertAdjacentHTML("beforeend",desc);}});descList.push(obj.desc);if(obj.displayTypeSignature){const{type,mappedNames,whereClause}=await obj.displayTypeSignature;const displayType=document.createElement("div");type.forEach((value,index)=>{if(index%2!==0){const highlight=document.createElement("strong");highlight.appendChild(document.createTextNode(value));displayType.appendChild(highlight);}else{displayType.appendChild(document.createTextNode(value));}});if(mappedNames.size>0||whereClause.size>0){let addWhereLineFn=()=>{const line=document.createElement("div");line.className="where";line.appendChild(document.createTextNode("where"));displayType.appendChild(line);addWhereLineFn=()=>{};};for(const[qname,name]of mappedNames){if(name===qname){continue;}addWhereLineFn();const line=document.createElement("div");line.className="where";line.appendChild(document.createTextNode(` ${qname} matches `));const lineStrong=document.createElement("strong");lineStrong.appendChild(document.createTextNode(name));line.appendChild(lineStrong);displayType.appendChild(line);}for(const[name,innerType]of whereClause){if(innerType.length<=1){continue;}addWhereLineFn();const line=document.createElement("div");line.className="where";line.appendChild(document.createTextNode(` ${name}: `));innerType.forEach((value,index)=>{if(index%2!==0){const highlight=document.createElement("strong");highlight.appendChild(document.createTextNode(value));line.appendChild(highlight);}else{line.appendChild(document.createTextNode(value));}});displayType.appendChild(line);}}displayType.className="type-signature";link.appendChild(displayType);}link.appendChild(description);output.appendChild(link);results.next().then(async nextResult=>{if(nextResult.value){addNextResultToOutput(nextResult.value);}else{await Promise.all(descList);yieldToBrowser().then(()=>{finishedCallback(count,output);});}});};const firstResult=await results.next();let correctionOutput="";if(query.correction!==null&&isTypeSearch){const orig=query.returned.length>0?query.returned[0].name:query.elems[0].name;correctionOutput="

"+`Type "${orig}" not found. `+"Showing results for closest type name "+`"${query.correction}" instead.

`;}if(query.proposeCorrectionFrom!==null&&isTypeSearch){const orig=query.proposeCorrectionFrom;const targ=query.proposeCorrectionTo;let message=`Type "${orig}" not found and used as generic parameter.`;if(targ!==null){message+=` Consider searching for "${targ}" instead.`;}correctionOutput=`

${message}

`;}if(firstResult.value){if(correctionOutput!==""){const h3=document.createElement("h3");h3.innerHTML=correctionOutput;output.appendChild(h3);}await addNextResultToOutput(firstResult.value);}else{output=document.createElement("div");if(correctionOutput!==""){const h3=document.createElement("h3");h3.innerHTML=correctionOutput;output.appendChild(h3);}output.className="search-failed"+extraClass;const dlroChannel=`https://doc.rust-lang.org/${getVar("channel")}`;if(query.userQuery!==""){output.innerHTML+="No results :(
"+"Try on DuckDuckGo?

"+"Or try looking in one of these:";}output.innerHTML+="Example searches:";yieldToBrowser().then(()=>finishedCallback(0,output));}return output;}function makeTab(tabNb,text,results,query,isTypeSearch,goToFirst){const isCurrentTab=window.searchState.currentTab===tabNb;const tabButton=document.createElement("button");tabButton.appendChild(document.createTextNode(text));tabButton.className=isCurrentTab?"selected":"";const tabCount=document.createElement("span");tabCount.className="count loading";tabCount.innerHTML="\u{2007}(\u{2007})\u{2007}\u{2007}";tabButton.appendChild(tabCount);return[tabButton,addTab(results,query,isCurrentTab,(count,output)=>{const search=window.searchState.outputElement();const error=query.error;if(count===0&&error!==null&&search){error.forEach((value,index)=>{value=value.split("<").join("<").split(">").join(">");if(index%2!==0){error[index]=`${value.replaceAll(" ", " ")}`;}else{error[index]=value;}});const errorReport=document.createElement("h3");errorReport.className="error";errorReport.innerHTML=`Query parser error: "${error.join("")}".`;search.insertBefore(errorReport,search.firstElementChild);}else if(goToFirst||(count===1&&getSettingValue("go-to-only-result")==="true")){window.onunload=()=>{};window.searchState.removeQueryParameters();const a=output.querySelector("a");if(a){a.click();return;}}const fmtNbElems=count<10?`\u{2007}(${count})\u{2007}\u{2007}`:count<100?`\u{2007}(${count})\u{2007}`:`\u{2007}(${count})`;tabCount.innerHTML=fmtNbElems;tabCount.className="count";printTab(window.searchState.currentTab);},isTypeSearch),];}async function showResults(docSearch,results,goToFirst,filterCrates){const search=window.searchState.outputElement();if(!search){return;}let crates="";const crateNames=await docSearch.getCrateNameList();if(crateNames.length>1){crates=" in 
"+"
";}nonnull(document.querySelector(".search-switcher")).innerHTML=`Search results${crates}`;const tabs=[];searchState.currentTab=0;if(results.query.error!==null){tabs.push(makeTab(0,"In Names",results.others,results.query,false,goToFirst));}else if(results.query.foundElems<=1&&results.query.returned.length===0&&!results.query.hasReturnArrow){tabs.push(makeTab(0,"In Names",results.others,results.query,false,goToFirst));tabs.push(makeTab(1,"In Parameters",results.in_args,results.query,true,false));tabs.push(makeTab(2,"In Return Types",results.returned,results.query,true,false));}else{const signatureTabTitle=results.query.elems.length===0?"In Function Return Types":results.query.returned.length===0?"In Function Parameters":"In Function Signatures";tabs.push(makeTab(0,signatureTabTitle,results.others,results.query,true,goToFirst));}const tabsElem=document.createElement("div");tabsElem.id="search-tabs";const resultsElem=document.createElement("div");resultsElem.id="results";search.innerHTML="";for(const[tabNb,[tab,output]]of tabs.entries()){tabsElem.appendChild(tab);const isCurrentTab=window.searchState.currentTab===tabNb;const placeholder=document.createElement("div");placeholder.className=isCurrentTab?"search-results active":"search-results";placeholder.innerHTML="Loading...";output.then(output=>{if(placeholder.parentElement){placeholder.parentElement.replaceChild(output,placeholder);}});resultsElem.appendChild(placeholder);}if(window.searchState.rustdocToolbar){nonnull(nonnull(window.searchState.containerElement()).querySelector(".main-heading"),).appendChild(window.searchState.rustdocToolbar);}const crateSearch=document.getElementById("crate-search");if(crateSearch){crateSearch.addEventListener("input",updateCrate);}search.appendChild(tabsElem);search.appendChild(resultsElem);window.searchState.showResults();window.searchState.focusedByTab=[null,null,null];let i=0;for(const elem of tabsElem.childNodes){const j=i;elem.onclick=()=>printTab(j);window.searchState.focusedByTab[i]=null;i+=1;}printTab(0);}function updateSearchHistory(url){const btn=document.querySelector("#search-button a");if(btn instanceof HTMLAnchorElement){btn.href=url;}if(!browserSupportsHistoryApi()){return;}const params=searchState.getQueryStringParams();if(!history.state&¶ms.search===undefined){history.pushState(null,"",url);}else{history.replaceState(null,"",url);}}async function search(forced){const query=DocSearch.parseQuery(nonnull(window.searchState.inputElement()).value.trim());let filterCrates=getFilterCrates();if(!forced&&query.userQuery===currentResults){if(query.userQuery.length>0){putBackSearch();}return;}currentResults=query.userQuery;searchState.setLoadingSearch();const params=searchState.getQueryStringParams();if(filterCrates===null&¶ms["filter-crate"]!==undefined){filterCrates=params["filter-crate"];}if(filterCrates!==null&&(await docSearch.getCrateNameList()).indexOf(filterCrates)===-1){filterCrates=null;}searchState.title="\""+query.userQuery+"\" Search - Rust";updateSearchHistory(buildUrl(query.userQuery,filterCrates));await showResults(docSearch,await docSearch.execQuery(query,filterCrates,window.currentCrate),params.go_to_first,filterCrates);}function onSearchSubmit(e){e.preventDefault();searchState.clearInputTimeout();search();}function putBackSearch(){const search_input=window.searchState.inputElement();if(!search_input){return;}if(search_input.value!==""&&!searchState.isDisplayed()){searchState.showResults();if(browserSupportsHistoryApi()){history.replaceState(null,"",buildUrl(search_input.value,getFilterCrates()));}document.title=searchState.title;}}function registerSearchEvents(){const params=searchState.getQueryStringParams();const inputElement=nonnull(window.searchState.inputElement());if(inputElement.value===""){inputElement.value=params.search||"";}const searchAfter500ms=()=>{searchState.clearInputTimeout();window.searchState.timeout=setTimeout(search,500);};inputElement.onkeyup=searchAfter500ms;inputElement.oninput=searchAfter500ms;if(inputElement.form){inputElement.form.onsubmit=onSearchSubmit;}inputElement.onchange=e=>{if(e.target!==document.activeElement){return;}searchState.clearInputTimeout();setTimeout(search,0);};inputElement.onpaste=inputElement.onchange;searchState.outputElement().addEventListener("keydown",e=>{if(!(e instanceof KeyboardEvent)){return;}if(e.altKey||e.ctrlKey||e.shiftKey||e.metaKey){return;}if(e.which===38){const active=document.activeElement;if(active){const previous=active.previousElementSibling;if(previous&&previous.className!==DEPRECATED_COUNT_SELECTOR){previous.focus();}else{searchState.focus();}}e.preventDefault();}else if(e.which===40){const active=document.activeElement;if(active){const next=active.nextElementSibling;if(next){next.focus();}const rect=active.getBoundingClientRect();if(window.innerHeight-rect.bottom{if(e.which===40){focusSearchResult();e.preventDefault();}});inputElement.addEventListener("focus",()=>{putBackSearch();});}function updateCrate(ev){if(ev.target.value==="all crates"){const query=nonnull(window.searchState.inputElement()).value.trim();updateSearchHistory(buildUrl(query,null));}currentResults=null;search(true);}const makeUint8ArrayFromBase64=Uint8Array.fromBase64?Uint8Array.fromBase64:(string=>{const bytes_as_string=atob(string);const l=bytes_as_string.length;const bytes=new Uint8Array(l);for(let i=0;i{for(const key in callbacks){if(Object.hasOwn(callbacks,key)){window[key]=callbacks[key];}}databaseCallbacks=callbacks;if(window.searchIndex){window.rr_(window.searchIndex);}},loadTreeByHash:hashHex=>{const script=document.createElement("script");script.src=`${ROOT_PATH}search.index/${hashHex}.js`;script.onerror=e=>{if(databaseCallbacks){databaseCallbacks.err_rn_(hashHex,e);}};document.documentElement.appendChild(script);},loadDataByNameAndHash:(name,hashHex)=>{const script=document.createElement("script");script.src=`${ROOT_PATH}search.index/${name}/${hashHex}.js`;script.onerror=e=>{if(databaseCallbacks){databaseCallbacks.err_rd_(hashHex,e);}};document.documentElement.appendChild(script);},});}else if(typeof exports!=="undefined"){exports.initSearch=initSearch;} \ No newline at end of file diff --git a/api/static.files/settings-170eb4bf.js b/api/static.files/settings-170eb4bf.js new file mode 100644 index 00000000..e0b58347 --- /dev/null +++ b/api/static.files/settings-170eb4bf.js @@ -0,0 +1,17 @@ +"use strict";(function(){const isSettingsPage=window.location.pathname.endsWith("/settings.html");function changeSetting(settingName,value){if(settingName==="theme"){const useSystem=value==="system preference"?"true":"false";updateLocalStorage("use-system-theme",useSystem);}updateLocalStorage(settingName,""+value);switch(settingName){case"theme":case"preferred-dark-theme":case"preferred-light-theme":updateTheme();updateLightAndDark();break;case"line-numbers":if(value===true){const f=window.rustdoc_add_line_numbers_to_examples;if(f!==undefined){f();}}else{const f=window.rustdoc_remove_line_numbers_from_examples;if(f!==undefined){f();}}break;case"sans-serif-fonts":case"hide-sidebar":case"hide-toc":case"hide-modnav":case"word-wrap-source-code":case"hide-deprecated-items":if(value===true){addClass(document.documentElement,settingName);}else{removeClass(document.documentElement,settingName);}}}function showLightAndDark(){removeClass(document.getElementById("preferred-light-theme"),"hidden");removeClass(document.getElementById("preferred-dark-theme"),"hidden");}function hideLightAndDark(){addClass(document.getElementById("preferred-light-theme"),"hidden");addClass(document.getElementById("preferred-dark-theme"),"hidden");}function updateLightAndDark(){const useSystem=getSettingValue("use-system-theme");if(useSystem==="true"||(useSystem===null&&getSettingValue("theme")===null)){showLightAndDark();}else{hideLightAndDark();}}function setEvents(settingsElement){updateLightAndDark();onEachLazy(settingsElement.querySelectorAll("input[type=\"checkbox\"]"),toggle=>{const settingId=toggle.id;const settingValue=getSettingValue(settingId);if(settingValue!==null){toggle.checked=settingValue==="true";}toggle.onchange=()=>{changeSetting(toggle.id,toggle.checked);};});onEachLazy(settingsElement.querySelectorAll("input[type=\"radio\"]"),elem=>{const settingId=elem.name;let settingValue=getSettingValue(settingId);if(settingId==="theme"){const useSystem=getSettingValue("use-system-theme");if(useSystem==="true"||settingValue===null){settingValue=useSystem==="false"?"light":"system preference";}}if(settingValue!==null&&settingValue!=="null"){elem.checked=settingValue===elem.value;}elem.addEventListener("change",()=>{changeSetting(elem.name,elem.value);});},);}function buildSettingsPageSections(settings){let output="";for(const setting of settings){const js_data_name=setting["js_name"];const setting_name=setting["name"];if(setting["options"]!==undefined){output+=`\ +
+
${setting_name}
+
`;onEach(setting["options"],option=>{const checked=option===setting["default"]?" checked":"";const full=`${js_data_name}-${option.replace(/ /g,"-")}`;output+=`\ + `;});output+=`\ +
+
`;}else{const checked=setting["default"]===true?" checked":"";output+=`\ +
\ + \ +
`;}}return output;}function buildSettingsPage(){const theme_list=getVar("themes");const theme_names=(theme_list===null?"":theme_list).split(",").filter(t=>t);theme_names.push("light","dark","ayu");const settings=[{"name":"Theme","js_name":"theme","default":"system preference","options":theme_names.concat("system preference"),},{"name":"Preferred light theme","js_name":"preferred-light-theme","default":"light","options":theme_names,},{"name":"Preferred dark theme","js_name":"preferred-dark-theme","default":"dark","options":theme_names,},{"name":"Auto-hide item contents for large items","js_name":"auto-hide-large-items","default":true,},{"name":"Auto-hide item methods' documentation","js_name":"auto-hide-method-docs","default":false,},{"name":"Auto-hide trait implementation documentation","js_name":"auto-hide-trait-implementations","default":false,},{"name":"Directly go to item in search if there is only one result","js_name":"go-to-only-result","default":false,},{"name":"Show line numbers on code examples","js_name":"line-numbers","default":false,},{"name":"Hide persistent navigation bar","js_name":"hide-sidebar","default":false,},{"name":"Hide table of contents","js_name":"hide-toc","default":false,},{"name":"Hide module navigation","js_name":"hide-modnav","default":false,},{"name":"Disable keyboard shortcuts","js_name":"disable-shortcuts","default":false,},{"name":"Use sans serif fonts","js_name":"sans-serif-fonts","default":false,},{"name":"Word wrap source code","js_name":"word-wrap-source-code","default":false,},{"name":"Hide deprecated items","js_name":"hide-deprecated-items","default":false,},];const elementKind=isSettingsPage?"section":"div";const innerHTML=`
${buildSettingsPageSections(settings)}
`;const el=document.createElement(elementKind);el.id="settings";if(!isSettingsPage){el.className="popover";}el.innerHTML=innerHTML;if(isSettingsPage){const mainElem=document.getElementById(MAIN_ID);if(mainElem!==null){mainElem.appendChild(el);}}else{el.setAttribute("tabindex","-1");onEachLazy(document.querySelectorAll(".settings-menu"),menu=>{if(menu.offsetWidth!==0){menu.appendChild(el);return true;}});}return el;}const settingsMenu=buildSettingsPage();function displaySettings(){settingsMenu.style.display="";onEachLazy(document.querySelectorAll(".settings-menu"),menu=>{if(menu.offsetWidth!==0){if(!menu.contains(settingsMenu)&&settingsMenu.parentElement){settingsMenu.parentElement.removeChild(settingsMenu);menu.appendChild(settingsMenu);}return true;}});onEachLazy(settingsMenu.querySelectorAll("input[type='checkbox']"),el=>{const val=getSettingValue(el.id);const checked=val==="true";if(checked!==el.checked&&val!==null){el.checked=checked;}});}function settingsBlurHandler(event){const isInPopover=onEachLazy(document.querySelectorAll(".settings-menu, .help-menu"),menu=>{return menu.contains(document.activeElement)||menu.contains(event.relatedTarget);},);if(!isInPopover){window.hidePopoverMenus();}}if(!isSettingsPage){const settingsMenu=nonnull(document.getElementById("settings"));onEachLazy(document.querySelectorAll(".settings-menu"),settingsButton=>{settingsButton.querySelector("a").onclick=event=>{if(!(event.target instanceof Element)||settingsMenu.contains(event.target)){return;}event.preventDefault();const shouldDisplaySettings=settingsMenu.style.display==="none";window.hideAllModals(false);if(shouldDisplaySettings){displaySettings();}};settingsButton.onblur=settingsBlurHandler;settingsButton.querySelector("a").onblur=settingsBlurHandler;});onEachLazy(settingsMenu.querySelectorAll("input"),el=>{el.onblur=settingsBlurHandler;});settingsMenu.onblur=settingsBlurHandler;}setTimeout(()=>{setEvents(settingsMenu);if(!isSettingsPage){displaySettings();}onEachLazy(document.querySelectorAll(".settings-menu"),settingsButton=>{removeClass(settingsButton,"rotate");});},0);})(); \ No newline at end of file diff --git a/api/static.files/src-script-813739b1.js b/api/static.files/src-script-813739b1.js new file mode 100644 index 00000000..bf546257 --- /dev/null +++ b/api/static.files/src-script-813739b1.js @@ -0,0 +1 @@ +"use strict";(function(){const rootPath=getVar("root-path");const NAME_OFFSET=0;const DIRS_OFFSET=1;const FILES_OFFSET=2;const RUSTDOC_MOBILE_BREAKPOINT=700;function closeSidebarIfMobile(){if(window.innerWidth{removeClass(document.documentElement,"src-sidebar-expanded");updateLocalStorage("source-sidebar-show","false");};window.rustdocShowSourceSidebar=()=>{addClass(document.documentElement,"src-sidebar-expanded");updateLocalStorage("source-sidebar-show","true");};window.rustdocToggleSrcSidebar=()=>{if(document.documentElement.classList.contains("src-sidebar-expanded")){window.rustdocCloseSourceSidebar();}else{window.rustdocShowSourceSidebar();}};function createSrcSidebar(srcIndexStr){const container=nonnull(document.querySelector("nav.sidebar"));const sidebar=document.createElement("div");sidebar.id="src-sidebar";const srcIndex=new Map(JSON.parse(srcIndexStr));let hasFoundFile=false;for(const[key,source]of srcIndex){source[NAME_OFFSET]=key;hasFoundFile=createDirEntry(source,sidebar,"",hasFoundFile);}container.appendChild(sidebar);const selected_elem=sidebar.getElementsByClassName("selected")[0];if(typeof selected_elem!=="undefined"){selected_elem.focus();}}function highlightSrcLines(){const match=window.location.hash.match(/^#?(\d+)(?:-(\d+))?$/);if(!match){return;}let from=parseInt(match[1],10);let to=from;if(typeof match[2]!=="undefined"){to=parseInt(match[2],10);}if(to{removeClass(e,"line-highlighted");});for(let i=from;i<=to;++i){elem=document.getElementById(""+i);if(!elem){break;}addClass(elem,"line-highlighted");}}const handleSrcHighlight=(function(){let prev_line_id=0;const set_fragment=name=>{const x=window.scrollX,y=window.scrollY;if(browserSupportsHistoryApi()){history.replaceState(null,"","#"+name);highlightSrcLines();}else{location.replace("#"+name);}window.scrollTo(x,y);};return ev=>{let cur_line_id=parseInt(ev.target.id,10);if(isNaN(cur_line_id)||ev.ctrlKey||ev.altKey||ev.metaKey){return;}ev.preventDefault();if(ev.shiftKey&&prev_line_id){if(prev_line_id>cur_line_id){const tmp=prev_line_id;prev_line_id=cur_line_id;cur_line_id=tmp;}set_fragment(prev_line_id+"-"+cur_line_id);}else{prev_line_id=cur_line_id;set_fragment(""+cur_line_id);}};}());window.addEventListener("hashchange",highlightSrcLines);onEachLazy(document.querySelectorAll("a[data-nosnippet]"),el=>{el.addEventListener("click",handleSrcHighlight);});highlightSrcLines();window.createSrcSidebar=createSrcSidebar;})(); \ No newline at end of file diff --git a/api/static.files/storage-41dd4d93.js b/api/static.files/storage-41dd4d93.js new file mode 100644 index 00000000..c2c52ad0 --- /dev/null +++ b/api/static.files/storage-41dd4d93.js @@ -0,0 +1,27 @@ +"use strict";const builtinThemes=["light","dark","ayu"];const darkThemes=["dark","ayu"];window.currentTheme=(function(){const currentTheme=document.getElementById("themeStyle");return currentTheme instanceof HTMLLinkElement?currentTheme:null;})();const settingsDataset=(function(){const settingsElement=document.getElementById("default-settings");return settingsElement&&settingsElement.dataset?settingsElement.dataset:null;})();function nonnull(x,msg){if(x===null){throw(msg||"unexpected null value!");}else{return x;}}function nonundef(x,msg){if(x===undefined){throw(msg||"unexpected null value!");}else{return x;}}function getSettingValue(settingName){const current=getCurrentValue(settingName);if(current===null&&settingsDataset!==null){const def=settingsDataset[settingName.replace(/-/g,"_")];if(def!==undefined){return def;}}return current;}const localStoredTheme=getSettingValue("theme");function hasClass(elem,className){return!!elem&&!!elem.classList&&elem.classList.contains(className);}function addClass(elem,className){if(elem&&elem.classList){elem.classList.add(className);}}function removeClass(elem,className){if(elem&&elem.classList){elem.classList.remove(className);}}function onEach(arr,func){for(const elem of arr){if(func(elem)){return true;}}return false;}function onEachLazy(lazyArray,func){return onEach(Array.prototype.slice.call(lazyArray),func);}function updateLocalStorage(name,value){try{if(value===null){window.localStorage.removeItem("rustdoc-"+name);}else{window.localStorage.setItem("rustdoc-"+name,value);}}catch{}}function getCurrentValue(name){try{return window.localStorage.getItem("rustdoc-"+name);}catch{return null;}}function getVar(name){const el=document.querySelector("head > meta[name='rustdoc-vars']");const v=el?el.getAttribute("data-"+name):null;if(v!==null){return v;}throw`rustdoc var "${name}" is missing`;}function switchTheme(newThemeName,saveTheme){const themeNames=(getVar("themes")||"").split(",").filter(t=>t);themeNames.push(...builtinThemes);if(newThemeName===null||themeNames.indexOf(newThemeName)===-1){return;}if(saveTheme){updateLocalStorage("theme",newThemeName);}document.documentElement.setAttribute("data-theme",newThemeName);if(builtinThemes.indexOf(newThemeName)!==-1){if(window.currentTheme&&window.currentTheme.parentNode){window.currentTheme.parentNode.removeChild(window.currentTheme);window.currentTheme=null;}}else{const newHref=getVar("root-path")+encodeURIComponent(newThemeName)+getVar("resource-suffix")+".css";if(!window.currentTheme){if(document.readyState==="loading"){document.write(``);window.currentTheme=(function(){const currentTheme=document.getElementById("themeStyle");return currentTheme instanceof HTMLLinkElement?currentTheme:null;})();}else{window.currentTheme=document.createElement("link");window.currentTheme.rel="stylesheet";window.currentTheme.id="themeStyle";window.currentTheme.href=newHref;document.documentElement.appendChild(window.currentTheme);}}else if(newHref!==window.currentTheme.href){window.currentTheme.href=newHref;}}}const updateTheme=(function(){const mql=window.matchMedia("(prefers-color-scheme: dark)");function updateTheme(){if(getSettingValue("use-system-theme")!=="false"){const lightTheme=getSettingValue("preferred-light-theme")||"light";const darkTheme=getSettingValue("preferred-dark-theme")||"dark";updateLocalStorage("use-system-theme","true");switchTheme(mql.matches?darkTheme:lightTheme,true);}else{switchTheme(getSettingValue("theme"),false);}}mql.addEventListener("change",updateTheme);return updateTheme;})();if(getSettingValue("use-system-theme")!=="false"&&window.matchMedia){if(getSettingValue("use-system-theme")===null&&getSettingValue("preferred-dark-theme")===null&&localStoredTheme!==null&&darkThemes.indexOf(localStoredTheme)>=0){updateLocalStorage("preferred-dark-theme",localStoredTheme);}}updateTheme();if(getSettingValue("source-sidebar-show")==="true"){addClass(document.documentElement,"src-sidebar-expanded");}(function(){const settings=["hide-sidebar","hide-toc","hide-modnav","word-wrap-source-code","hide-deprecated-items","sans-serif-fonts",];for(const setting of settings){if(getSettingValue(setting)==="true"){addClass(document.documentElement,setting);}}})();function updateSidebarWidth(){const desktopSidebarWidth=getSettingValue("desktop-sidebar-width");if(desktopSidebarWidth&&desktopSidebarWidth!=="null"){document.documentElement.style.setProperty("--desktop-sidebar-width",desktopSidebarWidth+"px",);}const srcSidebarWidth=getSettingValue("src-sidebar-width");if(srcSidebarWidth&&srcSidebarWidth!=="null"){document.documentElement.style.setProperty("--src-sidebar-width",srcSidebarWidth+"px",);}}updateSidebarWidth();window.addEventListener("pageshow",ev=>{if(ev.persisted){setTimeout(updateTheme,0);setTimeout(updateSidebarWidth,0);}});class RustdocToolbarElement extends HTMLElement{constructor(){super();}connectedCallback(){if(this.firstElementChild){return;}const rootPath=getVar("root-path");const currentUrl=window.location.href.split("?")[0].split("#")[0];this.innerHTML=` +
+ Search +
+
+ Settings +
+
+ Help +
+ `;}}window.customElements.define("rustdoc-toolbar",RustdocToolbarElement);class RustdocTopBarElement extends HTMLElement{constructor(){super();}connectedCallback(){const rootPath=getVar("root-path");const tmplt=document.createElement("template");tmplt.innerHTML=` + + + + + `;const shadow=this.attachShadow({mode:"open"});shadow.appendChild(tmplt.content.cloneNode(true));this.innerHTML+=` + +
+ Settings +
+
+ Help +
+ `;}}window.customElements.define("rustdoc-topbar",RustdocTopBarElement); \ No newline at end of file diff --git a/api/static.files/stringdex-2da4960a.js b/api/static.files/stringdex-2da4960a.js new file mode 100644 index 00000000..4bc46261 --- /dev/null +++ b/api/static.files/stringdex-2da4960a.js @@ -0,0 +1,2 @@ +const EMPTY_UINT8=new Uint8Array();class RoaringBitmap{constructor(u8array,startingOffset){const start=startingOffset?startingOffset:0;let i=start;this.keysAndCardinalities=EMPTY_UINT8;this.containers=[];this.consumed_len_bytes=0;if(u8array===null||u8array.length===i||u8array[i]===0){return this;}else if(u8array[i]>0xf0){const lspecial=u8array[i]&0x0f;this.keysAndCardinalities=new Uint8Array(lspecial*4);let pspecial=i+1;let key=u8array[pspecial+2]|(u8array[pspecial+3]<<8);let value=u8array[pspecial]|(u8array[pspecial+1]<<8);let entry=(key<<16)|value;let container;container=new RoaringBitmapArray(1,new Uint8Array(4));container.array[0]=value&0xFF;container.array[1]=(value>>8)&0xFF;this.containers.push(container);this.keysAndCardinalities[0]=key;this.keysAndCardinalities[1]=key>>8;pspecial+=4;for(let ispecial=1;ispecial>16;container=this.addToArrayAt(key);const cardinalityOld=container.cardinality;container.array[cardinalityOld*2]=value&0xFF;container.array[(cardinalityOld*2)+1]=(value>>8)&0xFF;container.cardinality=cardinalityOld+1;pspecial+=2;}this.consumed_len_bytes=pspecial-i;return this;}else if(u8array[i]>0xe0){const run_count_m1=(u8array[i]&0x0f);const run_count=run_count_m1+1;this.keysAndCardinalities=new Uint8Array(run_count*4);let pspecial=i+1;let pnspecial=pspecial+(run_count*4);let previous_key=null;let previous_container=null;for(let j=0;j>4):(u8array[pnspecial]&0x0f);if(j%2!==0){pnspecial+=1;}pspecial+=4;if(key===previous_key&&previous_container!==null){const new_container_array=new Uint8Array((previous_container.runcount+1)*4,);new_container_array.set(previous_container.array);new_container_array[previous_container.runcount*4]=value&0xFF;new_container_array[(previous_container.runcount*4)+1]=(value>>8)&0xFF;new_container_array[(previous_container.runcount*4)+2]=run_length_m1;previous_container.array=new_container_array;previous_container.runcount+=1;let cardinalitym1=this.keysAndCardinalities[(this.containers.length*4)-2]|(this.keysAndCardinalities[(this.containers.length*4)-1]<<8);cardinalitym1+=run_length_m1+1;this.keysAndCardinalities[(this.containers.length*4)-2]=cardinalitym1&0xFF;this.keysAndCardinalities[(this.containers.length*4)-1]=(cardinalitym1>>8)&0xFF;}else{previous_key=key;previous_container=new RoaringBitmapRun(1,Uint8Array.of(value&0xFF,(value>>8)&0xFF,run_length_m1,0,));this.containers.push(previous_container);this.keysAndCardinalities[(this.containers.length*4)-4]=key&0xFF;this.keysAndCardinalities[(this.containers.length*4)-3]=(key>>8)&0xFF;this.keysAndCardinalities[(this.containers.length*4)-2]=run_length_m1;}}if(run_count%2!==0){pnspecial+=1;}this.consumed_len_bytes=pnspecial-i;return this;}else if(u8array[i]>0xd0){const lspecial=u8array[i]&0x0f;this.keysAndCardinalities=new Uint8Array(lspecial*4);let pspecial=i+1;let key=u8array[pspecial+2]|(u8array[pspecial+3]<<8);let value=u8array[pspecial]|(u8array[pspecial+1]<<8);let entry=(key<<16)|value;let container;container=new RoaringBitmapArray(1,new Uint8Array(4));container.array[0]=value&0xFF;container.array[1]=(value>>8)&0xFF;this.containers.push(container);this.keysAndCardinalities[0]=key;this.keysAndCardinalities[1]=key>>8;pspecial+=4;for(let ispecial=1;ispecial>16;container=this.addToArrayAt(key);const cardinalityOld=container.cardinality;container.array[cardinalityOld*2]=value&0xFF;container.array[(cardinalityOld*2)+1]=(value>>8)&0xFF;container.cardinality=cardinalityOld+1;pspecial+=1;}this.consumed_len_bytes=pspecial-i;return this;}else if(u8array[i]>0x80){const lspecial=u8array[i]&0x3f;const lspecialm1=lspecial-1;this.keysAndCardinalities=new Uint8Array(4);i+=1;const key=u8array[i+2]|(u8array[i+3]<<8);const value=u8array[i]|(u8array[i+1]<<8);const container=new RoaringBitmapRun(1,new Uint8Array(4));container.array[0]=value&0xFF;container.array[1]=(value>>8)&0xFF;container.array[2]=lspecialm1&0xFF;container.array[3]=lspecialm1>>8;this.containers.push(container);this.keysAndCardinalities[0]=key&0xFF;this.keysAndCardinalities[1]=(key>>8)&0xFF;this.keysAndCardinalities[2]=lspecialm1&0xFF;this.keysAndCardinalities[3]=lspecialm1>>8;this.consumed_len_bytes=5;return this;}else if(u8array[i]<0x3a){const lspecial=u8array[i];this.keysAndCardinalities=new Uint8Array(lspecial*4);let pspecial=i+1;for(let ispecial=0;ispecial>8)&0xFF;container.cardinality=cardinalityOld+1;pspecial+=4;}this.consumed_len_bytes=pspecial-i;return this;}const has_runs=u8array[i]===0x3b;if(u8array[i]!==0x3a&&u8array[i]!==0x3b){throw new Error("not a roaring bitmap: "+u8array[i]);}const size=has_runs?((u8array[i+2]|(u8array[i+3]<<8))+1):((u8array[i+4]|(u8array[i+5]<<8)|(u8array[i+6]<<16)|(u8array[i+7]<<24)));i+=has_runs?4:8;let is_run;if(has_runs){const is_run_len=(size+7)>>3;is_run=new Uint8Array(u8array.buffer,i+u8array.byteOffset,is_run_len);i+=is_run_len;}else{is_run=EMPTY_UINT8;}this.keysAndCardinalities=u8array.subarray(i,i+(size*4));i+=size*4;let offsets=null;if(!has_runs||size>=4){offsets=[];for(let j=0;j>3]&(1<<(j&0x7))){const runcount=(u8array[i]|(u8array[i+1]<<8));i+=2;this.containers.push(new RoaringBitmapRun(runcount,new Uint8Array(u8array.buffer,i+u8array.byteOffset,runcount*4),));i+=runcount*4;}else if(cardinality>=4096){this.containers.push(new RoaringBitmapBits(new Uint8Array(u8array.buffer,i+u8array.byteOffset,8192,)));i+=8192;}else{const end=cardinality*2;this.containers.push(new RoaringBitmapArray(cardinality,new Uint8Array(u8array.buffer,i+u8array.byteOffset,end),));i+=end;}}this.consumed_len_bytes=i-start;}static makeSingleton(number){const result=new RoaringBitmap(null,0);result.keysAndCardinalities=Uint8Array.of((number>>16),(number>>24),0,0,);result.containers.push(new RoaringBitmapArray(1,Uint8Array.of(number,number>>8),));return result;}static everything(){if(EVERYTHING_BITMAP.isEmpty()){let i=0;const l=1<<16;const everything_range=new RoaringBitmapRun(1,Uint8Array.of(0,0,0xff,0xff));EVERYTHING_BITMAP.keysAndCardinalities=new Uint8Array(l*4);while(i>8;EVERYTHING_BITMAP.keysAndCardinalities[(i*4)+2]=0xff;EVERYTHING_BITMAP.keysAndCardinalities[(i*4)+3]=0xff;i+=1;}}return EVERYTHING_BITMAP;}static empty(){return EMPTY_BITMAP;}isEmpty(){return this.containers.length===0;}addToArrayAt(key){let mid=this.getContainerId(key);let container;if(mid===-1){container=new RoaringBitmapArray(0,new Uint8Array(2));mid=this.containers.length;this.containers.push(container);if(mid*4>this.keysAndCardinalities.length){const keysAndContainers=new Uint8Array(mid*8);keysAndContainers.set(this.keysAndCardinalities);this.keysAndCardinalities=keysAndContainers;}this.keysAndCardinalities[(mid*4)+0]=key;this.keysAndCardinalities[(mid*4)+1]=key>>8;}else{container=this.containers[mid];const cardinalityOld=this.keysAndCardinalities[(mid*4)+2]|(this.keysAndCardinalities[(mid*4)+3]<<8);const cardinality=cardinalityOld+1;this.keysAndCardinalities[(mid*4)+2]=cardinality;this.keysAndCardinalities[(mid*4)+3]=cardinality>>8;}const cardinalityOld=this.keysAndCardinalities[(mid*4)+2]|(this.keysAndCardinalities[(mid*4)+3]<<8);if(!(container instanceof RoaringBitmapArray)||container.array.byteLength<((cardinalityOld+1)*2)){const newBuf=new Uint8Array((cardinalityOld+1)*4);let idx=0;for(const cvalue of container.values()){newBuf[idx]=cvalue&0xFF;newBuf[idx+1]=(cvalue>>8)&0xFF;idx+=2;}if(container instanceof RoaringBitmapArray){container.cardinality=cardinalityOld;container.array=newBuf;return container;}const newcontainer=new RoaringBitmapArray(cardinalityOld,newBuf);this.containers[mid]=newcontainer;return newcontainer;}else{return container;}}union(that){if(this.isEmpty()){return that;}if(that.isEmpty()){return this;}if(this===RoaringBitmap.everything()||that===RoaringBitmap.everything()){return RoaringBitmap.everything();}let i=0;const il=this.containers.length;let j=0;const jl=that.containers.length;const result=new RoaringBitmap(null,0);result.keysAndCardinalities=new Uint8Array((il+jl)*4);while(i=jl||(i=il||(jthatContainer.array.length?thisContainer.array.length:thatContainer.array.length,);let k=0;const kl=resultArray.length;while(k>8)&0xFF;k+=2;}result.containers.push(new RoaringBitmapArray(resultValues.length,resultArray,));card=resultValues.length;}result.keysAndCardinalities[k+0]=this.keysAndCardinalities[ik+0];result.keysAndCardinalities[k+1]=this.keysAndCardinalities[ik+1];card-=1;result.keysAndCardinalities[k+2]=card;result.keysAndCardinalities[k+3]=card>>8;i+=1;j+=1;}}return result;}intersection(that){if(this.isEmpty()||that.isEmpty()){return EMPTY_BITMAP;}if(this===RoaringBitmap.everything()){return that;}if(that===RoaringBitmap.everything()){return this;}let i=0;const il=this.containers.length;let j=0;const jl=that.containers.length;const result=new RoaringBitmap(null,0);result.keysAndCardinalities=new Uint8Array((il>jl?il:jl)*4);while(i=jl||(i=il||(jthatContainer.array.length?thisContainer.array.length:thatContainer.array.length,);let k=0;const kl=resultArray.length;while(k>8)&0xFF;k+=2;}result.containers.push(new RoaringBitmapArray(resultValues.length,resultArray,));}}if(card!==0){result.keysAndCardinalities[k+0]=this.keysAndCardinalities[ik+0];result.keysAndCardinalities[k+1]=this.keysAndCardinalities[ik+1];card-=1;result.keysAndCardinalities[k+2]=card;result.keysAndCardinalities[k+3]=card>>8;}i+=1;j+=1;}}return result;}contains(keyvalue){const key=keyvalue>>16;const value=keyvalue&0xFFFF;const mid=this.getContainerId(key);return mid===-1?false:this.containers[mid].contains(value);}remove(keyvalue){const key=keyvalue>>16;const value=keyvalue&0xFFFF;const mid=this.getContainerId(key);if(mid===-1){return this;}const container=this.containers[mid];if(!container.contains(value)){return this;}const newCardinality=(this.keysAndCardinalities[(mid*4)+2]|(this.keysAndCardinalities[(mid*4)+3]<<8));const l=this.containers.length;const m=l-(newCardinality===0?1:0);const result=new RoaringBitmap(null,0);result.keysAndCardinalities=new Uint8Array(m*4);let j=0;for(let i=0;i>8;const card=newCardinality-1;result.keysAndCardinalities[(j*4)+2]=card;result.keysAndCardinalities[(j*4)+3]=card>>8;const newContainer=new RoaringBitmapArray(newCardinality,new Uint8Array(newCardinality*2),);let newContainerSlot=0;for(const containerValue of container.values()){if(containerValue!==value){newContainer.array[newContainerSlot]=value&0xFF;newContainerSlot+=1;newContainer.array[newContainerSlot]=value>>8;newContainerSlot+=1;}}result.containers.push(newContainer);j+=1;}}else{result.keysAndCardinalities[(j*4)+0]=this.keysAndCardinalities[(i*4)+0];result.keysAndCardinalities[(j*4)+1]=this.keysAndCardinalities[(i*4)+1];result.keysAndCardinalities[(j*4)+2]=this.keysAndCardinalities[(i*4)+2];result.keysAndCardinalities[(j*4)+3]=this.keysAndCardinalities[(i*4)+3];result.containers.push(this.containers[i]);j+=1;}}return result;}getContainerId(key){let left=0;let right=this.containers.length-1;while(left<=right){const mid=Math.floor((left+right)/2);const x=this.keysAndCardinalities[(mid*4)]|(this.keysAndCardinalities[(mid*4)+1]<<8);if(xkey){right=mid-1;}else{return mid;}}return-1;}*entries(){const l=this.containers.length;for(let i=0;i>1;const i=mid*4;const start=this.array[i]|(this.array[i+1]<<8);const lenm1=this.array[i+2]|(this.array[i+3]<<8);if((start+lenm1)value){right=mid-1;}else{return true;}}return false;}*values(){let i=0;while(i>1;const i=mid*2;const x=this.array[i]|(this.array[i+1]<<8);if(xvalue){right=mid-1;}else{return true;}}return false;}*values(){let i=0;const l=this.cardinality*2;while(i>3]&(1<<(value&7)));}*values(){let i=0;const l=this.array.length<<3;while(i=this.values.length*9){const keys=this.keys;const values=this.values;const l=values.length;this.capacityClass+=1;const capacity=1<otherDistance){const otherKey=keys.slice(j,j+5);values[slot]=value;value=otherValue;keys[j+0]=key[start+0];keys[j+1]=key[start+1];keys[j+2]=key[start+2];keys[j+3]=key[start+3];keys[j+4]=key[start+4];key=otherKey;start=0;distance=otherDistance;}distance+=1;slot=(slot+1)&mask;}}}get(key){if(key.length!==5){throw"invalid key";}return this.getWithOffsetKey(key,0);}getWithOffsetKey(key,start){const mask=~(0xffffffff<otherDistance){break;}}slot=(slot+1)&mask;}return undefined;}}function bitCount(n){n=(~~n)-((n>>1)&0x55555555);n=(n&0x33333333)+((n>>2)&0x33333333);return((n+(n>>4)&0xF0F0F0F)*0x1010101)>>24;}class Uint8ArraySearchPattern{constructor(needle){this.needle=needle;this.skipTable=[];const m=needle.length;for(let i=0;i<256;i+=1){this.skipTable.push(m);}for(let i=0;i=m){for(let i=m-1;i>=0;i-=1){if(haystack[skip+i]!==needle[i]){skip+=skipTable[haystack[skip+m-1]];continue search;}}return true;}return false;}}function loadDatabase(hooks){const callbacks={rr_:function(data){const dataObj=JSON.parse(data);for(const colName of Object.keys(dataObj)){if(Object.hasOwn(dataObj[colName],"N")){const counts=[];const countsstring=dataObj[colName]["N"];let i=0;const l=countsstring.length;while(i>8)&0xFF,lengthOrData,0,),));bitmap.keysAndCardinalities=Uint8Array.of((id>>16)&0xff,(id>>24)&0xff,lengthOrData,0,);}else{bitmap=RoaringBitmap.makeSingleton(id);}let tree;if(isSuffixOnly){tree=new SuffixSearchTree(EMPTY_SEARCH_TREE_BRANCHES,isRun?0:(lengthOrData+1),bitmap,);}else{tree=new PrefixSearchTree(EMPTY_SEARCH_TREE_BRANCHES,EMPTY_SEARCH_TREE_BRANCHES,isRun?EMPTY_UINT8:Uint8Array.of(LONG_ALPHABET.chars[lengthOrData]),bitmap,EMPTY_BITMAP,);}newPromise=Promise.resolve(tree);}else{const existingPromise=registry.searchTreePromises.get(nodeid);if(existingPromise){return existingPromise;}const hashHex=makeHexFromUint8Array(nodeid);newPromise=new Promise((resolve,reject)=>{const cb=registry.searchTreeLoadPromiseCallbacks.get(nodeid);if(cb){registry.searchTreeLoadPromiseCallbacks.set(nodeid,(err,data)=>{cb(err,data);if(data){resolve(data);}else{reject(err);}});}else{registry.searchTreeLoadPromiseCallbacks.set(nodeid,(err,data)=>{if(data){resolve(data);}else{reject(err);}});hooks.loadTreeByHash(hashHex);}});registry.searchTreePromises.set(nodeid,newPromise);}return newPromise;},dataLoadByNameAndHash:function(name,hash){const existingBucket=registry.dataColumnsBuckets.get(hash);if(existingBucket){return existingBucket;}const hashHex=makeHexFromUint8Array(hash);const newBucket=new Promise((resolve,reject)=>{const cb=registry.dataColumnLoadPromiseCallbacks.get(hash);if(cb){registry.dataColumnLoadPromiseCallbacks.set(hash,(err,data)=>{cb(err,data);if(data){resolve(data);}else{reject(err);}});}else{registry.dataColumnLoadPromiseCallbacks.set(hash,(err,data)=>{if(data){resolve(data);}else{reject(err);}});hooks.loadDataByNameAndHash(name,hashHex);}});registry.dataColumnsBuckets.set(hash,newBucket);return newBucket;},};class SearchTreeBranches{constructor(length,nodeids){this.nodeids=nodeids;this.subtrees=[];for(let i=0;i=this.keys[i]){throw new Error("HERE");}i+=1;}}*entries(){let i=0;const l=this.keys.length;while(i>1;if(this.keys[mid]k){right=mid-1;}else{return mid;}}return-1;}getKey(i){return this.keys[i];}getKeys(){return this.keys;}}const EMPTY_SEARCH_TREE_BRANCHES=new SearchTreeBranchesArray(EMPTY_UINT8,EMPTY_UINT8,);class Alphabet{constructor(){this.chars=[];this.len=0;this.bytes=0;this.flag=0;this.bitwidth=0;}contains(c){return this.chars.indexOf(c)!==-1;}index(c){return this.chars.indexOf(c);}}const VOWELONLY_ALPHABITMAP=Object.assign(new Alphabet(),{chars:[0x61,0x65,0x69,0x6f,0x75],len:5,bytes:0,flag:0x80,},);const CONSONANTSONLY_ALPHABET=Object.assign(new Alphabet(),{chars:[],len:21,bytes:2,flag:0xc0,},);for(let i=0x61;i<=0x7A;++i){if(i===0x61||i===0x65||i===0x69||i===0x6f||i===0x75){continue;}CONSONANTSONLY_ALPHABET.chars.push(i);}const HEX_ALPHABET=Object.assign(new Alphabet(),{chars:[],len:16,bytes:2,flag:0xfc,},);for(let i=0x30;i<=0x39;++i){HEX_ALPHABET.chars.push(i);}for(let i=0x61;i<=0x66;++i){HEX_ALPHABET.chars.push(i);}const SHORT_ALPHABET=Object.assign(new Alphabet(),{chars:[],len:24,bytes:3,flag:0xfd,},);for(let i=0x61;i<=0x7A;++i){if(i===0x76||i===0x71){continue;}SHORT_ALPHABET.chars.push(i);}const LONG_ALPHABET=Object.assign(new Alphabet(),{chars:[0x31,0x32,0x33,0x34,0x35,0x36],len:32,bytes:4,flag:0xfe,},);for(let i=0x61;i<=0x7A;++i){LONG_ALPHABET.chars.push(i);}const ASCII_ALPHABET=Object.assign(new Alphabet(),{chars:[],len:128,bytes:16,flag:0xf0,contains(c){return c<=0x7f;},index(c){return c;},},);for(let i=0x00;i<=0x7f;++i){ASCII_ALPHABET.chars.push(i);}const RAWBYTE_ALPHABET=Object.assign(new Alphabet(),{chars:[],len:256,bytes:32,flag:0xff,contains(_c){return true;},index(c){return c;},},);for(let i=0x00;i<=0xff;++i){RAWBYTE_ALPHABET.chars.push(i);}Alphabet.parse=function(start,buf){const flag=buf[start];const parsed=Alphabet.parseFlag(flag,start+1,buf);if(!parsed){return null;}parsed.consumed_len_bytes+=1;return parsed;};Alphabet.parseFlag=function(flag,i,buf){if(flag<=0x80){return null;}const alphabet=flag===RAWBYTE_ALPHABET.flag?RAWBYTE_ALPHABET:(flag===ASCII_ALPHABET.flag?ASCII_ALPHABET:(flag===LONG_ALPHABET.flag?LONG_ALPHABET:(flag===SHORT_ALPHABET.flag?SHORT_ALPHABET:(flag===HEX_ALPHABET.flag?HEX_ALPHABET:(flag>=CONSONANTSONLY_ALPHABET.flag?CONSONANTSONLY_ALPHABET:VOWELONLY_ALPHABITMAP)))));let len=alphabet===CONSONANTSONLY_ALPHABET||alphabet===VOWELONLY_ALPHABITMAP?bitCount(flag&0x1f):0;for(let ix=0;ix>3]&(1<<(i&0x07))){yield[this.alphabet.chars[i],this.subtrees[j]];j+=1;}i+=1;}}getIndex(c){if(!this.alphabet.contains(c)){return-1;}const k=this.alphabet.index(c);if(this.bitmap[k>>3]&(1<<(k&0x07))){let result=bitCount(~(0xff<<(k&0x07))&this.bitmap[k>>3]);for(let ix=0;ix<(k>>3);++ix){result+=bitCount(this.bitmap[ix]);}return result;}else{return-1;}}getKey(branch_index){return this.getKeys()[branch_index];}getKeys(){let length=0;for(let i=0;i>3]&(1<<(ix&0x07))){result[result_index]=this.alphabet.chars[ix];result_index+=1;}}return result;}}class PrefixSearchTree{constructor(branches,might_have_prefix_branches,data,leaves_whole,leaves_suffix,){this.might_have_prefix_branches=might_have_prefix_branches;this.branches=branches;this.data=data;this.leaves_suffix=leaves_suffix;this.leaves_whole=leaves_whole;}trie(dataColumn,searchPattern){return new PrefixTrie(this,0,dataColumn,searchPattern);}async search(name,dataColumn){if(typeof name==="string"){const utf8encoder=new TextEncoder();name=utf8encoder.encode(name);}const searchPattern=new Uint8ArraySearchPattern(name);let trie=this.trie(dataColumn,searchPattern);for(const datum of name){const newTrie=trie.child(datum);if(newTrie){trie=await newTrie;}else{return null;}}return trie;}async*searchLev(name,dataColumn){if(typeof name==="string"){const utf8encoder=new TextEncoder();name=utf8encoder.encode(name);}const w=name.length;if(w<3){const trie=await this.search(name,dataColumn);if(trie!==null){yield trie;}return;}const searchPattern=new Uint8ArraySearchPattern(name);const levParams=w>=6?new Lev2TParametricDescription(w):new Lev1TParametricDescription(w);const stack=[[Promise.resolve(this.trie(dataColumn,searchPattern)),0]];const n=levParams.n;while(stack.length!==0){const[triePromise,levState]=stack.pop();const trie=await triePromise;for(const byte of trie.keysExcludeSuffixOnly()){const levPos=levParams.getPosition(levState);const vector=levParams.getVector(name,byte,levPos,Math.min(w,levPos+(2*n)+1),);const newLevState=levParams.transition(levState,levPos,vector,);if(newLevState>=0){const child=trie.child(byte);if(child){stack.push([child,newLevState]);if(levParams.isAccept(newLevState)){yield child;}}}}}}getCurrentLeaves(){return this.leaves_whole.union(this.leaves_suffix);}}class PrefixTrie{constructor(tree,offset,dataColumn,searchPattern){this.tree=tree;this.offset=offset;this.dataColumn=dataColumn;this.searchPattern=searchPattern;}matches(){if(this.offset===this.tree.data.length){return this.tree.leaves_whole;}else{return EMPTY_BITMAP;}}async*substringMatches(){let layer=[Promise.resolve(this.tree)];while(layer.length){const current_layer=layer;layer=[];for await(const tree of current_layer){let rejected=null;let leaves=tree.getCurrentLeaves();for(const leaf of leaves.entries()){const haystack=await this.dataColumn.at(leaf);if(haystack===undefined||!this.searchPattern.matches(haystack)){if(!rejected){rejected=[];}rejected.push(leaf);}}if(rejected){if(leaves.cardinality()!==rejected.length){for(const rej of rejected){leaves=leaves.remove(rej);}yield leaves;}}else{yield leaves;}}const subnodes=new HashTable();for await(const nodeEncoded of current_layer){const node=nodeEncoded instanceof InlineNeighborsTree?nodeEncoded.decode():nodeEncoded;const branches=node.branches;const l=branches.subtrees.length;for(let i=0;i0&&backlog[backlogSlot].length>1].length){const parentSlot=(backlogSlot-1)>>1;const parent=backlog[parentSlot];backlog[parentSlot]=backlog[backlogSlot];backlog[backlogSlot]=parent;backlogSlot=parentSlot;}}while(backlog.length!==0){const backlogEntry=backlog[0];if(minLength!==null&&backlogEntry.length>minLength){break;}if(!backlogEntry.bitmap.isEmpty()){yield backlogEntry.bitmap;}backlog[0]=backlog[backlog.length-1];backlog.length-=1;let backlogSlot=0;const backlogLength=backlog.length;while(backlogSlot{return node.trie(this.dataColumn,this.searchPattern);})]);i+=1;}return nodes;}else{const codePoint=data[this.offset];const trie=new PrefixTrie(this.tree,this.offset+1,this.dataColumn,this.searchPattern,);return[[codePoint,Promise.resolve(trie)]];}}keysExcludeSuffixOnly(){const data=this.tree.data;if(this.offset===data.length){return this.tree.might_have_prefix_branches.getKeys();}else{return Uint8Array.of(data[this.offset]);}}childrenExcludeSuffixOnly(){const data=this.tree.data;if(this.offset===data.length){const nodes=[];let i=0;for(const[k,v]of this.tree.might_have_prefix_branches.entries()){let node;if(v){node=v;}else{const newnode=this.tree.might_have_prefix_branches.getNodeID(i);if(!newnode){throw new Error(`malformed tree; no node for key ${k}`);}node=registry.searchTreeLoadByNodeID(newnode);this.tree.might_have_prefix_branches.subtrees[i]=node;this.tree.branches.subtrees[this.tree.branches.getIndex(k)]=node;}nodes.push([k,node.then(node=>{return node.trie(this.dataColumn,this.searchPattern);})]);i+=1;}return nodes;}else{const codePoint=data[this.offset];const trie=new PrefixTrie(this.tree,this.offset+1,this.dataColumn,this.searchPattern,);return[[codePoint,Promise.resolve(trie)]];}}child(byte){if(this.offset===this.tree.data.length){const i=this.tree.branches.getIndex(byte);if(i!==-1){let branch=this.tree.branches.subtrees[i];if(branch===null){const newnode=this.tree.branches.getNodeID(i);if(!newnode){throw new Error(`malformed tree; no node for key ${byte}`);}branch=registry.searchTreeLoadByNodeID(newnode);this.tree.branches.subtrees[i]=branch;const mhpI=this.tree.might_have_prefix_branches.getIndex(byte);if(mhpI!==-1){this.tree.might_have_prefix_branches.subtrees[mhpI]=branch;}}return branch.then(branch=>branch.trie(this.dataColumn,this.searchPattern));}}else if(this.tree.data[this.offset]===byte){return Promise.resolve(new PrefixTrie(this.tree,this.offset+1,this.dataColumn,this.searchPattern,));}return null;}}class SuffixSearchTree{constructor(branches,dataLen,leaves_suffix,){this.branches=branches;this.dataLen=dataLen;this.leaves_suffix=leaves_suffix;}trie(dataColumn,searchPattern){return new SuffixTrie(this,0,dataColumn,searchPattern);}async search(name,dataColumn){if(typeof name==="string"){const utf8encoder=new TextEncoder();name=utf8encoder.encode(name);}const searchPattern=new Uint8ArraySearchPattern(name);let trie=this.trie(dataColumn,searchPattern);for(const datum of name){const newTrie=trie.child(datum);if(newTrie){trie=await newTrie;}else{return null;}}return trie;}async*searchLev(_name,_dataColumn){}getCurrentLeaves(){return this.leaves_suffix;}}class SuffixTrie{constructor(tree,offset,dataColumn,searchPattern){this.tree=tree;this.offset=offset;this.dataColumn=dataColumn;this.searchPattern=searchPattern;}matches(){return EMPTY_BITMAP;}async*substringMatches(){let layer=[Promise.resolve(this.tree)];while(layer.length){const current_layer=layer;layer=[];for await(const tree of current_layer){let rejected=null;let leaves=tree.getCurrentLeaves();for(const leaf of leaves.entries()){const haystack=await this.dataColumn.at(leaf);if(haystack===undefined||!this.searchPattern.matches(haystack)){if(!rejected){rejected=[];}rejected.push(leaf);}}if(rejected){if(leaves.cardinality()!==rejected.length){for(const rej of rejected){leaves=leaves.remove(rej);}yield leaves;}}else{yield leaves;}}const subnodes=new HashTable();for await(const nodeEncoded of current_layer){const node=nodeEncoded instanceof InlineNeighborsTree?nodeEncoded.decode():nodeEncoded;const branches=node.branches;const l=branches.subtrees.length;for(let i=0;ibranch.trie(this.dataColumn,this.searchPattern));}}else{return Promise.resolve(new SuffixTrie(this.tree,this.offset+1,this.dataColumn,this.searchPattern,));}return null;}}class InlineNeighborsTree{constructor(encoded,start,){this.encoded=encoded;this.start=start;}decode(){let i=this.start;const encoded=this.encoded;const has_branches=(encoded[i]&0x04)!==0;const is_suffixes_only=(encoded[i]&0x01)!==0;const leaves_count=(encoded[i]>>4)&0x07;const leaves_is_run=(encoded[i]>>7)!==0;i+=1;let branch_flag=0;if(has_branches){branch_flag=encoded[i];i+=1;}const dlen=encoded[i]&0x3f;i+=1;let data=EMPTY_UINT8;if(!is_suffixes_only&&dlen!==0){data=encoded.subarray(i,i+dlen);i+=dlen;}const branch_flag_alphabet=Alphabet.parseFlag(branch_flag,i,encoded);let branch_alphabitmap=EMPTY_UINT8;if(branch_flag_alphabet){branch_alphabitmap=new Uint8Array(branch_flag_alphabet.consumed_len_bytes+1);branch_alphabitmap[0]=branch_flag;branch_alphabitmap.set(encoded.subarray(i,i+branch_flag_alphabet.consumed_len_bytes),1,);i+=branch_flag_alphabet.consumed_len_bytes;}const branch_count=branch_flag_alphabet?branch_flag_alphabet.len:branch_flag;const leaf_value_upper=encoded[i]|(encoded[i+1]<<8);i+=2;const branch_nodes=[];for(let j=0;j>4)&0x07;const branch_leaves_is_run=(encoded[i]>>7)!==0;i+=1;let branch_data=EMPTY_UINT8;if(!is_suffixes_only&&branch_dlen!==0){branch_data=encoded.subarray(i,i+branch_dlen);i+=branch_dlen;}const branch_leaves=new RoaringBitmap(null);branch_leaves.keysAndCardinalities=Uint8Array.of(leaf_value_upper&0xff,(leaf_value_upper>>8)&0xff,(branch_leaves_count-1)&0xff,((branch_leaves_count-1)>>8)&0xff,);if(branch_leaves_is_run){branch_leaves.containers=[new RoaringBitmapRun(1,Uint8Array.of(encoded[i],encoded[i+1],branch_leaves_count-1,0,),),];i+=2;}else{branch_leaves.containers=[new RoaringBitmapArray(branch_leaves_count,encoded.subarray(i,i+(branch_leaves_count*2)),),];i+=branch_leaves_count*2;}branch_nodes.push(Promise.resolve(is_suffixes_only?new SuffixSearchTree(EMPTY_SEARCH_TREE_BRANCHES,branch_dlen,branch_leaves,):new PrefixSearchTree(EMPTY_SEARCH_TREE_BRANCHES,EMPTY_SEARCH_TREE_BRANCHES,branch_data,branch_leaves,EMPTY_BITMAP,),));}const branches=branch_count===0?EMPTY_SEARCH_TREE_BRANCHES:branch_flag_alphabet?new SearchTreeBranchesAlphaBitmap(branch_flag_alphabet.alphabet,branch_alphabitmap,EMPTY_UINT8,):new SearchTreeBranchesArray(encoded.subarray(i,i+branch_count),EMPTY_UINT8,);if(!branch_flag_alphabet){i+=branch_count;}branches.subtrees=branch_nodes;let leaves=EMPTY_BITMAP;if(leaves_count!==0){leaves=new RoaringBitmap(null);leaves.keysAndCardinalities=Uint8Array.of(leaf_value_upper&0xff,(leaf_value_upper>>8)&0xff,(leaves_count-1)&0xff,((leaves_count-1)>>8)&0xff,);if(leaves_is_run){leaves.containers=[new RoaringBitmapRun(1,Uint8Array.of(encoded[i],encoded[i+1],leaves_count-1,0,),),];i+=2;}else{leaves.containers=[new RoaringBitmapArray(leaves_count,encoded.subarray(i,i+(leaves_count*2)),),];i+=leaves_count*2;}}return is_suffixes_only?new SuffixSearchTree(branches,dlen,leaves,):new PrefixSearchTree(branches,branches,data,leaves,EMPTY_BITMAP,);}trie(dataColumn,searchPattern){const tree=this.decode();return tree instanceof SuffixSearchTree?new SuffixTrie(tree,0,dataColumn,searchPattern):new PrefixTrie(tree,0,dataColumn,searchPattern);}search(name,dataColumn){return this.decode().search(name,dataColumn);}searchLev(name,dataColumn){return this.decode().searchLev(name,dataColumn);}getCurrentLeaves(){return this.decode().getCurrentLeaves();}}class DataColumn{constructor(counts,hashes,emptyset,name,searchTree){this.searchTree=searchTree;this.hashes=hashes;this.emptyset=emptyset;this.name=name;this.buckets=[];this.bucket_keys=[];const l=counts.length;let k=0;let totalLength=0;for(let i=0;i=this.bucket_keys.length){return undefined;}else{const start=this.bucket_keys[idx];const bucket=this.buckets[idx];const data=this.buckets[idx].data;if(data===null){return this.atAsyncFetch(id,start,bucket);}else{return data[id-start];}}}}async atAsyncFetch(id,start,bucket){const{hash,end}=bucket;const dataSansEmptysetOrig=await registry.dataLoadByNameAndHash(this.name,hash,);let data=bucket.data;if(data!==null){return data[id-start];}const dataSansEmptyset=[...dataSansEmptysetOrig];let dataWithEmptyset=null;let pos=start;let insertCount=0;while(pos=48&&c<=63){dataSansEmptyset.push(backrefs[c-48]);i+=1;}else{let n=0;while(c<96){n=(n<<4)|(c&0xF);i+=1;c=data[i];}n=(n<<4)|(c&0xF);i+=1;const item=data.subarray(i,i+n);dataSansEmptyset.push(item);i+=n;backrefs.unshift(item);if(backrefs.length>16){backrefs.pop();}}}cb(null,dataSansEmptyset);}}function makeSearchTreeFromBase64(inputBase64){const input=makeUint8ArrayFromBase64(inputBase64);let i=0;const l=input.length;const stash=new HashTable();const hash=Uint8Array.of(0,0,0,0,0,0,0,0);const truncatedHash=hash.subarray(3,8);const hash_history=[];const data_history=[];let canonical=EMPTY_UINT8;let tree=new PrefixSearchTree(EMPTY_SEARCH_TREE_BRANCHES,EMPTY_SEARCH_TREE_BRANCHES,EMPTY_UINT8,EMPTY_BITMAP,EMPTY_BITMAP,);function makeBranchesFromBinaryData(input,i,compression_tag,){const is_pure_suffixes_only_node=(compression_tag&0x01)!==0x00;const is_stack_compressed=(compression_tag&0x02)!==0;const is_long_compressed=(compression_tag&0x04)!==0;const all_children_are_compressed=(compression_tag&0xF0)===0xF0&&!is_long_compressed;const any_children_are_compressed=(compression_tag&0xF0)!==0x00||is_long_compressed;const start_point=i;let cpalphabet=null;let cpalphabitmap=EMPTY_UINT8;let cplen;let csalphabet=null;let csalphabitmap=EMPTY_UINT8;let cslen;if(is_pure_suffixes_only_node){cplen=0;}else{const parsed=Alphabet.parse(i,input);if(parsed){cpalphabitmap=input.subarray(i,i+parsed.consumed_len_bytes);cpalphabet=parsed.alphabet;cplen=parsed.len;i+=parsed.consumed_len_bytes;}else{cplen=input[i];i+=1;}}const parsed=Alphabet.parse(i,input);if(parsed){csalphabitmap=input.subarray(i,i+parsed.consumed_len_bytes);csalphabet=parsed.alphabet;cslen=parsed.len;i+=parsed.consumed_len_bytes;}else{cslen=input[i];i+=1;}const branches_header=input.subarray(start_point,i);let j=0;let cpnodes;if(any_children_are_compressed){cpnodes=cplen===0?EMPTY_UINT8:new Uint8Array(cplen*5);while(j32){const raw_bits=new Uint8Array(RAWBYTE_ALPHABET.bytes+1);raw_bits[0]=RAWBYTE_ALPHABET.flag;const bits=raw_bits.subarray(1);const mhp_keys=might_have_prefix_branches.getKeys();const so_keys=branches.getKeys();let mhp_i=0;let so_i=0;let j=0;while(mhp_i>3]|=1<<(ix&0x07);mhp_i+=1;}else{const joff=j*5;const so_off=so_i*5;hashes[joff+0]=csnodes[so_off+0];hashes[joff+1]=csnodes[so_off+1];hashes[joff+2]=csnodes[so_off+2];hashes[joff+3]=csnodes[so_off+3];hashes[joff+4]=csnodes[so_off+4];const ix=so_keys[so_i];bits[ix>>3]|=1<<(ix&0x07);so_i+=1;}j+=1;}branches=new SearchTreeBranchesAlphaBitmap(RAWBYTE_ALPHABET,raw_bits,hashes,);}else{const merged_keys=new Uint8Array(cplen+cslen);const mhp_keys=might_have_prefix_branches.getKeys();const so_keys=branches.getKeys();let mhp_i=0;let so_i=0;let j=0;while(mhp_i>4)&0x07;const leaves_is_run=((compression_tag>>4)&0x08)!==0;const branch_flag=is_long_compressed?(compression_tag>>8)&0xff:0;if(is_data_compressed){data=data_history[data_history.length-dlen-1];dlen=data.length;}else if(is_pure_suffixes_only_node){data=EMPTY_UINT8;}else{data=dlen===0?EMPTY_UINT8:new Uint8Array(input.buffer,i+input.byteOffset,dlen);i+=dlen;}const branches_start=i;const branch_flag_alphabet=Alphabet.parseFlag(branch_flag,i,input);const branch_count=branch_flag_alphabet?branch_flag_alphabet.len:branch_flag;if(branch_flag_alphabet){i+=branch_flag_alphabet.consumed_len_bytes;}i+=2;for(let j=0;j>4)&0x0f;const branch_leaves_is_run=(input[i]>>7)!==0;i+=1;if(!is_pure_suffixes_only_node){i+=branch_dlen;}if(branch_leaves_is_run){i+=2;}else{i+=branch_leaves_count*2;}}if(!branch_flag_alphabet){i+=branch_count;}if(leaves_is_run){i+=2;}else{i+=leaves_count*2;}if(is_data_compressed){const clen=(1+(is_long_compressed?1:0)+1+dlen+(i-branches_start));const canonical=new Uint8Array(clen);let ci=0;canonical[ci]=input[start]^ 0x08;ci+=1;if(is_long_compressed){canonical[ci]=input[start+ci];ci+=1;}canonical[ci]=dlen|0xc0;ci+=1;for(let j=0;j1){if(is_pure_suffixes_only_node){data=EMPTY_UINT8;}else if(is_data_compressed){data=data_history[data_history.length-dlen-1];dlen=data.length;}else{data=dlen===0?EMPTY_UINT8:new Uint8Array(input.buffer,i+input.byteOffset,dlen);i+=dlen;}const{cpnodes,csnodes,consumed_len_bytes:branches_consumed_len_bytes,branches,might_have_prefix_branches,branches_header,branches_keys,}=no_branches_flag!==0?{cpnodes:EMPTY_UINT8,csnodes:EMPTY_UINT8,consumed_len_bytes:0,branches:EMPTY_SEARCH_TREE_BRANCHES,might_have_prefix_branches:EMPTY_SEARCH_TREE_BRANCHES,branches_header:EMPTY_UINT8,branches_keys:EMPTY_UINT8,}:makeBranchesFromBinaryData(input,i,compression_tag);i+=branches_consumed_len_bytes;let whole;let suffix;if(is_pure_suffixes_only_node){if(no_leaves_flag){whole=EMPTY_BITMAP;suffix=EMPTY_BITMAP;}else{suffix=input[i]===0?EMPTY_BITMAP1:new RoaringBitmap(input,i);i+=suffix.consumed_len_bytes;}tree=new SuffixSearchTree(branches,dlen,suffix,);const clen=((is_data_compressed?1:2)+branches_header.length+csnodes.length+branches_keys.length+suffix.consumed_len_bytes);if(canonical.length{registry.searchTreeRootCallback=(error,data)=>{if(data){resolve(data);}else{reject(error);}};hooks.loadRoot(callbacks);});}if(typeof window!=="undefined"){window.Stringdex={loadDatabase,};window.RoaringBitmap=RoaringBitmap;if(window.StringdexOnload){window.StringdexOnload.forEach(cb=>cb(window.Stringdex));}}else{module.exports.Stringdex={loadDatabase,};module.exports.RoaringBitmap=RoaringBitmap;}const makeUint8ArrayFromBase64=Uint8Array.fromBase64?Uint8Array.fromBase64:(string=>{const bytes_as_string=atob(string);const l=bytes_as_string.length;const bytes=new Uint8Array(l);for(let i=0;i{const alpha={"0":0,"1":1,"2":2,"3":3,"4":4,"5":5,"6":6,"7":7,"8":8,"9":9,"a":10,"b":11,"A":10,"B":11,"c":12,"d":13,"C":12,"D":13,"e":14,"f":15,"E":14,"F":15,};const l=string.length>>1;const bytes=new Uint8Array(l);for(let i=0;iarray.toHex()):(array=>{const alpha=["0","1","2","3","4","5","6","7","8","9","a","b","c","d","e","f",];const l=array.length;const v=[];for(let i=0;i>4]);v.push(alpha[array[i]&0xf]);}return v.join("");});function siphashOfBytes(input,k0lo,k0hi,k1lo,k1hi,output){let v0lo=k0lo ^ 0x70736575;let v0hi=k0hi ^ 0x736f6d65;let v1lo=k1lo ^ 0x6e646f6d;let v1hi=k1hi ^ 0x646f7261;let v2lo=k0lo ^ 0x6e657261;let v2hi=k0hi ^ 0x6c796765;let v3lo=k1lo ^ 0x79746573;let v3hi=k1hi ^ 0x74656462;const inputLength=input.length;let inputI=0;const left=inputLength&0x7;let milo=0;let mihi=0;while(inputI>>8;output[5]=(v0lo ^ v1lo ^ v2lo ^ v3lo)>>>16;output[4]=(v0lo ^ v1lo ^ v2lo ^ v3lo)>>>24;output[3]=(v0hi ^ v1hi ^ v2hi ^ v3hi)&0xff;output[2]=(v0hi ^ v1hi ^ v2hi ^ v3hi)>>>8;output[1]=(v0hi ^ v1hi ^ v2hi ^ v3hi)>>>16;output[0]=(v0hi ^ v1hi ^ v2hi ^ v3hi)>>>24;function u8ToU64le(offset,length){const n0=offset>>0)+(v1lo>>>0)>0xffffffff)?1:0))|0;v0lo=(v0lo+v1lo)|0;let v1lo_=v1lo;let v1hi_=v1hi;v1lo=(v1lo_<<13)|(v1hi_>>>19);v1hi=(v1hi_<<13)|(v1lo_>>>19);v1lo ^=v0lo;v1hi ^=v0hi;const v0lo_=v0lo;const v0hi_=v0hi;v0lo=v0hi_;v0hi=v0lo_;v2hi=(v2hi+v3hi+(((v2lo>>>0)+(v3lo>>>0)>0xffffffff)?1:0))|0;v2lo=(v2lo+v3lo)|0;let v3lo_=v3lo;let v3hi_=v3hi;v3lo=(v3lo_<<16)|(v3hi_>>>16);v3hi=(v3hi_<<16)|(v3lo_>>>16);v3lo ^=v2lo;v3hi ^=v2hi;v0hi=(v0hi+v3hi+(((v0lo>>>0)+(v3lo>>>0)>0xffffffff)?1:0))|0;v0lo=(v0lo+v3lo)|0;v3lo_=v3lo;v3hi_=v3hi;v3lo=(v3lo_<<21)|(v3hi_>>>11);v3hi=(v3hi_<<21)|(v3lo_>>>11);v3lo ^=v0lo;v3hi ^=v0hi;v2hi=(v2hi+v1hi+(((v2lo>>>0)+(v1lo>>>0)>0xffffffff)?1:0))|0;v2lo=(v2lo+v1lo)|0;v1lo_=v1lo;v1hi_=v1hi;v1lo=(v1lo_<<17)|(v1hi_>>>15);v1hi=(v1hi_<<17)|(v1lo_>>>15);v1lo ^=v2lo;v1hi ^=v2hi;const v2lo_=v2lo;const v2hi_=v2hi;v2lo=v2hi_;v2hi=v2lo_;}}class ParametricDescription{constructor(w,n,minErrors){this.w=w;this.n=n;this.minErrors=minErrors;}isAccept(absState){const state=Math.floor(absState/(this.w+1));const offset=absState%(this.w+1);return this.w-offset+this.minErrors[state]<=this.n;}getPosition(absState){return absState%(this.w+1);}getVector(name,charCode,pos,end){let vector=0;for(let i=pos;i>5;const bitStart=bitLoc&31;if(bitStart+bitsPerValue<=32){return((data[dataLoc]>>bitStart)&this.MASKS[bitsPerValue-1]);}else{const part=32-bitStart;return ~~(((data[dataLoc]>>bitStart)&this.MASKS[part-1])+((data[1+dataLoc]&this.MASKS[bitsPerValue-part-1])<Clone for SessionVerificationEvent",0],["impl Clone for QueuedOp",0],["impl Clone for MediaKind",0],["impl Clone for StreamKind",0],["impl Clone for MatchQuality",0],["impl Clone for DownloadEvent",0],["impl Clone for NetworkType",0],["impl Clone for ErrorKind",0],["impl Clone for PlaybackMode",0],["impl Clone for PlaybackOperation",0],["impl Clone for AutoplayDecision",0],["impl Clone for BackgroundAction",0],["impl Clone for PlayerStatusEvent",0],["impl Clone for MediaSource",0],["impl Clone for MediaType",0],["impl Clone for QueueContext",0],["impl Clone for AddPosition",0],["impl Clone for RepeatMode",0],["impl Clone for SkipAction",0],["impl Clone for VideoSeekStrategy",0],["impl Clone for MediaSessionType",0],["impl Clone for SleepTimerMode",0],["impl Clone for EndReason",0],["impl Clone for PlayerState",0],["impl Clone for ImageType",0],["impl Clone for RepoError",0],["impl Clone for SearchScope",0],["impl Clone for PollingHint",0],["impl Clone for EqPreset",0],["impl Clone for StreamingQuality",0],["impl Clone for VolumeLevel",0],["impl Clone for QueryParam",0],["impl Clone for AuthResult",0],["impl Clone for ServerInfo",0],["impl Clone for Session",0],["impl Clone for User",0],["impl Clone for CatalogIndexEvent",0],["impl Clone for CatalogSyncResult",0],["impl Clone for CatalogSyncStatus",0],["impl Clone for ResumeQueuedResult",0],["impl Clone for DiagnosticsBundle",0],["impl Clone for DiagnosticsInfo",0],["impl Clone for AlbumAffinityStatus",0],["impl Clone for AlbumRecommendation",0],["impl Clone for SmartCacheStats",0],["impl Clone for AlbumStorageInfo",0],["impl Clone for AlbumTrack",0],["impl Clone for DownloadManagerStats",0],["impl Clone for DownloadStats",0],["impl Clone for DownloadsResponse",0],["impl Clone for NetworkStateWrapperArg",0],["impl Clone for StorageStats",0],["impl Clone for PendingFavorite",0],["impl Clone for ExclusionCandidate",0],["impl Clone for MergedMediaItem",0],["impl Clone for FavoritesChangedEvent",0],["impl Clone for SearchUpdateEvent",0],["impl Clone for CachedItemPerson",0],["impl Clone for CachedPerson",0],["impl Clone for SeriesAudioPreference",0],["impl Clone for ActiveSession",0],["impl Clone for CachedItem",0],["impl Clone for CachedLibrary",0],["impl Clone for PlaybackProgress",0],["impl Clone for SecurityStatus",0],["impl Clone for ServerInfo",0],["impl Clone for UserInfo",0],["impl Clone for SyncQueueItem",0],["impl Clone for DrainReport",0],["impl Clone for QueuedRow",0],["impl Clone for ConnectivityChangeEvent",0],["impl Clone for ConnectivityReporter",0],["impl Clone for ConnectivityStatus",0],["impl Clone for CacheConfig",0],["impl Clone for SmartCache",0],["impl Clone for NetworkState",0],["impl Clone for NetworkStateHandle",0],["impl Clone for DownloadInfo",0],["impl Clone for DownloadTask",0],["impl Clone for JellyfinClient",0],["impl Clone for LmsSyncGroup",0],["impl Clone for NowPlayingItem",0],["impl Clone for PlayState",0],["impl Clone for SessionInfo",0],["impl Clone for HttpClient",0],["impl Clone for HttpConfig",0],["impl Clone for JellyfinConfig",0],["impl Clone for PlaybackContext",0],["impl Clone for PlaybackReporter",0],["impl Clone for EventThrottler",0],["impl Clone for AutoplaySettings",0],["impl Clone for PlayerError",0],["impl Clone for MediaItem",0],["impl Clone for SubtitleTrack",0],["impl Clone for QueueManager",0],["impl Clone for SleepTimerState",0],["impl Clone for ObservedTime",0],["impl Clone for LockscreenMetadata",0],["impl Clone for JRayActor",0],["impl Clone for JRayContext",0],["impl Clone for JellyfinMediaSource",0],["impl Clone for JellyfinMediaStream",0],["impl Clone for JellyfinUserData",0],["impl Clone for ArtistItem",0],["impl Clone for DownloadDiskUsage",0],["impl Clone for Genre",0],["impl Clone for GetItemsOptions",0],["impl Clone for ImageOptions",0],["impl Clone for Library",0],["impl Clone for LiveStreamInfo",0],["impl Clone for MediaItem",0],["impl Clone for MediaSource",0],["impl Clone for MediaStream",0],["impl Clone for Person",0],["impl Clone for PlaybackInfo",0],["impl Clone for PlaylistCreatedResult",0],["impl Clone for PlaylistEntry",0],["impl Clone for SearchOptions",0],["impl Clone for SearchResult",0],["impl Clone for UserData",0],["impl Clone for AudioSettings",0],["impl Clone for LibrarySettings",0],["impl Clone for VideoSettings",0],["impl Clone for Query",0],["impl Clone for PlaylistItem",0],["impl Clone for BackendInitError",0],["impl Clone for CacheConfig",0],["impl Clone for ThumbnailCacheStats",0]]]]); + if (window.register_implementors) { + window.register_implementors(implementors); + } else { + window.pending_implementors = implementors; + } +})() +//{"start":59,"fragment_lengths":[40208]} \ No newline at end of file diff --git a/api/trait.impl/core/cmp/trait.Eq.js b/api/trait.impl/core/cmp/trait.Eq.js new file mode 100644 index 00000000..f4448f78 --- /dev/null +++ b/api/trait.impl/core/cmp/trait.Eq.js @@ -0,0 +1,9 @@ +(function() { + const implementors = Object.fromEntries([["jellytau_lib",[["impl Eq for QueuedOp",0],["impl Eq for MediaKind",0],["impl Eq for StreamKind",0],["impl Eq for MatchQuality",0],["impl Eq for NetworkType",0],["impl Eq for Resolved",0],["impl Eq for PlaybackMode",0],["impl Eq for BackgroundAction",0],["impl Eq for MediaType",0],["impl Eq for AddPosition",0],["impl Eq for RepeatMode",0],["impl Eq for VideoSeekStrategy",0],["impl Eq for EndReason",0],["impl Eq for SearchScope",0],["impl Eq for PollingHint",0],["impl Eq for StreamingQuality",0],["impl Eq for PendingFavorite",0],["impl Eq for DrainReport",0],["impl Eq for NetworkState",0],["impl Eq for Span",0],["impl Eq for ArtistItem",0],["impl Eq for Person",0],["impl Eq for LibrarySettings",0]]]]); + if (window.register_implementors) { + window.register_implementors(implementors); + } else { + window.pending_implementors = implementors; + } +})() +//{"start":59,"fragment_lengths":[6667]} \ No newline at end of file diff --git a/api/trait.impl/core/cmp/trait.Ord.js b/api/trait.impl/core/cmp/trait.Ord.js new file mode 100644 index 00000000..9ada0408 --- /dev/null +++ b/api/trait.impl/core/cmp/trait.Ord.js @@ -0,0 +1,9 @@ +(function() { + const implementors = Object.fromEntries([["jellytau_lib",[["impl Ord for MatchQuality",0]]]]); + if (window.register_implementors) { + window.register_implementors(implementors); + } else { + window.pending_implementors = implementors; + } +})() +//{"start":59,"fragment_lengths":[319]} \ No newline at end of file diff --git a/api/trait.impl/core/cmp/trait.PartialEq.js b/api/trait.impl/core/cmp/trait.PartialEq.js new file mode 100644 index 00000000..ccf52eef --- /dev/null +++ b/api/trait.impl/core/cmp/trait.PartialEq.js @@ -0,0 +1,9 @@ +(function() { + const implementors = Object.fromEntries([["jellytau_lib",[["impl PartialEq for QueuedOp",0],["impl PartialEq for MediaKind",0],["impl PartialEq for StreamKind",0],["impl PartialEq for MatchQuality",0],["impl PartialEq for NetworkType",0],["impl PartialEq for ErrorKind",0],["impl PartialEq for Resolved",0],["impl PartialEq for PlaybackMode",0],["impl PartialEq for BackgroundAction",0],["impl PartialEq for MediaSource",0],["impl PartialEq for MediaType",0],["impl PartialEq for QueueContext",0],["impl PartialEq for AddPosition",0],["impl PartialEq for RepeatMode",0],["impl PartialEq for SkipAction",0],["impl PartialEq for VideoSeekStrategy",0],["impl PartialEq for MediaSessionType",0],["impl PartialEq for SleepTimerMode",0],["impl PartialEq for EndReason",0],["impl PartialEq for SearchScope",0],["impl PartialEq for PollingHint",0],["impl PartialEq for EqPreset",0],["impl PartialEq for StreamingQuality",0],["impl PartialEq for VolumeLevel",0],["impl PartialEq for AlbumTrack",0],["impl PartialEq for PendingFavorite",0],["impl PartialEq for DrainReport",0],["impl PartialEq for NetworkState",0],["impl PartialEq for Span",0],["impl PartialEq for MediaItem",0],["impl PartialEq for SubtitleTrack",0],["impl PartialEq for ArtistItem",0],["impl PartialEq for Person",0],["impl PartialEq for LibrarySettings",0]]]]); + if (window.register_implementors) { + window.register_implementors(implementors); + } else { + window.pending_implementors = implementors; + } +})() +//{"start":59,"fragment_lengths":[10540]} \ No newline at end of file diff --git a/api/trait.impl/core/cmp/trait.PartialOrd.js b/api/trait.impl/core/cmp/trait.PartialOrd.js new file mode 100644 index 00000000..d6c6155a --- /dev/null +++ b/api/trait.impl/core/cmp/trait.PartialOrd.js @@ -0,0 +1,9 @@ +(function() { + const implementors = Object.fromEntries([["jellytau_lib",[["impl PartialOrd for MatchQuality",0]]]]); + if (window.register_implementors) { + window.register_implementors(implementors); + } else { + window.pending_implementors = implementors; + } +})() +//{"start":59,"fragment_lengths":[340]} \ No newline at end of file diff --git a/api/trait.impl/core/convert/trait.From.js b/api/trait.impl/core/convert/trait.From.js new file mode 100644 index 00000000..8bf1d5f1 --- /dev/null +++ b/api/trait.impl/core/convert/trait.From.js @@ -0,0 +1,9 @@ +(function() { + const implementors = Object.fromEntries([["jellytau_lib",[["impl From<&NowPlayingItem> for MergedMediaItem",0],["impl From<&MediaItem> for MergedMediaItem",0],["impl From<&MediaItem> for AlbumTrack",0],["impl From<JellyfinUserData> for UserData",0]]]]); + if (window.register_implementors) { + window.register_implementors(implementors); + } else { + window.pending_implementors = implementors; + } +})() +//{"start":59,"fragment_lengths":[1963]} \ No newline at end of file diff --git a/api/trait.impl/core/default/trait.Default.js b/api/trait.impl/core/default/trait.Default.js new file mode 100644 index 00000000..79e1fcf0 --- /dev/null +++ b/api/trait.impl/core/default/trait.Default.js @@ -0,0 +1,9 @@ +(function() { + const implementors = Object.fromEntries([["jellytau_lib",[["impl Default for MediaKind",0],["impl Default for StreamKind",0],["impl Default for QueueContext",0],["impl Default for RepeatMode",0],["impl Default for PlayerState",0],["impl Default for StreamingQuality",0],["impl Default for VolumeLevel",0],["impl Default for DrainReport",0],["impl Default for ConnectivityStatus",0],["impl Default for CredentialStore",0],["impl Default for CacheConfig",0],["impl Default for NetworkState",0],["impl Default for NetworkStateHandle",0],["impl Default for HttpConfig",0],["impl Default for SyncConfig",0],["impl Default for EventThrottler",0],["impl Default for AutoplaySettings",0],["impl Default for NullBackend",0],["impl Default for QueueManager",0],["impl Default for MediaSessionManager",0],["impl Default for SleepTimerState",0],["impl Default for ObservedTime",0],["impl Default for ResumeTracker",0],["impl Default for PlayerController",0],["impl Default for GetItemsOptions",0],["impl Default for ImageOptions",0],["impl Default for MediaItem",0],["impl Default for SearchOptions",0],["impl Default for UserData",0],["impl Default for AudioSettings",0],["impl Default for LibrarySettings",0],["impl Default for VideoSettings",0],["impl Default for CacheConfig",0],["impl Default for ThumbnailWorker",0]]]]); + if (window.register_implementors) { + window.register_implementors(implementors); + } else { + window.pending_implementors = implementors; + } +})() +//{"start":59,"fragment_lengths":[10930]} \ No newline at end of file diff --git a/api/trait.impl/core/error/trait.Error.js b/api/trait.impl/core/error/trait.Error.js new file mode 100644 index 00000000..036cb02e --- /dev/null +++ b/api/trait.impl/core/error/trait.Error.js @@ -0,0 +1,9 @@ +(function() { + const implementors = Object.fromEntries([["jellytau_lib",[["impl Error for CredentialError",0],["impl Error for DownloadError",0],["impl Error for RepoError",0],["impl Error for ThumbnailDownloadError",0],["impl Error for PlayerError",0]]]]); + if (window.register_implementors) { + window.register_implementors(implementors); + } else { + window.pending_implementors = implementors; + } +})() +//{"start":59,"fragment_lengths":[1572]} \ No newline at end of file diff --git a/api/trait.impl/core/fmt/trait.Debug.js b/api/trait.impl/core/fmt/trait.Debug.js new file mode 100644 index 00000000..01df393e --- /dev/null +++ b/api/trait.impl/core/fmt/trait.Debug.js @@ -0,0 +1,9 @@ +(function() { + const implementors = Object.fromEntries([["jellytau_lib",[["impl Debug for SessionVerificationEvent",0],["impl Debug for AudioTrackSwitchResponse",0],["impl Debug for PlayQueueContext",0],["impl Debug for PlayTracksContext",0],["impl Debug for StreamQualityResponse",0],["impl Debug for VideoBackend",0],["impl Debug for VideoSeekResponse",0],["impl Debug for QueuedOp",0],["impl Debug for CredentialError",0],["impl Debug for CredentialResult",0],["impl Debug for MediaKind",0],["impl Debug for StreamKind",0],["impl Debug for MatchQuality",0],["impl Debug for DownloadEvent",0],["impl Debug for NetworkType",0],["impl Debug for DownloadError",0],["impl Debug for ErrorKind",0],["impl Debug for Resolved",0],["impl Debug for PlaybackMode",0],["impl Debug for PlaybackOperation",0],["impl Debug for AutoplayDecision",0],["impl Debug for BackgroundAction",0],["impl Debug for PlayerStatusEvent",0],["impl Debug for MediaSource",0],["impl Debug for MediaType",0],["impl Debug for QueueContext",0],["impl Debug for AddPosition",0],["impl Debug for RepeatMode",0],["impl Debug for SkipAction",0],["impl Debug for VideoSeekStrategy",0],["impl Debug for MediaSessionType",0],["impl Debug for SleepTimerMode",0],["impl Debug for EndReason",0],["impl Debug for PlayerState",0],["impl Debug for ImageTags",0],["impl Debug for ImageType",0],["impl Debug for RepoError",0],["impl Debug for SearchScope",0],["impl Debug for PollingHint",0],["impl Debug for EqPreset",0],["impl Debug for StreamingQuality",0],["impl Debug for VolumeLevel",0],["impl Debug for QueryParam",0],["impl Debug for ThumbnailDownloadError",0],["impl Debug for AuthResult",0],["impl Debug for AuthenticateByNameResponse",0],["impl Debug for JellyfinUser",0],["impl Debug for PublicSystemInfo",0],["impl Debug for ServerInfo",0],["impl Debug for Session",0],["impl Debug for User",0],["impl Debug for CatalogIndexEvent",0],["impl Debug for CatalogSyncResult",0],["impl Debug for CatalogSyncStatus",0],["impl Debug for ResumeQueuedResult",0],["impl Debug for DiagnosticsBundle",0],["impl Debug for DiagnosticsInfo",0],["impl Debug for AlbumAffinityStatus",0],["impl Debug for AlbumRecommendation",0],["impl Debug for SmartCacheStats",0],["impl Debug for AlbumStorageInfo",0],["impl Debug for AlbumTrack",0],["impl Debug for DownloadItemAndStartRequest",0],["impl Debug for DownloadItemRequest",0],["impl Debug for DownloadManagerStats",0],["impl Debug for DownloadStats",0],["impl Debug for DownloadVideoRequest",0],["impl Debug for DownloadsResponse",0],["impl Debug for NetworkStateWrapperArg",0],["impl Debug for StorageStats",0],["impl Debug for PendingFavorite",0],["impl Debug for ExclusionCandidate",0],["impl Debug for OfflineItem",0],["impl Debug for AddToQueueRequest",0],["impl Debug for AddTrackByIdRequest",0],["impl Debug for AddTracksByIdsRequest",0],["impl Debug for MergedMediaItem",0],["impl Debug for PlayAlbumTrackRequest",0],["impl Debug for PlayItemRequest",0],["impl Debug for PlayQueueRequest",0],["impl Debug for PlayTracksRequest",0],["impl Debug for PlaybackCapabilities",0],["impl Debug for PlayerStatus",0],["impl Debug for PreloadResult",0],["impl Debug for QueueStatus",0],["impl Debug for FavoritesChangedEvent",0],["impl Debug for SearchUpdateEvent",0],["impl Debug for CachedItemPerson",0],["impl Debug for CachedPerson",0],["impl Debug for SeriesAudioPreference",0],["impl Debug for ActiveSession",0],["impl Debug for CachedItem",0],["impl Debug for CachedLibrary",0],["impl Debug for PlaybackProgress",0],["impl Debug for SecurityStatus",0],["impl Debug for ServerInfo",0],["impl Debug for UserInfo",0],["impl Debug for GetImageRequest",0],["impl Debug for SyncQueueItem",0],["impl Debug for DrainReport",0],["impl Debug for QueuedRow",0],["impl Debug for ConnectivityChangeEvent",0],["impl Debug for ConnectivityStatus",0],["impl Debug for CacheConfig",0],["impl Debug for NetworkState",0],["impl Debug for DownloadInfo",0],["impl Debug for DownloadTask",0],["impl Debug for DownloadResult",0],["impl Debug for LmsSyncGroup",0],["impl Debug for NowPlayingItem",0],["impl Debug for PlayState",0],["impl Debug for SessionInfo",0],["impl Debug for HttpConfig",0],["impl Debug for JellyfinConfig",0],["impl Debug for PlaybackProgressRequest",0],["impl Debug for PlaybackStartRequest",0],["impl Debug for PlaybackStoppedRequest",0],["impl Debug for Span",0],["impl Debug for PlaybackContext",0],["impl Debug for AutoplaySettings",0],["impl Debug for PlayerError",0],["impl Debug for MediaItem",0],["impl Debug for SubtitleTrack",0],["impl Debug for QueueManager",0],["impl Debug for SleepTimerState",0],["impl Debug for ObservedTime",0],["impl Debug for ResumeTracker",0],["impl Debug for LockscreenMetadata",0],["impl Debug for CachedItem",0],["impl Debug for CreatePlaylistResponse",0],["impl Debug for ItemsResponse",0],["impl Debug for JRayActor",0],["impl Debug for JRayContext",0],["impl Debug for JellyfinItem",0],["impl Debug for JellyfinMediaSource",0],["impl Debug for JellyfinMediaStream",0],["impl Debug for JellyfinPlaylistItem",0],["impl Debug for JellyfinUserData",0],["impl Debug for PlaylistItemsResponse",0],["impl Debug for ArtistItem",0],["impl Debug for DownloadDiskUsage",0],["impl Debug for Genre",0],["impl Debug for GetItemsOptions",0],["impl Debug for ImageOptions",0],["impl Debug for Library",0],["impl Debug for LiveStreamInfo",0],["impl Debug for MediaItem",0],["impl Debug for MediaSource",0],["impl Debug for MediaStream",0],["impl Debug for Person",0],["impl Debug for PlaybackInfo",0],["impl Debug for PlaylistCreatedResult",0],["impl Debug for PlaylistEntry",0],["impl Debug for SearchOptions",0],["impl Debug for SearchResult",0],["impl Debug for UserData",0],["impl Debug for AudioSettings",0],["impl Debug for LibrarySettings",0],["impl Debug for VideoSettings",0],["impl Debug for PlaylistItem",0],["impl Debug for CacheConfig",0],["impl Debug for ThumbnailCacheStats",0]]]]); + if (window.register_implementors) { + window.register_implementors(implementors); + } else { + window.pending_implementors = implementors; + } +})() +//{"start":59,"fragment_lengths":[50589]} \ No newline at end of file diff --git a/api/trait.impl/core/fmt/trait.Display.js b/api/trait.impl/core/fmt/trait.Display.js new file mode 100644 index 00000000..2bf169ea --- /dev/null +++ b/api/trait.impl/core/fmt/trait.Display.js @@ -0,0 +1,9 @@ +(function() { + const implementors = Object.fromEntries([["jellytau_lib",[["impl Display for CredentialError",0],["impl Display for DownloadError",0],["impl Display for RepoError",0],["impl Display for ThumbnailDownloadError",0],["impl Display for PlayerError",0]]]]); + if (window.register_implementors) { + window.register_implementors(implementors); + } else { + window.pending_implementors = implementors; + } +})() +//{"start":59,"fragment_lengths":[1582]} \ No newline at end of file diff --git a/api/trait.impl/core/marker/trait.Copy.js b/api/trait.impl/core/marker/trait.Copy.js new file mode 100644 index 00000000..67be5a47 --- /dev/null +++ b/api/trait.impl/core/marker/trait.Copy.js @@ -0,0 +1,9 @@ +(function() { + const implementors = Object.fromEntries([["jellytau_lib",[["impl Copy for MediaKind",0],["impl Copy for StreamKind",0],["impl Copy for MatchQuality",0],["impl Copy for NetworkType",0],["impl Copy for BackgroundAction",0],["impl Copy for MediaType",0],["impl Copy for AddPosition",0],["impl Copy for RepeatMode",0],["impl Copy for SkipAction",0],["impl Copy for VideoSeekStrategy",0],["impl Copy for EndReason",0],["impl Copy for SearchScope",0],["impl Copy for PollingHint",0],["impl Copy for EqPreset",0],["impl Copy for StreamingQuality",0],["impl Copy for VolumeLevel",0],["impl Copy for NetworkState",0],["impl Copy for ObservedTime",0]]]]); + if (window.register_implementors) { + window.register_implementors(implementors); + } else { + window.pending_implementors = implementors; + } +})() +//{"start":59,"fragment_lengths":[5403]} \ No newline at end of file diff --git a/api/trait.impl/core/marker/trait.Freeze.js b/api/trait.impl/core/marker/trait.Freeze.js new file mode 100644 index 00000000..42f3ec93 --- /dev/null +++ b/api/trait.impl/core/marker/trait.Freeze.js @@ -0,0 +1,9 @@ +(function() { + const implementors = Object.fromEntries([["jellytau_lib",[["impl !Freeze for DownloadManagerWrapper",1,1,["jellytau_lib::commands::download::DownloadManagerWrapper"]],["impl !Freeze for MediaSessionManagerWrapper",1,1,["jellytau_lib::commands::player::MediaSessionManagerWrapper"]],["impl !Freeze for SmartCacheWrapper",1,1,["jellytau_lib::commands::player::SmartCacheWrapper"]],["impl !Freeze for VideoSettingsWrapper",1,1,["jellytau_lib::commands::player::VideoSettingsWrapper"]],["impl !Freeze for CredentialStoreWrapper",1,1,["jellytau_lib::commands::storage::CredentialStoreWrapper"]],["impl !Freeze for DatabaseWrapper",1,1,["jellytau_lib::commands::storage::DatabaseWrapper"]],["impl Freeze for SessionVerificationEvent",0,1,["jellytau_lib::auth::session_verifier::SessionVerificationEvent"]],["impl Freeze for AudioTrackSwitchResponse",0,1,["jellytau_lib::commands::player::AudioTrackSwitchResponse"]],["impl Freeze for PlayQueueContext",0,1,["jellytau_lib::commands::player::PlayQueueContext"]],["impl Freeze for PlayTracksContext",0,1,["jellytau_lib::commands::player::PlayTracksContext"]],["impl Freeze for StreamQualityResponse",0,1,["jellytau_lib::commands::player::StreamQualityResponse"]],["impl Freeze for VideoBackend",0,1,["jellytau_lib::commands::player::VideoBackend"]],["impl Freeze for VideoSeekResponse",0,1,["jellytau_lib::commands::player::VideoSeekResponse"]],["impl Freeze for QueuedOp",0,1,["jellytau_lib::commands::sync_drain::QueuedOp"]],["impl Freeze for CredentialError",0,1,["jellytau_lib::credentials::CredentialError"]],["impl Freeze for CredentialResult",0,1,["jellytau_lib::credentials::CredentialResult"]],["impl Freeze for MediaKind",0,1,["jellytau_lib::domain::media::MediaKind"]],["impl Freeze for StreamKind",0,1,["jellytau_lib::domain::media::StreamKind"]],["impl Freeze for MatchQuality",0,1,["jellytau_lib::domain::search_rank::MatchQuality"]],["impl Freeze for DownloadEvent",0,1,["jellytau_lib::download::events::DownloadEvent"]],["impl Freeze for NetworkType",0,1,["jellytau_lib::download::network::NetworkType"]],["impl Freeze for DownloadError",0,1,["jellytau_lib::download::worker::DownloadError"]],["impl Freeze for ErrorKind",0,1,["jellytau_lib::jellyfin::http_client::ErrorKind"]],["impl Freeze for Resolved",0,1,["jellytau_lib::media_server::Resolved"]],["impl Freeze for PlaybackMode",0,1,["jellytau_lib::playback_mode::PlaybackMode"]],["impl Freeze for PlaybackOperation",0,1,["jellytau_lib::playback_reporting::reporter::PlaybackOperation"]],["impl Freeze for AutoplayDecision",0,1,["jellytau_lib::player::autoplay::AutoplayDecision"]],["impl Freeze for BackgroundAction",0,1,["jellytau_lib::player::background_policy::BackgroundAction"]],["impl Freeze for PlayerStatusEvent",0,1,["jellytau_lib::player::events::PlayerStatusEvent"]],["impl Freeze for MediaSource",0,1,["jellytau_lib::player::media::MediaSource"]],["impl Freeze for MediaType",0,1,["jellytau_lib::player::media::MediaType"]],["impl Freeze for QueueContext",0,1,["jellytau_lib::player::media::QueueContext"]],["impl Freeze for AddPosition",0,1,["jellytau_lib::player::queue::AddPosition"]],["impl Freeze for RepeatMode",0,1,["jellytau_lib::player::queue::RepeatMode"]],["impl Freeze for SkipAction",0,1,["jellytau_lib::player::seek::SkipAction"]],["impl Freeze for VideoSeekStrategy",0,1,["jellytau_lib::player::seek::VideoSeekStrategy"]],["impl Freeze for MediaSessionType",0,1,["jellytau_lib::player::session::MediaSessionType"]],["impl Freeze for SleepTimerMode",0,1,["jellytau_lib::player::sleep_timer::SleepTimerMode"]],["impl Freeze for EndReason",0,1,["jellytau_lib::player::state::EndReason"]],["impl Freeze for PlayerState",0,1,["jellytau_lib::player::state::PlayerState"]],["impl Freeze for ImageTags",0,1,["jellytau_lib::repository::online::ImageTags"]],["impl Freeze for ImageType",0,1,["jellytau_lib::repository::types::ImageType"]],["impl Freeze for RepoError",0,1,["jellytau_lib::repository::types::RepoError"]],["impl Freeze for SearchScope",0,1,["jellytau_lib::repository::types::SearchScope"]],["impl Freeze for PollingHint",0,1,["jellytau_lib::session_poller::PollingHint"]],["impl Freeze for EqPreset",0,1,["jellytau_lib::settings::EqPreset"]],["impl Freeze for StreamingQuality",0,1,["jellytau_lib::settings::StreamingQuality"]],["impl Freeze for VolumeLevel",0,1,["jellytau_lib::settings::VolumeLevel"]],["impl Freeze for QueryParam",0,1,["jellytau_lib::storage::db_service::QueryParam"]],["impl Freeze for ThumbnailDownloadError",0,1,["jellytau_lib::thumbnail::worker::ThumbnailDownloadError"]],["impl Freeze for SessionVerifier",0,1,["jellytau_lib::auth::session_verifier::SessionVerifier"]],["impl Freeze for AuthManager",0,1,["jellytau_lib::auth::AuthManager"]],["impl Freeze for AuthResult",0,1,["jellytau_lib::auth::AuthResult"]],["impl Freeze for AuthenticateByNameResponse",0,1,["jellytau_lib::auth::AuthenticateByNameResponse"]],["impl Freeze for JellyfinUser",0,1,["jellytau_lib::auth::JellyfinUser"]],["impl Freeze for PublicSystemInfo",0,1,["jellytau_lib::auth::PublicSystemInfo"]],["impl Freeze for ServerInfo",0,1,["jellytau_lib::auth::ServerInfo"]],["impl Freeze for Session",0,1,["jellytau_lib::auth::Session"]],["impl Freeze for User",0,1,["jellytau_lib::auth::User"]],["impl Freeze for AuthManagerWrapper",0,1,["jellytau_lib::commands::auth::AuthManagerWrapper"]],["impl Freeze for SessionVerifierWrapper",0,1,["jellytau_lib::commands::auth::SessionVerifierWrapper"]],["impl Freeze for CatalogIndexEvent",0,1,["jellytau_lib::commands::catalog::CatalogIndexEvent"]],["impl Freeze for CatalogSyncResult",0,1,["jellytau_lib::commands::catalog::CatalogSyncResult"]],["impl Freeze for CatalogSyncStatus",0,1,["jellytau_lib::commands::catalog::CatalogSyncStatus"]],["impl Freeze for IndexPassGuard",0,1,["jellytau_lib::commands::catalog::IndexPassGuard"]],["impl Freeze for ResumeQueuedResult",0,1,["jellytau_lib::commands::catalog::ResumeQueuedResult"]],["impl Freeze for ConnectivityMonitorWrapper",0,1,["jellytau_lib::commands::connectivity::ConnectivityMonitorWrapper"]],["impl Freeze for DiagnosticsBundle",0,1,["jellytau_lib::commands::diagnostics::DiagnosticsBundle"]],["impl Freeze for DiagnosticsInfo",0,1,["jellytau_lib::commands::diagnostics::DiagnosticsInfo"]],["impl Freeze for AlbumAffinityStatus",0,1,["jellytau_lib::commands::download::smart_cache::AlbumAffinityStatus"]],["impl Freeze for AlbumRecommendation",0,1,["jellytau_lib::commands::download::smart_cache::AlbumRecommendation"]],["impl Freeze for SmartCacheStats",0,1,["jellytau_lib::commands::download::smart_cache::SmartCacheStats"]],["impl Freeze for AlbumStorageInfo",0,1,["jellytau_lib::commands::download::AlbumStorageInfo"]],["impl Freeze for AlbumTrack",0,1,["jellytau_lib::commands::download::AlbumTrack"]],["impl Freeze for DownloadItemAndStartRequest",0,1,["jellytau_lib::commands::download::DownloadItemAndStartRequest"]],["impl Freeze for DownloadItemRequest",0,1,["jellytau_lib::commands::download::DownloadItemRequest"]],["impl Freeze for DownloadManagerStats",0,1,["jellytau_lib::commands::download::DownloadManagerStats"]],["impl Freeze for DownloadStats",0,1,["jellytau_lib::commands::download::DownloadStats"]],["impl Freeze for DownloadVideoRequest",0,1,["jellytau_lib::commands::download::DownloadVideoRequest"]],["impl Freeze for DownloadsResponse",0,1,["jellytau_lib::commands::download::DownloadsResponse"]],["impl Freeze for NetworkStateWrapper",0,1,["jellytau_lib::commands::download::NetworkStateWrapper"]],["impl Freeze for NetworkStateWrapperArg",0,1,["jellytau_lib::commands::download::NetworkStateWrapperArg"]],["impl Freeze for StorageStats",0,1,["jellytau_lib::commands::download::StorageStats"]],["impl Freeze for PendingFavorite",0,1,["jellytau_lib::commands::favorites::PendingFavorite"]],["impl Freeze for ExclusionCandidate",0,1,["jellytau_lib::commands::library::ExclusionCandidate"]],["impl Freeze for OfflineItem",0,1,["jellytau_lib::commands::offline::OfflineItem"]],["impl Freeze for PlaybackModeManagerWrapper",0,1,["jellytau_lib::commands::playback_mode::PlaybackModeManagerWrapper"]],["impl Freeze for RemoteSessionStatus",0,1,["jellytau_lib::commands::playback_mode::RemoteSessionStatus"]],["impl Freeze for PlaybackReporterWrapper",0,1,["jellytau_lib::commands::playback_reporting::PlaybackReporterWrapper"]],["impl Freeze for AddToQueueRequest",0,1,["jellytau_lib::commands::player::queue::AddToQueueRequest"]],["impl Freeze for AddTrackByIdRequest",0,1,["jellytau_lib::commands::player::queue::AddTrackByIdRequest"]],["impl Freeze for AddTracksByIdsRequest",0,1,["jellytau_lib::commands::player::queue::AddTracksByIdsRequest"]],["impl Freeze for BackgroundAudioPlan",0,1,["jellytau_lib::commands::player::BackgroundAudioPlan"]],["impl Freeze for MergedMediaItem",0,1,["jellytau_lib::commands::player::MergedMediaItem"]],["impl Freeze for PlayAlbumTrackRequest",0,1,["jellytau_lib::commands::player::PlayAlbumTrackRequest"]],["impl Freeze for PlayItemRequest",0,1,["jellytau_lib::commands::player::PlayItemRequest"]],["impl Freeze for PlayQueueRequest",0,1,["jellytau_lib::commands::player::PlayQueueRequest"]],["impl Freeze for PlayTracksRequest",0,1,["jellytau_lib::commands::player::PlayTracksRequest"]],["impl Freeze for PlaybackCapabilities",0,1,["jellytau_lib::commands::player::PlaybackCapabilities"]],["impl Freeze for PlayerStateWrapper",0,1,["jellytau_lib::commands::player::PlayerStateWrapper"]],["impl Freeze for PlayerStatus",0,1,["jellytau_lib::commands::player::PlayerStatus"]],["impl Freeze for PreloadResult",0,1,["jellytau_lib::commands::player::PreloadResult"]],["impl Freeze for QueueStatus",0,1,["jellytau_lib::commands::player::QueueStatus"]],["impl Freeze for FavoritesChangedEvent",0,1,["jellytau_lib::commands::repository::FavoritesChangedEvent"]],["impl Freeze for RepositoryManager",0,1,["jellytau_lib::commands::repository::RepositoryManager"]],["impl Freeze for RepositoryManagerWrapper",0,1,["jellytau_lib::commands::repository::RepositoryManagerWrapper"]],["impl Freeze for SearchUpdateEvent",0,1,["jellytau_lib::commands::repository::SearchUpdateEvent"]],["impl Freeze for SessionPollerWrapper",0,1,["jellytau_lib::commands::sessions::SessionPollerWrapper"]],["impl Freeze for CachedItemPerson",0,1,["jellytau_lib::commands::storage::people::CachedItemPerson"]],["impl Freeze for CachedPerson",0,1,["jellytau_lib::commands::storage::people::CachedPerson"]],["impl Freeze for SeriesAudioPreference",0,1,["jellytau_lib::commands::storage::series_prefs::SeriesAudioPreference"]],["impl Freeze for ActiveSession",0,1,["jellytau_lib::commands::storage::ActiveSession"]],["impl Freeze for CachedItem",0,1,["jellytau_lib::commands::storage::CachedItem"]],["impl Freeze for CachedLibrary",0,1,["jellytau_lib::commands::storage::CachedLibrary"]],["impl Freeze for PlaybackProgress",0,1,["jellytau_lib::commands::storage::PlaybackProgress"]],["impl Freeze for SecurityStatus",0,1,["jellytau_lib::commands::storage::SecurityStatus"]],["impl Freeze for ServerInfo",0,1,["jellytau_lib::commands::storage::ServerInfo"]],["impl Freeze for ThumbnailCacheWrapper",0,1,["jellytau_lib::commands::storage::ThumbnailCacheWrapper"]],["impl Freeze for UserInfo",0,1,["jellytau_lib::commands::storage::UserInfo"]],["impl Freeze for GetImageRequest",0,1,["jellytau_lib::commands::storage::thumbnails::GetImageRequest"]],["impl Freeze for SyncQueueItem",0,1,["jellytau_lib::commands::sync::SyncQueueItem"]],["impl Freeze for DrainReport",0,1,["jellytau_lib::commands::sync_drain::DrainReport"]],["impl Freeze for PushFailure",0,1,["jellytau_lib::commands::sync_drain::PushFailure"]],["impl Freeze for QueuedRow",0,1,["jellytau_lib::commands::sync_drain::QueuedRow"]],["impl Freeze for ConnectivityChangeEvent",0,1,["jellytau_lib::connectivity::ConnectivityChangeEvent"]],["impl Freeze for ConnectivityMonitor",0,1,["jellytau_lib::connectivity::ConnectivityMonitor"]],["impl Freeze for ConnectivityReporter",0,1,["jellytau_lib::connectivity::ConnectivityReporter"]],["impl Freeze for ConnectivityStatus",0,1,["jellytau_lib::connectivity::ConnectivityStatus"]],["impl Freeze for CredentialStore",0,1,["jellytau_lib::credentials::CredentialStore"]],["impl Freeze for CacheConfig",0,1,["jellytau_lib::download::cache::CacheConfig"]],["impl Freeze for SmartCache",0,1,["jellytau_lib::download::cache::SmartCache"]],["impl Freeze for NetworkState",0,1,["jellytau_lib::download::network::NetworkState"]],["impl Freeze for NetworkStateHandle",0,1,["jellytau_lib::download::network::NetworkStateHandle"]],["impl Freeze for DownloadInfo",0,1,["jellytau_lib::download::DownloadInfo"]],["impl Freeze for DownloadManager",0,1,["jellytau_lib::download::DownloadManager"]],["impl Freeze for DownloadTask",0,1,["jellytau_lib::download::DownloadTask"]],["impl Freeze for DownloadResult",0,1,["jellytau_lib::download::worker::DownloadResult"]],["impl Freeze for DownloadWorker",0,1,["jellytau_lib::download::worker::DownloadWorker"]],["impl Freeze for JellyfinClient",0,1,["jellytau_lib::jellyfin::client::JellyfinClient"]],["impl Freeze for LmsSyncGroup",0,1,["jellytau_lib::jellyfin::client::LmsSyncGroup"]],["impl Freeze for NowPlayingItem",0,1,["jellytau_lib::jellyfin::client::NowPlayingItem"]],["impl Freeze for PlayState",0,1,["jellytau_lib::jellyfin::client::PlayState"]],["impl Freeze for SessionInfo",0,1,["jellytau_lib::jellyfin::client::SessionInfo"]],["impl Freeze for HttpClient",0,1,["jellytau_lib::jellyfin::http_client::HttpClient"]],["impl Freeze for HttpConfig",0,1,["jellytau_lib::jellyfin::http_client::HttpConfig"]],["impl Freeze for JellyfinConfig",0,1,["jellytau_lib::jellyfin::types::JellyfinConfig"]],["impl Freeze for PlaybackProgressRequest",0,1,["jellytau_lib::jellyfin::types::PlaybackProgressRequest"]],["impl Freeze for PlaybackStartRequest",0,1,["jellytau_lib::jellyfin::types::PlaybackStartRequest"]],["impl Freeze for PlaybackStoppedRequest",0,1,["jellytau_lib::jellyfin::types::PlaybackStoppedRequest"]],["impl Freeze for MediaServer",0,1,["jellytau_lib::media_server::MediaServer"]],["impl Freeze for MediaServerWrapper",0,1,["jellytau_lib::media_server::MediaServerWrapper"]],["impl Freeze for Span",0,1,["jellytau_lib::media_server::Span"]],["impl Freeze for PlatformRemoteVolumeControl",0,1,["jellytau_lib::playback_mode::PlatformRemoteVolumeControl"]],["impl Freeze for PlaybackModeManager",0,1,["jellytau_lib::playback_mode::PlaybackModeManager"]],["impl Freeze for PlaybackContext",0,1,["jellytau_lib::playback_reporting::reporter::PlaybackContext"]],["impl Freeze for PlaybackReporter",0,1,["jellytau_lib::playback_reporting::reporter::PlaybackReporter"]],["impl Freeze for SyncConfig",0,1,["jellytau_lib::playback_reporting::sync_processor::SyncConfig"]],["impl Freeze for SyncProcessor",0,1,["jellytau_lib::playback_reporting::sync_processor::SyncProcessor"]],["impl Freeze for EventThrottler",0,1,["jellytau_lib::playback_reporting::throttle::EventThrottler"]],["impl Freeze for AutoplaySettings",0,1,["jellytau_lib::player::autoplay::AutoplaySettings"]],["impl Freeze for NullBackend",0,1,["jellytau_lib::player::backend::NullBackend"]],["impl Freeze for PlayerError",0,1,["jellytau_lib::player::backend::PlayerError"]],["impl Freeze for TauriEventEmitter",0,1,["jellytau_lib::player::events::TauriEventEmitter"]],["impl Freeze for MediaItem",0,1,["jellytau_lib::player::media::MediaItem"]],["impl Freeze for SubtitleTrack",0,1,["jellytau_lib::player::media::SubtitleTrack"]],["impl Freeze for InternalState",0,1,["jellytau_lib::player::mpv_backend::InternalState"]],["impl Freeze for MpvBackend",0,1,["jellytau_lib::player::mpv_backend::MpvBackend"]],["impl Freeze for QueueManager",0,1,["jellytau_lib::player::queue::QueueManager"]],["impl Freeze for MediaSessionManager",0,1,["jellytau_lib::player::session::MediaSessionManager"]],["impl Freeze for SleepTimerState",0,1,["jellytau_lib::player::sleep_timer::SleepTimerState"]],["impl Freeze for ObservedTime",0,1,["jellytau_lib::player::stream_end::ObservedTime"]],["impl Freeze for ResumeTracker",0,1,["jellytau_lib::player::stream_end::ResumeTracker"]],["impl Freeze for LockscreenMetadata",0,1,["jellytau_lib::player::LockscreenMetadata"]],["impl Freeze for PlayerController",0,1,["jellytau_lib::player::PlayerController"]],["impl Freeze for ReporterSink",0,1,["jellytau_lib::player::ReporterSink"]],["impl Freeze for HybridRepository",0,1,["jellytau_lib::repository::hybrid::HybridRepository"]],["impl Freeze for CachedItem",0,1,["jellytau_lib::repository::offline::CachedItem"]],["impl Freeze for OfflineRepository",0,1,["jellytau_lib::repository::offline::OfflineRepository"]],["impl Freeze for CreatePlaylistResponse",0,1,["jellytau_lib::repository::online::CreatePlaylistResponse"]],["impl Freeze for ItemsResponse",0,1,["jellytau_lib::repository::online::ItemsResponse"]],["impl Freeze for JRayActor",0,1,["jellytau_lib::repository::online::JRayActor"]],["impl Freeze for JRayContext",0,1,["jellytau_lib::repository::online::JRayContext"]],["impl Freeze for JellyfinItem",0,1,["jellytau_lib::repository::online::JellyfinItem"]],["impl Freeze for JellyfinMediaSource",0,1,["jellytau_lib::repository::online::JellyfinMediaSource"]],["impl Freeze for JellyfinMediaStream",0,1,["jellytau_lib::repository::online::JellyfinMediaStream"]],["impl Freeze for JellyfinPlaylistItem",0,1,["jellytau_lib::repository::online::JellyfinPlaylistItem"]],["impl Freeze for JellyfinUserData",0,1,["jellytau_lib::repository::online::JellyfinUserData"]],["impl Freeze for OnlineRepository",0,1,["jellytau_lib::repository::online::OnlineRepository"]],["impl Freeze for PlaylistItemsResponse",0,1,["jellytau_lib::repository::online::PlaylistItemsResponse"]],["impl Freeze for ArtistItem",0,1,["jellytau_lib::repository::types::ArtistItem"]],["impl Freeze for DownloadDiskUsage",0,1,["jellytau_lib::repository::types::DownloadDiskUsage"]],["impl Freeze for Genre",0,1,["jellytau_lib::repository::types::Genre"]],["impl Freeze for GetItemsOptions",0,1,["jellytau_lib::repository::types::GetItemsOptions"]],["impl Freeze for ImageOptions",0,1,["jellytau_lib::repository::types::ImageOptions"]],["impl Freeze for Library",0,1,["jellytau_lib::repository::types::Library"]],["impl Freeze for LiveStreamInfo",0,1,["jellytau_lib::repository::types::LiveStreamInfo"]],["impl Freeze for MediaItem",0,1,["jellytau_lib::repository::types::MediaItem"]],["impl Freeze for MediaSource",0,1,["jellytau_lib::repository::types::MediaSource"]],["impl Freeze for MediaStream",0,1,["jellytau_lib::repository::types::MediaStream"]],["impl Freeze for Person",0,1,["jellytau_lib::repository::types::Person"]],["impl Freeze for PlaybackInfo",0,1,["jellytau_lib::repository::types::PlaybackInfo"]],["impl Freeze for PlaylistCreatedResult",0,1,["jellytau_lib::repository::types::PlaylistCreatedResult"]],["impl Freeze for PlaylistEntry",0,1,["jellytau_lib::repository::types::PlaylistEntry"]],["impl Freeze for SearchOptions",0,1,["jellytau_lib::repository::types::SearchOptions"]],["impl Freeze for SearchResult",0,1,["jellytau_lib::repository::types::SearchResult"]],["impl Freeze for UserData",0,1,["jellytau_lib::repository::types::UserData"]],["impl Freeze for SessionPollerManager",0,1,["jellytau_lib::session_poller::SessionPollerManager"]],["impl Freeze for AudioSettings",0,1,["jellytau_lib::settings::AudioSettings"]],["impl Freeze for LibrarySettings",0,1,["jellytau_lib::settings::LibrarySettings"]],["impl Freeze for VideoSettings",0,1,["jellytau_lib::settings::VideoSettings"]],["impl Freeze for Query",0,1,["jellytau_lib::storage::db_service::Query"]],["impl Freeze for RusqliteService",0,1,["jellytau_lib::storage::db_service::RusqliteService"]],["impl Freeze for PlaylistItem",0,1,["jellytau_lib::storage::models::PlaylistItem"]],["impl Freeze for Database",0,1,["jellytau_lib::storage::Database"]],["impl Freeze for BackendInitError",0,1,["jellytau_lib::BackendInitError"]],["impl Freeze for CacheConfig",0,1,["jellytau_lib::thumbnail::cache::CacheConfig"]],["impl Freeze for ThumbnailCache",0,1,["jellytau_lib::thumbnail::cache::ThumbnailCache"]],["impl Freeze for ThumbnailCacheStats",0,1,["jellytau_lib::thumbnail::ThumbnailCacheStats"]],["impl Freeze for ThumbnailWorker",0,1,["jellytau_lib::thumbnail::worker::ThumbnailWorker"]],["impl<'a> Freeze for Transaction<'a>",0,1,["jellytau_lib::storage::db_service::Transaction"]]]]]); + if (window.register_implementors) { + window.register_implementors(implementors); + } else { + window.pending_implementors = implementors; + } +})() +//{"start":59,"fragment_lengths":[83195]} \ No newline at end of file diff --git a/api/trait.impl/core/marker/trait.Send.js b/api/trait.impl/core/marker/trait.Send.js new file mode 100644 index 00000000..1ad54624 --- /dev/null +++ b/api/trait.impl/core/marker/trait.Send.js @@ -0,0 +1,9 @@ +(function() { + const implementors = Object.fromEntries([["jellytau_lib",[["impl<'a> !Send for Transaction<'a>",1,1,["jellytau_lib::storage::db_service::Transaction"]],["impl Send for SessionVerificationEvent",0,1,["jellytau_lib::auth::session_verifier::SessionVerificationEvent"]],["impl Send for AudioTrackSwitchResponse",0,1,["jellytau_lib::commands::player::AudioTrackSwitchResponse"]],["impl Send for PlayQueueContext",0,1,["jellytau_lib::commands::player::PlayQueueContext"]],["impl Send for PlayTracksContext",0,1,["jellytau_lib::commands::player::PlayTracksContext"]],["impl Send for StreamQualityResponse",0,1,["jellytau_lib::commands::player::StreamQualityResponse"]],["impl Send for VideoBackend",0,1,["jellytau_lib::commands::player::VideoBackend"]],["impl Send for VideoSeekResponse",0,1,["jellytau_lib::commands::player::VideoSeekResponse"]],["impl Send for QueuedOp",0,1,["jellytau_lib::commands::sync_drain::QueuedOp"]],["impl Send for CredentialError",0,1,["jellytau_lib::credentials::CredentialError"]],["impl Send for CredentialResult",0,1,["jellytau_lib::credentials::CredentialResult"]],["impl Send for MediaKind",0,1,["jellytau_lib::domain::media::MediaKind"]],["impl Send for StreamKind",0,1,["jellytau_lib::domain::media::StreamKind"]],["impl Send for MatchQuality",0,1,["jellytau_lib::domain::search_rank::MatchQuality"]],["impl Send for DownloadEvent",0,1,["jellytau_lib::download::events::DownloadEvent"]],["impl Send for NetworkType",0,1,["jellytau_lib::download::network::NetworkType"]],["impl Send for DownloadError",0,1,["jellytau_lib::download::worker::DownloadError"]],["impl Send for ErrorKind",0,1,["jellytau_lib::jellyfin::http_client::ErrorKind"]],["impl Send for Resolved",0,1,["jellytau_lib::media_server::Resolved"]],["impl Send for PlaybackMode",0,1,["jellytau_lib::playback_mode::PlaybackMode"]],["impl Send for PlaybackOperation",0,1,["jellytau_lib::playback_reporting::reporter::PlaybackOperation"]],["impl Send for AutoplayDecision",0,1,["jellytau_lib::player::autoplay::AutoplayDecision"]],["impl Send for BackgroundAction",0,1,["jellytau_lib::player::background_policy::BackgroundAction"]],["impl Send for PlayerStatusEvent",0,1,["jellytau_lib::player::events::PlayerStatusEvent"]],["impl Send for MediaSource",0,1,["jellytau_lib::player::media::MediaSource"]],["impl Send for MediaType",0,1,["jellytau_lib::player::media::MediaType"]],["impl Send for QueueContext",0,1,["jellytau_lib::player::media::QueueContext"]],["impl Send for AddPosition",0,1,["jellytau_lib::player::queue::AddPosition"]],["impl Send for RepeatMode",0,1,["jellytau_lib::player::queue::RepeatMode"]],["impl Send for SkipAction",0,1,["jellytau_lib::player::seek::SkipAction"]],["impl Send for VideoSeekStrategy",0,1,["jellytau_lib::player::seek::VideoSeekStrategy"]],["impl Send for MediaSessionType",0,1,["jellytau_lib::player::session::MediaSessionType"]],["impl Send for SleepTimerMode",0,1,["jellytau_lib::player::sleep_timer::SleepTimerMode"]],["impl Send for EndReason",0,1,["jellytau_lib::player::state::EndReason"]],["impl Send for PlayerState",0,1,["jellytau_lib::player::state::PlayerState"]],["impl Send for ImageTags",0,1,["jellytau_lib::repository::online::ImageTags"]],["impl Send for ImageType",0,1,["jellytau_lib::repository::types::ImageType"]],["impl Send for RepoError",0,1,["jellytau_lib::repository::types::RepoError"]],["impl Send for SearchScope",0,1,["jellytau_lib::repository::types::SearchScope"]],["impl Send for PollingHint",0,1,["jellytau_lib::session_poller::PollingHint"]],["impl Send for EqPreset",0,1,["jellytau_lib::settings::EqPreset"]],["impl Send for StreamingQuality",0,1,["jellytau_lib::settings::StreamingQuality"]],["impl Send for VolumeLevel",0,1,["jellytau_lib::settings::VolumeLevel"]],["impl Send for QueryParam",0,1,["jellytau_lib::storage::db_service::QueryParam"]],["impl Send for ThumbnailDownloadError",0,1,["jellytau_lib::thumbnail::worker::ThumbnailDownloadError"]],["impl Send for SessionVerifier",0,1,["jellytau_lib::auth::session_verifier::SessionVerifier"]],["impl Send for AuthManager",0,1,["jellytau_lib::auth::AuthManager"]],["impl Send for AuthResult",0,1,["jellytau_lib::auth::AuthResult"]],["impl Send for AuthenticateByNameResponse",0,1,["jellytau_lib::auth::AuthenticateByNameResponse"]],["impl Send for JellyfinUser",0,1,["jellytau_lib::auth::JellyfinUser"]],["impl Send for PublicSystemInfo",0,1,["jellytau_lib::auth::PublicSystemInfo"]],["impl Send for ServerInfo",0,1,["jellytau_lib::auth::ServerInfo"]],["impl Send for Session",0,1,["jellytau_lib::auth::Session"]],["impl Send for User",0,1,["jellytau_lib::auth::User"]],["impl Send for AuthManagerWrapper",0,1,["jellytau_lib::commands::auth::AuthManagerWrapper"]],["impl Send for SessionVerifierWrapper",0,1,["jellytau_lib::commands::auth::SessionVerifierWrapper"]],["impl Send for CatalogIndexEvent",0,1,["jellytau_lib::commands::catalog::CatalogIndexEvent"]],["impl Send for CatalogSyncResult",0,1,["jellytau_lib::commands::catalog::CatalogSyncResult"]],["impl Send for CatalogSyncStatus",0,1,["jellytau_lib::commands::catalog::CatalogSyncStatus"]],["impl Send for IndexPassGuard",0,1,["jellytau_lib::commands::catalog::IndexPassGuard"]],["impl Send for ResumeQueuedResult",0,1,["jellytau_lib::commands::catalog::ResumeQueuedResult"]],["impl Send for ConnectivityMonitorWrapper",0,1,["jellytau_lib::commands::connectivity::ConnectivityMonitorWrapper"]],["impl Send for DiagnosticsBundle",0,1,["jellytau_lib::commands::diagnostics::DiagnosticsBundle"]],["impl Send for DiagnosticsInfo",0,1,["jellytau_lib::commands::diagnostics::DiagnosticsInfo"]],["impl Send for AlbumAffinityStatus",0,1,["jellytau_lib::commands::download::smart_cache::AlbumAffinityStatus"]],["impl Send for AlbumRecommendation",0,1,["jellytau_lib::commands::download::smart_cache::AlbumRecommendation"]],["impl Send for SmartCacheStats",0,1,["jellytau_lib::commands::download::smart_cache::SmartCacheStats"]],["impl Send for AlbumStorageInfo",0,1,["jellytau_lib::commands::download::AlbumStorageInfo"]],["impl Send for AlbumTrack",0,1,["jellytau_lib::commands::download::AlbumTrack"]],["impl Send for DownloadItemAndStartRequest",0,1,["jellytau_lib::commands::download::DownloadItemAndStartRequest"]],["impl Send for DownloadItemRequest",0,1,["jellytau_lib::commands::download::DownloadItemRequest"]],["impl Send for DownloadManagerStats",0,1,["jellytau_lib::commands::download::DownloadManagerStats"]],["impl Send for DownloadManagerWrapper",0,1,["jellytau_lib::commands::download::DownloadManagerWrapper"]],["impl Send for DownloadStats",0,1,["jellytau_lib::commands::download::DownloadStats"]],["impl Send for DownloadVideoRequest",0,1,["jellytau_lib::commands::download::DownloadVideoRequest"]],["impl Send for DownloadsResponse",0,1,["jellytau_lib::commands::download::DownloadsResponse"]],["impl Send for NetworkStateWrapper",0,1,["jellytau_lib::commands::download::NetworkStateWrapper"]],["impl Send for NetworkStateWrapperArg",0,1,["jellytau_lib::commands::download::NetworkStateWrapperArg"]],["impl Send for StorageStats",0,1,["jellytau_lib::commands::download::StorageStats"]],["impl Send for PendingFavorite",0,1,["jellytau_lib::commands::favorites::PendingFavorite"]],["impl Send for ExclusionCandidate",0,1,["jellytau_lib::commands::library::ExclusionCandidate"]],["impl Send for OfflineItem",0,1,["jellytau_lib::commands::offline::OfflineItem"]],["impl Send for PlaybackModeManagerWrapper",0,1,["jellytau_lib::commands::playback_mode::PlaybackModeManagerWrapper"]],["impl Send for RemoteSessionStatus",0,1,["jellytau_lib::commands::playback_mode::RemoteSessionStatus"]],["impl Send for PlaybackReporterWrapper",0,1,["jellytau_lib::commands::playback_reporting::PlaybackReporterWrapper"]],["impl Send for AddToQueueRequest",0,1,["jellytau_lib::commands::player::queue::AddToQueueRequest"]],["impl Send for AddTrackByIdRequest",0,1,["jellytau_lib::commands::player::queue::AddTrackByIdRequest"]],["impl Send for AddTracksByIdsRequest",0,1,["jellytau_lib::commands::player::queue::AddTracksByIdsRequest"]],["impl Send for BackgroundAudioPlan",0,1,["jellytau_lib::commands::player::BackgroundAudioPlan"]],["impl Send for MediaSessionManagerWrapper",0,1,["jellytau_lib::commands::player::MediaSessionManagerWrapper"]],["impl Send for MergedMediaItem",0,1,["jellytau_lib::commands::player::MergedMediaItem"]],["impl Send for PlayAlbumTrackRequest",0,1,["jellytau_lib::commands::player::PlayAlbumTrackRequest"]],["impl Send for PlayItemRequest",0,1,["jellytau_lib::commands::player::PlayItemRequest"]],["impl Send for PlayQueueRequest",0,1,["jellytau_lib::commands::player::PlayQueueRequest"]],["impl Send for PlayTracksRequest",0,1,["jellytau_lib::commands::player::PlayTracksRequest"]],["impl Send for PlaybackCapabilities",0,1,["jellytau_lib::commands::player::PlaybackCapabilities"]],["impl Send for PlayerStateWrapper",0,1,["jellytau_lib::commands::player::PlayerStateWrapper"]],["impl Send for PlayerStatus",0,1,["jellytau_lib::commands::player::PlayerStatus"]],["impl Send for PreloadResult",0,1,["jellytau_lib::commands::player::PreloadResult"]],["impl Send for QueueStatus",0,1,["jellytau_lib::commands::player::QueueStatus"]],["impl Send for SmartCacheWrapper",0,1,["jellytau_lib::commands::player::SmartCacheWrapper"]],["impl Send for VideoSettingsWrapper",0,1,["jellytau_lib::commands::player::VideoSettingsWrapper"]],["impl Send for FavoritesChangedEvent",0,1,["jellytau_lib::commands::repository::FavoritesChangedEvent"]],["impl Send for RepositoryManager",0,1,["jellytau_lib::commands::repository::RepositoryManager"]],["impl Send for RepositoryManagerWrapper",0,1,["jellytau_lib::commands::repository::RepositoryManagerWrapper"]],["impl Send for SearchUpdateEvent",0,1,["jellytau_lib::commands::repository::SearchUpdateEvent"]],["impl Send for SessionPollerWrapper",0,1,["jellytau_lib::commands::sessions::SessionPollerWrapper"]],["impl Send for CachedItemPerson",0,1,["jellytau_lib::commands::storage::people::CachedItemPerson"]],["impl Send for CachedPerson",0,1,["jellytau_lib::commands::storage::people::CachedPerson"]],["impl Send for SeriesAudioPreference",0,1,["jellytau_lib::commands::storage::series_prefs::SeriesAudioPreference"]],["impl Send for ActiveSession",0,1,["jellytau_lib::commands::storage::ActiveSession"]],["impl Send for CachedItem",0,1,["jellytau_lib::commands::storage::CachedItem"]],["impl Send for CachedLibrary",0,1,["jellytau_lib::commands::storage::CachedLibrary"]],["impl Send for CredentialStoreWrapper",0,1,["jellytau_lib::commands::storage::CredentialStoreWrapper"]],["impl Send for DatabaseWrapper",0,1,["jellytau_lib::commands::storage::DatabaseWrapper"]],["impl Send for PlaybackProgress",0,1,["jellytau_lib::commands::storage::PlaybackProgress"]],["impl Send for SecurityStatus",0,1,["jellytau_lib::commands::storage::SecurityStatus"]],["impl Send for ServerInfo",0,1,["jellytau_lib::commands::storage::ServerInfo"]],["impl Send for ThumbnailCacheWrapper",0,1,["jellytau_lib::commands::storage::ThumbnailCacheWrapper"]],["impl Send for UserInfo",0,1,["jellytau_lib::commands::storage::UserInfo"]],["impl Send for GetImageRequest",0,1,["jellytau_lib::commands::storage::thumbnails::GetImageRequest"]],["impl Send for SyncQueueItem",0,1,["jellytau_lib::commands::sync::SyncQueueItem"]],["impl Send for DrainReport",0,1,["jellytau_lib::commands::sync_drain::DrainReport"]],["impl Send for PushFailure",0,1,["jellytau_lib::commands::sync_drain::PushFailure"]],["impl Send for QueuedRow",0,1,["jellytau_lib::commands::sync_drain::QueuedRow"]],["impl Send for ConnectivityChangeEvent",0,1,["jellytau_lib::connectivity::ConnectivityChangeEvent"]],["impl Send for ConnectivityMonitor",0,1,["jellytau_lib::connectivity::ConnectivityMonitor"]],["impl Send for ConnectivityReporter",0,1,["jellytau_lib::connectivity::ConnectivityReporter"]],["impl Send for ConnectivityStatus",0,1,["jellytau_lib::connectivity::ConnectivityStatus"]],["impl Send for CredentialStore",0,1,["jellytau_lib::credentials::CredentialStore"]],["impl Send for CacheConfig",0,1,["jellytau_lib::download::cache::CacheConfig"]],["impl Send for SmartCache",0,1,["jellytau_lib::download::cache::SmartCache"]],["impl Send for NetworkState",0,1,["jellytau_lib::download::network::NetworkState"]],["impl Send for NetworkStateHandle",0,1,["jellytau_lib::download::network::NetworkStateHandle"]],["impl Send for DownloadInfo",0,1,["jellytau_lib::download::DownloadInfo"]],["impl Send for DownloadManager",0,1,["jellytau_lib::download::DownloadManager"]],["impl Send for DownloadTask",0,1,["jellytau_lib::download::DownloadTask"]],["impl Send for DownloadResult",0,1,["jellytau_lib::download::worker::DownloadResult"]],["impl Send for DownloadWorker",0,1,["jellytau_lib::download::worker::DownloadWorker"]],["impl Send for JellyfinClient",0,1,["jellytau_lib::jellyfin::client::JellyfinClient"]],["impl Send for LmsSyncGroup",0,1,["jellytau_lib::jellyfin::client::LmsSyncGroup"]],["impl Send for NowPlayingItem",0,1,["jellytau_lib::jellyfin::client::NowPlayingItem"]],["impl Send for PlayState",0,1,["jellytau_lib::jellyfin::client::PlayState"]],["impl Send for SessionInfo",0,1,["jellytau_lib::jellyfin::client::SessionInfo"]],["impl Send for HttpClient",0,1,["jellytau_lib::jellyfin::http_client::HttpClient"]],["impl Send for HttpConfig",0,1,["jellytau_lib::jellyfin::http_client::HttpConfig"]],["impl Send for JellyfinConfig",0,1,["jellytau_lib::jellyfin::types::JellyfinConfig"]],["impl Send for PlaybackProgressRequest",0,1,["jellytau_lib::jellyfin::types::PlaybackProgressRequest"]],["impl Send for PlaybackStartRequest",0,1,["jellytau_lib::jellyfin::types::PlaybackStartRequest"]],["impl Send for PlaybackStoppedRequest",0,1,["jellytau_lib::jellyfin::types::PlaybackStoppedRequest"]],["impl Send for MediaServer",0,1,["jellytau_lib::media_server::MediaServer"]],["impl Send for MediaServerWrapper",0,1,["jellytau_lib::media_server::MediaServerWrapper"]],["impl Send for Span",0,1,["jellytau_lib::media_server::Span"]],["impl Send for PlatformRemoteVolumeControl",0,1,["jellytau_lib::playback_mode::PlatformRemoteVolumeControl"]],["impl Send for PlaybackModeManager",0,1,["jellytau_lib::playback_mode::PlaybackModeManager"]],["impl Send for PlaybackContext",0,1,["jellytau_lib::playback_reporting::reporter::PlaybackContext"]],["impl Send for PlaybackReporter",0,1,["jellytau_lib::playback_reporting::reporter::PlaybackReporter"]],["impl Send for SyncConfig",0,1,["jellytau_lib::playback_reporting::sync_processor::SyncConfig"]],["impl Send for SyncProcessor",0,1,["jellytau_lib::playback_reporting::sync_processor::SyncProcessor"]],["impl Send for EventThrottler",0,1,["jellytau_lib::playback_reporting::throttle::EventThrottler"]],["impl Send for AutoplaySettings",0,1,["jellytau_lib::player::autoplay::AutoplaySettings"]],["impl Send for NullBackend",0,1,["jellytau_lib::player::backend::NullBackend"]],["impl Send for PlayerError",0,1,["jellytau_lib::player::backend::PlayerError"]],["impl Send for TauriEventEmitter",0,1,["jellytau_lib::player::events::TauriEventEmitter"]],["impl Send for MediaItem",0,1,["jellytau_lib::player::media::MediaItem"]],["impl Send for SubtitleTrack",0,1,["jellytau_lib::player::media::SubtitleTrack"]],["impl Send for InternalState",0,1,["jellytau_lib::player::mpv_backend::InternalState"]],["impl Send for MpvBackend",0,1,["jellytau_lib::player::mpv_backend::MpvBackend"]],["impl Send for QueueManager",0,1,["jellytau_lib::player::queue::QueueManager"]],["impl Send for MediaSessionManager",0,1,["jellytau_lib::player::session::MediaSessionManager"]],["impl Send for SleepTimerState",0,1,["jellytau_lib::player::sleep_timer::SleepTimerState"]],["impl Send for ObservedTime",0,1,["jellytau_lib::player::stream_end::ObservedTime"]],["impl Send for ResumeTracker",0,1,["jellytau_lib::player::stream_end::ResumeTracker"]],["impl Send for LockscreenMetadata",0,1,["jellytau_lib::player::LockscreenMetadata"]],["impl Send for PlayerController",0,1,["jellytau_lib::player::PlayerController"]],["impl Send for ReporterSink",0,1,["jellytau_lib::player::ReporterSink"]],["impl Send for HybridRepository",0,1,["jellytau_lib::repository::hybrid::HybridRepository"]],["impl Send for CachedItem",0,1,["jellytau_lib::repository::offline::CachedItem"]],["impl Send for OfflineRepository",0,1,["jellytau_lib::repository::offline::OfflineRepository"]],["impl Send for CreatePlaylistResponse",0,1,["jellytau_lib::repository::online::CreatePlaylistResponse"]],["impl Send for ItemsResponse",0,1,["jellytau_lib::repository::online::ItemsResponse"]],["impl Send for JRayActor",0,1,["jellytau_lib::repository::online::JRayActor"]],["impl Send for JRayContext",0,1,["jellytau_lib::repository::online::JRayContext"]],["impl Send for JellyfinItem",0,1,["jellytau_lib::repository::online::JellyfinItem"]],["impl Send for JellyfinMediaSource",0,1,["jellytau_lib::repository::online::JellyfinMediaSource"]],["impl Send for JellyfinMediaStream",0,1,["jellytau_lib::repository::online::JellyfinMediaStream"]],["impl Send for JellyfinPlaylistItem",0,1,["jellytau_lib::repository::online::JellyfinPlaylistItem"]],["impl Send for JellyfinUserData",0,1,["jellytau_lib::repository::online::JellyfinUserData"]],["impl Send for OnlineRepository",0,1,["jellytau_lib::repository::online::OnlineRepository"]],["impl Send for PlaylistItemsResponse",0,1,["jellytau_lib::repository::online::PlaylistItemsResponse"]],["impl Send for ArtistItem",0,1,["jellytau_lib::repository::types::ArtistItem"]],["impl Send for DownloadDiskUsage",0,1,["jellytau_lib::repository::types::DownloadDiskUsage"]],["impl Send for Genre",0,1,["jellytau_lib::repository::types::Genre"]],["impl Send for GetItemsOptions",0,1,["jellytau_lib::repository::types::GetItemsOptions"]],["impl Send for ImageOptions",0,1,["jellytau_lib::repository::types::ImageOptions"]],["impl Send for Library",0,1,["jellytau_lib::repository::types::Library"]],["impl Send for LiveStreamInfo",0,1,["jellytau_lib::repository::types::LiveStreamInfo"]],["impl Send for MediaItem",0,1,["jellytau_lib::repository::types::MediaItem"]],["impl Send for MediaSource",0,1,["jellytau_lib::repository::types::MediaSource"]],["impl Send for MediaStream",0,1,["jellytau_lib::repository::types::MediaStream"]],["impl Send for Person",0,1,["jellytau_lib::repository::types::Person"]],["impl Send for PlaybackInfo",0,1,["jellytau_lib::repository::types::PlaybackInfo"]],["impl Send for PlaylistCreatedResult",0,1,["jellytau_lib::repository::types::PlaylistCreatedResult"]],["impl Send for PlaylistEntry",0,1,["jellytau_lib::repository::types::PlaylistEntry"]],["impl Send for SearchOptions",0,1,["jellytau_lib::repository::types::SearchOptions"]],["impl Send for SearchResult",0,1,["jellytau_lib::repository::types::SearchResult"]],["impl Send for UserData",0,1,["jellytau_lib::repository::types::UserData"]],["impl Send for SessionPollerManager",0,1,["jellytau_lib::session_poller::SessionPollerManager"]],["impl Send for AudioSettings",0,1,["jellytau_lib::settings::AudioSettings"]],["impl Send for LibrarySettings",0,1,["jellytau_lib::settings::LibrarySettings"]],["impl Send for VideoSettings",0,1,["jellytau_lib::settings::VideoSettings"]],["impl Send for Query",0,1,["jellytau_lib::storage::db_service::Query"]],["impl Send for RusqliteService",0,1,["jellytau_lib::storage::db_service::RusqliteService"]],["impl Send for PlaylistItem",0,1,["jellytau_lib::storage::models::PlaylistItem"]],["impl Send for Database",0,1,["jellytau_lib::storage::Database"]],["impl Send for BackendInitError",0,1,["jellytau_lib::BackendInitError"]],["impl Send for CacheConfig",0,1,["jellytau_lib::thumbnail::cache::CacheConfig"]],["impl Send for ThumbnailCache",0,1,["jellytau_lib::thumbnail::cache::ThumbnailCache"]],["impl Send for ThumbnailCacheStats",0,1,["jellytau_lib::thumbnail::ThumbnailCacheStats"]],["impl Send for ThumbnailWorker",0,1,["jellytau_lib::thumbnail::worker::ThumbnailWorker"]]]]]); + if (window.register_implementors) { + window.register_implementors(implementors); + } else { + window.pending_implementors = implementors; + } +})() +//{"start":59,"fragment_lengths":[81870]} \ No newline at end of file diff --git a/api/trait.impl/core/marker/trait.StructuralPartialEq.js b/api/trait.impl/core/marker/trait.StructuralPartialEq.js new file mode 100644 index 00000000..88c97ef3 --- /dev/null +++ b/api/trait.impl/core/marker/trait.StructuralPartialEq.js @@ -0,0 +1,9 @@ +(function() { + const implementors = Object.fromEntries([["jellytau_lib",[["impl StructuralPartialEq for QueuedOp",0],["impl StructuralPartialEq for MediaKind",0],["impl StructuralPartialEq for StreamKind",0],["impl StructuralPartialEq for MatchQuality",0],["impl StructuralPartialEq for NetworkType",0],["impl StructuralPartialEq for ErrorKind",0],["impl StructuralPartialEq for Resolved",0],["impl StructuralPartialEq for PlaybackMode",0],["impl StructuralPartialEq for BackgroundAction",0],["impl StructuralPartialEq for MediaSource",0],["impl StructuralPartialEq for MediaType",0],["impl StructuralPartialEq for QueueContext",0],["impl StructuralPartialEq for AddPosition",0],["impl StructuralPartialEq for RepeatMode",0],["impl StructuralPartialEq for SkipAction",0],["impl StructuralPartialEq for VideoSeekStrategy",0],["impl StructuralPartialEq for MediaSessionType",0],["impl StructuralPartialEq for SleepTimerMode",0],["impl StructuralPartialEq for EndReason",0],["impl StructuralPartialEq for SearchScope",0],["impl StructuralPartialEq for PollingHint",0],["impl StructuralPartialEq for EqPreset",0],["impl StructuralPartialEq for StreamingQuality",0],["impl StructuralPartialEq for VolumeLevel",0],["impl StructuralPartialEq for AlbumTrack",0],["impl StructuralPartialEq for PendingFavorite",0],["impl StructuralPartialEq for DrainReport",0],["impl StructuralPartialEq for NetworkState",0],["impl StructuralPartialEq for Span",0],["impl StructuralPartialEq for MediaItem",0],["impl StructuralPartialEq for SubtitleTrack",0],["impl StructuralPartialEq for ArtistItem",0],["impl StructuralPartialEq for Person",0],["impl StructuralPartialEq for LibrarySettings",0]]]]); + if (window.register_implementors) { + window.register_implementors(implementors); + } else { + window.pending_implementors = implementors; + } +})() +//{"start":59,"fragment_lengths":[11764]} \ No newline at end of file diff --git a/api/trait.impl/core/marker/trait.Sync.js b/api/trait.impl/core/marker/trait.Sync.js new file mode 100644 index 00000000..6f358520 --- /dev/null +++ b/api/trait.impl/core/marker/trait.Sync.js @@ -0,0 +1,9 @@ +(function() { + const implementors = Object.fromEntries([["jellytau_lib",[["impl<'a> !Sync for Transaction<'a>",1,1,["jellytau_lib::storage::db_service::Transaction"]],["impl Sync for SessionVerificationEvent",0,1,["jellytau_lib::auth::session_verifier::SessionVerificationEvent"]],["impl Sync for AudioTrackSwitchResponse",0,1,["jellytau_lib::commands::player::AudioTrackSwitchResponse"]],["impl Sync for PlayQueueContext",0,1,["jellytau_lib::commands::player::PlayQueueContext"]],["impl Sync for PlayTracksContext",0,1,["jellytau_lib::commands::player::PlayTracksContext"]],["impl Sync for StreamQualityResponse",0,1,["jellytau_lib::commands::player::StreamQualityResponse"]],["impl Sync for VideoBackend",0,1,["jellytau_lib::commands::player::VideoBackend"]],["impl Sync for VideoSeekResponse",0,1,["jellytau_lib::commands::player::VideoSeekResponse"]],["impl Sync for QueuedOp",0,1,["jellytau_lib::commands::sync_drain::QueuedOp"]],["impl Sync for CredentialError",0,1,["jellytau_lib::credentials::CredentialError"]],["impl Sync for CredentialResult",0,1,["jellytau_lib::credentials::CredentialResult"]],["impl Sync for MediaKind",0,1,["jellytau_lib::domain::media::MediaKind"]],["impl Sync for StreamKind",0,1,["jellytau_lib::domain::media::StreamKind"]],["impl Sync for MatchQuality",0,1,["jellytau_lib::domain::search_rank::MatchQuality"]],["impl Sync for DownloadEvent",0,1,["jellytau_lib::download::events::DownloadEvent"]],["impl Sync for NetworkType",0,1,["jellytau_lib::download::network::NetworkType"]],["impl Sync for DownloadError",0,1,["jellytau_lib::download::worker::DownloadError"]],["impl Sync for ErrorKind",0,1,["jellytau_lib::jellyfin::http_client::ErrorKind"]],["impl Sync for Resolved",0,1,["jellytau_lib::media_server::Resolved"]],["impl Sync for PlaybackMode",0,1,["jellytau_lib::playback_mode::PlaybackMode"]],["impl Sync for PlaybackOperation",0,1,["jellytau_lib::playback_reporting::reporter::PlaybackOperation"]],["impl Sync for AutoplayDecision",0,1,["jellytau_lib::player::autoplay::AutoplayDecision"]],["impl Sync for BackgroundAction",0,1,["jellytau_lib::player::background_policy::BackgroundAction"]],["impl Sync for PlayerStatusEvent",0,1,["jellytau_lib::player::events::PlayerStatusEvent"]],["impl Sync for MediaSource",0,1,["jellytau_lib::player::media::MediaSource"]],["impl Sync for MediaType",0,1,["jellytau_lib::player::media::MediaType"]],["impl Sync for QueueContext",0,1,["jellytau_lib::player::media::QueueContext"]],["impl Sync for AddPosition",0,1,["jellytau_lib::player::queue::AddPosition"]],["impl Sync for RepeatMode",0,1,["jellytau_lib::player::queue::RepeatMode"]],["impl Sync for SkipAction",0,1,["jellytau_lib::player::seek::SkipAction"]],["impl Sync for VideoSeekStrategy",0,1,["jellytau_lib::player::seek::VideoSeekStrategy"]],["impl Sync for MediaSessionType",0,1,["jellytau_lib::player::session::MediaSessionType"]],["impl Sync for SleepTimerMode",0,1,["jellytau_lib::player::sleep_timer::SleepTimerMode"]],["impl Sync for EndReason",0,1,["jellytau_lib::player::state::EndReason"]],["impl Sync for PlayerState",0,1,["jellytau_lib::player::state::PlayerState"]],["impl Sync for ImageTags",0,1,["jellytau_lib::repository::online::ImageTags"]],["impl Sync for ImageType",0,1,["jellytau_lib::repository::types::ImageType"]],["impl Sync for RepoError",0,1,["jellytau_lib::repository::types::RepoError"]],["impl Sync for SearchScope",0,1,["jellytau_lib::repository::types::SearchScope"]],["impl Sync for PollingHint",0,1,["jellytau_lib::session_poller::PollingHint"]],["impl Sync for EqPreset",0,1,["jellytau_lib::settings::EqPreset"]],["impl Sync for StreamingQuality",0,1,["jellytau_lib::settings::StreamingQuality"]],["impl Sync for VolumeLevel",0,1,["jellytau_lib::settings::VolumeLevel"]],["impl Sync for QueryParam",0,1,["jellytau_lib::storage::db_service::QueryParam"]],["impl Sync for ThumbnailDownloadError",0,1,["jellytau_lib::thumbnail::worker::ThumbnailDownloadError"]],["impl Sync for SessionVerifier",0,1,["jellytau_lib::auth::session_verifier::SessionVerifier"]],["impl Sync for AuthManager",0,1,["jellytau_lib::auth::AuthManager"]],["impl Sync for AuthResult",0,1,["jellytau_lib::auth::AuthResult"]],["impl Sync for AuthenticateByNameResponse",0,1,["jellytau_lib::auth::AuthenticateByNameResponse"]],["impl Sync for JellyfinUser",0,1,["jellytau_lib::auth::JellyfinUser"]],["impl Sync for PublicSystemInfo",0,1,["jellytau_lib::auth::PublicSystemInfo"]],["impl Sync for ServerInfo",0,1,["jellytau_lib::auth::ServerInfo"]],["impl Sync for Session",0,1,["jellytau_lib::auth::Session"]],["impl Sync for User",0,1,["jellytau_lib::auth::User"]],["impl Sync for AuthManagerWrapper",0,1,["jellytau_lib::commands::auth::AuthManagerWrapper"]],["impl Sync for SessionVerifierWrapper",0,1,["jellytau_lib::commands::auth::SessionVerifierWrapper"]],["impl Sync for CatalogIndexEvent",0,1,["jellytau_lib::commands::catalog::CatalogIndexEvent"]],["impl Sync for CatalogSyncResult",0,1,["jellytau_lib::commands::catalog::CatalogSyncResult"]],["impl Sync for CatalogSyncStatus",0,1,["jellytau_lib::commands::catalog::CatalogSyncStatus"]],["impl Sync for IndexPassGuard",0,1,["jellytau_lib::commands::catalog::IndexPassGuard"]],["impl Sync for ResumeQueuedResult",0,1,["jellytau_lib::commands::catalog::ResumeQueuedResult"]],["impl Sync for ConnectivityMonitorWrapper",0,1,["jellytau_lib::commands::connectivity::ConnectivityMonitorWrapper"]],["impl Sync for DiagnosticsBundle",0,1,["jellytau_lib::commands::diagnostics::DiagnosticsBundle"]],["impl Sync for DiagnosticsInfo",0,1,["jellytau_lib::commands::diagnostics::DiagnosticsInfo"]],["impl Sync for AlbumAffinityStatus",0,1,["jellytau_lib::commands::download::smart_cache::AlbumAffinityStatus"]],["impl Sync for AlbumRecommendation",0,1,["jellytau_lib::commands::download::smart_cache::AlbumRecommendation"]],["impl Sync for SmartCacheStats",0,1,["jellytau_lib::commands::download::smart_cache::SmartCacheStats"]],["impl Sync for AlbumStorageInfo",0,1,["jellytau_lib::commands::download::AlbumStorageInfo"]],["impl Sync for AlbumTrack",0,1,["jellytau_lib::commands::download::AlbumTrack"]],["impl Sync for DownloadItemAndStartRequest",0,1,["jellytau_lib::commands::download::DownloadItemAndStartRequest"]],["impl Sync for DownloadItemRequest",0,1,["jellytau_lib::commands::download::DownloadItemRequest"]],["impl Sync for DownloadManagerStats",0,1,["jellytau_lib::commands::download::DownloadManagerStats"]],["impl Sync for DownloadManagerWrapper",0,1,["jellytau_lib::commands::download::DownloadManagerWrapper"]],["impl Sync for DownloadStats",0,1,["jellytau_lib::commands::download::DownloadStats"]],["impl Sync for DownloadVideoRequest",0,1,["jellytau_lib::commands::download::DownloadVideoRequest"]],["impl Sync for DownloadsResponse",0,1,["jellytau_lib::commands::download::DownloadsResponse"]],["impl Sync for NetworkStateWrapper",0,1,["jellytau_lib::commands::download::NetworkStateWrapper"]],["impl Sync for NetworkStateWrapperArg",0,1,["jellytau_lib::commands::download::NetworkStateWrapperArg"]],["impl Sync for StorageStats",0,1,["jellytau_lib::commands::download::StorageStats"]],["impl Sync for PendingFavorite",0,1,["jellytau_lib::commands::favorites::PendingFavorite"]],["impl Sync for ExclusionCandidate",0,1,["jellytau_lib::commands::library::ExclusionCandidate"]],["impl Sync for OfflineItem",0,1,["jellytau_lib::commands::offline::OfflineItem"]],["impl Sync for PlaybackModeManagerWrapper",0,1,["jellytau_lib::commands::playback_mode::PlaybackModeManagerWrapper"]],["impl Sync for RemoteSessionStatus",0,1,["jellytau_lib::commands::playback_mode::RemoteSessionStatus"]],["impl Sync for PlaybackReporterWrapper",0,1,["jellytau_lib::commands::playback_reporting::PlaybackReporterWrapper"]],["impl Sync for AddToQueueRequest",0,1,["jellytau_lib::commands::player::queue::AddToQueueRequest"]],["impl Sync for AddTrackByIdRequest",0,1,["jellytau_lib::commands::player::queue::AddTrackByIdRequest"]],["impl Sync for AddTracksByIdsRequest",0,1,["jellytau_lib::commands::player::queue::AddTracksByIdsRequest"]],["impl Sync for BackgroundAudioPlan",0,1,["jellytau_lib::commands::player::BackgroundAudioPlan"]],["impl Sync for MediaSessionManagerWrapper",0,1,["jellytau_lib::commands::player::MediaSessionManagerWrapper"]],["impl Sync for MergedMediaItem",0,1,["jellytau_lib::commands::player::MergedMediaItem"]],["impl Sync for PlayAlbumTrackRequest",0,1,["jellytau_lib::commands::player::PlayAlbumTrackRequest"]],["impl Sync for PlayItemRequest",0,1,["jellytau_lib::commands::player::PlayItemRequest"]],["impl Sync for PlayQueueRequest",0,1,["jellytau_lib::commands::player::PlayQueueRequest"]],["impl Sync for PlayTracksRequest",0,1,["jellytau_lib::commands::player::PlayTracksRequest"]],["impl Sync for PlaybackCapabilities",0,1,["jellytau_lib::commands::player::PlaybackCapabilities"]],["impl Sync for PlayerStateWrapper",0,1,["jellytau_lib::commands::player::PlayerStateWrapper"]],["impl Sync for PlayerStatus",0,1,["jellytau_lib::commands::player::PlayerStatus"]],["impl Sync for PreloadResult",0,1,["jellytau_lib::commands::player::PreloadResult"]],["impl Sync for QueueStatus",0,1,["jellytau_lib::commands::player::QueueStatus"]],["impl Sync for SmartCacheWrapper",0,1,["jellytau_lib::commands::player::SmartCacheWrapper"]],["impl Sync for VideoSettingsWrapper",0,1,["jellytau_lib::commands::player::VideoSettingsWrapper"]],["impl Sync for FavoritesChangedEvent",0,1,["jellytau_lib::commands::repository::FavoritesChangedEvent"]],["impl Sync for RepositoryManager",0,1,["jellytau_lib::commands::repository::RepositoryManager"]],["impl Sync for RepositoryManagerWrapper",0,1,["jellytau_lib::commands::repository::RepositoryManagerWrapper"]],["impl Sync for SearchUpdateEvent",0,1,["jellytau_lib::commands::repository::SearchUpdateEvent"]],["impl Sync for SessionPollerWrapper",0,1,["jellytau_lib::commands::sessions::SessionPollerWrapper"]],["impl Sync for CachedItemPerson",0,1,["jellytau_lib::commands::storage::people::CachedItemPerson"]],["impl Sync for CachedPerson",0,1,["jellytau_lib::commands::storage::people::CachedPerson"]],["impl Sync for SeriesAudioPreference",0,1,["jellytau_lib::commands::storage::series_prefs::SeriesAudioPreference"]],["impl Sync for ActiveSession",0,1,["jellytau_lib::commands::storage::ActiveSession"]],["impl Sync for CachedItem",0,1,["jellytau_lib::commands::storage::CachedItem"]],["impl Sync for CachedLibrary",0,1,["jellytau_lib::commands::storage::CachedLibrary"]],["impl Sync for CredentialStoreWrapper",0,1,["jellytau_lib::commands::storage::CredentialStoreWrapper"]],["impl Sync for DatabaseWrapper",0,1,["jellytau_lib::commands::storage::DatabaseWrapper"]],["impl Sync for PlaybackProgress",0,1,["jellytau_lib::commands::storage::PlaybackProgress"]],["impl Sync for SecurityStatus",0,1,["jellytau_lib::commands::storage::SecurityStatus"]],["impl Sync for ServerInfo",0,1,["jellytau_lib::commands::storage::ServerInfo"]],["impl Sync for ThumbnailCacheWrapper",0,1,["jellytau_lib::commands::storage::ThumbnailCacheWrapper"]],["impl Sync for UserInfo",0,1,["jellytau_lib::commands::storage::UserInfo"]],["impl Sync for GetImageRequest",0,1,["jellytau_lib::commands::storage::thumbnails::GetImageRequest"]],["impl Sync for SyncQueueItem",0,1,["jellytau_lib::commands::sync::SyncQueueItem"]],["impl Sync for DrainReport",0,1,["jellytau_lib::commands::sync_drain::DrainReport"]],["impl Sync for PushFailure",0,1,["jellytau_lib::commands::sync_drain::PushFailure"]],["impl Sync for QueuedRow",0,1,["jellytau_lib::commands::sync_drain::QueuedRow"]],["impl Sync for ConnectivityChangeEvent",0,1,["jellytau_lib::connectivity::ConnectivityChangeEvent"]],["impl Sync for ConnectivityMonitor",0,1,["jellytau_lib::connectivity::ConnectivityMonitor"]],["impl Sync for ConnectivityReporter",0,1,["jellytau_lib::connectivity::ConnectivityReporter"]],["impl Sync for ConnectivityStatus",0,1,["jellytau_lib::connectivity::ConnectivityStatus"]],["impl Sync for CredentialStore",0,1,["jellytau_lib::credentials::CredentialStore"]],["impl Sync for CacheConfig",0,1,["jellytau_lib::download::cache::CacheConfig"]],["impl Sync for SmartCache",0,1,["jellytau_lib::download::cache::SmartCache"]],["impl Sync for NetworkState",0,1,["jellytau_lib::download::network::NetworkState"]],["impl Sync for NetworkStateHandle",0,1,["jellytau_lib::download::network::NetworkStateHandle"]],["impl Sync for DownloadInfo",0,1,["jellytau_lib::download::DownloadInfo"]],["impl Sync for DownloadManager",0,1,["jellytau_lib::download::DownloadManager"]],["impl Sync for DownloadTask",0,1,["jellytau_lib::download::DownloadTask"]],["impl Sync for DownloadResult",0,1,["jellytau_lib::download::worker::DownloadResult"]],["impl Sync for DownloadWorker",0,1,["jellytau_lib::download::worker::DownloadWorker"]],["impl Sync for JellyfinClient",0,1,["jellytau_lib::jellyfin::client::JellyfinClient"]],["impl Sync for LmsSyncGroup",0,1,["jellytau_lib::jellyfin::client::LmsSyncGroup"]],["impl Sync for NowPlayingItem",0,1,["jellytau_lib::jellyfin::client::NowPlayingItem"]],["impl Sync for PlayState",0,1,["jellytau_lib::jellyfin::client::PlayState"]],["impl Sync for SessionInfo",0,1,["jellytau_lib::jellyfin::client::SessionInfo"]],["impl Sync for HttpClient",0,1,["jellytau_lib::jellyfin::http_client::HttpClient"]],["impl Sync for HttpConfig",0,1,["jellytau_lib::jellyfin::http_client::HttpConfig"]],["impl Sync for JellyfinConfig",0,1,["jellytau_lib::jellyfin::types::JellyfinConfig"]],["impl Sync for PlaybackProgressRequest",0,1,["jellytau_lib::jellyfin::types::PlaybackProgressRequest"]],["impl Sync for PlaybackStartRequest",0,1,["jellytau_lib::jellyfin::types::PlaybackStartRequest"]],["impl Sync for PlaybackStoppedRequest",0,1,["jellytau_lib::jellyfin::types::PlaybackStoppedRequest"]],["impl Sync for MediaServer",0,1,["jellytau_lib::media_server::MediaServer"]],["impl Sync for MediaServerWrapper",0,1,["jellytau_lib::media_server::MediaServerWrapper"]],["impl Sync for Span",0,1,["jellytau_lib::media_server::Span"]],["impl Sync for PlatformRemoteVolumeControl",0,1,["jellytau_lib::playback_mode::PlatformRemoteVolumeControl"]],["impl Sync for PlaybackModeManager",0,1,["jellytau_lib::playback_mode::PlaybackModeManager"]],["impl Sync for PlaybackContext",0,1,["jellytau_lib::playback_reporting::reporter::PlaybackContext"]],["impl Sync for PlaybackReporter",0,1,["jellytau_lib::playback_reporting::reporter::PlaybackReporter"]],["impl Sync for SyncConfig",0,1,["jellytau_lib::playback_reporting::sync_processor::SyncConfig"]],["impl Sync for SyncProcessor",0,1,["jellytau_lib::playback_reporting::sync_processor::SyncProcessor"]],["impl Sync for EventThrottler",0,1,["jellytau_lib::playback_reporting::throttle::EventThrottler"]],["impl Sync for AutoplaySettings",0,1,["jellytau_lib::player::autoplay::AutoplaySettings"]],["impl Sync for NullBackend",0,1,["jellytau_lib::player::backend::NullBackend"]],["impl Sync for PlayerError",0,1,["jellytau_lib::player::backend::PlayerError"]],["impl Sync for TauriEventEmitter",0,1,["jellytau_lib::player::events::TauriEventEmitter"]],["impl Sync for MediaItem",0,1,["jellytau_lib::player::media::MediaItem"]],["impl Sync for SubtitleTrack",0,1,["jellytau_lib::player::media::SubtitleTrack"]],["impl Sync for InternalState",0,1,["jellytau_lib::player::mpv_backend::InternalState"]],["impl Sync for MpvBackend",0,1,["jellytau_lib::player::mpv_backend::MpvBackend"]],["impl Sync for QueueManager",0,1,["jellytau_lib::player::queue::QueueManager"]],["impl Sync for MediaSessionManager",0,1,["jellytau_lib::player::session::MediaSessionManager"]],["impl Sync for SleepTimerState",0,1,["jellytau_lib::player::sleep_timer::SleepTimerState"]],["impl Sync for ObservedTime",0,1,["jellytau_lib::player::stream_end::ObservedTime"]],["impl Sync for ResumeTracker",0,1,["jellytau_lib::player::stream_end::ResumeTracker"]],["impl Sync for LockscreenMetadata",0,1,["jellytau_lib::player::LockscreenMetadata"]],["impl Sync for PlayerController",0,1,["jellytau_lib::player::PlayerController"]],["impl Sync for ReporterSink",0,1,["jellytau_lib::player::ReporterSink"]],["impl Sync for HybridRepository",0,1,["jellytau_lib::repository::hybrid::HybridRepository"]],["impl Sync for CachedItem",0,1,["jellytau_lib::repository::offline::CachedItem"]],["impl Sync for OfflineRepository",0,1,["jellytau_lib::repository::offline::OfflineRepository"]],["impl Sync for CreatePlaylistResponse",0,1,["jellytau_lib::repository::online::CreatePlaylistResponse"]],["impl Sync for ItemsResponse",0,1,["jellytau_lib::repository::online::ItemsResponse"]],["impl Sync for JRayActor",0,1,["jellytau_lib::repository::online::JRayActor"]],["impl Sync for JRayContext",0,1,["jellytau_lib::repository::online::JRayContext"]],["impl Sync for JellyfinItem",0,1,["jellytau_lib::repository::online::JellyfinItem"]],["impl Sync for JellyfinMediaSource",0,1,["jellytau_lib::repository::online::JellyfinMediaSource"]],["impl Sync for JellyfinMediaStream",0,1,["jellytau_lib::repository::online::JellyfinMediaStream"]],["impl Sync for JellyfinPlaylistItem",0,1,["jellytau_lib::repository::online::JellyfinPlaylistItem"]],["impl Sync for JellyfinUserData",0,1,["jellytau_lib::repository::online::JellyfinUserData"]],["impl Sync for OnlineRepository",0,1,["jellytau_lib::repository::online::OnlineRepository"]],["impl Sync for PlaylistItemsResponse",0,1,["jellytau_lib::repository::online::PlaylistItemsResponse"]],["impl Sync for ArtistItem",0,1,["jellytau_lib::repository::types::ArtistItem"]],["impl Sync for DownloadDiskUsage",0,1,["jellytau_lib::repository::types::DownloadDiskUsage"]],["impl Sync for Genre",0,1,["jellytau_lib::repository::types::Genre"]],["impl Sync for GetItemsOptions",0,1,["jellytau_lib::repository::types::GetItemsOptions"]],["impl Sync for ImageOptions",0,1,["jellytau_lib::repository::types::ImageOptions"]],["impl Sync for Library",0,1,["jellytau_lib::repository::types::Library"]],["impl Sync for LiveStreamInfo",0,1,["jellytau_lib::repository::types::LiveStreamInfo"]],["impl Sync for MediaItem",0,1,["jellytau_lib::repository::types::MediaItem"]],["impl Sync for MediaSource",0,1,["jellytau_lib::repository::types::MediaSource"]],["impl Sync for MediaStream",0,1,["jellytau_lib::repository::types::MediaStream"]],["impl Sync for Person",0,1,["jellytau_lib::repository::types::Person"]],["impl Sync for PlaybackInfo",0,1,["jellytau_lib::repository::types::PlaybackInfo"]],["impl Sync for PlaylistCreatedResult",0,1,["jellytau_lib::repository::types::PlaylistCreatedResult"]],["impl Sync for PlaylistEntry",0,1,["jellytau_lib::repository::types::PlaylistEntry"]],["impl Sync for SearchOptions",0,1,["jellytau_lib::repository::types::SearchOptions"]],["impl Sync for SearchResult",0,1,["jellytau_lib::repository::types::SearchResult"]],["impl Sync for UserData",0,1,["jellytau_lib::repository::types::UserData"]],["impl Sync for SessionPollerManager",0,1,["jellytau_lib::session_poller::SessionPollerManager"]],["impl Sync for AudioSettings",0,1,["jellytau_lib::settings::AudioSettings"]],["impl Sync for LibrarySettings",0,1,["jellytau_lib::settings::LibrarySettings"]],["impl Sync for VideoSettings",0,1,["jellytau_lib::settings::VideoSettings"]],["impl Sync for Query",0,1,["jellytau_lib::storage::db_service::Query"]],["impl Sync for RusqliteService",0,1,["jellytau_lib::storage::db_service::RusqliteService"]],["impl Sync for PlaylistItem",0,1,["jellytau_lib::storage::models::PlaylistItem"]],["impl Sync for Database",0,1,["jellytau_lib::storage::Database"]],["impl Sync for BackendInitError",0,1,["jellytau_lib::BackendInitError"]],["impl Sync for CacheConfig",0,1,["jellytau_lib::thumbnail::cache::CacheConfig"]],["impl Sync for ThumbnailCache",0,1,["jellytau_lib::thumbnail::cache::ThumbnailCache"]],["impl Sync for ThumbnailCacheStats",0,1,["jellytau_lib::thumbnail::ThumbnailCacheStats"]],["impl Sync for ThumbnailWorker",0,1,["jellytau_lib::thumbnail::worker::ThumbnailWorker"]]]]]); + if (window.register_implementors) { + window.register_implementors(implementors); + } else { + window.pending_implementors = implementors; + } +})() +//{"start":59,"fragment_lengths":[81870]} \ No newline at end of file diff --git a/api/trait.impl/core/marker/trait.Unpin.js b/api/trait.impl/core/marker/trait.Unpin.js new file mode 100644 index 00000000..1d2ffa58 --- /dev/null +++ b/api/trait.impl/core/marker/trait.Unpin.js @@ -0,0 +1,9 @@ +(function() { + const implementors = Object.fromEntries([["jellytau_lib",[["impl Unpin for SessionVerificationEvent",0,1,["jellytau_lib::auth::session_verifier::SessionVerificationEvent"]],["impl Unpin for AudioTrackSwitchResponse",0,1,["jellytau_lib::commands::player::AudioTrackSwitchResponse"]],["impl Unpin for PlayQueueContext",0,1,["jellytau_lib::commands::player::PlayQueueContext"]],["impl Unpin for PlayTracksContext",0,1,["jellytau_lib::commands::player::PlayTracksContext"]],["impl Unpin for StreamQualityResponse",0,1,["jellytau_lib::commands::player::StreamQualityResponse"]],["impl Unpin for VideoBackend",0,1,["jellytau_lib::commands::player::VideoBackend"]],["impl Unpin for VideoSeekResponse",0,1,["jellytau_lib::commands::player::VideoSeekResponse"]],["impl Unpin for QueuedOp",0,1,["jellytau_lib::commands::sync_drain::QueuedOp"]],["impl Unpin for CredentialError",0,1,["jellytau_lib::credentials::CredentialError"]],["impl Unpin for CredentialResult",0,1,["jellytau_lib::credentials::CredentialResult"]],["impl Unpin for MediaKind",0,1,["jellytau_lib::domain::media::MediaKind"]],["impl Unpin for StreamKind",0,1,["jellytau_lib::domain::media::StreamKind"]],["impl Unpin for MatchQuality",0,1,["jellytau_lib::domain::search_rank::MatchQuality"]],["impl Unpin for DownloadEvent",0,1,["jellytau_lib::download::events::DownloadEvent"]],["impl Unpin for NetworkType",0,1,["jellytau_lib::download::network::NetworkType"]],["impl Unpin for DownloadError",0,1,["jellytau_lib::download::worker::DownloadError"]],["impl Unpin for ErrorKind",0,1,["jellytau_lib::jellyfin::http_client::ErrorKind"]],["impl Unpin for Resolved",0,1,["jellytau_lib::media_server::Resolved"]],["impl Unpin for PlaybackMode",0,1,["jellytau_lib::playback_mode::PlaybackMode"]],["impl Unpin for PlaybackOperation",0,1,["jellytau_lib::playback_reporting::reporter::PlaybackOperation"]],["impl Unpin for AutoplayDecision",0,1,["jellytau_lib::player::autoplay::AutoplayDecision"]],["impl Unpin for BackgroundAction",0,1,["jellytau_lib::player::background_policy::BackgroundAction"]],["impl Unpin for PlayerStatusEvent",0,1,["jellytau_lib::player::events::PlayerStatusEvent"]],["impl Unpin for MediaSource",0,1,["jellytau_lib::player::media::MediaSource"]],["impl Unpin for MediaType",0,1,["jellytau_lib::player::media::MediaType"]],["impl Unpin for QueueContext",0,1,["jellytau_lib::player::media::QueueContext"]],["impl Unpin for AddPosition",0,1,["jellytau_lib::player::queue::AddPosition"]],["impl Unpin for RepeatMode",0,1,["jellytau_lib::player::queue::RepeatMode"]],["impl Unpin for SkipAction",0,1,["jellytau_lib::player::seek::SkipAction"]],["impl Unpin for VideoSeekStrategy",0,1,["jellytau_lib::player::seek::VideoSeekStrategy"]],["impl Unpin for MediaSessionType",0,1,["jellytau_lib::player::session::MediaSessionType"]],["impl Unpin for SleepTimerMode",0,1,["jellytau_lib::player::sleep_timer::SleepTimerMode"]],["impl Unpin for EndReason",0,1,["jellytau_lib::player::state::EndReason"]],["impl Unpin for PlayerState",0,1,["jellytau_lib::player::state::PlayerState"]],["impl Unpin for ImageTags",0,1,["jellytau_lib::repository::online::ImageTags"]],["impl Unpin for ImageType",0,1,["jellytau_lib::repository::types::ImageType"]],["impl Unpin for RepoError",0,1,["jellytau_lib::repository::types::RepoError"]],["impl Unpin for SearchScope",0,1,["jellytau_lib::repository::types::SearchScope"]],["impl Unpin for PollingHint",0,1,["jellytau_lib::session_poller::PollingHint"]],["impl Unpin for EqPreset",0,1,["jellytau_lib::settings::EqPreset"]],["impl Unpin for StreamingQuality",0,1,["jellytau_lib::settings::StreamingQuality"]],["impl Unpin for VolumeLevel",0,1,["jellytau_lib::settings::VolumeLevel"]],["impl Unpin for QueryParam",0,1,["jellytau_lib::storage::db_service::QueryParam"]],["impl Unpin for ThumbnailDownloadError",0,1,["jellytau_lib::thumbnail::worker::ThumbnailDownloadError"]],["impl Unpin for SessionVerifier",0,1,["jellytau_lib::auth::session_verifier::SessionVerifier"]],["impl Unpin for AuthManager",0,1,["jellytau_lib::auth::AuthManager"]],["impl Unpin for AuthResult",0,1,["jellytau_lib::auth::AuthResult"]],["impl Unpin for AuthenticateByNameResponse",0,1,["jellytau_lib::auth::AuthenticateByNameResponse"]],["impl Unpin for JellyfinUser",0,1,["jellytau_lib::auth::JellyfinUser"]],["impl Unpin for PublicSystemInfo",0,1,["jellytau_lib::auth::PublicSystemInfo"]],["impl Unpin for ServerInfo",0,1,["jellytau_lib::auth::ServerInfo"]],["impl Unpin for Session",0,1,["jellytau_lib::auth::Session"]],["impl Unpin for User",0,1,["jellytau_lib::auth::User"]],["impl Unpin for AuthManagerWrapper",0,1,["jellytau_lib::commands::auth::AuthManagerWrapper"]],["impl Unpin for SessionVerifierWrapper",0,1,["jellytau_lib::commands::auth::SessionVerifierWrapper"]],["impl Unpin for CatalogIndexEvent",0,1,["jellytau_lib::commands::catalog::CatalogIndexEvent"]],["impl Unpin for CatalogSyncResult",0,1,["jellytau_lib::commands::catalog::CatalogSyncResult"]],["impl Unpin for CatalogSyncStatus",0,1,["jellytau_lib::commands::catalog::CatalogSyncStatus"]],["impl Unpin for IndexPassGuard",0,1,["jellytau_lib::commands::catalog::IndexPassGuard"]],["impl Unpin for ResumeQueuedResult",0,1,["jellytau_lib::commands::catalog::ResumeQueuedResult"]],["impl Unpin for ConnectivityMonitorWrapper",0,1,["jellytau_lib::commands::connectivity::ConnectivityMonitorWrapper"]],["impl Unpin for DiagnosticsBundle",0,1,["jellytau_lib::commands::diagnostics::DiagnosticsBundle"]],["impl Unpin for DiagnosticsInfo",0,1,["jellytau_lib::commands::diagnostics::DiagnosticsInfo"]],["impl Unpin for AlbumAffinityStatus",0,1,["jellytau_lib::commands::download::smart_cache::AlbumAffinityStatus"]],["impl Unpin for AlbumRecommendation",0,1,["jellytau_lib::commands::download::smart_cache::AlbumRecommendation"]],["impl Unpin for SmartCacheStats",0,1,["jellytau_lib::commands::download::smart_cache::SmartCacheStats"]],["impl Unpin for AlbumStorageInfo",0,1,["jellytau_lib::commands::download::AlbumStorageInfo"]],["impl Unpin for AlbumTrack",0,1,["jellytau_lib::commands::download::AlbumTrack"]],["impl Unpin for DownloadItemAndStartRequest",0,1,["jellytau_lib::commands::download::DownloadItemAndStartRequest"]],["impl Unpin for DownloadItemRequest",0,1,["jellytau_lib::commands::download::DownloadItemRequest"]],["impl Unpin for DownloadManagerStats",0,1,["jellytau_lib::commands::download::DownloadManagerStats"]],["impl Unpin for DownloadManagerWrapper",0,1,["jellytau_lib::commands::download::DownloadManagerWrapper"]],["impl Unpin for DownloadStats",0,1,["jellytau_lib::commands::download::DownloadStats"]],["impl Unpin for DownloadVideoRequest",0,1,["jellytau_lib::commands::download::DownloadVideoRequest"]],["impl Unpin for DownloadsResponse",0,1,["jellytau_lib::commands::download::DownloadsResponse"]],["impl Unpin for NetworkStateWrapper",0,1,["jellytau_lib::commands::download::NetworkStateWrapper"]],["impl Unpin for NetworkStateWrapperArg",0,1,["jellytau_lib::commands::download::NetworkStateWrapperArg"]],["impl Unpin for StorageStats",0,1,["jellytau_lib::commands::download::StorageStats"]],["impl Unpin for PendingFavorite",0,1,["jellytau_lib::commands::favorites::PendingFavorite"]],["impl Unpin for ExclusionCandidate",0,1,["jellytau_lib::commands::library::ExclusionCandidate"]],["impl Unpin for OfflineItem",0,1,["jellytau_lib::commands::offline::OfflineItem"]],["impl Unpin for PlaybackModeManagerWrapper",0,1,["jellytau_lib::commands::playback_mode::PlaybackModeManagerWrapper"]],["impl Unpin for RemoteSessionStatus",0,1,["jellytau_lib::commands::playback_mode::RemoteSessionStatus"]],["impl Unpin for PlaybackReporterWrapper",0,1,["jellytau_lib::commands::playback_reporting::PlaybackReporterWrapper"]],["impl Unpin for AddToQueueRequest",0,1,["jellytau_lib::commands::player::queue::AddToQueueRequest"]],["impl Unpin for AddTrackByIdRequest",0,1,["jellytau_lib::commands::player::queue::AddTrackByIdRequest"]],["impl Unpin for AddTracksByIdsRequest",0,1,["jellytau_lib::commands::player::queue::AddTracksByIdsRequest"]],["impl Unpin for BackgroundAudioPlan",0,1,["jellytau_lib::commands::player::BackgroundAudioPlan"]],["impl Unpin for MediaSessionManagerWrapper",0,1,["jellytau_lib::commands::player::MediaSessionManagerWrapper"]],["impl Unpin for MergedMediaItem",0,1,["jellytau_lib::commands::player::MergedMediaItem"]],["impl Unpin for PlayAlbumTrackRequest",0,1,["jellytau_lib::commands::player::PlayAlbumTrackRequest"]],["impl Unpin for PlayItemRequest",0,1,["jellytau_lib::commands::player::PlayItemRequest"]],["impl Unpin for PlayQueueRequest",0,1,["jellytau_lib::commands::player::PlayQueueRequest"]],["impl Unpin for PlayTracksRequest",0,1,["jellytau_lib::commands::player::PlayTracksRequest"]],["impl Unpin for PlaybackCapabilities",0,1,["jellytau_lib::commands::player::PlaybackCapabilities"]],["impl Unpin for PlayerStateWrapper",0,1,["jellytau_lib::commands::player::PlayerStateWrapper"]],["impl Unpin for PlayerStatus",0,1,["jellytau_lib::commands::player::PlayerStatus"]],["impl Unpin for PreloadResult",0,1,["jellytau_lib::commands::player::PreloadResult"]],["impl Unpin for QueueStatus",0,1,["jellytau_lib::commands::player::QueueStatus"]],["impl Unpin for SmartCacheWrapper",0,1,["jellytau_lib::commands::player::SmartCacheWrapper"]],["impl Unpin for VideoSettingsWrapper",0,1,["jellytau_lib::commands::player::VideoSettingsWrapper"]],["impl Unpin for FavoritesChangedEvent",0,1,["jellytau_lib::commands::repository::FavoritesChangedEvent"]],["impl Unpin for RepositoryManager",0,1,["jellytau_lib::commands::repository::RepositoryManager"]],["impl Unpin for RepositoryManagerWrapper",0,1,["jellytau_lib::commands::repository::RepositoryManagerWrapper"]],["impl Unpin for SearchUpdateEvent",0,1,["jellytau_lib::commands::repository::SearchUpdateEvent"]],["impl Unpin for SessionPollerWrapper",0,1,["jellytau_lib::commands::sessions::SessionPollerWrapper"]],["impl Unpin for CachedItemPerson",0,1,["jellytau_lib::commands::storage::people::CachedItemPerson"]],["impl Unpin for CachedPerson",0,1,["jellytau_lib::commands::storage::people::CachedPerson"]],["impl Unpin for SeriesAudioPreference",0,1,["jellytau_lib::commands::storage::series_prefs::SeriesAudioPreference"]],["impl Unpin for ActiveSession",0,1,["jellytau_lib::commands::storage::ActiveSession"]],["impl Unpin for CachedItem",0,1,["jellytau_lib::commands::storage::CachedItem"]],["impl Unpin for CachedLibrary",0,1,["jellytau_lib::commands::storage::CachedLibrary"]],["impl Unpin for CredentialStoreWrapper",0,1,["jellytau_lib::commands::storage::CredentialStoreWrapper"]],["impl Unpin for DatabaseWrapper",0,1,["jellytau_lib::commands::storage::DatabaseWrapper"]],["impl Unpin for PlaybackProgress",0,1,["jellytau_lib::commands::storage::PlaybackProgress"]],["impl Unpin for SecurityStatus",0,1,["jellytau_lib::commands::storage::SecurityStatus"]],["impl Unpin for ServerInfo",0,1,["jellytau_lib::commands::storage::ServerInfo"]],["impl Unpin for ThumbnailCacheWrapper",0,1,["jellytau_lib::commands::storage::ThumbnailCacheWrapper"]],["impl Unpin for UserInfo",0,1,["jellytau_lib::commands::storage::UserInfo"]],["impl Unpin for GetImageRequest",0,1,["jellytau_lib::commands::storage::thumbnails::GetImageRequest"]],["impl Unpin for SyncQueueItem",0,1,["jellytau_lib::commands::sync::SyncQueueItem"]],["impl Unpin for DrainReport",0,1,["jellytau_lib::commands::sync_drain::DrainReport"]],["impl Unpin for PushFailure",0,1,["jellytau_lib::commands::sync_drain::PushFailure"]],["impl Unpin for QueuedRow",0,1,["jellytau_lib::commands::sync_drain::QueuedRow"]],["impl Unpin for ConnectivityChangeEvent",0,1,["jellytau_lib::connectivity::ConnectivityChangeEvent"]],["impl Unpin for ConnectivityMonitor",0,1,["jellytau_lib::connectivity::ConnectivityMonitor"]],["impl Unpin for ConnectivityReporter",0,1,["jellytau_lib::connectivity::ConnectivityReporter"]],["impl Unpin for ConnectivityStatus",0,1,["jellytau_lib::connectivity::ConnectivityStatus"]],["impl Unpin for CredentialStore",0,1,["jellytau_lib::credentials::CredentialStore"]],["impl Unpin for CacheConfig",0,1,["jellytau_lib::download::cache::CacheConfig"]],["impl Unpin for SmartCache",0,1,["jellytau_lib::download::cache::SmartCache"]],["impl Unpin for NetworkState",0,1,["jellytau_lib::download::network::NetworkState"]],["impl Unpin for NetworkStateHandle",0,1,["jellytau_lib::download::network::NetworkStateHandle"]],["impl Unpin for DownloadInfo",0,1,["jellytau_lib::download::DownloadInfo"]],["impl Unpin for DownloadManager",0,1,["jellytau_lib::download::DownloadManager"]],["impl Unpin for DownloadTask",0,1,["jellytau_lib::download::DownloadTask"]],["impl Unpin for DownloadResult",0,1,["jellytau_lib::download::worker::DownloadResult"]],["impl Unpin for DownloadWorker",0,1,["jellytau_lib::download::worker::DownloadWorker"]],["impl Unpin for JellyfinClient",0,1,["jellytau_lib::jellyfin::client::JellyfinClient"]],["impl Unpin for LmsSyncGroup",0,1,["jellytau_lib::jellyfin::client::LmsSyncGroup"]],["impl Unpin for NowPlayingItem",0,1,["jellytau_lib::jellyfin::client::NowPlayingItem"]],["impl Unpin for PlayState",0,1,["jellytau_lib::jellyfin::client::PlayState"]],["impl Unpin for SessionInfo",0,1,["jellytau_lib::jellyfin::client::SessionInfo"]],["impl Unpin for HttpClient",0,1,["jellytau_lib::jellyfin::http_client::HttpClient"]],["impl Unpin for HttpConfig",0,1,["jellytau_lib::jellyfin::http_client::HttpConfig"]],["impl Unpin for JellyfinConfig",0,1,["jellytau_lib::jellyfin::types::JellyfinConfig"]],["impl Unpin for PlaybackProgressRequest",0,1,["jellytau_lib::jellyfin::types::PlaybackProgressRequest"]],["impl Unpin for PlaybackStartRequest",0,1,["jellytau_lib::jellyfin::types::PlaybackStartRequest"]],["impl Unpin for PlaybackStoppedRequest",0,1,["jellytau_lib::jellyfin::types::PlaybackStoppedRequest"]],["impl Unpin for MediaServer",0,1,["jellytau_lib::media_server::MediaServer"]],["impl Unpin for MediaServerWrapper",0,1,["jellytau_lib::media_server::MediaServerWrapper"]],["impl Unpin for Span",0,1,["jellytau_lib::media_server::Span"]],["impl Unpin for PlatformRemoteVolumeControl",0,1,["jellytau_lib::playback_mode::PlatformRemoteVolumeControl"]],["impl Unpin for PlaybackModeManager",0,1,["jellytau_lib::playback_mode::PlaybackModeManager"]],["impl Unpin for PlaybackContext",0,1,["jellytau_lib::playback_reporting::reporter::PlaybackContext"]],["impl Unpin for PlaybackReporter",0,1,["jellytau_lib::playback_reporting::reporter::PlaybackReporter"]],["impl Unpin for SyncConfig",0,1,["jellytau_lib::playback_reporting::sync_processor::SyncConfig"]],["impl Unpin for SyncProcessor",0,1,["jellytau_lib::playback_reporting::sync_processor::SyncProcessor"]],["impl Unpin for EventThrottler",0,1,["jellytau_lib::playback_reporting::throttle::EventThrottler"]],["impl Unpin for AutoplaySettings",0,1,["jellytau_lib::player::autoplay::AutoplaySettings"]],["impl Unpin for NullBackend",0,1,["jellytau_lib::player::backend::NullBackend"]],["impl Unpin for PlayerError",0,1,["jellytau_lib::player::backend::PlayerError"]],["impl Unpin for TauriEventEmitter",0,1,["jellytau_lib::player::events::TauriEventEmitter"]],["impl Unpin for MediaItem",0,1,["jellytau_lib::player::media::MediaItem"]],["impl Unpin for SubtitleTrack",0,1,["jellytau_lib::player::media::SubtitleTrack"]],["impl Unpin for InternalState",0,1,["jellytau_lib::player::mpv_backend::InternalState"]],["impl Unpin for MpvBackend",0,1,["jellytau_lib::player::mpv_backend::MpvBackend"]],["impl Unpin for QueueManager",0,1,["jellytau_lib::player::queue::QueueManager"]],["impl Unpin for MediaSessionManager",0,1,["jellytau_lib::player::session::MediaSessionManager"]],["impl Unpin for SleepTimerState",0,1,["jellytau_lib::player::sleep_timer::SleepTimerState"]],["impl Unpin for ObservedTime",0,1,["jellytau_lib::player::stream_end::ObservedTime"]],["impl Unpin for ResumeTracker",0,1,["jellytau_lib::player::stream_end::ResumeTracker"]],["impl Unpin for LockscreenMetadata",0,1,["jellytau_lib::player::LockscreenMetadata"]],["impl Unpin for PlayerController",0,1,["jellytau_lib::player::PlayerController"]],["impl Unpin for ReporterSink",0,1,["jellytau_lib::player::ReporterSink"]],["impl Unpin for HybridRepository",0,1,["jellytau_lib::repository::hybrid::HybridRepository"]],["impl Unpin for CachedItem",0,1,["jellytau_lib::repository::offline::CachedItem"]],["impl Unpin for OfflineRepository",0,1,["jellytau_lib::repository::offline::OfflineRepository"]],["impl Unpin for CreatePlaylistResponse",0,1,["jellytau_lib::repository::online::CreatePlaylistResponse"]],["impl Unpin for ItemsResponse",0,1,["jellytau_lib::repository::online::ItemsResponse"]],["impl Unpin for JRayActor",0,1,["jellytau_lib::repository::online::JRayActor"]],["impl Unpin for JRayContext",0,1,["jellytau_lib::repository::online::JRayContext"]],["impl Unpin for JellyfinItem",0,1,["jellytau_lib::repository::online::JellyfinItem"]],["impl Unpin for JellyfinMediaSource",0,1,["jellytau_lib::repository::online::JellyfinMediaSource"]],["impl Unpin for JellyfinMediaStream",0,1,["jellytau_lib::repository::online::JellyfinMediaStream"]],["impl Unpin for JellyfinPlaylistItem",0,1,["jellytau_lib::repository::online::JellyfinPlaylistItem"]],["impl Unpin for JellyfinUserData",0,1,["jellytau_lib::repository::online::JellyfinUserData"]],["impl Unpin for OnlineRepository",0,1,["jellytau_lib::repository::online::OnlineRepository"]],["impl Unpin for PlaylistItemsResponse",0,1,["jellytau_lib::repository::online::PlaylistItemsResponse"]],["impl Unpin for ArtistItem",0,1,["jellytau_lib::repository::types::ArtistItem"]],["impl Unpin for DownloadDiskUsage",0,1,["jellytau_lib::repository::types::DownloadDiskUsage"]],["impl Unpin for Genre",0,1,["jellytau_lib::repository::types::Genre"]],["impl Unpin for GetItemsOptions",0,1,["jellytau_lib::repository::types::GetItemsOptions"]],["impl Unpin for ImageOptions",0,1,["jellytau_lib::repository::types::ImageOptions"]],["impl Unpin for Library",0,1,["jellytau_lib::repository::types::Library"]],["impl Unpin for LiveStreamInfo",0,1,["jellytau_lib::repository::types::LiveStreamInfo"]],["impl Unpin for MediaItem",0,1,["jellytau_lib::repository::types::MediaItem"]],["impl Unpin for MediaSource",0,1,["jellytau_lib::repository::types::MediaSource"]],["impl Unpin for MediaStream",0,1,["jellytau_lib::repository::types::MediaStream"]],["impl Unpin for Person",0,1,["jellytau_lib::repository::types::Person"]],["impl Unpin for PlaybackInfo",0,1,["jellytau_lib::repository::types::PlaybackInfo"]],["impl Unpin for PlaylistCreatedResult",0,1,["jellytau_lib::repository::types::PlaylistCreatedResult"]],["impl Unpin for PlaylistEntry",0,1,["jellytau_lib::repository::types::PlaylistEntry"]],["impl Unpin for SearchOptions",0,1,["jellytau_lib::repository::types::SearchOptions"]],["impl Unpin for SearchResult",0,1,["jellytau_lib::repository::types::SearchResult"]],["impl Unpin for UserData",0,1,["jellytau_lib::repository::types::UserData"]],["impl Unpin for SessionPollerManager",0,1,["jellytau_lib::session_poller::SessionPollerManager"]],["impl Unpin for AudioSettings",0,1,["jellytau_lib::settings::AudioSettings"]],["impl Unpin for LibrarySettings",0,1,["jellytau_lib::settings::LibrarySettings"]],["impl Unpin for VideoSettings",0,1,["jellytau_lib::settings::VideoSettings"]],["impl Unpin for Query",0,1,["jellytau_lib::storage::db_service::Query"]],["impl Unpin for RusqliteService",0,1,["jellytau_lib::storage::db_service::RusqliteService"]],["impl Unpin for PlaylistItem",0,1,["jellytau_lib::storage::models::PlaylistItem"]],["impl Unpin for Database",0,1,["jellytau_lib::storage::Database"]],["impl Unpin for BackendInitError",0,1,["jellytau_lib::BackendInitError"]],["impl Unpin for CacheConfig",0,1,["jellytau_lib::thumbnail::cache::CacheConfig"]],["impl Unpin for ThumbnailCache",0,1,["jellytau_lib::thumbnail::cache::ThumbnailCache"]],["impl Unpin for ThumbnailCacheStats",0,1,["jellytau_lib::thumbnail::ThumbnailCacheStats"]],["impl Unpin for ThumbnailWorker",0,1,["jellytau_lib::thumbnail::worker::ThumbnailWorker"]],["impl<'a> Unpin for Transaction<'a>",0,1,["jellytau_lib::storage::db_service::Transaction"]]]]]); + if (window.register_implementors) { + window.register_implementors(implementors); + } else { + window.pending_implementors = implementors; + } +})() +//{"start":59,"fragment_lengths":[82529]} \ No newline at end of file diff --git a/api/trait.impl/core/marker/trait.UnsafeUnpin.js b/api/trait.impl/core/marker/trait.UnsafeUnpin.js new file mode 100644 index 00000000..e5235aa7 --- /dev/null +++ b/api/trait.impl/core/marker/trait.UnsafeUnpin.js @@ -0,0 +1,9 @@ +(function() { + const implementors = Object.fromEntries([["jellytau_lib",[["impl UnsafeUnpin for SessionVerificationEvent",0,1,["jellytau_lib::auth::session_verifier::SessionVerificationEvent"]],["impl UnsafeUnpin for AudioTrackSwitchResponse",0,1,["jellytau_lib::commands::player::AudioTrackSwitchResponse"]],["impl UnsafeUnpin for PlayQueueContext",0,1,["jellytau_lib::commands::player::PlayQueueContext"]],["impl UnsafeUnpin for PlayTracksContext",0,1,["jellytau_lib::commands::player::PlayTracksContext"]],["impl UnsafeUnpin for StreamQualityResponse",0,1,["jellytau_lib::commands::player::StreamQualityResponse"]],["impl UnsafeUnpin for VideoBackend",0,1,["jellytau_lib::commands::player::VideoBackend"]],["impl UnsafeUnpin for VideoSeekResponse",0,1,["jellytau_lib::commands::player::VideoSeekResponse"]],["impl UnsafeUnpin for QueuedOp",0,1,["jellytau_lib::commands::sync_drain::QueuedOp"]],["impl UnsafeUnpin for CredentialError",0,1,["jellytau_lib::credentials::CredentialError"]],["impl UnsafeUnpin for CredentialResult",0,1,["jellytau_lib::credentials::CredentialResult"]],["impl UnsafeUnpin for MediaKind",0,1,["jellytau_lib::domain::media::MediaKind"]],["impl UnsafeUnpin for StreamKind",0,1,["jellytau_lib::domain::media::StreamKind"]],["impl UnsafeUnpin for MatchQuality",0,1,["jellytau_lib::domain::search_rank::MatchQuality"]],["impl UnsafeUnpin for DownloadEvent",0,1,["jellytau_lib::download::events::DownloadEvent"]],["impl UnsafeUnpin for NetworkType",0,1,["jellytau_lib::download::network::NetworkType"]],["impl UnsafeUnpin for DownloadError",0,1,["jellytau_lib::download::worker::DownloadError"]],["impl UnsafeUnpin for ErrorKind",0,1,["jellytau_lib::jellyfin::http_client::ErrorKind"]],["impl UnsafeUnpin for Resolved",0,1,["jellytau_lib::media_server::Resolved"]],["impl UnsafeUnpin for PlaybackMode",0,1,["jellytau_lib::playback_mode::PlaybackMode"]],["impl UnsafeUnpin for PlaybackOperation",0,1,["jellytau_lib::playback_reporting::reporter::PlaybackOperation"]],["impl UnsafeUnpin for AutoplayDecision",0,1,["jellytau_lib::player::autoplay::AutoplayDecision"]],["impl UnsafeUnpin for BackgroundAction",0,1,["jellytau_lib::player::background_policy::BackgroundAction"]],["impl UnsafeUnpin for PlayerStatusEvent",0,1,["jellytau_lib::player::events::PlayerStatusEvent"]],["impl UnsafeUnpin for MediaSource",0,1,["jellytau_lib::player::media::MediaSource"]],["impl UnsafeUnpin for MediaType",0,1,["jellytau_lib::player::media::MediaType"]],["impl UnsafeUnpin for QueueContext",0,1,["jellytau_lib::player::media::QueueContext"]],["impl UnsafeUnpin for AddPosition",0,1,["jellytau_lib::player::queue::AddPosition"]],["impl UnsafeUnpin for RepeatMode",0,1,["jellytau_lib::player::queue::RepeatMode"]],["impl UnsafeUnpin for SkipAction",0,1,["jellytau_lib::player::seek::SkipAction"]],["impl UnsafeUnpin for VideoSeekStrategy",0,1,["jellytau_lib::player::seek::VideoSeekStrategy"]],["impl UnsafeUnpin for MediaSessionType",0,1,["jellytau_lib::player::session::MediaSessionType"]],["impl UnsafeUnpin for SleepTimerMode",0,1,["jellytau_lib::player::sleep_timer::SleepTimerMode"]],["impl UnsafeUnpin for EndReason",0,1,["jellytau_lib::player::state::EndReason"]],["impl UnsafeUnpin for PlayerState",0,1,["jellytau_lib::player::state::PlayerState"]],["impl UnsafeUnpin for ImageTags",0,1,["jellytau_lib::repository::online::ImageTags"]],["impl UnsafeUnpin for ImageType",0,1,["jellytau_lib::repository::types::ImageType"]],["impl UnsafeUnpin for RepoError",0,1,["jellytau_lib::repository::types::RepoError"]],["impl UnsafeUnpin for SearchScope",0,1,["jellytau_lib::repository::types::SearchScope"]],["impl UnsafeUnpin for PollingHint",0,1,["jellytau_lib::session_poller::PollingHint"]],["impl UnsafeUnpin for EqPreset",0,1,["jellytau_lib::settings::EqPreset"]],["impl UnsafeUnpin for StreamingQuality",0,1,["jellytau_lib::settings::StreamingQuality"]],["impl UnsafeUnpin for VolumeLevel",0,1,["jellytau_lib::settings::VolumeLevel"]],["impl UnsafeUnpin for QueryParam",0,1,["jellytau_lib::storage::db_service::QueryParam"]],["impl UnsafeUnpin for ThumbnailDownloadError",0,1,["jellytau_lib::thumbnail::worker::ThumbnailDownloadError"]],["impl UnsafeUnpin for SessionVerifier",0,1,["jellytau_lib::auth::session_verifier::SessionVerifier"]],["impl UnsafeUnpin for AuthManager",0,1,["jellytau_lib::auth::AuthManager"]],["impl UnsafeUnpin for AuthResult",0,1,["jellytau_lib::auth::AuthResult"]],["impl UnsafeUnpin for AuthenticateByNameResponse",0,1,["jellytau_lib::auth::AuthenticateByNameResponse"]],["impl UnsafeUnpin for JellyfinUser",0,1,["jellytau_lib::auth::JellyfinUser"]],["impl UnsafeUnpin for PublicSystemInfo",0,1,["jellytau_lib::auth::PublicSystemInfo"]],["impl UnsafeUnpin for ServerInfo",0,1,["jellytau_lib::auth::ServerInfo"]],["impl UnsafeUnpin for Session",0,1,["jellytau_lib::auth::Session"]],["impl UnsafeUnpin for User",0,1,["jellytau_lib::auth::User"]],["impl UnsafeUnpin for AuthManagerWrapper",0,1,["jellytau_lib::commands::auth::AuthManagerWrapper"]],["impl UnsafeUnpin for SessionVerifierWrapper",0,1,["jellytau_lib::commands::auth::SessionVerifierWrapper"]],["impl UnsafeUnpin for CatalogIndexEvent",0,1,["jellytau_lib::commands::catalog::CatalogIndexEvent"]],["impl UnsafeUnpin for CatalogSyncResult",0,1,["jellytau_lib::commands::catalog::CatalogSyncResult"]],["impl UnsafeUnpin for CatalogSyncStatus",0,1,["jellytau_lib::commands::catalog::CatalogSyncStatus"]],["impl UnsafeUnpin for IndexPassGuard",0,1,["jellytau_lib::commands::catalog::IndexPassGuard"]],["impl UnsafeUnpin for ResumeQueuedResult",0,1,["jellytau_lib::commands::catalog::ResumeQueuedResult"]],["impl UnsafeUnpin for ConnectivityMonitorWrapper",0,1,["jellytau_lib::commands::connectivity::ConnectivityMonitorWrapper"]],["impl UnsafeUnpin for DiagnosticsBundle",0,1,["jellytau_lib::commands::diagnostics::DiagnosticsBundle"]],["impl UnsafeUnpin for DiagnosticsInfo",0,1,["jellytau_lib::commands::diagnostics::DiagnosticsInfo"]],["impl UnsafeUnpin for AlbumAffinityStatus",0,1,["jellytau_lib::commands::download::smart_cache::AlbumAffinityStatus"]],["impl UnsafeUnpin for AlbumRecommendation",0,1,["jellytau_lib::commands::download::smart_cache::AlbumRecommendation"]],["impl UnsafeUnpin for SmartCacheStats",0,1,["jellytau_lib::commands::download::smart_cache::SmartCacheStats"]],["impl UnsafeUnpin for AlbumStorageInfo",0,1,["jellytau_lib::commands::download::AlbumStorageInfo"]],["impl UnsafeUnpin for AlbumTrack",0,1,["jellytau_lib::commands::download::AlbumTrack"]],["impl UnsafeUnpin for DownloadItemAndStartRequest",0,1,["jellytau_lib::commands::download::DownloadItemAndStartRequest"]],["impl UnsafeUnpin for DownloadItemRequest",0,1,["jellytau_lib::commands::download::DownloadItemRequest"]],["impl UnsafeUnpin for DownloadManagerStats",0,1,["jellytau_lib::commands::download::DownloadManagerStats"]],["impl UnsafeUnpin for DownloadManagerWrapper",0,1,["jellytau_lib::commands::download::DownloadManagerWrapper"]],["impl UnsafeUnpin for DownloadStats",0,1,["jellytau_lib::commands::download::DownloadStats"]],["impl UnsafeUnpin for DownloadVideoRequest",0,1,["jellytau_lib::commands::download::DownloadVideoRequest"]],["impl UnsafeUnpin for DownloadsResponse",0,1,["jellytau_lib::commands::download::DownloadsResponse"]],["impl UnsafeUnpin for NetworkStateWrapper",0,1,["jellytau_lib::commands::download::NetworkStateWrapper"]],["impl UnsafeUnpin for NetworkStateWrapperArg",0,1,["jellytau_lib::commands::download::NetworkStateWrapperArg"]],["impl UnsafeUnpin for StorageStats",0,1,["jellytau_lib::commands::download::StorageStats"]],["impl UnsafeUnpin for PendingFavorite",0,1,["jellytau_lib::commands::favorites::PendingFavorite"]],["impl UnsafeUnpin for ExclusionCandidate",0,1,["jellytau_lib::commands::library::ExclusionCandidate"]],["impl UnsafeUnpin for OfflineItem",0,1,["jellytau_lib::commands::offline::OfflineItem"]],["impl UnsafeUnpin for PlaybackModeManagerWrapper",0,1,["jellytau_lib::commands::playback_mode::PlaybackModeManagerWrapper"]],["impl UnsafeUnpin for RemoteSessionStatus",0,1,["jellytau_lib::commands::playback_mode::RemoteSessionStatus"]],["impl UnsafeUnpin for PlaybackReporterWrapper",0,1,["jellytau_lib::commands::playback_reporting::PlaybackReporterWrapper"]],["impl UnsafeUnpin for AddToQueueRequest",0,1,["jellytau_lib::commands::player::queue::AddToQueueRequest"]],["impl UnsafeUnpin for AddTrackByIdRequest",0,1,["jellytau_lib::commands::player::queue::AddTrackByIdRequest"]],["impl UnsafeUnpin for AddTracksByIdsRequest",0,1,["jellytau_lib::commands::player::queue::AddTracksByIdsRequest"]],["impl UnsafeUnpin for BackgroundAudioPlan",0,1,["jellytau_lib::commands::player::BackgroundAudioPlan"]],["impl UnsafeUnpin for MediaSessionManagerWrapper",0,1,["jellytau_lib::commands::player::MediaSessionManagerWrapper"]],["impl UnsafeUnpin for MergedMediaItem",0,1,["jellytau_lib::commands::player::MergedMediaItem"]],["impl UnsafeUnpin for PlayAlbumTrackRequest",0,1,["jellytau_lib::commands::player::PlayAlbumTrackRequest"]],["impl UnsafeUnpin for PlayItemRequest",0,1,["jellytau_lib::commands::player::PlayItemRequest"]],["impl UnsafeUnpin for PlayQueueRequest",0,1,["jellytau_lib::commands::player::PlayQueueRequest"]],["impl UnsafeUnpin for PlayTracksRequest",0,1,["jellytau_lib::commands::player::PlayTracksRequest"]],["impl UnsafeUnpin for PlaybackCapabilities",0,1,["jellytau_lib::commands::player::PlaybackCapabilities"]],["impl UnsafeUnpin for PlayerStateWrapper",0,1,["jellytau_lib::commands::player::PlayerStateWrapper"]],["impl UnsafeUnpin for PlayerStatus",0,1,["jellytau_lib::commands::player::PlayerStatus"]],["impl UnsafeUnpin for PreloadResult",0,1,["jellytau_lib::commands::player::PreloadResult"]],["impl UnsafeUnpin for QueueStatus",0,1,["jellytau_lib::commands::player::QueueStatus"]],["impl UnsafeUnpin for SmartCacheWrapper",0,1,["jellytau_lib::commands::player::SmartCacheWrapper"]],["impl UnsafeUnpin for VideoSettingsWrapper",0,1,["jellytau_lib::commands::player::VideoSettingsWrapper"]],["impl UnsafeUnpin for FavoritesChangedEvent",0,1,["jellytau_lib::commands::repository::FavoritesChangedEvent"]],["impl UnsafeUnpin for RepositoryManager",0,1,["jellytau_lib::commands::repository::RepositoryManager"]],["impl UnsafeUnpin for RepositoryManagerWrapper",0,1,["jellytau_lib::commands::repository::RepositoryManagerWrapper"]],["impl UnsafeUnpin for SearchUpdateEvent",0,1,["jellytau_lib::commands::repository::SearchUpdateEvent"]],["impl UnsafeUnpin for SessionPollerWrapper",0,1,["jellytau_lib::commands::sessions::SessionPollerWrapper"]],["impl UnsafeUnpin for CachedItemPerson",0,1,["jellytau_lib::commands::storage::people::CachedItemPerson"]],["impl UnsafeUnpin for CachedPerson",0,1,["jellytau_lib::commands::storage::people::CachedPerson"]],["impl UnsafeUnpin for SeriesAudioPreference",0,1,["jellytau_lib::commands::storage::series_prefs::SeriesAudioPreference"]],["impl UnsafeUnpin for ActiveSession",0,1,["jellytau_lib::commands::storage::ActiveSession"]],["impl UnsafeUnpin for CachedItem",0,1,["jellytau_lib::commands::storage::CachedItem"]],["impl UnsafeUnpin for CachedLibrary",0,1,["jellytau_lib::commands::storage::CachedLibrary"]],["impl UnsafeUnpin for CredentialStoreWrapper",0,1,["jellytau_lib::commands::storage::CredentialStoreWrapper"]],["impl UnsafeUnpin for DatabaseWrapper",0,1,["jellytau_lib::commands::storage::DatabaseWrapper"]],["impl UnsafeUnpin for PlaybackProgress",0,1,["jellytau_lib::commands::storage::PlaybackProgress"]],["impl UnsafeUnpin for SecurityStatus",0,1,["jellytau_lib::commands::storage::SecurityStatus"]],["impl UnsafeUnpin for ServerInfo",0,1,["jellytau_lib::commands::storage::ServerInfo"]],["impl UnsafeUnpin for ThumbnailCacheWrapper",0,1,["jellytau_lib::commands::storage::ThumbnailCacheWrapper"]],["impl UnsafeUnpin for UserInfo",0,1,["jellytau_lib::commands::storage::UserInfo"]],["impl UnsafeUnpin for GetImageRequest",0,1,["jellytau_lib::commands::storage::thumbnails::GetImageRequest"]],["impl UnsafeUnpin for SyncQueueItem",0,1,["jellytau_lib::commands::sync::SyncQueueItem"]],["impl UnsafeUnpin for DrainReport",0,1,["jellytau_lib::commands::sync_drain::DrainReport"]],["impl UnsafeUnpin for PushFailure",0,1,["jellytau_lib::commands::sync_drain::PushFailure"]],["impl UnsafeUnpin for QueuedRow",0,1,["jellytau_lib::commands::sync_drain::QueuedRow"]],["impl UnsafeUnpin for ConnectivityChangeEvent",0,1,["jellytau_lib::connectivity::ConnectivityChangeEvent"]],["impl UnsafeUnpin for ConnectivityMonitor",0,1,["jellytau_lib::connectivity::ConnectivityMonitor"]],["impl UnsafeUnpin for ConnectivityReporter",0,1,["jellytau_lib::connectivity::ConnectivityReporter"]],["impl UnsafeUnpin for ConnectivityStatus",0,1,["jellytau_lib::connectivity::ConnectivityStatus"]],["impl UnsafeUnpin for CredentialStore",0,1,["jellytau_lib::credentials::CredentialStore"]],["impl UnsafeUnpin for CacheConfig",0,1,["jellytau_lib::download::cache::CacheConfig"]],["impl UnsafeUnpin for SmartCache",0,1,["jellytau_lib::download::cache::SmartCache"]],["impl UnsafeUnpin for NetworkState",0,1,["jellytau_lib::download::network::NetworkState"]],["impl UnsafeUnpin for NetworkStateHandle",0,1,["jellytau_lib::download::network::NetworkStateHandle"]],["impl UnsafeUnpin for DownloadInfo",0,1,["jellytau_lib::download::DownloadInfo"]],["impl UnsafeUnpin for DownloadManager",0,1,["jellytau_lib::download::DownloadManager"]],["impl UnsafeUnpin for DownloadTask",0,1,["jellytau_lib::download::DownloadTask"]],["impl UnsafeUnpin for DownloadResult",0,1,["jellytau_lib::download::worker::DownloadResult"]],["impl UnsafeUnpin for DownloadWorker",0,1,["jellytau_lib::download::worker::DownloadWorker"]],["impl UnsafeUnpin for JellyfinClient",0,1,["jellytau_lib::jellyfin::client::JellyfinClient"]],["impl UnsafeUnpin for LmsSyncGroup",0,1,["jellytau_lib::jellyfin::client::LmsSyncGroup"]],["impl UnsafeUnpin for NowPlayingItem",0,1,["jellytau_lib::jellyfin::client::NowPlayingItem"]],["impl UnsafeUnpin for PlayState",0,1,["jellytau_lib::jellyfin::client::PlayState"]],["impl UnsafeUnpin for SessionInfo",0,1,["jellytau_lib::jellyfin::client::SessionInfo"]],["impl UnsafeUnpin for HttpClient",0,1,["jellytau_lib::jellyfin::http_client::HttpClient"]],["impl UnsafeUnpin for HttpConfig",0,1,["jellytau_lib::jellyfin::http_client::HttpConfig"]],["impl UnsafeUnpin for JellyfinConfig",0,1,["jellytau_lib::jellyfin::types::JellyfinConfig"]],["impl UnsafeUnpin for PlaybackProgressRequest",0,1,["jellytau_lib::jellyfin::types::PlaybackProgressRequest"]],["impl UnsafeUnpin for PlaybackStartRequest",0,1,["jellytau_lib::jellyfin::types::PlaybackStartRequest"]],["impl UnsafeUnpin for PlaybackStoppedRequest",0,1,["jellytau_lib::jellyfin::types::PlaybackStoppedRequest"]],["impl UnsafeUnpin for MediaServer",0,1,["jellytau_lib::media_server::MediaServer"]],["impl UnsafeUnpin for MediaServerWrapper",0,1,["jellytau_lib::media_server::MediaServerWrapper"]],["impl UnsafeUnpin for Span",0,1,["jellytau_lib::media_server::Span"]],["impl UnsafeUnpin for PlatformRemoteVolumeControl",0,1,["jellytau_lib::playback_mode::PlatformRemoteVolumeControl"]],["impl UnsafeUnpin for PlaybackModeManager",0,1,["jellytau_lib::playback_mode::PlaybackModeManager"]],["impl UnsafeUnpin for PlaybackContext",0,1,["jellytau_lib::playback_reporting::reporter::PlaybackContext"]],["impl UnsafeUnpin for PlaybackReporter",0,1,["jellytau_lib::playback_reporting::reporter::PlaybackReporter"]],["impl UnsafeUnpin for SyncConfig",0,1,["jellytau_lib::playback_reporting::sync_processor::SyncConfig"]],["impl UnsafeUnpin for SyncProcessor",0,1,["jellytau_lib::playback_reporting::sync_processor::SyncProcessor"]],["impl UnsafeUnpin for EventThrottler",0,1,["jellytau_lib::playback_reporting::throttle::EventThrottler"]],["impl UnsafeUnpin for AutoplaySettings",0,1,["jellytau_lib::player::autoplay::AutoplaySettings"]],["impl UnsafeUnpin for NullBackend",0,1,["jellytau_lib::player::backend::NullBackend"]],["impl UnsafeUnpin for PlayerError",0,1,["jellytau_lib::player::backend::PlayerError"]],["impl UnsafeUnpin for TauriEventEmitter",0,1,["jellytau_lib::player::events::TauriEventEmitter"]],["impl UnsafeUnpin for MediaItem",0,1,["jellytau_lib::player::media::MediaItem"]],["impl UnsafeUnpin for SubtitleTrack",0,1,["jellytau_lib::player::media::SubtitleTrack"]],["impl UnsafeUnpin for InternalState",0,1,["jellytau_lib::player::mpv_backend::InternalState"]],["impl UnsafeUnpin for MpvBackend",0,1,["jellytau_lib::player::mpv_backend::MpvBackend"]],["impl UnsafeUnpin for QueueManager",0,1,["jellytau_lib::player::queue::QueueManager"]],["impl UnsafeUnpin for MediaSessionManager",0,1,["jellytau_lib::player::session::MediaSessionManager"]],["impl UnsafeUnpin for SleepTimerState",0,1,["jellytau_lib::player::sleep_timer::SleepTimerState"]],["impl UnsafeUnpin for ObservedTime",0,1,["jellytau_lib::player::stream_end::ObservedTime"]],["impl UnsafeUnpin for ResumeTracker",0,1,["jellytau_lib::player::stream_end::ResumeTracker"]],["impl UnsafeUnpin for LockscreenMetadata",0,1,["jellytau_lib::player::LockscreenMetadata"]],["impl UnsafeUnpin for PlayerController",0,1,["jellytau_lib::player::PlayerController"]],["impl UnsafeUnpin for ReporterSink",0,1,["jellytau_lib::player::ReporterSink"]],["impl UnsafeUnpin for HybridRepository",0,1,["jellytau_lib::repository::hybrid::HybridRepository"]],["impl UnsafeUnpin for CachedItem",0,1,["jellytau_lib::repository::offline::CachedItem"]],["impl UnsafeUnpin for OfflineRepository",0,1,["jellytau_lib::repository::offline::OfflineRepository"]],["impl UnsafeUnpin for CreatePlaylistResponse",0,1,["jellytau_lib::repository::online::CreatePlaylistResponse"]],["impl UnsafeUnpin for ItemsResponse",0,1,["jellytau_lib::repository::online::ItemsResponse"]],["impl UnsafeUnpin for JRayActor",0,1,["jellytau_lib::repository::online::JRayActor"]],["impl UnsafeUnpin for JRayContext",0,1,["jellytau_lib::repository::online::JRayContext"]],["impl UnsafeUnpin for JellyfinItem",0,1,["jellytau_lib::repository::online::JellyfinItem"]],["impl UnsafeUnpin for JellyfinMediaSource",0,1,["jellytau_lib::repository::online::JellyfinMediaSource"]],["impl UnsafeUnpin for JellyfinMediaStream",0,1,["jellytau_lib::repository::online::JellyfinMediaStream"]],["impl UnsafeUnpin for JellyfinPlaylistItem",0,1,["jellytau_lib::repository::online::JellyfinPlaylistItem"]],["impl UnsafeUnpin for JellyfinUserData",0,1,["jellytau_lib::repository::online::JellyfinUserData"]],["impl UnsafeUnpin for OnlineRepository",0,1,["jellytau_lib::repository::online::OnlineRepository"]],["impl UnsafeUnpin for PlaylistItemsResponse",0,1,["jellytau_lib::repository::online::PlaylistItemsResponse"]],["impl UnsafeUnpin for ArtistItem",0,1,["jellytau_lib::repository::types::ArtistItem"]],["impl UnsafeUnpin for DownloadDiskUsage",0,1,["jellytau_lib::repository::types::DownloadDiskUsage"]],["impl UnsafeUnpin for Genre",0,1,["jellytau_lib::repository::types::Genre"]],["impl UnsafeUnpin for GetItemsOptions",0,1,["jellytau_lib::repository::types::GetItemsOptions"]],["impl UnsafeUnpin for ImageOptions",0,1,["jellytau_lib::repository::types::ImageOptions"]],["impl UnsafeUnpin for Library",0,1,["jellytau_lib::repository::types::Library"]],["impl UnsafeUnpin for LiveStreamInfo",0,1,["jellytau_lib::repository::types::LiveStreamInfo"]],["impl UnsafeUnpin for MediaItem",0,1,["jellytau_lib::repository::types::MediaItem"]],["impl UnsafeUnpin for MediaSource",0,1,["jellytau_lib::repository::types::MediaSource"]],["impl UnsafeUnpin for MediaStream",0,1,["jellytau_lib::repository::types::MediaStream"]],["impl UnsafeUnpin for Person",0,1,["jellytau_lib::repository::types::Person"]],["impl UnsafeUnpin for PlaybackInfo",0,1,["jellytau_lib::repository::types::PlaybackInfo"]],["impl UnsafeUnpin for PlaylistCreatedResult",0,1,["jellytau_lib::repository::types::PlaylistCreatedResult"]],["impl UnsafeUnpin for PlaylistEntry",0,1,["jellytau_lib::repository::types::PlaylistEntry"]],["impl UnsafeUnpin for SearchOptions",0,1,["jellytau_lib::repository::types::SearchOptions"]],["impl UnsafeUnpin for SearchResult",0,1,["jellytau_lib::repository::types::SearchResult"]],["impl UnsafeUnpin for UserData",0,1,["jellytau_lib::repository::types::UserData"]],["impl UnsafeUnpin for SessionPollerManager",0,1,["jellytau_lib::session_poller::SessionPollerManager"]],["impl UnsafeUnpin for AudioSettings",0,1,["jellytau_lib::settings::AudioSettings"]],["impl UnsafeUnpin for LibrarySettings",0,1,["jellytau_lib::settings::LibrarySettings"]],["impl UnsafeUnpin for VideoSettings",0,1,["jellytau_lib::settings::VideoSettings"]],["impl UnsafeUnpin for Query",0,1,["jellytau_lib::storage::db_service::Query"]],["impl UnsafeUnpin for RusqliteService",0,1,["jellytau_lib::storage::db_service::RusqliteService"]],["impl UnsafeUnpin for PlaylistItem",0,1,["jellytau_lib::storage::models::PlaylistItem"]],["impl UnsafeUnpin for Database",0,1,["jellytau_lib::storage::Database"]],["impl UnsafeUnpin for BackendInitError",0,1,["jellytau_lib::BackendInitError"]],["impl UnsafeUnpin for CacheConfig",0,1,["jellytau_lib::thumbnail::cache::CacheConfig"]],["impl UnsafeUnpin for ThumbnailCache",0,1,["jellytau_lib::thumbnail::cache::ThumbnailCache"]],["impl UnsafeUnpin for ThumbnailCacheStats",0,1,["jellytau_lib::thumbnail::ThumbnailCacheStats"]],["impl UnsafeUnpin for ThumbnailWorker",0,1,["jellytau_lib::thumbnail::worker::ThumbnailWorker"]],["impl<'a> UnsafeUnpin for Transaction<'a>",0,1,["jellytau_lib::storage::db_service::Transaction"]]]]]); + if (window.register_implementors) { + window.register_implementors(implementors); + } else { + window.pending_implementors = implementors; + } +})() +//{"start":59,"fragment_lengths":[86489]} \ No newline at end of file diff --git a/api/trait.impl/core/ops/drop/trait.Drop.js b/api/trait.impl/core/ops/drop/trait.Drop.js new file mode 100644 index 00000000..b2fef39e --- /dev/null +++ b/api/trait.impl/core/ops/drop/trait.Drop.js @@ -0,0 +1,9 @@ +(function() { + const implementors = Object.fromEntries([["jellytau_lib",[["impl Drop for IndexPassGuard",0],["impl Drop for MpvBackend",0],["impl Drop for SessionPollerManager",0]]]]); + if (window.register_implementors) { + window.register_implementors(implementors); + } else { + window.pending_implementors = implementors; + } +})() +//{"start":59,"fragment_lengths":[992]} \ No newline at end of file diff --git a/api/trait.impl/core/panic/unwind_safe/trait.RefUnwindSafe.js b/api/trait.impl/core/panic/unwind_safe/trait.RefUnwindSafe.js new file mode 100644 index 00000000..632e7c27 --- /dev/null +++ b/api/trait.impl/core/panic/unwind_safe/trait.RefUnwindSafe.js @@ -0,0 +1,9 @@ +(function() { + const implementors = Object.fromEntries([["jellytau_lib",[["impl !RefUnwindSafe for SessionVerifier",1,1,["jellytau_lib::auth::session_verifier::SessionVerifier"]],["impl !RefUnwindSafe for AuthManager",1,1,["jellytau_lib::auth::AuthManager"]],["impl !RefUnwindSafe for AuthManagerWrapper",1,1,["jellytau_lib::commands::auth::AuthManagerWrapper"]],["impl !RefUnwindSafe for SessionVerifierWrapper",1,1,["jellytau_lib::commands::auth::SessionVerifierWrapper"]],["impl !RefUnwindSafe for ConnectivityMonitorWrapper",1,1,["jellytau_lib::commands::connectivity::ConnectivityMonitorWrapper"]],["impl !RefUnwindSafe for NetworkStateWrapper",1,1,["jellytau_lib::commands::download::NetworkStateWrapper"]],["impl !RefUnwindSafe for PlaybackModeManagerWrapper",1,1,["jellytau_lib::commands::playback_mode::PlaybackModeManagerWrapper"]],["impl !RefUnwindSafe for PlaybackReporterWrapper",1,1,["jellytau_lib::commands::playback_reporting::PlaybackReporterWrapper"]],["impl !RefUnwindSafe for PlayerStateWrapper",1,1,["jellytau_lib::commands::player::PlayerStateWrapper"]],["impl !RefUnwindSafe for SessionPollerWrapper",1,1,["jellytau_lib::commands::sessions::SessionPollerWrapper"]],["impl !RefUnwindSafe for ConnectivityMonitor",1,1,["jellytau_lib::connectivity::ConnectivityMonitor"]],["impl !RefUnwindSafe for ConnectivityReporter",1,1,["jellytau_lib::connectivity::ConnectivityReporter"]],["impl !RefUnwindSafe for NetworkStateHandle",1,1,["jellytau_lib::download::network::NetworkStateHandle"]],["impl !RefUnwindSafe for DownloadWorker",1,1,["jellytau_lib::download::worker::DownloadWorker"]],["impl !RefUnwindSafe for JellyfinClient",1,1,["jellytau_lib::jellyfin::client::JellyfinClient"]],["impl !RefUnwindSafe for HttpClient",1,1,["jellytau_lib::jellyfin::http_client::HttpClient"]],["impl !RefUnwindSafe for PlaybackModeManager",1,1,["jellytau_lib::playback_mode::PlaybackModeManager"]],["impl !RefUnwindSafe for PlaybackReporter",1,1,["jellytau_lib::playback_reporting::reporter::PlaybackReporter"]],["impl !RefUnwindSafe for SyncProcessor",1,1,["jellytau_lib::playback_reporting::sync_processor::SyncProcessor"]],["impl !RefUnwindSafe for TauriEventEmitter",1,1,["jellytau_lib::player::events::TauriEventEmitter"]],["impl !RefUnwindSafe for MpvBackend",1,1,["jellytau_lib::player::mpv_backend::MpvBackend"]],["impl !RefUnwindSafe for PlayerController",1,1,["jellytau_lib::player::PlayerController"]],["impl !RefUnwindSafe for ReporterSink",1,1,["jellytau_lib::player::ReporterSink"]],["impl !RefUnwindSafe for HybridRepository",1,1,["jellytau_lib::repository::hybrid::HybridRepository"]],["impl !RefUnwindSafe for OnlineRepository",1,1,["jellytau_lib::repository::online::OnlineRepository"]],["impl !RefUnwindSafe for SessionPollerManager",1,1,["jellytau_lib::session_poller::SessionPollerManager"]],["impl !RefUnwindSafe for ThumbnailWorker",1,1,["jellytau_lib::thumbnail::worker::ThumbnailWorker"]],["impl<'a> !RefUnwindSafe for Transaction<'a>",1,1,["jellytau_lib::storage::db_service::Transaction"]],["impl RefUnwindSafe for SessionVerificationEvent",0,1,["jellytau_lib::auth::session_verifier::SessionVerificationEvent"]],["impl RefUnwindSafe for AudioTrackSwitchResponse",0,1,["jellytau_lib::commands::player::AudioTrackSwitchResponse"]],["impl RefUnwindSafe for PlayQueueContext",0,1,["jellytau_lib::commands::player::PlayQueueContext"]],["impl RefUnwindSafe for PlayTracksContext",0,1,["jellytau_lib::commands::player::PlayTracksContext"]],["impl RefUnwindSafe for StreamQualityResponse",0,1,["jellytau_lib::commands::player::StreamQualityResponse"]],["impl RefUnwindSafe for VideoBackend",0,1,["jellytau_lib::commands::player::VideoBackend"]],["impl RefUnwindSafe for VideoSeekResponse",0,1,["jellytau_lib::commands::player::VideoSeekResponse"]],["impl RefUnwindSafe for QueuedOp",0,1,["jellytau_lib::commands::sync_drain::QueuedOp"]],["impl RefUnwindSafe for CredentialError",0,1,["jellytau_lib::credentials::CredentialError"]],["impl RefUnwindSafe for CredentialResult",0,1,["jellytau_lib::credentials::CredentialResult"]],["impl RefUnwindSafe for MediaKind",0,1,["jellytau_lib::domain::media::MediaKind"]],["impl RefUnwindSafe for StreamKind",0,1,["jellytau_lib::domain::media::StreamKind"]],["impl RefUnwindSafe for MatchQuality",0,1,["jellytau_lib::domain::search_rank::MatchQuality"]],["impl RefUnwindSafe for DownloadEvent",0,1,["jellytau_lib::download::events::DownloadEvent"]],["impl RefUnwindSafe for NetworkType",0,1,["jellytau_lib::download::network::NetworkType"]],["impl RefUnwindSafe for DownloadError",0,1,["jellytau_lib::download::worker::DownloadError"]],["impl RefUnwindSafe for ErrorKind",0,1,["jellytau_lib::jellyfin::http_client::ErrorKind"]],["impl RefUnwindSafe for Resolved",0,1,["jellytau_lib::media_server::Resolved"]],["impl RefUnwindSafe for PlaybackMode",0,1,["jellytau_lib::playback_mode::PlaybackMode"]],["impl RefUnwindSafe for PlaybackOperation",0,1,["jellytau_lib::playback_reporting::reporter::PlaybackOperation"]],["impl RefUnwindSafe for AutoplayDecision",0,1,["jellytau_lib::player::autoplay::AutoplayDecision"]],["impl RefUnwindSafe for BackgroundAction",0,1,["jellytau_lib::player::background_policy::BackgroundAction"]],["impl RefUnwindSafe for PlayerStatusEvent",0,1,["jellytau_lib::player::events::PlayerStatusEvent"]],["impl RefUnwindSafe for MediaSource",0,1,["jellytau_lib::player::media::MediaSource"]],["impl RefUnwindSafe for MediaType",0,1,["jellytau_lib::player::media::MediaType"]],["impl RefUnwindSafe for QueueContext",0,1,["jellytau_lib::player::media::QueueContext"]],["impl RefUnwindSafe for AddPosition",0,1,["jellytau_lib::player::queue::AddPosition"]],["impl RefUnwindSafe for RepeatMode",0,1,["jellytau_lib::player::queue::RepeatMode"]],["impl RefUnwindSafe for SkipAction",0,1,["jellytau_lib::player::seek::SkipAction"]],["impl RefUnwindSafe for VideoSeekStrategy",0,1,["jellytau_lib::player::seek::VideoSeekStrategy"]],["impl RefUnwindSafe for MediaSessionType",0,1,["jellytau_lib::player::session::MediaSessionType"]],["impl RefUnwindSafe for SleepTimerMode",0,1,["jellytau_lib::player::sleep_timer::SleepTimerMode"]],["impl RefUnwindSafe for EndReason",0,1,["jellytau_lib::player::state::EndReason"]],["impl RefUnwindSafe for PlayerState",0,1,["jellytau_lib::player::state::PlayerState"]],["impl RefUnwindSafe for ImageTags",0,1,["jellytau_lib::repository::online::ImageTags"]],["impl RefUnwindSafe for ImageType",0,1,["jellytau_lib::repository::types::ImageType"]],["impl RefUnwindSafe for RepoError",0,1,["jellytau_lib::repository::types::RepoError"]],["impl RefUnwindSafe for SearchScope",0,1,["jellytau_lib::repository::types::SearchScope"]],["impl RefUnwindSafe for PollingHint",0,1,["jellytau_lib::session_poller::PollingHint"]],["impl RefUnwindSafe for EqPreset",0,1,["jellytau_lib::settings::EqPreset"]],["impl RefUnwindSafe for StreamingQuality",0,1,["jellytau_lib::settings::StreamingQuality"]],["impl RefUnwindSafe for VolumeLevel",0,1,["jellytau_lib::settings::VolumeLevel"]],["impl RefUnwindSafe for QueryParam",0,1,["jellytau_lib::storage::db_service::QueryParam"]],["impl RefUnwindSafe for ThumbnailDownloadError",0,1,["jellytau_lib::thumbnail::worker::ThumbnailDownloadError"]],["impl RefUnwindSafe for AuthResult",0,1,["jellytau_lib::auth::AuthResult"]],["impl RefUnwindSafe for AuthenticateByNameResponse",0,1,["jellytau_lib::auth::AuthenticateByNameResponse"]],["impl RefUnwindSafe for JellyfinUser",0,1,["jellytau_lib::auth::JellyfinUser"]],["impl RefUnwindSafe for PublicSystemInfo",0,1,["jellytau_lib::auth::PublicSystemInfo"]],["impl RefUnwindSafe for ServerInfo",0,1,["jellytau_lib::auth::ServerInfo"]],["impl RefUnwindSafe for Session",0,1,["jellytau_lib::auth::Session"]],["impl RefUnwindSafe for User",0,1,["jellytau_lib::auth::User"]],["impl RefUnwindSafe for CatalogIndexEvent",0,1,["jellytau_lib::commands::catalog::CatalogIndexEvent"]],["impl RefUnwindSafe for CatalogSyncResult",0,1,["jellytau_lib::commands::catalog::CatalogSyncResult"]],["impl RefUnwindSafe for CatalogSyncStatus",0,1,["jellytau_lib::commands::catalog::CatalogSyncStatus"]],["impl RefUnwindSafe for IndexPassGuard",0,1,["jellytau_lib::commands::catalog::IndexPassGuard"]],["impl RefUnwindSafe for ResumeQueuedResult",0,1,["jellytau_lib::commands::catalog::ResumeQueuedResult"]],["impl RefUnwindSafe for DiagnosticsBundle",0,1,["jellytau_lib::commands::diagnostics::DiagnosticsBundle"]],["impl RefUnwindSafe for DiagnosticsInfo",0,1,["jellytau_lib::commands::diagnostics::DiagnosticsInfo"]],["impl RefUnwindSafe for AlbumAffinityStatus",0,1,["jellytau_lib::commands::download::smart_cache::AlbumAffinityStatus"]],["impl RefUnwindSafe for AlbumRecommendation",0,1,["jellytau_lib::commands::download::smart_cache::AlbumRecommendation"]],["impl RefUnwindSafe for SmartCacheStats",0,1,["jellytau_lib::commands::download::smart_cache::SmartCacheStats"]],["impl RefUnwindSafe for AlbumStorageInfo",0,1,["jellytau_lib::commands::download::AlbumStorageInfo"]],["impl RefUnwindSafe for AlbumTrack",0,1,["jellytau_lib::commands::download::AlbumTrack"]],["impl RefUnwindSafe for DownloadItemAndStartRequest",0,1,["jellytau_lib::commands::download::DownloadItemAndStartRequest"]],["impl RefUnwindSafe for DownloadItemRequest",0,1,["jellytau_lib::commands::download::DownloadItemRequest"]],["impl RefUnwindSafe for DownloadManagerStats",0,1,["jellytau_lib::commands::download::DownloadManagerStats"]],["impl RefUnwindSafe for DownloadManagerWrapper",0,1,["jellytau_lib::commands::download::DownloadManagerWrapper"]],["impl RefUnwindSafe for DownloadStats",0,1,["jellytau_lib::commands::download::DownloadStats"]],["impl RefUnwindSafe for DownloadVideoRequest",0,1,["jellytau_lib::commands::download::DownloadVideoRequest"]],["impl RefUnwindSafe for DownloadsResponse",0,1,["jellytau_lib::commands::download::DownloadsResponse"]],["impl RefUnwindSafe for NetworkStateWrapperArg",0,1,["jellytau_lib::commands::download::NetworkStateWrapperArg"]],["impl RefUnwindSafe for StorageStats",0,1,["jellytau_lib::commands::download::StorageStats"]],["impl RefUnwindSafe for PendingFavorite",0,1,["jellytau_lib::commands::favorites::PendingFavorite"]],["impl RefUnwindSafe for ExclusionCandidate",0,1,["jellytau_lib::commands::library::ExclusionCandidate"]],["impl RefUnwindSafe for OfflineItem",0,1,["jellytau_lib::commands::offline::OfflineItem"]],["impl RefUnwindSafe for RemoteSessionStatus",0,1,["jellytau_lib::commands::playback_mode::RemoteSessionStatus"]],["impl RefUnwindSafe for AddToQueueRequest",0,1,["jellytau_lib::commands::player::queue::AddToQueueRequest"]],["impl RefUnwindSafe for AddTrackByIdRequest",0,1,["jellytau_lib::commands::player::queue::AddTrackByIdRequest"]],["impl RefUnwindSafe for AddTracksByIdsRequest",0,1,["jellytau_lib::commands::player::queue::AddTracksByIdsRequest"]],["impl RefUnwindSafe for BackgroundAudioPlan",0,1,["jellytau_lib::commands::player::BackgroundAudioPlan"]],["impl RefUnwindSafe for MediaSessionManagerWrapper",0,1,["jellytau_lib::commands::player::MediaSessionManagerWrapper"]],["impl RefUnwindSafe for MergedMediaItem",0,1,["jellytau_lib::commands::player::MergedMediaItem"]],["impl RefUnwindSafe for PlayAlbumTrackRequest",0,1,["jellytau_lib::commands::player::PlayAlbumTrackRequest"]],["impl RefUnwindSafe for PlayItemRequest",0,1,["jellytau_lib::commands::player::PlayItemRequest"]],["impl RefUnwindSafe for PlayQueueRequest",0,1,["jellytau_lib::commands::player::PlayQueueRequest"]],["impl RefUnwindSafe for PlayTracksRequest",0,1,["jellytau_lib::commands::player::PlayTracksRequest"]],["impl RefUnwindSafe for PlaybackCapabilities",0,1,["jellytau_lib::commands::player::PlaybackCapabilities"]],["impl RefUnwindSafe for PlayerStatus",0,1,["jellytau_lib::commands::player::PlayerStatus"]],["impl RefUnwindSafe for PreloadResult",0,1,["jellytau_lib::commands::player::PreloadResult"]],["impl RefUnwindSafe for QueueStatus",0,1,["jellytau_lib::commands::player::QueueStatus"]],["impl RefUnwindSafe for SmartCacheWrapper",0,1,["jellytau_lib::commands::player::SmartCacheWrapper"]],["impl RefUnwindSafe for VideoSettingsWrapper",0,1,["jellytau_lib::commands::player::VideoSettingsWrapper"]],["impl RefUnwindSafe for FavoritesChangedEvent",0,1,["jellytau_lib::commands::repository::FavoritesChangedEvent"]],["impl RefUnwindSafe for RepositoryManager",0,1,["jellytau_lib::commands::repository::RepositoryManager"]],["impl RefUnwindSafe for RepositoryManagerWrapper",0,1,["jellytau_lib::commands::repository::RepositoryManagerWrapper"]],["impl RefUnwindSafe for SearchUpdateEvent",0,1,["jellytau_lib::commands::repository::SearchUpdateEvent"]],["impl RefUnwindSafe for CachedItemPerson",0,1,["jellytau_lib::commands::storage::people::CachedItemPerson"]],["impl RefUnwindSafe for CachedPerson",0,1,["jellytau_lib::commands::storage::people::CachedPerson"]],["impl RefUnwindSafe for SeriesAudioPreference",0,1,["jellytau_lib::commands::storage::series_prefs::SeriesAudioPreference"]],["impl RefUnwindSafe for ActiveSession",0,1,["jellytau_lib::commands::storage::ActiveSession"]],["impl RefUnwindSafe for CachedItem",0,1,["jellytau_lib::commands::storage::CachedItem"]],["impl RefUnwindSafe for CachedLibrary",0,1,["jellytau_lib::commands::storage::CachedLibrary"]],["impl RefUnwindSafe for CredentialStoreWrapper",0,1,["jellytau_lib::commands::storage::CredentialStoreWrapper"]],["impl RefUnwindSafe for DatabaseWrapper",0,1,["jellytau_lib::commands::storage::DatabaseWrapper"]],["impl RefUnwindSafe for PlaybackProgress",0,1,["jellytau_lib::commands::storage::PlaybackProgress"]],["impl RefUnwindSafe for SecurityStatus",0,1,["jellytau_lib::commands::storage::SecurityStatus"]],["impl RefUnwindSafe for ServerInfo",0,1,["jellytau_lib::commands::storage::ServerInfo"]],["impl RefUnwindSafe for ThumbnailCacheWrapper",0,1,["jellytau_lib::commands::storage::ThumbnailCacheWrapper"]],["impl RefUnwindSafe for UserInfo",0,1,["jellytau_lib::commands::storage::UserInfo"]],["impl RefUnwindSafe for GetImageRequest",0,1,["jellytau_lib::commands::storage::thumbnails::GetImageRequest"]],["impl RefUnwindSafe for SyncQueueItem",0,1,["jellytau_lib::commands::sync::SyncQueueItem"]],["impl RefUnwindSafe for DrainReport",0,1,["jellytau_lib::commands::sync_drain::DrainReport"]],["impl RefUnwindSafe for PushFailure",0,1,["jellytau_lib::commands::sync_drain::PushFailure"]],["impl RefUnwindSafe for QueuedRow",0,1,["jellytau_lib::commands::sync_drain::QueuedRow"]],["impl RefUnwindSafe for ConnectivityChangeEvent",0,1,["jellytau_lib::connectivity::ConnectivityChangeEvent"]],["impl RefUnwindSafe for ConnectivityStatus",0,1,["jellytau_lib::connectivity::ConnectivityStatus"]],["impl RefUnwindSafe for CredentialStore",0,1,["jellytau_lib::credentials::CredentialStore"]],["impl RefUnwindSafe for CacheConfig",0,1,["jellytau_lib::download::cache::CacheConfig"]],["impl RefUnwindSafe for SmartCache",0,1,["jellytau_lib::download::cache::SmartCache"]],["impl RefUnwindSafe for NetworkState",0,1,["jellytau_lib::download::network::NetworkState"]],["impl RefUnwindSafe for DownloadInfo",0,1,["jellytau_lib::download::DownloadInfo"]],["impl RefUnwindSafe for DownloadManager",0,1,["jellytau_lib::download::DownloadManager"]],["impl RefUnwindSafe for DownloadTask",0,1,["jellytau_lib::download::DownloadTask"]],["impl RefUnwindSafe for DownloadResult",0,1,["jellytau_lib::download::worker::DownloadResult"]],["impl RefUnwindSafe for LmsSyncGroup",0,1,["jellytau_lib::jellyfin::client::LmsSyncGroup"]],["impl RefUnwindSafe for NowPlayingItem",0,1,["jellytau_lib::jellyfin::client::NowPlayingItem"]],["impl RefUnwindSafe for PlayState",0,1,["jellytau_lib::jellyfin::client::PlayState"]],["impl RefUnwindSafe for SessionInfo",0,1,["jellytau_lib::jellyfin::client::SessionInfo"]],["impl RefUnwindSafe for HttpConfig",0,1,["jellytau_lib::jellyfin::http_client::HttpConfig"]],["impl RefUnwindSafe for JellyfinConfig",0,1,["jellytau_lib::jellyfin::types::JellyfinConfig"]],["impl RefUnwindSafe for PlaybackProgressRequest",0,1,["jellytau_lib::jellyfin::types::PlaybackProgressRequest"]],["impl RefUnwindSafe for PlaybackStartRequest",0,1,["jellytau_lib::jellyfin::types::PlaybackStartRequest"]],["impl RefUnwindSafe for PlaybackStoppedRequest",0,1,["jellytau_lib::jellyfin::types::PlaybackStoppedRequest"]],["impl RefUnwindSafe for MediaServer",0,1,["jellytau_lib::media_server::MediaServer"]],["impl RefUnwindSafe for MediaServerWrapper",0,1,["jellytau_lib::media_server::MediaServerWrapper"]],["impl RefUnwindSafe for Span",0,1,["jellytau_lib::media_server::Span"]],["impl RefUnwindSafe for PlatformRemoteVolumeControl",0,1,["jellytau_lib::playback_mode::PlatformRemoteVolumeControl"]],["impl RefUnwindSafe for PlaybackContext",0,1,["jellytau_lib::playback_reporting::reporter::PlaybackContext"]],["impl RefUnwindSafe for SyncConfig",0,1,["jellytau_lib::playback_reporting::sync_processor::SyncConfig"]],["impl RefUnwindSafe for EventThrottler",0,1,["jellytau_lib::playback_reporting::throttle::EventThrottler"]],["impl RefUnwindSafe for AutoplaySettings",0,1,["jellytau_lib::player::autoplay::AutoplaySettings"]],["impl RefUnwindSafe for NullBackend",0,1,["jellytau_lib::player::backend::NullBackend"]],["impl RefUnwindSafe for PlayerError",0,1,["jellytau_lib::player::backend::PlayerError"]],["impl RefUnwindSafe for MediaItem",0,1,["jellytau_lib::player::media::MediaItem"]],["impl RefUnwindSafe for SubtitleTrack",0,1,["jellytau_lib::player::media::SubtitleTrack"]],["impl RefUnwindSafe for InternalState",0,1,["jellytau_lib::player::mpv_backend::InternalState"]],["impl RefUnwindSafe for QueueManager",0,1,["jellytau_lib::player::queue::QueueManager"]],["impl RefUnwindSafe for MediaSessionManager",0,1,["jellytau_lib::player::session::MediaSessionManager"]],["impl RefUnwindSafe for SleepTimerState",0,1,["jellytau_lib::player::sleep_timer::SleepTimerState"]],["impl RefUnwindSafe for ObservedTime",0,1,["jellytau_lib::player::stream_end::ObservedTime"]],["impl RefUnwindSafe for ResumeTracker",0,1,["jellytau_lib::player::stream_end::ResumeTracker"]],["impl RefUnwindSafe for LockscreenMetadata",0,1,["jellytau_lib::player::LockscreenMetadata"]],["impl RefUnwindSafe for CachedItem",0,1,["jellytau_lib::repository::offline::CachedItem"]],["impl RefUnwindSafe for OfflineRepository",0,1,["jellytau_lib::repository::offline::OfflineRepository"]],["impl RefUnwindSafe for CreatePlaylistResponse",0,1,["jellytau_lib::repository::online::CreatePlaylistResponse"]],["impl RefUnwindSafe for ItemsResponse",0,1,["jellytau_lib::repository::online::ItemsResponse"]],["impl RefUnwindSafe for JRayActor",0,1,["jellytau_lib::repository::online::JRayActor"]],["impl RefUnwindSafe for JRayContext",0,1,["jellytau_lib::repository::online::JRayContext"]],["impl RefUnwindSafe for JellyfinItem",0,1,["jellytau_lib::repository::online::JellyfinItem"]],["impl RefUnwindSafe for JellyfinMediaSource",0,1,["jellytau_lib::repository::online::JellyfinMediaSource"]],["impl RefUnwindSafe for JellyfinMediaStream",0,1,["jellytau_lib::repository::online::JellyfinMediaStream"]],["impl RefUnwindSafe for JellyfinPlaylistItem",0,1,["jellytau_lib::repository::online::JellyfinPlaylistItem"]],["impl RefUnwindSafe for JellyfinUserData",0,1,["jellytau_lib::repository::online::JellyfinUserData"]],["impl RefUnwindSafe for PlaylistItemsResponse",0,1,["jellytau_lib::repository::online::PlaylistItemsResponse"]],["impl RefUnwindSafe for ArtistItem",0,1,["jellytau_lib::repository::types::ArtistItem"]],["impl RefUnwindSafe for DownloadDiskUsage",0,1,["jellytau_lib::repository::types::DownloadDiskUsage"]],["impl RefUnwindSafe for Genre",0,1,["jellytau_lib::repository::types::Genre"]],["impl RefUnwindSafe for GetItemsOptions",0,1,["jellytau_lib::repository::types::GetItemsOptions"]],["impl RefUnwindSafe for ImageOptions",0,1,["jellytau_lib::repository::types::ImageOptions"]],["impl RefUnwindSafe for Library",0,1,["jellytau_lib::repository::types::Library"]],["impl RefUnwindSafe for LiveStreamInfo",0,1,["jellytau_lib::repository::types::LiveStreamInfo"]],["impl RefUnwindSafe for MediaItem",0,1,["jellytau_lib::repository::types::MediaItem"]],["impl RefUnwindSafe for MediaSource",0,1,["jellytau_lib::repository::types::MediaSource"]],["impl RefUnwindSafe for MediaStream",0,1,["jellytau_lib::repository::types::MediaStream"]],["impl RefUnwindSafe for Person",0,1,["jellytau_lib::repository::types::Person"]],["impl RefUnwindSafe for PlaybackInfo",0,1,["jellytau_lib::repository::types::PlaybackInfo"]],["impl RefUnwindSafe for PlaylistCreatedResult",0,1,["jellytau_lib::repository::types::PlaylistCreatedResult"]],["impl RefUnwindSafe for PlaylistEntry",0,1,["jellytau_lib::repository::types::PlaylistEntry"]],["impl RefUnwindSafe for SearchOptions",0,1,["jellytau_lib::repository::types::SearchOptions"]],["impl RefUnwindSafe for SearchResult",0,1,["jellytau_lib::repository::types::SearchResult"]],["impl RefUnwindSafe for UserData",0,1,["jellytau_lib::repository::types::UserData"]],["impl RefUnwindSafe for AudioSettings",0,1,["jellytau_lib::settings::AudioSettings"]],["impl RefUnwindSafe for LibrarySettings",0,1,["jellytau_lib::settings::LibrarySettings"]],["impl RefUnwindSafe for VideoSettings",0,1,["jellytau_lib::settings::VideoSettings"]],["impl RefUnwindSafe for Query",0,1,["jellytau_lib::storage::db_service::Query"]],["impl RefUnwindSafe for RusqliteService",0,1,["jellytau_lib::storage::db_service::RusqliteService"]],["impl RefUnwindSafe for PlaylistItem",0,1,["jellytau_lib::storage::models::PlaylistItem"]],["impl RefUnwindSafe for Database",0,1,["jellytau_lib::storage::Database"]],["impl RefUnwindSafe for BackendInitError",0,1,["jellytau_lib::BackendInitError"]],["impl RefUnwindSafe for CacheConfig",0,1,["jellytau_lib::thumbnail::cache::CacheConfig"]],["impl RefUnwindSafe for ThumbnailCache",0,1,["jellytau_lib::thumbnail::cache::ThumbnailCache"]],["impl RefUnwindSafe for ThumbnailCacheStats",0,1,["jellytau_lib::thumbnail::ThumbnailCacheStats"]]]]]); + if (window.register_implementors) { + window.register_implementors(implementors); + } else { + window.pending_implementors = implementors; + } +})() +//{"start":59,"fragment_lengths":[92897]} \ No newline at end of file diff --git a/api/trait.impl/core/panic/unwind_safe/trait.UnwindSafe.js b/api/trait.impl/core/panic/unwind_safe/trait.UnwindSafe.js new file mode 100644 index 00000000..139e7ee8 --- /dev/null +++ b/api/trait.impl/core/panic/unwind_safe/trait.UnwindSafe.js @@ -0,0 +1,9 @@ +(function() { + const implementors = Object.fromEntries([["jellytau_lib",[["impl !UnwindSafe for SessionVerifier",1,1,["jellytau_lib::auth::session_verifier::SessionVerifier"]],["impl !UnwindSafe for AuthManager",1,1,["jellytau_lib::auth::AuthManager"]],["impl !UnwindSafe for AuthManagerWrapper",1,1,["jellytau_lib::commands::auth::AuthManagerWrapper"]],["impl !UnwindSafe for SessionVerifierWrapper",1,1,["jellytau_lib::commands::auth::SessionVerifierWrapper"]],["impl !UnwindSafe for ConnectivityMonitorWrapper",1,1,["jellytau_lib::commands::connectivity::ConnectivityMonitorWrapper"]],["impl !UnwindSafe for NetworkStateWrapper",1,1,["jellytau_lib::commands::download::NetworkStateWrapper"]],["impl !UnwindSafe for PlaybackModeManagerWrapper",1,1,["jellytau_lib::commands::playback_mode::PlaybackModeManagerWrapper"]],["impl !UnwindSafe for PlaybackReporterWrapper",1,1,["jellytau_lib::commands::playback_reporting::PlaybackReporterWrapper"]],["impl !UnwindSafe for PlayerStateWrapper",1,1,["jellytau_lib::commands::player::PlayerStateWrapper"]],["impl !UnwindSafe for SessionPollerWrapper",1,1,["jellytau_lib::commands::sessions::SessionPollerWrapper"]],["impl !UnwindSafe for ConnectivityMonitor",1,1,["jellytau_lib::connectivity::ConnectivityMonitor"]],["impl !UnwindSafe for ConnectivityReporter",1,1,["jellytau_lib::connectivity::ConnectivityReporter"]],["impl !UnwindSafe for NetworkStateHandle",1,1,["jellytau_lib::download::network::NetworkStateHandle"]],["impl !UnwindSafe for DownloadWorker",1,1,["jellytau_lib::download::worker::DownloadWorker"]],["impl !UnwindSafe for JellyfinClient",1,1,["jellytau_lib::jellyfin::client::JellyfinClient"]],["impl !UnwindSafe for HttpClient",1,1,["jellytau_lib::jellyfin::http_client::HttpClient"]],["impl !UnwindSafe for PlaybackModeManager",1,1,["jellytau_lib::playback_mode::PlaybackModeManager"]],["impl !UnwindSafe for PlaybackReporter",1,1,["jellytau_lib::playback_reporting::reporter::PlaybackReporter"]],["impl !UnwindSafe for SyncProcessor",1,1,["jellytau_lib::playback_reporting::sync_processor::SyncProcessor"]],["impl !UnwindSafe for TauriEventEmitter",1,1,["jellytau_lib::player::events::TauriEventEmitter"]],["impl !UnwindSafe for MpvBackend",1,1,["jellytau_lib::player::mpv_backend::MpvBackend"]],["impl !UnwindSafe for PlayerController",1,1,["jellytau_lib::player::PlayerController"]],["impl !UnwindSafe for ReporterSink",1,1,["jellytau_lib::player::ReporterSink"]],["impl !UnwindSafe for HybridRepository",1,1,["jellytau_lib::repository::hybrid::HybridRepository"]],["impl !UnwindSafe for OnlineRepository",1,1,["jellytau_lib::repository::online::OnlineRepository"]],["impl !UnwindSafe for SessionPollerManager",1,1,["jellytau_lib::session_poller::SessionPollerManager"]],["impl !UnwindSafe for ThumbnailWorker",1,1,["jellytau_lib::thumbnail::worker::ThumbnailWorker"]],["impl<'a> !UnwindSafe for Transaction<'a>",1,1,["jellytau_lib::storage::db_service::Transaction"]],["impl UnwindSafe for SessionVerificationEvent",0,1,["jellytau_lib::auth::session_verifier::SessionVerificationEvent"]],["impl UnwindSafe for AudioTrackSwitchResponse",0,1,["jellytau_lib::commands::player::AudioTrackSwitchResponse"]],["impl UnwindSafe for PlayQueueContext",0,1,["jellytau_lib::commands::player::PlayQueueContext"]],["impl UnwindSafe for PlayTracksContext",0,1,["jellytau_lib::commands::player::PlayTracksContext"]],["impl UnwindSafe for StreamQualityResponse",0,1,["jellytau_lib::commands::player::StreamQualityResponse"]],["impl UnwindSafe for VideoBackend",0,1,["jellytau_lib::commands::player::VideoBackend"]],["impl UnwindSafe for VideoSeekResponse",0,1,["jellytau_lib::commands::player::VideoSeekResponse"]],["impl UnwindSafe for QueuedOp",0,1,["jellytau_lib::commands::sync_drain::QueuedOp"]],["impl UnwindSafe for CredentialError",0,1,["jellytau_lib::credentials::CredentialError"]],["impl UnwindSafe for CredentialResult",0,1,["jellytau_lib::credentials::CredentialResult"]],["impl UnwindSafe for MediaKind",0,1,["jellytau_lib::domain::media::MediaKind"]],["impl UnwindSafe for StreamKind",0,1,["jellytau_lib::domain::media::StreamKind"]],["impl UnwindSafe for MatchQuality",0,1,["jellytau_lib::domain::search_rank::MatchQuality"]],["impl UnwindSafe for DownloadEvent",0,1,["jellytau_lib::download::events::DownloadEvent"]],["impl UnwindSafe for NetworkType",0,1,["jellytau_lib::download::network::NetworkType"]],["impl UnwindSafe for DownloadError",0,1,["jellytau_lib::download::worker::DownloadError"]],["impl UnwindSafe for ErrorKind",0,1,["jellytau_lib::jellyfin::http_client::ErrorKind"]],["impl UnwindSafe for Resolved",0,1,["jellytau_lib::media_server::Resolved"]],["impl UnwindSafe for PlaybackMode",0,1,["jellytau_lib::playback_mode::PlaybackMode"]],["impl UnwindSafe for PlaybackOperation",0,1,["jellytau_lib::playback_reporting::reporter::PlaybackOperation"]],["impl UnwindSafe for AutoplayDecision",0,1,["jellytau_lib::player::autoplay::AutoplayDecision"]],["impl UnwindSafe for BackgroundAction",0,1,["jellytau_lib::player::background_policy::BackgroundAction"]],["impl UnwindSafe for PlayerStatusEvent",0,1,["jellytau_lib::player::events::PlayerStatusEvent"]],["impl UnwindSafe for MediaSource",0,1,["jellytau_lib::player::media::MediaSource"]],["impl UnwindSafe for MediaType",0,1,["jellytau_lib::player::media::MediaType"]],["impl UnwindSafe for QueueContext",0,1,["jellytau_lib::player::media::QueueContext"]],["impl UnwindSafe for AddPosition",0,1,["jellytau_lib::player::queue::AddPosition"]],["impl UnwindSafe for RepeatMode",0,1,["jellytau_lib::player::queue::RepeatMode"]],["impl UnwindSafe for SkipAction",0,1,["jellytau_lib::player::seek::SkipAction"]],["impl UnwindSafe for VideoSeekStrategy",0,1,["jellytau_lib::player::seek::VideoSeekStrategy"]],["impl UnwindSafe for MediaSessionType",0,1,["jellytau_lib::player::session::MediaSessionType"]],["impl UnwindSafe for SleepTimerMode",0,1,["jellytau_lib::player::sleep_timer::SleepTimerMode"]],["impl UnwindSafe for EndReason",0,1,["jellytau_lib::player::state::EndReason"]],["impl UnwindSafe for PlayerState",0,1,["jellytau_lib::player::state::PlayerState"]],["impl UnwindSafe for ImageTags",0,1,["jellytau_lib::repository::online::ImageTags"]],["impl UnwindSafe for ImageType",0,1,["jellytau_lib::repository::types::ImageType"]],["impl UnwindSafe for RepoError",0,1,["jellytau_lib::repository::types::RepoError"]],["impl UnwindSafe for SearchScope",0,1,["jellytau_lib::repository::types::SearchScope"]],["impl UnwindSafe for PollingHint",0,1,["jellytau_lib::session_poller::PollingHint"]],["impl UnwindSafe for EqPreset",0,1,["jellytau_lib::settings::EqPreset"]],["impl UnwindSafe for StreamingQuality",0,1,["jellytau_lib::settings::StreamingQuality"]],["impl UnwindSafe for VolumeLevel",0,1,["jellytau_lib::settings::VolumeLevel"]],["impl UnwindSafe for QueryParam",0,1,["jellytau_lib::storage::db_service::QueryParam"]],["impl UnwindSafe for ThumbnailDownloadError",0,1,["jellytau_lib::thumbnail::worker::ThumbnailDownloadError"]],["impl UnwindSafe for AuthResult",0,1,["jellytau_lib::auth::AuthResult"]],["impl UnwindSafe for AuthenticateByNameResponse",0,1,["jellytau_lib::auth::AuthenticateByNameResponse"]],["impl UnwindSafe for JellyfinUser",0,1,["jellytau_lib::auth::JellyfinUser"]],["impl UnwindSafe for PublicSystemInfo",0,1,["jellytau_lib::auth::PublicSystemInfo"]],["impl UnwindSafe for ServerInfo",0,1,["jellytau_lib::auth::ServerInfo"]],["impl UnwindSafe for Session",0,1,["jellytau_lib::auth::Session"]],["impl UnwindSafe for User",0,1,["jellytau_lib::auth::User"]],["impl UnwindSafe for CatalogIndexEvent",0,1,["jellytau_lib::commands::catalog::CatalogIndexEvent"]],["impl UnwindSafe for CatalogSyncResult",0,1,["jellytau_lib::commands::catalog::CatalogSyncResult"]],["impl UnwindSafe for CatalogSyncStatus",0,1,["jellytau_lib::commands::catalog::CatalogSyncStatus"]],["impl UnwindSafe for IndexPassGuard",0,1,["jellytau_lib::commands::catalog::IndexPassGuard"]],["impl UnwindSafe for ResumeQueuedResult",0,1,["jellytau_lib::commands::catalog::ResumeQueuedResult"]],["impl UnwindSafe for DiagnosticsBundle",0,1,["jellytau_lib::commands::diagnostics::DiagnosticsBundle"]],["impl UnwindSafe for DiagnosticsInfo",0,1,["jellytau_lib::commands::diagnostics::DiagnosticsInfo"]],["impl UnwindSafe for AlbumAffinityStatus",0,1,["jellytau_lib::commands::download::smart_cache::AlbumAffinityStatus"]],["impl UnwindSafe for AlbumRecommendation",0,1,["jellytau_lib::commands::download::smart_cache::AlbumRecommendation"]],["impl UnwindSafe for SmartCacheStats",0,1,["jellytau_lib::commands::download::smart_cache::SmartCacheStats"]],["impl UnwindSafe for AlbumStorageInfo",0,1,["jellytau_lib::commands::download::AlbumStorageInfo"]],["impl UnwindSafe for AlbumTrack",0,1,["jellytau_lib::commands::download::AlbumTrack"]],["impl UnwindSafe for DownloadItemAndStartRequest",0,1,["jellytau_lib::commands::download::DownloadItemAndStartRequest"]],["impl UnwindSafe for DownloadItemRequest",0,1,["jellytau_lib::commands::download::DownloadItemRequest"]],["impl UnwindSafe for DownloadManagerStats",0,1,["jellytau_lib::commands::download::DownloadManagerStats"]],["impl UnwindSafe for DownloadManagerWrapper",0,1,["jellytau_lib::commands::download::DownloadManagerWrapper"]],["impl UnwindSafe for DownloadStats",0,1,["jellytau_lib::commands::download::DownloadStats"]],["impl UnwindSafe for DownloadVideoRequest",0,1,["jellytau_lib::commands::download::DownloadVideoRequest"]],["impl UnwindSafe for DownloadsResponse",0,1,["jellytau_lib::commands::download::DownloadsResponse"]],["impl UnwindSafe for NetworkStateWrapperArg",0,1,["jellytau_lib::commands::download::NetworkStateWrapperArg"]],["impl UnwindSafe for StorageStats",0,1,["jellytau_lib::commands::download::StorageStats"]],["impl UnwindSafe for PendingFavorite",0,1,["jellytau_lib::commands::favorites::PendingFavorite"]],["impl UnwindSafe for ExclusionCandidate",0,1,["jellytau_lib::commands::library::ExclusionCandidate"]],["impl UnwindSafe for OfflineItem",0,1,["jellytau_lib::commands::offline::OfflineItem"]],["impl UnwindSafe for RemoteSessionStatus",0,1,["jellytau_lib::commands::playback_mode::RemoteSessionStatus"]],["impl UnwindSafe for AddToQueueRequest",0,1,["jellytau_lib::commands::player::queue::AddToQueueRequest"]],["impl UnwindSafe for AddTrackByIdRequest",0,1,["jellytau_lib::commands::player::queue::AddTrackByIdRequest"]],["impl UnwindSafe for AddTracksByIdsRequest",0,1,["jellytau_lib::commands::player::queue::AddTracksByIdsRequest"]],["impl UnwindSafe for BackgroundAudioPlan",0,1,["jellytau_lib::commands::player::BackgroundAudioPlan"]],["impl UnwindSafe for MediaSessionManagerWrapper",0,1,["jellytau_lib::commands::player::MediaSessionManagerWrapper"]],["impl UnwindSafe for MergedMediaItem",0,1,["jellytau_lib::commands::player::MergedMediaItem"]],["impl UnwindSafe for PlayAlbumTrackRequest",0,1,["jellytau_lib::commands::player::PlayAlbumTrackRequest"]],["impl UnwindSafe for PlayItemRequest",0,1,["jellytau_lib::commands::player::PlayItemRequest"]],["impl UnwindSafe for PlayQueueRequest",0,1,["jellytau_lib::commands::player::PlayQueueRequest"]],["impl UnwindSafe for PlayTracksRequest",0,1,["jellytau_lib::commands::player::PlayTracksRequest"]],["impl UnwindSafe for PlaybackCapabilities",0,1,["jellytau_lib::commands::player::PlaybackCapabilities"]],["impl UnwindSafe for PlayerStatus",0,1,["jellytau_lib::commands::player::PlayerStatus"]],["impl UnwindSafe for PreloadResult",0,1,["jellytau_lib::commands::player::PreloadResult"]],["impl UnwindSafe for QueueStatus",0,1,["jellytau_lib::commands::player::QueueStatus"]],["impl UnwindSafe for SmartCacheWrapper",0,1,["jellytau_lib::commands::player::SmartCacheWrapper"]],["impl UnwindSafe for VideoSettingsWrapper",0,1,["jellytau_lib::commands::player::VideoSettingsWrapper"]],["impl UnwindSafe for FavoritesChangedEvent",0,1,["jellytau_lib::commands::repository::FavoritesChangedEvent"]],["impl UnwindSafe for RepositoryManager",0,1,["jellytau_lib::commands::repository::RepositoryManager"]],["impl UnwindSafe for RepositoryManagerWrapper",0,1,["jellytau_lib::commands::repository::RepositoryManagerWrapper"]],["impl UnwindSafe for SearchUpdateEvent",0,1,["jellytau_lib::commands::repository::SearchUpdateEvent"]],["impl UnwindSafe for CachedItemPerson",0,1,["jellytau_lib::commands::storage::people::CachedItemPerson"]],["impl UnwindSafe for CachedPerson",0,1,["jellytau_lib::commands::storage::people::CachedPerson"]],["impl UnwindSafe for SeriesAudioPreference",0,1,["jellytau_lib::commands::storage::series_prefs::SeriesAudioPreference"]],["impl UnwindSafe for ActiveSession",0,1,["jellytau_lib::commands::storage::ActiveSession"]],["impl UnwindSafe for CachedItem",0,1,["jellytau_lib::commands::storage::CachedItem"]],["impl UnwindSafe for CachedLibrary",0,1,["jellytau_lib::commands::storage::CachedLibrary"]],["impl UnwindSafe for CredentialStoreWrapper",0,1,["jellytau_lib::commands::storage::CredentialStoreWrapper"]],["impl UnwindSafe for DatabaseWrapper",0,1,["jellytau_lib::commands::storage::DatabaseWrapper"]],["impl UnwindSafe for PlaybackProgress",0,1,["jellytau_lib::commands::storage::PlaybackProgress"]],["impl UnwindSafe for SecurityStatus",0,1,["jellytau_lib::commands::storage::SecurityStatus"]],["impl UnwindSafe for ServerInfo",0,1,["jellytau_lib::commands::storage::ServerInfo"]],["impl UnwindSafe for ThumbnailCacheWrapper",0,1,["jellytau_lib::commands::storage::ThumbnailCacheWrapper"]],["impl UnwindSafe for UserInfo",0,1,["jellytau_lib::commands::storage::UserInfo"]],["impl UnwindSafe for GetImageRequest",0,1,["jellytau_lib::commands::storage::thumbnails::GetImageRequest"]],["impl UnwindSafe for SyncQueueItem",0,1,["jellytau_lib::commands::sync::SyncQueueItem"]],["impl UnwindSafe for DrainReport",0,1,["jellytau_lib::commands::sync_drain::DrainReport"]],["impl UnwindSafe for PushFailure",0,1,["jellytau_lib::commands::sync_drain::PushFailure"]],["impl UnwindSafe for QueuedRow",0,1,["jellytau_lib::commands::sync_drain::QueuedRow"]],["impl UnwindSafe for ConnectivityChangeEvent",0,1,["jellytau_lib::connectivity::ConnectivityChangeEvent"]],["impl UnwindSafe for ConnectivityStatus",0,1,["jellytau_lib::connectivity::ConnectivityStatus"]],["impl UnwindSafe for CredentialStore",0,1,["jellytau_lib::credentials::CredentialStore"]],["impl UnwindSafe for CacheConfig",0,1,["jellytau_lib::download::cache::CacheConfig"]],["impl UnwindSafe for SmartCache",0,1,["jellytau_lib::download::cache::SmartCache"]],["impl UnwindSafe for NetworkState",0,1,["jellytau_lib::download::network::NetworkState"]],["impl UnwindSafe for DownloadInfo",0,1,["jellytau_lib::download::DownloadInfo"]],["impl UnwindSafe for DownloadManager",0,1,["jellytau_lib::download::DownloadManager"]],["impl UnwindSafe for DownloadTask",0,1,["jellytau_lib::download::DownloadTask"]],["impl UnwindSafe for DownloadResult",0,1,["jellytau_lib::download::worker::DownloadResult"]],["impl UnwindSafe for LmsSyncGroup",0,1,["jellytau_lib::jellyfin::client::LmsSyncGroup"]],["impl UnwindSafe for NowPlayingItem",0,1,["jellytau_lib::jellyfin::client::NowPlayingItem"]],["impl UnwindSafe for PlayState",0,1,["jellytau_lib::jellyfin::client::PlayState"]],["impl UnwindSafe for SessionInfo",0,1,["jellytau_lib::jellyfin::client::SessionInfo"]],["impl UnwindSafe for HttpConfig",0,1,["jellytau_lib::jellyfin::http_client::HttpConfig"]],["impl UnwindSafe for JellyfinConfig",0,1,["jellytau_lib::jellyfin::types::JellyfinConfig"]],["impl UnwindSafe for PlaybackProgressRequest",0,1,["jellytau_lib::jellyfin::types::PlaybackProgressRequest"]],["impl UnwindSafe for PlaybackStartRequest",0,1,["jellytau_lib::jellyfin::types::PlaybackStartRequest"]],["impl UnwindSafe for PlaybackStoppedRequest",0,1,["jellytau_lib::jellyfin::types::PlaybackStoppedRequest"]],["impl UnwindSafe for MediaServer",0,1,["jellytau_lib::media_server::MediaServer"]],["impl UnwindSafe for MediaServerWrapper",0,1,["jellytau_lib::media_server::MediaServerWrapper"]],["impl UnwindSafe for Span",0,1,["jellytau_lib::media_server::Span"]],["impl UnwindSafe for PlatformRemoteVolumeControl",0,1,["jellytau_lib::playback_mode::PlatformRemoteVolumeControl"]],["impl UnwindSafe for PlaybackContext",0,1,["jellytau_lib::playback_reporting::reporter::PlaybackContext"]],["impl UnwindSafe for SyncConfig",0,1,["jellytau_lib::playback_reporting::sync_processor::SyncConfig"]],["impl UnwindSafe for EventThrottler",0,1,["jellytau_lib::playback_reporting::throttle::EventThrottler"]],["impl UnwindSafe for AutoplaySettings",0,1,["jellytau_lib::player::autoplay::AutoplaySettings"]],["impl UnwindSafe for NullBackend",0,1,["jellytau_lib::player::backend::NullBackend"]],["impl UnwindSafe for PlayerError",0,1,["jellytau_lib::player::backend::PlayerError"]],["impl UnwindSafe for MediaItem",0,1,["jellytau_lib::player::media::MediaItem"]],["impl UnwindSafe for SubtitleTrack",0,1,["jellytau_lib::player::media::SubtitleTrack"]],["impl UnwindSafe for InternalState",0,1,["jellytau_lib::player::mpv_backend::InternalState"]],["impl UnwindSafe for QueueManager",0,1,["jellytau_lib::player::queue::QueueManager"]],["impl UnwindSafe for MediaSessionManager",0,1,["jellytau_lib::player::session::MediaSessionManager"]],["impl UnwindSafe for SleepTimerState",0,1,["jellytau_lib::player::sleep_timer::SleepTimerState"]],["impl UnwindSafe for ObservedTime",0,1,["jellytau_lib::player::stream_end::ObservedTime"]],["impl UnwindSafe for ResumeTracker",0,1,["jellytau_lib::player::stream_end::ResumeTracker"]],["impl UnwindSafe for LockscreenMetadata",0,1,["jellytau_lib::player::LockscreenMetadata"]],["impl UnwindSafe for CachedItem",0,1,["jellytau_lib::repository::offline::CachedItem"]],["impl UnwindSafe for OfflineRepository",0,1,["jellytau_lib::repository::offline::OfflineRepository"]],["impl UnwindSafe for CreatePlaylistResponse",0,1,["jellytau_lib::repository::online::CreatePlaylistResponse"]],["impl UnwindSafe for ItemsResponse",0,1,["jellytau_lib::repository::online::ItemsResponse"]],["impl UnwindSafe for JRayActor",0,1,["jellytau_lib::repository::online::JRayActor"]],["impl UnwindSafe for JRayContext",0,1,["jellytau_lib::repository::online::JRayContext"]],["impl UnwindSafe for JellyfinItem",0,1,["jellytau_lib::repository::online::JellyfinItem"]],["impl UnwindSafe for JellyfinMediaSource",0,1,["jellytau_lib::repository::online::JellyfinMediaSource"]],["impl UnwindSafe for JellyfinMediaStream",0,1,["jellytau_lib::repository::online::JellyfinMediaStream"]],["impl UnwindSafe for JellyfinPlaylistItem",0,1,["jellytau_lib::repository::online::JellyfinPlaylistItem"]],["impl UnwindSafe for JellyfinUserData",0,1,["jellytau_lib::repository::online::JellyfinUserData"]],["impl UnwindSafe for PlaylistItemsResponse",0,1,["jellytau_lib::repository::online::PlaylistItemsResponse"]],["impl UnwindSafe for ArtistItem",0,1,["jellytau_lib::repository::types::ArtistItem"]],["impl UnwindSafe for DownloadDiskUsage",0,1,["jellytau_lib::repository::types::DownloadDiskUsage"]],["impl UnwindSafe for Genre",0,1,["jellytau_lib::repository::types::Genre"]],["impl UnwindSafe for GetItemsOptions",0,1,["jellytau_lib::repository::types::GetItemsOptions"]],["impl UnwindSafe for ImageOptions",0,1,["jellytau_lib::repository::types::ImageOptions"]],["impl UnwindSafe for Library",0,1,["jellytau_lib::repository::types::Library"]],["impl UnwindSafe for LiveStreamInfo",0,1,["jellytau_lib::repository::types::LiveStreamInfo"]],["impl UnwindSafe for MediaItem",0,1,["jellytau_lib::repository::types::MediaItem"]],["impl UnwindSafe for MediaSource",0,1,["jellytau_lib::repository::types::MediaSource"]],["impl UnwindSafe for MediaStream",0,1,["jellytau_lib::repository::types::MediaStream"]],["impl UnwindSafe for Person",0,1,["jellytau_lib::repository::types::Person"]],["impl UnwindSafe for PlaybackInfo",0,1,["jellytau_lib::repository::types::PlaybackInfo"]],["impl UnwindSafe for PlaylistCreatedResult",0,1,["jellytau_lib::repository::types::PlaylistCreatedResult"]],["impl UnwindSafe for PlaylistEntry",0,1,["jellytau_lib::repository::types::PlaylistEntry"]],["impl UnwindSafe for SearchOptions",0,1,["jellytau_lib::repository::types::SearchOptions"]],["impl UnwindSafe for SearchResult",0,1,["jellytau_lib::repository::types::SearchResult"]],["impl UnwindSafe for UserData",0,1,["jellytau_lib::repository::types::UserData"]],["impl UnwindSafe for AudioSettings",0,1,["jellytau_lib::settings::AudioSettings"]],["impl UnwindSafe for LibrarySettings",0,1,["jellytau_lib::settings::LibrarySettings"]],["impl UnwindSafe for VideoSettings",0,1,["jellytau_lib::settings::VideoSettings"]],["impl UnwindSafe for Query",0,1,["jellytau_lib::storage::db_service::Query"]],["impl UnwindSafe for RusqliteService",0,1,["jellytau_lib::storage::db_service::RusqliteService"]],["impl UnwindSafe for PlaylistItem",0,1,["jellytau_lib::storage::models::PlaylistItem"]],["impl UnwindSafe for Database",0,1,["jellytau_lib::storage::Database"]],["impl UnwindSafe for BackendInitError",0,1,["jellytau_lib::BackendInitError"]],["impl UnwindSafe for CacheConfig",0,1,["jellytau_lib::thumbnail::cache::CacheConfig"]],["impl UnwindSafe for ThumbnailCache",0,1,["jellytau_lib::thumbnail::cache::ThumbnailCache"]],["impl UnwindSafe for ThumbnailCacheStats",0,1,["jellytau_lib::thumbnail::ThumbnailCacheStats"]]]]]); + if (window.register_implementors) { + window.register_implementors(implementors); + } else { + window.pending_implementors = implementors; + } +})() +//{"start":59,"fragment_lengths":[90917]} \ No newline at end of file diff --git a/api/trait.impl/jellytau_lib/commands/favorites/trait.FavoriteSink.js b/api/trait.impl/jellytau_lib/commands/favorites/trait.FavoriteSink.js new file mode 100644 index 00000000..d4ab97f1 --- /dev/null +++ b/api/trait.impl/jellytau_lib/commands/favorites/trait.FavoriteSink.js @@ -0,0 +1,9 @@ +(function() { + const implementors = Object.fromEntries([["jellytau_lib",[]]]); + if (window.register_implementors) { + window.register_implementors(implementors); + } else { + window.pending_implementors = implementors; + } +})() +//{"start":59,"fragment_lengths":[19]} \ No newline at end of file diff --git a/api/trait.impl/jellytau_lib/commands/sync_drain/trait.SyncSink.js b/api/trait.impl/jellytau_lib/commands/sync_drain/trait.SyncSink.js new file mode 100644 index 00000000..d4ab97f1 --- /dev/null +++ b/api/trait.impl/jellytau_lib/commands/sync_drain/trait.SyncSink.js @@ -0,0 +1,9 @@ +(function() { + const implementors = Object.fromEntries([["jellytau_lib",[]]]); + if (window.register_implementors) { + window.register_implementors(implementors); + } else { + window.pending_implementors = implementors; + } +})() +//{"start":59,"fragment_lengths":[19]} \ No newline at end of file diff --git a/api/trait.impl/jellytau_lib/playback_mode/trait.RemoteVolumeControl.js b/api/trait.impl/jellytau_lib/playback_mode/trait.RemoteVolumeControl.js new file mode 100644 index 00000000..d4ab97f1 --- /dev/null +++ b/api/trait.impl/jellytau_lib/playback_mode/trait.RemoteVolumeControl.js @@ -0,0 +1,9 @@ +(function() { + const implementors = Object.fromEntries([["jellytau_lib",[]]]); + if (window.register_implementors) { + window.register_implementors(implementors); + } else { + window.pending_implementors = implementors; + } +})() +//{"start":59,"fragment_lengths":[19]} \ No newline at end of file diff --git a/api/trait.impl/jellytau_lib/player/backend/trait.PlayerBackend.js b/api/trait.impl/jellytau_lib/player/backend/trait.PlayerBackend.js new file mode 100644 index 00000000..d4ab97f1 --- /dev/null +++ b/api/trait.impl/jellytau_lib/player/backend/trait.PlayerBackend.js @@ -0,0 +1,9 @@ +(function() { + const implementors = Object.fromEntries([["jellytau_lib",[]]]); + if (window.register_implementors) { + window.register_implementors(implementors); + } else { + window.pending_implementors = implementors; + } +})() +//{"start":59,"fragment_lengths":[19]} \ No newline at end of file diff --git a/api/trait.impl/jellytau_lib/player/events/trait.PlayerEventEmitter.js b/api/trait.impl/jellytau_lib/player/events/trait.PlayerEventEmitter.js new file mode 100644 index 00000000..d4ab97f1 --- /dev/null +++ b/api/trait.impl/jellytau_lib/player/events/trait.PlayerEventEmitter.js @@ -0,0 +1,9 @@ +(function() { + const implementors = Object.fromEntries([["jellytau_lib",[]]]); + if (window.register_implementors) { + window.register_implementors(implementors); + } else { + window.pending_implementors = implementors; + } +})() +//{"start":59,"fragment_lengths":[19]} \ No newline at end of file diff --git a/api/trait.impl/jellytau_lib/player/trait.PlaybackReportSink.js b/api/trait.impl/jellytau_lib/player/trait.PlaybackReportSink.js new file mode 100644 index 00000000..d4ab97f1 --- /dev/null +++ b/api/trait.impl/jellytau_lib/player/trait.PlaybackReportSink.js @@ -0,0 +1,9 @@ +(function() { + const implementors = Object.fromEntries([["jellytau_lib",[]]]); + if (window.register_implementors) { + window.register_implementors(implementors); + } else { + window.pending_implementors = implementors; + } +})() +//{"start":59,"fragment_lengths":[19]} \ No newline at end of file diff --git a/api/trait.impl/jellytau_lib/repository/exclusions/trait.ExcludeHidden.js b/api/trait.impl/jellytau_lib/repository/exclusions/trait.ExcludeHidden.js new file mode 100644 index 00000000..d4ab97f1 --- /dev/null +++ b/api/trait.impl/jellytau_lib/repository/exclusions/trait.ExcludeHidden.js @@ -0,0 +1,9 @@ +(function() { + const implementors = Object.fromEntries([["jellytau_lib",[]]]); + if (window.register_implementors) { + window.register_implementors(implementors); + } else { + window.pending_implementors = implementors; + } +})() +//{"start":59,"fragment_lengths":[19]} \ No newline at end of file diff --git a/api/trait.impl/jellytau_lib/repository/trait.MediaRepository.js b/api/trait.impl/jellytau_lib/repository/trait.MediaRepository.js new file mode 100644 index 00000000..d4ab97f1 --- /dev/null +++ b/api/trait.impl/jellytau_lib/repository/trait.MediaRepository.js @@ -0,0 +1,9 @@ +(function() { + const implementors = Object.fromEntries([["jellytau_lib",[]]]); + if (window.register_implementors) { + window.register_implementors(implementors); + } else { + window.pending_implementors = implementors; + } +})() +//{"start":59,"fragment_lengths":[19]} \ No newline at end of file diff --git a/api/trait.impl/jellytau_lib/repository/types/trait.MeaningfulContent.js b/api/trait.impl/jellytau_lib/repository/types/trait.MeaningfulContent.js new file mode 100644 index 00000000..d4ab97f1 --- /dev/null +++ b/api/trait.impl/jellytau_lib/repository/types/trait.MeaningfulContent.js @@ -0,0 +1,9 @@ +(function() { + const implementors = Object.fromEntries([["jellytau_lib",[]]]); + if (window.register_implementors) { + window.register_implementors(implementors); + } else { + window.pending_implementors = implementors; + } +})() +//{"start":59,"fragment_lengths":[19]} \ No newline at end of file diff --git a/api/trait.impl/jellytau_lib/storage/db_service/trait.DatabaseService.js b/api/trait.impl/jellytau_lib/storage/db_service/trait.DatabaseService.js new file mode 100644 index 00000000..d4ab97f1 --- /dev/null +++ b/api/trait.impl/jellytau_lib/storage/db_service/trait.DatabaseService.js @@ -0,0 +1,9 @@ +(function() { + const implementors = Object.fromEntries([["jellytau_lib",[]]]); + if (window.register_implementors) { + window.register_implementors(implementors); + } else { + window.pending_implementors = implementors; + } +})() +//{"start":59,"fragment_lengths":[19]} \ No newline at end of file diff --git a/api/trait.impl/jellytau_lib/utils/lock/trait.MutexSafe.js b/api/trait.impl/jellytau_lib/utils/lock/trait.MutexSafe.js new file mode 100644 index 00000000..d4ab97f1 --- /dev/null +++ b/api/trait.impl/jellytau_lib/utils/lock/trait.MutexSafe.js @@ -0,0 +1,9 @@ +(function() { + const implementors = Object.fromEntries([["jellytau_lib",[]]]); + if (window.register_implementors) { + window.register_implementors(implementors); + } else { + window.pending_implementors = implementors; + } +})() +//{"start":59,"fragment_lengths":[19]} \ No newline at end of file diff --git a/api/trait.impl/jellytau_lib/utils/lock/trait.RwLockSafe.js b/api/trait.impl/jellytau_lib/utils/lock/trait.RwLockSafe.js new file mode 100644 index 00000000..d4ab97f1 --- /dev/null +++ b/api/trait.impl/jellytau_lib/utils/lock/trait.RwLockSafe.js @@ -0,0 +1,9 @@ +(function() { + const implementors = Object.fromEntries([["jellytau_lib",[]]]); + if (window.register_implementors) { + window.register_implementors(implementors); + } else { + window.pending_implementors = implementors; + } +})() +//{"start":59,"fragment_lengths":[19]} \ No newline at end of file diff --git a/api/trait.impl/serde_core/de/trait.Deserialize.js b/api/trait.impl/serde_core/de/trait.Deserialize.js new file mode 100644 index 00000000..bdc41eaf --- /dev/null +++ b/api/trait.impl/serde_core/de/trait.Deserialize.js @@ -0,0 +1,9 @@ +(function() { + const implementors = Object.fromEntries([["jellytau_lib",[["impl<'de> Deserialize<'de> for PlayQueueContext",0],["impl<'de> Deserialize<'de> for PlayTracksContext",0],["impl<'de> Deserialize<'de> for MediaKind",0],["impl<'de> Deserialize<'de> for StreamKind",0],["impl<'de> Deserialize<'de> for DownloadEvent",0],["impl<'de> Deserialize<'de> for NetworkType",0],["impl<'de> Deserialize<'de> for PlaybackMode",0],["impl<'de> Deserialize<'de> for BackgroundAction",0],["impl<'de> Deserialize<'de> for PlayerStatusEvent",0],["impl<'de> Deserialize<'de> for MediaSource",0],["impl<'de> Deserialize<'de> for MediaType",0],["impl<'de> Deserialize<'de> for QueueContext",0],["impl<'de> Deserialize<'de> for RepeatMode",0],["impl<'de> Deserialize<'de> for MediaSessionType",0],["impl<'de> Deserialize<'de> for SleepTimerMode",0],["impl<'de> Deserialize<'de> for EndReason",0],["impl<'de> Deserialize<'de> for PlayerState",0],["impl<'de> Deserialize<'de> for ImageTags",0],["impl<'de> Deserialize<'de> for ImageType",0],["impl<'de> Deserialize<'de> for RepoError",0],["impl<'de> Deserialize<'de> for SearchScope",0],["impl<'de> Deserialize<'de> for EqPreset",0],["impl<'de> Deserialize<'de> for StreamingQuality",0],["impl<'de> Deserialize<'de> for VolumeLevel",0],["impl<'de> Deserialize<'de> for AuthResult",0],["impl<'de> Deserialize<'de> for AuthenticateByNameResponse",0],["impl<'de> Deserialize<'de> for JellyfinUser",0],["impl<'de> Deserialize<'de> for PublicSystemInfo",0],["impl<'de> Deserialize<'de> for ServerInfo",0],["impl<'de> Deserialize<'de> for Session",0],["impl<'de> Deserialize<'de> for User",0],["impl<'de> Deserialize<'de> for CatalogIndexEvent",0],["impl<'de> Deserialize<'de> for CatalogSyncResult",0],["impl<'de> Deserialize<'de> for CatalogSyncStatus",0],["impl<'de> Deserialize<'de> for ResumeQueuedResult",0],["impl<'de> Deserialize<'de> for DiagnosticsBundle",0],["impl<'de> Deserialize<'de> for DiagnosticsInfo",0],["impl<'de> Deserialize<'de> for DownloadItemAndStartRequest",0],["impl<'de> Deserialize<'de> for DownloadItemRequest",0],["impl<'de> Deserialize<'de> for DownloadStats",0],["impl<'de> Deserialize<'de> for DownloadVideoRequest",0],["impl<'de> Deserialize<'de> for DownloadsResponse",0],["impl<'de> Deserialize<'de> for NetworkStateWrapperArg",0],["impl<'de> Deserialize<'de> for ExclusionCandidate",0],["impl<'de> Deserialize<'de> for OfflineItem",0],["impl<'de> Deserialize<'de> for AddToQueueRequest",0],["impl<'de> Deserialize<'de> for AddTrackByIdRequest",0],["impl<'de> Deserialize<'de> for AddTracksByIdsRequest",0],["impl<'de> Deserialize<'de> for PlayAlbumTrackRequest",0],["impl<'de> Deserialize<'de> for PlayItemRequest",0],["impl<'de> Deserialize<'de> for PlayQueueRequest",0],["impl<'de> Deserialize<'de> for PlayTracksRequest",0],["impl<'de> Deserialize<'de> for FavoritesChangedEvent",0],["impl<'de> Deserialize<'de> for SearchUpdateEvent",0],["impl<'de> Deserialize<'de> for CachedItemPerson",0],["impl<'de> Deserialize<'de> for CachedPerson",0],["impl<'de> Deserialize<'de> for SeriesAudioPreference",0],["impl<'de> Deserialize<'de> for ActiveSession",0],["impl<'de> Deserialize<'de> for CachedItem",0],["impl<'de> Deserialize<'de> for CachedLibrary",0],["impl<'de> Deserialize<'de> for PlaybackProgress",0],["impl<'de> Deserialize<'de> for SecurityStatus",0],["impl<'de> Deserialize<'de> for ServerInfo",0],["impl<'de> Deserialize<'de> for UserInfo",0],["impl<'de> Deserialize<'de> for GetImageRequest",0],["impl<'de> Deserialize<'de> for SyncQueueItem",0],["impl<'de> Deserialize<'de> for DrainReport",0],["impl<'de> Deserialize<'de> for ConnectivityStatus",0],["impl<'de> Deserialize<'de> for CacheConfig",0],["impl<'de> Deserialize<'de> for NetworkState",0],["impl<'de> Deserialize<'de> for DownloadInfo",0],["impl<'de> Deserialize<'de> for LmsSyncGroup",0],["impl<'de> Deserialize<'de> for NowPlayingItem",0],["impl<'de> Deserialize<'de> for PlayState",0],["impl<'de> Deserialize<'de> for SessionInfo",0],["impl<'de> Deserialize<'de> for AutoplaySettings",0],["impl<'de> Deserialize<'de> for MediaItem",0],["impl<'de> Deserialize<'de> for SubtitleTrack",0],["impl<'de> Deserialize<'de> for QueueManager",0],["impl<'de> Deserialize<'de> for SleepTimerState",0],["impl<'de> Deserialize<'de> for CreatePlaylistResponse",0],["impl<'de> Deserialize<'de> for ItemsResponse",0],["impl<'de> Deserialize<'de> for JRayActor",0],["impl<'de> Deserialize<'de> for JRayContext",0],["impl<'de> Deserialize<'de> for JellyfinItem",0],["impl<'de> Deserialize<'de> for JellyfinMediaSource",0],["impl<'de> Deserialize<'de> for JellyfinMediaStream",0],["impl<'de> Deserialize<'de> for JellyfinPlaylistItem",0],["impl<'de> Deserialize<'de> for JellyfinUserData",0],["impl<'de> Deserialize<'de> for PlaylistItemsResponse",0],["impl<'de> Deserialize<'de> for ArtistItem",0],["impl<'de> Deserialize<'de> for DownloadDiskUsage",0],["impl<'de> Deserialize<'de> for Genre",0],["impl<'de> Deserialize<'de> for GetItemsOptions",0],["impl<'de> Deserialize<'de> for ImageOptions",0],["impl<'de> Deserialize<'de> for Library",0],["impl<'de> Deserialize<'de> for LiveStreamInfo",0],["impl<'de> Deserialize<'de> for MediaItem",0],["impl<'de> Deserialize<'de> for MediaSource",0],["impl<'de> Deserialize<'de> for MediaStream",0],["impl<'de> Deserialize<'de> for Person",0],["impl<'de> Deserialize<'de> for PlaybackInfo",0],["impl<'de> Deserialize<'de> for PlaylistCreatedResult",0],["impl<'de> Deserialize<'de> for PlaylistEntry",0],["impl<'de> Deserialize<'de> for SearchOptions",0],["impl<'de> Deserialize<'de> for SearchResult",0],["impl<'de> Deserialize<'de> for UserData",0],["impl<'de> Deserialize<'de> for AudioSettings",0],["impl<'de> Deserialize<'de> for LibrarySettings",0],["impl<'de> Deserialize<'de> for VideoSettings",0],["impl<'de> Deserialize<'de> for PlaylistItem",0],["impl<'de> Deserialize<'de> for ThumbnailCacheStats",0]]]]); + if (window.register_implementors) { + window.register_implementors(implementors); + } else { + window.pending_implementors = implementors; + } +})() +//{"start":59,"fragment_lengths":[40665]} \ No newline at end of file diff --git a/api/trait.impl/serde_core/ser/trait.Serialize.js b/api/trait.impl/serde_core/ser/trait.Serialize.js new file mode 100644 index 00000000..aae53e29 --- /dev/null +++ b/api/trait.impl/serde_core/ser/trait.Serialize.js @@ -0,0 +1,9 @@ +(function() { + const implementors = Object.fromEntries([["jellytau_lib",[["impl Serialize for SessionVerificationEvent",0],["impl Serialize for AudioTrackSwitchResponse",0],["impl Serialize for StreamQualityResponse",0],["impl Serialize for VideoBackend",0],["impl Serialize for VideoSeekResponse",0],["impl Serialize for MediaKind",0],["impl Serialize for StreamKind",0],["impl Serialize for DownloadEvent",0],["impl Serialize for NetworkType",0],["impl Serialize for PlaybackMode",0],["impl Serialize for AutoplayDecision",0],["impl Serialize for BackgroundAction",0],["impl Serialize for PlayerStatusEvent",0],["impl Serialize for MediaSource",0],["impl Serialize for MediaType",0],["impl Serialize for QueueContext",0],["impl Serialize for RepeatMode",0],["impl Serialize for MediaSessionType",0],["impl Serialize for SleepTimerMode",0],["impl Serialize for EndReason",0],["impl Serialize for PlayerState",0],["impl Serialize for ImageType",0],["impl Serialize for RepoError",0],["impl Serialize for SearchScope",0],["impl Serialize for EqPreset",0],["impl Serialize for StreamingQuality",0],["impl Serialize for VolumeLevel",0],["impl Serialize for AuthResult",0],["impl Serialize for ServerInfo",0],["impl Serialize for Session",0],["impl Serialize for User",0],["impl Serialize for CatalogIndexEvent",0],["impl Serialize for CatalogSyncResult",0],["impl Serialize for CatalogSyncStatus",0],["impl Serialize for ResumeQueuedResult",0],["impl Serialize for DiagnosticsBundle",0],["impl Serialize for DiagnosticsInfo",0],["impl Serialize for AlbumAffinityStatus",0],["impl Serialize for AlbumRecommendation",0],["impl Serialize for SmartCacheStats",0],["impl Serialize for AlbumStorageInfo",0],["impl Serialize for DownloadManagerStats",0],["impl Serialize for DownloadStats",0],["impl Serialize for DownloadsResponse",0],["impl Serialize for NetworkStateWrapperArg",0],["impl Serialize for StorageStats",0],["impl Serialize for ExclusionCandidate",0],["impl Serialize for OfflineItem",0],["impl Serialize for RemoteSessionStatus",0],["impl Serialize for MergedMediaItem",0],["impl Serialize for PlaybackCapabilities",0],["impl Serialize for PlayerStatus",0],["impl Serialize for PreloadResult",0],["impl Serialize for QueueStatus",0],["impl Serialize for FavoritesChangedEvent",0],["impl Serialize for SearchUpdateEvent",0],["impl Serialize for CachedItemPerson",0],["impl Serialize for CachedPerson",0],["impl Serialize for SeriesAudioPreference",0],["impl Serialize for ActiveSession",0],["impl Serialize for CachedItem",0],["impl Serialize for CachedLibrary",0],["impl Serialize for PlaybackProgress",0],["impl Serialize for SecurityStatus",0],["impl Serialize for ServerInfo",0],["impl Serialize for UserInfo",0],["impl Serialize for SyncQueueItem",0],["impl Serialize for DrainReport",0],["impl Serialize for ConnectivityChangeEvent",0],["impl Serialize for ConnectivityStatus",0],["impl Serialize for CacheConfig",0],["impl Serialize for NetworkState",0],["impl Serialize for DownloadInfo",0],["impl Serialize for LmsSyncGroup",0],["impl Serialize for NowPlayingItem",0],["impl Serialize for PlayState",0],["impl Serialize for SessionInfo",0],["impl Serialize for PlaybackProgressRequest",0],["impl Serialize for PlaybackStartRequest",0],["impl Serialize for PlaybackStoppedRequest",0],["impl Serialize for AutoplaySettings",0],["impl Serialize for MediaItem",0],["impl Serialize for SubtitleTrack",0],["impl Serialize for QueueManager",0],["impl Serialize for SleepTimerState",0],["impl Serialize for JRayActor",0],["impl Serialize for ArtistItem",0],["impl Serialize for DownloadDiskUsage",0],["impl Serialize for Genre",0],["impl Serialize for GetItemsOptions",0],["impl Serialize for ImageOptions",0],["impl Serialize for Library",0],["impl Serialize for LiveStreamInfo",0],["impl Serialize for MediaItem",0],["impl Serialize for MediaSource",0],["impl Serialize for MediaStream",0],["impl Serialize for Person",0],["impl Serialize for PlaybackInfo",0],["impl Serialize for PlaylistCreatedResult",0],["impl Serialize for PlaylistEntry",0],["impl Serialize for SearchOptions",0],["impl Serialize for SearchResult",0],["impl Serialize for UserData",0],["impl Serialize for AudioSettings",0],["impl Serialize for LibrarySettings",0],["impl Serialize for VideoSettings",0],["impl Serialize for PlaylistItem",0],["impl Serialize for BackendInitError",0],["impl Serialize for ThumbnailCacheStats",0]]]]); + if (window.register_implementors) { + window.register_implementors(implementors); + } else { + window.pending_implementors = implementors; + } +})() +//{"start":59,"fragment_lengths":[36734]} \ No newline at end of file diff --git a/api/trait.impl/specta/type/trait.Flatten.js b/api/trait.impl/specta/type/trait.Flatten.js new file mode 100644 index 00000000..56d7fa9b --- /dev/null +++ b/api/trait.impl/specta/type/trait.Flatten.js @@ -0,0 +1,9 @@ +(function() { + const implementors = Object.fromEntries([["jellytau_lib",[["impl Flatten for AudioTrackSwitchResponse",0],["impl Flatten for PlayQueueContext",0],["impl Flatten for PlayTracksContext",0],["impl Flatten for StreamQualityResponse",0],["impl Flatten for VideoSeekResponse",0],["impl Flatten for PlaybackMode",0],["impl Flatten for AutoplayDecision",0],["impl Flatten for PlayerStatusEvent",0],["impl Flatten for MediaSource",0],["impl Flatten for QueueContext",0],["impl Flatten for MediaSessionType",0],["impl Flatten for SleepTimerMode",0],["impl Flatten for PlayerState",0],["impl Flatten for RepoError",0],["impl Flatten for AuthResult",0],["impl Flatten for AuthenticateByNameResponse",0],["impl Flatten for JellyfinUser",0],["impl Flatten for PublicSystemInfo",0],["impl Flatten for ServerInfo",0],["impl Flatten for Session",0],["impl Flatten for User",0],["impl Flatten for CatalogIndexEvent",0],["impl Flatten for CatalogSyncResult",0],["impl Flatten for CatalogSyncStatus",0],["impl Flatten for ResumeQueuedResult",0],["impl Flatten for DiagnosticsBundle",0],["impl Flatten for DiagnosticsInfo",0],["impl Flatten for AlbumAffinityStatus",0],["impl Flatten for AlbumRecommendation",0],["impl Flatten for SmartCacheStats",0],["impl Flatten for AlbumStorageInfo",0],["impl Flatten for DownloadItemAndStartRequest",0],["impl Flatten for DownloadItemRequest",0],["impl Flatten for DownloadManagerStats",0],["impl Flatten for DownloadStats",0],["impl Flatten for DownloadVideoRequest",0],["impl Flatten for DownloadsResponse",0],["impl Flatten for NetworkStateWrapperArg",0],["impl Flatten for StorageStats",0],["impl Flatten for ExclusionCandidate",0],["impl Flatten for OfflineItem",0],["impl Flatten for RemoteSessionStatus",0],["impl Flatten for AddToQueueRequest",0],["impl Flatten for AddTrackByIdRequest",0],["impl Flatten for AddTracksByIdsRequest",0],["impl Flatten for MergedMediaItem",0],["impl Flatten for PlayAlbumTrackRequest",0],["impl Flatten for PlayItemRequest",0],["impl Flatten for PlayQueueRequest",0],["impl Flatten for PlayTracksRequest",0],["impl Flatten for PlaybackCapabilities",0],["impl Flatten for PlayerStatus",0],["impl Flatten for PreloadResult",0],["impl Flatten for QueueStatus",0],["impl Flatten for FavoritesChangedEvent",0],["impl Flatten for SearchUpdateEvent",0],["impl Flatten for CachedItemPerson",0],["impl Flatten for CachedPerson",0],["impl Flatten for SeriesAudioPreference",0],["impl Flatten for ActiveSession",0],["impl Flatten for CachedItem",0],["impl Flatten for CachedLibrary",0],["impl Flatten for PlaybackProgress",0],["impl Flatten for SecurityStatus",0],["impl Flatten for ServerInfo",0],["impl Flatten for UserInfo",0],["impl Flatten for GetImageRequest",0],["impl Flatten for SyncQueueItem",0],["impl Flatten for DrainReport",0],["impl Flatten for ConnectivityChangeEvent",0],["impl Flatten for ConnectivityStatus",0],["impl Flatten for CacheConfig",0],["impl Flatten for NetworkState",0],["impl Flatten for DownloadInfo",0],["impl Flatten for LmsSyncGroup",0],["impl Flatten for NowPlayingItem",0],["impl Flatten for PlayState",0],["impl Flatten for SessionInfo",0],["impl Flatten for AutoplaySettings",0],["impl Flatten for MediaItem",0],["impl Flatten for SubtitleTrack",0],["impl Flatten for QueueManager",0],["impl Flatten for SleepTimerState",0],["impl Flatten for JRayActor",0],["impl Flatten for ArtistItem",0],["impl Flatten for DownloadDiskUsage",0],["impl Flatten for Genre",0],["impl Flatten for GetItemsOptions",0],["impl Flatten for ImageOptions",0],["impl Flatten for Library",0],["impl Flatten for LiveStreamInfo",0],["impl Flatten for MediaItem",0],["impl Flatten for MediaSource",0],["impl Flatten for MediaStream",0],["impl Flatten for Person",0],["impl Flatten for PlaybackInfo",0],["impl Flatten for PlaylistCreatedResult",0],["impl Flatten for PlaylistEntry",0],["impl Flatten for SearchOptions",0],["impl Flatten for SearchResult",0],["impl Flatten for UserData",0],["impl Flatten for AudioSettings",0],["impl Flatten for LibrarySettings",0],["impl Flatten for VideoSettings",0],["impl Flatten for ThumbnailCacheStats",0]]]]); + if (window.register_implementors) { + window.register_implementors(implementors); + } else { + window.pending_implementors = implementors; + } +})() +//{"start":59,"fragment_lengths":[20241]} \ No newline at end of file diff --git a/api/trait.impl/specta/type/trait.NamedType.js b/api/trait.impl/specta/type/trait.NamedType.js new file mode 100644 index 00000000..432037d8 --- /dev/null +++ b/api/trait.impl/specta/type/trait.NamedType.js @@ -0,0 +1,9 @@ +(function() { + const implementors = Object.fromEntries([["jellytau_lib",[["impl NamedType for AudioTrackSwitchResponse",0],["impl NamedType for PlayQueueContext",0],["impl NamedType for PlayTracksContext",0],["impl NamedType for StreamQualityResponse",0],["impl NamedType for VideoBackend",0],["impl NamedType for VideoSeekResponse",0],["impl NamedType for MediaKind",0],["impl NamedType for StreamKind",0],["impl NamedType for NetworkType",0],["impl NamedType for PlaybackMode",0],["impl NamedType for AutoplayDecision",0],["impl NamedType for BackgroundAction",0],["impl NamedType for PlayerStatusEvent",0],["impl NamedType for MediaSource",0],["impl NamedType for MediaType",0],["impl NamedType for QueueContext",0],["impl NamedType for RepeatMode",0],["impl NamedType for MediaSessionType",0],["impl NamedType for SleepTimerMode",0],["impl NamedType for EndReason",0],["impl NamedType for PlayerState",0],["impl NamedType for ImageType",0],["impl NamedType for RepoError",0],["impl NamedType for SearchScope",0],["impl NamedType for EqPreset",0],["impl NamedType for StreamingQuality",0],["impl NamedType for VolumeLevel",0],["impl NamedType for AuthResult",0],["impl NamedType for AuthenticateByNameResponse",0],["impl NamedType for JellyfinUser",0],["impl NamedType for PublicSystemInfo",0],["impl NamedType for ServerInfo",0],["impl NamedType for Session",0],["impl NamedType for User",0],["impl NamedType for CatalogIndexEvent",0],["impl NamedType for CatalogSyncResult",0],["impl NamedType for CatalogSyncStatus",0],["impl NamedType for ResumeQueuedResult",0],["impl NamedType for DiagnosticsBundle",0],["impl NamedType for DiagnosticsInfo",0],["impl NamedType for AlbumAffinityStatus",0],["impl NamedType for AlbumRecommendation",0],["impl NamedType for SmartCacheStats",0],["impl NamedType for AlbumStorageInfo",0],["impl NamedType for DownloadItemAndStartRequest",0],["impl NamedType for DownloadItemRequest",0],["impl NamedType for DownloadManagerStats",0],["impl NamedType for DownloadStats",0],["impl NamedType for DownloadVideoRequest",0],["impl NamedType for DownloadsResponse",0],["impl NamedType for NetworkStateWrapperArg",0],["impl NamedType for StorageStats",0],["impl NamedType for ExclusionCandidate",0],["impl NamedType for OfflineItem",0],["impl NamedType for RemoteSessionStatus",0],["impl NamedType for AddToQueueRequest",0],["impl NamedType for AddTrackByIdRequest",0],["impl NamedType for AddTracksByIdsRequest",0],["impl NamedType for MergedMediaItem",0],["impl NamedType for PlayAlbumTrackRequest",0],["impl NamedType for PlayItemRequest",0],["impl NamedType for PlayQueueRequest",0],["impl NamedType for PlayTracksRequest",0],["impl NamedType for PlaybackCapabilities",0],["impl NamedType for PlayerStatus",0],["impl NamedType for PreloadResult",0],["impl NamedType for QueueStatus",0],["impl NamedType for FavoritesChangedEvent",0],["impl NamedType for SearchUpdateEvent",0],["impl NamedType for CachedItemPerson",0],["impl NamedType for CachedPerson",0],["impl NamedType for SeriesAudioPreference",0],["impl NamedType for ActiveSession",0],["impl NamedType for CachedItem",0],["impl NamedType for CachedLibrary",0],["impl NamedType for PlaybackProgress",0],["impl NamedType for SecurityStatus",0],["impl NamedType for ServerInfo",0],["impl NamedType for UserInfo",0],["impl NamedType for GetImageRequest",0],["impl NamedType for SyncQueueItem",0],["impl NamedType for DrainReport",0],["impl NamedType for ConnectivityChangeEvent",0],["impl NamedType for ConnectivityStatus",0],["impl NamedType for CacheConfig",0],["impl NamedType for NetworkState",0],["impl NamedType for DownloadInfo",0],["impl NamedType for LmsSyncGroup",0],["impl NamedType for NowPlayingItem",0],["impl NamedType for PlayState",0],["impl NamedType for SessionInfo",0],["impl NamedType for AutoplaySettings",0],["impl NamedType for MediaItem",0],["impl NamedType for SubtitleTrack",0],["impl NamedType for QueueManager",0],["impl NamedType for SleepTimerState",0],["impl NamedType for JRayActor",0],["impl NamedType for ArtistItem",0],["impl NamedType for DownloadDiskUsage",0],["impl NamedType for Genre",0],["impl NamedType for GetItemsOptions",0],["impl NamedType for ImageOptions",0],["impl NamedType for Library",0],["impl NamedType for LiveStreamInfo",0],["impl NamedType for MediaItem",0],["impl NamedType for MediaSource",0],["impl NamedType for MediaStream",0],["impl NamedType for Person",0],["impl NamedType for PlaybackInfo",0],["impl NamedType for PlaylistCreatedResult",0],["impl NamedType for PlaylistEntry",0],["impl NamedType for SearchOptions",0],["impl NamedType for SearchResult",0],["impl NamedType for UserData",0],["impl NamedType for AudioSettings",0],["impl NamedType for LibrarySettings",0],["impl NamedType for VideoSettings",0],["impl NamedType for ThumbnailCacheStats",0]]]]); + if (window.register_implementors) { + window.register_implementors(implementors); + } else { + window.pending_implementors = implementors; + } +})() +//{"start":59,"fragment_lengths":[22708]} \ No newline at end of file diff --git a/api/trait.impl/specta/type/trait.Type.js b/api/trait.impl/specta/type/trait.Type.js new file mode 100644 index 00000000..07ff88d0 --- /dev/null +++ b/api/trait.impl/specta/type/trait.Type.js @@ -0,0 +1,9 @@ +(function() { + const implementors = Object.fromEntries([["jellytau_lib",[["impl Type for AudioTrackSwitchResponse",0],["impl Type for PlayQueueContext",0],["impl Type for PlayTracksContext",0],["impl Type for StreamQualityResponse",0],["impl Type for VideoBackend",0],["impl Type for VideoSeekResponse",0],["impl Type for MediaKind",0],["impl Type for StreamKind",0],["impl Type for NetworkType",0],["impl Type for PlaybackMode",0],["impl Type for AutoplayDecision",0],["impl Type for BackgroundAction",0],["impl Type for PlayerStatusEvent",0],["impl Type for MediaSource",0],["impl Type for MediaType",0],["impl Type for QueueContext",0],["impl Type for RepeatMode",0],["impl Type for MediaSessionType",0],["impl Type for SleepTimerMode",0],["impl Type for EndReason",0],["impl Type for PlayerState",0],["impl Type for ImageType",0],["impl Type for RepoError",0],["impl Type for SearchScope",0],["impl Type for EqPreset",0],["impl Type for StreamingQuality",0],["impl Type for VolumeLevel",0],["impl Type for AuthResult",0],["impl Type for AuthenticateByNameResponse",0],["impl Type for JellyfinUser",0],["impl Type for PublicSystemInfo",0],["impl Type for ServerInfo",0],["impl Type for Session",0],["impl Type for User",0],["impl Type for CatalogIndexEvent",0],["impl Type for CatalogSyncResult",0],["impl Type for CatalogSyncStatus",0],["impl Type for ResumeQueuedResult",0],["impl Type for DiagnosticsBundle",0],["impl Type for DiagnosticsInfo",0],["impl Type for AlbumAffinityStatus",0],["impl Type for AlbumRecommendation",0],["impl Type for SmartCacheStats",0],["impl Type for AlbumStorageInfo",0],["impl Type for DownloadItemAndStartRequest",0],["impl Type for DownloadItemRequest",0],["impl Type for DownloadManagerStats",0],["impl Type for DownloadStats",0],["impl Type for DownloadVideoRequest",0],["impl Type for DownloadsResponse",0],["impl Type for NetworkStateWrapperArg",0],["impl Type for StorageStats",0],["impl Type for ExclusionCandidate",0],["impl Type for OfflineItem",0],["impl Type for RemoteSessionStatus",0],["impl Type for AddToQueueRequest",0],["impl Type for AddTrackByIdRequest",0],["impl Type for AddTracksByIdsRequest",0],["impl Type for MergedMediaItem",0],["impl Type for PlayAlbumTrackRequest",0],["impl Type for PlayItemRequest",0],["impl Type for PlayQueueRequest",0],["impl Type for PlayTracksRequest",0],["impl Type for PlaybackCapabilities",0],["impl Type for PlayerStatus",0],["impl Type for PreloadResult",0],["impl Type for QueueStatus",0],["impl Type for FavoritesChangedEvent",0],["impl Type for SearchUpdateEvent",0],["impl Type for CachedItemPerson",0],["impl Type for CachedPerson",0],["impl Type for SeriesAudioPreference",0],["impl Type for ActiveSession",0],["impl Type for CachedItem",0],["impl Type for CachedLibrary",0],["impl Type for PlaybackProgress",0],["impl Type for SecurityStatus",0],["impl Type for ServerInfo",0],["impl Type for UserInfo",0],["impl Type for GetImageRequest",0],["impl Type for SyncQueueItem",0],["impl Type for DrainReport",0],["impl Type for ConnectivityChangeEvent",0],["impl Type for ConnectivityStatus",0],["impl Type for CacheConfig",0],["impl Type for NetworkState",0],["impl Type for DownloadInfo",0],["impl Type for LmsSyncGroup",0],["impl Type for NowPlayingItem",0],["impl Type for PlayState",0],["impl Type for SessionInfo",0],["impl Type for AutoplaySettings",0],["impl Type for MediaItem",0],["impl Type for SubtitleTrack",0],["impl Type for QueueManager",0],["impl Type for SleepTimerState",0],["impl Type for JRayActor",0],["impl Type for ArtistItem",0],["impl Type for DownloadDiskUsage",0],["impl Type for Genre",0],["impl Type for GetItemsOptions",0],["impl Type for ImageOptions",0],["impl Type for Library",0],["impl Type for LiveStreamInfo",0],["impl Type for MediaItem",0],["impl Type for MediaSource",0],["impl Type for MediaStream",0],["impl Type for Person",0],["impl Type for PlaybackInfo",0],["impl Type for PlaylistCreatedResult",0],["impl Type for PlaylistEntry",0],["impl Type for SearchOptions",0],["impl Type for SearchResult",0],["impl Type for UserData",0],["impl Type for AudioSettings",0],["impl Type for LibrarySettings",0],["impl Type for VideoSettings",0],["impl Type for ThumbnailCacheStats",0]]]]); + if (window.register_implementors) { + window.register_implementors(implementors); + } else { + window.pending_implementors = implementors; + } +})() +//{"start":59,"fragment_lengths":[22118]} \ No newline at end of file diff --git a/api/trait.impl/tauri_specta/event/trait.Event.js b/api/trait.impl/tauri_specta/event/trait.Event.js new file mode 100644 index 00000000..53b6a25c --- /dev/null +++ b/api/trait.impl/tauri_specta/event/trait.Event.js @@ -0,0 +1,9 @@ +(function() { + const implementors = Object.fromEntries([["jellytau_lib",[["impl Event for PlayerStatusEvent",0]]]]); + if (window.register_implementors) { + window.register_implementors(implementors); + } else { + window.pending_implementors = implementors; + } +})() +//{"start":59,"fragment_lengths":[206]} \ No newline at end of file diff --git a/api/type.impl/alloc/sync/struct.Arc.js b/api/type.impl/alloc/sync/struct.Arc.js new file mode 100644 index 00000000..bdf5f52d --- /dev/null +++ b/api/type.impl/alloc/sync/struct.Arc.js @@ -0,0 +1,9 @@ +(function() { + var type_impls = Object.fromEntries([["jellytau_lib",[["
Source§

impl<T, A> Allocator for Arc<T, A>
where\n T: Allocator + ?Sized,\n A: Allocator,

Source§

fn allocate(&self, layout: Layout) -> Result<NonNull<[u8]>, AllocError>

🔬This is a nightly-only experimental API. (allocator_api)
Attempts to allocate a block of memory. Read more
Source§

fn allocate_zeroed(&self, layout: Layout) -> Result<NonNull<[u8]>, AllocError>

🔬This is a nightly-only experimental API. (allocator_api)
Behaves like allocate, but also ensures that the returned memory is zero-initialized. Read more
Source§

unsafe fn deallocate(&self, ptr: NonNull<u8>, layout: Layout)

🔬This is a nightly-only experimental API. (allocator_api)
Deallocates the memory referenced by ptr. Read more
Source§

unsafe fn grow(\n &self,\n ptr: NonNull<u8>,\n old_layout: Layout,\n new_layout: Layout,\n) -> Result<NonNull<[u8]>, AllocError>

🔬This is a nightly-only experimental API. (allocator_api)
Attempts to extend the memory block. Read more
Source§

unsafe fn grow_zeroed(\n &self,\n ptr: NonNull<u8>,\n old_layout: Layout,\n new_layout: Layout,\n) -> Result<NonNull<[u8]>, AllocError>

🔬This is a nightly-only experimental API. (allocator_api)
Behaves like grow, but also ensures that the new contents are set to zero before being\nreturned. Read more
Source§

unsafe fn shrink(\n &self,\n ptr: NonNull<u8>,\n old_layout: Layout,\n new_layout: Layout,\n) -> Result<NonNull<[u8]>, AllocError>

🔬This is a nightly-only experimental API. (allocator_api)
Attempts to shrink the memory block. Read more
Source§

fn by_ref(&self) -> &Self
where\n Self: Sized,

🔬This is a nightly-only experimental API. (allocator_api)
Creates a “by reference” adapter for this instance of Allocator. Read more
","Allocator","jellytau_lib::player::events::SharedEventEmitter"],["
Source§

impl<T> Arc<T>
where\n T: CloneToUninit + ?Sized,

Source

pub fn clone_from_ref(value: &T) -> Arc<T>

🔬This is a nightly-only experimental API. (clone_from_ref)

Constructs a new Arc<T> with a clone of value.

\n
§Examples
\n
#![feature(clone_from_ref)]\nuse std::sync::Arc;\n\nlet hello: Arc<str> = Arc::clone_from_ref(\"hello\");
Source

pub fn try_clone_from_ref(value: &T) -> Result<Arc<T>, AllocError>

🔬This is a nightly-only experimental API. (clone_from_ref)

Constructs a new Arc<T> with a clone of value, returning an error if allocation fails

\n
§Examples
\n
#![feature(clone_from_ref)]\n#![feature(allocator_api)]\nuse std::sync::Arc;\n\nlet hello: Arc<str> = Arc::try_clone_from_ref(\"hello\")?;
",0,"jellytau_lib::player::events::SharedEventEmitter"],["
Source§

impl<T> Arc<T>
where\n T: ?Sized,

1.17.0 · Source

pub unsafe fn from_raw(ptr: *const T) -> Arc<T>

Constructs an Arc<T> from a raw pointer.

\n

The raw pointer must have been previously returned by a call to\nArc<U>::into_raw or Arc<U>::into_raw_with_allocator.

\n
§Safety
\n
    \n
  • Creating a Arc<T> from a pointer other than one returned from\nArc<U>::into_raw or Arc<U>::into_raw_with_allocator\nis undefined behavior.
  • \n
  • If U is sized, it must have the same size and alignment as T. This\nis trivially true if U is T.
  • \n
  • If U is unsized, its data pointer must have the same size and\nalignment as T. This is trivially true if Arc<U> was constructed\nthrough Arc<T> and then converted to Arc<U> through an unsized\ncoercion.
  • \n
  • Note that if U or U’s data pointer is not T but has the same size\nand alignment, this is basically like transmuting references of\ndifferent types. See mem::transmute for more information\non what restrictions apply in this case.
  • \n
  • The raw pointer must point to a block of memory allocated by the global allocator.
  • \n
  • The user of from_raw has to make sure a specific value of T is only\ndropped once.
  • \n
\n

This function is unsafe because improper use may lead to memory unsafety,\neven if the returned Arc<T> is never accessed.

\n
§Examples
\n
use std::sync::Arc;\n\nlet x = Arc::new(\"hello\".to_owned());\nlet x_ptr = Arc::into_raw(x);\n\nunsafe {\n    // Convert back to an `Arc` to prevent leak.\n    let x = Arc::from_raw(x_ptr);\n    assert_eq!(&*x, \"hello\");\n\n    // Further calls to `Arc::from_raw(x_ptr)` would be memory-unsafe.\n}\n\n// The memory was freed when `x` went out of scope above, so `x_ptr` is now dangling!
\n

Convert a slice back into its original array:

\n\n
use std::sync::Arc;\n\nlet x: Arc<[u32]> = Arc::new([1, 2, 3]);\nlet x_ptr: *const [u32] = Arc::into_raw(x);\n\nunsafe {\n    let x: Arc<[u32; 3]> = Arc::from_raw(x_ptr.cast::<[u32; 3]>());\n    assert_eq!(&*x, &[1, 2, 3]);\n}
1.17.0 · Source

pub fn into_raw(this: Arc<T>) -> *const T

Consumes the Arc, returning the wrapped pointer.

\n

To avoid a memory leak the pointer must be converted back to an Arc using\nArc::from_raw.

\n
§Examples
\n
use std::sync::Arc;\n\nlet x = Arc::new(\"hello\".to_owned());\nlet x_ptr = Arc::into_raw(x);\nassert_eq!(unsafe { &*x_ptr }, \"hello\");
1.51.0 · Source

pub unsafe fn increment_strong_count(ptr: *const T)

Increments the strong reference count on the Arc<T> associated with the\nprovided pointer by one.

\n
§Safety
\n

The pointer must have been obtained through Arc::into_raw and must satisfy the\nsame layout requirements specified in Arc::from_raw_in.\nThe associated Arc instance must be valid (i.e. the strong count must be at\nleast 1) for the duration of this method, and ptr must point to a block of memory\nallocated by the global allocator.

\n
§Examples
\n
use std::sync::Arc;\n\nlet five = Arc::new(5);\n\nunsafe {\n    let ptr = Arc::into_raw(five);\n    Arc::increment_strong_count(ptr);\n\n    // This assertion is deterministic because we haven't shared\n    // the `Arc` between threads.\n    let five = Arc::from_raw(ptr);\n    assert_eq!(2, Arc::strong_count(&five));\n}
1.51.0 · Source

pub unsafe fn decrement_strong_count(ptr: *const T)

Decrements the strong reference count on the Arc<T> associated with the\nprovided pointer by one.

\n
§Safety
\n

The pointer must have been obtained through Arc::into_raw and must satisfy the\nsame layout requirements specified in Arc::from_raw_in.\nThe associated Arc instance must be valid (i.e. the strong count must be at\nleast 1) when invoking this method, and ptr must point to a block of memory\nallocated by the global allocator. This method can be used to release the final\nArc and backing storage, but should not be called after the final Arc has been\nreleased.

\n
§Examples
\n
use std::sync::Arc;\n\nlet five = Arc::new(5);\n\nunsafe {\n    let ptr = Arc::into_raw(five);\n    Arc::increment_strong_count(ptr);\n\n    // Those assertions are deterministic because we haven't shared\n    // the `Arc` between threads.\n    let five = Arc::from_raw(ptr);\n    assert_eq!(2, Arc::strong_count(&five));\n    Arc::decrement_strong_count(ptr);\n    assert_eq!(1, Arc::strong_count(&five));\n}
",0,"jellytau_lib::player::events::SharedEventEmitter"],["
Source§

impl<T> Arc<T>

1.0.0 · Source

pub fn new(data: T) -> Arc<T>

Constructs a new Arc<T>.

\n
§Examples
\n
use std::sync::Arc;\n\nlet five = Arc::new(5);
1.60.0 · Source

pub fn new_cyclic<F>(data_fn: F) -> Arc<T>
where\n F: FnOnce(&Weak<T>) -> T,

Constructs a new Arc<T> while giving you a Weak<T> to the allocation,\nto allow you to construct a T which holds a weak pointer to itself.

\n

Generally, a structure circularly referencing itself, either directly or\nindirectly, should not hold a strong reference to itself to prevent a memory leak.\nUsing this function, you get access to the weak pointer during the\ninitialization of T, before the Arc<T> is created, such that you can\nclone and store it inside the T.

\n

new_cyclic first allocates the managed allocation for the Arc<T>,\nthen calls your closure, giving it a Weak<T> to this allocation,\nand only afterwards completes the construction of the Arc<T> by placing\nthe T returned from your closure into the allocation.

\n

Since the new Arc<T> is not fully-constructed until Arc<T>::new_cyclic\nreturns, calling upgrade on the weak reference inside your closure will\nfail and result in a None value.

\n
§Panics
\n

If data_fn panics, the panic is propagated to the caller, and the\ntemporary Weak<T> is dropped normally.

\n
§Example
\n
use std::sync::{Arc, Weak};\n\nstruct Gadget {\n    me: Weak<Gadget>,\n}\n\nimpl Gadget {\n    /// Constructs a reference counted Gadget.\n    fn new() -> Arc<Self> {\n        // `me` is a `Weak<Gadget>` pointing at the new allocation of the\n        // `Arc` we're constructing.\n        Arc::new_cyclic(|me| {\n            // Create the actual struct here.\n            Gadget { me: me.clone() }\n        })\n    }\n\n    /// Returns a reference counted pointer to Self.\n    fn me(&self) -> Arc<Self> {\n        self.me.upgrade().unwrap()\n    }\n}
1.82.0 · Source

pub fn new_uninit() -> Arc<MaybeUninit<T>>

Constructs a new Arc with uninitialized contents.

\n
§Examples
\n
use std::sync::Arc;\n\nlet mut five = Arc::<u32>::new_uninit();\n\n// Deferred initialization:\nArc::get_mut(&mut five).unwrap().write(5);\n\nlet five = unsafe { five.assume_init() };\n\nassert_eq!(*five, 5)
1.92.0 · Source

pub fn new_zeroed() -> Arc<MaybeUninit<T>>

Constructs a new Arc with uninitialized contents, with the memory\nbeing filled with 0 bytes.

\n

See MaybeUninit::zeroed for examples of correct and incorrect usage\nof this method.

\n
§Examples
\n
use std::sync::Arc;\n\nlet zero = Arc::<u32>::new_zeroed();\nlet zero = unsafe { zero.assume_init() };\n\nassert_eq!(*zero, 0)
1.33.0 · Source

pub fn pin(data: T) -> Pin<Arc<T>>

Constructs a new Pin<Arc<T>>. If T does not implement Unpin, then\ndata will be pinned in memory and unable to be moved.

\n
Source

pub fn try_pin(data: T) -> Result<Pin<Arc<T>>, AllocError>

🔬This is a nightly-only experimental API. (allocator_api)

Constructs a new Pin<Arc<T>>, return an error if allocation fails.

\n
Source

pub fn try_new(data: T) -> Result<Arc<T>, AllocError>

🔬This is a nightly-only experimental API. (allocator_api)

Constructs a new Arc<T>, returning an error if allocation fails.

\n
§Examples
\n
#![feature(allocator_api)]\nuse std::sync::Arc;\n\nlet five = Arc::try_new(5)?;
Source

pub fn try_new_uninit() -> Result<Arc<MaybeUninit<T>>, AllocError>

🔬This is a nightly-only experimental API. (allocator_api)

Constructs a new Arc with uninitialized contents, returning an error\nif allocation fails.

\n
§Examples
\n
#![feature(allocator_api)]\n\nuse std::sync::Arc;\n\nlet mut five = Arc::<u32>::try_new_uninit()?;\n\n// Deferred initialization:\nArc::get_mut(&mut five).unwrap().write(5);\n\nlet five = unsafe { five.assume_init() };\n\nassert_eq!(*five, 5);
Source

pub fn try_new_zeroed() -> Result<Arc<MaybeUninit<T>>, AllocError>

🔬This is a nightly-only experimental API. (allocator_api)

Constructs a new Arc with uninitialized contents, with the memory\nbeing filled with 0 bytes, returning an error if allocation fails.

\n

See MaybeUninit::zeroed for examples of correct and incorrect usage\nof this method.

\n
§Examples
\n
#![feature( allocator_api)]\n\nuse std::sync::Arc;\n\nlet zero = Arc::<u32>::try_new_zeroed()?;\nlet zero = unsafe { zero.assume_init() };\n\nassert_eq!(*zero, 0);
Source

pub fn map<U>(this: Arc<T>, f: impl FnOnce(&T) -> U) -> Arc<U>

🔬This is a nightly-only experimental API. (smart_pointer_try_map)

Maps the value in an Arc, reusing the allocation if possible.

\n

f is called on a reference to the value in the Arc, and the result is returned, also in\nan Arc.

\n

Note: this is an associated function, which means that you have\nto call it as Arc::map(a, f) instead of r.map(a). This\nis so that there is no conflict with a method on the inner type.

\n
§Examples
\n
#![feature(smart_pointer_try_map)]\n\nuse std::sync::Arc;\n\nlet r = Arc::new(7);\nlet new = Arc::map(r, |i| i + 7);\nassert_eq!(*new, 14);
Source

pub fn try_map<R>(\n this: Arc<T>,\n f: impl FnOnce(&T) -> R,\n) -> <<R as Try>::Residual as Residual<Arc<<R as Try>::Output>>>::TryType
where\n R: Try,\n <R as Try>::Residual: Residual<Arc<<R as Try>::Output>>,

🔬This is a nightly-only experimental API. (smart_pointer_try_map)

Attempts to map the value in an Arc, reusing the allocation if possible.

\n

f is called on a reference to the value in the Arc, and if the operation succeeds, the\nresult is returned, also in an Arc.

\n

Note: this is an associated function, which means that you have\nto call it as Arc::try_map(a, f) instead of a.try_map(f). This\nis so that there is no conflict with a method on the inner type.

\n
§Examples
\n
#![feature(smart_pointer_try_map)]\n\nuse std::sync::Arc;\n\nlet b = Arc::new(7);\nlet new = Arc::try_map(b, |&i| u32::try_from(i)).unwrap();\nassert_eq!(*new, 7);
",0,"jellytau_lib::player::events::SharedEventEmitter"],["
Source§

impl<T, A> Arc<T, A>
where\n T: CloneToUninit + ?Sized,\n A: Allocator,

Source

pub fn clone_from_ref_in(value: &T, alloc: A) -> Arc<T, A>

🔬This is a nightly-only experimental API. (clone_from_ref)

Constructs a new Arc<T> with a clone of value in the provided allocator.

\n
§Examples
\n
#![feature(clone_from_ref)]\n#![feature(allocator_api)]\nuse std::sync::Arc;\nuse std::alloc::System;\n\nlet hello: Arc<str, System> = Arc::clone_from_ref_in(\"hello\", System);
Source

pub fn try_clone_from_ref_in(\n value: &T,\n alloc: A,\n) -> Result<Arc<T, A>, AllocError>

🔬This is a nightly-only experimental API. (clone_from_ref)

Constructs a new Arc<T> with a clone of value in the provided allocator, returning an error if allocation fails

\n
§Examples
\n
#![feature(clone_from_ref)]\n#![feature(allocator_api)]\nuse std::sync::Arc;\nuse std::alloc::System;\n\nlet hello: Arc<str, System> = Arc::try_clone_from_ref_in(\"hello\", System)?;
",0,"jellytau_lib::player::events::SharedEventEmitter"],["
Source§

impl<T, A> Arc<T, A>
where\n A: Allocator,\n T: ?Sized,

Source

pub fn allocator(this: &Arc<T, A>) -> &A

🔬This is a nightly-only experimental API. (allocator_api)

Returns a reference to the underlying allocator.

\n

Note: this is an associated function, which means that you have\nto call it as Arc::allocator(&a) instead of a.allocator(). This\nis so that there is no conflict with a method on the inner type.

\n
Source

pub fn into_raw_with_allocator(this: Arc<T, A>) -> (*const T, A)

🔬This is a nightly-only experimental API. (allocator_api)

Consumes the Arc, returning the wrapped pointer and allocator.

\n

To avoid a memory leak the pointer must be converted back to an Arc using\nArc::from_raw_in.

\n
§Examples
\n
#![feature(allocator_api)]\nuse std::sync::Arc;\nuse std::alloc::System;\n\nlet x = Arc::new_in(\"hello\".to_owned(), System);\nlet (ptr, alloc) = Arc::into_raw_with_allocator(x);\nassert_eq!(unsafe { &*ptr }, \"hello\");\nlet x = unsafe { Arc::from_raw_in(ptr, alloc) };\nassert_eq!(&*x, \"hello\");
1.45.0 · Source

pub fn as_ptr(this: &Arc<T, A>) -> *const T

Provides a raw pointer to the data.

\n

The counts are not affected in any way and the Arc is not consumed. The pointer is valid for\nas long as there are strong counts in the Arc.

\n
§Examples
\n
use std::sync::Arc;\n\nlet x = Arc::new(\"hello\".to_owned());\nlet y = Arc::clone(&x);\nlet x_ptr = Arc::as_ptr(&x);\nassert_eq!(x_ptr, Arc::as_ptr(&y));\nassert_eq!(unsafe { &*x_ptr }, \"hello\");
Source

pub unsafe fn from_raw_in(ptr: *const T, alloc: A) -> Arc<T, A>

🔬This is a nightly-only experimental API. (allocator_api)

Constructs an Arc<T, A> from a raw pointer.

\n

The raw pointer must have been previously returned by a call to Arc<U, A>::into_raw or Arc<U, A>::into_raw_with_allocator.

\n
§Safety
\n
    \n
  • Creating a Arc<T, A> from a pointer other than one returned from\nArc<U, A>::into_raw or Arc<U, A>::into_raw_with_allocator\nis undefined behavior.
  • \n
  • If U is sized, it must have the same size and alignment as T. This\nis trivially true if U is T.
  • \n
  • If U is unsized, its data pointer must have the same size and\nalignment as T. This is trivially true if Arc<U, A> was constructed\nthrough Arc<T, A> and then converted to Arc<U, A> through an unsized\ncoercion.
  • \n
  • Note that if U or U’s data pointer is not T but has the same size\nand alignment, this is basically like transmuting references of\ndifferent types. See mem::transmute for more information\non what restrictions apply in this case.
  • \n
  • The raw pointer must point to a block of memory allocated by alloc
  • \n
  • The user of from_raw has to make sure a specific value of T is only\ndropped once.
  • \n
\n

This function is unsafe because improper use may lead to memory unsafety,\neven if the returned Arc<T> is never accessed.

\n
§Examples
\n
#![feature(allocator_api)]\n\nuse std::sync::Arc;\nuse std::alloc::System;\n\nlet x = Arc::new_in(\"hello\".to_owned(), System);\nlet (x_ptr, alloc) = Arc::into_raw_with_allocator(x);\n\nunsafe {\n    // Convert back to an `Arc` to prevent leak.\n    let x = Arc::from_raw_in(x_ptr, System);\n    assert_eq!(&*x, \"hello\");\n\n    // Further calls to `Arc::from_raw(x_ptr)` would be memory-unsafe.\n}\n\n// The memory was freed when `x` went out of scope above, so `x_ptr` is now dangling!
\n

Convert a slice back into its original array:

\n\n
#![feature(allocator_api)]\n\nuse std::sync::Arc;\nuse std::alloc::System;\n\nlet x: Arc<[u32], _> = Arc::new_in([1, 2, 3], System);\nlet x_ptr: *const [u32] = Arc::into_raw_with_allocator(x).0;\n\nunsafe {\n    let x: Arc<[u32; 3], _> = Arc::from_raw_in(x_ptr.cast::<[u32; 3]>(), System);\n    assert_eq!(&*x, &[1, 2, 3]);\n}
1.4.0 · Source

pub fn downgrade(this: &Arc<T, A>) -> Weak<T, A>
where\n A: Clone,

Creates a new Weak pointer to this allocation.

\n
§Examples
\n
use std::sync::Arc;\n\nlet five = Arc::new(5);\n\nlet weak_five = Arc::downgrade(&five);
1.15.0 · Source

pub fn weak_count(this: &Arc<T, A>) -> usize

Gets the number of Weak pointers to this allocation.

\n
§Safety
\n

This method by itself is safe, but using it correctly requires extra care.\nAnother thread can change the weak count at any time,\nincluding potentially between calling this method and acting on the result.

\n
§Examples
\n
use std::sync::Arc;\n\nlet five = Arc::new(5);\nlet _weak_five = Arc::downgrade(&five);\n\n// This assertion is deterministic because we haven't shared\n// the `Arc` or `Weak` between threads.\nassert_eq!(1, Arc::weak_count(&five));
1.15.0 · Source

pub fn strong_count(this: &Arc<T, A>) -> usize

Gets the number of strong (Arc) pointers to this allocation.

\n
§Safety
\n

This method by itself is safe, but using it correctly requires extra care.\nAnother thread can change the strong count at any time,\nincluding potentially between calling this method and acting on the result.

\n
§Examples
\n
use std::sync::Arc;\n\nlet five = Arc::new(5);\nlet _also_five = Arc::clone(&five);\n\n// This assertion is deterministic because we haven't shared\n// the `Arc` between threads.\nassert_eq!(2, Arc::strong_count(&five));
Source

pub unsafe fn increment_strong_count_in(ptr: *const T, alloc: A)
where\n A: Clone,

🔬This is a nightly-only experimental API. (allocator_api)

Increments the strong reference count on the Arc<T> associated with the\nprovided pointer by one.

\n
§Safety
\n

The pointer must have been obtained through Arc::into_raw and must satisfy the\nsame layout requirements specified in Arc::from_raw_in.\nThe associated Arc instance must be valid (i.e. the strong count must be at\nleast 1) for the duration of this method, and ptr must point to a block of memory\nallocated by alloc.

\n
§Examples
\n
#![feature(allocator_api)]\n\nuse std::sync::Arc;\nuse std::alloc::System;\n\nlet five = Arc::new_in(5, System);\n\nunsafe {\n    let (ptr, _alloc) = Arc::into_raw_with_allocator(five);\n    Arc::increment_strong_count_in(ptr, System);\n\n    // This assertion is deterministic because we haven't shared\n    // the `Arc` between threads.\n    let five = Arc::from_raw_in(ptr, System);\n    assert_eq!(2, Arc::strong_count(&five));\n}
Source

pub unsafe fn decrement_strong_count_in(ptr: *const T, alloc: A)

🔬This is a nightly-only experimental API. (allocator_api)

Decrements the strong reference count on the Arc<T> associated with the\nprovided pointer by one.

\n
§Safety
\n

The pointer must have been obtained through Arc::into_raw and must satisfy the\nsame layout requirements specified in Arc::from_raw_in.\nThe associated Arc instance must be valid (i.e. the strong count must be at\nleast 1) when invoking this method, and ptr must point to a block of memory\nallocated by alloc. This method can be used to release the final\nArc and backing storage, but should not be called after the final Arc has been\nreleased.

\n
§Examples
\n
#![feature(allocator_api)]\n\nuse std::sync::Arc;\nuse std::alloc::System;\n\nlet five = Arc::new_in(5, System);\n\nunsafe {\n    let (ptr, _alloc) = Arc::into_raw_with_allocator(five);\n    Arc::increment_strong_count_in(ptr, System);\n\n    // Those assertions are deterministic because we haven't shared\n    // the `Arc` between threads.\n    let five = Arc::from_raw_in(ptr, System);\n    assert_eq!(2, Arc::strong_count(&five));\n    Arc::decrement_strong_count_in(ptr, System);\n    assert_eq!(1, Arc::strong_count(&five));\n}
1.17.0 · Source

pub fn ptr_eq(this: &Arc<T, A>, other: &Arc<T, A>) -> bool

Returns true if the two Arcs point to the same allocation in a vein similar to\nptr::eq. This function ignores the metadata of dyn Trait pointers.

\n
§Examples
\n
use std::sync::Arc;\n\nlet five = Arc::new(5);\nlet same_five = Arc::clone(&five);\nlet other_five = Arc::new(5);\n\nassert!(Arc::ptr_eq(&five, &same_five));\nassert!(!Arc::ptr_eq(&five, &other_five));
",0,"jellytau_lib::player::events::SharedEventEmitter"],["
Source§

impl<T, A> Arc<T, A>
where\n T: CloneToUninit + ?Sized,\n A: Allocator + Clone,

1.4.0 · Source

pub fn make_mut(this: &mut Arc<T, A>) -> &mut T

Makes a mutable reference into the given Arc.

\n

If there are other Arc pointers to the same allocation, then make_mut will\nclone the inner value to a new allocation to ensure unique ownership. This is also\nreferred to as clone-on-write.

\n

However, if there are no other Arc pointers to this allocation, but some Weak\npointers, then the Weak pointers will be dissociated and the inner value will not\nbe cloned.

\n

See also get_mut, which will fail rather than cloning the inner value\nor dissociating Weak pointers.

\n
§Examples
\n
use std::sync::Arc;\n\nlet mut data = Arc::new(5);\n\n*Arc::make_mut(&mut data) += 1;         // Won't clone anything\nlet mut other_data = Arc::clone(&data); // Won't clone inner data\n*Arc::make_mut(&mut data) += 1;         // Clones inner data\n*Arc::make_mut(&mut data) += 1;         // Won't clone anything\n*Arc::make_mut(&mut other_data) *= 2;   // Won't clone anything\n\n// Now `data` and `other_data` point to different allocations.\nassert_eq!(*data, 8);\nassert_eq!(*other_data, 12);
\n

Weak pointers will be dissociated:

\n\n
use std::sync::Arc;\n\nlet mut data = Arc::new(75);\nlet weak = Arc::downgrade(&data);\n\nassert!(75 == *data);\nassert!(75 == *weak.upgrade().unwrap());\n\n*Arc::make_mut(&mut data) += 1;\n\nassert!(76 == *data);\nassert!(weak.upgrade().is_none());
",0,"jellytau_lib::player::events::SharedEventEmitter"],["
Source§

impl<T, A> Arc<T, A>
where\n T: Clone,\n A: Allocator,

1.76.0 · Source

pub fn unwrap_or_clone(this: Arc<T, A>) -> T

If we have the only reference to T then unwrap it. Otherwise, clone T and return the\nclone.

\n

Assuming arc_t is of type Arc<T>, this function is functionally equivalent to\n(*arc_t).clone(), but will avoid cloning the inner value where possible.

\n
§Examples
\n
let inner = String::from(\"test\");\nlet ptr = inner.as_ptr();\n\nlet arc = Arc::new(inner);\nlet inner = Arc::unwrap_or_clone(arc);\n// The inner value was not cloned\nassert!(ptr::eq(ptr, inner.as_ptr()));\n\nlet arc = Arc::new(inner);\nlet arc2 = arc.clone();\nlet inner = Arc::unwrap_or_clone(arc);\n// Because there were 2 references, we had to clone the inner value.\nassert!(!ptr::eq(ptr, inner.as_ptr()));\n// `arc2` is the last reference, so when we unwrap it we get back\n// the original `String`.\nlet inner = Arc::unwrap_or_clone(arc2);\nassert!(ptr::eq(ptr, inner.as_ptr()));
",0,"jellytau_lib::player::events::SharedEventEmitter"],["
Source§

impl<T, A> Arc<T, A>
where\n A: Allocator,\n T: ?Sized,

1.4.0 · Source

pub fn get_mut(this: &mut Arc<T, A>) -> Option<&mut T>

Returns a mutable reference into the given Arc, if there are\nno other Arc or Weak pointers to the same allocation.

\n

Returns None otherwise, because it is not safe to\nmutate a shared value.

\n

See also make_mut, which will clone\nthe inner value when there are other Arc pointers.

\n
§Examples
\n
use std::sync::Arc;\n\nlet mut x = Arc::new(3);\n*Arc::get_mut(&mut x).unwrap() = 4;\nassert_eq!(*x, 4);\n\nlet _y = Arc::clone(&x);\nassert!(Arc::get_mut(&mut x).is_none());
Source

pub unsafe fn get_mut_unchecked(this: &mut Arc<T, A>) -> &mut T

🔬This is a nightly-only experimental API. (get_mut_unchecked)

Returns a mutable reference into the given Arc,\nwithout any check.

\n

See also get_mut, which is safe and does appropriate checks.

\n
§Safety
\n

If any other Arc or Weak pointers to the same allocation exist, then\nthey must not be dereferenced or have active borrows for the duration\nof the returned borrow, and their inner type must be exactly the same as the\ninner type of this Arc (including lifetimes). This is trivially the case if no\nsuch pointers exist, for example immediately after Arc::new.

\n
§Examples
\n
#![feature(get_mut_unchecked)]\n\nuse std::sync::Arc;\n\nlet mut x = Arc::new(String::new());\nunsafe {\n    Arc::get_mut_unchecked(&mut x).push_str(\"foo\")\n}\nassert_eq!(*x, \"foo\");
\n

Other Arc pointers to the same allocation must be to the same type.

\n\n
#![feature(get_mut_unchecked)]\n\nuse std::sync::Arc;\n\nlet x: Arc<str> = Arc::from(\"Hello, world!\");\nlet mut y: Arc<[u8]> = x.clone().into();\nunsafe {\n    // this is Undefined Behavior, because x's inner type is str, not [u8]\n    Arc::get_mut_unchecked(&mut y).fill(0xff); // 0xff is invalid in UTF-8\n}\nprintln!(\"{}\", &*x); // Invalid UTF-8 in a str
\n

Other Arc pointers to the same allocation must be to the exact same type, including lifetimes.

\n\n
#![feature(get_mut_unchecked)]\n\nuse std::sync::Arc;\n\nlet x: Arc<&str> = Arc::new(\"Hello, world!\");\n{\n    let s = String::from(\"Oh, no!\");\n    let mut y: Arc<&str> = x.clone();\n    unsafe {\n        // this is Undefined Behavior, because x's inner type\n        // is &'long str, not &'short str\n        *Arc::get_mut_unchecked(&mut y) = &s;\n    }\n}\nprintln!(\"{}\", &*x); // Use-after-free
Source

pub fn is_unique(this: &Arc<T, A>) -> bool

🔬This is a nightly-only experimental API. (arc_is_unique)

Determine whether this is the unique reference to the underlying data.

\n

Returns true if there are no other Arc or Weak pointers to the same allocation;\nreturns false otherwise.

\n

If this function returns true, then is guaranteed to be safe to call get_mut_unchecked\non this Arc, so long as no clones occur in between.

\n
§Examples
\n
#![feature(arc_is_unique)]\n\nuse std::sync::Arc;\n\nlet x = Arc::new(3);\nassert!(Arc::is_unique(&x));\n\nlet y = Arc::clone(&x);\nassert!(!Arc::is_unique(&x));\ndrop(y);\n\n// Weak references also count, because they could be upgraded at any time.\nlet z = Arc::downgrade(&x);\nassert!(!Arc::is_unique(&x));
§Pointer invalidation
\n

This function will always return the same value as Arc::get_mut(arc).is_some(). However,\nunlike that operation it does not produce any mutable references to the underlying data,\nmeaning no pointers to the data inside the Arc are invalidated by the call. Thus, the\nfollowing code is valid, even though it would be UB if it used Arc::get_mut:

\n\n
#![feature(arc_is_unique)]\n\nuse std::sync::Arc;\n\nlet arc = Arc::new(5);\nlet pointer: *const i32 = &*arc;\nassert!(Arc::is_unique(&arc));\nassert_eq!(unsafe { *pointer }, 5);
§Atomic orderings
\n

Concurrent drops to other Arc pointers to the same allocation will synchronize with this\ncall - that is, this call performs an Acquire operation on the underlying strong and weak\nref counts. This ensures that calling get_mut_unchecked is safe.

\n

Note that this operation requires locking the weak ref count, so concurrent calls to\ndowngrade may spin-loop for a short period of time.

\n
",0,"jellytau_lib::player::events::SharedEventEmitter"],["
Source§

impl<T, A> Arc<T, A>
where\n A: Allocator,

Source

pub fn new_in(data: T, alloc: A) -> Arc<T, A>

🔬This is a nightly-only experimental API. (allocator_api)

Constructs a new Arc<T> in the provided allocator.

\n
§Examples
\n
#![feature(allocator_api)]\n\nuse std::sync::Arc;\nuse std::alloc::System;\n\nlet five = Arc::new_in(5, System);
Source

pub fn new_uninit_in(alloc: A) -> Arc<MaybeUninit<T>, A>

🔬This is a nightly-only experimental API. (allocator_api)

Constructs a new Arc with uninitialized contents in the provided allocator.

\n
§Examples
\n
#![feature(get_mut_unchecked)]\n#![feature(allocator_api)]\n\nuse std::sync::Arc;\nuse std::alloc::System;\n\nlet mut five = Arc::<u32, _>::new_uninit_in(System);\n\nlet five = unsafe {\n    // Deferred initialization:\n    Arc::get_mut_unchecked(&mut five).as_mut_ptr().write(5);\n\n    five.assume_init()\n};\n\nassert_eq!(*five, 5)
Source

pub fn new_zeroed_in(alloc: A) -> Arc<MaybeUninit<T>, A>

🔬This is a nightly-only experimental API. (allocator_api)

Constructs a new Arc with uninitialized contents, with the memory\nbeing filled with 0 bytes, in the provided allocator.

\n

See MaybeUninit::zeroed for examples of correct and incorrect usage\nof this method.

\n
§Examples
\n
#![feature(allocator_api)]\n\nuse std::sync::Arc;\nuse std::alloc::System;\n\nlet zero = Arc::<u32, _>::new_zeroed_in(System);\nlet zero = unsafe { zero.assume_init() };\n\nassert_eq!(*zero, 0)
Source

pub fn new_cyclic_in<F>(data_fn: F, alloc: A) -> Arc<T, A>
where\n F: FnOnce(&Weak<T, A>) -> T,

🔬This is a nightly-only experimental API. (allocator_api)

Constructs a new Arc<T, A> in the given allocator while giving you a Weak<T, A> to the allocation,\nto allow you to construct a T which holds a weak pointer to itself.

\n

Generally, a structure circularly referencing itself, either directly or\nindirectly, should not hold a strong reference to itself to prevent a memory leak.\nUsing this function, you get access to the weak pointer during the\ninitialization of T, before the Arc<T, A> is created, such that you can\nclone and store it inside the T.

\n

new_cyclic_in first allocates the managed allocation for the Arc<T, A>,\nthen calls your closure, giving it a Weak<T, A> to this allocation,\nand only afterwards completes the construction of the Arc<T, A> by placing\nthe T returned from your closure into the allocation.

\n

Since the new Arc<T, A> is not fully-constructed until Arc<T, A>::new_cyclic_in\nreturns, calling upgrade on the weak reference inside your closure will\nfail and result in a None value.

\n
§Panics
\n

If data_fn panics, the panic is propagated to the caller, and the\ntemporary Weak<T> is dropped normally.

\n
§Example
\n

See new_cyclic

\n
Source

pub fn pin_in(data: T, alloc: A) -> Pin<Arc<T, A>>
where\n A: 'static,

🔬This is a nightly-only experimental API. (allocator_api)

Constructs a new Pin<Arc<T, A>> in the provided allocator. If T does not implement Unpin,\nthen data will be pinned in memory and unable to be moved.

\n
Source

pub fn try_pin_in(data: T, alloc: A) -> Result<Pin<Arc<T, A>>, AllocError>
where\n A: 'static,

🔬This is a nightly-only experimental API. (allocator_api)

Constructs a new Pin<Arc<T, A>> in the provided allocator, return an error if allocation\nfails.

\n
Source

pub fn try_new_in(data: T, alloc: A) -> Result<Arc<T, A>, AllocError>

🔬This is a nightly-only experimental API. (allocator_api)

Constructs a new Arc<T, A> in the provided allocator, returning an error if allocation fails.

\n
§Examples
\n
#![feature(allocator_api)]\n\nuse std::sync::Arc;\nuse std::alloc::System;\n\nlet five = Arc::try_new_in(5, System)?;
Source

pub fn try_new_uninit_in(alloc: A) -> Result<Arc<MaybeUninit<T>, A>, AllocError>

🔬This is a nightly-only experimental API. (allocator_api)

Constructs a new Arc with uninitialized contents, in the provided allocator, returning an\nerror if allocation fails.

\n
§Examples
\n
#![feature(allocator_api)]\n#![feature(get_mut_unchecked)]\n\nuse std::sync::Arc;\nuse std::alloc::System;\n\nlet mut five = Arc::<u32, _>::try_new_uninit_in(System)?;\n\nlet five = unsafe {\n    // Deferred initialization:\n    Arc::get_mut_unchecked(&mut five).as_mut_ptr().write(5);\n\n    five.assume_init()\n};\n\nassert_eq!(*five, 5);
Source

pub fn try_new_zeroed_in(alloc: A) -> Result<Arc<MaybeUninit<T>, A>, AllocError>

🔬This is a nightly-only experimental API. (allocator_api)

Constructs a new Arc with uninitialized contents, with the memory\nbeing filled with 0 bytes, in the provided allocator, returning an error if allocation\nfails.

\n

See MaybeUninit::zeroed for examples of correct and incorrect usage\nof this method.

\n
§Examples
\n
#![feature(allocator_api)]\n\nuse std::sync::Arc;\nuse std::alloc::System;\n\nlet zero = Arc::<u32, _>::try_new_zeroed_in(System)?;\nlet zero = unsafe { zero.assume_init() };\n\nassert_eq!(*zero, 0);
1.4.0 · Source

pub fn try_unwrap(this: Arc<T, A>) -> Result<T, Arc<T, A>>

Returns the inner value, if the Arc has exactly one strong reference.

\n

Otherwise, an Err is returned with the same Arc that was\npassed in.

\n

This will succeed even if there are outstanding weak references.

\n

It is strongly recommended to use Arc::into_inner instead if you don’t\nkeep the Arc in the Err case.\nImmediately dropping the Err-value, as the expression\nArc::try_unwrap(this).ok() does, can cause the strong count to\ndrop to zero and the inner value of the Arc to be dropped.\nFor instance, if two threads execute such an expression in parallel,\nthere is a race condition without the possibility of unsafety:\nThe threads could first both check whether they own the last instance\nin Arc::try_unwrap, determine that they both do not, and then both\ndiscard and drop their instance in the call to ok.\nIn this scenario, the value inside the Arc is safely destroyed\nby exactly one of the threads, but neither thread will ever be able\nto use the value.

\n
§Examples
\n
use std::sync::Arc;\n\nlet x = Arc::new(3);\nassert_eq!(Arc::try_unwrap(x), Ok(3));\n\nlet x = Arc::new(4);\nlet _y = Arc::clone(&x);\nassert_eq!(*Arc::try_unwrap(x).unwrap_err(), 4);
1.70.0 · Source

pub fn into_inner(this: Arc<T, A>) -> Option<T>

Returns the inner value, if the Arc has exactly one strong reference.

\n

Otherwise, None is returned and the Arc is dropped.

\n

This will succeed even if there are outstanding weak references.

\n

If Arc::into_inner is called on every clone of this Arc,\nit is guaranteed that exactly one of the calls returns the inner value.\nThis means in particular that the inner value is not dropped.

\n

Arc::try_unwrap is conceptually similar to Arc::into_inner, but it\nis meant for different use-cases. If used as a direct replacement\nfor Arc::into_inner anyway, such as with the expression\nArc::try_unwrap(this).ok(), then it does\nnot give the same guarantee as described in the previous paragraph.\nFor more information, see the examples below and read the documentation\nof Arc::try_unwrap.

\n
§Examples
\n

Minimal example demonstrating the guarantee that Arc::into_inner gives.

\n\n
use std::sync::Arc;\n\nlet x = Arc::new(3);\nlet y = Arc::clone(&x);\n\n// Two threads calling `Arc::into_inner` on both clones of an `Arc`:\nlet x_thread = std::thread::spawn(|| Arc::into_inner(x));\nlet y_thread = std::thread::spawn(|| Arc::into_inner(y));\n\nlet x_inner_value = x_thread.join().unwrap();\nlet y_inner_value = y_thread.join().unwrap();\n\n// One of the threads is guaranteed to receive the inner value:\nassert!(matches!(\n    (x_inner_value, y_inner_value),\n    (None, Some(3)) | (Some(3), None)\n));\n// The result could also be `(None, None)` if the threads called\n// `Arc::try_unwrap(x).ok()` and `Arc::try_unwrap(y).ok()` instead.
\n

A more practical example demonstrating the need for Arc::into_inner:

\n\n
use std::sync::Arc;\n\n// Definition of a simple singly linked list using `Arc`:\n#[derive(Clone)]\nstruct LinkedList<T>(Option<Arc<Node<T>>>);\nstruct Node<T>(T, Option<Arc<Node<T>>>);\n\n// Dropping a long `LinkedList<T>` relying on the destructor of `Arc`\n// can cause a stack overflow. To prevent this, we can provide a\n// manual `Drop` implementation that does the destruction in a loop:\nimpl<T> Drop for LinkedList<T> {\n    fn drop(&mut self) {\n        let mut link = self.0.take();\n        while let Some(arc_node) = link.take() {\n            if let Some(Node(_value, next)) = Arc::into_inner(arc_node) {\n                link = next;\n            }\n        }\n    }\n}\n\n// Implementation of `new` and `push` omitted\nimpl<T> LinkedList<T> {\n    /* ... */\n}\n\n// The following code could have still caused a stack overflow\n// despite the manual `Drop` impl if that `Drop` impl had used\n// `Arc::try_unwrap(arc).ok()` instead of `Arc::into_inner(arc)`.\n\n// Create a long list and clone it\nlet mut x = LinkedList::new();\nlet size = 100000;\nfor i in 0..size {\n    x.push(i); // Adds i to the front of x\n}\nlet y = x.clone();\n\n// Drop the clones in parallel\nlet x_thread = std::thread::spawn(|| drop(x));\nlet y_thread = std::thread::spawn(|| drop(y));\nx_thread.join().unwrap();\ny_thread.join().unwrap();
",0,"jellytau_lib::player::events::SharedEventEmitter"],["
§

impl<T> Arg for Arc<T>
where\n T: Arg,

§

const ARG_TYPE: ArgType = T::ARG_TYPE

The corresponding D-Bus argument type code.
§

fn signature() -> Signature<'static>

The corresponding D-Bus type signature for this type.
","Arg","jellytau_lib::player::events::SharedEventEmitter"],["
1.64.0 · Source§

impl<T> AsFd for Arc<T>
where\n T: AsFd + ?Sized,

This impl allows implementing traits that require AsFd on Arc.

\n
\n
use std::net::UdpSocket;\nuse std::sync::Arc;\n\ntrait MyTrait: AsFd {}\nimpl MyTrait for Arc<UdpSocket> {}\nimpl MyTrait for Box<UdpSocket> {}
Source§

fn as_fd(&self) -> BorrowedFd<'_>

Borrows the file descriptor. Read more
","AsFd","jellytau_lib::player::events::SharedEventEmitter"],["
1.63.0 · Source§

impl<T> AsRawFd for Arc<T>
where\n T: AsRawFd,

This impl allows implementing traits that require AsRawFd on Arc.

\n
\n
use std::net::UdpSocket;\nuse std::sync::Arc;\ntrait MyTrait: AsRawFd {\n}\nimpl MyTrait for Arc<UdpSocket> {}\nimpl MyTrait for Box<UdpSocket> {}
Source§

fn as_raw_fd(&self) -> i32

Extracts the raw file descriptor. Read more
","AsRawFd","jellytau_lib::player::events::SharedEventEmitter"],["
1.5.0 · Source§

impl<T, A> AsRef<T> for Arc<T, A>
where\n A: Allocator,\n T: ?Sized,

Source§

fn as_ref(&self) -> &T

Converts this type into a shared reference of the (usually inferred) input type.
","AsRef","jellytau_lib::player::events::SharedEventEmitter"],["
1.0.0 · Source§

impl<T, A> Borrow<T> for Arc<T, A>
where\n A: Allocator,\n T: ?Sized,

Source§

fn borrow(&self) -> &T

Immutably borrows from an owned value. Read more
","Borrow","jellytau_lib::player::events::SharedEventEmitter"],["
1.0.0 · Source§

impl<T, A> Clone for Arc<T, A>
where\n A: Allocator + Clone,\n T: ?Sized,

Source§

fn clone(&self) -> Arc<T, A>

Makes a clone of the Arc pointer.

\n

This creates another pointer to the same allocation, increasing the\nstrong reference count.

\n
§Examples
\n
use std::sync::Arc;\n\nlet five = Arc::new(5);\n\nlet _ = Arc::clone(&five);
1.0.0 (const: unstable) · Source§

fn clone_from(&mut self, source: &Self)

Performs copy-assignment from source. Read more
","Clone","jellytau_lib::player::events::SharedEventEmitter"],["
1.0.0 · Source§

impl<T, A> Debug for Arc<T, A>
where\n T: Debug + ?Sized,\n A: Allocator,

Source§

fn fmt(&self, f: &mut Formatter<'_>) -> Result<(), Error>

Formats the value using the given formatter. Read more
","Debug","jellytau_lib::player::events::SharedEventEmitter"],["
1.0.0 · Source§

impl<T> Default for Arc<T>
where\n T: Default,

Available on non-no_global_oom_handling only.
Source§

fn default() -> Arc<T>

Creates a new Arc<T>, with the Default value for T.

\n
§Examples
\n
use std::sync::Arc;\n\nlet x: Arc<i32> = Default::default();\nassert_eq!(*x, 0);
","Default","jellytau_lib::player::events::SharedEventEmitter"],["
1.0.0 · Source§

impl<T, A> Deref for Arc<T, A>
where\n A: Allocator,\n T: ?Sized,

Source§

type Target = T

The resulting type after dereferencing.
Source§

fn deref(&self) -> &T

Dereferences the value.
","Deref","jellytau_lib::player::events::SharedEventEmitter"],["
Source§

impl<'de, T> Deserialize<'de> for Arc<T>
where\n Box<T>: Deserialize<'de>,\n T: ?Sized,

Available on crate feature rc and (crate features std or alloc) only.

This impl requires the \"rc\" Cargo feature of Serde.

\n

Deserializing a data structure containing Arc will not attempt to\ndeduplicate Arc references to the same data. Every deserialized Arc\nwill end up with a strong count of 1.

\n
Source§

fn deserialize<D>(\n deserializer: D,\n) -> Result<Arc<T>, <D as Deserializer<'de>>::Error>
where\n D: Deserializer<'de>,

Deserialize this value from the given Serde deserializer. Read more
","Deserialize<'de>","jellytau_lib::player::events::SharedEventEmitter"],["
Source§

impl<'de, T, U> DeserializeAs<'de, Arc<T>> for Arc<U>
where\n U: DeserializeAs<'de, T>,

Available on crate feature alloc and target_has_atomic=ptr only.
Source§

fn deserialize_as<D>(\n deserializer: D,\n) -> Result<Arc<T>, <D as Deserializer<'de>>::Error>
where\n D: Deserializer<'de>,

Deserialize this value from the given Serde deserializer.
","DeserializeAs<'de, Arc>","jellytau_lib::player::events::SharedEventEmitter"],["
1.0.0 · Source§

impl<T, A> Display for Arc<T, A>
where\n T: Display + ?Sized,\n A: Allocator,

Source§

fn fmt(&self, f: &mut Formatter<'_>) -> Result<(), Error>

Formats the value using the given formatter. Read more
","Display","jellytau_lib::player::events::SharedEventEmitter"],["
§

impl<T> Downgrade for Arc<T>

§

type Weak = Weak<T>

Weak reference type.
§

fn downgrade(&self) -> <Arc<T> as Downgrade>::Weak

Downgrade to a weak reference.
","Downgrade","jellytau_lib::player::events::SharedEventEmitter"],["
1.0.0 · Source§

impl<T, A> Drop for Arc<T, A>
where\n A: Allocator,\n T: ?Sized,

Source§

fn drop(&mut self)

Drops the Arc.

\n

This will decrement the strong reference count. If the strong reference\ncount reaches zero then the only other references (if any) are\nWeak, so we drop the inner value.

\n
§Examples
\n
use std::sync::Arc;\n\nstruct Foo;\n\nimpl Drop for Foo {\n    fn drop(&mut self) {\n        println!(\"dropped!\");\n    }\n}\n\nlet foo  = Arc::new(Foo);\nlet foo2 = Arc::clone(&foo);\n\ndrop(foo);    // Doesn't print anything\ndrop(foo2);   // Prints \"dropped!\"
Source§

fn pin_drop(self: Pin<&mut Self>)

🔬This is a nightly-only experimental API. (pin_ergonomics)
Execute the destructor for this type, but different to Drop::drop, it requires self\nto be pinned. Read more
","Drop","jellytau_lib::player::events::SharedEventEmitter"],["
1.52.0 · Source§

impl<T> Error for Arc<T>
where\n T: Error + ?Sized,

Source§

fn cause(&self) -> Option<&dyn Error>

👎Deprecated since 1.33.0:

replaced by Error::source, which can support downcasting

\n
Source§

fn source(&self) -> Option<&(dyn Error + 'static)>

Returns the lower-level source of this error, if any. Read more
Source§

fn provide<'a>(&'a self, req: &mut Request<'a>)

🔬This is a nightly-only experimental API. (error_generic_member_access)
Provides type-based access to context intended for error reports. Read more
1.0.0 · Source§

fn description(&self) -> &str

👎Deprecated since 1.42.0:

use the Display impl or to_string()

\n
","Error","jellytau_lib::player::events::SharedEventEmitter"],["
1.21.0 · Source§

impl<T, A> From<Box<T, A>> for Arc<T, A>
where\n A: Allocator,\n T: ?Sized,

Available on non-no_global_oom_handling only.
Source§

fn from(v: Box<T, A>) -> Arc<T, A>

Move a boxed object to a new, reference-counted allocation.

\n
§Example
\n
let unique: Box<str> = Box::from(\"eggplant\");\nlet shared: Arc<str> = Arc::from(unique);\nassert_eq!(\"eggplant\", &shared[..]);
","From>","jellytau_lib::player::events::SharedEventEmitter"],["
1.45.0 · Source§

impl<'a, B> From<Cow<'a, B>> for Arc<B>
where\n B: ToOwned + ?Sized,\n Arc<B>: From<&'a B> + From<<B as ToOwned>::Owned>,

Source§

fn from(cow: Cow<'a, B>) -> Arc<B>

Creates an atomically reference-counted pointer from a clone-on-write\npointer by copying its content.

\n
§Example
\n
let cow: Cow<'_, str> = Cow::Borrowed(\"eggplant\");\nlet shared: Arc<str> = Arc::from(cow);\nassert_eq!(\"eggplant\", &shared[..]);
","From>","jellytau_lib::player::events::SharedEventEmitter"],["
1.6.0 · Source§

impl<T> From<T> for Arc<T>

Available on non-no_global_oom_handling only.
Source§

fn from(t: T) -> Arc<T>

Converts a T into an Arc<T>

\n

The conversion moves the value into a\nnewly allocated Arc. It is equivalent to\ncalling Arc::new(t).

\n
§Example
\n
let x = 5;\nlet arc = Arc::new(5);\n\nassert_eq!(Arc::from(x), arc);
","From","jellytau_lib::player::events::SharedEventEmitter"],["
§

impl<'a, T> Get<'a> for Arc<T>
where\n T: Get<'a>,

§

fn get(i: &mut Iter<'a>) -> Option<Arc<T>>

Performs the get operation.
","Get<'a>","jellytau_lib::player::events::SharedEventEmitter"],["
§

impl<H> HasDisplayHandle for Arc<H>
where\n H: HasDisplayHandle + ?Sized,

Available on crate feature alloc only.
§

fn display_handle(&self) -> Result<DisplayHandle<'_>, HandleError>

Get a handle to the display controller of the windowing system.
","HasDisplayHandle","jellytau_lib::player::events::SharedEventEmitter"],["
§

impl<H> HasWindowHandle for Arc<H>
where\n H: HasWindowHandle + ?Sized,

Available on crate feature alloc only.
§

fn window_handle(&self) -> Result<WindowHandle<'_>, HandleError>

Get a handle to the window.
","HasWindowHandle","jellytau_lib::player::events::SharedEventEmitter"],["
1.0.0 · Source§

impl<T, A> Hash for Arc<T, A>
where\n T: Hash + ?Sized,\n A: Allocator,

Source§

fn hash<H>(&self, state: &mut H)
where\n H: Hasher,

Feeds this value into the given Hasher. Read more
1.3.0 · Source§

fn hash_slice<H>(data: &[Self], state: &mut H)
where\n H: Hasher,\n Self: Sized,

Feeds a slice of this type into the given Hasher. Read more
","Hash","jellytau_lib::player::events::SharedEventEmitter"],["
§

impl<Sp> LocalSpawn for Arc<Sp>
where\n Sp: LocalSpawn + ?Sized,

§

fn spawn_local_obj(\n &self,\n future: LocalFutureObj<'static, ()>,\n) -> Result<(), SpawnError>

Spawns a future that will be run to completion. Read more
§

fn status_local(&self) -> Result<(), SpawnError>

Determines whether the executor is able to spawn new tasks. Read more
","LocalSpawn","jellytau_lib::player::events::SharedEventEmitter"],["
Source§

impl<T> Log for Arc<T>
where\n T: Log + ?Sized,

Available on crate feature std only.
Source§

fn enabled(&self, metadata: &Metadata<'_>) -> bool

Determines if a log message with the specified metadata would be\nlogged. Read more
Source§

fn log(&self, record: &Record<'_>)

Logs the Record. Read more
Source§

fn flush(&self)

Flushes any buffered records. Read more
","Log","jellytau_lib::player::events::SharedEventEmitter"],["
§

impl<T> NamedType for Arc<T>
where\n T: NamedType,

§

fn sid() -> SpectaID

§

fn named_data_type(\n type_map: &mut TypeCollection,\n generics: &[DataType],\n) -> NamedDataType

this is equivalent to [Type::inline] but returns a [NamedDataType] instead.
§

fn definition_named_data_type(type_map: &mut TypeCollection) -> NamedDataType

this is equivalent to [Type::definition] but returns a [NamedDataType] instead.
","NamedType","jellytau_lib::player::events::SharedEventEmitter"],["
1.0.0 · Source§

impl<T, A> Ord for Arc<T, A>
where\n T: Ord + ?Sized,\n A: Allocator,

Source§

fn cmp(&self, other: &Arc<T, A>) -> Ordering

Comparison for two Arcs.

\n

The two are compared by calling cmp() on their inner values.

\n
§Examples
\n
use std::sync::Arc;\nuse std::cmp::Ordering;\n\nlet five = Arc::new(5);\n\nassert_eq!(Ordering::Less, five.cmp(&Arc::new(6)));
1.21.0 (const: unstable) · Source§

fn max(self, other: Self) -> Self
where\n Self: Sized,

Compares and returns the maximum of two values. Read more
1.21.0 (const: unstable) · Source§

fn min(self, other: Self) -> Self
where\n Self: Sized,

Compares and returns the minimum of two values. Read more
1.50.0 (const: unstable) · Source§

fn clamp(self, min: Self, max: Self) -> Self
where\n Self: Sized,

Restrict a value to a certain interval. Read more
","Ord","jellytau_lib::player::events::SharedEventEmitter"],["
1.0.0 · Source§

impl<T, A> PartialEq for Arc<T, A>
where\n T: PartialEq + ?Sized,\n A: Allocator,

Source§

fn eq(&self, other: &Arc<T, A>) -> bool

Equality for two Arcs.

\n

Two Arcs are equal if their inner values are equal, even if they are\nstored in different allocation.

\n

If T also implements Eq (implying reflexivity of equality),\ntwo Arcs that point to the same allocation are always equal.

\n
§Examples
\n
use std::sync::Arc;\n\nlet five = Arc::new(5);\n\nassert!(five == Arc::new(5));
Source§

fn ne(&self, other: &Arc<T, A>) -> bool

Inequality for two Arcs.

\n

Two Arcs are not equal if their inner values are not equal.

\n

If T also implements Eq (implying reflexivity of equality),\ntwo Arcs that point to the same value are always equal.

\n
§Examples
\n
use std::sync::Arc;\n\nlet five = Arc::new(5);\n\nassert!(five != Arc::new(6));
","PartialEq","jellytau_lib::player::events::SharedEventEmitter"],["
1.0.0 · Source§

impl<T, A> PartialOrd for Arc<T, A>
where\n T: PartialOrd + ?Sized,\n A: Allocator,

Source§

fn partial_cmp(&self, other: &Arc<T, A>) -> Option<Ordering>

Partial comparison for two Arcs.

\n

The two are compared by calling partial_cmp() on their inner values.

\n
§Examples
\n
use std::sync::Arc;\nuse std::cmp::Ordering;\n\nlet five = Arc::new(5);\n\nassert_eq!(Some(Ordering::Less), five.partial_cmp(&Arc::new(6)));
Source§

fn lt(&self, other: &Arc<T, A>) -> bool

Less-than comparison for two Arcs.

\n

The two are compared by calling < on their inner values.

\n
§Examples
\n
use std::sync::Arc;\n\nlet five = Arc::new(5);\n\nassert!(five < Arc::new(6));
Source§

fn le(&self, other: &Arc<T, A>) -> bool

‘Less than or equal to’ comparison for two Arcs.

\n

The two are compared by calling <= on their inner values.

\n
§Examples
\n
use std::sync::Arc;\n\nlet five = Arc::new(5);\n\nassert!(five <= Arc::new(5));
Source§

fn gt(&self, other: &Arc<T, A>) -> bool

Greater-than comparison for two Arcs.

\n

The two are compared by calling > on their inner values.

\n
§Examples
\n
use std::sync::Arc;\n\nlet five = Arc::new(5);\n\nassert!(five > Arc::new(4));
Source§

fn ge(&self, other: &Arc<T, A>) -> bool

‘Greater than or equal to’ comparison for two Arcs.

\n

The two are compared by calling >= on their inner values.

\n
§Examples
\n
use std::sync::Arc;\n\nlet five = Arc::new(5);\n\nassert!(five >= Arc::new(5));
","PartialOrd","jellytau_lib::player::events::SharedEventEmitter"],["
1.0.0 · Source§

impl<T, A> Pointer for Arc<T, A>
where\n A: Allocator,\n T: ?Sized,

Source§

fn fmt(&self, f: &mut Formatter<'_>) -> Result<(), Error>

Formats the value using the given formatter. Read more
","Pointer","jellytau_lib::player::events::SharedEventEmitter"],["
§

impl<T> Property for Arc<T>
where\n T: Property,

§

type Value = <T as Property>::Value

","Property","jellytau_lib::player::events::SharedEventEmitter"],["
§

impl<T> PropertyGet for Arc<T>
where\n T: PropertyGet,

§

type Value = <T as PropertyGet>::Value

§

fn get<R, F>(&self, f: F) -> R
where\n F: Fn(&<Arc<T> as PropertyGet>::Value) -> R,

","PropertyGet","jellytau_lib::player::events::SharedEventEmitter"],["
§

impl<T> PropertySetNested for Arc<T>
where\n T: PropertySetNested,

§

type SetNestedValue = <T as PropertySetNested>::SetNestedValue

§

fn set_nested<F>(&self, f: F)
where\n F: FnOnce(&mut <Arc<T> as PropertySetNested>::SetNestedValue),

","PropertySetNested","jellytau_lib::player::events::SharedEventEmitter"],["
1.73.0 · Source§

impl<R> Read for Arc<R>
where\n R: Read + IoHandle + ?Sized,\n &'a R: for<'a> Read,

Source§

fn read(&mut self, buf: &mut [u8]) -> Result<usize, Error>

Pull some bytes from this source into the specified buffer, returning\nhow many bytes were read. Read more
Source§

fn read_buf(&mut self, cursor: BorrowedCursor<'_>) -> Result<(), Error>

🔬This is a nightly-only experimental API. (read_buf)
Pull some bytes from this source into the specified buffer. Read more
Source§

fn read_vectored(&mut self, bufs: &mut [IoSliceMut<'_>]) -> Result<usize, Error>

Like read, except that it reads into a slice of buffers. Read more
Source§

fn is_read_vectored(&self) -> bool

🔬This is a nightly-only experimental API. (can_vector)
Determines if this Reader has an efficient read_vectored\nimplementation. Read more
Source§

fn read_to_end(&mut self, buf: &mut Vec<u8>) -> Result<usize, Error>

Reads all bytes until EOF in this source, placing them into buf. Read more
Source§

fn read_to_string(&mut self, buf: &mut String) -> Result<usize, Error>

Reads all bytes until EOF in this source, appending them to buf. Read more
Source§

fn read_exact(&mut self, buf: &mut [u8]) -> Result<(), Error>

Reads the exact number of bytes required to fill buf. Read more
Source§

fn read_buf_exact(&mut self, cursor: BorrowedCursor<'_>) -> Result<(), Error>

🔬This is a nightly-only experimental API. (read_buf)
Reads the exact number of bytes required to fill cursor. Read more
1.0.0 · Source§

fn by_ref(&mut self) -> &mut Self
where\n Self: Sized,

Creates a “by reference” adapter for this instance of Read. Read more
1.0.0 · Source§

fn bytes(self) -> Bytes<Self>
where\n Self: Sized,

Transforms this Read instance to an Iterator over its bytes. Read more
1.0.0 · Source§

fn chain<R>(self, next: R) -> Chain<Self, R>
where\n R: Read,\n Self: Sized,

Creates an adapter which will chain this stream with another. Read more
1.0.0 · Source§

fn take(self, limit: u64) -> Take<Self>
where\n Self: Sized,

Creates an adapter which will read at most limit bytes from it. Read more
Source§

fn read_array<const N: usize>(&mut self) -> Result<[u8; N], Error>
where\n Self: Sized,

🔬This is a nightly-only experimental API. (read_array)
Read and return a fixed array of bytes from this source. Read more
","Read","jellytau_lib::player::events::SharedEventEmitter"],["
§

impl<T> RefArg for Arc<T>
where\n T: RefArg + ?Sized,

§

fn arg_type(&self) -> ArgType

The corresponding D-Bus argument type code.
§

fn signature(&self) -> Signature<'static>

The corresponding D-Bus type signature for this type.
§

fn append(&self, i: &mut IterAppend<'_>)

Performs the append operation.
§

fn as_any(&self) -> &(dyn Any + 'static)
where\n T: 'static,

Transforms this argument to Any (which can be downcasted to read the current value). Read more
§

fn as_any_mut(&mut self) -> &mut (dyn Any + 'static)
where\n T: 'static,

Transforms this argument to Any (which can be downcasted to read the current value). Read more
§

fn as_i64(&self) -> Option<i64>

Try to read the argument as an i64. Read more
§

fn as_u64(&self) -> Option<u64>

Try to read the argument as an u64. Read more
§

fn as_f64(&self) -> Option<f64>

Try to read the argument as an f64. Read more
§

fn as_str(&self) -> Option<&str>

Try to read the argument as a str. Read more
§

fn as_iter<'a>(\n &'a self,\n) -> Option<Box<dyn Iterator<Item = &'a dyn RefArg> + 'a>>

Try to read the argument as an iterator. Read more
§

fn as_static_inner(&self, index: usize) -> Option<&(dyn RefArg + 'static)>
where\n Arc<T>: 'static,

Try to read the inner of an argument, as another argument, specifying an index. Read more
§

fn box_clone(&self) -> Box<dyn RefArg>

Deep clone of the RefArg, causing the result to be ’static. Read more
§

fn array_clone(_arg: &[Self]) -> Option<Box<dyn RefArg>>
where\n Self: Sized,

Deep clone of an array. Read more
","RefArg","jellytau_lib::player::events::SharedEventEmitter"],["
§

impl<T> RefCounted for Arc<T>
where\n T: 'static,

§

type InnerType = T

The inner type
§

unsafe fn ref_(\n this: *const <Arc<T> as RefCounted>::InnerType,\n) -> *const <Arc<T> as RefCounted>::InnerType

The function used to increment the inner type refcount
§

fn as_ptr(&self) -> *const <Arc<T> as RefCounted>::InnerType

Provides access to a raw pointer to InnerType
§

unsafe fn into_raw(self) -> *const <Arc<T> as RefCounted>::InnerType

Converts the RefCounted object to a raw pointer to InnerType
§

unsafe fn from_raw(this: *const <Arc<T> as RefCounted>::InnerType) -> Arc<T>

Converts a raw pointer to InnerType to a RefCounted object
","RefCounted","jellytau_lib::player::events::SharedEventEmitter"],["
1.73.0 · Source§

impl<S> Seek for Arc<S>
where\n S: Seek + IoHandle + ?Sized,\n &'a S: for<'a> Seek,

Source§

fn seek(&mut self, pos: SeekFrom) -> Result<u64, Error>

Seek to an offset, in bytes, in a stream. Read more
Source§

fn rewind(&mut self) -> Result<(), Error>

Rewind to the beginning of a stream. Read more
Source§

fn stream_len(&mut self) -> Result<u64, Error>

🔬This is a nightly-only experimental API. (seek_stream_len)
Returns the length of this stream (in bytes). Read more
Source§

fn stream_position(&mut self) -> Result<u64, Error>

Returns the current seek position from the start of the stream. Read more
Source§

fn seek_relative(&mut self, offset: i64) -> Result<(), Error>

Seeks relative to the current position. Read more
","Seek","jellytau_lib::player::events::SharedEventEmitter"],["
Source§

impl<T> Serialize for Arc<T>
where\n T: Serialize + ?Sized,

Available on crate feature rc and (crate features std or alloc) only.

This impl requires the \"rc\" Cargo feature of Serde.

\n

Serializing a data structure containing Arc will serialize a copy of\nthe contents of the Arc each time the Arc is referenced within the\ndata structure. Serialization will not attempt to deduplicate these\nrepeated data.

\n
Source§

fn serialize<S>(\n &self,\n serializer: S,\n) -> Result<<S as Serializer>::Ok, <S as Serializer>::Error>
where\n S: Serializer,

Serialize this value into the given Serde serializer. Read more
","Serialize","jellytau_lib::player::events::SharedEventEmitter"],["
Source§

impl<T, U> SerializeAs<Arc<T>> for Arc<U>
where\n U: SerializeAs<T>,

Available on crate feature alloc and target_has_atomic=ptr only.
Source§

fn serialize_as<S>(\n source: &Arc<T>,\n serializer: S,\n) -> Result<<S as Serializer>::Ok, <S as Serializer>::Error>
where\n S: Serializer,

Serialize this value into the given Serde serializer.
","SerializeAs>","jellytau_lib::player::events::SharedEventEmitter"],["
§

impl<Request, S> Service<Request> for Arc<S>
where\n S: Service<Request> + ?Sized,

§

type Response = <S as Service<Request>>::Response

Responses given by the service.
§

type Error = <S as Service<Request>>::Error

Errors produced by the service. Read more
§

type Future = <S as Service<Request>>::Future

The future response value.
§

fn call(&self, req: Request) -> <Arc<S> as Service<Request>>::Future

Process the request and return the response asynchronously.\ncall takes &self instead of mut &self because: Read more
","Service","jellytau_lib::player::events::SharedEventEmitter"],["
Source§

impl<S> Source for Arc<S>
where\n S: Source + ?Sized,

Source§

fn visit<'kvs>(\n &'kvs self,\n visitor: &mut dyn VisitSource<'kvs>,\n) -> Result<(), Error>

Visit key-values. Read more
Source§

fn get(&self, key: Key<'_>) -> Option<Value<'_>>

Get the value for a given key. Read more
Source§

fn count(&self) -> usize

Count the number of key-values that can be visited. Read more
","Source","jellytau_lib::player::events::SharedEventEmitter"],["
§

impl<Sp> Spawn for Arc<Sp>
where\n Sp: Spawn + ?Sized,

§

fn spawn_obj(&self, future: FutureObj<'static, ()>) -> Result<(), SpawnError>

Spawns a future that will be run to completion. Read more
§

fn status(&self) -> Result<(), SpawnError>

Determines whether the executor is able to spawn new tasks. Read more
","Spawn","jellytau_lib::player::events::SharedEventEmitter"],["
§

impl<S> Subscriber for Arc<S>
where\n S: Subscriber + ?Sized,

§

fn register_callsite(&self, metadata: &'static Metadata<'static>) -> Interest

Registers a new callsite with this subscriber, returning whether or not\nthe subscriber is interested in being notified about the callsite. Read more
§

fn enabled(&self, metadata: &Metadata<'_>) -> bool

Returns true if a span or event with the specified metadata would be\nrecorded. Read more
§

fn max_level_hint(&self) -> Option<LevelFilter>

Returns the highest verbosity level that this Subscriber will\nenable, or None, if the subscriber does not implement level-based\nfiltering or chooses not to implement this method. Read more
§

fn new_span(&self, span: &Attributes<'_>) -> Id

Visit the construction of a new span, returning a new span ID for the\nspan being constructed. Read more
§

fn record(&self, span: &Id, values: &Record<'_>)

Record a set of values on a span. Read more
§

fn record_follows_from(&self, span: &Id, follows: &Id)

Adds an indication that span follows from the span with the id\nfollows. Read more
§

fn event_enabled(&self, event: &Event<'_>) -> bool

Determine if an [Event] should be recorded. Read more
§

fn event(&self, event: &Event<'_>)

Records that an Event has occurred. Read more
§

fn enter(&self, span: &Id)

Records that a span has been entered. Read more
§

fn exit(&self, span: &Id)

Records that a span has been exited. Read more
§

fn clone_span(&self, id: &Id) -> Id

Notifies the subscriber that a span ID has been cloned. Read more
§

fn try_close(&self, id: Id) -> bool

Notifies the subscriber that a span ID has been dropped, and returns\ntrue if there are now 0 IDs that refer to that span. Read more
§

fn drop_span(&self, id: Id)

👎Deprecated since 0.1.2:

use Subscriber::try_close instead

\n
This method is deprecated. Read more
§

fn current_span(&self) -> Current

Returns a type representing this subscriber’s view of the current span. Read more
§

unsafe fn downcast_raw(&self, id: TypeId) -> Option<*const ()>

If self is the same type as the provided TypeId, returns an untyped\n*const pointer to that type. Otherwise, returns None. Read more
§

fn on_register_dispatch(&self, subscriber: &Dispatch)

Invoked when this subscriber becomes a [Dispatch]. Read more
","Subscriber","jellytau_lib::player::events::SharedEventEmitter"],["
§

impl<T> ToSql for Arc<T>
where\n T: ToSql + ?Sized,

§

fn to_sql(&self) -> Result<ToSqlOutput<'_>, Error>

Converts Rust value to SQLite value
","ToSql","jellytau_lib::player::events::SharedEventEmitter"],["
§

impl<T> Type for Arc<T>
where\n T: Type + ?Sized,

§

const SIGNATURE: &'static Signature = T::SIGNATURE

The signature for the implementing type, in parsed format. Read more
","Type","jellytau_lib::player::events::SharedEventEmitter"],["
§

impl<T> Type for Arc<T>
where\n T: Type,

§

fn inline(type_map: &mut TypeCollection, generics: Generics<'_>) -> DataType

Returns the definition of a type using the provided generics. Read more
§

fn reference(type_map: &mut TypeCollection, generics: &[DataType]) -> Reference

Generates a datatype corresponding to a reference to this type,\nas determined by its category. Getting a reference to a type implies that\nit should belong in the type map (since it has to be referenced from somewhere),\nso the output of definition will be put into the type map.
","Type","jellytau_lib::player::events::SharedEventEmitter"],["
1.73.0 · Source§

impl<W> Write for Arc<W>
where\n W: Write + IoHandle + ?Sized,\n &'a W: for<'a> Write,

Source§

fn write(&mut self, buf: &[u8]) -> Result<usize, Error>

Writes a buffer into this writer, returning how many bytes were written. Read more
Source§

fn write_vectored(&mut self, bufs: &[IoSlice<'_>]) -> Result<usize, Error>

Like write, except that it writes from a slice of buffers. Read more
Source§

fn is_write_vectored(&self) -> bool

🔬This is a nightly-only experimental API. (can_vector)
Determines if this Writer has an efficient write_vectored\nimplementation. Read more
Source§

fn flush(&mut self) -> Result<(), Error>

Flushes this output stream, ensuring that all intermediately buffered\ncontents reach their destination. Read more
Source§

fn write_all(&mut self, buf: &[u8]) -> Result<(), Error>

Attempts to write an entire buffer into this writer. Read more
Source§

fn write_all_vectored(&mut self, bufs: &mut [IoSlice<'_>]) -> Result<(), Error>

🔬This is a nightly-only experimental API. (write_all_vectored)
Attempts to write multiple buffers into this writer. Read more
Source§

fn write_fmt(&mut self, fmt: Arguments<'_>) -> Result<(), Error>

Writes a formatted string into this writer, returning any error\nencountered. Read more
1.0.0 · Source§

fn by_ref(&mut self) -> &mut Self
where\n Self: Sized,

Creates a “by reference” adapter for this instance of Write. Read more
","Write","jellytau_lib::player::events::SharedEventEmitter"],["
Source§

impl<T> CloneFromCell for Arc<T>
where\n T: ?Sized,

","CloneFromCell","jellytau_lib::player::events::SharedEventEmitter"],["
Source§

impl<T, U, A> CoerceUnsized<Arc<U, A>> for Arc<T, A>
where\n T: Unsize<U> + ?Sized,\n A: Allocator,\n U: ?Sized,

","CoerceUnsized>","jellytau_lib::player::events::SharedEventEmitter"],["
Source§

impl<T, A> DerefPure for Arc<T, A>
where\n A: Allocator,\n T: ?Sized,

","DerefPure","jellytau_lib::player::events::SharedEventEmitter"],["
§

impl<T> DictKey for Arc<T>
where\n T: DictKey,

","DictKey","jellytau_lib::player::events::SharedEventEmitter"],["
Source§

impl<T, U> DispatchFromDyn<Arc<U>> for Arc<T>
where\n T: Unsize<U> + ?Sized,\n U: ?Sized,

","DispatchFromDyn>","jellytau_lib::player::events::SharedEventEmitter"],["
1.0.0 · Source§

impl<T, A> Eq for Arc<T, A>
where\n T: Eq + ?Sized,\n A: Allocator,

","Eq","jellytau_lib::player::events::SharedEventEmitter"],["
§

impl<T> Flatten for Arc<T>
where\n T: Flatten,

","Flatten","jellytau_lib::player::events::SharedEventEmitter"],["
Source§

impl<T, A> PinCoerceUnsized for Arc<T, A>
where\n A: Allocator,\n T: ?Sized,

","PinCoerceUnsized","jellytau_lib::player::events::SharedEventEmitter"],["
1.0.0 · Source§

impl<T, A> Send for Arc<T, A>
where\n T: Sync + Send + ?Sized,\n A: Allocator + Send,

","Send","jellytau_lib::player::events::SharedEventEmitter"],["
1.0.0 · Source§

impl<T, A> Sync for Arc<T, A>
where\n T: Sync + Send + ?Sized,\n A: Allocator + Sync,

","Sync","jellytau_lib::player::events::SharedEventEmitter"],["
1.33.0 · Source§

impl<T, A> Unpin for Arc<T, A>
where\n A: Allocator,\n T: ?Sized,

","Unpin","jellytau_lib::player::events::SharedEventEmitter"],["
1.9.0 · Source§

impl<T, A> UnwindSafe for Arc<T, A>
where\n T: RefUnwindSafe + ?Sized,\n A: Allocator + UnwindSafe,

","UnwindSafe","jellytau_lib::player::events::SharedEventEmitter"],["
Source§

impl<T, A> UseCloned for Arc<T, A>
where\n A: Allocator + Clone,\n T: ?Sized,

","UseCloned","jellytau_lib::player::events::SharedEventEmitter"]]]]); + if (window.register_type_impls) { + window.register_type_impls(type_impls); + } else { + window.pending_type_impls = type_impls; + } +})() +//{"start":55,"fragment_lengths":[310617]} \ No newline at end of file diff --git a/api/type.impl/core/result/enum.Result.js b/api/type.impl/core/result/enum.Result.js new file mode 100644 index 00000000..6a9cfca8 --- /dev/null +++ b/api/type.impl/core/result/enum.Result.js @@ -0,0 +1,9 @@ +(function() { + var type_impls = Object.fromEntries([["jellytau_lib",[["
1.0.0 · Source§

impl<T, E> Clone for Result<T, E>
where\n T: Clone,\n E: Clone,

Source§

fn clone(&self) -> Result<T, E>

Returns a duplicate of the value. Read more
Source§

fn clone_from(&mut self, source: &Result<T, E>)

Performs copy-assignment from source. Read more
","Clone","jellytau_lib::storage::db_service::DbResult"],["
Source§

impl<T, E> Context<T, E> for Result<T, E>
where\n E: StdError + Send + Sync + 'static,

Source§

fn context<C>(self, context: C) -> Result<T, Error>
where\n C: Display + Send + Sync + 'static,

Wrap the error value with additional context.
Source§

fn with_context<C, F>(self, context: F) -> Result<T, Error>
where\n C: Display + Send + Sync + 'static,\n F: FnOnce() -> C,

Wrap the error value with additional context that is evaluated lazily\nonly once an error does occur.
","Context","jellytau_lib::storage::db_service::DbResult"],["
1.0.0 · Source§

impl<T, E> Debug for Result<T, E>
where\n T: Debug,\n E: Debug,

Source§

fn fmt(&self, f: &mut Formatter<'_>) -> Result<(), Error>

Formats the value using the given formatter. Read more
","Debug","jellytau_lib::storage::db_service::DbResult"],["
Source§

impl<'de, T, E> Deserialize<'de> for Result<T, E>
where\n T: Deserialize<'de>,\n E: Deserialize<'de>,

Available on crate feature result only.
Source§

fn deserialize<D>(\n deserializer: D,\n) -> Result<Result<T, E>, <D as Deserializer<'de>>::Error>
where\n D: Deserializer<'de>,

Deserialize this value from the given Serde deserializer. Read more
","Deserialize<'de>","jellytau_lib::storage::db_service::DbResult"],["
Source§

impl<'de, T, TAs, E, EAs> DeserializeAs<'de, Result<T, E>> for Result<TAs, EAs>
where\n TAs: DeserializeAs<'de, T>,\n EAs: DeserializeAs<'de, E>,

Source§

fn deserialize_as<D>(\n deserializer: D,\n) -> Result<Result<T, E>, <D as Deserializer<'de>>::Error>
where\n D: Deserializer<'de>,

Deserialize this value from the given Serde deserializer.
","DeserializeAs<'de, Result>","jellytau_lib::storage::db_service::DbResult"],["
§

impl<G, I, T> FromGlib<G> for Result<Option<T>, I>
where\n G: Copy,\n I: Error,\n T: TryFromGlib<G, Error = GlibNoneOrInvalidError<I>>,

§

unsafe fn from_glib(val: G) -> Result<Option<T>, I>

","FromGlib","jellytau_lib::storage::db_service::DbResult"],["
1.0.0 · Source§

impl<A, E, V> FromIterator<Result<A, E>> for Result<V, E>
where\n V: FromIterator<A>,

Source§

fn from_iter<I>(iter: I) -> Result<V, E>
where\n I: IntoIterator<Item = Result<A, E>>,

Takes each element in the Iterator: if it is an Err, no further\nelements are taken, and the Err is returned. Should no Err occur, a\ncontainer with the values of each Result is returned.

\n

Here is an example which increments every integer in a vector,\nchecking for overflow:

\n\n
let v = vec![1, 2];\nlet res: Result<Vec<u32>, &'static str> = v.iter().map(|x: &u32|\n    x.checked_add(1).ok_or(\"Overflow!\")\n).collect();\nassert_eq!(res, Ok(vec![2, 3]));
\n

Here is another example that tries to subtract one from another list\nof integers, this time checking for underflow:

\n\n
let v = vec![1, 2, 0];\nlet res: Result<Vec<u32>, &'static str> = v.iter().map(|x: &u32|\n    x.checked_sub(1).ok_or(\"Underflow!\")\n).collect();\nassert_eq!(res, Err(\"Underflow!\"));
\n

Here is a variation on the previous example, showing that no\nfurther elements are taken from iter after the first Err.

\n\n
let v = vec![3, 2, 1, 10];\nlet mut shared = 0;\nlet res: Result<Vec<u32>, &'static str> = v.iter().map(|x: &u32| {\n    shared += x;\n    x.checked_sub(2).ok_or(\"Underflow!\")\n}).collect();\nassert_eq!(res, Err(\"Underflow!\"));\nassert_eq!(shared, 6);
\n

Since the third element caused an underflow, no further elements were taken,\nso the final value of shared is 6 (= 3 + 2 + 1), not 16.

\n
","FromIterator>","jellytau_lib::storage::db_service::DbResult"],["
Source§

impl<T, E, F> FromResidual<Result<Infallible, E>> for Result<T, F>
where\n F: From<E>,

Source§

fn from_residual(residual: Result<Infallible, E>) -> Result<T, F>

🔬This is a nightly-only experimental API. (try_trait_v2)
Constructs the type from a compatible Residual type. Read more
","FromResidual>","jellytau_lib::storage::db_service::DbResult"],["
Source§

impl<T, E, F> FromResidual<Yeet<E>> for Result<T, F>
where\n F: From<E>,

Source§

fn from_residual(_: Yeet<E>) -> Result<T, F>

🔬This is a nightly-only experimental API. (try_trait_v2)
Constructs the type from a compatible Residual type. Read more
","FromResidual>","jellytau_lib::storage::db_service::DbResult"],["
1.0.0 · Source§

impl<T, E> Hash for Result<T, E>
where\n T: Hash,\n E: Hash,

Source§

fn hash<__H>(&self, state: &mut __H)
where\n __H: Hasher,

Feeds this value into the given Hasher. Read more
1.3.0 · Source§

fn hash_slice<H>(data: &[Self], state: &mut H)
where\n H: Hasher,\n Self: Sized,

Feeds a slice of this type into the given Hasher. Read more
","Hash","jellytau_lib::storage::db_service::DbResult"],["
§

impl<O, E, G> IntoGlib for Result<O, E>
where\n G: Copy,\n O: IntoGlib<GlibType = G> + TryFromGlib<G, Error = E>,\n E: IntoGlib<GlibType = G>,

§

type GlibType = G

§

fn into_glib(self) -> <Result<O, E> as IntoGlib>::GlibType

","IntoGlib","jellytau_lib::storage::db_service::DbResult"],["
1.0.0 · Source§

impl<T, E> IntoIterator for Result<T, E>

Source§

fn into_iter(self) -> IntoIter<T>

Returns a consuming iterator over the possibly contained value.

\n

The iterator yields one value if the result is Result::Ok, otherwise none.

\n
§Examples
\n
let x: Result<u32, &str> = Ok(5);\nlet v: Vec<u32> = x.into_iter().collect();\nassert_eq!(v, [5]);\n\nlet x: Result<u32, &str> = Err(\"nothing!\");\nlet v: Vec<u32> = x.into_iter().collect();\nassert_eq!(v, []);
Source§

type Item = T

The type of the elements being iterated over.
Source§

type IntoIter = IntoIter<T>

Which kind of iterator are we turning this into?
","IntoIterator","jellytau_lib::storage::db_service::DbResult"],["
1.0.0 (const: unstable) · Source§

impl<T, E> Ord for Result<T, E>
where\n T: Ord,\n E: Ord,

Source§

fn cmp(&self, other: &Result<T, E>) -> Ordering

This method returns an Ordering between self and other. Read more
1.21.0 (const: unstable) · Source§

fn max(self, other: Self) -> Self
where\n Self: Sized,

Compares and returns the maximum of two values. Read more
1.21.0 (const: unstable) · Source§

fn min(self, other: Self) -> Self
where\n Self: Sized,

Compares and returns the minimum of two values. Read more
1.50.0 (const: unstable) · Source§

fn clamp(self, min: Self, max: Self) -> Self
where\n Self: Sized,

Restrict a value to a certain interval. Read more
","Ord","jellytau_lib::storage::db_service::DbResult"],["
1.0.0 (const: unstable) · Source§

impl<T, E> PartialEq for Result<T, E>
where\n T: PartialEq,\n E: PartialEq,

Source§

fn eq(&self, other: &Result<T, E>) -> bool

Tests for self and other values to be equal, and is used by ==.
1.0.0 (const: unstable) · Source§

fn ne(&self, other: &Rhs) -> bool

Tests for !=. The default implementation is almost always sufficient,\nand should not be overridden without very good reason.
","PartialEq","jellytau_lib::storage::db_service::DbResult"],["
1.0.0 (const: unstable) · Source§

impl<T, E> PartialOrd for Result<T, E>
where\n T: PartialOrd,\n E: PartialOrd,

Source§

fn partial_cmp(&self, other: &Result<T, E>) -> Option<Ordering>

This method returns an ordering between self and other values if one exists. Read more
1.0.0 (const: unstable) · Source§

fn lt(&self, other: &Rhs) -> bool

Tests less than (for self and other) and is used by the < operator. Read more
1.0.0 (const: unstable) · Source§

fn le(&self, other: &Rhs) -> bool

Tests less than or equal to (for self and other) and is used by the\n<= operator. Read more
1.0.0 (const: unstable) · Source§

fn gt(&self, other: &Rhs) -> bool

Tests greater than (for self and other) and is used by the >\noperator. Read more
1.0.0 (const: unstable) · Source§

fn ge(&self, other: &Rhs) -> bool

Tests greater than or equal to (for self and other) and is used by\nthe >= operator. Read more
","PartialOrd","jellytau_lib::storage::db_service::DbResult"],["
§

impl<B, E> Policy<B, E> for Result<Action, E>
where\n E: Clone,

§

fn redirect(&mut self, _: &Attempt<'_>) -> Result<Action, E>

Invoked when the service received a response with a redirection status code (3xx). Read more
§

fn on_request(&mut self, _request: &mut Request<B>)

Invoked right before the service makes a request, regardless of whether it is redirected\nor not. Read more
§

fn clone_body(&self, _body: &B) -> Option<B>

Try to clone a request body before the service makes a redirected request. Read more
","Policy","jellytau_lib::storage::db_service::DbResult"],["
1.16.0 · Source§

impl<T, U, E> Product<Result<U, E>> for Result<T, E>
where\n T: Product<U>,

Source§

fn product<I>(iter: I) -> Result<T, E>
where\n I: Iterator<Item = Result<U, E>>,

Takes each element in the Iterator: if it is an Err, no further\nelements are taken, and the Err is returned. Should no Err\noccur, the product of all elements is returned.

\n
§Examples
\n

This multiplies each number in a vector of strings,\nif a string could not be parsed the operation returns Err:

\n\n
let nums = vec![\"5\", \"10\", \"1\", \"2\"];\nlet total: Result<usize, _> = nums.iter().map(|w| w.parse::<usize>()).product();\nassert_eq!(total, Ok(100));\nlet nums = vec![\"5\", \"10\", \"one\", \"2\"];\nlet total: Result<usize, _> = nums.iter().map(|w| w.parse::<usize>()).product();\nassert!(total.is_err());
","Product>","jellytau_lib::storage::db_service::DbResult"],["
Source§

impl<T, E> Residual<T> for Result<Infallible, E>

Source§

type TryType = Result<T, E>

🔬This is a nightly-only experimental API. (try_trait_v2_residual)
The “return” type of this meta-function.
","Residual","jellytau_lib::storage::db_service::DbResult"],["
Source§

impl<T, E> Result<&T, E>

1.59.0 (const: 1.83.0) · Source

pub const fn copied(self) -> Result<T, E>
where\n T: Copy,

Maps a Result<&T, E> to a Result<T, E> by copying the contents of the\nOk part.

\n
§Examples
\n
let val = 12;\nlet x: Result<&i32, i32> = Ok(&val);\nassert_eq!(x, Ok(&12));\nlet copied = x.copied();\nassert_eq!(copied, Ok(12));
1.59.0 · Source

pub fn cloned(self) -> Result<T, E>
where\n T: Clone,

Maps a Result<&T, E> to a Result<T, E> by cloning the contents of the\nOk part.

\n
§Examples
\n
let val = 12;\nlet x: Result<&i32, i32> = Ok(&val);\nassert_eq!(x, Ok(&12));\nlet cloned = x.cloned();\nassert_eq!(cloned, Ok(12));
",0,"jellytau_lib::storage::db_service::DbResult"],["
Source§

impl<T, E> Result<&mut T, E>

1.59.0 (const: 1.83.0) · Source

pub const fn copied(self) -> Result<T, E>
where\n T: Copy,

Maps a Result<&mut T, E> to a Result<T, E> by copying the contents of the\nOk part.

\n
§Examples
\n
let mut val = 12;\nlet x: Result<&mut i32, i32> = Ok(&mut val);\nassert_eq!(x, Ok(&mut 12));\nlet copied = x.copied();\nassert_eq!(copied, Ok(12));
1.59.0 · Source

pub fn cloned(self) -> Result<T, E>
where\n T: Clone,

Maps a Result<&mut T, E> to a Result<T, E> by cloning the contents of the\nOk part.

\n
§Examples
\n
let mut val = 12;\nlet x: Result<&mut i32, i32> = Ok(&mut val);\nassert_eq!(x, Ok(&mut 12));\nlet cloned = x.cloned();\nassert_eq!(cloned, Ok(12));
",0,"jellytau_lib::storage::db_service::DbResult"],["
Source§

impl<T, E> Result<Option<T>, E>

1.33.0 (const: 1.83.0) · Source

pub const fn transpose(self) -> Option<Result<T, E>>

Transposes a Result of an Option into an Option of a Result.

\n

Ok(None) will be mapped to None.\nOk(Some(_)) and Err(_) will be mapped to Some(Ok(_)) and Some(Err(_)).

\n
§Examples
\n
#[derive(Debug, Eq, PartialEq)]\nstruct SomeErr;\n\nlet x: Result<Option<i32>, SomeErr> = Ok(Some(5));\nlet y: Option<Result<i32, SomeErr>> = Some(Ok(5));\nassert_eq!(x.transpose(), y);
",0,"jellytau_lib::storage::db_service::DbResult"],["
Source§

impl<T, E> Result<Result<T, E>, E>

1.89.0 (const: 1.89.0) · Source

pub const fn flatten(self) -> Result<T, E>

Converts from Result<Result<T, E>, E> to Result<T, E>

\n
§Examples
\n
let x: Result<Result<&'static str, u32>, u32> = Ok(Ok(\"hello\"));\nassert_eq!(Ok(\"hello\"), x.flatten());\n\nlet x: Result<Result<&'static str, u32>, u32> = Ok(Err(6));\nassert_eq!(Err(6), x.flatten());\n\nlet x: Result<Result<&'static str, u32>, u32> = Err(6);\nassert_eq!(Err(6), x.flatten());
\n

Flattening only removes one level of nesting at a time:

\n\n
let x: Result<Result<Result<&'static str, u32>, u32>, u32> = Ok(Ok(Ok(\"hello\")));\nassert_eq!(Ok(Ok(\"hello\")), x.flatten());\nassert_eq!(Ok(\"hello\"), x.flatten().flatten());
",0,"jellytau_lib::storage::db_service::DbResult"],["
Source§

impl<T, E> Result<T, E>

1.0.0 (const: 1.48.0) · Source

pub const fn is_ok(&self) -> bool

Returns true if the result is Ok.

\n
§Examples
\n
let x: Result<i32, &str> = Ok(-3);\nassert_eq!(x.is_ok(), true);\n\nlet x: Result<i32, &str> = Err(\"Some error message\");\nassert_eq!(x.is_ok(), false);
1.70.0 (const: unstable) · Source

pub fn is_ok_and<F>(self, f: F) -> bool
where\n F: FnOnce(T) -> bool,

Returns true if the result is Ok and the value inside of it matches a predicate.

\n
§Examples
\n
let x: Result<u32, &str> = Ok(2);\nassert_eq!(x.is_ok_and(|x| x > 1), true);\n\nlet x: Result<u32, &str> = Ok(0);\nassert_eq!(x.is_ok_and(|x| x > 1), false);\n\nlet x: Result<u32, &str> = Err(\"hey\");\nassert_eq!(x.is_ok_and(|x| x > 1), false);\n\nlet x: Result<String, &str> = Ok(\"ownership\".to_string());\nassert_eq!(x.as_ref().is_ok_and(|x| x.len() > 1), true);\nprintln!(\"still alive {:?}\", x);
1.0.0 (const: 1.48.0) · Source

pub const fn is_err(&self) -> bool

Returns true if the result is Err.

\n
§Examples
\n
let x: Result<i32, &str> = Ok(-3);\nassert_eq!(x.is_err(), false);\n\nlet x: Result<i32, &str> = Err(\"Some error message\");\nassert_eq!(x.is_err(), true);
1.70.0 (const: unstable) · Source

pub fn is_err_and<F>(self, f: F) -> bool
where\n F: FnOnce(E) -> bool,

Returns true if the result is Err and the value inside of it matches a predicate.

\n
§Examples
\n
use std::io::{Error, ErrorKind};\n\nlet x: Result<u32, Error> = Err(Error::new(ErrorKind::NotFound, \"!\"));\nassert_eq!(x.is_err_and(|x| x.kind() == ErrorKind::NotFound), true);\n\nlet x: Result<u32, Error> = Err(Error::new(ErrorKind::PermissionDenied, \"!\"));\nassert_eq!(x.is_err_and(|x| x.kind() == ErrorKind::NotFound), false);\n\nlet x: Result<u32, Error> = Ok(123);\nassert_eq!(x.is_err_and(|x| x.kind() == ErrorKind::NotFound), false);\n\nlet x: Result<u32, String> = Err(\"ownership\".to_string());\nassert_eq!(x.as_ref().is_err_and(|x| x.len() > 1), true);\nprintln!(\"still alive {:?}\", x);
1.0.0 (const: unstable) · Source

pub fn ok(self) -> Option<T>

Converts from Result<T, E> to Option<T>.

\n

Converts self into an Option<T>, consuming self,\nand converting the error to None, if any.

\n
§Examples
\n
let x: Result<u32, &str> = Ok(2);\nassert_eq!(x.ok(), Some(2));\n\nlet x: Result<u32, &str> = Err(\"Nothing here\");\nassert_eq!(x.ok(), None);
1.0.0 (const: unstable) · Source

pub fn err(self) -> Option<E>

Converts from Result<T, E> to Option<E>.

\n

Converts self into an Option<E>, consuming self,\nand discarding the success value, if any.

\n
§Examples
\n
let x: Result<u32, &str> = Ok(2);\nassert_eq!(x.err(), None);\n\nlet x: Result<u32, &str> = Err(\"Nothing here\");\nassert_eq!(x.err(), Some(\"Nothing here\"));
1.0.0 (const: 1.48.0) · Source

pub const fn as_ref(&self) -> Result<&T, &E>

Converts from &Result<T, E> to Result<&T, &E>.

\n

Produces a new Result, containing a reference\ninto the original, leaving the original in place.

\n
§Examples
\n
let x: Result<u32, &str> = Ok(2);\nassert_eq!(x.as_ref(), Ok(&2));\n\nlet x: Result<u32, &str> = Err(\"Error\");\nassert_eq!(x.as_ref(), Err(&\"Error\"));
1.0.0 (const: 1.83.0) · Source

pub const fn as_mut(&mut self) -> Result<&mut T, &mut E>

Converts from &mut Result<T, E> to Result<&mut T, &mut E>.

\n
§Examples
\n
fn mutate(r: &mut Result<i32, i32>) {\n    match r.as_mut() {\n        Ok(v) => *v = 42,\n        Err(e) => *e = 0,\n    }\n}\n\nlet mut x: Result<i32, i32> = Ok(2);\nmutate(&mut x);\nassert_eq!(x.unwrap(), 42);\n\nlet mut x: Result<i32, i32> = Err(13);\nmutate(&mut x);\nassert_eq!(x.unwrap_err(), 0);
1.0.0 (const: unstable) · Source

pub fn map<U, F>(self, op: F) -> Result<U, E>
where\n F: FnOnce(T) -> U,

Maps a Result<T, E> to Result<U, E> by applying a function to a\ncontained Ok value, leaving an Err value untouched.

\n

This function can be used to compose the results of two functions.

\n
§Examples
\n

Print the numbers on each line of a string multiplied by two.

\n\n
let line = \"1\\n2\\n3\\n4\\n\";\n\nfor num in line.lines() {\n    match num.parse::<i32>().map(|i| i * 2) {\n        Ok(n) => println!(\"{n}\"),\n        Err(..) => {}\n    }\n}
1.41.0 (const: unstable) · Source

pub fn map_or<U, F>(self, default: U, f: F) -> U
where\n F: FnOnce(T) -> U,

Returns the provided default (if Err), or\napplies a function to the contained value (if Ok).

\n

Arguments passed to map_or are eagerly evaluated; if you are passing\nthe result of a function call, it is recommended to use map_or_else,\nwhich is lazily evaluated.

\n
§Examples
\n
let x: Result<_, &str> = Ok(\"foo\");\nassert_eq!(x.map_or(42, |v| v.len()), 3);\n\nlet x: Result<&str, _> = Err(\"bar\");\nassert_eq!(x.map_or(42, |v| v.len()), 42);
1.41.0 (const: unstable) · Source

pub fn map_or_else<U, D, F>(self, default: D, f: F) -> U
where\n D: FnOnce(E) -> U,\n F: FnOnce(T) -> U,

Maps a Result<T, E> to U by applying fallback function default to\na contained Err value, or function f to a contained Ok value.

\n

This function can be used to unpack a successful result\nwhile handling an error.

\n
§Examples
\n
let k = 21;\n\nlet x : Result<_, &str> = Ok(\"foo\");\nassert_eq!(x.map_or_else(|e| k * 2, |v| v.len()), 3);\n\nlet x : Result<&str, _> = Err(\"bar\");\nassert_eq!(x.map_or_else(|e| k * 2, |v| v.len()), 42);
Source

pub const fn map_or_default<U, F>(self, f: F) -> U
where\n F: FnOnce(T) -> U,\n U: Default,

🔬This is a nightly-only experimental API. (result_option_map_or_default)

Maps a Result<T, E> to a U by applying function f to the contained\nvalue if the result is Ok, otherwise if Err, returns the\ndefault value for the type U.

\n
§Examples
\n
#![feature(result_option_map_or_default)]\n\nlet x: Result<_, &str> = Ok(\"foo\");\nlet y: Result<&str, _> = Err(\"bar\");\n\nassert_eq!(x.map_or_default(|x| x.len()), 3);\nassert_eq!(y.map_or_default(|y| y.len()), 0);
1.0.0 (const: unstable) · Source

pub fn map_err<F, O>(self, op: O) -> Result<T, F>
where\n O: FnOnce(E) -> F,

Maps a Result<T, E> to Result<T, F> by applying a function to a\ncontained Err value, leaving an Ok value untouched.

\n

This function can be used to pass through a successful result while handling\nan error.

\n
§Examples
\n
fn stringify(x: u32) -> String { format!(\"error code: {x}\") }\n\nlet x: Result<u32, u32> = Ok(2);\nassert_eq!(x.map_err(stringify), Ok(2));\n\nlet x: Result<u32, u32> = Err(13);\nassert_eq!(x.map_err(stringify), Err(\"error code: 13\".to_string()));
1.76.0 (const: unstable) · Source

pub fn inspect<F>(self, f: F) -> Result<T, E>
where\n F: FnOnce(&T),

Calls a function with a reference to the contained value if Ok.

\n

Returns the original result.

\n
§Examples
\n
let x: u8 = \"4\"\n    .parse::<u8>()\n    .inspect(|x| println!(\"original: {x}\"))\n    .map(|x| x.pow(3))\n    .expect(\"failed to parse number\");
1.76.0 (const: unstable) · Source

pub fn inspect_err<F>(self, f: F) -> Result<T, E>
where\n F: FnOnce(&E),

Calls a function with a reference to the contained value if Err.

\n

Returns the original result.

\n
§Examples
\n
use std::{fs, io};\n\nfn read() -> io::Result<String> {\n    fs::read_to_string(\"address.txt\")\n        .inspect_err(|e| eprintln!(\"failed to read file: {e}\"))\n}
1.47.0 (const: unstable) · Source

pub fn as_deref(&self) -> Result<&<T as Deref>::Target, &E>
where\n T: Deref,

Converts from Result<T, E> (or &Result<T, E>) to Result<&<T as Deref>::Target, &E>.

\n

Coerces the Ok variant of the original Result via Deref\nand returns the new Result.

\n
§Examples
\n
let x: Result<String, u32> = Ok(\"hello\".to_string());\nlet y: Result<&str, &u32> = Ok(\"hello\");\nassert_eq!(x.as_deref(), y);\n\nlet x: Result<String, u32> = Err(42);\nlet y: Result<&str, &u32> = Err(&42);\nassert_eq!(x.as_deref(), y);
1.47.0 (const: unstable) · Source

pub fn as_deref_mut(&mut self) -> Result<&mut <T as Deref>::Target, &mut E>
where\n T: DerefMut,

Converts from Result<T, E> (or &mut Result<T, E>) to Result<&mut <T as DerefMut>::Target, &mut E>.

\n

Coerces the Ok variant of the original Result via DerefMut\nand returns the new Result.

\n
§Examples
\n
let mut s = \"HELLO\".to_string();\nlet mut x: Result<String, u32> = Ok(\"hello\".to_string());\nlet y: Result<&mut str, &mut u32> = Ok(&mut s);\nassert_eq!(x.as_deref_mut().map(|x| { x.make_ascii_uppercase(); x }), y);\n\nlet mut i = 42;\nlet mut x: Result<String, u32> = Err(42);\nlet y: Result<&mut str, &mut u32> = Err(&mut i);\nassert_eq!(x.as_deref_mut().map(|x| { x.make_ascii_uppercase(); x }), y);
1.0.0 (const: unstable) · Source

pub fn iter(&self) -> Iter<'_, T>

Returns an iterator over the possibly contained value.

\n

The iterator yields one value if the result is Result::Ok, otherwise none.

\n
§Examples
\n
let x: Result<u32, &str> = Ok(7);\nassert_eq!(x.iter().next(), Some(&7));\n\nlet x: Result<u32, &str> = Err(\"nothing!\");\nassert_eq!(x.iter().next(), None);
1.0.0 (const: unstable) · Source

pub fn iter_mut(&mut self) -> IterMut<'_, T>

Returns a mutable iterator over the possibly contained value.

\n

The iterator yields one value if the result is Result::Ok, otherwise none.

\n
§Examples
\n
let mut x: Result<u32, &str> = Ok(7);\nmatch x.iter_mut().next() {\n    Some(v) => *v = 40,\n    None => {},\n}\nassert_eq!(x, Ok(40));\n\nlet mut x: Result<u32, &str> = Err(\"nothing!\");\nassert_eq!(x.iter_mut().next(), None);
1.4.0 · Source

pub fn expect(self, msg: &str) -> T
where\n E: Debug,

Returns the contained Ok value, consuming the self value.

\n

Because this function may panic, its use is generally discouraged.\nInstead, prefer to use pattern matching and handle the Err\ncase explicitly, or call unwrap_or, unwrap_or_else, or\nunwrap_or_default.

\n
§Panics
\n

Panics if the value is an Err, with a panic message including the\npassed message, and the content of the Err.

\n
§Examples
\n
let x: Result<u32, &str> = Err(\"emergency failure\");\nx.expect(\"Testing expect\"); // panics with `Testing expect: emergency failure`
§Recommended Message Style
\n

We recommend that expect messages are used to describe the reason you\nexpect the Result should be Ok.

\n\n
let path = std::env::var(\"IMPORTANT_PATH\")\n    .expect(\"env variable `IMPORTANT_PATH` should be set by `wrapper_script.sh`\");
\n

Hint: If you’re having trouble remembering how to phrase expect\nerror messages remember to focus on the word “should” as in “env\nvariable should be set by blah” or “the given binary should be available\nand executable by the current user”.

\n

For more detail on expect message styles and the reasoning behind our recommendation please\nrefer to the section on “Common Message\nStyles” in the\nstd::error module docs.

\n
1.0.0 · Source

pub fn unwrap(self) -> T
where\n E: Debug,

Returns the contained Ok value, consuming the self value.

\n

Because this function may panic, its use is generally discouraged.\nPanics are meant for unrecoverable errors, and\nmay abort the entire program.

\n

Instead, prefer to use the ? (try) operator, or pattern matching\nto handle the Err case explicitly, or call unwrap_or,\nunwrap_or_else, or unwrap_or_default.

\n
§Panics
\n

Panics if the value is an Err, with a panic message provided by the\nErr’s value.

\n
§Examples
\n

Basic usage:

\n\n
let x: Result<u32, &str> = Ok(2);\nassert_eq!(x.unwrap(), 2);
\n
let x: Result<u32, &str> = Err(\"emergency failure\");\nx.unwrap(); // panics with `emergency failure`
1.16.0 (const: unstable) · Source

pub fn unwrap_or_default(self) -> T
where\n T: Default,

Returns the contained Ok value or a default

\n

Consumes the self argument then, if Ok, returns the contained\nvalue, otherwise if Err, returns the default value for that\ntype.

\n
§Examples
\n

Converts a string to an integer, turning poorly-formed strings\ninto 0 (the default value for integers). parse converts\na string to any other type that implements FromStr, returning an\nErr on error.

\n\n
let good_year_from_input = \"1909\";\nlet bad_year_from_input = \"190blarg\";\nlet good_year = good_year_from_input.parse().unwrap_or_default();\nlet bad_year = bad_year_from_input.parse().unwrap_or_default();\n\nassert_eq!(1909, good_year);\nassert_eq!(0, bad_year);
1.17.0 · Source

pub fn expect_err(self, msg: &str) -> E
where\n T: Debug,

Returns the contained Err value, consuming the self value.

\n
§Panics
\n

Panics if the value is an Ok, with a panic message including the\npassed message, and the content of the Ok.

\n
§Examples
\n
let x: Result<u32, &str> = Ok(10);\nx.expect_err(\"Testing expect_err\"); // panics with `Testing expect_err: 10`
1.0.0 · Source

pub fn unwrap_err(self) -> E
where\n T: Debug,

Returns the contained Err value, consuming the self value.

\n
§Panics
\n

Panics if the value is an Ok, with a custom panic message provided\nby the Ok’s value.

\n
§Examples
\n
let x: Result<u32, &str> = Ok(2);\nx.unwrap_err(); // panics with `2`
\n
let x: Result<u32, &str> = Err(\"emergency failure\");\nassert_eq!(x.unwrap_err(), \"emergency failure\");
Source

pub const fn into_ok(self) -> T
where\n E: Into<!>,

🔬This is a nightly-only experimental API. (unwrap_infallible)

Returns the contained Ok value, but never panics.

\n

Unlike unwrap, this method is known to never panic on the\nresult types it is implemented for. Therefore, it can be used\ninstead of unwrap as a maintainability safeguard that will fail\nto compile if the error type of the Result is later changed\nto an error that can actually occur.

\n
§Examples
\n
\nfn only_good_news() -> Result<String, !> {\n    Ok(\"this is fine\".into())\n}\n\nlet s: String = only_good_news().into_ok();\nprintln!(\"{s}\");
Source

pub const fn into_err(self) -> E
where\n T: Into<!>,

🔬This is a nightly-only experimental API. (unwrap_infallible)

Returns the contained Err value, but never panics.

\n

Unlike unwrap_err, this method is known to never panic on the\nresult types it is implemented for. Therefore, it can be used\ninstead of unwrap_err as a maintainability safeguard that will fail\nto compile if the ok type of the Result is later changed\nto a type that can actually occur.

\n
§Examples
\n
\nfn only_bad_news() -> Result<!, String> {\n    Err(\"Oops, it failed\".into())\n}\n\nlet error: String = only_bad_news().into_err();\nprintln!(\"{error}\");
1.0.0 (const: unstable) · Source

pub fn and<U>(self, res: Result<U, E>) -> Result<U, E>

Returns res if the result is Ok, otherwise returns the Err value of self.

\n

Arguments passed to and are eagerly evaluated; if you are passing the\nresult of a function call, it is recommended to use and_then, which is\nlazily evaluated.

\n
§Examples
\n
let x: Result<u32, &str> = Ok(2);\nlet y: Result<&str, &str> = Err(\"late error\");\nassert_eq!(x.and(y), Err(\"late error\"));\n\nlet x: Result<u32, &str> = Err(\"early error\");\nlet y: Result<&str, &str> = Ok(\"foo\");\nassert_eq!(x.and(y), Err(\"early error\"));\n\nlet x: Result<u32, &str> = Err(\"not a 2\");\nlet y: Result<&str, &str> = Err(\"late error\");\nassert_eq!(x.and(y), Err(\"not a 2\"));\n\nlet x: Result<u32, &str> = Ok(2);\nlet y: Result<&str, &str> = Ok(\"different result type\");\nassert_eq!(x.and(y), Ok(\"different result type\"));
1.0.0 (const: unstable) · Source

pub fn and_then<U, F>(self, op: F) -> Result<U, E>
where\n F: FnOnce(T) -> Result<U, E>,

Calls op if the result is Ok, otherwise returns the Err value of self.

\n

This function can be used for control flow based on Result values.

\n
§Examples
\n
fn sq_then_to_string(x: u32) -> Result<String, &'static str> {\n    x.checked_mul(x).map(|sq| sq.to_string()).ok_or(\"overflowed\")\n}\n\nassert_eq!(Ok(2).and_then(sq_then_to_string), Ok(4.to_string()));\nassert_eq!(Ok(1_000_000).and_then(sq_then_to_string), Err(\"overflowed\"));\nassert_eq!(Err(\"not a number\").and_then(sq_then_to_string), Err(\"not a number\"));
\n

Often used to chain fallible operations that may return Err.

\n\n
use std::{io::ErrorKind, path::Path};\n\n// Note: on Windows \"/\" maps to \"C:\\\"\nlet root_modified_time = Path::new(\"/\").metadata().and_then(|md| md.modified());\nassert!(root_modified_time.is_ok());\n\nlet should_fail = Path::new(\"/bad/path\").metadata().and_then(|md| md.modified());\nassert!(should_fail.is_err());\nassert_eq!(should_fail.unwrap_err().kind(), ErrorKind::NotFound);
1.0.0 (const: unstable) · Source

pub fn or<F>(self, res: Result<T, F>) -> Result<T, F>

Returns res if the result is Err, otherwise returns the Ok value of self.

\n

Arguments passed to or are eagerly evaluated; if you are passing the\nresult of a function call, it is recommended to use or_else, which is\nlazily evaluated.

\n
§Examples
\n
let x: Result<u32, &str> = Ok(2);\nlet y: Result<u32, &str> = Err(\"late error\");\nassert_eq!(x.or(y), Ok(2));\n\nlet x: Result<u32, &str> = Err(\"early error\");\nlet y: Result<u32, &str> = Ok(2);\nassert_eq!(x.or(y), Ok(2));\n\nlet x: Result<u32, &str> = Err(\"not a 2\");\nlet y: Result<u32, &str> = Err(\"late error\");\nassert_eq!(x.or(y), Err(\"late error\"));\n\nlet x: Result<u32, &str> = Ok(2);\nlet y: Result<u32, &str> = Ok(100);\nassert_eq!(x.or(y), Ok(2));
1.0.0 (const: unstable) · Source

pub fn or_else<F, O>(self, op: O) -> Result<T, F>
where\n O: FnOnce(E) -> Result<T, F>,

Calls op if the result is Err, otherwise returns the Ok value of self.

\n

This function can be used for control flow based on result values.

\n
§Examples
\n
fn sq(x: u32) -> Result<u32, u32> { Ok(x * x) }\nfn err(x: u32) -> Result<u32, u32> { Err(x) }\n\nassert_eq!(Ok(2).or_else(sq).or_else(sq), Ok(2));\nassert_eq!(Ok(2).or_else(err).or_else(sq), Ok(2));\nassert_eq!(Err(3).or_else(sq).or_else(err), Ok(9));\nassert_eq!(Err(3).or_else(err).or_else(err), Err(3));
1.0.0 (const: unstable) · Source

pub fn unwrap_or(self, default: T) -> T

Returns the contained Ok value or a provided default.

\n

Arguments passed to unwrap_or are eagerly evaluated; if you are passing\nthe result of a function call, it is recommended to use unwrap_or_else,\nwhich is lazily evaluated.

\n
§Examples
\n
let default = 2;\nlet x: Result<u32, &str> = Ok(9);\nassert_eq!(x.unwrap_or(default), 9);\n\nlet x: Result<u32, &str> = Err(\"error\");\nassert_eq!(x.unwrap_or(default), default);
1.0.0 (const: unstable) · Source

pub fn unwrap_or_else<F>(self, op: F) -> T
where\n F: FnOnce(E) -> T,

Returns the contained Ok value or computes it from a closure.

\n
§Examples
\n
fn count(x: &str) -> usize { x.len() }\n\nassert_eq!(Ok(2).unwrap_or_else(count), 2);\nassert_eq!(Err(\"foo\").unwrap_or_else(count), 3);
1.58.0 (const: unstable) · Source

pub unsafe fn unwrap_unchecked(self) -> T

Returns the contained Ok value, consuming the self value,\nwithout checking that the value is not an Err.

\n
§Safety
\n

Calling this method on an Err is undefined behavior.

\n
§Examples
\n
let x: Result<u32, &str> = Ok(2);\nassert_eq!(unsafe { x.unwrap_unchecked() }, 2);
\n
let x: Result<u32, &str> = Err(\"emergency failure\");\nunsafe { x.unwrap_unchecked() }; // Undefined behavior!
1.58.0 · Source

pub unsafe fn unwrap_err_unchecked(self) -> E

Returns the contained Err value, consuming the self value,\nwithout checking that the value is not an Ok.

\n
§Safety
\n

Calling this method on an Ok is undefined behavior.

\n
§Examples
\n
let x: Result<u32, &str> = Ok(2);\nunsafe { x.unwrap_err_unchecked() }; // Undefined behavior!
\n
let x: Result<u32, &str> = Err(\"emergency failure\");\nassert_eq!(unsafe { x.unwrap_err_unchecked() }, \"emergency failure\");
",0,"jellytau_lib::storage::db_service::DbResult"],["
Source§

impl<T, E> Serialize for Result<T, E>
where\n T: Serialize,\n E: Serialize,

Available on crate feature result only.
Source§

fn serialize<S>(\n &self,\n serializer: S,\n) -> Result<<S as Serializer>::Ok, <S as Serializer>::Error>
where\n S: Serializer,

Serialize this value into the given Serde serializer. Read more
","Serialize","jellytau_lib::storage::db_service::DbResult"],["
Source§

impl<T, TAs, E, EAs> SerializeAs<Result<T, E>> for Result<TAs, EAs>
where\n TAs: SerializeAs<T>,\n EAs: SerializeAs<E>,

Source§

fn serialize_as<S>(\n source: &Result<T, E>,\n serializer: S,\n) -> Result<<S as Serializer>::Ok, <S as Serializer>::Error>
where\n S: Serializer,

Serialize this value into the given Serde serializer.
","SerializeAs>","jellytau_lib::storage::db_service::DbResult"],["
1.16.0 · Source§

impl<T, U, E> Sum<Result<U, E>> for Result<T, E>
where\n T: Sum<U>,

Source§

fn sum<I>(iter: I) -> Result<T, E>
where\n I: Iterator<Item = Result<U, E>>,

Takes each element in the Iterator: if it is an Err, no further\nelements are taken, and the Err is returned. Should no Err\noccur, the sum of all elements is returned.

\n
§Examples
\n

This sums up every integer in a vector, rejecting the sum if a negative\nelement is encountered:

\n\n
let f = |&x: &i32| if x < 0 { Err(\"Negative element found\") } else { Ok(x) };\nlet v = vec![1, 2];\nlet res: Result<i32, _> = v.iter().map(f).sum();\nassert_eq!(res, Ok(3));\nlet v = vec![1, -2];\nlet res: Result<i32, _> = v.iter().map(f).sum();\nassert_eq!(res, Err(\"Negative element found\"));
","Sum>","jellytau_lib::storage::db_service::DbResult"],["
1.61.0 · Source§

impl<T, E> Termination for Result<T, E>
where\n T: Termination,\n E: Debug,

Source§

fn report(self) -> ExitCode

Is called to get the representation of the value as status code.\nThis status code is returned to the operating system.
","Termination","jellytau_lib::storage::db_service::DbResult"],["
Source§

impl<T, E> Try for Result<T, E>

Source§

type Output = T

🔬This is a nightly-only experimental API. (try_trait_v2)
The type of the value produced by ? when not short-circuiting.
Source§

type Residual = Result<Infallible, E>

🔬This is a nightly-only experimental API. (try_trait_v2)
The type of the value passed to FromResidual::from_residual\nas part of ? when short-circuiting. Read more
Source§

fn from_output(output: <Result<T, E> as Try>::Output) -> Result<T, E>

🔬This is a nightly-only experimental API. (try_trait_v2)
Constructs the type from its Output type. Read more
Source§

fn branch(\n self,\n) -> ControlFlow<<Result<T, E> as Try>::Residual, <Result<T, E> as Try>::Output>

🔬This is a nightly-only experimental API. (try_trait_v2)
Used in ? to decide whether the operator should produce a value\n(because this returned ControlFlow::Continue)\nor propagate a value back to the caller\n(because this returned ControlFlow::Break). Read more
","Try","jellytau_lib::storage::db_service::DbResult"],["
§

impl<T, E> TryWriteable for Result<T, E>
where\n T: Writeable,\n E: Writeable + Clone,

§

type Error = E

§

fn try_write_to<W>(\n &self,\n sink: &mut W,\n) -> Result<Result<(), <Result<T, E> as TryWriteable>::Error>, Error>
where\n W: Write + ?Sized,

Writes the content of this writeable to a sink. Read more
§

fn try_write_to_parts<S>(\n &self,\n sink: &mut S,\n) -> Result<Result<(), <Result<T, E> as TryWriteable>::Error>, Error>
where\n S: PartsWrite + ?Sized,

Writes the content of this writeable to a sink with parts (annotations). Read more
§

fn writeable_length_hint(&self) -> LengthHint

Returns a hint for the number of UTF-8 bytes that will be written to the sink. Read more
","TryWriteable","jellytau_lib::storage::db_service::DbResult"],["
Source§

impl<T, E> CloneFromCell for Result<T, E>
where\n T: CloneFromCell,\n E: CloneFromCell,

","CloneFromCell","jellytau_lib::storage::db_service::DbResult"],["
1.0.0 · Source§

impl<T, E> Copy for Result<T, E>
where\n T: Copy,\n E: Copy,

","Copy","jellytau_lib::storage::db_service::DbResult"],["
1.0.0 (const: unstable) · Source§

impl<T, E> Eq for Result<T, E>
where\n T: Eq,\n E: Eq,

","Eq","jellytau_lib::storage::db_service::DbResult"],["
1.0.0 · Source§

impl<T, E> StructuralPartialEq for Result<T, E>

","StructuralPartialEq","jellytau_lib::storage::db_service::DbResult"],["
Source§

impl<T, E> UseCloned for Result<T, E>
where\n T: UseCloned,\n E: UseCloned,

","UseCloned","jellytau_lib::storage::db_service::DbResult"]]]]); + if (window.register_type_impls) { + window.register_type_impls(type_impls); + } else { + window.pending_type_impls = type_impls; + } +})() +//{"start":55,"fragment_lengths":[194376]} \ No newline at end of file diff --git a/api/type.impl/std/primitive.tuple.js b/api/type.impl/std/primitive.tuple.js new file mode 100644 index 00000000..dacda644 --- /dev/null +++ b/api/type.impl/std/primitive.tuple.js @@ -0,0 +1,9 @@ +(function() { + var type_impls = Object.fromEntries([["jellytau_lib",[]]]); + if (window.register_type_impls) { + window.register_type_impls(type_impls); + } else { + window.pending_type_impls = type_impls; + } +})() +//{"start":55,"fragment_lengths":[19]} \ No newline at end of file diff --git a/architecture/01-rust-backend.html b/architecture/01-rust-backend.html new file mode 100644 index 00000000..82c1088a --- /dev/null +++ b/architecture/01-rust-backend.html @@ -0,0 +1,871 @@ + + + + + + Rust Backend - JellyTau Documentation + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+ + + + + + + + + + + + + + + + + + + +
+ +
+ + + + + + + + +
+
+

Rust Backend Architecture

+

Location: src-tauri/src/

+

Media Session State Machine

+

Location: src-tauri/src/player/session.rs

+

The media session tracks the high-level playback context (what kind of media is being consumed) and persists beyond individual playback states. This enables persistent UI (miniplayer for audio) and proper transitions between content types.

+

Architecture Note: The session manager is a separate app-level state manager (not inside PlayerController), coordinated by the commands layer. This maintains clean separation of concerns.

+
stateDiagram-v2
+    [*] --> Idle
+
+    Idle --> AudioActive : play_queue(audio)
+    Idle --> MovieActive : play_item(movie)
+    Idle --> TvShowActive : play_item(episode)
+
+    state "Audio Session" as AudioSession {
+        [*] --> AudioActive
+        AudioActive --> AudioInactive : playback_ended
+        AudioInactive --> AudioActive : resume/play
+        AudioActive --> AudioActive : next/previous
+    }
+
+    state "Movie Session" as MovieSession {
+        [*] --> MovieActive
+        MovieActive --> MovieInactive : playback_ended
+        MovieInactive --> MovieActive : resume
+    }
+
+    state "TV Show Session" as TvShowSession {
+        [*] --> TvShowActive
+        TvShowActive --> TvShowInactive : playback_ended
+        TvShowInactive --> TvShowActive : next_episode/resume
+    }
+
+    AudioSession --> Idle : dismiss/clear_queue
+    AudioSession --> MovieSession : play_item(movie)
+    AudioSession --> TvShowSession : play_item(episode)
+
+    MovieSession --> Idle : dismiss/playback_complete
+    MovieSession --> AudioSession : play_queue(audio)
+
+    TvShowSession --> Idle : dismiss/series_complete
+    TvShowSession --> AudioSession : play_queue(audio)
+
+    note right of Idle
+        No active media session
+        Queue may exist but not playing
+        No miniplayer/video player shown
+    end note
+
+    note right of AudioSession
+        SHOW: Miniplayer (always visible)
+        - Active: Play/pause/skip controls enabled
+        - Inactive: Play button to resume queue
+        Persists until explicit dismiss
+    end note
+
+    note right of MovieSession
+        SHOW: Full video player
+        - Active: Video playing/paused
+        - Inactive: Resume dialog
+        Auto-dismiss when playback ends
+    end note
+
+    note right of TvShowSession
+        SHOW: Full video player + Next Episode UI
+        - Active: Video playing/paused
+        - Inactive: Next episode prompt
+        Auto-dismiss when series ends
+    end note
+
+

Session State Enum:

+
#![allow(unused)]
+fn main() {
+#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
+#[serde(tag = "type", rename_all = "snake_case")]
+pub enum MediaSessionType {
+    /// No active session - browsing library
+    Idle,
+
+    /// Audio playback session (music, audiobooks, podcasts)
+    /// Persists until explicitly dismissed
+    Audio {
+        /// Last/current track being played
+        last_item: Option<MediaItem>,
+        /// True = playing/paused, False = stopped/ended
+        is_active: bool,
+    },
+
+    /// Movie playback (single video, auto-dismiss on end)
+    Movie {
+        item: MediaItem,
+        is_active: bool,  // true = playing/paused, false = ended
+    },
+
+    /// TV show playback (supports next episode auto-advance)
+    TvShow {
+        item: MediaItem,
+        series_id: String,
+        is_active: bool,  // true = playing/paused, false = ended
+    },
+}
+}
+

State Transitions & Rules:

+
+ + + + + + + + + + + + +
From StateEventTo StateUI BehaviorNotes
Idleplay_queue(audio)Audio (active)Show miniplayerCreates audio session
Idleplay_item(movie)Movie (active)Show video playerCreates movie session
Idleplay_item(episode)TvShow (active)Show video playerCreates TV session
Audio (active)playback_endedAudio (inactive)Miniplayer stays visibleQueue preserved
Audio (inactive)play/resumeAudio (active)Miniplayer enabledResume from queue
Audio (active/inactive)dismissIdleHide miniplayerClear session
Audio (active/inactive)play_item(movie)Movie (active)Switch to video playerReplace session
Movie (active)playback_endedIdleHide video playerAuto-dismiss
Movie (active)dismissIdleHide video playerUser dismiss
TvShow (active)playback_endedTvShow (inactive)Show next episode UIWait for user choice
TvShow (inactive)next_episodeTvShow (active)Play next episodeStay in session
TvShow (inactive)series_completeIdleHide video playerNo more episodes
+
+

Key Design Decisions:

+
    +
  1. Audio Sessions Persist: Miniplayer stays visible even when queue ends, allows easy resume
  2. +
  3. Video Sessions Auto-Dismiss: Movies auto-close when finished (unless paused)
  4. +
  5. Single Active Session: Playing new content type replaces current session
  6. +
  7. Explicit Dismiss for Audio: User must click close button to clear audio session
  8. +
  9. Session != PlayerState: Session is higher-level, PlayerState tracks playing/paused/seeking
  10. +
+

Edge Cases Handled:

+
    +
  • Album finishes: Session goes inactive, miniplayer shows last track with play disabled
  • +
  • User wants to dismiss: Close button clears session -> Idle
  • +
  • Switch content types: New session replaces old (audio -> movie)
  • +
  • Paused for extended time: Session persists indefinitely
  • +
  • Playback errors: Session stays inactive, allows retry
  • +
  • Queue operations while idle: Queue exists but no session created until play
  • +
+

Player State Machine (Low-Level Playback)

+

Location: src-tauri/src/player/state.rs

+

The player uses a deterministic state machine with 6 states (operates within a media session):

+
stateDiagram-v2
+    [*] --> Idle
+    Idle --> Loading : Load
+    Loading --> Playing : MediaLoaded
+    Playing --> Paused : Pause
+    Paused --> Playing : Play
+    Paused --> Seeking : Seek
+    Seeking --> Playing : PositionUpdate
+    Playing --> Idle : Stop
+    Paused --> Idle : Stop
+    Idle --> Error : Error
+    Loading --> Error : Error
+    Playing --> Error : Error
+    Paused --> Error : Error
+    Seeking --> Error : Error
+
+    state Playing {
+        [*] : position, duration
+    }
+    state Paused {
+        [*] : position, duration
+    }
+    state Seeking {
+        [*] : target
+    }
+    state Error {
+        [*] : error message
+    }
+
+

State Enum:

+
#![allow(unused)]
+fn main() {
+pub enum PlayerState {
+    Idle,
+    Loading { media: MediaItem },
+    Playing { media: MediaItem, position: f64, duration: f64 },
+    Paused { media: MediaItem, position: f64, duration: f64 },
+    Seeking { media: MediaItem, target: f64 },
+    Error { media: Option<MediaItem>, error: String },
+}
+}
+

Event Enum:

+
#![allow(unused)]
+fn main() {
+pub enum PlayerEvent {
+    Load(MediaItem),
+    Play,
+    Pause,
+    Stop,
+    Seek(f64),
+    Next,
+    Previous,
+    MediaLoaded(f64),      // duration
+    PositionUpdate(f64),   // position
+    PlaybackEnded,
+    Error(String),
+}
+}
+

Playback Mode State Machine

+

Location: src-tauri/src/playback_mode/mod.rs

+

The playback mode manages whether media is playing locally on the device or remotely on another Jellyfin session (TV, browser, etc.):

+
stateDiagram-v2
+    [*] --> Idle
+
+    Idle --> Local : play_queue()
+    Idle --> Remote : transfer_to_remote(session_id)
+
+    Local --> Remote : transfer_to_remote(session_id)
+    Local --> Idle : stop()
+
+    Remote --> Local : transfer_to_local()
+    Remote --> Idle : session_disconnected()
+    Remote --> Idle : stop()
+
+    state Local {
+        [*] : Playing on device
+        [*] : ExoPlayer active
+        [*] : Volume buttons -> device
+    }
+
+    state Remote {
+        [*] : Controlling session
+        [*] : session_id
+        [*] : Volume buttons -> remote
+        [*] : Android: VolumeProvider active
+    }
+
+    state Idle {
+        [*] : No active playback
+    }
+
+

State Enum:

+
#![allow(unused)]
+fn main() {
+pub enum PlaybackMode {
+    Local,                           // Playing on local device
+    Remote { session_id: String },   // Controlling remote Jellyfin session
+    Idle,                            // No active playback
+}
+}
+

State Transitions:

+
+ + + + + + + +
FromEventToSide Effects
Idleplay_queue()LocalStart local playback
Idletransfer_to_remote(session_id)RemoteSend queue to remote session
Localtransfer_to_remote(session_id)RemoteStop local, send queue to remote, enable remote volume (Android)
Localstop()IdleStop local playback
Remotetransfer_to_local()LocalGet remote state, stop remote, start local at same position, disable remote volume
Remotestop()IdleStop remote playback, disable remote volume
Remotesession_disconnected()IdleSession lost, disable remote volume
+
+

Integration with Player State Machine:

+
    +
  • When PlaybackMode = Local: Player state machine is active (Idle/Loading/Playing/Paused/etc.)
  • +
  • When PlaybackMode = Remote: Player state is typically Idle (remote session controls playback)
  • +
  • When PlaybackMode = Idle: Player state is Idle
  • +
+

Android Volume Control Integration:

+

When transitioning to Remote mode on Android:

+
    +
  1. Call enable_remote_volume(initial_volume)
  2. +
  3. VolumeProviderCompat intercepts hardware volume buttons
  4. +
  5. PlaybackStateCompat is set to STATE_PLAYING (shows volume UI)
  6. +
  7. Volume commands routed to remote session via Jellyfin API
  8. +
+

When transitioning away from Remote mode:

+
    +
  1. Call disable_remote_volume()
  2. +
  3. Volume buttons return to controlling device volume
  4. +
  5. PlaybackStateCompat set to STATE_NONE
  6. +
  7. VolumeProviderCompat is cleared
  8. +
+

Media Item & Source

+

Location: src-tauri/src/player/media.rs

+
#![allow(unused)]
+fn main() {
+pub struct MediaItem {
+    pub id: String,
+    pub title: String,
+    pub artist: Option<String>,
+    pub album: Option<String>,
+    pub duration: Option<f64>,
+    pub artwork_url: Option<String>,
+    pub media_type: MediaType,
+    pub source: MediaSource,
+}
+
+pub enum MediaType {
+    Audio,
+    Video,
+}
+
+pub enum MediaSource {
+    Remote {
+        stream_url: String,
+        jellyfin_item_id: String,
+    },
+    Local {
+        file_path: PathBuf,
+        jellyfin_item_id: Option<String>,
+    },
+    DirectUrl {
+        url: String,
+    },
+}
+}
+

The MediaSource enum enables:

+
    +
  • Remote: Streaming from Jellyfin server
  • +
  • Local: Downloaded/cached files (future offline support)
  • +
  • DirectUrl: Direct URLs (channel plugins, external sources)
  • +
+

Queue Manager

+

Location: src-tauri/src/player/queue.rs

+
#![allow(unused)]
+fn main() {
+pub struct QueueManager {
+    items: Vec<MediaItem>,
+    current_index: Option<usize>,
+    shuffle: bool,
+    repeat: RepeatMode,
+    shuffle_order: Vec<usize>,  // Fisher-Yates permutation
+    history: Vec<usize>,        // For back navigation in shuffle
+}
+
+pub enum RepeatMode {
+    Off,
+    All,
+    One,
+}
+}
+

Queue Navigation Logic:

+
flowchart TB
+    QM[QueueManager]
+    QM --> Shuffle
+    QM --> Repeat
+    QM --> History
+
+    subgraph Shuffle["Shuffle Mode"]
+        ShuffleOff["OFF<br/>next() returns index + 1"]
+        ShuffleOn["ON<br/>next() follows shuffle_order[]"]
+    end
+
+    subgraph Repeat["Repeat Mode"]
+        RepeatOff["OFF<br/>next() at end: -> None"]
+        RepeatAll["ALL<br/>next() at end: -> wrap to index 0"]
+        RepeatOne["ONE<br/>next() returns same item"]
+    end
+
+    subgraph History["History"]
+        HistoryDesc["Used for previous()<br/>in shuffle mode"]
+    end
+
+

Favorites System

+

Location:

+
    +
  • Commands: src-tauri/src/commands/favorites.rs (offline drain), +src-tauri/src/commands/repository.rs (query + toggle), +src-tauri/src/commands/storage/ (local user_data writes)
  • +
  • Repository: get_favorites on the trait, implemented by online.rs, +offline.rs and hybrid.rs
  • +
  • Frontend: src/lib/services/favorites.ts, +src/lib/components/FavoriteButton.svelte, /library/favorites
  • +
+

Favouriting has two halves that are easy to confuse: marking an item, which +has existed since UR-017, and browsing what was marked, which arrived with +UR-067…069 (DR-113 … DR-120). Both go through the repository, not around it.

+

Marking

+

Optimistic local write, then server sync:

+
flowchart TB
+    UI[FavoriteButton] -->|Click| Service[toggleFavorite]
+    Service -->|"1. Optimistic"| LocalDB[("SQLite user_data<br/>is_favorite, pending_sync")]
+    Service -->|"2. Sync"| Repo[Repository]
+    Repo -->|POST / DELETE| JellyfinAPI["/Users/{id}/FavoriteItems/{itemId}"]
+    Service -->|"3. Mark synced"| LocalDB
+    Drain["spawn_favorites_drain<br/>(background task)"] -->|"pending_sync = 1"| Repo
+
+
    +
  1. The local row is updated immediately, so the heart fills without a round trip.
  2. +
  3. The repository is asked to mark or unmark on the server.
  4. +
  5. On success pending_sync is cleared; on failure the row stays pending.
  6. +
  7. A background drain (spawn_favorites_drain, started in lib.rs setup) +retries pending rows, so a favourite marked offline still reaches the server +(DR-120). This is the same pattern as the sync-queue drain — see +Background workers.
  8. +
+

Browsing

+

get_favorites(scope, options) answers "what did this user favourite", across +libraries, with the scope owned by Rust — the frontend sends a +SearchScope variant and never names +an item type. HybridRepository splits it the same way it splits every query:

+
+ + + +
MethodUsed for
get_favorites_cache_onlyThe instant leg — the local user_data join
get_favorites_server_onlyThe reconciliation leg
get_favoritesCache-first with server merge, per the repository's usual policy
+
+

GetItemsOptions.favorites_only is the other entry point: it filters an +existing library listing rather than starting a cross-library query (DR-116), +which is what a library page's favourites filter uses.

+

Server favourite state is mirrored into the local user_data table on catalog +sync (DR-113/DR-114), so a favourite marked in another Jellyfin client shows up +here — before this, MediaItem.user_data was left empty and no query anywhere +asked for favourites.

+

Tauri commands:

+
+ + + + +
CommandDescription
repository_get_favoritesCross-library favourites for a scope
repository_mark_favorite / repository_unmark_favoriteToggle on the server, through the repository
storage_toggle_favoriteLocal optimistic write (is_favorite, pending_sync)
storage_mark_syncedClear pending_sync after a successful server write
+
+

Frontend surfaces (DR-117 … DR-119): the /library/favorites page with a +scope selector, favourite rows on home (favoriteMovies / favoriteShows / +favoriteMusic in stores/home.ts), a favourites tile per category in the +library mosaic, and FavoriteButton mounted wherever a whole item is shown — +movie, series, episode, album, artist and playlist detail views as well as the +mini player.

+

Player Backend Trait

+

Location: src-tauri/src/player/backend.rs

+
#![allow(unused)]
+fn main() {
+pub trait PlayerBackend: Send + Sync {
+    fn load(&mut self, media: &MediaItem) -> Result<(), PlayerError>;
+    fn play(&mut self) -> Result<(), PlayerError>;
+    fn pause(&mut self) -> Result<(), PlayerError>;
+    fn stop(&mut self) -> Result<(), PlayerError>;
+    fn seek(&mut self, position: f64) -> Result<(), PlayerError>;
+    fn set_volume(&mut self, volume: f32) -> Result<(), PlayerError>;
+    fn position(&self) -> f64;
+    fn duration(&self) -> Option<f64>;
+    fn state(&self) -> PlayerState;
+    fn is_loaded(&self) -> bool;
+    fn volume(&self) -> f32;
+}
+}
+

Implementations:

+ +

Player Controller

+

Location: src-tauri/src/player/mod.rs

+

The PlayerController orchestrates playback:

+
#![allow(unused)]
+fn main() {
+pub struct PlayerController {
+    backend: Arc<Mutex<Box<dyn PlayerBackend>>>,
+    queue: Arc<Mutex<QueueManager>>,
+    muted: bool,
+    sleep_timer: Arc<Mutex<SleepTimerState>>,
+    autoplay_settings: Arc<Mutex<AutoplaySettings>>,
+    autoplay_episode_count: Arc<Mutex<u32>>,  // Session-based counter
+    repository: Arc<Mutex<Option<Arc<dyn MediaRepository>>>>,
+    event_emitter: Arc<Mutex<Option<Arc<dyn PlayerEventEmitter>>>>,
+    // ... other fields
+}
+}
+

Key Methods:

+
    +
  • play_item(item): Load and play single item (resets autoplay counter)
  • +
  • play_queue(items, start_index): Load queue and start playback (resets autoplay counter)
  • +
  • next() / previous(): Queue navigation (resets autoplay counter)
  • +
  • toggle_shuffle() / cycle_repeat(): Mode changes
  • +
  • set_sleep_timer(mode) / cancel_sleep_timer(): Sleep timer control
  • +
  • on_playback_ended(): Autoplay decision making (checks sleep timer, episode limit, queue)
  • +
+

Playlist System

+

Location: src-tauri/src/commands/playlist.rs, src-tauri/src/repository/

+

TRACES: UR-014 | JA-019 | JA-020

+

The playlist system provides full CRUD operations for Jellyfin playlists with offline support through the cache-first repository pattern.

+

Types:

+
#![allow(unused)]
+fn main() {
+/// A media item within a playlist, with its distinct playlist entry ID
+#[derive(Debug, Clone, Serialize, Deserialize)]
+#[serde(rename_all = "camelCase")]
+pub struct PlaylistEntry {
+    /// Jellyfin's PlaylistItemId (distinct from the media item ID)
+    pub playlist_item_id: String,
+    #[serde(flatten)]
+    pub item: MediaItem,
+}
+
+/// Result of creating a new playlist
+#[derive(Debug, Clone, Serialize, Deserialize)]
+#[serde(rename_all = "camelCase")]
+pub struct PlaylistCreatedResult {
+    pub id: String,
+}
+}
+

Key Design Decision: PlaylistEntry wraps a MediaItem with a distinct playlist_item_id. This is critical because removing items from a playlist requires the playlist entry ID (not the media item ID), since the same track can appear multiple times.

+

MediaRepository Trait Methods:

+
#![allow(unused)]
+fn main() {
+async fn create_playlist(&self, name: &str, item_ids: Option<Vec<String>>) -> Result<PlaylistCreatedResult, RepoError>;
+async fn delete_playlist(&self, playlist_id: &str) -> Result<(), RepoError>;
+async fn rename_playlist(&self, playlist_id: &str, name: &str) -> Result<(), RepoError>;
+async fn get_playlist_items(&self, playlist_id: &str) -> Result<Vec<PlaylistEntry>, RepoError>;
+async fn add_to_playlist(&self, playlist_id: &str, item_ids: Vec<String>) -> Result<(), RepoError>;
+async fn remove_from_playlist(&self, playlist_id: &str, entry_ids: Vec<String>) -> Result<(), RepoError>;
+async fn move_playlist_item(&self, playlist_id: &str, item_id: &str, new_index: u32) -> Result<(), RepoError>;
+}
+

Cache Strategy:

+
    +
  • Write operations (create, delete, rename, add, remove, move): Delegate directly to online repository
  • +
  • Read operation (get_playlist_items): Uses cache-first parallel racing (100ms cache timeout, server fallback)
  • +
  • Background cache update after server fetch via save_playlist_items_to_cache()
  • +
+

Playlist Tauri Commands:

+
+ + + + + + + +
CommandParametersReturns
playlist_createhandle, name, item_ids?PlaylistCreatedResult
playlist_deletehandle, playlist_id()
playlist_renamehandle, playlist_id, name()
playlist_get_itemshandle, playlist_idVec<PlaylistEntry>
playlist_add_itemshandle, playlist_id, item_ids()
playlist_remove_itemshandle, playlist_id, entry_ids()
playlist_move_itemhandle, playlist_id, item_id, new_index()
+
+

Tauri Commands (Player)

+

Location: src-tauri/src/commands/player.rs

+
+ + + + + + + + + + + + + + + + + + + + + + + +
CommandParametersReturns
player_play_itemPlayItemRequestPlayerStatus
player_play_queueitems, start_index, shufflePlayerStatus
player_play-PlayerStatus
player_pause-PlayerStatus
player_toggle-PlayerStatus
player_stop-PlayerStatus
player_next-PlayerStatus
player_previous-PlayerStatus
player_seekposition: f64PlayerStatus
player_set_volumevolume: f32PlayerStatus
player_toggle_shuffle-QueueStatus
player_cycle_repeat-QueueStatus
player_get_status-PlayerStatus
player_get_queue-QueueStatus
player_get_session-MediaSessionType
player_dismiss_session-()
player_set_sleep_timermode: SleepTimerMode()
player_cancel_sleep_timer-()
player_set_video_settingssettings: VideoSettingsVideoSettings
player_get_video_settings-VideoSettings
player_set_autoplay_settingssettings: AutoplaySettingsAutoplaySettings
player_get_autoplay_settings-AutoplaySettings
player_on_playback_ended-()
+
+

Domain Vocabulary Owned by Rust

+

The frontend is presentation-only and must not encode Jellyfin's taxonomy — the +rule in CLAUDE.md and +scoped-search-boundary.md. These are the +places where that vocabulary actually lives.

+

Search scope and the taxonomy boundary

+

Location: src-tauri/src/repository/types.rs

+

SearchScope is the canonical example the boundary rule is taught from. The +frontend sends an opaque variant; Rust expands it into Jellyfin item types:

+
#![allow(unused)]
+fn main() {
+pub enum SearchScope { All, Music, Movies, Tv }
+
+impl SearchScope {
+    /// The Jellyfin item types this scope requests, or `None` for `All`.
+    pub fn item_types(self) -> Option<Vec<String>> { … }
+
+    /// The scope a library of this Jellyfin `CollectionType` belongs to.
+    pub fn for_collection_type(collection_type: &str) -> Option<SearchScope> { … }
+}
+}
+

Two details that are load-bearing:

+
    +
  • All returns None, not the union of every listed type. An explicit +includeItemTypes list filters out anything not named in it, so a union would +silently drop People, folders, and any type nobody enumerated. Callers must +omit the filter entirely on None.
  • +
  • for_collection_type maps a Jellyfin CollectionType to a favourites +category (DR-175). It changes when Jellyfin renames a collection type, not +when the library page is redesigned — which is the test for whether something +belongs on this side of the boundary.
  • +
+

⚠️ The result side has not moved yet. GROUP_ITEM_TYPES in +src/lib/utils/searchScope.ts still maps result groups to item types in the +frontend, and check:boundary does not match its shape. Tracked as Stage 2 of +scoped-search-boundary-implementation.md.

+

Library exclusions

+

Location: src-tauri/src/repository/exclusions.rs (TRACES: UR-076 | DR-209)

+

Folders the user has chosen to keep out of music browsing — a "Podcasts" folder +inside a music library being the canonical case. Excluded by item id, not by +name, in a process-wide RwLock<Vec<String>> restored from the database at +startup, and applied by the repository layer to every music query (libraries, +artists, albums, genres, search, home rows).

+

The id is normalised (trim, strip -, lowercase) because Jellyfin writes the +same GUID both dashed and undashed depending on the endpoint. The predecessor was +a frontend filter matching the English string "Podcasts" — wrong in three ways at +once, and the reason this lives in the repository.

+

The set is process-wide rather than a field on a repository for the same reason +as online::STREAMING_QUALITY: it is a preference about this user's browsing, +not about a server session, so it must survive a repository being rebuilt on +re-login.

+

Streaming quality ladder

+

Location: src-tauri/src/settings.rs (TRACES: UR-074 | DR-162)

+

StreamingQuality is a bandwidth ladder (Original, 20/10/8/4/2/1 Mbps, +720 kbps), not a resolution picker: it exists to fit a connection, and the +resolution cap is chosen from the bitrate so the encoder does not spend a small +budget on pixels it cannot afford.

+
+ + + + +
MethodAnswers
max_bitrate()Total bits/s (video + audio), None for Original
audio_bitrate()The audio share — shrinks down the ladder, so 384 kbps is not a third of the budget at the bottom
video_bitrate()Total minus audio, so the two together honour the ceiling
max_height()Resolution ceiling that suits the bitrate
+
+

The ceiling goes to PlaybackInfo as MaxStreamingBitrate and into the +device profile. Sending it there — not just on the transcode URL — is what makes +the cap real: a stream the server decides to direct play is served at the +source file's own bitrate, and no URL parameter afterwards can reduce it.

+

The frontend names a variant and nothing else; the labels the picker shows are +served over IPC by player_get_streaming_qualities.

+

Background workers

+

Three long-lived tasks are spawned from the Tauri setup hook in lib.rs. All +three exist because when something happens is a backend policy, not something +a page load should decide.

+
+ + + +
WorkerLocationResponsibility
spawn_catalog_indexercommands/catalog.rsKeeps the local FTS5 catalog fresh (DR-109, IR-030)
spawn_favorites_draincommands/favorites.rsRetries favourite toggles made while offline (DR-120)
spawn_sync_queue_draincommands/sync_drain.rsDrains the offline mutation queue (DR-131)
+
+

Catalog indexer

+

Replaces the frontend's startup-only syncCatalog() call. It ticks on +CATALOG_INDEX_TICK and runs a pass when three things hold: a repository exists, +the server is reachable, and the index is due per index_is_due. A tick is +nearly free — one indexed app_settings lookup — which is what makes it +responsive to events it cannot subscribe to, such as signing in: a fresh install +would otherwise sit unindexed until the next scheduled pass.

+

index_is_due treats both "never indexed" and an unparseable stored timestamp as +due; a corrupt timestamp should trigger a re-index, not silently freeze the +catalog. A failed pass is never fatal — it leaves the existing index in place and +warns. Progress is emitted on CATALOG_INDEX_EVENT for the staleness hint in the +UI.

+ +
+ + +
+
+ + + +
+ + + + + + + + + + + + + + + + + + +
+ + diff --git a/architecture/02-svelte-frontend.html b/architecture/02-svelte-frontend.html new file mode 100644 index 00000000..876a5de3 --- /dev/null +++ b/architecture/02-svelte-frontend.html @@ -0,0 +1,997 @@ + + + + + + Svelte Frontend - JellyTau Documentation + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+ + + + + + + + + + + + + + + + + + + +
+ +
+ + + + + + + + +
+
+

Svelte Frontend Architecture

+

Store Structure

+

Location: src/lib/stores/

+
flowchart TB
+    subgraph Stores
+        subgraph auth["auth.ts"]
+            AuthState["AuthState<br/>- user<br/>- serverUrl<br/>- token<br/>- isLoading"]
+        end
+        subgraph playerStore["player.ts"]
+            PlayerStoreState["PlayerState<br/>- kind<br/>- media<br/>- position<br/>- duration"]
+        end
+        subgraph queueStore["queue.ts"]
+            QueueState["QueueState<br/>- items<br/>- index<br/>- shuffle<br/>- repeat"]
+        end
+        subgraph libraryStore["library.ts"]
+            LibraryState["LibraryState<br/>- libraries<br/>- items<br/>- loading"]
+        end
+        subgraph Derived["Derived Stores"]
+            DerivedList["isAuthenticated, currentUser<br/>isPlaying, isPaused, currentMedia<br/>hasNext, hasPrevious, isShuffle<br/>libraryItems, isLibraryLoading"]
+        end
+    end
+
+

Music Library Architecture

+

Category-Based Navigation:

+

JellyTau's music library uses a category-based navigation system with a dedicated landing page that routes users to specialized views for different content types.

+

Route Structure:

+
graph TD
+    Music["/library/music<br/>(Landing page with category cards)"]
+    Tracks["Tracks<br/>(List view only)"]
+    Artists["Artists<br/>(Grid view)"]
+    Albums["Albums<br/>(Grid view)"]
+    Playlists["Playlists<br/>(Grid view)"]
+    Genres["Genres<br/>(Genre browser)"]
+
+    Music --> Tracks
+    Music --> Artists
+    Music --> Albums
+    Music --> Playlists
+    Music --> Genres
+
+

View Enforcement:

+

Ordinal content (where position carries meaning) is always a list. Everything +else honours the user's persisted grid/list preference — see +ux-flows.md §5A.2.

+
+ + + + + + + +
Content TypeView ModeToggle VisibleComponent Used
TracksList (forced — ordinal)NoTrackList
ArtistsUser preferenceYesLibraryGrid
AlbumsUser preferenceYesLibraryGrid
PlaylistsUser preferenceYesLibraryGrid
GenresUser preference (both levels)YesLibraryGrid
Album Detail TracksList (forced — ordinal)NoTrackList
Season EpisodesList (forced — ordinal)NoSeasonSection
+
+

TrackList Component:

+

The TrackList component (src/lib/components/library/TrackList.svelte) is a dedicated component for displaying songs in list format:

+
    +
  • No Thumbnails: Track numbers only (transform to play button on hover)
  • +
  • Desktop Layout: Table with columns: #, Title, Artist, Album, Duration
  • +
  • Mobile Layout: Compact rows with track number and metadata
  • +
  • Configurable Columns: showArtist and showAlbum props control column visibility
  • +
  • Click Behavior: Clicking a track plays it and queues all filtered tracks
  • +
+

Example Usage:

+
<TrackList
+  tracks={filteredTracks}
+  loading={loading}
+  showArtist={true}
+  showAlbum={true}
+/>
+
+

LibraryGrid view mode:

+

LibraryGrid reads the global viewMode store (persisted to localStorage) +and renders LibraryListView or the card grid accordingly. The showViewToggle +prop controls whether the toggle buttons appear in the page header; the grid +itself always follows the stored preference.

+

A forceGrid prop previously existed to pin pages to grid regardless of +preference. No caller ever passed it, so it was removed — pages that were +documented as "forced grid" have in practice always honoured the toggle.

+

Playback Reporting Service

+

Location: src/lib/services/playbackReporting.ts

+

The playback reporting service ensures playback progress is synced to both the Jellyfin server AND the local SQLite database. This dual-write approach enables:

+
    +
  • Offline "Continue Watching" functionality
  • +
  • Sync queue for when network is unavailable
  • +
  • Consistent progress across app restarts
  • +
+
sequenceDiagram
+    participant VideoPlayer
+    participant PlaybackService as playbackReporting.ts
+    participant LocalDB as Local SQLite<br/>(Tauri Commands)
+    participant Jellyfin as Jellyfin Server
+
+    VideoPlayer->>PlaybackService: reportPlaybackProgress(itemId, position)
+
+    par Local Storage (always works)
+        PlaybackService->>LocalDB: invoke("storage_update_playback_progress")
+        LocalDB-->>PlaybackService: Ok (pending_sync = true)
+    and Server Sync (if online)
+        PlaybackService->>Jellyfin: POST /Sessions/Playing/Progress
+        Jellyfin-->>PlaybackService: Ok
+        PlaybackService->>LocalDB: invoke("storage_mark_synced")
+    end
+
+

Service Functions:

+
    +
  • reportPlaybackStart(itemId, positionSeconds) - Called when playback begins
  • +
  • reportPlaybackProgress(itemId, positionSeconds, isPaused) - Called periodically (every 10s)
  • +
  • reportPlaybackStopped(itemId, positionSeconds) - Called when player closes or video ends
  • +
+

Tauri Commands:

+
+ + + + +
CommandDescription
storage_update_playback_progressUpdate position in local DB (marks pending_sync = true)
storage_mark_playedMark item as played, increment play count
storage_get_playback_progressGet stored progress for an item
storage_mark_syncedClear pending_sync flag after successful server sync
+
+

Database Schema Notes:

+
    +
  • The user_data table stores playback progress using Jellyfin IDs directly (as TEXT)
  • +
  • Playback progress can be tracked even when the full item metadata hasn't been downloaded yet
  • +
+

Resume Playback Feature:

+
    +
  • When loading media for playback, the app checks local database for saved progress
  • +
  • If progress exists (>30 seconds watched and <90% complete), shows resume dialog
  • +
  • User can choose to "Resume" from saved position or "Start from Beginning"
  • +
  • For video: Uses startTimeSeconds parameter in stream URL to begin transcoding from resume point
  • +
  • For audio: Seeks to resume position after loading via MPV backend
  • +
  • Implemented in src/routes/player/[id]/+page.svelte
  • +
+

Repository Architecture (Rust-Based)

+

Location: src-tauri/src/repository/

+
classDiagram
+    class MediaRepository {
+        <<trait>>
+        +get_libraries()
+        +get_items(parent_id, options)
+        +get_item(item_id)
+        +search(query, options)
+        +get_latest_items(parent_id, limit)
+        +get_resume_items(parent_id, limit)
+        +get_next_up_episodes(series_id, limit)
+        +get_genres(parent_id)
+        +get_playback_info(item_id)
+        +report_playback_start(item_id, position_ticks)
+        +report_playback_progress(item_id, position_ticks, is_paused)
+        +report_playback_stopped(item_id, position_ticks)
+        +mark_favorite(item_id)
+        +unmark_favorite(item_id)
+        +get_person(person_id)
+        +get_items_by_person(person_id, options)
+        +get_image_url(item_id, image_type, options)
+        +create_playlist(name, item_ids)
+        +delete_playlist(playlist_id)
+        +rename_playlist(playlist_id, name)
+        +get_playlist_items(playlist_id)
+        +add_to_playlist(playlist_id, item_ids)
+        +remove_from_playlist(playlist_id, entry_ids)
+        +move_playlist_item(playlist_id, item_id, new_index)
+    }
+
+    class OnlineRepository {
+        -http_client: Arc~HttpClient~
+        -server_url: String
+        -user_id: String
+        -access_token: String
+        -connectivity: Option~Arc~ConnectivityMonitor~~
+        +new()
+        +with_connectivity()
+        -report_outcome()
+    }
+
+    class OfflineRepository {
+        -db_service: Arc~DatabaseService~
+        -server_id: String
+        -user_id: String
+        +new()
+        +cache_library()
+        +cache_items()
+        +cache_item()
+    }
+
+    class HybridRepository {
+        -online: Arc~OnlineRepository~
+        -offline: Arc~OfflineRepository~
+        +new()
+        -parallel_race()
+        -cache_with_timeout()
+    }
+
+    MediaRepository <|.. OnlineRepository
+    MediaRepository <|.. OfflineRepository
+    MediaRepository <|.. HybridRepository
+
+    HybridRepository --> OnlineRepository
+    HybridRepository --> OfflineRepository
+
+

Key Implementation Details:

+
    +
  1. +

    Cache-First Racing Strategy (hybrid.rs):

    +
      +
    • Runs cache (SQLite) and server (HTTP) queries in parallel
    • +
    • Cache has 100ms timeout
    • +
    • Returns cache result if it has meaningful content
    • +
    • Falls back to server result otherwise
    • +
    • Background cache updates planned
    • +
    • Connectivity feedback: OnlineRepository reports the outcome of every server request to the ConnectivityMonitor (classified via RepoError). This is the source of truth for the offline/online banner — see 07-connectivity.md. The frontend connectivity store is a pure reflection of the resulting events; navigator.onLine is only an advisory hint that triggers an immediate recheck.
    • +
    +
  2. +
  3. +

    Handle-Based Resource Management (repository.rs commands):

    +
    #![allow(unused)]
    +fn main() {
    +// Frontend creates repository with UUID handle
    +repository_create(server_url, user_id, access_token, server_id) -> String (UUID)
    +
    +// All operations use handle for identification
    +repository_get_libraries(handle: String) -> Vec<Library>
    +repository_get_items(handle: String, ...) -> SearchResult
    +
    +// Cleanup when done
    +repository_destroy(handle: String)
    +}
    +
      +
    • Enables multiple concurrent repository instances
    • +
    • Thread-safe with Arc<Mutex<HashMap<String, Arc<HybridRepository>>>>
    • +
    • No global state conflicts
    • +
    +
  4. +
  5. +

    Frontend API Layer (src/lib/api/repository-client.ts):

    +
      +
    • Thin TypeScript wrapper over Rust commands
    • +
    • Maintains handle throughout session
    • +
    • All methods: invoke<T>("repository_operation", { handle, ...args })
    • +
    • ~100 lines (down from 1061 lines)
    • +
    +
  6. +
+

Playback Mode System

+

Location: src-tauri/src/playback_mode/mod.rs

+

The playback mode system manages transitions between local device playback and remote Jellyfin session control:

+
#![allow(unused)]
+fn main() {
+pub enum PlaybackMode {
+    Local,                     // Playing on local device
+    Remote { session_id: String }, // Controlling remote session
+    Idle,                      // Not playing
+}
+
+pub struct PlaybackModeManager {
+    current_mode: PlaybackMode,
+    player_controller: Arc<Mutex<PlayerController>>,
+    jellyfin_client: Arc<JellyfinClient>,
+}
+}
+

Key Operations:

+
    +
  1. +

    Transfer to Remote (transfer_to_remote(session_id)):

    +
    sequenceDiagram
    +    participant UI
    +    participant Manager as PlaybackModeManager
    +    participant Player as PlayerController
    +    participant Jellyfin as Jellyfin API
    +
    +    UI->>Manager: transfer_to_remote(session_id)
    +    Manager->>Player: Extract queue items
    +    Manager->>Manager: Get Jellyfin IDs from queue
    +    Manager->>Jellyfin: POST /Sessions/{id}/Playing
    +    Note over Jellyfin: Start playback with queue
    +    Manager->>Jellyfin: POST /Sessions/{id}/Playing/Seek
    +    Note over Jellyfin: Seek to current position
    +    Manager->>Player: Stop local playback
    +    Manager->>Manager: Set mode to Remote
    +
    +
  2. +
  3. +

    Transfer to Local (transfer_to_local(item_id, position_ticks)):

    +
      +
    • Stops remote session playback
    • +
    • Prepares local player to resume
    • +
    • Sets mode to Local
    • +
    +
  4. +
+

Tauri Commands (playback_mode.rs):

+
    +
  • playback_mode_get_current() -> Returns current PlaybackMode
  • +
  • playback_mode_transfer_to_remote(session_id) -> Async transfer
  • +
  • playback_mode_transfer_to_local(item_id, position_ticks) -> Async transfer back
  • +
  • playback_mode_is_transferring() -> Check transfer state
  • +
  • playback_mode_set(mode) -> Direct mode setting
  • +
+

Frontend Store (src/lib/stores/playbackMode.ts):

+
    +
  • Thin wrapper calling Rust commands
  • +
  • Maintains UI state (isTransferring, transferError)
  • +
  • Listens to mode change events from Rust
  • +
+

Database Service Abstraction

+

Location: src-tauri/src/storage/db_service.rs

+

Async database interface wrapping synchronous rusqlite to prevent blocking the Tokio runtime:

+
#![allow(unused)]
+fn main() {
+#[async_trait]
+pub trait DatabaseService: Send + Sync {
+    async fn execute(&self, query: Query) -> Result<usize, DatabaseError>;
+    async fn execute_batch(&self, queries: Vec<Query>) -> Result<(), DatabaseError>;
+    async fn query_one<T, F>(&self, query: Query, mapper: F) -> Result<T, DatabaseError>
+        where F: FnOnce(&Row) -> Result<T> + Send + 'static;
+    async fn query_optional<T, F>(&self, query: Query, mapper: F) -> Result<Option<T>, DatabaseError>
+        where F: FnOnce(&Row) -> Result<T> + Send + 'static;
+    async fn query_many<T, F>(&self, query: Query, mapper: F) -> Result<Vec<T>, DatabaseError>
+        where F: Fn(&Row) -> Result<T> + Send + 'static;
+    async fn transaction<F, T>(&self, f: F) -> Result<T, DatabaseError>
+        where F: FnOnce(Transaction) -> Result<T> + Send + 'static;
+}
+
+pub struct RusqliteService {
+    connection: Arc<Mutex<Connection>>,
+}
+
+impl DatabaseService for RusqliteService {
+    async fn execute(&self, query: Query) -> Result<usize, DatabaseError> {
+        let conn = self.connection.clone();
+        tokio::task::spawn_blocking(move || {
+            // Execute query on blocking thread pool
+        }).await?
+    }
+    // ... other methods use spawn_blocking
+}
+}
+

Key Benefits:

+
    +
  • No Freezing: All blocking DB ops run in thread pool via spawn_blocking
  • +
  • Type Safety: QueryParam enum prevents SQL injection
  • +
  • Future Proof: Easy to swap to native async DB (tokio-rusqlite)
  • +
  • Testable: Can mock DatabaseService for tests
  • +
+

Usage Pattern:

+
#![allow(unused)]
+fn main() {
+// Before (blocking - causes UI freeze)
+let conn = database.connection();
+let conn = conn.lock().unwrap();  // BLOCKS
+conn.query_row(...)  // BLOCKS
+
+// After (async - no freezing)
+let db_service = database.service();
+let query = Query::with_params("SELECT ...", vec![...]);
+db_service.query_one(query, |row| {...}).await  // spawn_blocking internally
+}
+

Component Hierarchy

+
graph TD
+    subgraph Routes["Routes (src/routes/)"]
+        LoginPage["Login Page"]
+        LibLayout["Library Layout"]
+        LibDetail["Album/Series Detail"]
+        MusicCategory["Music Category Landing"]
+        Tracks["Tracks"]
+        Artists["Artists"]
+        Albums["Albums"]
+        Playlists["Playlists"]
+        Genres["Genres"]
+        Downloads["Downloads Page"]
+        Settings["Settings Page"]
+        PlayerPage["Player Page"]
+    end
+
+    subgraph PlayerComps["Player Components"]
+        AudioPlayer["AudioPlayer"]
+        VideoPlayer["VideoPlayer"]
+        MiniPlayer["MiniPlayer"]
+        Controls["Controls"]
+        Queue["Queue"]
+        SleepTimerModal["SleepTimerModal"]
+        SleepTimerIndicator["SleepTimerIndicator"]
+    end
+
+    subgraph SessionComps["Sessions Components"]
+        CastButton["CastButton"]
+        SessionModal["SessionPickerModal"]
+        SessionCard["SessionCard"]
+        SessionsList["SessionsList"]
+        RemoteControls["RemoteControls"]
+    end
+
+    subgraph LibraryComps["Library Components"]
+        LibGrid["LibraryGrid"]
+        LibListView["LibraryListView"]
+        TrackList["TrackList"]
+        PlaylistDetail["PlaylistDetailView"]
+        DownloadBtn["DownloadButton"]
+        MediaCard["MediaCard"]
+    end
+
+    subgraph PlaylistComps["Playlist Components"]
+        CreatePlaylistModal["CreatePlaylistModal"]
+        AddToPlaylistModal["AddToPlaylistModal"]
+    end
+
+    subgraph CommonComps["Common Components"]
+        ScrollPicker["ScrollPicker"]
+    end
+
+    subgraph OtherComps["Other Components"]
+        Search["Search"]
+        FavoriteBtn["FavoriteButton"]
+        DownloadItem["DownloadItem"]
+    end
+
+    LibLayout --> PlayerComps
+    LibLayout --> LibDetail
+    MusicCategory --> Tracks
+    MusicCategory --> Artists
+    MusicCategory --> Albums
+    MusicCategory --> Playlists
+    MusicCategory --> Genres
+    LibDetail --> LibraryComps
+    Playlists --> PlaylistComps
+    Playlists --> PlaylistDetail
+    Downloads --> DownloadItem
+    PlayerPage --> PlayerComps
+
+    MiniPlayer --> CastButton
+    CastButton --> SessionModal
+    SleepTimerModal --> ScrollPicker
+    PlayerComps --> LibraryComps
+
+

MiniPlayer Behavior

+

Location: src/lib/components/player/MiniPlayer.svelte

+

The MiniPlayer is a persistent bottom bar for audio playback that supports touch gestures and playback controls.

+

Touch Gesture Handling:

+

The MiniPlayer uses touch events to distinguish between taps (on controls) and swipe-up gestures (to expand to full player page):

+
function handleTouchStart(e: TouchEvent) {
+  touchStartX = e.touches[0].clientX;
+  touchStartY = e.touches[0].clientY;
+  touchEndX = touchStartX;   // Initialize to start position
+  touchEndY = touchStartY;   // Prevents taps being treated as swipes
+  isSwiping = true;
+}
+
+

Key Design Decision: touchEndX/touchEndY must be initialized to the start position in handleTouchStart. Without this, a pure tap (no touchmove event fired) would compute the swipe distance against (0,0), making every tap look like a massive swipe-up and inadvertently navigating to the player page.

+

Skip Button State:

+

The MiniPlayer's next/previous buttons are enabled based on appState.hasNext/hasPrevious, which are updated by playerEvents.ts calling invoke("player_get_queue") on every StateChanged event from the backend.

+

Sleep Timer Architecture

+

Location: src-tauri/src/player/sleep_timer.rs, src-tauri/src/player/mod.rs

+

TRACES: UR-026 | DR-029

+

The sleep timer supports three modes for stopping playback:

+
#![allow(unused)]
+fn main() {
+#[serde(tag = "kind", rename_all = "camelCase")]
+pub enum SleepTimerMode {
+    Off,
+    Time { end_time: i64 },      // Unix timestamp in milliseconds
+    EndOfTrack,                    // Stop after current track/episode
+    Episodes { remaining: u32 },   // Stop after N more episodes
+}
+}
+

Timer Modes:

+
+ + + +
ModeTriggerHow It Stops
TimeUser selects 15/30/45/60 min via roller UIBackground timer thread stops backend when remaining_seconds == 0; also checked at track boundaries in on_playback_ended()
EndOfTrackUser clicks "End of current track"Checked in on_playback_ended(), returns AutoplayDecision::Stop
EpisodesUser selects 1-10 episodesdecrement_episode() in on_playback_ended(), stops when counter reaches 0
+
+

Time-Based Timer Flow:

+
sequenceDiagram
+    participant UI as SleepTimerModal
+    participant Store as sleepTimer store
+    participant Rust as PlayerController
+    participant Thread as Timer Thread
+    participant Backend as PlayerBackend
+
+    UI->>Store: setTimeTimer(30)
+    Store->>Rust: invoke("player_set_sleep_timer", {mode})
+    Rust->>Rust: Set SleepTimerMode::Time { end_time }
+    Rust->>UI: Emit SleepTimerChanged event
+
+    loop Every 1 second
+        Thread->>Thread: update_remaining_seconds()
+        Thread->>UI: Emit SleepTimerChanged (countdown)
+        alt remaining_seconds == 0
+            Thread->>Backend: stop()
+            Thread->>UI: Emit SleepTimerChanged (Off)
+        end
+    end
+
+

Frontend Components:

+
    +
  • ScrollPicker (src/lib/components/common/ScrollPicker.svelte): Reusable scroll-wheel picker using CSS scroll-snap-type: y mandatory. Configurable items, visible count, and item height. Used by SleepTimerModal for time selection.
  • +
  • SleepTimerModal (src/lib/components/player/SleepTimerModal.svelte): Modal with three sections - time picker (roller), end of track button, episode counter. Time section uses ScrollPicker with 15/30/45/60 min options. Accepts optional mediaType prop to override queue-based detection (used by VideoPlayer since video playback clears the audio queue).
  • +
  • SleepTimerIndicator (src/lib/components/player/SleepTimerIndicator.svelte): Compact indicator showing active timer status with countdown.
  • +
  • Sleep buttons: Clock icon buttons on AudioPlayer header, Controls bar, MiniPlayer, and VideoPlayer control bar. Shows clock icon when inactive, SleepTimerIndicator when active.
  • +
+

Key Design Decisions:

+
    +
  1. All logic in Rust: Frontend only displays state and invokes commands
  2. +
  3. Background timer thread: Handles time-based countdown independently of track boundaries
  4. +
  5. Dual stop mechanism for Time mode: Timer thread stops mid-track; on_playback_ended() catches edge case at track boundary
  6. +
  7. Event-driven UI updates: Timer thread emits SleepTimerChanged every second for countdown display
  8. +
+

Auto-Play Episode Limit

+
+

⚠️ Autoplay is season-bounded. player/mod.rs:fetch_next_episode_for_item +does not cross a season boundary, so autoplay stops at the end of a season even +though the "More Episodes" strip runs past it. Fixing it should reuse +repository_get_series_episodes, but it touches the playback state machine and +the Android JNI advance path (see the AutoplayDecision deadlock note in +CLAUDE.md) — its own change, not a drive-by.

+
+

Location: src-tauri/src/player/mod.rs, src-tauri/src/player/autoplay.rs, src-tauri/src/settings.rs

+

TRACES: UR-023 | DR-049

+

Limits how many episodes auto-play consecutively before requiring manual intervention.

+

Settings:

+
#![allow(unused)]
+fn main() {
+// In AutoplaySettings (runtime, in PlayerController)
+pub struct AutoplaySettings {
+    pub enabled: bool,
+    pub countdown_seconds: u32,
+    pub max_episodes: u32,  // 0 = unlimited
+}
+
+// In VideoSettings (persisted, settings page)
+pub struct VideoSettings {
+    pub auto_play_next_episode: bool,
+    pub auto_play_countdown_seconds: u32,
+    pub auto_play_max_episodes: u32,  // 0 = unlimited
+}
+}
+

Session-Based Counter:

+

The autoplay_episode_count field in PlayerController tracks consecutive auto-played episodes:

+
    +
  • Incremented: In on_playback_ended() when auto-playing next episode
  • +
  • Reset: On any manual user action (play_item(), play_queue(), next(), previous())
  • +
  • Limit check: When max_episodes > 0 and count >= max_episodes, the popup shows with auto_advance: false - user must manually click "Play Now" to continue
  • +
+
flowchart TB
+    PlaybackEnded["on_playback_ended()"] --> CheckEpisode{"Is video<br/>episode?"}
+    CheckEpisode -->|"No"| AudioFlow["Audio queue logic"]
+    CheckEpisode -->|"Yes"| FetchNext["Fetch next episode"]
+    FetchNext --> IncrementCount["increment_autoplay_count()"]
+    IncrementCount --> CheckLimit{"max_episodes > 0<br/>AND count >= max?"}
+    CheckLimit -->|"No"| ShowPopup["ShowNextEpisodePopup<br/>auto_advance: true"]
+    CheckLimit -->|"Yes"| ShowPopupManual["ShowNextEpisodePopup<br/>auto_advance: false"]
+    ShowPopupManual --> UserClick["User clicks 'Play Now'"]
+    UserClick --> PlayItem["play_item() -> resets counter"]
+
+

Settings Sync:

+

VideoSettings (settings page) and AutoplaySettings (PlayerController runtime) are synced via player_set_video_settings, which updates both the VideoSettingsWrapper state and calls controller.set_autoplay_settings().

+

Database: Migration 016 adds autoplay_max_episodes INTEGER DEFAULT 0 to user_player_settings.

+

Settings UI: Button grid with options: Unlimited, 1, 2, 3, 5, 10 episodes. Visible only when auto-play is enabled.

+

Player Page Navigation Guard

+

Location: src/routes/player/[id]/+page.svelte

+

When the user navigates to the full player page (e.g., by swiping up on MiniPlayer), the loadAndPlay function checks whether the track is already playing before initiating new playback:

+
const alreadyPlayingMedia = get(storeCurrentMedia);
+if (alreadyPlayingMedia?.id === id && !startPosition) {
+  // Track already playing - show UI without restarting playback
+  // Fetch queue status for hasNext/hasPrevious
+  return;
+}
+
+

Why This Matters: Without this guard, navigating to the player page would restart playback with a single-track queue, destroying the existing album/playlist queue that the backend is playing. The Rust backend maintains the full queue (visible on the Android lock screen), but the frontend loadAndPlay function would overwrite it by calling player_play_tracks with just the current track.

+

Playlist Management UI

+

TRACES: UR-014 | JA-019 | JA-020

+

Location: src/lib/components/playlist/, src/lib/components/library/PlaylistDetailView.svelte

+

The playlist UI provides full CRUD operations for Jellyfin playlists with offline sync support.

+

Components:

+
    +
  • +

    CreatePlaylistModal (src/lib/components/playlist/CreatePlaylistModal.svelte):

    +
      +
    • Modal for creating new playlists with a name input
    • +
    • Accepts optional initialItemIds to pre-populate with tracks
    • +
    • Keyboard support: Enter to create, Escape to close
    • +
    • Navigates to new playlist detail page on creation
    • +
    +
  • +
  • +

    AddToPlaylistModal (src/lib/components/playlist/AddToPlaylistModal.svelte):

    +
      +
    • Modal listing all existing playlists to add tracks to
    • +
    • "New Playlist" button for inline creation flow
    • +
    • Shows playlist artwork via CachedImage
    • +
    • Loading state with skeleton placeholders
    • +
    +
  • +
  • +

    PlaylistDetailView (src/lib/components/library/PlaylistDetailView.svelte):

    +
      +
    • Full playlist detail page with artwork, name, track count, total duration
    • +
    • Click-to-rename with inline editing
    • +
    • Play all / shuffle play buttons
    • +
    • Delete with confirmation dialog
    • +
    • Per-track removal buttons
    • +
    • Uses TrackList component for track display
    • +
    • Passes { type: "playlist", playlistId, playlistName } context to player
    • +
    +
  • +
  • +

    Playlists Page (src/routes/library/music/playlists/+page.svelte):

    +
      +
    • Grid view using GenericMediaListPage
    • +
    • Floating action button (FAB) to create new playlists
    • +
    • Search by playlist name
    • +
    +
  • +
+

Frontend API Methods (src/lib/api/repository-client.ts):

+
    +
  • createPlaylist(name, itemIds?) -> PlaylistCreatedResult
  • +
  • deletePlaylist(playlistId)
  • +
  • renamePlaylist(playlistId, name)
  • +
  • getPlaylistItems(playlistId) -> PlaylistEntry[]
  • +
  • addToPlaylist(playlistId, itemIds)
  • +
  • removeFromPlaylist(playlistId, entryIds)
  • +
  • movePlaylistItem(playlistId, itemId, newIndex)
  • +
+

Offline Sync (src/lib/services/syncService.ts): +All playlist mutations are queued for offline sync:

+
    +
  • queuePlaylistCreate, queuePlaylistDelete, queuePlaylistRename
  • +
  • queuePlaylistAddItems, queuePlaylistRemoveItems, queuePlaylistReorderItem
  • +
+

App Shell and Chrome

+

Location: src/lib/utils/layoutShell.ts (pure rules), +src/lib/components/AppHeader.svelte, +src/lib/components/account/AccountMenu.svelte, BottomUi.svelte +TRACES: UR-054 | DR-075, DR-076, DR-077

+

Account actions used to be reachable only from /library/* — the header +that hosted them belonged to the library layout, the bottom nav offered Home / +Search / Library, and the desktop username was inert text. From /, /search +or /downloads there was no route to Settings or Sign out at all. The header is +now shared and rendered from the root layout.

+

Visibility rules

+

All four rules are pure functions in layoutShell.ts, so the contract is +unit-testable rather than a scattering of $derived booleans that drift per +route and platform (which is what they were):

+
+ + + + +
FunctionRule
showBottomNavEvery authenticated route except /player/* and /login
showGlobalMiniPlayerEverything except /player/*, /login, /settings. Not gated on platform or /library — the root owns the mini player everywhere, so the library route must never render a second one
routeOwnsLayout/library, /player/, /login render their own full-height flex column; everything else renders into the root scroller
showGlobalHeaderAuthenticated, not a layout-owning route, not /settings (the user is already there)
+
+

The structural fix worth not undoing

+

The "last row hidden behind the nav" bug is solved structurally, not by +measurement: the bottom UI is an in-flow flex child below the scroller +(BottomUi.svelte), so the scroller is physically bounded above it and cannot +render behind it. There is no measurement and no reserved padding. If you +restructure the shell, preserve the scroll containment — reintroducing padding +math reintroduces the bug.

+

AccountMenu

+

One component for both breakpoints, anchored to the username/avatar (a real +button with aria-expanded, not a bare three-dot icon). Fixed item order: +identity block (user + server) → Downloads, Settings, Display → divider → Sign +out, destructive and last. Dismissal is backdrop click, Escape, and focus +return to the trigger.

+

The identity block falls back to the bare host of the server URL when the server +has no human-readable name, so it always shows something server-identifying.

+

Settings' Display section and the library page-header toggle are two views onto +the same persisted viewMode store (jellytau-view-mode) — no second state, +no migration, and they stay in sync for free.

+

Library Mosaic

+

Location: src/lib/components/library/libraryMosaic.ts (pure), +MosaicGrid.svelte, MosaicTile.svelte +TRACES: UR-075, UR-067 | DR-174, DR-175

+

The library overview and the home "Your Libraries" strip are a mosaic, not a +grid: rows share one height and each tile is as wide as its own artwork is, so a +square music cover, a 16:9 library backdrop and a 2:3 poster sit in the same row +at their own proportions instead of all three being cropped into whichever box a +grid picked.

+

libraryMosaic.ts is deliberately pure — it takes the libraries and returns the +tiles to draw, so ordering and de-duplication are unit-testable rather than +buried in markup. Tiles start at an assumed aspect (square, 16:9) and a +measured image overrides it in MosaicGrid.

+

Note what this file does not decide: which favourites category a library +belongs to. That is Jellyfin vocabulary and arrives on the library itself as +favoritesScope, from SearchScope::for_collection_type in Rust (see +01-rust-backend.md). +The frontend only decides what to call it and where to put it.

+

Series and Episode Navigation

+

Location: src/lib/components/library/SeasonSection.svelte, +EpisodeFocusView.svelte, episodeStrip.ts (pure) +TRACES: UR-062 … UR-064 | DR-101 … DR-107

+

Opening a series lands the viewer where they actually are in it. "Where is this +viewer in this series" is resolved in Rust (DR-101), not by the page: the +series detail page asks the repository and anchors on the answer — the current +season expanded, the current episode highlighted and scrolled into view, and a +hero button labelled Resume S2E4 / Play S1E1.

+

A season is not a destination: /library/<seasonId> redirects to its series +(DR-103). Video library routes collapse to one per library (DR-105).

+

episodeStrip.ts holds the pure logic for the "More Episodes" strip, extracted +from the component because it had three distinct bugs that markup made +untestable: the strip collapsing to just the current episode while real siblings +existed, number-less episodes all matching as "current" (undefined === undefined), and the window dead-ending at a season boundary instead of running +past it. It matches by id first and only falls back to season+episode number when +both numbers are known on both sides.

+

Downloaded Browse

+

Location: src/lib/services/downloadedCatalog.ts, +src/lib/components/downloads/DownloadedBrowse.svelte +TRACES: UR-055, UR-056 | DR-081 … DR-085

+

/downloads is two views: Downloaded (the default) — the library filtered to +what is on the device, reusing the same grids, cards and detail pages as online +browsing — and Transfers, the in-flight progress rows demoted to a secondary +tab.

+

downloadedCatalog reads the offline-only browse path on the repository, +never the hybrid merge. That is the point: an empty result means "nothing +downloaded here", never "server unreachable", so the view is authoritative +regardless of connectivity. It also owns disk usage — a per-item/container byte +map plus the device total, aggregated by the backend from downloads.file_size +(DR-085).

+

Safe-area Insets

+

Location: src/app.css, WindowInsetsBridge.kt +TRACES: UR-066 | DR-112, IR-031

+

The Android WebView does not reliably report system-bar insets through +env(safe-area-inset-*). Native WindowInsets (systemBars() | displayCutout()) are therefore pushed in as CSS custom properties, and every +edge takes the larger of the two sources:

+
--safe-top: max(env(safe-area-inset-top, 0px), var(--jt-inset-top, 0px));
+
+

Two rules keep this from going wrong: one owner per edge (two components both +padding the top edge double-pads it), and no nested h-screen — a full-height +child inside a full-height parent that has already consumed the inset overflows +by exactly the inset.

+

Unlike addJavascriptInterface, the inset push only writes CSS properties, so it +can safely be re-sent on resume.

+

Native Video Store

+

Location: src/lib/stores/nativeVideo.ts +TRACES: UR-003, UR-004 | DR-188

+

Two separate concerns live here, deliberately:

+
    +
  • experimentalNativeVideo — the user-facing opt-in flag, defaulting to on. +Rust already decides which backend this platform has (useHtml5Element from +player_play_item); this flag only suppresses that decision. It never turns +native on where Rust says HTML5. An explicit stored choice wins in both +directions, so someone who opted out is not re-enabled by a default flip — +hence the null check rather than a bare === "true".
  • +
  • nativeVideoActive — whether a native surface is on screen right now. +Setting it toggles data-native-video on <html>, which is what the CSS in +app.css keys off to clear the app's opaque backgrounds. It is deliberately +not derived from the flag: the backgrounds must come back the moment the +player unmounts.
  • +
+

See 05-platform-backends.md +for what is behind the WebView.

+

Logging

+

Location: src/lib/utils/logger.ts +TRACES: DR-204

+

The frontend's equivalent of the Rust log crate: four levels +(debug < info < warn < error), a compile-environment default (dev → debug, +production → warn), and a runtime override that is the moral equivalent of +RUST_LOG. Scoped loggers carry the subsystem in the message, so a filtered +console stays usable while a player, a download worker and a store are all +talking.

+

Production deliberately keeps warn and error: this is a client talking to a +server that may or may not be there, and a silent failure is worse to support +than a noisy console. Only the chatter is suppressed.

+

no-console is an ESLint error, with the sink module itself the only +exception, so a raw console.* cannot re-appear.

+ +
+ + +
+
+ + + +
+ + + + + + + + + + + + + + + + + + +
+ + diff --git a/architecture/03-data-flow.html b/architecture/03-data-flow.html new file mode 100644 index 00000000..2995936d --- /dev/null +++ b/architecture/03-data-flow.html @@ -0,0 +1,434 @@ + + + + + + Data Flow - JellyTau Documentation + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+ + + + + + + + + + + + + + + + + + + +
+ +
+ + + + + + + + +
+
+

Data Flow

+

Repository Query Flow (Cache-First)

+
sequenceDiagram
+    participant UI as Svelte Component
+    participant Client as RepositoryClient (TS)
+    participant Rust as Tauri Command
+    participant Hybrid as HybridRepository
+    participant Cache as OfflineRepository (SQLite)
+    participant Server as OnlineRepository (HTTP)
+    participant Conn as ConnectivityMonitor
+
+    UI->>Client: getItems(parentId)
+    Client->>Rust: invoke("repository_get_items", {handle, parentId})
+    Rust->>Hybrid: get_items()
+
+    par Parallel Racing
+        Hybrid->>Cache: get_items() with 100ms timeout
+        Hybrid->>Server: get_items() (no timeout)
+    end
+
+    Note over Server,Conn: Every server request reports its outcome
+    alt Server succeeds (or answers with 4xx/5xx)
+        Server->>Conn: mark_reachable() (server is up)
+    else Network failure / timeout
+        Server->>Conn: mark_unreachable() (debounced)
+    end
+
+    alt Cache returns with content
+        Cache-->>Hybrid: Result with items
+        Hybrid-->>Rust: Return cache result
+    else Cache timeout or empty
+        Server-->>Hybrid: Fresh result
+        Hybrid-->>Rust: Return server result
+    end
+
+    Rust-->>Client: SearchResult
+    Client-->>UI: items[]
+    Note over UI: Reactive update
+
+

Key Points:

+
    +
  • Cache queries have 100ms timeout for responsiveness
  • +
  • Server queries always run for fresh data
  • +
  • Cache wins if it has meaningful content
  • +
  • Automatic fallback to server if cache is empty/stale
  • +
  • Background cache updates (planned)
  • +
  • Connectivity side-effect: each server request feeds the ConnectivityMonitor, which is the source of truth for the offline/online banner (see 07-connectivity.md). A server-answered error (401/404/5xx) still counts as reachable — only network failures, sustained past a debounce window, flip the app to offline.
  • +
+

Search Flow (Locally Indexed)

+

TRACES: UR-065 | DR-108 … DR-111, IR-030

+

Search does not depend on a per-keystroke round trip to Jellyfin. The instant leg +reads the local SQLite catalog, which is already synced and already +FTS5-indexed, so results appear as fast as SQLite can answer — online or offline. +The server query stays, demoted to a background reconciliation that merges in +late results.

+
sequenceDiagram
+    participant UI as Search UI
+    participant Rust as repository_search
+    participant Cache as Local catalog (FTS5)
+    participant Server as Jellyfin
+    participant Indexer as spawn_catalog_indexer
+
+    UI->>Rust: search(query, scope)
+    Rust->>Cache: FTS5 query, scope expanded by SearchScope::item_types()
+    Cache-->>UI: instant results
+    Rust->>Server: reconciliation query (background)
+    Server-->>UI: search-event with late/merged results
+    Note over Indexer,Cache: Independent of any query:<br/>scheduled crawl keeps the index fresh,<br/>prunes items deleted on the server
+
+

Key points:

+
    +
  • The scope is opaque on the wire. The frontend sends a SearchScope +variant; Rust expands it to item types +(01-rust-backend.md).
  • +
  • Index freshness is a Rust policy, not a frontend startup call — a scheduled +background pass, not "whatever was synced when the app last launched" +(DR-109). See +Background workers.
  • +
  • Index hygiene matters as much as freshness: the catalog save path uses +INSERT OR REPLACE and the crawl prunes rows for content deleted on the +server, or search keeps returning items that no longer exist (DR-110).
  • +
  • The index covers exactly the types the result groups render (DR-111) — +including Artists, which the crawl must reach or the Artists group is silently +always empty.
  • +
+

Deliberately not done, with reasons:

+
    +
  • Incremental indexing (Jellyfin's MinDateLastSaved). A full crawl is +what makes the deletion sweep sound — it yields the authoritative id set per +library, and an incremental pass cannot detect deletions. Worth revisiting if +full crawls prove slow on large libraries; measure first.
  • +
  • Removing the server leg. The reconciliation query stays.
  • +
+
+

⚠️ Two dead search implementations still exist: storage_search_items +(commands/storage/mod.rs) and offline_search (commands/offline.rs). Both +are registered in lib.rs and exported to bindings.ts; neither is called +from the frontend. Deleting them is correct and unclaimed.

+
+

Playback Initiation Flow

+
sequenceDiagram
+    participant User
+    participant AudioPlayer
+    participant Tauri as Tauri IPC
+    participant Command as player_play_item()
+    participant Controller as PlayerController
+    participant Backend as PlayerBackend
+    participant Store as Frontend Store
+
+    User->>AudioPlayer: clicks play
+    AudioPlayer->>Tauri: invoke("player_play_item", {item})
+    Tauri->>Command: player_play_item()
+    Command->>Command: Convert PlayItemRequest -> MediaItem
+    Command->>Controller: play_item(item)
+    Controller->>Backend: load(item)
+    Note over Backend: State -> Loading
+    Controller->>Backend: play()
+    Note over Backend: State -> Playing
+    Controller-->>Command: Ok(())
+    Command-->>Tauri: PlayerStatus {state, position, duration, volume}
+    Tauri-->>AudioPlayer: status
+    AudioPlayer->>Store: player.setPlaying(media, position, duration)
+    Note over Store: UI updates reactively
+
+

Playback Mode Transfer Flow

+
sequenceDiagram
+    participant UI as Cast Button
+    participant Store as playbackMode store
+    participant Rust as Tauri Command
+    participant Manager as PlaybackModeManager
+    participant Player as PlayerController
+    participant Jellyfin as Jellyfin API
+
+    UI->>Store: transferToRemote(sessionId)
+    Store->>Rust: invoke("playback_mode_transfer_to_remote", {sessionId})
+    Rust->>Manager: transfer_to_remote()
+
+    Manager->>Player: Get current queue
+    Player-->>Manager: Vec<MediaItem>
+    Manager->>Manager: Extract Jellyfin IDs
+
+    Manager->>Jellyfin: POST /Sessions/{id}/Playing<br/>{itemIds, startIndex}
+    Jellyfin-->>Manager: 200 OK
+
+    Manager->>Jellyfin: POST /Sessions/{id}/Playing/Seek<br/>{positionTicks}
+    Jellyfin-->>Manager: 200 OK
+
+    Manager->>Player: stop()
+    Manager->>Manager: mode = Remote {sessionId}
+
+    Manager-->>Rust: Ok(())
+    Rust-->>Store: PlaybackMode
+    Store->>UI: Update cast icon
+
+

Queue Navigation Flow

+
flowchart TB
+    User["User clicks Next"] --> Invoke["invoke('player_next')"]
+    Invoke --> ControllerNext["controller.next()"]
+    ControllerNext --> QueueNext["queue.next()<br/>- Check repeat mode<br/>- Check shuffle<br/>- Update history"]
+
+    QueueNext --> None["None<br/>(at end)"]
+    QueueNext --> Some["Some(next)"]
+    QueueNext --> Same["Same<br/>(repeat one)"]
+
+    Some --> PlayItem["play_item(next)<br/>Returns new status"]
+
+

Volume Control Flow

+
sequenceDiagram
+    participant User
+    participant Slider as Volume Slider
+    participant Handler as handleVolumeChange()
+    participant Tauri as Tauri IPC
+    participant Command as player_set_volume
+    participant Controller as PlayerController
+    participant Backend as MpvBackend/NullBackend
+    participant Events as playerEvents.ts
+    participant Store as Player Store
+    participant UI
+
+    User->>Slider: adjusts (0-100)
+    Slider->>Handler: oninput event
+    Handler->>Handler: Convert 0-100 -> 0.0-1.0
+    Handler->>Tauri: invoke("player_set_volume", {volume})
+    Tauri->>Command: player_set_volume
+    Command->>Controller: set_volume(volume)
+    Controller->>Backend: set_volume(volume)
+    Backend->>Backend: Clamp to 0.0-1.0
+    Note over Backend: MpvBackend: Send to MPV loop
+    Backend-->>Tauri: emit "player-event"
+    Tauri-->>Events: VolumeChanged event
+    Events->>Store: player.setVolume(volume)
+    Store-->>UI: Reactive update
+    Note over UI: Both AudioPlayer and<br/>MiniPlayer stay in sync
+
+

Key Implementation Details:

+
    +
  • Volume is stored in the backend (NullBackend/MpvBackend)
  • +
  • PlayerController.volume() delegates to backend
  • +
  • get_player_status() returns controller.volume() (not hardcoded)
  • +
  • Frontend uses normalized 0.0-1.0 scale, UI shows 0-100
  • +
+ +
+ + +
+
+ + + +
+ + + + + + + + + + + + + + + + + + +
+ + diff --git a/architecture/04-type-sync-and-threading.html b/architecture/04-type-sync-and-threading.html new file mode 100644 index 00000000..40bf4fe7 --- /dev/null +++ b/architecture/04-type-sync-and-threading.html @@ -0,0 +1,346 @@ + + + + + + Type Sync & Threading - JellyTau Documentation + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+ + + + + + + + + + + + + + + + + + + +
+ +
+ + + + + + + + +
+
+

Type Synchronization & Thread Safety

+

PlayerState (Rust <-> TypeScript)

+

Rust:

+
#![allow(unused)]
+fn main() {
+pub enum PlayerState {
+    Idle,
+    Loading { media: MediaItem },
+    Playing { media: MediaItem, position: f64, duration: f64 },
+    Paused { media: MediaItem, position: f64, duration: f64 },
+    Seeking { media: MediaItem, target: f64 },
+    Error { media: Option<MediaItem>, error: String },
+}
+}
+

TypeScript:

+
type PlayerState =
+  | { kind: "idle" }
+  | { kind: "loading"; media: MediaItem }
+  | { kind: "playing"; media: MediaItem; position: number; duration: number }
+  | { kind: "paused"; media: MediaItem; position: number; duration: number }
+  | { kind: "seeking"; media: MediaItem; target: number }
+  | { kind: "error"; media: MediaItem | null; error: string };
+
+

MediaItem Serialization

+
#![allow(unused)]
+fn main() {
+// Rust (serde serialization)
+#[derive(Serialize, Deserialize)]
+pub struct MediaItem {
+    pub id: String,
+    pub title: String,
+    #[serde(skip_serializing_if = "Option::is_none")]
+    pub artist: Option<String>,
+    // ...
+}
+}
+
// TypeScript
+interface MediaItem {
+  id: string;
+  title: string;
+  artist?: string;
+  // ...
+}
+
+

Tauri v2 IPC Parameter Naming Convention

+

CRITICAL: Tauri v2's #[tauri::command] macro automatically converts snake_case Rust parameter names to camelCase for the frontend. All invoke() calls must use camelCase for top-level parameters.

+

Rule: Rust fn cmd(repository_handle: String) -> Frontend sends { repositoryHandle: "..." }

+
// CORRECT - Tauri v2 auto-converts snake_case -> camelCase
+await invoke("player_play_tracks", {
+  repositoryHandle: "handle-123",     // Rust: repository_handle
+  request: { trackIds: ["id1"], startIndex: 0 }
+});
+
+await invoke("remote_send_command", {
+  sessionId: "session-123",           // Rust: session_id
+  command: "PlayPause"
+});
+
+await invoke("pin_item", {
+  itemId: "item-123"                  // Rust: item_id
+});
+
+// WRONG - snake_case causes "invalid args request" error on Android
+await invoke("player_play_tracks", {
+  repository_handle: "handle-123",    // Will fail!
+});
+
+

Parameter Name Mapping (Rust -> Frontend):

+
+ + + + + + + +
Rust ParameterFrontend ParameterUsed By
repository_handlerepositoryHandleplayer_play_tracks, player_add_track_by_id, player_play_album_track
session_idsessionIdremote_send_command, remote_play_on_session, remote_session_seek
item_iditemIdpin_item, unpin_item
current_item_idcurrentItemIdplayback_mode_transfer_to_local
position_tickspositionTicksplayback_mode_transfer_to_local, remote_session_seek
item_idsitemIdsremote_play_on_session
start_indexstartIndexremote_play_on_session
+
+

Nested struct fields use #[serde(rename_all = "camelCase")] separately - this is serde deserialization, not the command macro. Both layers convert independently.

+

Test Coverage: Integration tests in src/lib/utils/tauriIntegration.test.ts validate all invoke calls use correct camelCase parameter names.

+

Rust Backend Thread Safety

+
#![allow(unused)]
+fn main() {
+// Shared state wrapped in Arc<Mutex<>>
+pub struct PlayerController {
+    backend: Arc<Mutex<Box<dyn PlayerBackend>>>,
+    queue: Arc<Mutex<QueueManager>>,
+    // ...
+}
+
+// Tauri state wrapper
+pub struct PlayerStateWrapper(pub Mutex<PlayerController>);
+
+// Command handler pattern
+#[tauri::command]
+pub fn player_play(state: State<PlayerStateWrapper>) -> Result<PlayerStatus, String> {
+    let mut controller = state.0.lock().unwrap();  // Acquire lock
+    controller.play()?;                             // Operate
+    Ok(get_player_status(&controller))              // Lock released
+}
+}
+

Frontend Stores

+

Svelte stores are inherently reactive and thread-safe for UI updates:

+
const { subscribe, update } = writable<PlayerStore>(initialState);
+
+// Atomic updates
+function setPlaying(media: MediaItem, position: number, duration: number) {
+  update(state => ({
+    ...state,
+    state: { kind: "playing", media, position, duration }
+  }));
+}
+
+ +
+ + +
+
+ + + +
+ + + + + + + + + + + + + + + + + + +
+ + diff --git a/architecture/05-platform-backends.html b/architecture/05-platform-backends.html new file mode 100644 index 00000000..df3570e5 --- /dev/null +++ b/architecture/05-platform-backends.html @@ -0,0 +1,876 @@ + + + + + + Platform Backends - JellyTau Documentation + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+ + + + + + + + + + + + + + + + + + + +
+ +
+ + + + + + + + +
+
+

Platform-Specific Player Backends

+

Player Events System

+

Location: src-tauri/src/player/events.rs

+

The player uses a push-based event system to notify the frontend of state changes:

+
#![allow(unused)]
+fn main() {
+pub enum PlayerStatusEvent {
+    /// Playback position updated (emitted periodically during playback)
+    PositionUpdate { position: f64, duration: f64 },
+
+    /// Player state changed
+    StateChanged { state: String, media_id: Option<String> },
+
+    /// Media has finished loading and is ready to play
+    MediaLoaded { duration: f64 },
+
+    /// Playback has ended naturally
+    PlaybackEnded,
+
+    /// Buffering state changed
+    Buffering { percent: u8 },
+
+    /// An error occurred during playback
+    Error { message: String, recoverable: bool },
+
+    /// Volume changed
+    VolumeChanged { volume: f32, muted: bool },
+
+    /// Sleep timer state changed
+    SleepTimerChanged {
+        mode: SleepTimerMode,
+        remaining_seconds: u32,
+    },
+
+    /// Show next episode popup with countdown
+    ShowNextEpisodePopup {
+        current_episode: MediaItem,
+        next_episode: MediaItem,
+        countdown_seconds: u32,
+        auto_advance: bool,
+    },
+
+    /// Countdown tick (emitted every second during autoplay countdown)
+    CountdownTick { remaining_seconds: u32 },
+
+    /// Queue changed (items added, removed, reordered, or playback mode changed)
+    QueueChanged {
+        items: Vec<MediaItem>,
+        current_index: Option<usize>,
+        shuffle: bool,
+        repeat: RepeatMode,
+        has_next: bool,
+        has_previous: bool,
+    },
+
+    /// Media session changed (activity context changed: Audio/Movie/TvShow/Idle)
+    SessionChanged { session: MediaSessionType },
+}
+}
+

Events are emitted via Tauri's event system:

+
flowchart LR
+    subgraph Backend["Player Backend"]
+        MPV["MPV/ExoPlayer"]
+    end
+
+    subgraph EventSystem["Event System"]
+        Emitter["TauriEventEmitter<br/>emit()"]
+        Bus["Tauri Event Bus<br/>'player-event'"]
+    end
+
+    subgraph Frontend["Frontend"]
+        Listener["playerEvents.ts<br/>Frontend Listener"]
+        Store["Player Store Update<br/>(position, state, etc)"]
+    end
+
+    MPV --> Emitter --> Bus --> Listener --> Store
+
+

Frontend Listener (src/lib/services/playerEvents.ts):

+
    +
  • Listens for player-event Tauri events
  • +
  • Updates player/queue stores based on event type
  • +
  • Auto-advances to next track on PlaybackEnded
  • +
  • On StateChanged events, calls invoke("player_get_queue") to update appState.hasNext/hasPrevious -- this enables MiniPlayer skip button state
  • +
+

Important: The command is player_get_queue (returns QueueStatus with hasNext/hasPrevious). There is no player_get_queue_status command.

+

HTML5 Video Adapter (webview-rendered video)

+

Location: src/lib/player/html5Adapter.ts, src/lib/player/index.ts, report commands in +src-tauri/src/commands/player/timers.rs

+

Video on desktop (Linux WebKitGTK) — and, per current interim behavior, Android — is rendered by an +HTML5 <video>/HLS element inside the webview. libmpv is initialized audio-only (vo=null, +video=false), so the native backend cannot render or observe this element. The <video> is therefore +the real player, living outside Rust's reach.

+

To keep the PlayerController the single source of truth (matching the audio path), the HTML5 element +is treated as a dumb output device that reports back into Rust, rather than an independent state +authority:

+
flowchart LR
+    subgraph Webview["Webview"]
+        Video["HTML5 <video> / HLS.js"]
+        Adapter["html5Adapter.ts<br/>(reports DOM events)"]
+    end
+    subgraph Backend["Rust"]
+        Cmds["player_report_state<br/>player_report_position<br/>player_report_media_loaded"]
+        Controller["PlayerController"]
+        Emitter["TauriEventEmitter"]
+    end
+    subgraph Frontend["Frontend"]
+        Events["playerEvents.ts"]
+        Store["player store"]
+    end
+
+    Video -->|DOM events| Adapter --> Cmds --> Controller --> Emitter --> Events --> Store
+
+

Key points:

+
    +
  • The adapter re-emits the same PlayerStatusEvents (StateChanged, PositionUpdate, MediaLoaded) +the native backends emit, so playerEvents.ts needs no HTML5-specific branch — HTML5 is just +another event source feeding the existing pipeline.
  • +
  • Position reports are throttled (~250ms) to match the MPV cadence and avoid flooding IPC from the +60fps RAF loop.
  • +
  • Boundary rule: UI components never touch the report commands or videoElement state directly. +Playback control goes through the unified facade src/lib/player/index.ts (playerController); +HTML5 state reporting goes through html5Adapter.ts. This restores the documented invariant +("frontend only displays state and invokes commands") for the video path.
  • +
+

MpvBackend (Linux)

+

Location: src-tauri/src/player/mpv/

+

The MPV backend uses libmpv for audio playback on Linux. Since MPV handles are not Send, all operations occur on a dedicated thread.

+
flowchart TB
+    subgraph MainThread["Main Thread"]
+        MpvBackend["MpvBackend<br/>- command_tx<br/>- shared_state<br/>- shutdown"]
+        Commands["Commands:<br/>Load, Play, Pause<br/>Stop, Seek, SetVolume"]
+    end
+
+    subgraph EventLoopThread["MPV Event Loop Thread"]
+        EventLoop["event_loop.rs<br/>- MPV Handle<br/>- command_rx<br/>- Event Emitter"]
+        TauriEmitter["TauriEventEmitter"]
+    end
+
+    MpvBackend -->|"MpvCommand"| EventLoop
+    MpvBackend <-->|"Arc<Mutex<>>"| EventLoop
+    EventLoop -->|"Events"| TauriEmitter
+    TauriEmitter --> FrontendStore["Frontend Store"]
+
+

Key Components:

+
#![allow(unused)]
+fn main() {
+// Command enum sent to event loop thread
+pub enum MpvCommand {
+    Load { url: String, media: MediaItem },
+    Play,
+    Pause,
+    Stop,
+    Seek(f64),
+    SetVolume(f32),
+    Quit,
+}
+
+// Shared state between main thread and event loop
+pub struct MpvSharedState {
+    pub state: PlayerState,
+    pub position: f64,
+    pub duration: Option<f64>,
+    pub volume: f32,
+    pub is_loaded: bool,
+    pub current_media: Option<MediaItem>,
+}
+}
+

Event Loop (event_loop.rs):

+
    +
  • Initializes MPV with audio-only config (vo=null, video=false)
  • +
  • Observes properties: time-pos, duration, pause, volume
  • +
  • Emits position updates every 250ms during playback
  • +
  • Processes commands from channel (non-blocking)
  • +
  • Handles MPV events: FileLoaded, EndFile, PropertyChange
  • +
+

ExoPlayerBackend (Android)

+

Location: src-tauri/src/player/android/ and Kotlin sources

+

The ExoPlayer backend uses Android's Media3/ExoPlayer library via JNI.

+
flowchart TB
+    subgraph RustNative["Rust (Native)"]
+        ExoBackend["ExoPlayerBackend<br/>- player_ref<br/>- shared_state"]
+        NativeFuncs["JNI Callbacks<br/>nativeOnPosition...<br/>nativeOnState...<br/>nativeOnMediaLoaded<br/>nativeOnPlaybackEnd"]
+        TauriEmitter2["TauriEventEmitter"]
+    end
+
+    subgraph KotlinJVM["Kotlin (JVM)"]
+        JellyTauPlayer["JellyTauPlayer<br/>- ExoPlayer<br/>- Player.Listener"]
+    end
+
+    ExoBackend -->|"JNI Calls"| JellyTauPlayer
+    JellyTauPlayer -->|"Callbacks"| NativeFuncs
+    NativeFuncs --> TauriEmitter2
+    TauriEmitter2 --> FrontendStore2["Frontend Store"]
+
+

Kotlin Player (JellyTauPlayer.kt):

+
class JellyTauPlayer(context: Context) {
+    private val exoPlayer: ExoPlayer
+    private var positionUpdateJob: Job?
+
+    // Methods callable from Rust via JNI
+    fun load(url: String, mediaId: String)
+    fun play()
+    fun pause()
+    fun stop()
+    fun seek(positionSeconds: Double)
+    fun setVolume(volume: Float)
+
+    // Native callbacks to Rust
+    private external fun nativeOnPositionUpdate(position: Double, duration: Double)
+    private external fun nativeOnStateChanged(state: String, mediaId: String?)
+    private external fun nativeOnMediaLoaded(duration: Double)
+    private external fun nativeOnPlaybackEnded()
+}
+
+

JNI Callbacks (Rust):

+
#![allow(unused)]
+fn main() {
+#[no_mangle]
+pub extern "system" fn Java_com_dtourolle_jellytau_player_JellyTauPlayer_nativeOnPositionUpdate(
+    _env: JNIEnv, _class: JClass, position: jdouble, duration: jdouble
+) {
+    // Update shared state
+    // Emit PlayerStatusEvent::PositionUpdate
+}
+}
+

Audio settings on ExoPlayer

+

TRACES: UR-027, UR-032, UR-033 | DR-030, DR-035, DR-036

+

PlayerBackend declares set_audio_settings with a default Ok(()) body. For a +long time ExoPlayerBackend took that default, so Settings › Audio rendered +controls that silently did nothing on Android — the parity gap recorded in +requirements.md, +now closed.

+

The settings cross to Kotlin as JSON over JNI, not as a wide signature, so new +fields do not change the method signature — the same approach load() uses for +subtitles:

+
#![allow(unused)]
+fn main() {
+fn set_audio_settings(&mut self, settings: &AudioSettings) -> Result<(), PlayerError> {
+    let json = audio_settings_jni_payload(settings)?;
+    env.call_method(&self.player_ref, "setAudioSettings", "(Ljava/lang/String;)V", …)?;
+    // Store the sanitised form, so audio_settings() reflects what was applied.
+    self.shared_state.lock_safe().audio_settings =
+        settings.clone().with_crossfade_clamped().with_equalizer_normalised();
+}
+}
+

Kotlin owns the mechanics — attaching AudioEffects to the audio session — while +the canonical band layout and preset curves stay in Rust:

+
+ + + + +
FeatureAndroid mechanismNotes
GaplesspauseAtEndOfMediaItems
Volume normalizationLoudnessEnhancerA gain stage — approximate next to MPV's dynaudnorm
Equalizerandroid.media.audiofx.EqualizerThe canonical 10 bands are resampled onto the device's own band centres
CrossfadeUnimplemented on every platform (DR-034), architecturally blocked on MPV. Building it on Android alone would invert the parity gap
+
+

Two things are deliberately still open: the effects are not yet verified on a +physical device (AudioEffect availability and band layouts are device-specific), +and the trait default is still a silent Ok(()) rather than an error, so a backend +that omits the method still reports success. Flipping that default waits on the +device verification.

+

The equalizer, and where its vocabulary lives

+

TRACES: UR-027 | DR-030, IR-020

+

The canonical band layout (EQ_BANDS) and the preset curves live in +settings.rs, not in either backend and not in the UI: a preset is a gain +curve defined by the band layout, and the layout is a property of the audio +engine rather than of the picker that renders it. Presets are Flat, Rock, Pop, +Jazz, Classical, Bass Boost, Treble Boost and Vocal, all conservative (within +±8 dB) so they stack safely with volume normalization.

+
+ + +
PlatformMechanism
LinuxOne ffmpeg two-pole peaking equalizer filter per band, composed by build_af_filter into MPV's af property alongside the normalization filter: equalizer=f=31:width_type=o:width=1:g=5
Androidandroid.media.audiofx.Equalizer, with the canonical 10 bands resampled onto whatever band centres the device actually has
+
+

Gains are normalised (with_equalizer_normalised) before use, and bands beyond +EQ_BANDS are ignored, so a malformed settings payload cannot produce a filter +chain of unbounded length.

+

Background Audio Handoff (Android)

+

TRACES: UR-040 | IR-025, DR-051, DR-052, DR-178 … DR-180, DR-196, DR-203

+

Keeping a video's audio alive when the app is backgrounded or the screen +locks, while video decode stops. Two verified facts drive the whole design:

+
    +
  1. An Android WebView <video> does not keep playing audio once the app is +backgrounded — the system throttles the WebView and media pauses.
  2. +
  3. Keeping audio alive in the background requires a native foreground media +service, which already exists for music (JellyTauPlaybackService + +JellyTauPlayer + MediaSessionCompat).
  4. +
+

So this is a handoff, not "keep the WebView alive": on background, tear down +the current renderer and play the same item audio-only through the native +service; on foreground, hand back. In the project's one-directional playback +model this is a change of which player is authoritative, and the position must +transfer cleanly across it.

+
sequenceDiagram
+    participant App as App backgrounded
+    participant FE as VideoPlayer
+    participant Rust as player_enter/exit_background_audio
+    participant Exo as Native audio service
+
+    App-->>FE: jellytau-background (DOM CustomEvent)
+    FE->>Rust: enter(item, position, audioStreamIndex)
+    Rust->>Exo: play audio-only at position
+    Note over Exo: lockscreen + notification, existing MediaSession
+    App-->>FE: jellytau-foreground
+    FE->>Rust: exit() -> final position
+    Rust-->>FE: position
+    FE->>FE: restart the renderer that is on screen
+
+

Details that were each a shipped defect:

+
    +
  • Position is absolute. Transcoded HLS tracks time as +videoElement.currentTime + seekOffset (the element resets to 0 after each +transcode reload). computeHandoffPosition sums both terms; using the element +time alone rewinds by the offset.
  • +
  • A downloaded episode takes no base URL and an ordinary seek (DR-180); a +stream takes the base and no seek; a handoff at 0:00 takes neither.
  • +
  • The return must restart the renderer that is actually on screen (DR-196). +The two paths resume by different means — the webview <video> reloads off its +stream URL, watched by an $effect; ExoPlayer owns no element and nothing +watches the URL for it, so it needs an explicit re-issue. Doing only the URL +assignment restarted nothing on the native path and left a black screen with a +play button that did nothing.
  • +
  • wasPlaying is captured on the way out so play/pause survives the round +trip, and the handoff does not silently rewind (DR-203).
  • +
  • Mutually exclusive with PiP. Toggle on → setAutoEnterEnabled(false); +toggle off → PiP on background, the status quo. The frontend re-asserts the +value whenever the toggle changes and on unmount, so a stale setting cannot +leak into the next player.
  • +
  • The pure arithmetic and state transitions live in +backgroundAudioHandoff.ts, free of Svelte and the DOM, so they are testable +without mounting the player.
  • +
+

Native signals background/foreground to the frontend as DOM CustomEvents +(jellytau-background / jellytau-foreground); the frontend carries the toggle +state to native through the AndroidBackgroundAudio bridge. No-op on every +non-Android platform.

+

Native Video Compositing (Android)

+

TRACES: UR-003, UR-004 | DR-150 … DR-152, DR-182 … DR-196

+

Android can render video on the native ExoPlayer surface behind a transparent +Tauri WebView, with the Svelte controls drawn over it. This is on by default; +the HTML5 <video> path remains the fallback and is not being removed. The +default has been flipped and reverted twice and each revert has a named cause — +the per-defect record is in requirements.md (DR-150 … DR-196).

+
flowchart TB
+    subgraph Window["One Android window"]
+        Texture["TextureView (index 0)<br/>ExoPlayer video"]
+        WebView["Tauri WebView (above)<br/>transparent, Svelte controls"]
+    end
+    Rust["ExoPlayerBackend"] -->|JNI| Player["JellyTauPlayer"]
+    Player --> Texture
+    MainActivity -->|"setTransparent(true)"| WebView
+    VideoOverlayManager -->|"attach / detach"| Texture
+
+

Load-bearing details, each of which was a shipped defect:

+
    +
  • TextureView, not SurfaceView (DR-192). A SurfaceView renders on its own +layer outside the app window and punches a transparent hole through it; +everything drawn above that hole — for us the whole UI — depends on that +composition path, which Android's own documentation says does not reliably +work. A TextureView makes "behind" ordinary view z-order within one window.
  • +
  • Attached at index 0 by VideoOverlayManager, and detached when the video +goes (DR-184) — a surface left in the hierarchy outlives its player.
  • +
  • Bridges are installed before the page that uses them (DR-183). +addJavascriptInterface must run once per WebView instance and a call that +lands after the page has loaded never reaches it, so setTransparent(true) +could be dropped entirely.
  • +
  • The app shell stops painting over the surface (DR-185). app.css clears +its opaque backgrounds off [data-native-video]; before that, a CSS rule +targeted an attribute nothing ever set, so the fix looked applied and was not.
  • +
  • The poster card can lift on a path with no <video> element (DR-182) — the +native reveal fires on a playing state or a position tick carrying a position +or duration, and on nothing else.
  • +
  • Letterbox bars are painted, not left holding whatever was last in the +framebuffer (DR-194).
  • +
  • There is deliberately no audio-focus bridge: manual focus requests from the +WebView competed with Chromium's AudioFocusDelegate and with ExoPlayer, and +the resulting AUDIOFOCUS_LOSS paused playback.
  • +
+

Related Kotlin pieces in the same window: PictureInPictureManager (DR-160/161), +ScreenWakeManager (DR-202 — Android counts its display timeout from touch +events, which a playing video does not generate), ImmersiveModeBridge and +WindowInsetsBridge (IR-031/DR-112 — see +02-svelte-frontend.md).

+

Android MediaSession & Remote Volume Control

+

Location: JellyTauPlaybackService.kt

+

JellyTau uses a dual MediaSession architecture for Android to support both Media3 playback controls and remote volume control:

+
flowchart TB
+    subgraph Service["JellyTauPlaybackService"]
+        MediaSession["Media3 MediaSession<br/>- Lockscreen controls<br/>- Media notifications<br/>- Play/Pause/Next/Previous"]
+
+        MediaSessionCompat["MediaSessionCompat<br/>- Remote volume control<br/>- Hardware button interception"]
+
+        VolumeProvider["VolumeProviderCompat<br/>- onSetVolumeTo()<br/>- onAdjustVolume()"]
+
+        MediaSessionCompat --> VolumeProvider
+    end
+
+    subgraph Hardware["System"]
+        VolumeButtons["Hardware Volume Buttons"]
+        Lockscreen["Lockscreen Controls"]
+        Notification["Media Notification"]
+    end
+
+    subgraph Rust["Rust Backend"]
+        JNI["JNI Callbacks<br/>nativeOnRemoteVolumeChange()"]
+        PlaybackMode["PlaybackModeManager<br/>send_remote_volume_command()"]
+        JellyfinAPI["Jellyfin API<br/>session_set_volume()"]
+    end
+
+    VolumeButtons --> VolumeProvider
+    Lockscreen --> MediaSession
+    Notification --> MediaSession
+
+    VolumeProvider --> JNI
+    JNI --> PlaybackMode
+    PlaybackMode --> JellyfinAPI
+
+

Architecture Rationale:

+

JellyTau maintains both MediaSession types because they serve different purposes:

+
    +
  1. Media3 MediaSession: Handles lockscreen/notification playback controls (play/pause/next/previous)
  2. +
  3. MediaSessionCompat: Intercepts hardware volume button presses for remote playback control
  4. +
+

When in remote playback mode (controlling a Jellyfin session on another device):

+
    +
  • Volume buttons are routed through VolumeProviderCompat
  • +
  • Volume changes are sent to the remote session via Jellyfin API
  • +
  • System volume UI shows the remote session's volume level
  • +
+

Remote Volume Flow:

+
sequenceDiagram
+    participant User
+    participant VolumeButton as Hardware Volume Button
+    participant VolumeProvider as VolumeProviderCompat
+    participant JNI as nativeOnRemoteVolumeChange
+    participant PlaybackMode as PlaybackModeManager
+    participant Jellyfin as Jellyfin Server
+    participant RemoteSession as Remote Session (TV/Browser)
+
+    User->>VolumeButton: Press Volume Up
+    VolumeButton->>VolumeProvider: onAdjustVolume(ADJUST_RAISE)
+    VolumeProvider->>VolumeProvider: remoteVolumeLevel += 2
+    VolumeProvider->>VolumeProvider: currentVolume = remoteVolumeLevel
+    VolumeProvider->>JNI: nativeOnRemoteVolumeChange("VolumeUp", level)
+    JNI->>PlaybackMode: send_remote_volume_command("VolumeUp", level)
+    PlaybackMode->>Jellyfin: POST /Sessions/{id}/Command/VolumeUp
+    Jellyfin->>RemoteSession: Set volume to new level
+    RemoteSession-->>User: Volume changes on TV/Browser
+
+

Key Implementation Details:

+

Enabling Remote Volume (enableRemoteVolume()):

+
fun enableRemoteVolume(initialVolume: Int) {
+    volumeProvider = object : VolumeProviderCompat(
+        VolumeProviderCompat.VOLUME_CONTROL_ABSOLUTE,
+        100,  // Max volume
+        initialVolume
+    ) {
+        override fun onSetVolumeTo(volume: Int) {
+            remoteVolumeLevel = volume.coerceIn(0, 100)
+            nativeOnRemoteVolumeChange("SetVolume", remoteVolumeLevel)
+        }
+
+        override fun onAdjustVolume(direction: Int) {
+            when (direction) {
+                AudioManager.ADJUST_RAISE -> {
+                    remoteVolumeLevel = (remoteVolumeLevel + 2).coerceAtMost(100)
+                    nativeOnRemoteVolumeChange("VolumeUp", remoteVolumeLevel)
+                    currentVolume = remoteVolumeLevel
+                }
+                AudioManager.ADJUST_LOWER -> {
+                    remoteVolumeLevel = (remoteVolumeLevel - 2).coerceAtLeast(0)
+                    nativeOnRemoteVolumeChange("VolumeDown", remoteVolumeLevel)
+                    currentVolume = remoteVolumeLevel
+                }
+            }
+        }
+    }
+
+    mediaSessionCompat.setPlaybackToRemote(volumeProvider)
+}
+
+

Disabling Remote Volume (disableRemoteVolume()):

+
fun disableRemoteVolume() {
+    mediaSessionCompat.setPlaybackToLocal(AudioManager.STREAM_MUSIC)
+    volumeProvider = null
+}
+
+

Rust Integration (src-tauri/src/player/android/mod.rs):

+
#![allow(unused)]
+fn main() {
+/// Enable remote volume control on Android
+pub fn enable_remote_volume(initial_volume: i32) -> Result<(), String> {
+    start_playback_service()?;
+    let service_instance = get_playback_service_instance()?;
+    env.call_method(&service_instance, "enableRemoteVolume", "(I)V",
+        &[JValue::Int(initial_volume)])?;
+    Ok(())
+}
+}
+

Dependencies (src-tauri/android/build.gradle.kts):

+
dependencies {
+    implementation("androidx.media3:media3-session:1.5.1")  // Media3 MediaSession
+    implementation("androidx.media:media:1.7.0")            // MediaSessionCompat
+}
+
+

Integration with Playback Mode:

+

Remote volume is automatically enabled/disabled during playback mode transfers:

+
#![allow(unused)]
+fn main() {
+// In PlaybackModeManager::transfer_to_remote()
+#[cfg(target_os = "android")]
+{
+    if let Err(e) = crate::player::enable_remote_volume(50) {
+        log::warn!("Failed to enable remote volume: {}", e);
+    }
+}
+
+// In PlaybackModeManager::transfer_to_local()
+#[cfg(target_os = "android")]
+{
+    if let Err(e) = crate::player::disable_remote_volume() {
+        log::warn!("Failed to disable remote volume: {}", e);
+    }
+}
+}
+

Android Album Art Caching

+

Location: src-tauri/android/src/main/java/com/dtourolle/jellytau/player/AlbumArtCache.kt

+

Album art caching provides efficient bitmap storage for lock screen notifications with automatic LRU eviction and memory management.

+
flowchart TB
+    subgraph JellyTauPlayer["JellyTauPlayer.kt"]
+        LoadMedia["loadWithMetadata()<br/>- Store artworkUrl<br/>- Launch async download"]
+        AsyncDownload["Coroutine<br/>- Non-blocking<br/>- Dispatchers.IO"]
+    end
+
+    subgraph Cache["AlbumArtCache.kt"]
+        MemoryCache["LruCache<String, Bitmap><br/>- 1/8 of heap<br/>- ~12-16MB typical<br/>- 50-100 albums capacity"]
+        Download["Download & Scale<br/>- 512x512 max<br/>- Exponential backoff"]
+        ErrorHandle["Error Handling<br/>- Graceful fallback<br/>- Auto-retry"]
+    end
+
+    subgraph Service["JellyTauPlaybackService.kt"]
+        UpdateMeta["updateMediaMetadata()<br/>- Accept Bitmap parameter<br/>- Add METADATA_KEY_ALBUM_ART"]
+        Notification["Notification<br/>- setLargeIcon()<br/>- Lock screen display"]
+    end
+
+    LoadMedia --> AsyncDownload
+    AsyncDownload --> MemoryCache
+    MemoryCache --> Download
+    Download --> ErrorHandle
+    AsyncDownload --> UpdateMeta
+    UpdateMeta --> Notification
+
+

AlbumArtCache Singleton:

+
class AlbumArtCache(context: Context) {
+    private val memoryCache = object : LruCache<String, Bitmap>(cacheSize) {
+        override fun sizeOf(key: String, bitmap: Bitmap): Int {
+            return bitmap.byteCount / 1024  // Size in KB
+        }
+    }
+
+    suspend fun getArtwork(url: String): Bitmap? {
+        memoryCache.get(url)?.let { return it }
+        return downloadAndCache(url)
+    }
+
+    private suspend fun downloadAndCache(url: String): Bitmap? =
+        withContext(Dispatchers.IO) {
+            // HTTP download with 5s timeout
+            // Scale to 512x512 max
+            // Auto-evict LRU if needed
+        }
+}
+
+

Integration Flow:

+
    +
  1. +

    Track Load (loadWithMetadata()):

    +
      +
    • Store artwork URL in currentArtworkUrl
    • +
    • Reset bitmap to null
    • +
    • Start playback immediately (non-blocking)
    • +
    +
  2. +
  3. +

    Async Download (Background Coroutine):

    +
      +
    • Check cache: instant hit if available
    • +
    • Network miss: download, scale, cache
    • +
    • Auto-retry on network failure with exponential backoff
    • +
    • Graceful fallback if artwork unavailable
    • +
    +
  4. +
  5. +

    Notification Update:

    +
      +
    • Pass bitmap to updatePlaybackServiceNotification()
    • +
    • Add to MediaMetadataCompat with METADATA_KEY_ALBUM_ART
    • +
    • Display as large icon in notification
    • +
    • Show on lock screen
    • +
    +
  6. +
+

Memory Management:

+
+ + + + + + +
MetricValue
Cache Size1/8 of heap (12-16MB typical)
Max Resolution512x512 pixels
Capacity~50-100 album arts
Eviction PolicyLRU (Least Recently Used)
LifetimeIn-memory only (app session)
Network Timeout5 seconds per download
+
+

Performance Characteristics:

+
    +
  • Cache Hit: ~1ms (in-memory retrieval)
  • +
  • Cache Miss: ~200-500ms (download + scale)
  • +
  • Playback Impact: Zero (async downloads)
  • +
  • Memory Overhead: Max 16MB (auto-eviction)
  • +
  • Error Recovery: Automatic with exponential backoff
  • +
+

Backend Initialization

+

Location: src-tauri/src/lib.rs

+

Backend selection is platform-specific:

+
#![allow(unused)]
+fn main() {
+fn create_player_backend(app_handle: tauri::AppHandle) -> Box<dyn PlayerBackend> {
+    let event_emitter = Arc::new(TauriEventEmitter::new(app_handle));
+
+    #[cfg(target_os = "linux")]
+    {
+        match MpvBackend::new(event_emitter.clone()) {
+            Ok(backend) => return Box::new(backend),
+            Err(e) => eprintln!("MPV init failed: {}", e),
+        }
+    }
+
+    #[cfg(target_os = "android")]
+    {
+        // ExoPlayer requires Activity context, initialized separately
+    }
+
+    // Fallback
+    Box::new(NullBackend::new())
+}
+}
+ +
+ + +
+
+ + + +
+ + + + + + + + + + + + + + + + + + +
+ + diff --git a/architecture/06-downloads-and-offline.html b/architecture/06-downloads-and-offline.html new file mode 100644 index 00000000..6b9ac5a9 --- /dev/null +++ b/architecture/06-downloads-and-offline.html @@ -0,0 +1,530 @@ + + + + + + Downloads & Offline - JellyTau Documentation + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+ + + + + + + + + + + + + + + + + + + +
+ +
+ + + + + + + + +
+
+

Download Manager & Offline Architecture

+

Overview

+

Location: src-tauri/src/download/

+

The download manager provides offline media support with priority-based queue management, progress tracking, retry logic, and smart caching.

+
flowchart TB
+    subgraph Frontend["Frontend"]
+        DownloadButton["DownloadButton.svelte"]
+        DownloadsPage["/downloads"]
+        DownloadsStore["downloads.ts store"]
+    end
+
+    subgraph Backend["Rust Backend"]
+        Commands["Download Commands"]
+        DownloadManager["DownloadManager"]
+        DownloadWorker["DownloadWorker"]
+        SmartCache["SmartCache Engine"]
+    end
+
+    subgraph Storage["Storage"]
+        SQLite[("SQLite DB")]
+        MediaFiles[("Downloaded Files")]
+    end
+
+    DownloadButton -->|"invoke('download_item')"| Commands
+    DownloadsPage -->|"invoke('get_downloads')"| Commands
+    Commands --> DownloadManager
+    DownloadManager --> DownloadWorker
+    DownloadManager --> SmartCache
+    DownloadWorker -->|"HTTP Stream"| MediaFiles
+    DownloadWorker -->|"Events"| DownloadsStore
+    Commands <--> SQLite
+    SmartCache <--> SQLite
+
+

Download Worker

+

Location: src-tauri/src/download/worker.rs

+

The download worker handles HTTP streaming with retry logic and resume support:

+
#![allow(unused)]
+fn main() {
+pub struct DownloadWorker {
+    client: reqwest::Client,
+    max_retries: u32,
+}
+
+pub struct DownloadTask {
+    pub id: i64,
+    pub item_id: String,
+    pub user_id: String,
+    pub priority: i32,
+    pub url: String,
+    pub target_path: PathBuf,
+    pub mime_type: Option<String>,
+    pub expected_size: Option<i64>,
+}
+}
+

Retry Strategy:

+
    +
  • Exponential backoff: 5s, 15s, 45s
  • +
  • Maximum 3 retry attempts
  • +
  • HTTP Range requests for resume support
  • +
  • Progress events emitted every 1MB
  • +
+

Download Flow:

+
sequenceDiagram
+    participant UI
+    participant Command as download_item
+    participant DB as SQLite
+    participant Worker as DownloadWorker
+    participant Jellyfin as Jellyfin Server
+    participant Store as downloads store
+
+    UI->>Command: download_item(itemId, userId)
+    Command->>DB: INSERT INTO downloads
+    Command->>Worker: Start download task
+    Worker->>Jellyfin: GET /Items/{id}/Download
+
+    loop Progress Updates
+        Jellyfin->>Worker: Stream chunks
+        Worker->>Worker: Write to .part file
+        Worker->>Store: Emit progress event
+        Store->>UI: Update progress bar
+    end
+
+    Worker->>Worker: Rename .part to final
+    Worker->>DB: UPDATE status='completed'
+    Worker->>Store: Emit completed event
+    Store->>UI: Show completed
+
+

Smart Caching Engine

+

Location: src-tauri/src/download/cache.rs

+

The smart caching system provides predictive downloads based on listening patterns:

+
#![allow(unused)]
+fn main() {
+pub struct SmartCache {
+    config: Arc<Mutex<CacheConfig>>,
+    album_play_history: Arc<Mutex<HashMap<String, Vec<String>>>>,
+}
+
+pub struct CacheConfig {
+    pub queue_precache_enabled: bool,
+    pub queue_precache_count: usize,        // Default: 5
+    pub album_affinity_enabled: bool,
+    pub album_affinity_threshold: usize,    // Default: 3
+    pub storage_limit: u64,                 // Default: 10GB
+    pub wifi_only: bool,                    // Default: true
+}
+}
+

Caching Strategies:

+
    +
  1. Queue Pre-caching: Auto-download next 5 tracks when playing (WiFi only)
  2. +
  3. Album Affinity: If user plays 3+ tracks from album, cache entire album
  4. +
  5. LRU Eviction: Remove least recently accessed when storage limit reached
  6. +
+
flowchart TB
+    Play["Track Played"] --> CheckQueue{"Queue<br/>Pre-cache?"}
+    CheckQueue -->|"Yes"| CacheNext5["Download<br/>Next 5 Tracks"]
+
+    Play --> TrackHistory["Track Play History"]
+    TrackHistory --> CheckAlbum{"3+ Tracks<br/>from Album?"}
+    CheckAlbum -->|"Yes"| CacheAlbum["Download<br/>Full Album"]
+
+    CacheNext5 --> CheckStorage{"Storage<br/>Limit?"}
+    CacheAlbum --> CheckStorage
+    CheckStorage -->|"Exceeded"| EvictLRU["Evict LRU Items"]
+    CheckStorage -->|"OK"| Download["Queue Download"]
+
+

One Storage Model: Cache Entries Are Downloads

+

TRACES: UR-071 | DR-126, DR-127

+

A cache entry is a download with a shorter life: the same downloads row and +the same file handling, distinguished by download_source plus an expiry. There +is one storage model rather than a cache and a download library that can +disagree about what is on disk.

+
+ + +
download_sourceLifeReclaimed by
'auto' (temporary)Expiry, or eviction under space pressureBoth
'user' (permanent)No expiryNeither
+
+

Eviction only reclaims the temporary tier. evict_lru_async originally +selected every completed download ordered by completed_at ASC with no source +filter, so hitting the storage limit deleted the oldest download — typically a +film saved deliberately for offline — to make room for a newly precached track. +It now evicts only COALESCE(download_source, 'user') = 'auto' rows. +COALESCE rather than a bare equality is load-bearing: rows predating the +migration can be NULL, and unknown provenance must be treated as the user's, +never as disposable. Freeing less than requested is the correct outcome when +only user downloads remain — the caller reports "unable to free enough".

+

A temporary row can be promoted to permanent when the user chooses to keep +it. That only clears the expiry and flips the source; the bytes never move.

+

Offline Catalog Visibility

+

TRACES: UR-052 | DR-078, DR-079, DR-080

+

Offline, a library page shows only media on the device. A "Show all server +media" toggle additionally reveals the cached server catalog, greyed out and +queueable for download on reconnect.

+

The gate is a process-global INCLUDE_CATALOG_BROWSE in +repository/offline.rs, written by the set_show_server_catalog command. It +gates the synced-catalog leg of get_items; without it the toggle rendered but +every server item still appeared, which is the defect the spec was written for. +isConnected derives from backend-reported reachability alone (DR-079) — see +07-connectivity.md.

+

Per-item disk usage comes from repository_get_download_disk_usage +(DownloadDiskUsage), aggregated from downloads.file_size — used by the +Downloaded browse cards, detail pages, the device total and the remove +confirmation (DR-085).

+

Download Commands

+

Location: src-tauri/src/commands/download/mod.rs (the commands below), pinning.rs, smart_cache.rs

+
+ + + + + + + + + + + + +
CommandParametersDescription
download_itemitem_id, user_id, file_pathQueue single item download
download_albumalbum_id, user_idQueue all tracks in album
get_downloadsuser_id, status_filterGet download list
pause_downloaddownload_idPause active download
resume_downloaddownload_idResume paused download
cancel_downloaddownload_idCancel and delete partial
delete_downloaddownload_idDelete completed download
download_video / download_series / download_seasonitem idsQueue video content
get_download_storage_statsuser_idDevice totals for the downloads screen
delete_album_downloads / delete_downloads_under / delete_all_downloadscontainer idBulk removal
pin_item / unpin_item / is_item_pinneditem_idProtect metadata from a cache clear
set_max_concurrent_downloadsmaxWorker concurrency (3 by default)
+
+

Offline Commands

+

Location: src-tauri/src/commands/offline.rs

+
+ + + +
CommandParametersDescription
offline_is_availableitem_idCheck if item downloaded
offline_get_itemsuser_idGet all offline items
offline_searchuser_id, querySearch downloaded items
+
+

Player Integration

+

Location: src-tauri/src/commands/player.rs (modified)

+

The player checks for local downloads before streaming:

+
#![allow(unused)]
+fn main() {
+fn create_media_item(req: PlayItemRequest, db: Option<&DatabaseWrapper>) -> MediaItem {
+    let local_path = db.and_then(|db_wrapper| {
+        check_for_local_download(db_wrapper, &jellyfin_id).ok().flatten()
+    });
+
+    let source = if let Some(path) = local_path {
+        MediaSource::Local {
+            file_path: PathBuf::from(path),
+            jellyfin_item_id: Some(jellyfin_id.clone())
+        }
+    } else {
+        MediaSource::Remote {
+            stream_url: req.stream_url,
+            jellyfin_item_id: jellyfin_id.clone()
+        }
+    };
+
+    MediaItem { source, /* ... */ }
+}
+}
+

Frontend Downloads Store

+

Location: src/lib/stores/downloads.ts

+
interface DownloadsState {
+  downloads: Record<number, DownloadInfo>;
+  activeCount: number;
+  queuedCount: number;
+}
+
+const downloads = createDownloadsStore();
+
+// Actions
+downloads.downloadItem(itemId, userId, filePath)
+downloads.downloadAlbum(albumId, userId)
+downloads.pause(downloadId)
+downloads.resume(downloadId)
+downloads.cancel(downloadId)
+downloads.delete(downloadId)
+downloads.refresh(userId, statusFilter)
+
+// Derived stores
+export const activeDownloads = derived(downloads, ($d) =>
+  Object.values($d.downloads).filter((d) => d.status === 'downloading')
+);
+
+

Event Handling:

+

The store listens to Tauri events for real-time updates:

+
listen<DownloadEvent>('download-event', (event) => {
+  const payload = event.payload;
+
+  switch (payload.type) {
+    case 'started':
+      // Update status to 'downloading'
+    case 'progress':
+      // Update progress and bytes_downloaded
+    case 'completed':
+      // Update status to 'completed', progress to 1.0
+    case 'failed':
+      // Update status to 'failed', store error message
+  }
+});
+
+

Download UI Components

+

DownloadButton (src/lib/components/library/DownloadButton.svelte):

+
    +
  • Multiple states: available, downloading, completed, failed, paused
  • +
  • Circular progress ring during download
  • +
  • Size variants: sm, md, lg
  • +
  • Integrated into TrackList with showDownload={true} prop
  • +
+

DownloadItem (src/lib/components/downloads/DownloadItem.svelte):

+
    +
  • Individual download list item with progress bar
  • +
  • Action buttons: pause, resume, cancel, delete
  • +
  • Status indicators with color coding
  • +
+

Downloads Page (src/routes/downloads/+page.svelte):

+
    +
  • Active/Completed tabs
  • +
  • Bulk actions: Pause All, Resume All, Clear Completed
  • +
  • Empty states with helpful instructions
  • +
+

Database Schema

+

downloads table:

+
CREATE TABLE downloads (
+    id              INTEGER PRIMARY KEY AUTOINCREMENT,
+    item_id         TEXT NOT NULL,
+    user_id         TEXT NOT NULL,
+    file_path       TEXT,
+    file_size       INTEGER,
+    mime_type       TEXT,
+    status          TEXT DEFAULT 'pending',  -- pending, downloading, completed, failed, paused
+    progress        REAL DEFAULT 0.0,
+    bytes_downloaded INTEGER DEFAULT 0,
+    priority        INTEGER DEFAULT 0,
+    error_message   TEXT,
+    retry_count     INTEGER DEFAULT 0,
+    queued_at       TEXT DEFAULT CURRENT_TIMESTAMP,
+    started_at      TEXT,
+    completed_at    TEXT
+);
+
+CREATE INDEX idx_downloads_queue
+  ON downloads(status, priority DESC, queued_at ASC)
+  WHERE status IN ('pending', 'downloading');
+
+ +
+ + +
+
+ + + +
+ + + + + + + + + + + + + + + + + + +
+ + diff --git a/architecture/07-connectivity.html b/architecture/07-connectivity.html new file mode 100644 index 00000000..69bdf9b0 --- /dev/null +++ b/architecture/07-connectivity.html @@ -0,0 +1,346 @@ + + + + + + Connectivity - JellyTau Documentation + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+ + + + + + + + + + + + + + + + + + + +
+ +
+ + + + + + + + +
+
+

Connectivity & Network Architecture

+

HTTP Client with Retry Logic

+

Location: src-tauri/src/jellyfin/http_client.rs

+

The HTTP client provides automatic retry with exponential backoff for network resilience:

+
#![allow(unused)]
+fn main() {
+pub struct HttpClient {
+    client: reqwest::Client,
+    config: HttpConfig,
+}
+
+pub struct HttpConfig {
+    pub timeout: Duration,        // Default: 30s (large library queries can be slow)
+    pub max_retries: u32,         // Default: 3
+}
+}
+
+

Note: ordinary requests use the 30s timeout above. The connectivity recovery probe (ping) uses a shorter, dedicated 5s timeout so an unreachable server is detected quickly while offline.

+
+

Retry Strategy:

+
    +
  • Retry delays: 1s, 2s, 4s (exponential backoff)
  • +
  • Retries on: Network errors, 5xx server errors
  • +
  • No retry on: 4xx client errors, 401/403 authentication errors
  • +
+

Error Classification:

+
#![allow(unused)]
+fn main() {
+pub enum ErrorKind {
+    Network,        // Connection failures, timeouts, DNS errors
+    Authentication, // 401/403 responses
+    Server,         // 5xx server errors
+    Client,         // Other 4xx errors
+}
+}
+

Connectivity Monitor

+

Location: src-tauri/src/connectivity/mod.rs

+

The connectivity monitor is the single source of truth for server reachability. Its primary signal is the outcome of real repository traffic — every server request the user actually makes. A standalone /System/Info/Public probe is kept only as an offline recovery detector.

+

Source of truth: repository traffic

+

OnlineRepository reports the result of each server request to the monitor, classified via RepoError:

+
+ + + + + + +
Repository outcomeMeaningEffect on reachability
Ok(_)Server answered successfullyMark reachable (instant recovery)
Err(Authentication)Server answered with 401/403Mark reachable (server is up; request was rejected)
Err(NotFound)Server answered with 404Mark reachable (server is up)
Err(Server)Server answered with 5xx / bad bodyMark reachable (server is up)
Err(Network)Connection failure / timeout / DNSCandidate for offline (see debounce)
Err(Database)Local cache error onlyNo effect (not a server signal)
+
+

This classification fixes the previous bug where a successful /System/Info/Public ping reported "online" even while the user's authenticated data calls were failing — and vice versa.

+

Time-window debounce (offline) + instant recovery (online)

+

To stop the banner from flapping on a single dropped request, the transition to offline is debounced over a time window:

+
    +
  • On the first Network failure, the monitor records first_failure_at.
  • +
  • It flips is_server_reachable = false only once Network failures have persisted continuously for OFFLINE_CONFIRM_WINDOW (5s) with no intervening success.
  • +
  • Any success (or server-answered error) clears first_failure_at and immediately marks reachable.
  • +
+

Recovery is therefore instant and asymmetric: one good response brings the app back online, but a brief blip never trips the banner.

+

Offline-only recovery probe

+
flowchart TB
+    Repo["OnlineRepository"] -->|"success / RepoError"| Monitor["ConnectivityMonitor"]
+    Monitor --> State{"is_server_reachable?"}
+    State -->|"Online"| NoProbe["No background polling<br/>(real traffic is the signal)"]
+    State -->|"Offline"| Probe["5s /System/Info/Public probe<br/>(recovery detector)"]
+    Probe -->|"reachable again"| Monitor
+    Monitor -->|"on change"| Emit["Emit connectivity:changed<br/>+ connectivity:reconnected"]
+    Emit --> Frontend["Frontend Store → banner"]
+
+

While online, there is no background polling — real requests keep the state fresh. While offline, the fast 5s probe runs so an idle app still detects the server returning even when no user traffic is flowing.

+

Features:

+
    +
  • Traffic-driven: Reachability follows the requests the user actually makes.
  • +
  • Time-window debounce: Offline declared only after OFFLINE_CONFIRM_WINDOW (5s) of sustained network failure; recovery is instant.
  • +
  • Offline-only probe: 5s /System/Info/Public probe runs only while offline.
  • +
  • Event Emission: Emits connectivity:changed and connectivity:reconnected events.
  • +
  • Thread-Safe: Uses Arc<RwLock<>> for shared state.
  • +
+

Tauri Commands:

+
+ + + + + + + +
CommandDescription
connectivity_check_serverManual reachability check (also used by the frontend's advisory navigator.onLine hint)
connectivity_set_server_urlUpdate monitored server URL
connectivity_get_statusGet current connectivity status
connectivity_start_monitoringStart the offline recovery probe
connectivity_stop_monitoringStop the probe
connectivity_mark_reachableMark reachable — driven by OnlineRepository on every server success
connectivity_mark_unreachableMark unreachable — driven by OnlineRepository on RepoError::Network (subject to debounce)
+
+

Frontend Integration:

+
// The store is a pure reflection of backend events — it no longer decides
+// reachability itself. navigator.onLine is advisory: it triggers an immediate
+// recheck rather than forcing the offline state.
+listen<{ isReachable: boolean }>("connectivity:changed", (event) => {
+  updateConnectivityState(event.payload.isReachable);
+});
+
+

Network Resilience Architecture

+

The connectivity system provides resilience through multiple layers:

+
    +
  1. HTTP Client Layer: Automatic retry with exponential backoff
  2. +
  3. Connectivity Monitoring: Reachability derived from real repository traffic, with an offline-only recovery probe
  4. +
  5. Frontend Integration: Offline mode detection and UI updates (a pure reflection of backend events)
  6. +
  7. Sync Queue: Offline mutations queued for later (see 06-downloads-and-offline.md)
  8. +
+

Design Principles:

+
    +
  • Single source of truth: Reachability follows the outcome of real requests, classified via RepoError; the frontend store and the probe never compete to decide it.
  • +
  • Fail Fast: Don't retry 4xx errors (client errors, authentication).
  • +
  • Fail Slow: Retry network and 5xx errors with increasing delays.
  • +
  • Debounced offline, instant online: Declare offline only after a sustained failure window; recover on the first success.
  • +
  • Probe only when needed: Background polling runs only while offline, as a recovery detector.
  • +
  • Event-Driven: Frontend reacts to connectivity changes via events.
  • +
+ +
+ + +
+
+ + + +
+ + + + + + + + + + + + + + + + + + +
+ + diff --git a/architecture/08-database-design.html b/architecture/08-database-design.html new file mode 100644 index 00000000..f3489d9e --- /dev/null +++ b/architecture/08-database-design.html @@ -0,0 +1,788 @@ + + + + + + Database Design - JellyTau Documentation + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+ + + + + + + + + + + + + + + + + + + +
+ +
+ + + + + + + + +
+
+

Offline Database Design

+

Entity Relationship Diagram

+
erDiagram
+    servers ||--o{ users : "has"
+    servers ||--o{ libraries : "has"
+    libraries ||--o{ items : "contains"
+    items ||--o{ items : "parent_of"
+    items ||--o{ user_data : "has"
+    items ||--o{ downloads : "has"
+    items ||--o{ media_streams : "has"
+    items ||--o{ thumbnails : "has"
+    users ||--o{ user_data : "owns"
+    users ||--o{ downloads : "owns"
+    users ||--o{ sync_queue : "owns"
+
+    servers {
+        int id PK
+        string jellyfin_id UK
+        string name
+        string url
+        string version
+        datetime last_sync
+    }
+
+    users {
+        int id PK
+        string jellyfin_id
+        int server_id FK
+        string name
+        boolean is_active
+    }
+
+    libraries {
+        int id PK
+        string jellyfin_id
+        int server_id FK
+        string name
+        string collection_type
+        string image_tag
+    }
+
+    items {
+        int id PK
+        string jellyfin_id
+        int server_id FK
+        int library_id FK
+        int parent_id FK
+        string type
+        string name
+        string sort_name
+        string overview
+        int production_year
+        float community_rating
+        string official_rating
+        int runtime_ticks
+        string primary_image_tag
+        string backdrop_image_tag
+        string album_id
+        string album_name
+        string album_artist
+        json artists
+        json genres
+        int index_number
+        int parent_index_number
+        string premiere_date
+        json metadata_json
+        datetime created_at
+        datetime updated_at
+        datetime last_sync
+    }
+
+    user_data {
+        int id PK
+        int item_id FK
+        int user_id FK
+        int position_ticks
+        int play_count
+        boolean is_favorite
+        boolean played
+        datetime last_played
+        datetime updated_at
+        datetime synced_at
+    }
+
+    downloads {
+        int id PK
+        int item_id FK
+        int user_id FK
+        string file_path
+        int file_size
+        string status
+        float progress
+        int priority
+        string error_message
+        datetime created_at
+        datetime completed_at
+    }
+
+    media_streams {
+        int id PK
+        int item_id FK
+        int stream_index
+        string type
+        string codec
+        string language
+        string display_title
+        boolean is_default
+        boolean is_forced
+        boolean is_external
+    }
+
+    sync_queue {
+        int id PK
+        int user_id FK
+        string operation
+        string entity_type
+        string entity_id
+        json payload
+        datetime created_at
+        int attempts
+        datetime last_attempt
+        string status
+    }
+
+    thumbnails {
+        int id PK
+        int item_id FK
+        string image_type
+        string image_tag
+        string file_path
+        int width
+        int height
+        datetime cached_at
+    }
+
+

Table Definitions

+

servers

+

Stores connected Jellyfin server information.

+
CREATE TABLE servers (
+    id              INTEGER PRIMARY KEY AUTOINCREMENT,
+    jellyfin_id     TEXT NOT NULL UNIQUE,
+    name            TEXT NOT NULL,
+    url             TEXT NOT NULL,
+    version         TEXT,
+    last_sync       DATETIME,
+    created_at      DATETIME DEFAULT CURRENT_TIMESTAMP,
+    updated_at      DATETIME DEFAULT CURRENT_TIMESTAMP
+);
+
+

users

+

Stores user accounts per server. Access tokens are stored separately in secure storage (see 09-security.md).

+
CREATE TABLE users (
+    id              INTEGER PRIMARY KEY AUTOINCREMENT,
+    jellyfin_id     TEXT NOT NULL,
+    server_id       INTEGER NOT NULL REFERENCES servers(id) ON DELETE CASCADE,
+    name            TEXT NOT NULL,
+    is_active       BOOLEAN DEFAULT 0,
+    created_at      DATETIME DEFAULT CURRENT_TIMESTAMP,
+    updated_at      DATETIME DEFAULT CURRENT_TIMESTAMP,
+    UNIQUE(jellyfin_id, server_id)
+);
+
+

libraries

+

Stores library/collection metadata.

+
CREATE TABLE libraries (
+    id              INTEGER PRIMARY KEY AUTOINCREMENT,
+    jellyfin_id     TEXT NOT NULL,
+    server_id       INTEGER NOT NULL REFERENCES servers(id) ON DELETE CASCADE,
+    name            TEXT NOT NULL,
+    collection_type TEXT,
+    image_tag       TEXT,
+    sort_order      INTEGER DEFAULT 0,
+    last_sync       DATETIME,
+    created_at      DATETIME DEFAULT CURRENT_TIMESTAMP,
+    updated_at      DATETIME DEFAULT CURRENT_TIMESTAMP,
+    UNIQUE(jellyfin_id, server_id)
+);
+
+CREATE INDEX idx_libraries_server ON libraries(server_id);
+
+

items

+

Main table for all media items (movies, episodes, albums, songs, etc.).

+
CREATE TABLE items (
+    id                   INTEGER PRIMARY KEY AUTOINCREMENT,
+    jellyfin_id          TEXT NOT NULL,
+    server_id            INTEGER NOT NULL REFERENCES servers(id) ON DELETE CASCADE,
+    library_id           INTEGER REFERENCES libraries(id) ON DELETE SET NULL,
+    parent_id            INTEGER REFERENCES items(id) ON DELETE CASCADE,
+
+    -- Basic metadata
+    type                 TEXT NOT NULL,
+    name                 TEXT NOT NULL,
+    sort_name            TEXT,
+    overview             TEXT,
+
+    -- Media info
+    production_year      INTEGER,
+    community_rating     REAL,
+    official_rating      TEXT,
+    runtime_ticks        INTEGER,
+
+    -- Images
+    primary_image_tag    TEXT,
+    backdrop_image_tag   TEXT,
+
+    -- Audio-specific
+    album_id             TEXT,
+    album_name           TEXT,
+    album_artist         TEXT,
+    artists              TEXT,                   -- JSON array
+
+    -- Series/Season-specific
+    index_number         INTEGER,
+    parent_index_number  INTEGER,
+    series_id            TEXT,
+    series_name          TEXT,
+    season_id            TEXT,
+
+    -- Additional
+    genres               TEXT,                   -- JSON array
+    premiere_date        TEXT,
+    metadata_json        TEXT,
+
+    -- Sync tracking
+    created_at           DATETIME DEFAULT CURRENT_TIMESTAMP,
+    updated_at           DATETIME DEFAULT CURRENT_TIMESTAMP,
+    last_sync            DATETIME,
+
+    UNIQUE(jellyfin_id, server_id)
+);
+
+-- Performance indexes
+CREATE INDEX idx_items_server ON items(server_id);
+CREATE INDEX idx_items_library ON items(library_id);
+CREATE INDEX idx_items_parent ON items(parent_id);
+CREATE INDEX idx_items_type ON items(type);
+CREATE INDEX idx_items_album ON items(album_id);
+CREATE INDEX idx_items_series ON items(series_id);
+CREATE INDEX idx_items_name ON items(name COLLATE NOCASE);
+
+-- Full-text search
+CREATE VIRTUAL TABLE items_fts USING fts5(
+    name,
+    overview,
+    artists,
+    album_name,
+    album_artist,
+    content='items',
+    content_rowid='id'
+);
+
+-- Triggers to keep FTS in sync
+CREATE TRIGGER items_ai AFTER INSERT ON items BEGIN
+    INSERT INTO items_fts(rowid, name, overview, artists, album_name, album_artist)
+    VALUES (new.id, new.name, new.overview, new.artists, new.album_name, new.album_artist);
+END;
+
+CREATE TRIGGER items_ad AFTER DELETE ON items BEGIN
+    INSERT INTO items_fts(items_fts, rowid, name, overview, artists, album_name, album_artist)
+    VALUES ('delete', old.id, old.name, old.overview, old.artists, old.album_name, old.album_artist);
+END;
+
+CREATE TRIGGER items_au AFTER UPDATE ON items BEGIN
+    INSERT INTO items_fts(items_fts, rowid, name, overview, artists, album_name, album_artist)
+    VALUES ('delete', old.id, old.name, old.overview, old.artists, old.album_name, old.album_artist);
+    INSERT INTO items_fts(rowid, name, overview, artists, album_name, album_artist)
+    VALUES (new.id, new.name, new.overview, new.artists, new.album_name, new.album_artist);
+END;
+
+

media_streams

+

Stores subtitle and audio track information for items.

+
CREATE TABLE media_streams (
+    id              INTEGER PRIMARY KEY AUTOINCREMENT,
+    item_id         INTEGER NOT NULL REFERENCES items(id) ON DELETE CASCADE,
+    stream_index    INTEGER NOT NULL,
+    type            TEXT NOT NULL,
+    codec           TEXT,
+    language        TEXT,
+    display_title   TEXT,
+    is_default      BOOLEAN DEFAULT 0,
+    is_forced       BOOLEAN DEFAULT 0,
+    is_external     BOOLEAN DEFAULT 0,
+    path            TEXT,
+    UNIQUE(item_id, stream_index)
+);
+
+CREATE INDEX idx_media_streams_item ON media_streams(item_id);
+
+

user_data

+

Stores per-user data for items (favorites, progress, play count).

+
CREATE TABLE user_data (
+    id              INTEGER PRIMARY KEY AUTOINCREMENT,
+    item_id         INTEGER NOT NULL REFERENCES items(id) ON DELETE CASCADE,
+    user_id         INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
+
+    -- Playback state
+    position_ticks  INTEGER DEFAULT 0,
+    play_count      INTEGER DEFAULT 0,
+    played          BOOLEAN DEFAULT 0,
+    last_played     DATETIME,
+
+    -- User preferences
+    is_favorite     BOOLEAN DEFAULT 0,
+    user_rating     REAL,
+
+    -- Sync tracking
+    updated_at      DATETIME DEFAULT CURRENT_TIMESTAMP,
+    synced_at       DATETIME,
+    needs_sync      BOOLEAN DEFAULT 0,
+
+    UNIQUE(item_id, user_id)
+);
+
+CREATE INDEX idx_user_data_item ON user_data(item_id);
+CREATE INDEX idx_user_data_user ON user_data(user_id);
+CREATE INDEX idx_user_data_needs_sync ON user_data(needs_sync) WHERE needs_sync = 1;
+CREATE INDEX idx_user_data_favorites ON user_data(user_id, is_favorite) WHERE is_favorite = 1;
+CREATE INDEX idx_user_data_in_progress ON user_data(user_id, position_ticks)
+    WHERE position_ticks > 0 AND played = 0;
+
+

downloads

+

Tracks downloaded media files.

+
CREATE TABLE downloads (
+    id              INTEGER PRIMARY KEY AUTOINCREMENT,
+    item_id         INTEGER NOT NULL REFERENCES items(id) ON DELETE CASCADE,
+    user_id         INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
+
+    file_path       TEXT,
+    file_size       INTEGER,
+    file_hash       TEXT,
+
+    status          TEXT NOT NULL DEFAULT 'pending',
+    progress        REAL DEFAULT 0,
+    bytes_downloaded INTEGER DEFAULT 0,
+
+    transcode_profile TEXT,
+
+    priority        INTEGER DEFAULT 0,
+    error_message   TEXT,
+    retry_count     INTEGER DEFAULT 0,
+
+    created_at      DATETIME DEFAULT CURRENT_TIMESTAMP,
+    started_at      DATETIME,
+    completed_at    DATETIME,
+    expires_at      DATETIME,
+
+    UNIQUE(item_id, user_id)
+);
+
+CREATE INDEX idx_downloads_status ON downloads(status);
+CREATE INDEX idx_downloads_user ON downloads(user_id);
+CREATE INDEX idx_downloads_queue ON downloads(status, priority DESC, created_at ASC)
+    WHERE status IN ('pending', 'downloading');
+
+

sync_queue

+

Stores mutations to sync back to server when online.

+
CREATE TABLE sync_queue (
+    id              INTEGER PRIMARY KEY AUTOINCREMENT,
+    user_id         INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
+
+    operation       TEXT NOT NULL,
+    entity_type     TEXT NOT NULL,
+    entity_id       TEXT NOT NULL,
+    payload         TEXT,
+
+    status          TEXT DEFAULT 'pending',
+    attempts        INTEGER DEFAULT 0,
+    max_attempts    INTEGER DEFAULT 5,
+    last_attempt    DATETIME,
+    error_message   TEXT,
+
+    created_at      DATETIME DEFAULT CURRENT_TIMESTAMP,
+    completed_at    DATETIME
+);
+
+CREATE INDEX idx_sync_queue_status ON sync_queue(status, created_at ASC)
+    WHERE status = 'pending';
+CREATE INDEX idx_sync_queue_user ON sync_queue(user_id);
+
+

thumbnails

+

Caches downloaded artwork.

+
CREATE TABLE thumbnails (
+    id              INTEGER PRIMARY KEY AUTOINCREMENT,
+    item_id         INTEGER NOT NULL REFERENCES items(id) ON DELETE CASCADE,
+    image_type      TEXT NOT NULL,
+    image_tag       TEXT,
+    file_path       TEXT NOT NULL,
+    width           INTEGER,
+    height          INTEGER,
+    file_size       INTEGER,
+    cached_at       DATETIME DEFAULT CURRENT_TIMESTAMP,
+    last_accessed   DATETIME DEFAULT CURRENT_TIMESTAMP,
+    UNIQUE(item_id, image_type, width)
+);
+
+CREATE INDEX idx_thumbnails_item ON thumbnails(item_id);
+CREATE INDEX idx_thumbnails_lru ON thumbnails(last_accessed ASC);
+
+

playlists (for local/synced playlists)

+
CREATE TABLE playlists (
+    id              INTEGER PRIMARY KEY AUTOINCREMENT,
+    jellyfin_id     TEXT,
+    user_id         INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
+    name            TEXT NOT NULL,
+    description     TEXT,
+    is_local_only   BOOLEAN DEFAULT 0,
+    created_at      DATETIME DEFAULT CURRENT_TIMESTAMP,
+    updated_at      DATETIME DEFAULT CURRENT_TIMESTAMP,
+    synced_at       DATETIME,
+    needs_sync      BOOLEAN DEFAULT 0
+);
+
+CREATE TABLE playlist_items (
+    id              INTEGER PRIMARY KEY AUTOINCREMENT,
+    playlist_id     INTEGER NOT NULL REFERENCES playlists(id) ON DELETE CASCADE,
+    item_id         INTEGER NOT NULL REFERENCES items(id) ON DELETE CASCADE,
+    sort_order      INTEGER NOT NULL,
+    added_at        DATETIME DEFAULT CURRENT_TIMESTAMP,
+    UNIQUE(playlist_id, item_id)
+);
+
+CREATE INDEX idx_playlist_items_playlist ON playlist_items(playlist_id, sort_order);
+
+

Key Queries

+

Get items for offline library browsing

+
-- Get all albums in a music library
+SELECT * FROM items
+WHERE library_id = ? AND type = 'MusicAlbum'
+ORDER BY sort_name;
+
+-- Get tracks for an album
+SELECT * FROM items
+WHERE album_id = ? AND type = 'Audio'
+ORDER BY parent_index_number, index_number;
+
+

Resume / Continue Watching

+
SELECT i.*, ud.position_ticks, ud.last_played
+FROM items i
+JOIN user_data ud ON ud.item_id = i.id
+WHERE ud.user_id = ?
+  AND ud.position_ticks > 0
+  AND ud.played = 0
+ORDER BY ud.last_played DESC
+LIMIT 20;
+
+ +
SELECT i.* FROM items i
+JOIN items_fts fts ON fts.rowid = i.id
+WHERE items_fts MATCH ?
+ORDER BY rank;
+
+

Download queue management

+
-- Get next item to download
+SELECT d.*, i.name, i.type
+FROM downloads d
+JOIN items i ON i.id = d.item_id
+WHERE d.status = 'pending'
+ORDER BY d.priority DESC, d.created_at ASC
+LIMIT 1;
+
+-- Get download progress for UI
+SELECT
+    d.status,
+    COUNT(*) as count,
+    SUM(d.file_size) as total_size,
+    SUM(d.bytes_downloaded) as downloaded
+FROM downloads d
+WHERE d.user_id = ?
+GROUP BY d.status;
+
+

Sync queue processing

+
-- Get pending sync operations (oldest first)
+SELECT * FROM sync_queue
+WHERE status = 'pending'
+  AND attempts < max_attempts
+ORDER BY created_at ASC
+LIMIT 10;
+
+-- Mark operation complete
+UPDATE sync_queue
+SET status = 'completed', completed_at = CURRENT_TIMESTAMP
+WHERE id = ?;
+
+

Data Flow

+

Online Mode

+
flowchart TB
+    subgraph OnlineMode["Online Mode"]
+        JellyfinServer["Jellyfin Server"]
+        OnlineRepo["OnlineRepo"]
+        SQLite["SQLite"]
+        HybridRepo["HybridRepository"]
+        UI["UI / Stores"]
+
+        JellyfinServer -->|"API Response"| OnlineRepo
+        OnlineRepo -->|"Cache"| SQLite
+        SQLite -->|"Sync"| JellyfinServer
+        OnlineRepo -->|"Response"| HybridRepo
+        SQLite -->|"Fallback"| HybridRepo
+        HybridRepo --> UI
+    end
+
+

Offline Mode

+
flowchart TB
+    subgraph OfflineMode["Offline Mode"]
+        OfflineRepo["OfflineRepo"]
+        SQLite2["SQLite"]
+        SyncQueue["sync_queue<br/>(Queued for later)"]
+        HybridRepo2["HybridRepository"]
+        UI2["UI / Stores"]
+
+        OfflineRepo <-->|"Query"| SQLite2
+        SQLite2 -->|"Mutations"| SyncQueue
+        OfflineRepo --> HybridRepo2
+        HybridRepo2 --> UI2
+    end
+
+

Sync on Reconnect

+
flowchart LR
+    NetworkRestored["Network restored"]
+    SyncService["SyncService"]
+    SyncQueue2["sync_queue"]
+    JellyfinAPI["Jellyfin API"]
+    MarkSynced["Mark synced"]
+
+    NetworkRestored --> SyncService
+    SyncService -->|"Read"| SyncQueue2
+    SyncQueue2 -->|"Send"| JellyfinAPI
+    JellyfinAPI -->|"Success"| MarkSynced
+    MarkSynced --> SyncService
+
+

Storage Estimates

+
+ + + + + +
Content TypeMetadata SizeThumbnail SizeMedia Size
Song~2 KB~50 KB (300px)5-15 MB
Album (12 tracks)~30 KB~100 KB60-180 MB
Movie~5 KB~200 KB1-8 GB
Episode~3 KB~100 KB300 MB - 2 GB
Full music library (5000 songs)~10 MB~250 MB25-75 GB
+
+

Rust Module Structure

+
src-tauri/src/storage/
+├── mod.rs              # Module exports, Database struct
+├── schema.rs           # Table definitions, migrations
+├── models.rs           # Rust structs matching tables
+├── queries/
+│   ├── mod.rs
+│   ├── items.rs        # Item CRUD operations
+│   ├── user_data.rs    # User data operations
+│   ├── downloads.rs    # Download queue operations
+│   └── sync.rs         # Sync queue operations
+└── sync/
+    ├── mod.rs          # SyncService
+    ├── manager.rs      # Background sync manager
+    └── operations.rs   # Individual sync operation handlers
+
+ +
+ + +
+
+ + + +
+ + + + + + + + + + + + + + + + + + +
+ + diff --git a/architecture/09-security.html b/architecture/09-security.html new file mode 100644 index 00000000..1879b6ff --- /dev/null +++ b/architecture/09-security.html @@ -0,0 +1,377 @@ + + + + + + Security - JellyTau Documentation + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+ + + + + + + + + + + + + + + + + + + +
+ +
+ + + + + + + + +
+
+

Security

+

Authentication Token Storage

+

Access tokens are not stored in the SQLite database. Instead, they are stored using platform-native secure storage:

+
flowchart TB
+    LoginSuccess["Login Success"]
+    KeyringCheck{"System Keyring<br/>Available?"}
+    OSCredential["Store in OS Credential Manager<br/>- Linux: libsecret/GNOME Keyring<br/>- macOS: Keychain<br/>- Windows: Credential Manager<br/>- Android: EncryptedSharedPrefs"]
+    EncryptedFallback["Encrypted File Fallback<br/>(AES-256-GCM)"]
+
+    LoginSuccess --> KeyringCheck
+    KeyringCheck -->|"Yes"| OSCredential
+    KeyringCheck -->|"No"| EncryptedFallback
+
+

Key Format:

+
jellytau::{server_id}::{user_id}::access_token
+
+

Rationale:

+
    +
  • Tokens in SQLite would be readable if the database file is accessed
  • +
  • System keyrings provide OS-level encryption and access control
  • +
  • Fallback ensures functionality on minimal systems without a keyring daemon
  • +
+

Secure Storage Module

+

Location: src-tauri/src/secure_storage/ (planned)

+
#![allow(unused)]
+fn main() {
+pub trait SecureStorage: Send + Sync {
+    fn store(&self, key: &str, value: &str) -> Result<(), SecureStorageError>;
+    fn retrieve(&self, key: &str) -> Result<Option<String>, SecureStorageError>;
+    fn delete(&self, key: &str) -> Result<(), SecureStorageError>;
+}
+
+// Platform implementations
+pub struct KeyringStorage;      // Uses keyring crate
+pub struct EncryptedFileStorage; // AES-256-GCM fallback
+}
+

Network Security

+
+ + + + + + +
AspectImplementation
TransportHTTPS required for all Jellyfin API calls
Certificate ValidationSystem CA store (configurable for self-signed)
Token TransmissionBearer token in Authorization header only
Token RefreshHandled by Jellyfin server (long-lived tokens)
Android cleartextres/xml/network_security_config.xml blocks cleartext everywhere except 127.0.0.1 (the loopback media server, DR-137/DR-138). The manifest's usesCleartextTraffic is ignored once the config is present, so the config is the single authority
Android WebViewmixedContentMode = COMPATIBILITY with allowFileAccess/allowContentAccess both false (DR-199). These are the second half of the cleartext policy: ALWAYS_ALLOW re-opened by hand what the network security config closes. Change the two together
+
+

Webview Content Security Policy

+

app.security.csp in tauri.conf.json (TRACES: UR-012, UR-071 | DR-198). It was +null — CSP disabled — which meant any script that reached the web layer +inherited the full IPC surface. Tauri computes the header from this value when it +serves the embedded HTML, injecting a nonce for SvelteKit's inline bootstrap +script, so script-src needs no 'unsafe-inline'.

+
default-src 'self';
+script-src  'self';
+style-src   'self' 'unsafe-inline';
+font-src    'self' data:;
+img-src     'self' data: blob: asset: http://asset.localhost http: https:;
+media-src   'self' blob: asset: http://asset.localhost http://127.0.0.1:* http: https:;
+connect-src 'self' ipc: http://ipc.localhost http: https:;
+worker-src  'self' blob:;
+object-src 'none'; frame-src 'none'; base-uri 'self'; form-action 'self'; frame-ancestors 'none'
+
+
+ + + + + + + + + +
DirectiveWhy
default-src 'self'Everything not named below is same-origin only.
script-src 'self'The genuinely restrictive half. Bundled JS only; Tauri's build-time nonce covers the one inline <script> in index.html. Adding 'unsafe-inline' here would silently do nothing anyway — a nonce in a directive voids it.
style-src 'self' 'unsafe-inline'Svelte compiles style="…" attributes into markup, including app.html's display: contents wrapper, and CSP treats a style attribute as inline. Safe only while no <style> element survives into index.html: Tauri would nonce it, and the nonce would then void 'unsafe-inline'. The production build extracts all CSS to files, so it currently has none.
img-srcThumbnails come from two places: the asset protocol (asset://localhost/… on Linux/macOS, http://asset.localhost/… on Windows/Android — the same protocol, named differently by convertFileSrc) and, on a cache miss, straight from the Jellyfin server. data:/blob: cover inline and generated images.
media-src<video>/<audio> sources: HLS transcodes and progressive streams from the server, the token-guarded loopback media server on http://127.0.0.1:<random port> (DR-137), and blob: for the MSE object URL hls.js attaches.
connect-srcipc: / http://ipc.localhost is Tauri's invoke transport (custom scheme on Linux/macOS, http host on Windows/Android) — without it every command is blocked. http:/https: is hls.js fetching manifests and segments; ordinary API traffic goes through Rust and is not subject to CSP.
worker-src 'self' blob:hls.js runs its demuxer in a worker built from a blob (enableWorker: true). Without blob: it falls back to main-thread demuxing — playback survives but costs more CPU.
object-src, frame-src = 'none'No plugins, no iframes; both are classic injection sinks.
base-uri 'self', form-action 'self', frame-ancestors 'none'Block <base> hijacking, form exfiltration and framing. frame-ancestors is only honoured when the policy is delivered as a header, which is platform-dependent; it is harmless where it is not.
+
+

img-src/media-src/connect-src are deliberately permissive. The Jellyfin +origin is typed in by the user at run time and is routinely plain http on a +LAN, so it cannot be enumerated at build time. http: https: is a wide grant for +data — but it still bars file:, filesystem: and scripting schemes, and it +does not touch script-src, which is where an injected origin would actually +hurt. A run-time policy naming the server exactly was considered and rejected: +Tauri derives the header from immutable config at the moment it serves the HTML, +so it would mean rebuilding the config and reloading the webview whenever the +user adds or switches a server, to constrain a destination the user chooses +anyway.

+

devCsp mirrors the policy with 'unsafe-inline' 'unsafe-eval' on script-src +and ws:/wss: on connect-src, because the Vite dev server injects styles and +code and drives HMR over a websocket. It applies only to tauri dev.

+

Asset protocol scope

+

app.security.assetProtocol.scope is $APPDATA/thumbnails/** — not the storage +root. imageCache.ts is the only convertFileSrc caller left in the frontend: +downloaded media moved to the loopback media server in DR-137, and downloaded +audio is opened by MPV/ExoPlayer directly from its path. The old $APPDATA/** +grant let the webview read the SQLite database and the encrypted-token fallback +file alongside the thumbnails it actually needs.

+

If a new feature hands the webview a local file, widen this scope to that +subdirectory specifically; a path outside it resolves to nothing and the webview +reports NETWORK_NO_SOURCE (which is exactly how DR-134's failure presented).

+

Local Data Protection

+
+ + + + +
Data TypeProtection
Access TokensSystem keyring or encrypted file
Database (SQLite)Plaintext (metadata only, no secrets)
Downloaded MediaFilesystem permissions only
Cached ThumbnailsFilesystem permissions only
+
+

Path Confinement and Input Binding

+

Two classes of defect, both of the same shape: a value that arrived from +outside decided something it should not, at a site whose neighbours a few lines +away already did it correctly.

+

Filesystem path confinement

+
+ + +
SurfaceRuleTRACES
Thumbnail cacheThe filename is built from item_id, image_type and tag; all three are sanitised (non-alphanumerics → _), and the resolved path is checked with starts_with(cache_dir) at the point of useDR-210
Downloadsfile_path and target_dir are sanitised inside download_item itself, not only in download_item_and_start — the latter is what made the existing guard bypassable rather than absentDR-211
+
+

Two mechanics worth remembering, because both are easy to get subtly wrong:

+
    +
  • Path::join neither folds .. nor keeps the base when handed an absolute +path. Confinement therefore has to be checked after the join, not before.
  • +
  • Sanitising is per path component. Whole-string sanitising would rewrite +downloads/x.mp3 to downloads_x.mp3 and relocate every existing download.
  • +
+

The database keeps both the raw key and the resolved path, so lookups still match +and pre-existing rows still resolve.

+

Query and URL construction

+

Caller-supplied values are bound or encoded, never interpolated (DR-212):

+
    +
  • The offline get_items item-type filter uses parameter placeholders rather +than formatting IN ('a','b').
  • +
  • build_get_items_endpoint encodes ParentId / IncludeItemTypes / SortBy / +SortOrder. Encoding is per element and list separators stay unencoded, +because Jellyfin splits these parameters on the comma.
  • +
  • player_set_volume clamps at the command boundary — it previously accepted +NaN and out-of-range floats even though every backend clamps internally.
  • +
+

Security Considerations

+
    +
  1. No Secrets in SQLite: The database contains only non-sensitive metadata
  2. +
  3. Token Isolation: Each user/server combination has a separate token entry
  4. +
  5. Logout Cleanup: Token deletion from secure storage on logout
  6. +
  7. No Token Logging: Tokens are never written to logs or debug output
  8. +
  9. IPC Security: Tauri's IPC uses structured commands, not arbitrary code execution
  10. +
  11. Webview Containment: A restrictive script-src keeps injected script off the IPC surface; the asset protocol is scoped to the thumbnail cache only (see above)
  12. +
+ +
+ + +
+
+ + + +
+ + + + + + + + + + + + + + + + + + +
+ + diff --git a/architecture/index.html b/architecture/index.html new file mode 100644 index 00000000..86f9067e --- /dev/null +++ b/architecture/index.html @@ -0,0 +1,443 @@ + + + + + + Overview - JellyTau Documentation + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+ + + + + + + + + + + + + + + + + + + +
+ +
+ + + + + + + + +
+
+

JellyTau Software Architecture

+

This document describes the current architecture of JellyTau, a cross-platform Jellyfin client built with Tauri, SvelteKit, and Rust.

+

Last Updated: 2026-06-20

+

Architecture Overview

+

JellyTau uses a client-server architecture: business logic lives in a comprehensive Rust backend, while a UI-rich Svelte frontend handles presentation and interaction.

+

Architecture Principles

+
    +
  • Business Logic in Rust: Core logic — playback, repository, sync, downloads, connectivity — lives in Rust for performance, reliability, and type safety.
  • +
  • Presentation in Svelte: The frontend (~20.5k non-test lines) owns UI, layout, navigation, and interaction state and invokes Rust commands. It is intentionally UI-heavy, not a thin wrapper. Largest pieces: components + routes (~14.6k lines), stores (~3.4k), api/services/utils (~2.4k); VideoPlayer.svelte alone is ~1.6k lines.
  • +
  • Events + Polling hybrid: Rust emits events the frontend listens to, and the UI also polls status on short intervals in a few hot spots (e.g. queue status in library/+layout.svelte, playback progress in VideoPlayer.svelte).
  • +
  • Unified player boundary: UI components control playback only through the frontend facade src/lib/player/index.ts (playerController), never by calling commands.player* directly. Webview-rendered HTML5 video reports its state back into Rust via src/lib/player/html5Adapter.ts and the player_report_* commands, so the PlayerController stays the single source of truth in both native (MPV/ExoPlayer) and HTML5 modes (see 05-platform-backends.md).
  • +
  • Handle-Based Resources: UUID handles for stateful Rust objects.
  • +
  • Cache-First: Parallel queries with intelligent fallback.
  • +
  • Single source of truth for reachability: Server reachability is derived from the outcome of real repository traffic, not a side-channel poller. The OnlineRepository reports each server result to the ConnectivityMonitor (classified via RepoError), which applies a time-window debounce before declaring the server offline and recovers instantly on the first success. The standalone /System/Info/Public probe runs only while offline, as a recovery detector for idle sessions.
  • +
  • Poison-tolerant locking: Shared std::sync state is accessed via the MutexSafe/RwLockSafe helpers in utils/lock.rs, which recover a poisoned lock instead of cascading a panic across the player.
  • +
  • Graceful backend init: If a native player backend (MPV/ExoPlayer) fails to initialize, the app falls back to a no-op backend and emits a backend-init-failed event rather than crashing.
  • +
+
flowchart TB
+    subgraph Frontend["Svelte Frontend"]
+        subgraph Stores["Stores (Thin Wrappers)"]
+            auth["auth"]
+            player["player"]
+            queue["queue"]
+            library["library"]
+            connectivity["connectivity"]
+            playbackMode["playbackMode"]
+        end
+        subgraph Components
+            playerComp["player/"]
+            libraryComp["library/"]
+            Search["Search"]
+        end
+        subgraph Routes
+            routeLibrary["/library"]
+            routePlayer["/player"]
+            routeRoot["/"]
+        end
+        subgraph API["API Layer (Thin Client)"]
+            RepositoryClient["RepositoryClient<br/>(Handle-based)"]
+            JellyfinClient["JellyfinClient<br/>(Helper)"]
+        end
+    end
+
+    Frontend -->|"Tauri IPC (invoke)"| Backend
+
+    subgraph Backend["Rust Backend (Business Logic)"]
+        subgraph Commands["Tauri Commands (90+)"]
+            PlayerCmds["player.rs"]
+            RepoCmds["repository.rs (27)"]
+            PlaybackModeCmds["playback_mode.rs (5)"]
+            StorageCmds["storage.rs"]
+            ConnectivityCmds["connectivity.rs (7)"]
+        end
+
+        subgraph Core["Core Modules"]
+            MediaSessionManager["MediaSessionManager<br/>(Audio/Movie/TvShow/Idle)"]
+
+            PlayerController["PlayerController<br/>+ PlayerBackend<br/>+ QueueManager"]
+
+            Repository["Repository Layer<br/>HybridRepository (cache-first)<br/>OnlineRepository (HTTP)<br/>OfflineRepository (SQLite)"]
+
+            PlaybackModeManager["PlaybackModeManager<br/>(Local/Remote/Idle)"]
+
+            ConnectivityMonitor["ConnectivityMonitor<br/>(Adaptive polling)"]
+
+            HttpClient["HttpClient<br/>(Exponential backoff retry)"]
+        end
+
+        subgraph Storage["Storage Layer"]
+            DatabaseService["DatabaseService<br/>(Async trait)"]
+            SQLite["SQLite Database<br/>(13 tables)"]
+        end
+
+        Commands --> Core
+        Core --> Storage
+        Repository --> HttpClient
+        Repository --> DatabaseService
+        Repository -->|"reports server outcome<br/>(success / RepoError)"| ConnectivityMonitor
+    end
+
+
+

The Repository --> ConnectivityMonitor edge is the source of truth for the offline/online banner: every server request the user actually makes updates reachability. The monitor's own polling is now an offline-only recovery probe (see 07-connectivity.md).

+
+
+

Detailed Documentation

+

Each major subsystem is documented in its own file in this directory:

+
+ + + + + + + + + +
DocumentContents
01 - Rust BackendMedia session state machine, player state machine, playback mode, media items, queue manager, favorites (marking + browsing), player backend trait, player controller, playlist system, domain vocabulary owned by Rust (search scope, library exclusions, streaming quality ladder), background workers (catalog indexer, drains), Tauri commands
02 - Svelte FrontendStore structure, music library navigation, playback reporting, repository architecture, playback mode system, database service abstraction, component hierarchy, MiniPlayer, sleep timer, auto-play, navigation guard, playlist management UI, library mosaic, series/episode navigation, downloaded browse, safe-area insets, native-video store, logging
03 - Data FlowRepository query flow (cache-first), locally-indexed search, playback initiation, playback mode transfer, queue navigation, volume control
04 - Type Sync & ThreadingRust/TypeScript type synchronization, Tauri v2 IPC parameter naming convention, thread safety patterns
05 - Platform BackendsPlayer events system, HTML5 video adapter, MpvBackend (Linux), ExoPlayerBackend (Android) incl. audio settings parity, native video compositing, MediaSession & remote volume, album art caching, backend initialization
06 - Downloads & OfflineDownload manager, download worker, smart caching engine, one storage model (cache entries are downloads), offline catalog visibility, download/offline commands, player integration, frontend store, UI components
07 - ConnectivityHTTP client with retry logic, connectivity monitor, network resilience architecture
08 - Database DesignEntity relationships, all table definitions (servers, users, libraries, items, user_data, downloads, media_streams, sync_queue, thumbnails, playlists), key queries, data flow diagrams, storage estimates
09 - SecurityAuthentication token storage, secure storage module, network security, webview CSP + asset-protocol scope, path confinement and input binding, local data protection
+
+
+

File Structure Summary

+
src-tauri/src/
+├── lib.rs                    # Tauri app setup, state initialization
+├── commands/                 # Tauri command handlers (~245 #[tauri::command] fns)
+│   ├── mod.rs               # Command exports
+│   ├── player/              # Player commands: queue, remote, session, settings, timers
+│   ├── repository.rs        # Repository commands (items, search, favourites, disk usage)
+│   ├── catalog.rs           # Catalog sync + the background index pass
+│   ├── favorites.rs         # Offline favourite drain
+│   ├── library.rs           # Library listing + folder exclusions
+│   ├── playlist.rs          # Playlist commands
+│   ├── playback_mode.rs     # Local/remote transfer
+│   ├── playback_reporting.rs
+│   ├── connectivity.rs      # Connectivity commands
+│   ├── storage/             # Storage & database commands: people, series_prefs, thumbnails
+│   ├── download/            # Download commands: mod, pinning, smart_cache
+│   ├── offline.rs           # Offline commands
+│   ├── device.rs            # Device id / capabilities
+│   ├── sessions.rs          # Remote sessions
+│   ├── sync.rs              # Sync queue commands
+│   └── sync_drain.rs        # Background sync-queue drain
+├── repository/              # Repository pattern implementation
+│   ├── mod.rs               # MediaRepository trait, handle management
+│   ├── types.rs             # RepoError, Library, MediaItem, etc.
+│   ├── hybrid.rs            # HybridRepository with cache-first racing
+│   ├── online.rs            # OnlineRepository (HTTP API)
+│   └── offline.rs           # OfflineRepository (SQLite queries)
+├── playback_mode/           # Playback mode manager
+│   └── mod.rs               # PlaybackMode enum, transfer logic
+├── connectivity/            # Connectivity monitoring
+│   └── mod.rs               # ConnectivityMonitor, adaptive polling
+├── jellyfin/                # Jellyfin API client
+│   ├── mod.rs               # Module exports
+│   ├── http_client.rs       # HTTP client with retry logic
+│   └── client.rs            # JellyfinClient for API calls
+├── storage/                 # Database layer
+│   ├── mod.rs               # Database struct, migrations
+│   ├── db_service.rs        # DatabaseService trait (async wrapper)
+│   ├── schema.rs            # Table definitions
+│   └── queries/             # Query modules
+├── download/                # Download manager module
+│   ├── mod.rs               # DownloadManager, DownloadInfo, DownloadTask
+│   ├── worker.rs            # DownloadWorker, HTTP streaming, retry logic
+│   ├── events.rs            # DownloadEvent enum
+│   └── cache.rs             # SmartCache, CacheConfig, LRU eviction
+└── player/                  # Player subsystem
+    ├── mod.rs               # PlayerController
+    ├── session.rs           # MediaSessionManager, MediaSessionType
+    ├── state.rs             # PlayerState, PlayerEvent
+    ├── media.rs             # MediaItem, MediaSource, MediaType
+    ├── queue.rs             # QueueManager, RepeatMode
+    ├── backend.rs           # PlayerBackend trait, NullBackend
+    ├── events.rs            # PlayerStatusEvent, TauriEventEmitter
+    ├── mpv/                 # Linux MPV backend
+    │   ├── mod.rs           # MpvBackend implementation
+    │   └── event_loop.rs    # Dedicated thread for MPV operations
+    └── android/             # Android ExoPlayer backend
+        └── mod.rs           # ExoPlayerBackend + JNI bindings
+
+src/lib/
+├── api/                     # Thin API layer (~200 lines total)
+│   ├── types.ts             # TypeScript type definitions
+│   ├── repository-client.ts # RepositoryClient wrapper (~100 lines)
+│   ├── client.ts            # JellyfinClient (helper for streaming)
+│   └── sessions.ts          # SessionsApi (remote session control)
+├── player/                  # Unified player boundary (frontend)
+│   ├── index.ts             # playerController facade — the only write-side entry point for playback
+│   └── html5Adapter.ts      # Reports webview <video> DOM events back into Rust (player_report_*)
+├── services/
+│   ├── playerEvents.ts      # Tauri event listener for player events
+│   └── playbackReporting.ts # Thin wrapper (~50 lines)
+├── stores/                  # Thin reactive wrappers over Rust commands
+│   ├── index.ts             # Re-exports
+│   ├── auth.ts              # Auth store (calls Rust commands)
+│   ├── player.ts            # Player store
+│   ├── queue.ts             # Queue store
+│   ├── library.ts           # Library store
+│   ├── playbackMode.ts      # Playback mode store (~150 lines)
+│   ├── connectivity.ts      # Connectivity store (~250 lines)
+│   └── downloads.ts         # Downloads store with event listeners
+└── components/
+    ├── Search.svelte
+    ├── player/              # Player UI components
+    ├── playlist/            # Playlist modals (Create, AddTo)
+    ├── sessions/            # Remote session control UI
+    ├── downloads/           # Download UI components
+    └── library/             # Library UI components + PlaylistDetailView
+
+

Key Architecture Changes

+

What moved to Rust (~3,500 lines of business logic):

+
    +
  1. HTTP Client (338 lines) - Retry logic with exponential backoff
  2. +
  3. Connectivity Monitor (301 lines) - Reachability derived from real repository traffic, time-window debounce, offline-only recovery probe, event emission
  4. +
  5. Repository Pattern (1061 lines) - Cache-first hybrid with parallel racing
  6. +
  7. Database Service - Async wrapper preventing UI freezing
  8. +
  9. Playback Mode (303 lines) - Local/remote transfer coordination
  10. +
+

Svelte/TypeScript frontend (~20.5k non-test lines, plus ~9.6k test lines):

+
    +
  • Components + routes (~14.6k lines) — UI and presentation
  • +
  • Stores (~3.4k lines) — reactive state that invokes Rust commands and listens for events
  • +
  • api / services / utils (~2.4k lines) — typed clients, event listeners, conversion helpers
  • +
+

The frontend is genuinely UI-heavy; business decisions live in Rust, but the UI owns layout, navigation, and interaction state.

+

Total Commands: ~245 #[tauri::command] functions across 17 command modules +(~58k lines of Rust, ~37k non-test lines of TypeScript/Svelte).

+
+

Counts and line totals in this file are periodic snapshots, not gates — the +authority is the tree. Regenerate with +grep -rc '#\[tauri::command\]' src-tauri/src and wc -l.

+
+ +
+ + +
+
+ + + +
+ + + + + + + + + + + + + + + + + + +
+ + diff --git a/assets/logo.png b/assets/logo.png new file mode 100644 index 00000000..a9e44e54 Binary files /dev/null and b/assets/logo.png differ diff --git a/ayu-highlight.css b/ayu-highlight.css new file mode 100644 index 00000000..32c94322 --- /dev/null +++ b/ayu-highlight.css @@ -0,0 +1,78 @@ +/* +Based off of the Ayu theme +Original by Dempfi (https://github.com/dempfi/ayu) +*/ + +.hljs { + display: block; + overflow-x: auto; + background: #191f26; + color: #e6e1cf; +} + +.hljs-comment, +.hljs-quote { + color: #5c6773; + font-style: italic; +} + +.hljs-variable, +.hljs-template-variable, +.hljs-attribute, +.hljs-attr, +.hljs-regexp, +.hljs-link, +.hljs-selector-id, +.hljs-selector-class { + color: #ff7733; +} + +.hljs-number, +.hljs-meta, +.hljs-builtin-name, +.hljs-literal, +.hljs-type, +.hljs-params { + color: #ffee99; +} + +.hljs-string, +.hljs-bullet { + color: #b8cc52; +} + +.hljs-title, +.hljs-built_in, +.hljs-section { + color: #ffb454; +} + +.hljs-keyword, +.hljs-selector-tag, +.hljs-symbol { + color: #ff7733; +} + +.hljs-name { + color: #36a3d9; +} + +.hljs-tag { + color: #00568d; +} + +.hljs-emphasis { + font-style: italic; +} + +.hljs-strong { + font-weight: bold; +} + +.hljs-addition { + color: #91b362; +} + +.hljs-deletion { + color: #d96c75; +} diff --git a/book.js b/book.js new file mode 100644 index 00000000..aa12e7ec --- /dev/null +++ b/book.js @@ -0,0 +1,697 @@ +"use strict"; + +// Fix back button cache problem +window.onunload = function () { }; + +// Global variable, shared between modules +function playground_text(playground, hidden = true) { + let code_block = playground.querySelector("code"); + + if (window.ace && code_block.classList.contains("editable")) { + let editor = window.ace.edit(code_block); + return editor.getValue(); + } else if (hidden) { + return code_block.textContent; + } else { + return code_block.innerText; + } +} + +(function codeSnippets() { + function fetch_with_timeout(url, options, timeout = 6000) { + return Promise.race([ + fetch(url, options), + new Promise((_, reject) => setTimeout(() => reject(new Error('timeout')), timeout)) + ]); + } + + var playgrounds = Array.from(document.querySelectorAll(".playground")); + if (playgrounds.length > 0) { + fetch_with_timeout("https://play.rust-lang.org/meta/crates", { + headers: { + 'Content-Type': "application/json", + }, + method: 'POST', + mode: 'cors', + }) + .then(response => response.json()) + .then(response => { + // get list of crates available in the rust playground + let playground_crates = response.crates.map(item => item["id"]); + playgrounds.forEach(block => handle_crate_list_update(block, playground_crates)); + }); + } + + function handle_crate_list_update(playground_block, playground_crates) { + // update the play buttons after receiving the response + update_play_button(playground_block, playground_crates); + + // and install on change listener to dynamically update ACE editors + if (window.ace) { + let code_block = playground_block.querySelector("code"); + if (code_block.classList.contains("editable")) { + let editor = window.ace.edit(code_block); + editor.addEventListener("change", function (e) { + update_play_button(playground_block, playground_crates); + }); + // add Ctrl-Enter command to execute rust code + editor.commands.addCommand({ + name: "run", + bindKey: { + win: "Ctrl-Enter", + mac: "Ctrl-Enter" + }, + exec: _editor => run_rust_code(playground_block) + }); + } + } + } + + // updates the visibility of play button based on `no_run` class and + // used crates vs ones available on https://play.rust-lang.org + function update_play_button(pre_block, playground_crates) { + var play_button = pre_block.querySelector(".play-button"); + + // skip if code is `no_run` + if (pre_block.querySelector('code').classList.contains("no_run")) { + play_button.classList.add("hidden"); + return; + } + + // get list of `extern crate`'s from snippet + var txt = playground_text(pre_block); + var re = /extern\s+crate\s+([a-zA-Z_0-9]+)\s*;/g; + var snippet_crates = []; + var item; + while (item = re.exec(txt)) { + snippet_crates.push(item[1]); + } + + // check if all used crates are available on play.rust-lang.org + var all_available = snippet_crates.every(function (elem) { + return playground_crates.indexOf(elem) > -1; + }); + + if (all_available) { + play_button.classList.remove("hidden"); + } else { + play_button.classList.add("hidden"); + } + } + + function run_rust_code(code_block) { + var result_block = code_block.querySelector(".result"); + if (!result_block) { + result_block = document.createElement('code'); + result_block.className = 'result hljs language-bash'; + + code_block.append(result_block); + } + + let text = playground_text(code_block); + let classes = code_block.querySelector('code').classList; + let edition = "2015"; + if(classes.contains("edition2018")) { + edition = "2018"; + } else if(classes.contains("edition2021")) { + edition = "2021"; + } + var params = { + version: "stable", + optimize: "0", + code: text, + edition: edition + }; + + if (text.indexOf("#![feature") !== -1) { + params.version = "nightly"; + } + + result_block.innerText = "Running..."; + + fetch_with_timeout("https://play.rust-lang.org/evaluate.json", { + headers: { + 'Content-Type': "application/json", + }, + method: 'POST', + mode: 'cors', + body: JSON.stringify(params) + }) + .then(response => response.json()) + .then(response => { + if (response.result.trim() === '') { + result_block.innerText = "No output"; + result_block.classList.add("result-no-output"); + } else { + result_block.innerText = response.result; + result_block.classList.remove("result-no-output"); + } + }) + .catch(error => result_block.innerText = "Playground Communication: " + error.message); + } + + // Syntax highlighting Configuration + hljs.configure({ + tabReplace: ' ', // 4 spaces + languages: [], // Languages used for auto-detection + }); + + let code_nodes = Array + .from(document.querySelectorAll('code')) + // Don't highlight `inline code` blocks in headers. + .filter(function (node) {return !node.parentElement.classList.contains("header"); }); + + if (window.ace) { + // language-rust class needs to be removed for editable + // blocks or highlightjs will capture events + code_nodes + .filter(function (node) {return node.classList.contains("editable"); }) + .forEach(function (block) { block.classList.remove('language-rust'); }); + + code_nodes + .filter(function (node) {return !node.classList.contains("editable"); }) + .forEach(function (block) { hljs.highlightBlock(block); }); + } else { + code_nodes.forEach(function (block) { hljs.highlightBlock(block); }); + } + + // Adding the hljs class gives code blocks the color css + // even if highlighting doesn't apply + code_nodes.forEach(function (block) { block.classList.add('hljs'); }); + + Array.from(document.querySelectorAll("code.hljs")).forEach(function (block) { + + var lines = Array.from(block.querySelectorAll('.boring')); + // If no lines were hidden, return + if (!lines.length) { return; } + block.classList.add("hide-boring"); + + var buttons = document.createElement('div'); + buttons.className = 'buttons'; + buttons.innerHTML = ""; + + // add expand button + var pre_block = block.parentNode; + pre_block.insertBefore(buttons, pre_block.firstChild); + + pre_block.querySelector('.buttons').addEventListener('click', function (e) { + if (e.target.classList.contains('fa-eye')) { + e.target.classList.remove('fa-eye'); + e.target.classList.add('fa-eye-slash'); + e.target.title = 'Hide lines'; + e.target.setAttribute('aria-label', e.target.title); + + block.classList.remove('hide-boring'); + } else if (e.target.classList.contains('fa-eye-slash')) { + e.target.classList.remove('fa-eye-slash'); + e.target.classList.add('fa-eye'); + e.target.title = 'Show hidden lines'; + e.target.setAttribute('aria-label', e.target.title); + + block.classList.add('hide-boring'); + } + }); + }); + + if (window.playground_copyable) { + Array.from(document.querySelectorAll('pre code')).forEach(function (block) { + var pre_block = block.parentNode; + if (!pre_block.classList.contains('playground')) { + var buttons = pre_block.querySelector(".buttons"); + if (!buttons) { + buttons = document.createElement('div'); + buttons.className = 'buttons'; + pre_block.insertBefore(buttons, pre_block.firstChild); + } + + var clipButton = document.createElement('button'); + clipButton.className = 'fa fa-copy clip-button'; + clipButton.title = 'Copy to clipboard'; + clipButton.setAttribute('aria-label', clipButton.title); + clipButton.innerHTML = ''; + + buttons.insertBefore(clipButton, buttons.firstChild); + } + }); + } + + // Process playground code blocks + Array.from(document.querySelectorAll(".playground")).forEach(function (pre_block) { + // Add play button + var buttons = pre_block.querySelector(".buttons"); + if (!buttons) { + buttons = document.createElement('div'); + buttons.className = 'buttons'; + pre_block.insertBefore(buttons, pre_block.firstChild); + } + + var runCodeButton = document.createElement('button'); + runCodeButton.className = 'fa fa-play play-button'; + runCodeButton.hidden = true; + runCodeButton.title = 'Run this code'; + runCodeButton.setAttribute('aria-label', runCodeButton.title); + + buttons.insertBefore(runCodeButton, buttons.firstChild); + runCodeButton.addEventListener('click', function (e) { + run_rust_code(pre_block); + }); + + if (window.playground_copyable) { + var copyCodeClipboardButton = document.createElement('button'); + copyCodeClipboardButton.className = 'fa fa-copy clip-button'; + copyCodeClipboardButton.innerHTML = ''; + copyCodeClipboardButton.title = 'Copy to clipboard'; + copyCodeClipboardButton.setAttribute('aria-label', copyCodeClipboardButton.title); + + buttons.insertBefore(copyCodeClipboardButton, buttons.firstChild); + } + + let code_block = pre_block.querySelector("code"); + if (window.ace && code_block.classList.contains("editable")) { + var undoChangesButton = document.createElement('button'); + undoChangesButton.className = 'fa fa-history reset-button'; + undoChangesButton.title = 'Undo changes'; + undoChangesButton.setAttribute('aria-label', undoChangesButton.title); + + buttons.insertBefore(undoChangesButton, buttons.firstChild); + + undoChangesButton.addEventListener('click', function () { + let editor = window.ace.edit(code_block); + editor.setValue(editor.originalCode); + editor.clearSelection(); + }); + } + }); +})(); + +(function themes() { + var html = document.querySelector('html'); + var themeToggleButton = document.getElementById('theme-toggle'); + var themePopup = document.getElementById('theme-list'); + var themeColorMetaTag = document.querySelector('meta[name="theme-color"]'); + var stylesheets = { + ayuHighlight: document.querySelector("[href$='ayu-highlight.css']"), + tomorrowNight: document.querySelector("[href$='tomorrow-night.css']"), + highlight: document.querySelector("[href$='highlight.css']"), + }; + + function showThemes() { + themePopup.style.display = 'block'; + themeToggleButton.setAttribute('aria-expanded', true); + themePopup.querySelector("button#" + get_theme()).focus(); + } + + function updateThemeSelected() { + themePopup.querySelectorAll('.theme-selected').forEach(function (el) { + el.classList.remove('theme-selected'); + }); + themePopup.querySelector("button#" + get_theme()).classList.add('theme-selected'); + } + + function hideThemes() { + themePopup.style.display = 'none'; + themeToggleButton.setAttribute('aria-expanded', false); + themeToggleButton.focus(); + } + + function get_theme() { + var theme; + try { theme = localStorage.getItem('mdbook-theme'); } catch (e) { } + if (theme === null || theme === undefined) { + return default_theme; + } else { + return theme; + } + } + + function set_theme(theme, store = true) { + let ace_theme; + + if (theme == 'coal' || theme == 'navy') { + stylesheets.ayuHighlight.disabled = true; + stylesheets.tomorrowNight.disabled = false; + stylesheets.highlight.disabled = true; + + ace_theme = "ace/theme/tomorrow_night"; + } else if (theme == 'ayu') { + stylesheets.ayuHighlight.disabled = false; + stylesheets.tomorrowNight.disabled = true; + stylesheets.highlight.disabled = true; + ace_theme = "ace/theme/tomorrow_night"; + } else { + stylesheets.ayuHighlight.disabled = true; + stylesheets.tomorrowNight.disabled = true; + stylesheets.highlight.disabled = false; + ace_theme = "ace/theme/dawn"; + } + + setTimeout(function () { + themeColorMetaTag.content = getComputedStyle(document.documentElement).backgroundColor; + }, 1); + + if (window.ace && window.editors) { + window.editors.forEach(function (editor) { + editor.setTheme(ace_theme); + }); + } + + var previousTheme = get_theme(); + + if (store) { + try { localStorage.setItem('mdbook-theme', theme); } catch (e) { } + } + + html.classList.remove(previousTheme); + html.classList.add(theme); + updateThemeSelected(); + } + + // Set theme + var theme = get_theme(); + + set_theme(theme, false); + + themeToggleButton.addEventListener('click', function () { + if (themePopup.style.display === 'block') { + hideThemes(); + } else { + showThemes(); + } + }); + + themePopup.addEventListener('click', function (e) { + var theme; + if (e.target.className === "theme") { + theme = e.target.id; + } else if (e.target.parentElement.className === "theme") { + theme = e.target.parentElement.id; + } else { + return; + } + set_theme(theme); + }); + + themePopup.addEventListener('focusout', function(e) { + // e.relatedTarget is null in Safari and Firefox on macOS (see workaround below) + if (!!e.relatedTarget && !themeToggleButton.contains(e.relatedTarget) && !themePopup.contains(e.relatedTarget)) { + hideThemes(); + } + }); + + // Should not be needed, but it works around an issue on macOS & iOS: https://github.com/rust-lang/mdBook/issues/628 + document.addEventListener('click', function(e) { + if (themePopup.style.display === 'block' && !themeToggleButton.contains(e.target) && !themePopup.contains(e.target)) { + hideThemes(); + } + }); + + document.addEventListener('keydown', function (e) { + if (e.altKey || e.ctrlKey || e.metaKey || e.shiftKey) { return; } + if (!themePopup.contains(e.target)) { return; } + + switch (e.key) { + case 'Escape': + e.preventDefault(); + hideThemes(); + break; + case 'ArrowUp': + e.preventDefault(); + var li = document.activeElement.parentElement; + if (li && li.previousElementSibling) { + li.previousElementSibling.querySelector('button').focus(); + } + break; + case 'ArrowDown': + e.preventDefault(); + var li = document.activeElement.parentElement; + if (li && li.nextElementSibling) { + li.nextElementSibling.querySelector('button').focus(); + } + break; + case 'Home': + e.preventDefault(); + themePopup.querySelector('li:first-child button').focus(); + break; + case 'End': + e.preventDefault(); + themePopup.querySelector('li:last-child button').focus(); + break; + } + }); +})(); + +(function sidebar() { + var body = document.querySelector("body"); + var sidebar = document.getElementById("sidebar"); + var sidebarLinks = document.querySelectorAll('#sidebar a'); + var sidebarToggleButton = document.getElementById("sidebar-toggle"); + var sidebarResizeHandle = document.getElementById("sidebar-resize-handle"); + var firstContact = null; + + function showSidebar() { + body.classList.remove('sidebar-hidden') + body.classList.add('sidebar-visible'); + Array.from(sidebarLinks).forEach(function (link) { + link.setAttribute('tabIndex', 0); + }); + sidebarToggleButton.setAttribute('aria-expanded', true); + sidebar.setAttribute('aria-hidden', false); + try { localStorage.setItem('mdbook-sidebar', 'visible'); } catch (e) { } + } + + + var sidebarAnchorToggles = document.querySelectorAll('#sidebar a.toggle'); + + function toggleSection(ev) { + ev.currentTarget.parentElement.classList.toggle('expanded'); + } + + Array.from(sidebarAnchorToggles).forEach(function (el) { + el.addEventListener('click', toggleSection); + }); + + function hideSidebar() { + body.classList.remove('sidebar-visible') + body.classList.add('sidebar-hidden'); + Array.from(sidebarLinks).forEach(function (link) { + link.setAttribute('tabIndex', -1); + }); + sidebarToggleButton.setAttribute('aria-expanded', false); + sidebar.setAttribute('aria-hidden', true); + try { localStorage.setItem('mdbook-sidebar', 'hidden'); } catch (e) { } + } + + // Toggle sidebar + sidebarToggleButton.addEventListener('click', function sidebarToggle() { + if (body.classList.contains("sidebar-hidden")) { + var current_width = parseInt( + document.documentElement.style.getPropertyValue('--sidebar-width'), 10); + if (current_width < 150) { + document.documentElement.style.setProperty('--sidebar-width', '150px'); + } + showSidebar(); + } else if (body.classList.contains("sidebar-visible")) { + hideSidebar(); + } else { + if (getComputedStyle(sidebar)['transform'] === 'none') { + hideSidebar(); + } else { + showSidebar(); + } + } + }); + + sidebarResizeHandle.addEventListener('mousedown', initResize, false); + + function initResize(e) { + window.addEventListener('mousemove', resize, false); + window.addEventListener('mouseup', stopResize, false); + body.classList.add('sidebar-resizing'); + } + function resize(e) { + var pos = (e.clientX - sidebar.offsetLeft); + if (pos < 20) { + hideSidebar(); + } else { + if (body.classList.contains("sidebar-hidden")) { + showSidebar(); + } + pos = Math.min(pos, window.innerWidth - 100); + document.documentElement.style.setProperty('--sidebar-width', pos + 'px'); + } + } + //on mouseup remove windows functions mousemove & mouseup + function stopResize(e) { + body.classList.remove('sidebar-resizing'); + window.removeEventListener('mousemove', resize, false); + window.removeEventListener('mouseup', stopResize, false); + } + + document.addEventListener('touchstart', function (e) { + firstContact = { + x: e.touches[0].clientX, + time: Date.now() + }; + }, { passive: true }); + + document.addEventListener('touchmove', function (e) { + if (!firstContact) + return; + + var curX = e.touches[0].clientX; + var xDiff = curX - firstContact.x, + tDiff = Date.now() - firstContact.time; + + if (tDiff < 250 && Math.abs(xDiff) >= 150) { + if (xDiff >= 0 && firstContact.x < Math.min(document.body.clientWidth * 0.25, 300)) + showSidebar(); + else if (xDiff < 0 && curX < 300) + hideSidebar(); + + firstContact = null; + } + }, { passive: true }); +})(); + +(function chapterNavigation() { + document.addEventListener('keydown', function (e) { + if (e.altKey || e.ctrlKey || e.metaKey || e.shiftKey) { return; } + if (window.search && window.search.hasFocus()) { return; } + var html = document.querySelector('html'); + + function next() { + var nextButton = document.querySelector('.nav-chapters.next'); + if (nextButton) { + window.location.href = nextButton.href; + } + } + function prev() { + var previousButton = document.querySelector('.nav-chapters.previous'); + if (previousButton) { + window.location.href = previousButton.href; + } + } + switch (e.key) { + case 'ArrowRight': + e.preventDefault(); + if (html.dir == 'rtl') { + prev(); + } else { + next(); + } + break; + case 'ArrowLeft': + e.preventDefault(); + if (html.dir == 'rtl') { + next(); + } else { + prev(); + } + break; + } + }); +})(); + +(function clipboard() { + var clipButtons = document.querySelectorAll('.clip-button'); + + function hideTooltip(elem) { + elem.firstChild.innerText = ""; + elem.className = 'fa fa-copy clip-button'; + } + + function showTooltip(elem, msg) { + elem.firstChild.innerText = msg; + elem.className = 'fa fa-copy tooltipped'; + } + + var clipboardSnippets = new ClipboardJS('.clip-button', { + text: function (trigger) { + hideTooltip(trigger); + let playground = trigger.closest("pre"); + return playground_text(playground, false); + } + }); + + Array.from(clipButtons).forEach(function (clipButton) { + clipButton.addEventListener('mouseout', function (e) { + hideTooltip(e.currentTarget); + }); + }); + + clipboardSnippets.on('success', function (e) { + e.clearSelection(); + showTooltip(e.trigger, "Copied!"); + }); + + clipboardSnippets.on('error', function (e) { + showTooltip(e.trigger, "Clipboard error!"); + }); +})(); + +(function scrollToTop () { + var menuTitle = document.querySelector('.menu-title'); + + menuTitle.addEventListener('click', function () { + document.scrollingElement.scrollTo({ top: 0, behavior: 'smooth' }); + }); +})(); + +(function controllMenu() { + var menu = document.getElementById('menu-bar'); + + (function controllPosition() { + var scrollTop = document.scrollingElement.scrollTop; + var prevScrollTop = scrollTop; + var minMenuY = -menu.clientHeight - 50; + // When the script loads, the page can be at any scroll (e.g. if you reforesh it). + menu.style.top = scrollTop + 'px'; + // Same as parseInt(menu.style.top.slice(0, -2), but faster + var topCache = menu.style.top.slice(0, -2); + menu.classList.remove('sticky'); + var stickyCache = false; // Same as menu.classList.contains('sticky'), but faster + document.addEventListener('scroll', function () { + scrollTop = Math.max(document.scrollingElement.scrollTop, 0); + // `null` means that it doesn't need to be updated + var nextSticky = null; + var nextTop = null; + var scrollDown = scrollTop > prevScrollTop; + var menuPosAbsoluteY = topCache - scrollTop; + if (scrollDown) { + nextSticky = false; + if (menuPosAbsoluteY > 0) { + nextTop = prevScrollTop; + } + } else { + if (menuPosAbsoluteY > 0) { + nextSticky = true; + } else if (menuPosAbsoluteY < minMenuY) { + nextTop = prevScrollTop + minMenuY; + } + } + if (nextSticky === true && stickyCache === false) { + menu.classList.add('sticky'); + stickyCache = true; + } else if (nextSticky === false && stickyCache === true) { + menu.classList.remove('sticky'); + stickyCache = false; + } + if (nextTop !== null) { + menu.style.top = nextTop + 'px'; + topCache = nextTop; + } + prevScrollTop = scrollTop; + }, { passive: true }); + })(); + (function controllBorder() { + function updateBorder() { + if (menu.offsetTop === 0) { + menu.classList.remove('bordered'); + } else { + menu.classList.add('bordered'); + } + } + updateBorder(); + document.addEventListener('scroll', updateBorder, { passive: true }); + })(); +})(); diff --git a/build/build-builder-image.html b/build/build-builder-image.html new file mode 100644 index 00000000..450d2082 --- /dev/null +++ b/build/build-builder-image.html @@ -0,0 +1,364 @@ + + + + + + Builder Image - JellyTau Documentation + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+ + + + + + + + + + + + + + + + + + + +
+ +
+ + + + + + + + +
+
+

Building and Pushing the JellyTau Builder Image

+

This document explains how to create and push the pre-built builder Docker image to your registry for use in Gitea Act CI/CD.

+

Prerequisites

+
    +
  • Docker installed and running
  • +
  • Access to your Docker registry (e.g., gitea.tourolle.paris)
  • +
  • Docker registry credentials configured (docker login)
  • +
+

Building the Builder Image

+

Step 1: Build the Image Locally

+
# From the project root
+docker build -f Dockerfile.builder -t jellytau-builder:latest .
+
+

This creates a local image with:

+
    +
  • All system dependencies
  • +
  • Rust with Android targets
  • +
  • Android SDK and NDK
  • +
  • Node.js and Bun
  • +
  • All build tools pre-installed
  • +
+

Step 2: Tag for Your Registry

+

Replace gitea.tourolle.paris/dtourolle with your actual registry path:

+
docker tag jellytau-builder:latest gitea.tourolle.paris/dtourolle/jellytau-builder:latest
+
+

Step 3: Login to Your Registry

+

If not already logged in:

+
docker login gitea.tourolle.paris
+
+

Step 4: Push to Registry

+
docker push gitea.tourolle.paris/dtourolle/jellytau-builder:latest
+
+

Complete One-Liner

+
docker build -f Dockerfile.builder -t jellytau-builder:latest . && \
+docker tag jellytau-builder:latest gitea.tourolle.paris/dtourolle/jellytau-builder:latest && \
+docker push gitea.tourolle.paris/dtourolle/jellytau-builder:latest
+
+

Verifying the Build

+

Check that the image was pushed successfully:

+
# List images in your registry (depends on registry API support)
+docker search gitea.tourolle.paris/dtourolle/jellytau-builder
+
+# Or pull and test locally
+docker pull gitea.tourolle.paris/dtourolle/jellytau-builder:latest
+docker run -it gitea.tourolle.paris/dtourolle/jellytau-builder:latest bun --version
+
+

Using in CI/CD

+

The workflow at .gitea/workflows/build-and-test.yml automatically uses:

+
container:
+  image: gitea.tourolle.paris/dtourolle/jellytau-builder:latest
+
+

Once pushed, your CI/CD pipeline will use this pre-built image instead of installing everything during the build, saving significant time.

+

Updating the Builder Image

+

When dependencies change (new Rust version, Android SDK update, etc.):

+
    +
  1. Update Dockerfile.builder with the new configuration
  2. +
  3. Rebuild and push with a new tag:
  4. +
+
docker build -f Dockerfile.builder -t jellytau-builder:v1.2.0 .
+docker tag jellytau-builder:v1.2.0 gitea.tourolle.paris/dtourolle/jellytau-builder:v1.2.0
+docker push gitea.tourolle.paris/dtourolle/jellytau-builder:v1.2.0
+
+
    +
  1. Update the workflow to use the new tag:
  2. +
+
container:
+  image: gitea.tourolle.paris/dtourolle/jellytau-builder:v1.2.0
+
+

Image Contents

+

The builder image includes:

+
    +
  • Base OS: Ubuntu 24.04
  • +
  • Languages: +
      +
    • Rust (stable) with targets: aarch64-linux-android, armv7-linux-androideabi, x86_64-linux-android
    • +
    • Node.js 20.x
    • +
    • OpenJDK 17 (for Android)
    • +
    +
  • +
  • Tools: +
      +
    • Bun package manager
    • +
    • Android SDK 34
    • +
    • Android NDK 27.0.11902837
    • +
    • Build essentials (gcc, make, etc.)
    • +
    • Git, curl, wget
    • +
    • libssl, libclang development libraries
    • +
    +
  • +
  • Pre-configured: +
      +
    • Rust toolchain components (rustfmt, clippy)
    • +
    • Android SDK/NDK environment variables
    • +
    • All paths optimized for building
    • +
    +
  • +
+

Build Time

+

First build takes ~15-20 minutes depending on internet speed (downloads Android SDK/NDK). +Subsequent builds are cached and take seconds.

+

Storage

+

The built image is approximately 4-5 GB. Ensure your registry has sufficient storage.

+

Troubleshooting

+

"Image not found" in CI

+
    +
  • Verify the image name matches exactly in the workflow
  • +
  • Check that the image was successfully pushed: docker push output should show successful layers
  • +
  • Ensure Gitea has access to your registry (check network/firewall)
  • +
+

Build fails with "command not found"

+
    +
  • The image may not have finished pushing. Wait a few moments and retry the CI job.
  • +
  • Check that all layers were pushed successfully in the push output.
  • +
+

Registry authentication in CI

+

If your registry requires credentials in CI:

+
    +
  1. Create a deploy token in your registry
  2. +
  3. Add to Gitea secrets as REGISTRY_USERNAME and REGISTRY_TOKEN
  4. +
  5. Use in workflow:
  6. +
+
- name: Login to Registry
+  run: |
+    docker login gitea.tourolle.paris -u ${{ secrets.REGISTRY_USERNAME }} -p ${{ secrets.REGISTRY_TOKEN }}
+
+

References

+ + +
+ + +
+
+ + + +
+ + + + + + + + + + + + + + + + + + +
+ + diff --git a/build/build-desktop-packages.html b/build/build-desktop-packages.html new file mode 100644 index 00000000..068b2953 --- /dev/null +++ b/build/build-desktop-packages.html @@ -0,0 +1,300 @@ + + + + + + Desktop Packaging - JellyTau Documentation + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+ + + + + + + + + + + + + + + + + + + +
+ +
+ + + + + + + + +
+
+

Desktop packaging (Linux, Arch, Windows)

+

How to produce distributable desktop packages for JellyTau. All three flows can +run in Docker so no host toolchain setup is required. Outputs land in ./dist.

+

One builder image (shared with CI)

+

The deb/rpm and Windows-cross flows build on the unified registry builder +(../Dockerfile.builder → +gitea.tourolle.paris/dtourolle/jellytau-builder), the same image CI uses. It +carries every packaging tool: Android SDK/NDK, rpm/file (Linux bundler), +cargo-xwin + lld + llvm + nsis + the x86_64-pc-windows-msvc rust target +(Windows). There is one dependency source of truth — no per-stage tool +installs.

+

The desktop stages in ../Dockerfile are thin FROM ${BUILDER_IMAGE} environments; the actual build runs at container-run time on +your bind-mounted source (like the dev service), so source edits need no image +rebuild.

+

If you changed Dockerfile.builder (e.g. added a tool), rebuild and push it +first, or the packaging flows use the stale registry image:

+
scripts/build-builder-image.sh           # build + push :latest to the registry
+# ...or iterate locally without pushing:
+docker build -f Dockerfile.builder -t jellytau-builder:latest .
+BUILDER_IMAGE=jellytau-builder:latest bun run docker:build:windows
+
+

Arch uses a separate archlinux image (../Dockerfile.arch) +because makepkg is Arch-specific — it is not part of the unified builder.

+
+ + + +
TargetFormatDocker commandFunctional?
Debian/Ubuntu, Fedora.deb, .rpmbun run docker:build:linux✅ yes
Arch Linux.pkg.tar.zstbun run docker:build:arch✅ yes
WindowsNSIS installer + .exebun run docker:build:windows✅ yes (unsigned)
+
+

Linux: deb + rpm

+

Tauri's bundler produces these natively. The build runs on the existing Ubuntu +builder image (../Dockerfile, desktop-linux-build stage):

+
bun run docker:build:linux            # deb + rpm -> ./dist
+# or, on a host with the Tauri Linux deps installed:
+BUNDLES="deb,rpm" scripts/build-desktop-linux.sh
+
+

Runtime dependency: the app links libmpv (audio) and WebKitGTK (webview + HTML5 +transcoded video). The deb/rpm declare these.

+
+

Note: appimage is also a valid Tauri target if you want a portable bundle — +add it to BUNDLES.

+
+

Arch Linux: pacman package

+

Tauri has no pacman bundle target (as of tauri-cli 2.9.x — valid targets +are deb/rpm/appimage/msi/nsis/app/dmg). So we ship a hand-written PKGBUILD in +../packaging/arch/PKGBUILD and build it with +makepkg on an Arch base image (../Dockerfile.arch):

+
bun run docker:build:arch             # .pkg.tar.zst -> ./dist
+
+

The PKGBUILD is AUR-ready: swap its source=() for a release tarball/VCS URL to +publish. Runtime deps: webkit2gtk-4.1, mpv, gtk3, libayatana-appindicator.

+

makepkg refuses to run as root, so the Docker stage builds as a non-root +builder user. Because the image COPYs the source at build time, the +arch-build compose service does not bind-mount the repo — rebuild the image +to pick up source changes.

+

Windows: NSIS installer cross-compiled from Linux

+

Produces a working (unsigned) NSIS installer + .exe via the official Tauri +cross-compile path — the x86_64-pc-windows-msvc target driven by cargo-xwin. +Video plays via WebView2 and audio via the webview <audio> backend. See +build-windows.md for the full explanation.

+
bun run docker:build:windows                     # NSIS installer + .exe -> ./dist
+WIN_BUNDLES=none bun run docker:build:windows     # exe only, skip bundling
+
+

The Docker windows-cross stage is a thin layer over the builder, which carries +cargo-xwin + lld + llvm + nsis + the x86_64-pc-windows-msvc target. +Cross-compilation is Tauri's "last resort" path (less tested than building on +Windows); a windows-latest CI job is the fallback if it misbehaves.

+ +
+ + +
+
+ + + +
+ + + + + + + + + + + + + + + + + + +
+ + diff --git a/build/build-release.html b/build/build-release.html new file mode 100644 index 00000000..a2b8fee1 --- /dev/null +++ b/build/build-release.html @@ -0,0 +1,580 @@ + + + + + + Build & Release - JellyTau Documentation + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+ + + + + + + + + + + + + + + + + + + +
+ +
+ + + + + + + + +
+
+

Build & Release Workflow

+

This document explains the automated build and release process for JellyTau.

+

Overview

+

The CI/CD pipeline automatically:

+
    +
  1. ✅ Runs all tests (frontend + Rust)
  2. +
  3. ✅ Builds Linux binaries (AppImage + DEB)
  4. +
  5. ✅ Builds Android APK and AAB
  6. +
  7. ✅ Creates releases with artifacts
  8. +
  9. ✅ Tags releases with version numbers
  10. +
+

Workflow Triggers

+

Automatic Trigger

+

When you push a version tag:

+
git tag v1.0.0
+git push origin v1.0.0
+
+

The workflow automatically:

+
    +
  1. Runs tests
  2. +
  3. Builds both platforms
  4. +
  5. Creates a GitHub release with artifacts
  6. +
  7. Tags it as release/prerelease based on version
  8. +
+

Manual Trigger

+

In Gitea Actions UI:

+
    +
  1. Go to Actions tab
  2. +
  3. Click Build & Release workflow
  4. +
  5. Click Run workflow
  6. +
  7. Optionally specify a version
  8. +
  9. Workflow runs without creating a release
  10. +
+

Version Tagging

+

Format

+

Version tags follow semantic versioning: v{MAJOR}.{MINOR}.{PATCH}

+

Examples:

+
    +
  • v1.0.0 - Release version
  • +
  • v1.0.0-rc1 - Release candidate (marked as prerelease)
  • +
  • v1.0.0-beta - Beta version (marked as prerelease)
  • +
  • v0.1.0-alpha - Alpha version (marked as prerelease)
  • +
+

Creating a Release

+
# Create and push a version tag
+git tag v1.0.0 -m "Release version 1.0.0"
+git push origin v1.0.0
+
+# Or create from main branch
+git tag -a v1.0.0 -m "Release version 1.0.0" main
+git push origin v1.0.0
+
+

Release Status

+

Versions containing rc, beta, or alpha are marked as prerelease:

+
git tag v1.0.0-rc1      # ⚠️ Prerelease
+git tag v1.0.0-beta     # ⚠️ Prerelease
+git tag v1.0.0-alpha    # ⚠️ Prerelease
+git tag v1.0.0          # ✅ Full release
+
+

Workflow Steps

+

1. Test Phase

+

Runs on all tags and manual triggers:

+
    +
  • Frontend tests (vitest)
  • +
  • Rust tests (cargo test)
  • +
  • TypeScript type checking
  • +
+

Failure: Stops workflow, no build/release

+

2. Build Linux Phase

+

Runs after tests pass:

+
    +
  • Installs system dependencies
  • +
  • Builds with Tauri
  • +
  • Generates: +
      +
    • AppImage - Universal Linux binary
    • +
    • DEB - Debian/Ubuntu package
    • +
    +
  • +
+

Output: artifacts/linux/

+

3. Build Android Phase

+

Runs in parallel with Linux build:

+
    +
  • Installs Android SDK/NDK
  • +
  • Configures Rust for Android targets
  • +
  • Builds with Tauri
  • +
  • Generates: +
      +
    • APK - Android app package (installable)
    • +
    • AAB - Android App Bundle (for Play Store)
    • +
    +
  • +
+

Output: artifacts/android/

+

4. Create Release Phase

+

Runs after both builds succeed (only on version tags):

+
    +
  • Prepares release notes
  • +
  • Downloads build artifacts
  • +
  • Creates GitHub/Gitea release
  • +
  • Uploads all artifacts
  • +
  • Tags as prerelease if applicable
  • +
+

Artifacts

+

Linux Artifacts

+

AppImage

+
    +
  • File: jellytau_*.AppImage
  • +
  • Size: ~100-150 MB
  • +
  • Use: Run directly on any Linux distro
  • +
  • Installation: +
    chmod +x JellyTau_*.AppImage
    +./JellyTau_*.AppImage
    +
    +
  • +
+

DEB Package

+
    +
  • File: JellyTau_*.deb
  • +
  • Size: ~80-120 MB
  • +
  • Use: Install on Debian/Ubuntu/similar
  • +
  • Installation: +
    sudo dpkg -i JellyTau_*.deb
    +jellytau
    +
    +
  • +
  • Note: the Debian package is named jelly-tau (Tauri kebab-cases +productName), while the command stays jellytau. The package declares +Replaces/Conflicts/Provides: jellytau, so upgrading from a release built +before the rename replaces it rather than installing a second copy.
  • +
+

RPM Package

+
    +
  • File: JellyTau-*.rpm
  • +
  • Use: Install on Fedora/openSUSE/similar
  • +
  • Installation: +
    sudo rpm -i JellyTau-*.rpm
    +jellytau
    +
    +
  • +
+

Android Artifacts

+

APK

+
    +
  • File: jellytau-release.apk
  • +
  • Size: ~60-100 MB
  • +
  • Use: Direct installation on Android devices
  • +
  • Installation: +
    adb install jellytau-release.apk
    +# Or sideload via file manager
    +
    +
  • +
+

AAB (Android App Bundle)

+
    +
  • File: jellytau-release.aab
  • +
  • Size: ~50-90 MB
  • +
  • Use: Upload to Google Play Console
  • +
  • Note: Cannot be installed directly; for Play Store distribution
  • +
+

Release Notes

+

Release notes are automatically generated with:

+
    +
  • Version number
  • +
  • Download links
  • +
  • Installation instructions
  • +
  • System requirements
  • +
  • Known issues link
  • +
  • Changelog reference
  • +
+

Build Matrix

+
+ + +
PlatformOSArchitectureFormat
LinuxAnyx86_64AppImage, DEB
Android8.0+arm64, armv7, x86_64APK, AAB
+
+

Troubleshooting

+

Build Fails During Test Phase

+
    +
  1. Check test output in Gitea Actions
  2. +
  3. Run tests locally: bun run test and bun run test:rust
  4. +
  5. Fix failing tests
  6. +
  7. Create new tag with fixed code
  8. +
+

Linux Build Fails

+
    +
  1. Check system dependencies installed
  2. +
  3. Verify Tauri configuration
  4. +
  5. Check cargo dependencies
  6. +
  7. Clear cache: Delete .cargo and target/ directories
  8. +
+

Android Build Fails

+
    +
  1. Check Android SDK/NDK setup
  2. +
  3. Verify Java 17 is installed
  4. +
  5. Check Rust Android targets: rustup target list
  6. +
  7. Clear cache and rebuild
  8. +
+

Release Not Created

+
    +
  1. Tag must start with v (e.g., v1.0.0)
  2. +
  3. Tests must pass
  4. +
  5. Both builds must succeed
  6. +
  7. Check workflow logs for errors
  8. +
+

GitHub Release vs Gitea

+

The workflow uses GitHub Actions SDK but is designed for Gitea. For Gitea-native releases:

+
    +
  1. Workflow creates artifacts
  2. +
  3. Artifacts are available in Actions artifacts
  4. +
  5. Download and manually create Gitea release, or
  6. +
  7. Set up Gitea API integration to auto-publish
  8. +
+

Customization

+

Change Release Notes Template

+

Edit .gitea/workflows/build-release.yml, section Prepare release notes:

+
- name: Prepare release notes
+  id: release_notes
+  run: |
+    # Add your custom release notes format here
+    echo "Custom notes" > release_notes.md
+
+

Add New Platforms

+

To add macOS or Windows builds:

+
    +
  1. Add new build-{platform} job
  2. +
  3. Set appropriate runs-on runner
  4. +
  5. Add platform-specific dependencies
  6. +
  7. Update artifact upload
  8. +
  9. Include in needs: [build-linux, build-android, build-{platform}]
  10. +
+

Change Build Targets

+

Modify Tauri configuration or add targets:

+
- name: Build for Linux
+  run: |
+    # Add target specification
+    bun run tauri build -- --target x86_64-unknown-linux-gnu
+
+

Monitoring

+

Check Status

+
    +
  1. Go to Actions tab in Gitea
  2. +
  3. View Build & Release workflow runs
  4. +
  5. Click specific run to see logs
  6. +
+

Notifications

+

Set up notifications for:

+
    +
  • Build failures
  • +
  • Release creation
  • +
  • Tag pushes
  • +
+

Performance

+

Build Times (Approximate)

+
    +
  • Test phase: 5-10 minutes
  • +
  • Linux build: 10-15 minutes
  • +
  • Android build: 15-20 minutes
  • +
  • Total: 30-45 minutes
  • +
+

Caching

+

Workflow caches:

+
    +
  • Rust dependencies (cargo)
  • +
  • Bun node_modules
  • +
  • Android SDK components
  • +
+

Security

+

Secrets

+

The workflow uses:

+
    +
  • GITHUB_TOKEN - Built-in, no setup needed
  • +
  • No credentials needed for Gitea
  • +
+

Verification

+

To verify build integrity:

+
    +
  1. Download artifacts
  2. +
  3. Verify signatures (if implemented)
  4. +
  5. Check file hashes
  6. +
  7. Test on target platform
  8. +
+

Best Practices

+

Versioning

+
    +
  1. Follow semantic versioning: v{MAJOR}.{MINOR}.{PATCH}
  2. +
  3. Tag releases in git
  4. +
  5. Update CHANGELOG.md before tagging
  6. +
  7. Include release notes in tag message
  8. +
+

Testing Before Release

+
# Local testing before release
+bun run test          # Frontend tests
+bun run test:rust     # Rust tests
+bun run check         # Type checking
+bun run tauri build   # Local build test
+
+

Documentation

+
    +
  1. Update CHANGELOG.md with changes
  2. +
  3. Update README.md with new features
  4. +
  5. Document breaking changes
  6. +
  7. Add migration guide if needed
  8. +
+

Example Release Workflow

+
# 1. Update version in relevant files (package.json, Cargo.toml, etc.)
+vim package.json
+vim src-tauri/tauri.conf.json
+
+# 2. Update CHANGELOG
+vim CHANGELOG.md
+
+# 3. Commit changes
+git add .
+git commit -m "Bump version to v1.0.0"
+
+# 4. Create annotated tag
+git tag -a v1.0.0 -m "Release version 1.0.0
+
+Features:
+- Feature 1
+- Feature 2
+
+Fixes:
+- Fix 1
+- Fix 2"
+
+# 5. Push tag to trigger workflow
+git push origin v1.0.0
+
+# 6. Monitor workflow in Gitea Actions
+# Wait for tests → Linux build → Android build → Release
+
+# 7. Download artifacts and test
+# Visit release page and verify downloads
+
+

References

+ +
+

Last Updated: 2026-02-13

+ +
+ + +
+
+ + + +
+ + + + + + + + + + + + + + + + + + +
+ + diff --git a/build/build-windows.html b/build/build-windows.html new file mode 100644 index 00000000..e778be9c --- /dev/null +++ b/build/build-windows.html @@ -0,0 +1,300 @@ + + + + + + Windows Build - JellyTau Documentation + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+ + + + + + + + + + + + + + + + + + + +
+ +
+ + + + + + + + +
+
+

Windows build

+

JellyTau targets Linux and Android primarily, but a working Windows build — +including an NSIS installer cross-compiled from Linux — is produced by the +Docker tooling. It is not yet a first-class release target (no code signing / CI +job / SMTC lockscreen), but it runs and plays media.

+

How playback works on Windows

+
    +
  • Video — renders through the webview HTML5 <video> element (hls.js) on +every platform; on Windows that is WebView2 (Chromium/Edge), which plays HLS + +h264 fine. No Windows-specific code.
  • +
  • Audio-only (music) — the native audio backends are libmpv (Linux) and +ExoPlayer (Android); neither exists on Windows. Instead +create_player_backend() in ../src-tauri/src/lib.rs +uses WebviewAudioBackend on non-Linux/non-Android targets: it hands the stream +URL to a webview <audio> element (see +../src/lib/services/webviewAudio.ts), +which reports state back through the same player_report_* round-trip the video +path uses. Pure Rust + Tauri events.
  • +
+

Cross-compiling from Linux (MSVC + cargo-xwin)

+

We use the official Tauri cross-compile path: +the MSVC target (x86_64-pc-windows-msvc) driven by +cargo-xwin, which downloads the MSVC +CRT / Windows SDK headers and links with lld. MSVC is the target Tauri +officially supports for Windows (mingw/GNU is not), and — unlike GNU — it lets the +Tauri CLI bundle the NSIS installer from a Linux host.

+
+

Why not mingw/GNU? The GNU target does link a valid .exe, but the Tauri CLI +gates --bundles by the host OS unless it recognizes a real Windows build. +--runner cargo-xwin --target x86_64-pc-windows-msvc is what flips it into +Windows mode and enables the nsis/msi bundlers on Linux.

+
+

The builder image (../Dockerfile.builder) bakes in the +whole toolchain: the x86_64-pc-windows-msvc rust target, cargo-xwin, lld, +llvm, and nsis.

+
bun run docker:build:windows            # NSIS installer + .exe -> ./dist
+WIN_BUNDLES=none bun run docker:build:windows   # exe only, skip bundling
+
+

Or directly on a host that has the toolchain:

+
scripts/build-windows-cross.sh                    # nsis installer + exe
+WIN_BUNDLES=none scripts/build-windows-cross.sh   # exe only
+
+

Under the hood the build runs:

+
tauri build --runner cargo-xwin --target x86_64-pc-windows-msvc --bundles nsis
+
+

Outputs:

+
    +
  • .exesrc-tauri/target/x86_64-pc-windows-msvc/release/jellytau.exe
  • +
  • NSIS installer — .../release/bundle/nsis/*-setup.exe
  • +
+

(both copied to ./dist when OUTPUT_DIR is set).

+

Caveats

+
    +
  • Cross-compilation is a last resort per Tauri's own docs — it's less tested +than building on Windows. If it misbehaves, a windows-latest CI job or a +Windows VM building natively (tauri build --bundles nsis) is the fallback.
  • +
  • Code signing is not wired up — the installer is unsigned, so Windows +SmartScreen will warn on first run.
  • +
+

Outstanding for a first-class Windows release

+
    +
  1. Gapless/crossfade + SMTC (lockscreen) — currently no-ops in the webview audio +path.
  2. +
  3. Downloaded (Local source) file playback needs convertFileSrc on the +frontend; streaming works today.
  4. +
  5. Code signing + a Windows packaging CI job.
  6. +
+ +
+ + +
+
+ + + +
+ + + + + + + + + + + + + + + + + + +
+ + diff --git a/build/docker.html b/build/docker.html new file mode 100644 index 00000000..1ef56961 --- /dev/null +++ b/build/docker.html @@ -0,0 +1,471 @@ + + + + + + Docker - JellyTau Documentation + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+ + + + + + + + + + + + + + + + + + + +
+ +
+ + + + + + + + +
+
+

Docker & CI/CD Setup for JellyTau

+

This document explains how to use the Docker configuration and Gitea Act CI/CD pipeline for building and testing JellyTau.

+

Overview

+

The setup includes:

+
    +
  • Dockerfile.builder: Pre-built image with all dependencies (push to your registry)
  • +
  • Dockerfile: Multi-stage build for local testing and building
  • +
  • docker-compose.yml: Orchestration for local development and testing
  • +
  • .gitea/workflows/build-and-test.yml: Automated CI/CD pipeline using pre-built builder image
  • +
+

Quick Start

+

For CI/CD (Gitea Actions):

+
    +
  1. Build and push builder image (see build-builder-image.md)
  2. +
  3. Push to master branch - workflow runs automatically
  4. +
  5. Check Actions tab for results and APK artifacts
  6. +
+

For Local Testing:

+
docker-compose run test       # Run tests
+docker-compose run android-build  # Build APK
+docker-compose run dev        # Interactive shell
+
+

Docker Usage

+

Prerequisites

+
    +
  • Docker Engine 20.10+
  • +
  • Docker Compose 2.0+ (if using docker-compose)
  • +
  • At least 10GB free disk space (for Android SDK and build artifacts)
  • +
+

Building the Docker Image

+
# Build the complete image
+docker build -t jellytau:latest .
+
+# Build specific target
+docker build -t jellytau:test --target test .
+docker build -t jellytau:android --target android-build .
+
+

Using Docker Compose

+

Run Tests Only

+
docker-compose run test
+
+

This will:

+
    +
  1. Install all dependencies
  2. +
  3. Run frontend tests (Vitest)
  4. +
  5. Run Rust backend tests
  6. +
  7. Report results
  8. +
+

Build Android APK

+
docker-compose run android-build
+
+

This will:

+
    +
  1. Run tests first (depends on test service)
  2. +
  3. If tests pass, build the Android APK
  4. +
  5. Output APK files to src-tauri/gen/android/app/build/outputs/apk/
  6. +
+

Interactive Development

+
docker-compose run dev
+
+

This starts an interactive shell with all development tools available. From here you can:

+
bun install
+bun run build
+bun test
+bun run tauri android build --apk true
+
+

Run All Services in Sequence

+
docker-compose up --abort-on-container-exit
+
+

Extracting Build Artifacts

+

After a successful build, APK files are located in:

+
src-tauri/gen/android/app/build/outputs/apk/
+
+

Copy to your host machine:

+
docker cp jellytau-android-build:/app/src-tauri/gen/android/app/build/outputs/apk ./apk-output
+
+

Gitea Act CI/CD Pipeline

+

The .gitea/workflows/build-and-test.yml workflow automates:

+

Single Job: Runs on every push to master and PRs

+
    +
  • Uses pre-built builder image (no setup time)
  • +
  • Installs project dependencies
  • +
  • Runs frontend tests (Vitest)
  • +
  • Runs Rust backend tests
  • +
  • Builds the frontend
  • +
  • Builds the Android APK
  • +
  • Uploads APK as artifact (30-day retention)
  • +
+

The workflow skips markdown files to avoid unnecessary builds.

+

Workflow Triggers

+

The workflow runs on:

+
    +
  • Push to master or main branches
  • +
  • Pull requests to master or main branches
  • +
  • Can be extended with: workflow_dispatch for manual triggers
  • +
+

Setting Up the Builder Image

+

Before using the CI/CD pipeline, you must build and push the builder image:

+
# Build the image
+docker build -f Dockerfile.builder -t jellytau-builder:latest .
+
+# Tag for your registry
+docker tag jellytau-builder:latest gitea.tourolle.paris/dtourolle/jellytau-builder:latest
+
+# Push to registry
+docker push gitea.tourolle.paris/dtourolle/jellytau-builder:latest
+
+

See build-builder-image.md for detailed instructions.

+

Setting Up Gitea Act

+
    +
  1. +

    Ensure builder image is pushed (see above)

    +
  2. +
  3. +

    Push to Gitea repository: +The workflow will automatically trigger on push to master or pull requests

    +
  4. +
  5. +

    View workflow runs in Gitea UI:

    +
      +
    • Navigate to your repository
    • +
    • Go to Actions tab
    • +
    • Click on workflow runs to see logs
    • +
    +
  6. +
  7. +

    Test locally (optional):

    +
    # Install act if needed
    +curl https://gitea.com/actions/setup-act/releases/download/v0.25.0/act-0.25.0-linux-x86_64.tar.gz | tar xz
    +
    +# Run locally (requires builder image to be available)
    +./act push --file .gitea/workflows/build-and-test.yml
    +
    +
  8. +
+

Customizing the Workflow

+

Modify Build Triggers

+

Edit .gitea/workflows/build-and-test.yml to change when builds run:

+
on:
+  push:
+    branches:
+      - master
+      - develop  # Add more branches
+    paths:
+      - 'src/**'  # Only run if src/ changes
+      - 'src-tauri/**'  # Only run if Rust code changes
+
+

Add Notifications

+

Add Slack, Discord, or email notifications on build completion:

+
- name: Notify on success
+  if: success()
+  run: |
+    curl -X POST https://slack-webhook-url...
+
+

Customize APK Upload

+

Modify artifact retention or add to cloud storage:

+
- name: Upload APK to S3
+  uses: actions/s3-sync@v1
+  with:
+    aws_access_key_id: ${{ secrets.AWS_ACCESS_KEY }}
+    aws_secret_access_key: ${{ secrets.AWS_SECRET_KEY }}
+    aws_bucket: my-apk-bucket
+    source_dir: src-tauri/gen/android/app/build/outputs/apk/
+
+

Environment Setup in CI

+

Secret Variables

+

To use secrets in the workflow, set them in Gitea:

+
    +
  1. Go to Repository Settings → Secrets
  2. +
  3. Add secrets like: +
      +
    • AWS_ACCESS_KEY for S3 uploads
    • +
    • SLACK_WEBHOOK_URL for notifications
    • +
    • GITHUB_TOKEN for releases (pre-configured)
    • +
    +
  4. +
+

Troubleshooting

+

Out of Memory During Build

+

Android builds are memory-intensive. If you get OOM errors:

+
# Limit memory in docker-compose
+services:
+  android-build:
+    deploy:
+      resources:
+        limits:
+          memory: 6G
+
+

Or increase Docker's memory allocation in Docker Desktop settings.

+

Android SDK Download Timeout

+

If downloads timeout, increase timeout or download manually:

+
# In container, with longer timeout
+timeout 600 sdkmanager --sdk_root=$ANDROID_HOME ...
+
+

Rust Compilation Errors

+

Make sure Rust is updated:

+
rustup update
+rustup target add aarch64-linux-android armv7-linux-androideabi x86_64-linux-android
+
+

Cache Issues

+

Clear Docker cache and rebuild:

+
docker-compose down -v  # Remove volumes
+docker system prune      # Clean up dangling images
+docker-compose up --build
+
+

Performance Tips

+
    +
  1. Cache Reuse: Both Docker and Gitea Act cache dependencies across runs
  2. +
  3. Parallel Steps: The workflow runs frontend and Rust tests in series; consider parallelizing for faster CI
  4. +
  5. Incremental Builds: Rust and Node caches persist between runs
  6. +
  7. Docker Buildkit: Enable for faster builds: +
    DOCKER_BUILDKIT=1 docker build .
    +
    +
  8. +
+

Security Considerations

+
    +
  • Dockerfile uses ubuntu:24.04 base image from official Docker Hub
  • +
  • NDK is downloaded from official Google servers (verified via HTTPS)
  • +
  • No credentials are stored in the Dockerfile
  • +
  • Use Gitea Secrets for sensitive values (API keys, tokens, etc.)
  • +
  • Lock dependency versions in Cargo.toml and package.json
  • +
+

Next Steps

+
    +
  1. Test locally with docker-compose up
  2. +
  3. Push to your Gitea repository
  4. +
  5. Monitor workflow runs in the Actions tab
  6. +
  7. Configure secrets in repository settings for production builds
  8. +
  9. Set up artifact retention policies (currently 30 days)
  10. +
+

References

+ + +
+ + +
+
+ + + +
+ + + + + + + + + + + + + + + + + + +
+ + diff --git a/clipboard.min.js b/clipboard.min.js new file mode 100644 index 00000000..02c549e3 --- /dev/null +++ b/clipboard.min.js @@ -0,0 +1,7 @@ +/*! + * clipboard.js v2.0.4 + * https://zenorocha.github.io/clipboard.js + * + * Licensed MIT © Zeno Rocha + */ +!function(t,e){"object"==typeof exports&&"object"==typeof module?module.exports=e():"function"==typeof define&&define.amd?define([],e):"object"==typeof exports?exports.ClipboardJS=e():t.ClipboardJS=e()}(this,function(){return function(n){var o={};function r(t){if(o[t])return o[t].exports;var e=o[t]={i:t,l:!1,exports:{}};return n[t].call(e.exports,e,e.exports,r),e.l=!0,e.exports}return r.m=n,r.c=o,r.d=function(t,e,n){r.o(t,e)||Object.defineProperty(t,e,{enumerable:!0,get:n})},r.r=function(t){"undefined"!=typeof Symbol&&Symbol.toStringTag&&Object.defineProperty(t,Symbol.toStringTag,{value:"Module"}),Object.defineProperty(t,"__esModule",{value:!0})},r.t=function(e,t){if(1&t&&(e=r(e)),8&t)return e;if(4&t&&"object"==typeof e&&e&&e.__esModule)return e;var n=Object.create(null);if(r.r(n),Object.defineProperty(n,"default",{enumerable:!0,value:e}),2&t&&"string"!=typeof e)for(var o in e)r.d(n,o,function(t){return e[t]}.bind(null,o));return n},r.n=function(t){var e=t&&t.__esModule?function(){return t.default}:function(){return t};return r.d(e,"a",e),e},r.o=function(t,e){return Object.prototype.hasOwnProperty.call(t,e)},r.p="",r(r.s=0)}([function(t,e,n){"use strict";var r="function"==typeof Symbol&&"symbol"==typeof Symbol.iterator?function(t){return typeof t}:function(t){return t&&"function"==typeof Symbol&&t.constructor===Symbol&&t!==Symbol.prototype?"symbol":typeof t},i=function(){function o(t,e){for(var n=0;n .hljs { + color: var(--links); +} + +/* + body-container is necessary because mobile browsers don't seem to like + overflow-x on the body tag when there is a tag. +*/ +#body-container { + /* + This is used when the sidebar pushes the body content off the side of + the screen on small screens. Without it, dragging on mobile Safari + will want to reposition the viewport in a weird way. + */ + overflow-x: clip; +} + +/* Menu Bar */ + +#menu-bar, +#menu-bar-hover-placeholder { + z-index: 101; + margin: auto calc(0px - var(--page-padding)); +} +#menu-bar { + position: relative; + display: flex; + flex-wrap: wrap; + background-color: var(--bg); + border-block-end-color: var(--bg); + border-block-end-width: 1px; + border-block-end-style: solid; +} +#menu-bar.sticky, +.js #menu-bar-hover-placeholder:hover + #menu-bar, +.js #menu-bar:hover, +.js.sidebar-visible #menu-bar { + position: -webkit-sticky; + position: sticky; + top: 0 !important; +} +#menu-bar-hover-placeholder { + position: sticky; + position: -webkit-sticky; + top: 0; + height: var(--menu-bar-height); +} +#menu-bar.bordered { + border-block-end-color: var(--table-border-color); +} +#menu-bar i, #menu-bar .icon-button { + position: relative; + padding: 0 8px; + z-index: 10; + line-height: var(--menu-bar-height); + cursor: pointer; + transition: color 0.5s; +} +@media only screen and (max-width: 420px) { + #menu-bar i, #menu-bar .icon-button { + padding: 0 5px; + } +} + +.icon-button { + border: none; + background: none; + padding: 0; + color: inherit; +} +.icon-button i { + margin: 0; +} + +.right-buttons { + margin: 0 15px; +} +.right-buttons a { + text-decoration: none; +} + +.left-buttons { + display: flex; + margin: 0 5px; +} +.no-js .left-buttons button { + display: none; +} + +.menu-title { + display: inline-block; + font-weight: 200; + font-size: 2.4rem; + line-height: var(--menu-bar-height); + text-align: center; + margin: 0; + flex: 1; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} +.js .menu-title { + cursor: pointer; +} + +.menu-bar, +.menu-bar:visited, +.nav-chapters, +.nav-chapters:visited, +.mobile-nav-chapters, +.mobile-nav-chapters:visited, +.menu-bar .icon-button, +.menu-bar a i { + color: var(--icons); +} + +.menu-bar i:hover, +.menu-bar .icon-button:hover, +.nav-chapters:hover, +.mobile-nav-chapters i:hover { + color: var(--icons-hover); +} + +/* Nav Icons */ + +.nav-chapters { + font-size: 2.5em; + text-align: center; + text-decoration: none; + + position: fixed; + top: 0; + bottom: 0; + margin: 0; + max-width: 150px; + min-width: 90px; + + display: flex; + justify-content: center; + align-content: center; + flex-direction: column; + + transition: color 0.5s, background-color 0.5s; +} + +.nav-chapters:hover { + text-decoration: none; + background-color: var(--theme-hover); + transition: background-color 0.15s, color 0.15s; +} + +.nav-wrapper { + margin-block-start: 50px; + display: none; +} + +.mobile-nav-chapters { + font-size: 2.5em; + text-align: center; + text-decoration: none; + width: 90px; + border-radius: 5px; + background-color: var(--sidebar-bg); +} + +/* Only Firefox supports flow-relative values */ +.previous { float: left; } +[dir=rtl] .previous { float: right; } + +/* Only Firefox supports flow-relative values */ +.next { + float: right; + right: var(--page-padding); +} +[dir=rtl] .next { + float: left; + right: unset; + left: var(--page-padding); +} + +/* Use the correct buttons for RTL layouts*/ +[dir=rtl] .previous i.fa-angle-left:before {content:"\f105";} +[dir=rtl] .next i.fa-angle-right:before { content:"\f104"; } + +@media only screen and (max-width: 1080px) { + .nav-wide-wrapper { display: none; } + .nav-wrapper { display: block; } +} + +/* sidebar-visible */ +@media only screen and (max-width: 1380px) { + #sidebar-toggle-anchor:checked ~ .page-wrapper .nav-wide-wrapper { display: none; } + #sidebar-toggle-anchor:checked ~ .page-wrapper .nav-wrapper { display: block; } +} + +/* Inline code */ + +:not(pre) > .hljs { + display: inline; + padding: 0.1em 0.3em; + border-radius: 3px; +} + +:not(pre):not(a) > .hljs { + color: var(--inline-code-color); + overflow-x: initial; +} + +a:hover > .hljs { + text-decoration: underline; +} + +pre { + position: relative; +} +pre > .buttons { + position: absolute; + z-index: 100; + right: 0px; + top: 2px; + margin: 0px; + padding: 2px 0px; + + color: var(--sidebar-fg); + cursor: pointer; + visibility: hidden; + opacity: 0; + transition: visibility 0.1s linear, opacity 0.1s linear; +} +pre:hover > .buttons { + visibility: visible; + opacity: 1 +} +pre > .buttons :hover { + color: var(--sidebar-active); + border-color: var(--icons-hover); + background-color: var(--theme-hover); +} +pre > .buttons i { + margin-inline-start: 8px; +} +pre > .buttons button { + cursor: inherit; + margin: 0px 5px; + padding: 3px 5px; + font-size: 14px; + + border-style: solid; + border-width: 1px; + border-radius: 4px; + border-color: var(--icons); + background-color: var(--theme-popup-bg); + transition: 100ms; + transition-property: color,border-color,background-color; + color: var(--icons); +} +@media (pointer: coarse) { + pre > .buttons button { + /* On mobile, make it easier to tap buttons. */ + padding: 0.3rem 1rem; + } + + .sidebar-resize-indicator { + /* Hide resize indicator on devices with limited accuracy */ + display: none; + } +} +pre > code { + display: block; + padding: 1rem; +} + +/* FIXME: ACE editors overlap their buttons because ACE does absolute + positioning within the code block which breaks padding. The only solution I + can think of is to move the padding to the outer pre tag (or insert a div + wrapper), but that would require fixing a whole bunch of CSS rules. +*/ +.hljs.ace_editor { + padding: 0rem 0rem; +} + +pre > .result { + margin-block-start: 10px; +} + +/* Search */ + +#searchresults a { + text-decoration: none; +} + +mark { + border-radius: 2px; + padding-block-start: 0; + padding-block-end: 1px; + padding-inline-start: 3px; + padding-inline-end: 3px; + margin-block-start: 0; + margin-block-end: -1px; + margin-inline-start: -3px; + margin-inline-end: -3px; + background-color: var(--search-mark-bg); + transition: background-color 300ms linear; + cursor: pointer; +} + +mark.fade-out { + background-color: rgba(0,0,0,0) !important; + cursor: auto; +} + +.searchbar-outer { + margin-inline-start: auto; + margin-inline-end: auto; + max-width: var(--content-max-width); +} + +#searchbar { + width: 100%; + margin-block-start: 5px; + margin-block-end: 0; + margin-inline-start: auto; + margin-inline-end: auto; + padding: 10px 16px; + transition: box-shadow 300ms ease-in-out; + border: 1px solid var(--searchbar-border-color); + border-radius: 3px; + background-color: var(--searchbar-bg); + color: var(--searchbar-fg); +} +#searchbar:focus, +#searchbar.active { + box-shadow: 0 0 3px var(--searchbar-shadow-color); +} + +.searchresults-header { + font-weight: bold; + font-size: 1em; + padding-block-start: 18px; + padding-block-end: 0; + padding-inline-start: 5px; + padding-inline-end: 0; + color: var(--searchresults-header-fg); +} + +.searchresults-outer { + margin-inline-start: auto; + margin-inline-end: auto; + max-width: var(--content-max-width); + border-block-end: 1px dashed var(--searchresults-border-color); +} + +ul#searchresults { + list-style: none; + padding-inline-start: 20px; +} +ul#searchresults li { + margin: 10px 0px; + padding: 2px; + border-radius: 2px; +} +ul#searchresults li.focus { + background-color: var(--searchresults-li-bg); +} +ul#searchresults span.teaser { + display: block; + clear: both; + margin-block-start: 5px; + margin-block-end: 0; + margin-inline-start: 20px; + margin-inline-end: 0; + font-size: 0.8em; +} +ul#searchresults span.teaser em { + font-weight: bold; + font-style: normal; +} + +/* Sidebar */ + +.sidebar { + position: fixed; + left: 0; + top: 0; + bottom: 0; + width: var(--sidebar-width); + font-size: 0.875em; + box-sizing: border-box; + -webkit-overflow-scrolling: touch; + overscroll-behavior-y: contain; + background-color: var(--sidebar-bg); + color: var(--sidebar-fg); +} +[dir=rtl] .sidebar { left: unset; right: 0; } +.sidebar-resizing { + -moz-user-select: none; + -webkit-user-select: none; + -ms-user-select: none; + user-select: none; +} +.no-js .sidebar, +.js:not(.sidebar-resizing) .sidebar { + transition: transform 0.3s; /* Animation: slide away */ +} +.sidebar code { + line-height: 2em; +} +.sidebar .sidebar-scrollbox { + overflow-y: auto; + position: absolute; + top: 0; + bottom: 0; + left: 0; + right: 0; + padding: 10px 10px; +} +.sidebar .sidebar-resize-handle { + position: absolute; + cursor: col-resize; + width: 0; + right: calc(var(--sidebar-resize-indicator-width) * -1); + top: 0; + bottom: 0; + display: flex; + align-items: center; +} + +.sidebar-resize-handle .sidebar-resize-indicator { + width: 100%; + height: 12px; + background-color: var(--icons); + margin-inline-start: var(--sidebar-resize-indicator-space); +} + +[dir=rtl] .sidebar .sidebar-resize-handle { + left: calc(var(--sidebar-resize-indicator-width) * -1); + right: unset; +} +.js .sidebar .sidebar-resize-handle { + cursor: col-resize; + width: calc(var(--sidebar-resize-indicator-width) - var(--sidebar-resize-indicator-space)); +} +/* sidebar-hidden */ +#sidebar-toggle-anchor:not(:checked) ~ .sidebar { + transform: translateX(calc(0px - var(--sidebar-width) - var(--sidebar-resize-indicator-width))); + z-index: -1; +} +[dir=rtl] #sidebar-toggle-anchor:not(:checked) ~ .sidebar { + transform: translateX(calc(var(--sidebar-width) + var(--sidebar-resize-indicator-width))); +} +.sidebar::-webkit-scrollbar { + background: var(--sidebar-bg); +} +.sidebar::-webkit-scrollbar-thumb { + background: var(--scrollbar); +} + +/* sidebar-visible */ +#sidebar-toggle-anchor:checked ~ .page-wrapper { + transform: translateX(calc(var(--sidebar-width) + var(--sidebar-resize-indicator-width))); +} +[dir=rtl] #sidebar-toggle-anchor:checked ~ .page-wrapper { + transform: translateX(calc(0px - var(--sidebar-width) - var(--sidebar-resize-indicator-width))); +} +@media only screen and (min-width: 620px) { + #sidebar-toggle-anchor:checked ~ .page-wrapper { + transform: none; + margin-inline-start: calc(var(--sidebar-width) + var(--sidebar-resize-indicator-width)); + } + [dir=rtl] #sidebar-toggle-anchor:checked ~ .page-wrapper { + transform: none; + } +} + +.chapter { + list-style: none outside none; + padding-inline-start: 0; + line-height: 2.2em; +} + +.chapter ol { + width: 100%; +} + +.chapter li { + display: flex; + color: var(--sidebar-non-existant); +} +.chapter li a { + display: block; + padding: 0; + text-decoration: none; + color: var(--sidebar-fg); +} + +.chapter li a:hover { + color: var(--sidebar-active); +} + +.chapter li a.active { + color: var(--sidebar-active); +} + +.chapter li > a.toggle { + cursor: pointer; + display: block; + margin-inline-start: auto; + padding: 0 10px; + user-select: none; + opacity: 0.68; +} + +.chapter li > a.toggle div { + transition: transform 0.5s; +} + +/* collapse the section */ +.chapter li:not(.expanded) + li > ol { + display: none; +} + +.chapter li.chapter-item { + line-height: 1.5em; + margin-block-start: 0.6em; +} + +.chapter li.expanded > a.toggle div { + transform: rotate(90deg); +} + +.spacer { + width: 100%; + height: 3px; + margin: 5px 0px; +} +.chapter .spacer { + background-color: var(--sidebar-spacer); +} + +@media (-moz-touch-enabled: 1), (pointer: coarse) { + .chapter li a { padding: 5px 0; } + .spacer { margin: 10px 0; } +} + +.section { + list-style: none outside none; + padding-inline-start: 20px; + line-height: 1.9em; +} + +/* Theme Menu Popup */ + +.theme-popup { + position: absolute; + left: 10px; + top: var(--menu-bar-height); + z-index: 1000; + border-radius: 4px; + font-size: 0.7em; + color: var(--fg); + background: var(--theme-popup-bg); + border: 1px solid var(--theme-popup-border); + margin: 0; + padding: 0; + list-style: none; + display: none; + /* Don't let the children's background extend past the rounded corners. */ + overflow: hidden; +} +[dir=rtl] .theme-popup { left: unset; right: 10px; } +.theme-popup .default { + color: var(--icons); +} +.theme-popup .theme { + width: 100%; + border: 0; + margin: 0; + padding: 2px 20px; + line-height: 25px; + white-space: nowrap; + text-align: start; + cursor: pointer; + color: inherit; + background: inherit; + font-size: inherit; +} +.theme-popup .theme:hover { + background-color: var(--theme-hover); +} + +.theme-selected::before { + display: inline-block; + content: "✓"; + margin-inline-start: -14px; + width: 14px; +} diff --git a/css/general.css b/css/general.css new file mode 100644 index 00000000..7670b087 --- /dev/null +++ b/css/general.css @@ -0,0 +1,232 @@ +/* Base styles and content styles */ + +:root { + /* Browser default font-size is 16px, this way 1 rem = 10px */ + font-size: 62.5%; + color-scheme: var(--color-scheme); +} + +html { + font-family: "Open Sans", sans-serif; + color: var(--fg); + background-color: var(--bg); + text-size-adjust: none; + -webkit-text-size-adjust: none; +} + +body { + margin: 0; + font-size: 1.6rem; + overflow-x: hidden; +} + +code { + font-family: var(--mono-font) !important; + font-size: var(--code-font-size); + direction: ltr !important; +} + +/* make long words/inline code not x overflow */ +main { + overflow-wrap: break-word; +} + +/* make wide tables scroll if they overflow */ +.table-wrapper { + overflow-x: auto; +} + +/* Don't change font size in headers. */ +h1 code, h2 code, h3 code, h4 code, h5 code, h6 code { + font-size: unset; +} + +.left { float: left; } +.right { float: right; } +.boring { opacity: 0.6; } +.hide-boring .boring { display: none; } +.hidden { display: none !important; } + +h2, h3 { margin-block-start: 2.5em; } +h4, h5 { margin-block-start: 2em; } + +.header + .header h3, +.header + .header h4, +.header + .header h5 { + margin-block-start: 1em; +} + +h1:target::before, +h2:target::before, +h3:target::before, +h4:target::before, +h5:target::before, +h6:target::before { + display: inline-block; + content: "»"; + margin-inline-start: -30px; + width: 30px; +} + +/* This is broken on Safari as of version 14, but is fixed + in Safari Technology Preview 117 which I think will be Safari 14.2. + https://bugs.webkit.org/show_bug.cgi?id=218076 +*/ +:target { + /* Safari does not support logical properties */ + scroll-margin-top: calc(var(--menu-bar-height) + 0.5em); +} + +.page { + outline: 0; + padding: 0 var(--page-padding); + margin-block-start: calc(0px - var(--menu-bar-height)); /* Compensate for the #menu-bar-hover-placeholder */ +} +.page-wrapper { + box-sizing: border-box; + background-color: var(--bg); +} +.no-js .page-wrapper, +.js:not(.sidebar-resizing) .page-wrapper { + transition: margin-left 0.3s ease, transform 0.3s ease; /* Animation: slide away */ +} +[dir=rtl] .js:not(.sidebar-resizing) .page-wrapper { + transition: margin-right 0.3s ease, transform 0.3s ease; /* Animation: slide away */ +} + +.content { + overflow-y: auto; + padding: 0 5px 50px 5px; +} +.content main { + margin-inline-start: auto; + margin-inline-end: auto; + max-width: var(--content-max-width); +} +.content p { line-height: 1.45em; } +.content ol { line-height: 1.45em; } +.content ul { line-height: 1.45em; } +.content a { text-decoration: none; } +.content a:hover { text-decoration: underline; } +.content img, .content video { max-width: 100%; } +.content .header:link, +.content .header:visited { + color: var(--fg); +} +.content .header:link, +.content .header:visited:hover { + text-decoration: none; +} + +table { + margin: 0 auto; + border-collapse: collapse; +} +table td { + padding: 3px 20px; + border: 1px var(--table-border-color) solid; +} +table thead { + background: var(--table-header-bg); +} +table thead td { + font-weight: 700; + border: none; +} +table thead th { + padding: 3px 20px; +} +table thead tr { + border: 1px var(--table-header-bg) solid; +} +/* Alternate background colors for rows */ +table tbody tr:nth-child(2n) { + background: var(--table-alternate-bg); +} + + +blockquote { + margin: 20px 0; + padding: 0 20px; + color: var(--fg); + background-color: var(--quote-bg); + border-block-start: .1em solid var(--quote-border); + border-block-end: .1em solid var(--quote-border); +} + +.warning { + margin: 20px; + padding: 0 20px; + border-inline-start: 2px solid var(--warning-border); +} + +.warning:before { + position: absolute; + width: 3rem; + height: 3rem; + margin-inline-start: calc(-1.5rem - 21px); + content: "ⓘ"; + text-align: center; + background-color: var(--bg); + color: var(--warning-border); + font-weight: bold; + font-size: 2rem; +} + +blockquote .warning:before { + background-color: var(--quote-bg); +} + +kbd { + background-color: var(--table-border-color); + border-radius: 4px; + border: solid 1px var(--theme-popup-border); + box-shadow: inset 0 -1px 0 var(--theme-hover); + display: inline-block; + font-size: var(--code-font-size); + font-family: var(--mono-font); + line-height: 10px; + padding: 4px 5px; + vertical-align: middle; +} + +:not(.footnote-definition) + .footnote-definition, +.footnote-definition + :not(.footnote-definition) { + margin-block-start: 2em; +} +.footnote-definition { + font-size: 0.9em; + margin: 0.5em 0; +} +.footnote-definition p { + display: inline; +} + +.tooltiptext { + position: absolute; + visibility: hidden; + color: #fff; + background-color: #333; + transform: translateX(-50%); /* Center by moving tooltip 50% of its width left */ + left: -8px; /* Half of the width of the icon */ + top: -35px; + font-size: 0.8em; + text-align: center; + border-radius: 6px; + padding: 5px 8px; + margin: 5px; + z-index: 1000; +} +.tooltipped .tooltiptext { + visibility: visible; +} + +.chapter li.part-title { + color: var(--sidebar-fg); + margin: 5px 0px; + font-weight: bold; +} + +.result-no-output { + font-style: italic; +} diff --git a/css/print.css b/css/print.css new file mode 100644 index 00000000..80ec3a54 --- /dev/null +++ b/css/print.css @@ -0,0 +1,50 @@ + +#sidebar, +#menu-bar, +.nav-chapters, +.mobile-nav-chapters { + display: none; +} + +#page-wrapper.page-wrapper { + transform: none !important; + margin-inline-start: 0px; + overflow-y: initial; +} + +#content { + max-width: none; + margin: 0; + padding: 0; +} + +.page { + overflow-y: initial; +} + +code { + direction: ltr !important; +} + +pre > .buttons { + z-index: 2; +} + +a, a:visited, a:active, a:hover { + color: #4183c4; + text-decoration: none; +} + +h1, h2, h3, h4, h5, h6 { + page-break-inside: avoid; + page-break-after: avoid; +} + +pre, code { + page-break-inside: avoid; + white-space: pre-wrap; +} + +.fa { + display: none !important; +} diff --git a/css/variables.css b/css/variables.css new file mode 100644 index 00000000..0da55e8c --- /dev/null +++ b/css/variables.css @@ -0,0 +1,279 @@ + +/* Globals */ + +:root { + --sidebar-width: 300px; + --sidebar-resize-indicator-width: 8px; + --sidebar-resize-indicator-space: 2px; + --page-padding: 15px; + --content-max-width: 750px; + --menu-bar-height: 50px; + --mono-font: "Source Code Pro", Consolas, "Ubuntu Mono", Menlo, "DejaVu Sans Mono", monospace, monospace; + --code-font-size: 0.875em /* please adjust the ace font size accordingly in editor.js */ +} + +/* Themes */ + +.ayu { + --bg: hsl(210, 25%, 8%); + --fg: #c5c5c5; + + --sidebar-bg: #14191f; + --sidebar-fg: #c8c9db; + --sidebar-non-existant: #5c6773; + --sidebar-active: #ffb454; + --sidebar-spacer: #2d334f; + + --scrollbar: var(--sidebar-fg); + + --icons: #737480; + --icons-hover: #b7b9cc; + + --links: #0096cf; + + --inline-code-color: #ffb454; + + --theme-popup-bg: #14191f; + --theme-popup-border: #5c6773; + --theme-hover: #191f26; + + --quote-bg: hsl(226, 15%, 17%); + --quote-border: hsl(226, 15%, 22%); + + --warning-border: #ff8e00; + + --table-border-color: hsl(210, 25%, 13%); + --table-header-bg: hsl(210, 25%, 28%); + --table-alternate-bg: hsl(210, 25%, 11%); + + --searchbar-border-color: #848484; + --searchbar-bg: #424242; + --searchbar-fg: #fff; + --searchbar-shadow-color: #d4c89f; + --searchresults-header-fg: #666; + --searchresults-border-color: #888; + --searchresults-li-bg: #252932; + --search-mark-bg: #e3b171; + + --color-scheme: dark; +} + +.coal { + --bg: hsl(200, 7%, 8%); + --fg: #98a3ad; + + --sidebar-bg: #292c2f; + --sidebar-fg: #a1adb8; + --sidebar-non-existant: #505254; + --sidebar-active: #3473ad; + --sidebar-spacer: #393939; + + --scrollbar: var(--sidebar-fg); + + --icons: #43484d; + --icons-hover: #b3c0cc; + + --links: #2b79a2; + + --inline-code-color: #c5c8c6; + + --theme-popup-bg: #141617; + --theme-popup-border: #43484d; + --theme-hover: #1f2124; + + --quote-bg: hsl(234, 21%, 18%); + --quote-border: hsl(234, 21%, 23%); + + --warning-border: #ff8e00; + + --table-border-color: hsl(200, 7%, 13%); + --table-header-bg: hsl(200, 7%, 28%); + --table-alternate-bg: hsl(200, 7%, 11%); + + --searchbar-border-color: #aaa; + --searchbar-bg: #b7b7b7; + --searchbar-fg: #000; + --searchbar-shadow-color: #aaa; + --searchresults-header-fg: #666; + --searchresults-border-color: #98a3ad; + --searchresults-li-bg: #2b2b2f; + --search-mark-bg: #355c7d; + + --color-scheme: dark; +} + +.light { + --bg: hsl(0, 0%, 100%); + --fg: hsl(0, 0%, 0%); + + --sidebar-bg: #fafafa; + --sidebar-fg: hsl(0, 0%, 0%); + --sidebar-non-existant: #aaaaaa; + --sidebar-active: #1f1fff; + --sidebar-spacer: #f4f4f4; + + --scrollbar: #8F8F8F; + + --icons: #747474; + --icons-hover: #000000; + + --links: #20609f; + + --inline-code-color: #301900; + + --theme-popup-bg: #fafafa; + --theme-popup-border: #cccccc; + --theme-hover: #e6e6e6; + + --quote-bg: hsl(197, 37%, 96%); + --quote-border: hsl(197, 37%, 91%); + + --warning-border: #ff8e00; + + --table-border-color: hsl(0, 0%, 95%); + --table-header-bg: hsl(0, 0%, 80%); + --table-alternate-bg: hsl(0, 0%, 97%); + + --searchbar-border-color: #aaa; + --searchbar-bg: #fafafa; + --searchbar-fg: #000; + --searchbar-shadow-color: #aaa; + --searchresults-header-fg: #666; + --searchresults-border-color: #888; + --searchresults-li-bg: #e4f2fe; + --search-mark-bg: #a2cff5; + + --color-scheme: light; +} + +.navy { + --bg: hsl(226, 23%, 11%); + --fg: #bcbdd0; + + --sidebar-bg: #282d3f; + --sidebar-fg: #c8c9db; + --sidebar-non-existant: #505274; + --sidebar-active: #2b79a2; + --sidebar-spacer: #2d334f; + + --scrollbar: var(--sidebar-fg); + + --icons: #737480; + --icons-hover: #b7b9cc; + + --links: #2b79a2; + + --inline-code-color: #c5c8c6; + + --theme-popup-bg: #161923; + --theme-popup-border: #737480; + --theme-hover: #282e40; + + --quote-bg: hsl(226, 15%, 17%); + --quote-border: hsl(226, 15%, 22%); + + --warning-border: #ff8e00; + + --table-border-color: hsl(226, 23%, 16%); + --table-header-bg: hsl(226, 23%, 31%); + --table-alternate-bg: hsl(226, 23%, 14%); + + --searchbar-border-color: #aaa; + --searchbar-bg: #aeaec6; + --searchbar-fg: #000; + --searchbar-shadow-color: #aaa; + --searchresults-header-fg: #5f5f71; + --searchresults-border-color: #5c5c68; + --searchresults-li-bg: #242430; + --search-mark-bg: #a2cff5; + + --color-scheme: dark; +} + +.rust { + --bg: hsl(60, 9%, 87%); + --fg: #262625; + + --sidebar-bg: #3b2e2a; + --sidebar-fg: #c8c9db; + --sidebar-non-existant: #505254; + --sidebar-active: #e69f67; + --sidebar-spacer: #45373a; + + --scrollbar: var(--sidebar-fg); + + --icons: #737480; + --icons-hover: #262625; + + --links: #2b79a2; + + --inline-code-color: #6e6b5e; + + --theme-popup-bg: #e1e1db; + --theme-popup-border: #b38f6b; + --theme-hover: #99908a; + + --quote-bg: hsl(60, 5%, 75%); + --quote-border: hsl(60, 5%, 70%); + + --warning-border: #ff8e00; + + --table-border-color: hsl(60, 9%, 82%); + --table-header-bg: #b3a497; + --table-alternate-bg: hsl(60, 9%, 84%); + + --searchbar-border-color: #aaa; + --searchbar-bg: #fafafa; + --searchbar-fg: #000; + --searchbar-shadow-color: #aaa; + --searchresults-header-fg: #666; + --searchresults-border-color: #888; + --searchresults-li-bg: #dec2a2; + --search-mark-bg: #e69f67; + + --color-scheme: light; +} + +@media (prefers-color-scheme: dark) { + .light.no-js { + --bg: hsl(200, 7%, 8%); + --fg: #98a3ad; + + --sidebar-bg: #292c2f; + --sidebar-fg: #a1adb8; + --sidebar-non-existant: #505254; + --sidebar-active: #3473ad; + --sidebar-spacer: #393939; + + --scrollbar: var(--sidebar-fg); + + --icons: #43484d; + --icons-hover: #b3c0cc; + + --links: #2b79a2; + + --inline-code-color: #c5c8c6; + + --theme-popup-bg: #141617; + --theme-popup-border: #43484d; + --theme-hover: #1f2124; + + --quote-bg: hsl(234, 21%, 18%); + --quote-border: hsl(234, 21%, 23%); + + --warning-border: #ff8e00; + + --table-border-color: hsl(200, 7%, 13%); + --table-header-bg: hsl(200, 7%, 28%); + --table-alternate-bg: hsl(200, 7%, 11%); + + --searchbar-border-color: #aaa; + --searchbar-bg: #b7b7b7; + --searchbar-fg: #000; + --searchbar-shadow-color: #aaa; + --searchresults-header-fg: #666; + --searchresults-border-color: #98a3ad; + --searchresults-li-bg: #2b2b2f; + --search-mark-bg: #355c7d; + } +} diff --git a/defect-windows.html b/defect-windows.html new file mode 100644 index 00000000..3baf656b --- /dev/null +++ b/defect-windows.html @@ -0,0 +1,371 @@ + + + + + + Defect Windows - JellyTau Documentation + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+ + + + + + + + + + + + + + + + + + + +
+ +
+ + + + + + + + +
+
+

Defect windows — which bugs were present when

+

For each fixed defect, the releases it was actually present in. Companion to +CHANGELOG.md, which says what changed; this says how long each +fault had been shipping before it did.

+

"Present since" is the first release containing the defective code, not the +first release where a user could hit it — those differ, sometimes by months, and +the gap is called out where it matters. "How dated" records the evidence, so a +row can be re-checked or disputed:

+
+ + + +
MethodMeaning
pickaxegit log -S<token> on the defective token — the commit that introduced the exact string, then the earliest tag containing it. Strongest evidence.
featureThe defect is inseparable from a feature that landed whole (bad rung in a new algorithm, missing caller in new plumbing), dated to that feature's release.
absenceThe fix adds something that was never there. Dated to when the surrounding code was built, since there is no introducing commit to find. Weakest — treat as "no later than".
+
+

Present since the first release

+

Nine defects date to the initial proof of concept (v0.0.1, 2026-06-23) and shipped +for between two weeks and seven weeks short of two months before anyone hit them. +That is the dominant pattern here: not regressions, but original assumptions that +went unexercised until a later feature leaned on them.

+
+ + + + + + + + + + + + + +
DefectPresent sinceFixed inShipped broken forHow dated
AudioStreamIndex=0 pinned the video stream as the audio track (DR-140)v0.0.1v0.4.6~7 weekspickaxe
Download URL spelled videoBitrate, which Jellyfin does not bind (DR-123)v0.0.1v0.5.1~7 weekspickaxe
pause_download / resume_download were no-ops (DR-168)v0.0.1v0.5.3~7.5 weekspickaxe
.part sidecar named by with_extension, so no cleanup path matched it (DR-169)v0.0.1v0.5.3~7.5 weekspickaxe
Range sent on every retry regardless of the response (DR-170)v0.0.1v0.5.3~7.5 weekspickaxe
/Items/Latest requested with the default GroupItems=falsev0.0.1v0.5.1~7 weekspickaxe
SubtitleStreamIndex omitted from PlaybackInfo, letting the server burn in (DR-176)v0.0.1v0.5.5~8 weekspickaxe
No PlaySessionId, and one hardcoded DeviceId, on every stream URL (DR-177)v0.0.1v0.5.5~8 weekspickaxe
download_item never recorded media_type; NULL read as 'audio' (DR-135)v0.0.1v0.4.6~7 weekspickaxe
download_album read its track list from the local cache (DR-173)v0.0.1v0.5.5~8 weekspickaxe
Device profile carried no MaxAudioChannels (DR-141)v0.0.1v0.4.6~7 weeksabsence
Streaming ceiling fixed at 20 Mbps with no way to lower it (UR-074)v0.0.1v0.5.3 (as a feature)~7.5 weekspickaxe
Hero banner auto-rotation never restarted after a manual swipe (DR-038)v0.0.1v0.9.1~8.5 weekspickaxe
+
+

Why they took so long to surface

+

Four of these were latent until a later feature exercised them, which is why +the fix lands so far from the cause:

+
    +
  • The videoBitrate casing was harmless while every download was original. It +became visible only once a quality picker existed to select against — and then +produced no error, just a full-size file, because Jellyfin discards an unbound +query key silently.
  • +
  • The unconditional Range header was inert for the same reason: original is +the one rung served with a Content-Length and real byte-range support. It +started corrupting files in v0.5.1, the moment the casing fix made +transcoded downloads actually transcode. So the code dates to v0.0.1 and the +corruption to v0.5.1 — a one-release window for the visible symptom.
  • +
  • The missing PlaySessionId only bites when a stream is re-opened for the same +item. Nothing re-opened one until quality switching, transcoded seek and +audio-track switching existed.
  • +
  • The omitted SubtitleStreamIndex only bites on sources whose own default +subtitle track is image-based, since that is what forces the server from +sidecar to burn-in.
  • +
+

Two were masked by soft failure: the asset protocol being disabled (DR-134) +was hidden by the thumbnail cache falling back to the server copy whenever the +server was reachable, and AudioStreamIndex=0 was hidden by servers that +silently correct an out-of-range index — which is exactly why it was reported as +"some videos have no audio" rather than as a bug in the client.

+

Introduced by a feature, fixed later

+
+ + + + + + + + + + + + + +
DefectPresent sinceFixed inHow dated
Native-path resume position never applied (both layers assumed the other seeked)v0.0.9/v0.0.10v0.5.1feature (PlayerAdapter contract)
get_downloaded_items matched "this library exists" rather than constraining the item to it (DR-167)v0.0.17v0.5.3feature (browsable downloaded library)
SCOPE_ITEM_TYPES — the frontend/backend boundary leak (DR-063)v0.0.17v0.2.1pickaxe
check:boundary anchored to the query site, blind to a named const (DR-094)v0.0.17v0.2.1feature (tripwire landed with the leak it missed)
Coverage gate divided by hardcoded denominators, reporting 158% (DR-093)v0.0.1v0.2.1pickaxe
Tap deferral raced the WebView's synthesized click (DR-092 → DR-098)v0.1.5v0.2.7feature (the deferral itself)
Transport for webview media decided from el.paused in the DOM (DR-097)v0.0.9/v0.0.10v0.2.7feature (Html5PlayerAdapter)
pick_current_episode rung 3 returned the first gap, not the furthest watchedv0.3.0v0.5.1feature
mirror_user_data mirrored is_favorite alone and returned early (DR-155)v0.4.0v0.5.1pickaxe
Stop-report path never fed the sync queue that existed for it (DR-154)v0.4.6v0.5.1feature (queue + drain landed with no producer)
Background-audio base applied in two display-only places (DR-159)v0.2.9v0.5.3pickaxe
Positions reported as 0 before the first tick, and always 0 for webview media (DR-178/179/180)v0.5.3v0.5.5feature (DR-159's tick boundary)
Length-less handoff transcode left to the player's own load-error retry, which can only restart it (DR-203)v0.0.16v0.8.2feature (the handoff's progressive-mp3 choice)
+
+

Three of these are worth separating out, because the defect is not a mistake in +the code so much as plumbing that was built and never connected:

+
    +
  • repository_get_next_up_episodes accepted a series_id from the day it was +written, and no caller passed one until v0.3.0.
  • +
  • The sync queue and its drain were built, tested and running in v0.4.6 with +neither of its two would-be producers ever called.
  • +
  • Both halves of the watched-state backend existed with no caller before v0.5.3.
  • +
+

An automated check cannot see any of these — the code is present, tested and +reachable in principle. Only tracing a requirement to a call site catches it.

+

Short windows (one release or less)

+
+ + + + +
DefectPresent sinceFixed inNote
experimentalNativeVideo defaulted on, shipping audio with a blank screen (DR-161 → DR-172)v0.5.3v0.5.4One release. The decode path was fine; the compositing step never ran.
Webview-shaped audio profile insufficient — server ignores a profile's audio codec (DR-149)v0.4.7v0.4.8The v0.4.7 fix for DR-148 was necessary and not sufficient.
Android versionCode floor went stale (minor*100 yielding less than the 5002 already in the field)v0.5.0v0.5.3Caught before a broken APK shipped; no released build was un-installable.
Subtitle sidecar work reverted by a commit assembled from a stale treev0.5.5v0.5.5Never released broken — both commits are in v0.5.5.
+
+

Fixed twice / never actually broken

+
    +
  • +

    Autoplay time reset (v0.0.2). Two commit objects carry this identical +change: dcf08f30 (merged via Gitea PR #3, tagged v0.0.2) and fa7cb6e9 (the +local original). Both have the same parent 674c8e5c and the same diff. A merge +chain pulled fa7cb6e9 and its follow-up 1e599627 into master's history +during v0.5.5, so git log v0.5.4..v0.5.5 lists an autoplay fix that changed no +file in that release — nextEpisodeService.ts is byte-identical across the tag +boundary. The fix shipped in v0.0.2 and has not regressed.

    +

    This is the one case where reading the changelog off git log subjects would +have produced a false entry, and it is a good argument for the project's +practice of deriving release notes from TRACES rather than commit subjects.

    +
  • +
+

Recurring shapes

+

Four causes account for most of the table:

+
    +
  1. An omitted parameter is not a neutral default. SubtitleStreamIndex, +AudioStreamIndex, GroupItems and MaxAudioChannels all had a server-side +default that was actively wrong, and in three of the four the server's choice +was more expensive than the one intended — burn-in forcing a full re-encode +being the extreme case.
  2. +
  3. Silent binding failures. videoBitRate produced no error, no warning and a +plausible-looking file. So did an unbound Range, and so did the coverage gate +dividing by a stale denominator.
  4. +
  5. Two layers each assuming the other acts. Native resume (adapter recorded +the position, backend never seeked), end-of-playback dispatch (two paths, one +unreachable), and the surface/attach split in v0.5.0's native video.
  6. +
  7. A guard keyed on state that moves. The tap deferral keyed suppression on a +timer handle the callback had already cleared; the HTML5 toggle keyed +play-vs-pause on el.paused, which flips while buffering.
  8. +
+

Reproducing this

+

The pickaxe rows can be re-derived directly:

+
git log --oneline --reverse -S'<defective token>' -- src-tauri/src   # introducing commit
+git tag --contains <sha> | sort -V | head -1                          # first release with it
+
+

Blaming the lines a fix removed (git blame at the fix's parent) is faster to run +across many commits but was not used for the rows above: it reliably lands on +whichever commit last touched the adjacent lines, which is usually not the commit +that introduced the defect. It was used only to shortlist candidates.

+ +
+ + +
+
+ + + +
+ + + + + + + + + + + + + + + + + + +
+ + diff --git a/elasticlunr.min.js b/elasticlunr.min.js new file mode 100644 index 00000000..94b20dd2 --- /dev/null +++ b/elasticlunr.min.js @@ -0,0 +1,10 @@ +/** + * elasticlunr - http://weixsong.github.io + * Lightweight full-text search engine in Javascript for browser search and offline search. - 0.9.5 + * + * Copyright (C) 2017 Oliver Nightingale + * Copyright (C) 2017 Wei Song + * MIT Licensed + * @license + */ +!function(){function e(e){if(null===e||"object"!=typeof e)return e;var t=e.constructor();for(var n in e)e.hasOwnProperty(n)&&(t[n]=e[n]);return t}var t=function(e){var n=new t.Index;return n.pipeline.add(t.trimmer,t.stopWordFilter,t.stemmer),e&&e.call(n,n),n};t.version="0.9.5",lunr=t,t.utils={},t.utils.warn=function(e){return function(t){e.console&&console.warn&&console.warn(t)}}(this),t.utils.toString=function(e){return void 0===e||null===e?"":e.toString()},t.EventEmitter=function(){this.events={}},t.EventEmitter.prototype.addListener=function(){var e=Array.prototype.slice.call(arguments),t=e.pop(),n=e;if("function"!=typeof t)throw new TypeError("last argument must be a function");n.forEach(function(e){this.hasHandler(e)||(this.events[e]=[]),this.events[e].push(t)},this)},t.EventEmitter.prototype.removeListener=function(e,t){if(this.hasHandler(e)){var n=this.events[e].indexOf(t);-1!==n&&(this.events[e].splice(n,1),0==this.events[e].length&&delete this.events[e])}},t.EventEmitter.prototype.emit=function(e){if(this.hasHandler(e)){var t=Array.prototype.slice.call(arguments,1);this.events[e].forEach(function(e){e.apply(void 0,t)},this)}},t.EventEmitter.prototype.hasHandler=function(e){return e in this.events},t.tokenizer=function(e){if(!arguments.length||null===e||void 0===e)return[];if(Array.isArray(e)){var n=e.filter(function(e){return null===e||void 0===e?!1:!0});n=n.map(function(e){return t.utils.toString(e).toLowerCase()});var i=[];return n.forEach(function(e){var n=e.split(t.tokenizer.seperator);i=i.concat(n)},this),i}return e.toString().trim().toLowerCase().split(t.tokenizer.seperator)},t.tokenizer.defaultSeperator=/[\s\-]+/,t.tokenizer.seperator=t.tokenizer.defaultSeperator,t.tokenizer.setSeperator=function(e){null!==e&&void 0!==e&&"object"==typeof e&&(t.tokenizer.seperator=e)},t.tokenizer.resetSeperator=function(){t.tokenizer.seperator=t.tokenizer.defaultSeperator},t.tokenizer.getSeperator=function(){return t.tokenizer.seperator},t.Pipeline=function(){this._queue=[]},t.Pipeline.registeredFunctions={},t.Pipeline.registerFunction=function(e,n){n in t.Pipeline.registeredFunctions&&t.utils.warn("Overwriting existing registered function: "+n),e.label=n,t.Pipeline.registeredFunctions[n]=e},t.Pipeline.getRegisteredFunction=function(e){return e in t.Pipeline.registeredFunctions!=!0?null:t.Pipeline.registeredFunctions[e]},t.Pipeline.warnIfFunctionNotRegistered=function(e){var n=e.label&&e.label in this.registeredFunctions;n||t.utils.warn("Function is not registered with pipeline. This may cause problems when serialising the index.\n",e)},t.Pipeline.load=function(e){var n=new t.Pipeline;return e.forEach(function(e){var i=t.Pipeline.getRegisteredFunction(e);if(!i)throw new Error("Cannot load un-registered function: "+e);n.add(i)}),n},t.Pipeline.prototype.add=function(){var e=Array.prototype.slice.call(arguments);e.forEach(function(e){t.Pipeline.warnIfFunctionNotRegistered(e),this._queue.push(e)},this)},t.Pipeline.prototype.after=function(e,n){t.Pipeline.warnIfFunctionNotRegistered(n);var i=this._queue.indexOf(e);if(-1===i)throw new Error("Cannot find existingFn");this._queue.splice(i+1,0,n)},t.Pipeline.prototype.before=function(e,n){t.Pipeline.warnIfFunctionNotRegistered(n);var i=this._queue.indexOf(e);if(-1===i)throw new Error("Cannot find existingFn");this._queue.splice(i,0,n)},t.Pipeline.prototype.remove=function(e){var t=this._queue.indexOf(e);-1!==t&&this._queue.splice(t,1)},t.Pipeline.prototype.run=function(e){for(var t=[],n=e.length,i=this._queue.length,o=0;n>o;o++){for(var r=e[o],s=0;i>s&&(r=this._queue[s](r,o,e),void 0!==r&&null!==r);s++);void 0!==r&&null!==r&&t.push(r)}return t},t.Pipeline.prototype.reset=function(){this._queue=[]},t.Pipeline.prototype.get=function(){return this._queue},t.Pipeline.prototype.toJSON=function(){return this._queue.map(function(e){return t.Pipeline.warnIfFunctionNotRegistered(e),e.label})},t.Index=function(){this._fields=[],this._ref="id",this.pipeline=new t.Pipeline,this.documentStore=new t.DocumentStore,this.index={},this.eventEmitter=new t.EventEmitter,this._idfCache={},this.on("add","remove","update",function(){this._idfCache={}}.bind(this))},t.Index.prototype.on=function(){var e=Array.prototype.slice.call(arguments);return this.eventEmitter.addListener.apply(this.eventEmitter,e)},t.Index.prototype.off=function(e,t){return this.eventEmitter.removeListener(e,t)},t.Index.load=function(e){e.version!==t.version&&t.utils.warn("version mismatch: current "+t.version+" importing "+e.version);var n=new this;n._fields=e.fields,n._ref=e.ref,n.documentStore=t.DocumentStore.load(e.documentStore),n.pipeline=t.Pipeline.load(e.pipeline),n.index={};for(var i in e.index)n.index[i]=t.InvertedIndex.load(e.index[i]);return n},t.Index.prototype.addField=function(e){return this._fields.push(e),this.index[e]=new t.InvertedIndex,this},t.Index.prototype.setRef=function(e){return this._ref=e,this},t.Index.prototype.saveDocument=function(e){return this.documentStore=new t.DocumentStore(e),this},t.Index.prototype.addDoc=function(e,n){if(e){var n=void 0===n?!0:n,i=e[this._ref];this.documentStore.addDoc(i,e),this._fields.forEach(function(n){var o=this.pipeline.run(t.tokenizer(e[n]));this.documentStore.addFieldLength(i,n,o.length);var r={};o.forEach(function(e){e in r?r[e]+=1:r[e]=1},this);for(var s in r){var u=r[s];u=Math.sqrt(u),this.index[n].addToken(s,{ref:i,tf:u})}},this),n&&this.eventEmitter.emit("add",e,this)}},t.Index.prototype.removeDocByRef=function(e){if(e&&this.documentStore.isDocStored()!==!1&&this.documentStore.hasDoc(e)){var t=this.documentStore.getDoc(e);this.removeDoc(t,!1)}},t.Index.prototype.removeDoc=function(e,n){if(e){var n=void 0===n?!0:n,i=e[this._ref];this.documentStore.hasDoc(i)&&(this.documentStore.removeDoc(i),this._fields.forEach(function(n){var o=this.pipeline.run(t.tokenizer(e[n]));o.forEach(function(e){this.index[n].removeToken(e,i)},this)},this),n&&this.eventEmitter.emit("remove",e,this))}},t.Index.prototype.updateDoc=function(e,t){var t=void 0===t?!0:t;this.removeDocByRef(e[this._ref],!1),this.addDoc(e,!1),t&&this.eventEmitter.emit("update",e,this)},t.Index.prototype.idf=function(e,t){var n="@"+t+"/"+e;if(Object.prototype.hasOwnProperty.call(this._idfCache,n))return this._idfCache[n];var i=this.index[t].getDocFreq(e),o=1+Math.log(this.documentStore.length/(i+1));return this._idfCache[n]=o,o},t.Index.prototype.getFields=function(){return this._fields.slice()},t.Index.prototype.search=function(e,n){if(!e)return[];e="string"==typeof e?{any:e}:JSON.parse(JSON.stringify(e));var i=null;null!=n&&(i=JSON.stringify(n));for(var o=new t.Configuration(i,this.getFields()).get(),r={},s=Object.keys(e),u=0;u0&&t.push(e);for(var i in n)"docs"!==i&&"df"!==i&&this.expandToken(e+i,t,n[i]);return t},t.InvertedIndex.prototype.toJSON=function(){return{root:this.root}},t.Configuration=function(e,n){var e=e||"";if(void 0==n||null==n)throw new Error("fields should not be null");this.config={};var i;try{i=JSON.parse(e),this.buildUserConfig(i,n)}catch(o){t.utils.warn("user configuration parse failed, will use default configuration"),this.buildDefaultConfig(n)}},t.Configuration.prototype.buildDefaultConfig=function(e){this.reset(),e.forEach(function(e){this.config[e]={boost:1,bool:"OR",expand:!1}},this)},t.Configuration.prototype.buildUserConfig=function(e,n){var i="OR",o=!1;if(this.reset(),"bool"in e&&(i=e.bool||i),"expand"in e&&(o=e.expand||o),"fields"in e)for(var r in e.fields)if(n.indexOf(r)>-1){var s=e.fields[r],u=o;void 0!=s.expand&&(u=s.expand),this.config[r]={boost:s.boost||0===s.boost?s.boost:1,bool:s.bool||i,expand:u}}else t.utils.warn("field name in user configuration not found in index instance fields");else this.addAllFields2UserConfig(i,o,n)},t.Configuration.prototype.addAllFields2UserConfig=function(e,t,n){n.forEach(function(n){this.config[n]={boost:1,bool:e,expand:t}},this)},t.Configuration.prototype.get=function(){return this.config},t.Configuration.prototype.reset=function(){this.config={}},lunr.SortedSet=function(){this.length=0,this.elements=[]},lunr.SortedSet.load=function(e){var t=new this;return t.elements=e,t.length=e.length,t},lunr.SortedSet.prototype.add=function(){var e,t;for(e=0;e1;){if(r===e)return o;e>r&&(t=o),r>e&&(n=o),i=n-t,o=t+Math.floor(i/2),r=this.elements[o]}return r===e?o:-1},lunr.SortedSet.prototype.locationFor=function(e){for(var t=0,n=this.elements.length,i=n-t,o=t+Math.floor(i/2),r=this.elements[o];i>1;)e>r&&(t=o),r>e&&(n=o),i=n-t,o=t+Math.floor(i/2),r=this.elements[o];return r>e?o:e>r?o+1:void 0},lunr.SortedSet.prototype.intersect=function(e){for(var t=new lunr.SortedSet,n=0,i=0,o=this.length,r=e.length,s=this.elements,u=e.elements;;){if(n>o-1||i>r-1)break;s[n]!==u[i]?s[n]u[i]&&i++:(t.add(s[n]),n++,i++)}return t},lunr.SortedSet.prototype.clone=function(){var e=new lunr.SortedSet;return e.elements=this.toArray(),e.length=e.elements.length,e},lunr.SortedSet.prototype.union=function(e){var t,n,i;this.length>=e.length?(t=this,n=e):(t=e,n=this),i=t.clone();for(var o=0,r=n.toArray();o + + + + diff --git a/fonts/OPEN-SANS-LICENSE.txt b/fonts/OPEN-SANS-LICENSE.txt new file mode 100644 index 00000000..d6456956 --- /dev/null +++ b/fonts/OPEN-SANS-LICENSE.txt @@ -0,0 +1,202 @@ + + Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + + TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + + 1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + + 2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + + 3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + + 4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + + 5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + + 6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + + 7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + + 8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + + 9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + + END OF TERMS AND CONDITIONS + + APPENDIX: How to apply the Apache License to your work. + + To apply the Apache License to your work, attach the following + boilerplate notice, with the fields enclosed by brackets "[]" + replaced with your own identifying information. (Don't include + the brackets!) The text should be enclosed in the appropriate + comment syntax for the file format. We also recommend that a + file or class name and description of purpose be included on the + same "printed page" as the copyright notice for easier + identification within third-party archives. + + Copyright [yyyy] [name of copyright owner] + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. diff --git a/fonts/SOURCE-CODE-PRO-LICENSE.txt b/fonts/SOURCE-CODE-PRO-LICENSE.txt new file mode 100644 index 00000000..366206f5 --- /dev/null +++ b/fonts/SOURCE-CODE-PRO-LICENSE.txt @@ -0,0 +1,93 @@ +Copyright 2010, 2012 Adobe Systems Incorporated (http://www.adobe.com/), with Reserved Font Name 'Source'. All Rights Reserved. Source is a trademark of Adobe Systems Incorporated in the United States and/or other countries. + +This Font Software is licensed under the SIL Open Font License, Version 1.1. +This license is copied below, and is also available with a FAQ at: +http://scripts.sil.org/OFL + + +----------------------------------------------------------- +SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007 +----------------------------------------------------------- + +PREAMBLE +The goals of the Open Font License (OFL) are to stimulate worldwide +development of collaborative font projects, to support the font creation +efforts of academic and linguistic communities, and to provide a free and +open framework in which fonts may be shared and improved in partnership +with others. + +The OFL allows the licensed fonts to be used, studied, modified and +redistributed freely as long as they are not sold by themselves. The +fonts, including any derivative works, can be bundled, embedded, +redistributed and/or sold with any software provided that any reserved +names are not used by derivative works. The fonts and derivatives, +however, cannot be released under any other type of license. The +requirement for fonts to remain under this license does not apply +to any document created using the fonts or their derivatives. + +DEFINITIONS +"Font Software" refers to the set of files released by the Copyright +Holder(s) under this license and clearly marked as such. This may +include source files, build scripts and documentation. + +"Reserved Font Name" refers to any names specified as such after the +copyright statement(s). + +"Original Version" refers to the collection of Font Software components as +distributed by the Copyright Holder(s). + +"Modified Version" refers to any derivative made by adding to, deleting, +or substituting -- in part or in whole -- any of the components of the +Original Version, by changing formats or by porting the Font Software to a +new environment. + +"Author" refers to any designer, engineer, programmer, technical +writer or other person who contributed to the Font Software. + +PERMISSION & CONDITIONS +Permission is hereby granted, free of charge, to any person obtaining +a copy of the Font Software, to use, study, copy, merge, embed, modify, +redistribute, and sell modified and unmodified copies of the Font +Software, subject to the following conditions: + +1) Neither the Font Software nor any of its individual components, +in Original or Modified Versions, may be sold by itself. + +2) Original or Modified Versions of the Font Software may be bundled, +redistributed and/or sold with any software, provided that each copy +contains the above copyright notice and this license. These can be +included either as stand-alone text files, human-readable headers or +in the appropriate machine-readable metadata fields within text or +binary files as long as those fields can be easily viewed by the user. + +3) No Modified Version of the Font Software may use the Reserved Font +Name(s) unless explicit written permission is granted by the corresponding +Copyright Holder. This restriction only applies to the primary font name as +presented to the users. + +4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font +Software shall not be used to promote, endorse or advertise any +Modified Version, except to acknowledge the contribution(s) of the +Copyright Holder(s) and the Author(s) or with their explicit written +permission. + +5) The Font Software, modified or unmodified, in part or in whole, +must be distributed entirely under this license, and must not be +distributed under any other license. The requirement for fonts to +remain under this license does not apply to any document created +using the Font Software. + +TERMINATION +This license becomes null and void if any of the above conditions are +not met. + +DISCLAIMER +THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF +MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT +OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE +COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL +DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM +OTHER DEALINGS IN THE FONT SOFTWARE. diff --git a/fonts/fonts.css b/fonts/fonts.css new file mode 100644 index 00000000..858efa59 --- /dev/null +++ b/fonts/fonts.css @@ -0,0 +1,100 @@ +/* Open Sans is licensed under the Apache License, Version 2.0. See http://www.apache.org/licenses/LICENSE-2.0 */ +/* Source Code Pro is under the Open Font License. See https://scripts.sil.org/cms/scripts/page.php?site_id=nrsi&id=OFL */ + +/* open-sans-300 - latin_vietnamese_latin-ext_greek-ext_greek_cyrillic-ext_cyrillic */ +@font-face { + font-family: 'Open Sans'; + font-style: normal; + font-weight: 300; + src: local('Open Sans Light'), local('OpenSans-Light'), + url('open-sans-v17-all-charsets-300.woff2') format('woff2'); +} + +/* open-sans-300italic - latin_vietnamese_latin-ext_greek-ext_greek_cyrillic-ext_cyrillic */ +@font-face { + font-family: 'Open Sans'; + font-style: italic; + font-weight: 300; + src: local('Open Sans Light Italic'), local('OpenSans-LightItalic'), + url('open-sans-v17-all-charsets-300italic.woff2') format('woff2'); +} + +/* open-sans-regular - latin_vietnamese_latin-ext_greek-ext_greek_cyrillic-ext_cyrillic */ +@font-face { + font-family: 'Open Sans'; + font-style: normal; + font-weight: 400; + src: local('Open Sans Regular'), local('OpenSans-Regular'), + url('open-sans-v17-all-charsets-regular.woff2') format('woff2'); +} + +/* open-sans-italic - latin_vietnamese_latin-ext_greek-ext_greek_cyrillic-ext_cyrillic */ +@font-face { + font-family: 'Open Sans'; + font-style: italic; + font-weight: 400; + src: local('Open Sans Italic'), local('OpenSans-Italic'), + url('open-sans-v17-all-charsets-italic.woff2') format('woff2'); +} + +/* open-sans-600 - latin_vietnamese_latin-ext_greek-ext_greek_cyrillic-ext_cyrillic */ +@font-face { + font-family: 'Open Sans'; + font-style: normal; + font-weight: 600; + src: local('Open Sans SemiBold'), local('OpenSans-SemiBold'), + url('open-sans-v17-all-charsets-600.woff2') format('woff2'); +} + +/* open-sans-600italic - latin_vietnamese_latin-ext_greek-ext_greek_cyrillic-ext_cyrillic */ +@font-face { + font-family: 'Open Sans'; + font-style: italic; + font-weight: 600; + src: local('Open Sans SemiBold Italic'), local('OpenSans-SemiBoldItalic'), + url('open-sans-v17-all-charsets-600italic.woff2') format('woff2'); +} + +/* open-sans-700 - latin_vietnamese_latin-ext_greek-ext_greek_cyrillic-ext_cyrillic */ +@font-face { + font-family: 'Open Sans'; + font-style: normal; + font-weight: 700; + src: local('Open Sans Bold'), local('OpenSans-Bold'), + url('open-sans-v17-all-charsets-700.woff2') format('woff2'); +} + +/* open-sans-700italic - latin_vietnamese_latin-ext_greek-ext_greek_cyrillic-ext_cyrillic */ +@font-face { + font-family: 'Open Sans'; + font-style: italic; + font-weight: 700; + src: local('Open Sans Bold Italic'), local('OpenSans-BoldItalic'), + url('open-sans-v17-all-charsets-700italic.woff2') format('woff2'); +} + +/* open-sans-800 - latin_vietnamese_latin-ext_greek-ext_greek_cyrillic-ext_cyrillic */ +@font-face { + font-family: 'Open Sans'; + font-style: normal; + font-weight: 800; + src: local('Open Sans ExtraBold'), local('OpenSans-ExtraBold'), + url('open-sans-v17-all-charsets-800.woff2') format('woff2'); +} + +/* open-sans-800italic - latin_vietnamese_latin-ext_greek-ext_greek_cyrillic-ext_cyrillic */ +@font-face { + font-family: 'Open Sans'; + font-style: italic; + font-weight: 800; + src: local('Open Sans ExtraBold Italic'), local('OpenSans-ExtraBoldItalic'), + url('open-sans-v17-all-charsets-800italic.woff2') format('woff2'); +} + +/* source-code-pro-500 - latin_vietnamese_latin-ext_greek_cyrillic-ext_cyrillic */ +@font-face { + font-family: 'Source Code Pro'; + font-style: normal; + font-weight: 500; + src: url('source-code-pro-v11-all-charsets-500.woff2') format('woff2'); +} diff --git a/fonts/open-sans-v17-all-charsets-300.woff2 b/fonts/open-sans-v17-all-charsets-300.woff2 new file mode 100644 index 00000000..9f51be37 Binary files /dev/null and b/fonts/open-sans-v17-all-charsets-300.woff2 differ diff --git a/fonts/open-sans-v17-all-charsets-300italic.woff2 b/fonts/open-sans-v17-all-charsets-300italic.woff2 new file mode 100644 index 00000000..2f545448 Binary files /dev/null and b/fonts/open-sans-v17-all-charsets-300italic.woff2 differ diff --git a/fonts/open-sans-v17-all-charsets-600.woff2 b/fonts/open-sans-v17-all-charsets-600.woff2 new file mode 100644 index 00000000..f503d558 Binary files /dev/null and b/fonts/open-sans-v17-all-charsets-600.woff2 differ diff --git a/fonts/open-sans-v17-all-charsets-600italic.woff2 b/fonts/open-sans-v17-all-charsets-600italic.woff2 new file mode 100644 index 00000000..c99aabe8 Binary files /dev/null and b/fonts/open-sans-v17-all-charsets-600italic.woff2 differ diff --git a/fonts/open-sans-v17-all-charsets-700.woff2 b/fonts/open-sans-v17-all-charsets-700.woff2 new file mode 100644 index 00000000..421a1ab2 Binary files /dev/null and b/fonts/open-sans-v17-all-charsets-700.woff2 differ diff --git a/fonts/open-sans-v17-all-charsets-700italic.woff2 b/fonts/open-sans-v17-all-charsets-700italic.woff2 new file mode 100644 index 00000000..12ce3d20 Binary files /dev/null and b/fonts/open-sans-v17-all-charsets-700italic.woff2 differ diff --git a/fonts/open-sans-v17-all-charsets-800.woff2 b/fonts/open-sans-v17-all-charsets-800.woff2 new file mode 100644 index 00000000..c94a223b Binary files /dev/null and b/fonts/open-sans-v17-all-charsets-800.woff2 differ diff --git a/fonts/open-sans-v17-all-charsets-800italic.woff2 b/fonts/open-sans-v17-all-charsets-800italic.woff2 new file mode 100644 index 00000000..eed7d3c6 Binary files /dev/null and b/fonts/open-sans-v17-all-charsets-800italic.woff2 differ diff --git a/fonts/open-sans-v17-all-charsets-italic.woff2 b/fonts/open-sans-v17-all-charsets-italic.woff2 new file mode 100644 index 00000000..398b68a0 Binary files /dev/null and b/fonts/open-sans-v17-all-charsets-italic.woff2 differ diff --git a/fonts/open-sans-v17-all-charsets-regular.woff2 b/fonts/open-sans-v17-all-charsets-regular.woff2 new file mode 100644 index 00000000..8383e94c Binary files /dev/null and b/fonts/open-sans-v17-all-charsets-regular.woff2 differ diff --git a/fonts/source-code-pro-v11-all-charsets-500.woff2 b/fonts/source-code-pro-v11-all-charsets-500.woff2 new file mode 100644 index 00000000..72224568 Binary files /dev/null and b/fonts/source-code-pro-v11-all-charsets-500.woff2 differ diff --git a/highlight.css b/highlight.css new file mode 100644 index 00000000..ba57b82b --- /dev/null +++ b/highlight.css @@ -0,0 +1,82 @@ +/* + * An increased contrast highlighting scheme loosely based on the + * "Base16 Atelier Dune Light" theme by Bram de Haan + * (http://atelierbram.github.io/syntax-highlighting/atelier-schemes/dune) + * Original Base16 color scheme by Chris Kempson + * (https://github.com/chriskempson/base16) + */ + +/* Comment */ +.hljs-comment, +.hljs-quote { + color: #575757; +} + +/* Red */ +.hljs-variable, +.hljs-template-variable, +.hljs-attribute, +.hljs-tag, +.hljs-name, +.hljs-regexp, +.hljs-link, +.hljs-name, +.hljs-selector-id, +.hljs-selector-class { + color: #d70025; +} + +/* Orange */ +.hljs-number, +.hljs-meta, +.hljs-built_in, +.hljs-builtin-name, +.hljs-literal, +.hljs-type, +.hljs-params { + color: #b21e00; +} + +/* Green */ +.hljs-string, +.hljs-symbol, +.hljs-bullet { + color: #008200; +} + +/* Blue */ +.hljs-title, +.hljs-section { + color: #0030f2; +} + +/* Purple */ +.hljs-keyword, +.hljs-selector-tag { + color: #9d00ec; +} + +.hljs { + display: block; + overflow-x: auto; + background: #f6f7f6; + color: #000; +} + +.hljs-emphasis { + font-style: italic; +} + +.hljs-strong { + font-weight: bold; +} + +.hljs-addition { + color: #22863a; + background-color: #f0fff4; +} + +.hljs-deletion { + color: #b31d28; + background-color: #ffeef0; +} diff --git a/highlight.js b/highlight.js new file mode 100644 index 00000000..18d24345 --- /dev/null +++ b/highlight.js @@ -0,0 +1,54 @@ +/* + Highlight.js 10.1.1 (93fd0d73) + License: BSD-3-Clause + Copyright (c) 2006-2020, Ivan Sagalaev +*/ +var hljs=function(){"use strict";function e(n){Object.freeze(n);var t="function"==typeof n;return Object.getOwnPropertyNames(n).forEach((function(r){!Object.hasOwnProperty.call(n,r)||null===n[r]||"object"!=typeof n[r]&&"function"!=typeof n[r]||t&&("caller"===r||"callee"===r||"arguments"===r)||Object.isFrozen(n[r])||e(n[r])})),n}class n{constructor(e){void 0===e.data&&(e.data={}),this.data=e.data}ignoreMatch(){this.ignore=!0}}function t(e){return e.replace(/&/g,"&").replace(//g,">").replace(/"/g,""").replace(/'/g,"'")}function r(e,...n){var t={};for(const n in e)t[n]=e[n];return n.forEach((function(e){for(const n in e)t[n]=e[n]})),t}function a(e){return e.nodeName.toLowerCase()}var i=Object.freeze({__proto__:null,escapeHTML:t,inherit:r,nodeStream:function(e){var n=[];return function e(t,r){for(var i=t.firstChild;i;i=i.nextSibling)3===i.nodeType?r+=i.nodeValue.length:1===i.nodeType&&(n.push({event:"start",offset:r,node:i}),r=e(i,r),a(i).match(/br|hr|img|input/)||n.push({event:"stop",offset:r,node:i}));return r}(e,0),n},mergeStreams:function(e,n,r){var i=0,s="",o=[];function l(){return e.length&&n.length?e[0].offset!==n[0].offset?e[0].offset"}function u(e){s+=""}function d(e){("start"===e.event?c:u)(e.node)}for(;e.length||n.length;){var g=l();if(s+=t(r.substring(i,g[0].offset)),i=g[0].offset,g===e){o.reverse().forEach(u);do{d(g.splice(0,1)[0]),g=l()}while(g===e&&g.length&&g[0].offset===i);o.reverse().forEach(c)}else"start"===g[0].event?o.push(g[0].node):o.pop(),d(g.splice(0,1)[0])}return s+t(r.substr(i))}});const s="
",o=e=>!!e.kind;class l{constructor(e,n){this.buffer="",this.classPrefix=n.classPrefix,e.walk(this)}addText(e){this.buffer+=t(e)}openNode(e){if(!o(e))return;let n=e.kind;e.sublanguage||(n=`${this.classPrefix}${n}`),this.span(n)}closeNode(e){o(e)&&(this.buffer+=s)}value(){return this.buffer}span(e){this.buffer+=``}}class c{constructor(){this.rootNode={children:[]},this.stack=[this.rootNode]}get top(){return this.stack[this.stack.length-1]}get root(){return this.rootNode}add(e){this.top.children.push(e)}openNode(e){const n={kind:e,children:[]};this.add(n),this.stack.push(n)}closeNode(){if(this.stack.length>1)return this.stack.pop()}closeAllNodes(){for(;this.closeNode(););}toJSON(){return JSON.stringify(this.rootNode,null,4)}walk(e){return this.constructor._walk(e,this.rootNode)}static _walk(e,n){return"string"==typeof n?e.addText(n):n.children&&(e.openNode(n),n.children.forEach(n=>this._walk(e,n)),e.closeNode(n)),e}static _collapse(e){"string"!=typeof e&&e.children&&(e.children.every(e=>"string"==typeof e)?e.children=[e.children.join("")]:e.children.forEach(e=>{c._collapse(e)}))}}class u extends c{constructor(e){super(),this.options=e}addKeyword(e,n){""!==e&&(this.openNode(n),this.addText(e),this.closeNode())}addText(e){""!==e&&this.add(e)}addSublanguage(e,n){const t=e.root;t.kind=n,t.sublanguage=!0,this.add(t)}toHTML(){return new l(this,this.options).value()}finalize(){return!0}}function d(e){return e?"string"==typeof e?e:e.source:null}const g="(-?)(\\b0[xX][a-fA-F0-9]+|(\\b\\d+(\\.\\d*)?|\\.\\d+)([eE][-+]?\\d+)?)",h={begin:"\\\\[\\s\\S]",relevance:0},f={className:"string",begin:"'",end:"'",illegal:"\\n",contains:[h]},p={className:"string",begin:'"',end:'"',illegal:"\\n",contains:[h]},b={begin:/\b(a|an|the|are|I'm|isn't|don't|doesn't|won't|but|just|should|pretty|simply|enough|gonna|going|wtf|so|such|will|you|your|they|like|more)\b/},m=function(e,n,t={}){var a=r({className:"comment",begin:e,end:n,contains:[]},t);return a.contains.push(b),a.contains.push({className:"doctag",begin:"(?:TODO|FIXME|NOTE|BUG|OPTIMIZE|HACK|XXX):",relevance:0}),a},v=m("//","$"),x=m("/\\*","\\*/"),E=m("#","$");var _=Object.freeze({__proto__:null,IDENT_RE:"[a-zA-Z]\\w*",UNDERSCORE_IDENT_RE:"[a-zA-Z_]\\w*",NUMBER_RE:"\\b\\d+(\\.\\d+)?",C_NUMBER_RE:g,BINARY_NUMBER_RE:"\\b(0b[01]+)",RE_STARTERS_RE:"!|!=|!==|%|%=|&|&&|&=|\\*|\\*=|\\+|\\+=|,|-|-=|/=|/|:|;|<<|<<=|<=|<|===|==|=|>>>=|>>=|>=|>>>|>>|>|\\?|\\[|\\{|\\(|\\^|\\^=|\\||\\|=|\\|\\||~",SHEBANG:(e={})=>{const n=/^#![ ]*\//;return e.binary&&(e.begin=function(...e){return e.map(e=>d(e)).join("")}(n,/.*\b/,e.binary,/\b.*/)),r({className:"meta",begin:n,end:/$/,relevance:0,"on:begin":(e,n)=>{0!==e.index&&n.ignoreMatch()}},e)},BACKSLASH_ESCAPE:h,APOS_STRING_MODE:f,QUOTE_STRING_MODE:p,PHRASAL_WORDS_MODE:b,COMMENT:m,C_LINE_COMMENT_MODE:v,C_BLOCK_COMMENT_MODE:x,HASH_COMMENT_MODE:E,NUMBER_MODE:{className:"number",begin:"\\b\\d+(\\.\\d+)?",relevance:0},C_NUMBER_MODE:{className:"number",begin:g,relevance:0},BINARY_NUMBER_MODE:{className:"number",begin:"\\b(0b[01]+)",relevance:0},CSS_NUMBER_MODE:{className:"number",begin:"\\b\\d+(\\.\\d+)?(%|em|ex|ch|rem|vw|vh|vmin|vmax|cm|mm|in|pt|pc|px|deg|grad|rad|turn|s|ms|Hz|kHz|dpi|dpcm|dppx)?",relevance:0},REGEXP_MODE:{begin:/(?=\/[^/\n]*\/)/,contains:[{className:"regexp",begin:/\//,end:/\/[gimuy]*/,illegal:/\n/,contains:[h,{begin:/\[/,end:/\]/,relevance:0,contains:[h]}]}]},TITLE_MODE:{className:"title",begin:"[a-zA-Z]\\w*",relevance:0},UNDERSCORE_TITLE_MODE:{className:"title",begin:"[a-zA-Z_]\\w*",relevance:0},METHOD_GUARD:{begin:"\\.\\s*[a-zA-Z_]\\w*",relevance:0},END_SAME_AS_BEGIN:function(e){return Object.assign(e,{"on:begin":(e,n)=>{n.data._beginMatch=e[1]},"on:end":(e,n)=>{n.data._beginMatch!==e[1]&&n.ignoreMatch()}})}}),N="of and for in not or if then".split(" ");function w(e,n){return n?+n:function(e){return N.includes(e.toLowerCase())}(e)?0:1}const R=t,y=r,{nodeStream:k,mergeStreams:O}=i,M=Symbol("nomatch");return function(t){var a=[],i={},s={},o=[],l=!0,c=/(^(<[^>]+>|\t|)+|\n)/gm,g="Could not find the language '{}', did you forget to load/include a language module?";const h={disableAutodetect:!0,name:"Plain text",contains:[]};var f={noHighlightRe:/^(no-?highlight)$/i,languageDetectRe:/\blang(?:uage)?-([\w-]+)\b/i,classPrefix:"hljs-",tabReplace:null,useBR:!1,languages:null,__emitter:u};function p(e){return f.noHighlightRe.test(e)}function b(e,n,t,r){var a={code:n,language:e};S("before:highlight",a);var i=a.result?a.result:m(a.language,a.code,t,r);return i.code=a.code,S("after:highlight",i),i}function m(e,t,a,s){var o=t;function c(e,n){var t=E.case_insensitive?n[0].toLowerCase():n[0];return Object.prototype.hasOwnProperty.call(e.keywords,t)&&e.keywords[t]}function u(){null!=y.subLanguage?function(){if(""!==A){var e=null;if("string"==typeof y.subLanguage){if(!i[y.subLanguage])return void O.addText(A);e=m(y.subLanguage,A,!0,k[y.subLanguage]),k[y.subLanguage]=e.top}else e=v(A,y.subLanguage.length?y.subLanguage:null);y.relevance>0&&(I+=e.relevance),O.addSublanguage(e.emitter,e.language)}}():function(){if(!y.keywords)return void O.addText(A);let e=0;y.keywordPatternRe.lastIndex=0;let n=y.keywordPatternRe.exec(A),t="";for(;n;){t+=A.substring(e,n.index);const r=c(y,n);if(r){const[e,a]=r;O.addText(t),t="",I+=a,O.addKeyword(n[0],e)}else t+=n[0];e=y.keywordPatternRe.lastIndex,n=y.keywordPatternRe.exec(A)}t+=A.substr(e),O.addText(t)}(),A=""}function h(e){return e.className&&O.openNode(e.className),y=Object.create(e,{parent:{value:y}})}function p(e){return 0===y.matcher.regexIndex?(A+=e[0],1):(L=!0,0)}var b={};function x(t,r){var i=r&&r[0];if(A+=t,null==i)return u(),0;if("begin"===b.type&&"end"===r.type&&b.index===r.index&&""===i){if(A+=o.slice(r.index,r.index+1),!l){const n=Error("0 width match regex");throw n.languageName=e,n.badRule=b.rule,n}return 1}if(b=r,"begin"===r.type)return function(e){var t=e[0],r=e.rule;const a=new n(r),i=[r.__beforeBegin,r["on:begin"]];for(const n of i)if(n&&(n(e,a),a.ignore))return p(t);return r&&r.endSameAsBegin&&(r.endRe=RegExp(t.replace(/[-/\\^$*+?.()|[\]{}]/g,"\\$&"),"m")),r.skip?A+=t:(r.excludeBegin&&(A+=t),u(),r.returnBegin||r.excludeBegin||(A=t)),h(r),r.returnBegin?0:t.length}(r);if("illegal"===r.type&&!a){const e=Error('Illegal lexeme "'+i+'" for mode "'+(y.className||"")+'"');throw e.mode=y,e}if("end"===r.type){var s=function(e){var t=e[0],r=o.substr(e.index),a=function e(t,r,a){let i=function(e,n){var t=e&&e.exec(n);return t&&0===t.index}(t.endRe,a);if(i){if(t["on:end"]){const e=new n(t);t["on:end"](r,e),e.ignore&&(i=!1)}if(i){for(;t.endsParent&&t.parent;)t=t.parent;return t}}if(t.endsWithParent)return e(t.parent,r,a)}(y,e,r);if(!a)return M;var i=y;i.skip?A+=t:(i.returnEnd||i.excludeEnd||(A+=t),u(),i.excludeEnd&&(A=t));do{y.className&&O.closeNode(),y.skip||y.subLanguage||(I+=y.relevance),y=y.parent}while(y!==a.parent);return a.starts&&(a.endSameAsBegin&&(a.starts.endRe=a.endRe),h(a.starts)),i.returnEnd?0:t.length}(r);if(s!==M)return s}if("illegal"===r.type&&""===i)return 1;if(B>1e5&&B>3*r.index)throw Error("potential infinite loop, way more iterations than matches");return A+=i,i.length}var E=T(e);if(!E)throw console.error(g.replace("{}",e)),Error('Unknown language: "'+e+'"');var _=function(e){function n(n,t){return RegExp(d(n),"m"+(e.case_insensitive?"i":"")+(t?"g":""))}class t{constructor(){this.matchIndexes={},this.regexes=[],this.matchAt=1,this.position=0}addRule(e,n){n.position=this.position++,this.matchIndexes[this.matchAt]=n,this.regexes.push([n,e]),this.matchAt+=function(e){return RegExp(e.toString()+"|").exec("").length-1}(e)+1}compile(){0===this.regexes.length&&(this.exec=()=>null);const e=this.regexes.map(e=>e[1]);this.matcherRe=n(function(e,n="|"){for(var t=/\[(?:[^\\\]]|\\.)*\]|\(\??|\\([1-9][0-9]*)|\\./,r=0,a="",i=0;i0&&(a+=n),a+="(";o.length>0;){var l=t.exec(o);if(null==l){a+=o;break}a+=o.substring(0,l.index),o=o.substring(l.index+l[0].length),"\\"===l[0][0]&&l[1]?a+="\\"+(+l[1]+s):(a+=l[0],"("===l[0]&&r++)}a+=")"}return a}(e),!0),this.lastIndex=0}exec(e){this.matcherRe.lastIndex=this.lastIndex;const n=this.matcherRe.exec(e);if(!n)return null;const t=n.findIndex((e,n)=>n>0&&void 0!==e),r=this.matchIndexes[t];return n.splice(0,t),Object.assign(n,r)}}class a{constructor(){this.rules=[],this.multiRegexes=[],this.count=0,this.lastIndex=0,this.regexIndex=0}getMatcher(e){if(this.multiRegexes[e])return this.multiRegexes[e];const n=new t;return this.rules.slice(e).forEach(([e,t])=>n.addRule(e,t)),n.compile(),this.multiRegexes[e]=n,n}considerAll(){this.regexIndex=0}addRule(e,n){this.rules.push([e,n]),"begin"===n.type&&this.count++}exec(e){const n=this.getMatcher(this.regexIndex);n.lastIndex=this.lastIndex;const t=n.exec(e);return t&&(this.regexIndex+=t.position+1,this.regexIndex===this.count&&(this.regexIndex=0)),t}}function i(e,n){const t=e.input[e.index-1],r=e.input[e.index+e[0].length];"."!==t&&"."!==r||n.ignoreMatch()}if(e.contains&&e.contains.includes("self"))throw Error("ERR: contains `self` is not supported at the top-level of a language. See documentation.");return function t(s,o){const l=s;if(s.compiled)return l;s.compiled=!0,s.__beforeBegin=null,s.keywords=s.keywords||s.beginKeywords;let c=null;if("object"==typeof s.keywords&&(c=s.keywords.$pattern,delete s.keywords.$pattern),s.keywords&&(s.keywords=function(e,n){var t={};return"string"==typeof e?r("keyword",e):Object.keys(e).forEach((function(n){r(n,e[n])})),t;function r(e,r){n&&(r=r.toLowerCase()),r.split(" ").forEach((function(n){var r=n.split("|");t[r[0]]=[e,w(r[0],r[1])]}))}}(s.keywords,e.case_insensitive)),s.lexemes&&c)throw Error("ERR: Prefer `keywords.$pattern` to `mode.lexemes`, BOTH are not allowed. (see mode reference) ");return l.keywordPatternRe=n(s.lexemes||c||/\w+/,!0),o&&(s.beginKeywords&&(s.begin="\\b("+s.beginKeywords.split(" ").join("|")+")(?=\\b|\\s)",s.__beforeBegin=i),s.begin||(s.begin=/\B|\b/),l.beginRe=n(s.begin),s.endSameAsBegin&&(s.end=s.begin),s.end||s.endsWithParent||(s.end=/\B|\b/),s.end&&(l.endRe=n(s.end)),l.terminator_end=d(s.end)||"",s.endsWithParent&&o.terminator_end&&(l.terminator_end+=(s.end?"|":"")+o.terminator_end)),s.illegal&&(l.illegalRe=n(s.illegal)),void 0===s.relevance&&(s.relevance=1),s.contains||(s.contains=[]),s.contains=[].concat(...s.contains.map((function(e){return function(e){return e.variants&&!e.cached_variants&&(e.cached_variants=e.variants.map((function(n){return r(e,{variants:null},n)}))),e.cached_variants?e.cached_variants:function e(n){return!!n&&(n.endsWithParent||e(n.starts))}(e)?r(e,{starts:e.starts?r(e.starts):null}):Object.isFrozen(e)?r(e):e}("self"===e?s:e)}))),s.contains.forEach((function(e){t(e,l)})),s.starts&&t(s.starts,o),l.matcher=function(e){const n=new a;return e.contains.forEach(e=>n.addRule(e.begin,{rule:e,type:"begin"})),e.terminator_end&&n.addRule(e.terminator_end,{type:"end"}),e.illegal&&n.addRule(e.illegal,{type:"illegal"}),n}(l),l}(e)}(E),N="",y=s||_,k={},O=new f.__emitter(f);!function(){for(var e=[],n=y;n!==E;n=n.parent)n.className&&e.unshift(n.className);e.forEach(e=>O.openNode(e))}();var A="",I=0,S=0,B=0,L=!1;try{for(y.matcher.considerAll();;){B++,L?L=!1:(y.matcher.lastIndex=S,y.matcher.considerAll());const e=y.matcher.exec(o);if(!e)break;const n=x(o.substring(S,e.index),e);S=e.index+n}return x(o.substr(S)),O.closeAllNodes(),O.finalize(),N=O.toHTML(),{relevance:I,value:N,language:e,illegal:!1,emitter:O,top:y}}catch(n){if(n.message&&n.message.includes("Illegal"))return{illegal:!0,illegalBy:{msg:n.message,context:o.slice(S-100,S+100),mode:n.mode},sofar:N,relevance:0,value:R(o),emitter:O};if(l)return{illegal:!1,relevance:0,value:R(o),emitter:O,language:e,top:y,errorRaised:n};throw n}}function v(e,n){n=n||f.languages||Object.keys(i);var t=function(e){const n={relevance:0,emitter:new f.__emitter(f),value:R(e),illegal:!1,top:h};return n.emitter.addText(e),n}(e),r=t;return n.filter(T).filter(I).forEach((function(n){var a=m(n,e,!1);a.language=n,a.relevance>r.relevance&&(r=a),a.relevance>t.relevance&&(r=t,t=a)})),r.language&&(t.second_best=r),t}function x(e){return f.tabReplace||f.useBR?e.replace(c,e=>"\n"===e?f.useBR?"
":e:f.tabReplace?e.replace(/\t/g,f.tabReplace):e):e}function E(e){let n=null;const t=function(e){var n=e.className+" ";n+=e.parentNode?e.parentNode.className:"";const t=f.languageDetectRe.exec(n);if(t){var r=T(t[1]);return r||(console.warn(g.replace("{}",t[1])),console.warn("Falling back to no-highlight mode for this block.",e)),r?t[1]:"no-highlight"}return n.split(/\s+/).find(e=>p(e)||T(e))}(e);if(p(t))return;S("before:highlightBlock",{block:e,language:t}),f.useBR?(n=document.createElement("div")).innerHTML=e.innerHTML.replace(/\n/g,"").replace(//g,"\n"):n=e;const r=n.textContent,a=t?b(t,r,!0):v(r),i=k(n);if(i.length){const e=document.createElement("div");e.innerHTML=a.value,a.value=O(i,k(e),r)}a.value=x(a.value),S("after:highlightBlock",{block:e,result:a}),e.innerHTML=a.value,e.className=function(e,n,t){var r=n?s[n]:t,a=[e.trim()];return e.match(/\bhljs\b/)||a.push("hljs"),e.includes(r)||a.push(r),a.join(" ").trim()}(e.className,t,a.language),e.result={language:a.language,re:a.relevance,relavance:a.relevance},a.second_best&&(e.second_best={language:a.second_best.language,re:a.second_best.relevance,relavance:a.second_best.relevance})}const N=()=>{if(!N.called){N.called=!0;var e=document.querySelectorAll("pre code");a.forEach.call(e,E)}};function T(e){return e=(e||"").toLowerCase(),i[e]||i[s[e]]}function A(e,{languageName:n}){"string"==typeof e&&(e=[e]),e.forEach(e=>{s[e]=n})}function I(e){var n=T(e);return n&&!n.disableAutodetect}function S(e,n){var t=e;o.forEach((function(e){e[t]&&e[t](n)}))}Object.assign(t,{highlight:b,highlightAuto:v,fixMarkup:x,highlightBlock:E,configure:function(e){f=y(f,e)},initHighlighting:N,initHighlightingOnLoad:function(){window.addEventListener("DOMContentLoaded",N,!1)},registerLanguage:function(e,n){var r=null;try{r=n(t)}catch(n){if(console.error("Language definition for '{}' could not be registered.".replace("{}",e)),!l)throw n;console.error(n),r=h}r.name||(r.name=e),i[e]=r,r.rawDefinition=n.bind(null,t),r.aliases&&A(r.aliases,{languageName:e})},listLanguages:function(){return Object.keys(i)},getLanguage:T,registerAliases:A,requireLanguage:function(e){var n=T(e);if(n)return n;throw Error("The '{}' language is required, but not loaded.".replace("{}",e))},autoDetection:I,inherit:y,addPlugin:function(e){o.push(e)}}),t.debugMode=function(){l=!1},t.safeMode=function(){l=!0},t.versionString="10.1.1";for(const n in _)"object"==typeof _[n]&&e(_[n]);return Object.assign(t,_),t}({})}();"object"==typeof exports&&"undefined"!=typeof module&&(module.exports=hljs); +hljs.registerLanguage("apache",function(){"use strict";return function(e){var n={className:"number",begin:"\\d{1,3}\\.\\d{1,3}\\.\\d{1,3}\\.\\d{1,3}(:\\d{1,5})?"};return{name:"Apache config",aliases:["apacheconf"],case_insensitive:!0,contains:[e.HASH_COMMENT_MODE,{className:"section",begin:"",contains:[n,{className:"number",begin:":\\d{1,5}"},e.inherit(e.QUOTE_STRING_MODE,{relevance:0})]},{className:"attribute",begin:/\w+/,relevance:0,keywords:{nomarkup:"order deny allow setenv rewriterule rewriteengine rewritecond documentroot sethandler errordocument loadmodule options header listen serverroot servername"},starts:{end:/$/,relevance:0,keywords:{literal:"on off all deny allow"},contains:[{className:"meta",begin:"\\s\\[",end:"\\]$"},{className:"variable",begin:"[\\$%]\\{",end:"\\}",contains:["self",{className:"number",begin:"[\\$%]\\d+"}]},n,{className:"number",begin:"\\d+"},e.QUOTE_STRING_MODE]}}],illegal:/\S/}}}()); +hljs.registerLanguage("bash",function(){"use strict";return function(e){const s={};Object.assign(s,{className:"variable",variants:[{begin:/\$[\w\d#@][\w\d_]*/},{begin:/\$\{/,end:/\}/,contains:[{begin:/:-/,contains:[s]}]}]});const t={className:"subst",begin:/\$\(/,end:/\)/,contains:[e.BACKSLASH_ESCAPE]},n={className:"string",begin:/"/,end:/"/,contains:[e.BACKSLASH_ESCAPE,s,t]};t.contains.push(n);const a={begin:/\$\(\(/,end:/\)\)/,contains:[{begin:/\d+#[0-9a-f]+/,className:"number"},e.NUMBER_MODE,s]},i=e.SHEBANG({binary:"(fish|bash|zsh|sh|csh|ksh|tcsh|dash|scsh)",relevance:10}),c={className:"function",begin:/\w[\w\d_]*\s*\(\s*\)\s*\{/,returnBegin:!0,contains:[e.inherit(e.TITLE_MODE,{begin:/\w[\w\d_]*/})],relevance:0};return{name:"Bash",aliases:["sh","zsh"],keywords:{$pattern:/\b-?[a-z\._]+\b/,keyword:"if then else elif fi for while in do done case esac function",literal:"true false",built_in:"break cd continue eval exec exit export getopts hash pwd readonly return shift test times trap umask unset alias bind builtin caller command declare echo enable help let local logout mapfile printf read readarray source type typeset ulimit unalias set shopt autoload bg bindkey bye cap chdir clone comparguments compcall compctl compdescribe compfiles compgroups compquote comptags comptry compvalues dirs disable disown echotc echoti emulate fc fg float functions getcap getln history integer jobs kill limit log noglob popd print pushd pushln rehash sched setcap setopt stat suspend ttyctl unfunction unhash unlimit unsetopt vared wait whence where which zcompile zformat zftp zle zmodload zparseopts zprof zpty zregexparse zsocket zstyle ztcp",_:"-ne -eq -lt -gt -f -d -e -s -l -a"},contains:[i,e.SHEBANG(),c,a,e.HASH_COMMENT_MODE,n,{className:"",begin:/\\"/},{className:"string",begin:/'/,end:/'/},s]}}}()); +hljs.registerLanguage("c-like",function(){"use strict";return function(e){function t(e){return"(?:"+e+")?"}var n="(decltype\\(auto\\)|"+t("[a-zA-Z_]\\w*::")+"[a-zA-Z_]\\w*"+t("<.*?>")+")",r={className:"keyword",begin:"\\b[a-z\\d_]*_t\\b"},a={className:"string",variants:[{begin:'(u8?|U|L)?"',end:'"',illegal:"\\n",contains:[e.BACKSLASH_ESCAPE]},{begin:"(u8?|U|L)?'(\\\\(x[0-9A-Fa-f]{2}|u[0-9A-Fa-f]{4,8}|[0-7]{3}|\\S)|.)",end:"'",illegal:"."},e.END_SAME_AS_BEGIN({begin:/(?:u8?|U|L)?R"([^()\\ ]{0,16})\(/,end:/\)([^()\\ ]{0,16})"/})]},i={className:"number",variants:[{begin:"\\b(0b[01']+)"},{begin:"(-?)\\b([\\d']+(\\.[\\d']*)?|\\.[\\d']+)(u|U|l|L|ul|UL|f|F|b|B)"},{begin:"(-?)(\\b0[xX][a-fA-F0-9']+|(\\b[\\d']+(\\.[\\d']*)?|\\.[\\d']+)([eE][-+]?[\\d']+)?)"}],relevance:0},s={className:"meta",begin:/#\s*[a-z]+\b/,end:/$/,keywords:{"meta-keyword":"if else elif endif define undef warning error line pragma _Pragma ifdef ifndef include"},contains:[{begin:/\\\n/,relevance:0},e.inherit(a,{className:"meta-string"}),{className:"meta-string",begin:/<.*?>/,end:/$/,illegal:"\\n"},e.C_LINE_COMMENT_MODE,e.C_BLOCK_COMMENT_MODE]},o={className:"title",begin:t("[a-zA-Z_]\\w*::")+e.IDENT_RE,relevance:0},c=t("[a-zA-Z_]\\w*::")+e.IDENT_RE+"\\s*\\(",l={keyword:"int float while private char char8_t char16_t char32_t catch import module export virtual operator sizeof dynamic_cast|10 typedef const_cast|10 const for static_cast|10 union namespace unsigned long volatile static protected bool template mutable if public friend do goto auto void enum else break extern using asm case typeid wchar_t short reinterpret_cast|10 default double register explicit signed typename try this switch continue inline delete alignas alignof constexpr consteval constinit decltype concept co_await co_return co_yield requires noexcept static_assert thread_local restrict final override atomic_bool atomic_char atomic_schar atomic_uchar atomic_short atomic_ushort atomic_int atomic_uint atomic_long atomic_ulong atomic_llong atomic_ullong new throw return and and_eq bitand bitor compl not not_eq or or_eq xor xor_eq",built_in:"std string wstring cin cout cerr clog stdin stdout stderr stringstream istringstream ostringstream auto_ptr deque list queue stack vector map set pair bitset multiset multimap unordered_set unordered_map unordered_multiset unordered_multimap priority_queue make_pair array shared_ptr abort terminate abs acos asin atan2 atan calloc ceil cosh cos exit exp fabs floor fmod fprintf fputs free frexp fscanf future isalnum isalpha iscntrl isdigit isgraph islower isprint ispunct isspace isupper isxdigit tolower toupper labs ldexp log10 log malloc realloc memchr memcmp memcpy memset modf pow printf putchar puts scanf sinh sin snprintf sprintf sqrt sscanf strcat strchr strcmp strcpy strcspn strlen strncat strncmp strncpy strpbrk strrchr strspn strstr tanh tan vfprintf vprintf vsprintf endl initializer_list unique_ptr _Bool complex _Complex imaginary _Imaginary",literal:"true false nullptr NULL"},d=[r,e.C_LINE_COMMENT_MODE,e.C_BLOCK_COMMENT_MODE,i,a],_={variants:[{begin:/=/,end:/;/},{begin:/\(/,end:/\)/},{beginKeywords:"new throw return else",end:/;/}],keywords:l,contains:d.concat([{begin:/\(/,end:/\)/,keywords:l,contains:d.concat(["self"]),relevance:0}]),relevance:0},u={className:"function",begin:"("+n+"[\\*&\\s]+)+"+c,returnBegin:!0,end:/[{;=]/,excludeEnd:!0,keywords:l,illegal:/[^\w\s\*&:<>]/,contains:[{begin:"decltype\\(auto\\)",keywords:l,relevance:0},{begin:c,returnBegin:!0,contains:[o],relevance:0},{className:"params",begin:/\(/,end:/\)/,keywords:l,relevance:0,contains:[e.C_LINE_COMMENT_MODE,e.C_BLOCK_COMMENT_MODE,a,i,r,{begin:/\(/,end:/\)/,keywords:l,relevance:0,contains:["self",e.C_LINE_COMMENT_MODE,e.C_BLOCK_COMMENT_MODE,a,i,r]}]},r,e.C_LINE_COMMENT_MODE,e.C_BLOCK_COMMENT_MODE,s]};return{aliases:["c","cc","h","c++","h++","hpp","hh","hxx","cxx"],keywords:l,disableAutodetect:!0,illegal:"",keywords:l,contains:["self",r]},{begin:e.IDENT_RE+"::",keywords:l},{className:"class",beginKeywords:"class struct",end:/[{;:]/,contains:[{begin://,contains:["self"]},e.TITLE_MODE]}]),exports:{preprocessor:s,strings:a,keywords:l}}}}()); +hljs.registerLanguage("c",function(){"use strict";return function(e){var n=e.getLanguage("c-like").rawDefinition();return n.name="C",n.aliases=["c","h"],n}}()); +hljs.registerLanguage("coffeescript",function(){"use strict";const e=["as","in","of","if","for","while","finally","var","new","function","do","return","void","else","break","catch","instanceof","with","throw","case","default","try","switch","continue","typeof","delete","let","yield","const","class","debugger","async","await","static","import","from","export","extends"],n=["true","false","null","undefined","NaN","Infinity"],a=[].concat(["setInterval","setTimeout","clearInterval","clearTimeout","require","exports","eval","isFinite","isNaN","parseFloat","parseInt","decodeURI","decodeURIComponent","encodeURI","encodeURIComponent","escape","unescape"],["arguments","this","super","console","window","document","localStorage","module","global"],["Intl","DataView","Number","Math","Date","String","RegExp","Object","Function","Boolean","Error","Symbol","Set","Map","WeakSet","WeakMap","Proxy","Reflect","JSON","Promise","Float64Array","Int16Array","Int32Array","Int8Array","Uint16Array","Uint32Array","Float32Array","Array","Uint8Array","Uint8ClampedArray","ArrayBuffer"],["EvalError","InternalError","RangeError","ReferenceError","SyntaxError","TypeError","URIError"]);return function(r){var t={keyword:e.concat(["then","unless","until","loop","by","when","and","or","is","isnt","not"]).filter((e=>n=>!e.includes(n))(["var","const","let","function","static"])).join(" "),literal:n.concat(["yes","no","on","off"]).join(" "),built_in:a.concat(["npm","print"]).join(" ")},i="[A-Za-z$_][0-9A-Za-z$_]*",s={className:"subst",begin:/#\{/,end:/}/,keywords:t},o=[r.BINARY_NUMBER_MODE,r.inherit(r.C_NUMBER_MODE,{starts:{end:"(\\s*/)?",relevance:0}}),{className:"string",variants:[{begin:/'''/,end:/'''/,contains:[r.BACKSLASH_ESCAPE]},{begin:/'/,end:/'/,contains:[r.BACKSLASH_ESCAPE]},{begin:/"""/,end:/"""/,contains:[r.BACKSLASH_ESCAPE,s]},{begin:/"/,end:/"/,contains:[r.BACKSLASH_ESCAPE,s]}]},{className:"regexp",variants:[{begin:"///",end:"///",contains:[s,r.HASH_COMMENT_MODE]},{begin:"//[gim]{0,3}(?=\\W)",relevance:0},{begin:/\/(?![ *]).*?(?![\\]).\/[gim]{0,3}(?=\W)/}]},{begin:"@"+i},{subLanguage:"javascript",excludeBegin:!0,excludeEnd:!0,variants:[{begin:"```",end:"```"},{begin:"`",end:"`"}]}];s.contains=o;var c=r.inherit(r.TITLE_MODE,{begin:i}),l={className:"params",begin:"\\([^\\(]",returnBegin:!0,contains:[{begin:/\(/,end:/\)/,keywords:t,contains:["self"].concat(o)}]};return{name:"CoffeeScript",aliases:["coffee","cson","iced"],keywords:t,illegal:/\/\*/,contains:o.concat([r.COMMENT("###","###"),r.HASH_COMMENT_MODE,{className:"function",begin:"^\\s*"+i+"\\s*=\\s*(\\(.*\\))?\\s*\\B[-=]>",end:"[-=]>",returnBegin:!0,contains:[c,l]},{begin:/[:\(,=]\s*/,relevance:0,contains:[{className:"function",begin:"(\\(.*\\))?\\s*\\B[-=]>",end:"[-=]>",returnBegin:!0,contains:[l]}]},{className:"class",beginKeywords:"class",end:"$",illegal:/[:="\[\]]/,contains:[{beginKeywords:"extends",endsWithParent:!0,illegal:/[:="\[\]]/,contains:[c]},c]},{begin:i+":",end:":",returnBegin:!0,returnEnd:!0,relevance:0}])}}}()); +hljs.registerLanguage("cpp",function(){"use strict";return function(e){var t=e.getLanguage("c-like").rawDefinition();return t.disableAutodetect=!1,t.name="C++",t.aliases=["cc","c++","h++","hpp","hh","hxx","cxx"],t}}()); +hljs.registerLanguage("csharp",function(){"use strict";return function(e){var n={keyword:"abstract as base bool break byte case catch char checked const continue decimal default delegate do double enum event explicit extern finally fixed float for foreach goto if implicit in int interface internal is lock long object operator out override params private protected public readonly ref sbyte sealed short sizeof stackalloc static string struct switch this try typeof uint ulong unchecked unsafe ushort using virtual void volatile while add alias ascending async await by descending dynamic equals from get global group into join let nameof on orderby partial remove select set value var when where yield",literal:"null false true"},i=e.inherit(e.TITLE_MODE,{begin:"[a-zA-Z](\\.?\\w)*"}),a={className:"number",variants:[{begin:"\\b(0b[01']+)"},{begin:"(-?)\\b([\\d']+(\\.[\\d']*)?|\\.[\\d']+)(u|U|l|L|ul|UL|f|F|b|B)"},{begin:"(-?)(\\b0[xX][a-fA-F0-9']+|(\\b[\\d']+(\\.[\\d']*)?|\\.[\\d']+)([eE][-+]?[\\d']+)?)"}],relevance:0},s={className:"string",begin:'@"',end:'"',contains:[{begin:'""'}]},t=e.inherit(s,{illegal:/\n/}),l={className:"subst",begin:"{",end:"}",keywords:n},r=e.inherit(l,{illegal:/\n/}),c={className:"string",begin:/\$"/,end:'"',illegal:/\n/,contains:[{begin:"{{"},{begin:"}}"},e.BACKSLASH_ESCAPE,r]},o={className:"string",begin:/\$@"/,end:'"',contains:[{begin:"{{"},{begin:"}}"},{begin:'""'},l]},g=e.inherit(o,{illegal:/\n/,contains:[{begin:"{{"},{begin:"}}"},{begin:'""'},r]});l.contains=[o,c,s,e.APOS_STRING_MODE,e.QUOTE_STRING_MODE,a,e.C_BLOCK_COMMENT_MODE],r.contains=[g,c,t,e.APOS_STRING_MODE,e.QUOTE_STRING_MODE,a,e.inherit(e.C_BLOCK_COMMENT_MODE,{illegal:/\n/})];var d={variants:[o,c,s,e.APOS_STRING_MODE,e.QUOTE_STRING_MODE]},E={begin:"<",end:">",contains:[{beginKeywords:"in out"},i]},_=e.IDENT_RE+"(<"+e.IDENT_RE+"(\\s*,\\s*"+e.IDENT_RE+")*>)?(\\[\\])?",b={begin:"@"+e.IDENT_RE,relevance:0};return{name:"C#",aliases:["cs","c#"],keywords:n,illegal:/::/,contains:[e.COMMENT("///","$",{returnBegin:!0,contains:[{className:"doctag",variants:[{begin:"///",relevance:0},{begin:"\x3c!--|--\x3e"},{begin:""}]}]}),e.C_LINE_COMMENT_MODE,e.C_BLOCK_COMMENT_MODE,{className:"meta",begin:"#",end:"$",keywords:{"meta-keyword":"if else elif endif define undef warning error line region endregion pragma checksum"}},d,a,{beginKeywords:"class interface",end:/[{;=]/,illegal:/[^\s:,]/,contains:[{beginKeywords:"where class"},i,E,e.C_LINE_COMMENT_MODE,e.C_BLOCK_COMMENT_MODE]},{beginKeywords:"namespace",end:/[{;=]/,illegal:/[^\s:]/,contains:[i,e.C_LINE_COMMENT_MODE,e.C_BLOCK_COMMENT_MODE]},{className:"meta",begin:"^\\s*\\[",excludeBegin:!0,end:"\\]",excludeEnd:!0,contains:[{className:"meta-string",begin:/"/,end:/"/}]},{beginKeywords:"new return throw await else",relevance:0},{className:"function",begin:"("+_+"\\s+)+"+e.IDENT_RE+"\\s*(\\<.+\\>)?\\s*\\(",returnBegin:!0,end:/\s*[{;=]/,excludeEnd:!0,keywords:n,contains:[{begin:e.IDENT_RE+"\\s*(\\<.+\\>)?\\s*\\(",returnBegin:!0,contains:[e.TITLE_MODE,E],relevance:0},{className:"params",begin:/\(/,end:/\)/,excludeBegin:!0,excludeEnd:!0,keywords:n,relevance:0,contains:[d,a,e.C_BLOCK_COMMENT_MODE]},e.C_LINE_COMMENT_MODE,e.C_BLOCK_COMMENT_MODE]},b]}}}()); +hljs.registerLanguage("css",function(){"use strict";return function(e){var n={begin:/(?:[A-Z\_\.\-]+|--[a-zA-Z0-9_-]+)\s*:/,returnBegin:!0,end:";",endsWithParent:!0,contains:[{className:"attribute",begin:/\S/,end:":",excludeEnd:!0,starts:{endsWithParent:!0,excludeEnd:!0,contains:[{begin:/[\w-]+\(/,returnBegin:!0,contains:[{className:"built_in",begin:/[\w-]+/},{begin:/\(/,end:/\)/,contains:[e.APOS_STRING_MODE,e.QUOTE_STRING_MODE,e.CSS_NUMBER_MODE]}]},e.CSS_NUMBER_MODE,e.QUOTE_STRING_MODE,e.APOS_STRING_MODE,e.C_BLOCK_COMMENT_MODE,{className:"number",begin:"#[0-9A-Fa-f]+"},{className:"meta",begin:"!important"}]}}]};return{name:"CSS",case_insensitive:!0,illegal:/[=\/|'\$]/,contains:[e.C_BLOCK_COMMENT_MODE,{className:"selector-id",begin:/#[A-Za-z0-9_-]+/},{className:"selector-class",begin:/\.[A-Za-z0-9_-]+/},{className:"selector-attr",begin:/\[/,end:/\]/,illegal:"$",contains:[e.APOS_STRING_MODE,e.QUOTE_STRING_MODE]},{className:"selector-pseudo",begin:/:(:)?[a-zA-Z0-9\_\-\+\(\)"'.]+/},{begin:"@(page|font-face)",lexemes:"@[a-z-]+",keywords:"@page @font-face"},{begin:"@",end:"[{;]",illegal:/:/,returnBegin:!0,contains:[{className:"keyword",begin:/@\-?\w[\w]*(\-\w+)*/},{begin:/\s/,endsWithParent:!0,excludeEnd:!0,relevance:0,keywords:"and or not only",contains:[{begin:/[a-z-]+:/,className:"attribute"},e.APOS_STRING_MODE,e.QUOTE_STRING_MODE,e.CSS_NUMBER_MODE]}]},{className:"selector-tag",begin:"[a-zA-Z-][a-zA-Z0-9_-]*",relevance:0},{begin:"{",end:"}",illegal:/\S/,contains:[e.C_BLOCK_COMMENT_MODE,n]}]}}}()); +hljs.registerLanguage("diff",function(){"use strict";return function(e){return{name:"Diff",aliases:["patch"],contains:[{className:"meta",relevance:10,variants:[{begin:/^@@ +\-\d+,\d+ +\+\d+,\d+ +@@$/},{begin:/^\*\*\* +\d+,\d+ +\*\*\*\*$/},{begin:/^\-\-\- +\d+,\d+ +\-\-\-\-$/}]},{className:"comment",variants:[{begin:/Index: /,end:/$/},{begin:/={3,}/,end:/$/},{begin:/^\-{3}/,end:/$/},{begin:/^\*{3} /,end:/$/},{begin:/^\+{3}/,end:/$/},{begin:/^\*{15}$/}]},{className:"addition",begin:"^\\+",end:"$"},{className:"deletion",begin:"^\\-",end:"$"},{className:"addition",begin:"^\\!",end:"$"}]}}}()); +hljs.registerLanguage("go",function(){"use strict";return function(e){var n={keyword:"break default func interface select case map struct chan else goto package switch const fallthrough if range type continue for import return var go defer bool byte complex64 complex128 float32 float64 int8 int16 int32 int64 string uint8 uint16 uint32 uint64 int uint uintptr rune",literal:"true false iota nil",built_in:"append cap close complex copy imag len make new panic print println real recover delete"};return{name:"Go",aliases:["golang"],keywords:n,illegal:"e(n)).join("")}return function(a){var s={className:"number",relevance:0,variants:[{begin:/([\+\-]+)?[\d]+_[\d_]+/},{begin:a.NUMBER_RE}]},i=a.COMMENT();i.variants=[{begin:/;/,end:/$/},{begin:/#/,end:/$/}];var t={className:"variable",variants:[{begin:/\$[\w\d"][\w\d_]*/},{begin:/\$\{(.*?)}/}]},r={className:"literal",begin:/\bon|off|true|false|yes|no\b/},l={className:"string",contains:[a.BACKSLASH_ESCAPE],variants:[{begin:"'''",end:"'''",relevance:10},{begin:'"""',end:'"""',relevance:10},{begin:'"',end:'"'},{begin:"'",end:"'"}]},c={begin:/\[/,end:/\]/,contains:[i,r,t,l,s,"self"],relevance:0},g="("+[/[A-Za-z0-9_-]+/,/"(\\"|[^"])*"/,/'[^']*'/].map(n=>e(n)).join("|")+")";return{name:"TOML, also INI",aliases:["toml"],case_insensitive:!0,illegal:/\S/,contains:[i,{className:"section",begin:/\[+/,end:/\]+/},{begin:n(g,"(\\s*\\.\\s*",g,")*",n("(?=",/\s*=\s*[^#\s]/,")")),className:"attr",starts:{end:/$/,contains:[i,c,r,t,l,s]}}]}}}()); +hljs.registerLanguage("java",function(){"use strict";function e(e){return e?"string"==typeof e?e:e.source:null}function n(e){return a("(",e,")?")}function a(...n){return n.map(n=>e(n)).join("")}function s(...n){return"("+n.map(n=>e(n)).join("|")+")"}return function(e){var t="false synchronized int abstract float private char boolean var static null if const for true while long strictfp finally protected import native final void enum else break transient catch instanceof byte super volatile case assert short package default double public try this switch continue throws protected public private module requires exports do",i={className:"meta",begin:"@[À-ʸa-zA-Z_$][À-ʸa-zA-Z_$0-9]*",contains:[{begin:/\(/,end:/\)/,contains:["self"]}]},r=e=>a("[",e,"]+([",e,"_]*[",e,"]+)?"),c={className:"number",variants:[{begin:`\\b(0[bB]${r("01")})[lL]?`},{begin:`\\b(0${r("0-7")})[dDfFlL]?`},{begin:a(/\b0[xX]/,s(a(r("a-fA-F0-9"),/\./,r("a-fA-F0-9")),a(r("a-fA-F0-9"),/\.?/),a(/\./,r("a-fA-F0-9"))),/([pP][+-]?(\d+))?/,/[fFdDlL]?/)},{begin:a(/\b/,s(a(/\d*\./,r("\\d")),r("\\d")),/[eE][+-]?[\d]+[dDfF]?/)},{begin:a(/\b/,r(/\d/),n(/\.?/),n(r(/\d/)),/[dDfFlL]?/)}],relevance:0};return{name:"Java",aliases:["jsp"],keywords:t,illegal:/<\/|#/,contains:[e.COMMENT("/\\*\\*","\\*/",{relevance:0,contains:[{begin:/\w+@/,relevance:0},{className:"doctag",begin:"@[A-Za-z]+"}]}),e.C_LINE_COMMENT_MODE,e.C_BLOCK_COMMENT_MODE,e.APOS_STRING_MODE,e.QUOTE_STRING_MODE,{className:"class",beginKeywords:"class interface",end:/[{;=]/,excludeEnd:!0,keywords:"class interface",illegal:/[:"\[\]]/,contains:[{beginKeywords:"extends implements"},e.UNDERSCORE_TITLE_MODE]},{beginKeywords:"new throw return else",relevance:0},{className:"function",begin:"([À-ʸa-zA-Z_$][À-ʸa-zA-Z_$0-9]*(<[À-ʸa-zA-Z_$][À-ʸa-zA-Z_$0-9]*(\\s*,\\s*[À-ʸa-zA-Z_$][À-ʸa-zA-Z_$0-9]*)*>)?\\s+)+"+e.UNDERSCORE_IDENT_RE+"\\s*\\(",returnBegin:!0,end:/[{;=]/,excludeEnd:!0,keywords:t,contains:[{begin:e.UNDERSCORE_IDENT_RE+"\\s*\\(",returnBegin:!0,relevance:0,contains:[e.UNDERSCORE_TITLE_MODE]},{className:"params",begin:/\(/,end:/\)/,keywords:t,relevance:0,contains:[i,e.APOS_STRING_MODE,e.QUOTE_STRING_MODE,e.C_NUMBER_MODE,e.C_BLOCK_COMMENT_MODE]},e.C_LINE_COMMENT_MODE,e.C_BLOCK_COMMENT_MODE]},c,i]}}}()); +hljs.registerLanguage("javascript",function(){"use strict";const e=["as","in","of","if","for","while","finally","var","new","function","do","return","void","else","break","catch","instanceof","with","throw","case","default","try","switch","continue","typeof","delete","let","yield","const","class","debugger","async","await","static","import","from","export","extends"],n=["true","false","null","undefined","NaN","Infinity"],a=[].concat(["setInterval","setTimeout","clearInterval","clearTimeout","require","exports","eval","isFinite","isNaN","parseFloat","parseInt","decodeURI","decodeURIComponent","encodeURI","encodeURIComponent","escape","unescape"],["arguments","this","super","console","window","document","localStorage","module","global"],["Intl","DataView","Number","Math","Date","String","RegExp","Object","Function","Boolean","Error","Symbol","Set","Map","WeakSet","WeakMap","Proxy","Reflect","JSON","Promise","Float64Array","Int16Array","Int32Array","Int8Array","Uint16Array","Uint32Array","Float32Array","Array","Uint8Array","Uint8ClampedArray","ArrayBuffer"],["EvalError","InternalError","RangeError","ReferenceError","SyntaxError","TypeError","URIError"]);function s(e){return r("(?=",e,")")}function r(...e){return e.map(e=>(function(e){return e?"string"==typeof e?e:e.source:null})(e)).join("")}return function(t){var i="[A-Za-z$_][0-9A-Za-z$_]*",c={begin:/<[A-Za-z0-9\\._:-]+/,end:/\/[A-Za-z0-9\\._:-]+>|\/>/},o={$pattern:"[A-Za-z$_][0-9A-Za-z$_]*",keyword:e.join(" "),literal:n.join(" "),built_in:a.join(" ")},l={className:"number",variants:[{begin:"\\b(0[bB][01]+)n?"},{begin:"\\b(0[oO][0-7]+)n?"},{begin:t.C_NUMBER_RE+"n?"}],relevance:0},E={className:"subst",begin:"\\$\\{",end:"\\}",keywords:o,contains:[]},d={begin:"html`",end:"",starts:{end:"`",returnEnd:!1,contains:[t.BACKSLASH_ESCAPE,E],subLanguage:"xml"}},g={begin:"css`",end:"",starts:{end:"`",returnEnd:!1,contains:[t.BACKSLASH_ESCAPE,E],subLanguage:"css"}},u={className:"string",begin:"`",end:"`",contains:[t.BACKSLASH_ESCAPE,E]};E.contains=[t.APOS_STRING_MODE,t.QUOTE_STRING_MODE,d,g,u,l,t.REGEXP_MODE];var b=E.contains.concat([{begin:/\(/,end:/\)/,contains:["self"].concat(E.contains,[t.C_BLOCK_COMMENT_MODE,t.C_LINE_COMMENT_MODE])},t.C_BLOCK_COMMENT_MODE,t.C_LINE_COMMENT_MODE]),_={className:"params",begin:/\(/,end:/\)/,excludeBegin:!0,excludeEnd:!0,contains:b};return{name:"JavaScript",aliases:["js","jsx","mjs","cjs"],keywords:o,contains:[t.SHEBANG({binary:"node",relevance:5}),{className:"meta",relevance:10,begin:/^\s*['"]use (strict|asm)['"]/},t.APOS_STRING_MODE,t.QUOTE_STRING_MODE,d,g,u,t.C_LINE_COMMENT_MODE,t.COMMENT("/\\*\\*","\\*/",{relevance:0,contains:[{className:"doctag",begin:"@[A-Za-z]+",contains:[{className:"type",begin:"\\{",end:"\\}",relevance:0},{className:"variable",begin:i+"(?=\\s*(-)|$)",endsParent:!0,relevance:0},{begin:/(?=[^\n])\s/,relevance:0}]}]}),t.C_BLOCK_COMMENT_MODE,l,{begin:r(/[{,\n]\s*/,s(r(/(((\/\/.*)|(\/\*(.|\n)*\*\/))\s*)*/,i+"\\s*:"))),relevance:0,contains:[{className:"attr",begin:i+s("\\s*:"),relevance:0}]},{begin:"("+t.RE_STARTERS_RE+"|\\b(case|return|throw)\\b)\\s*",keywords:"return throw case",contains:[t.C_LINE_COMMENT_MODE,t.C_BLOCK_COMMENT_MODE,t.REGEXP_MODE,{className:"function",begin:"(\\([^(]*(\\([^(]*(\\([^(]*\\))?\\))?\\)|"+t.UNDERSCORE_IDENT_RE+")\\s*=>",returnBegin:!0,end:"\\s*=>",contains:[{className:"params",variants:[{begin:t.UNDERSCORE_IDENT_RE},{className:null,begin:/\(\s*\)/,skip:!0},{begin:/\(/,end:/\)/,excludeBegin:!0,excludeEnd:!0,keywords:o,contains:b}]}]},{begin:/,/,relevance:0},{className:"",begin:/\s/,end:/\s*/,skip:!0},{variants:[{begin:"<>",end:""},{begin:c.begin,end:c.end}],subLanguage:"xml",contains:[{begin:c.begin,end:c.end,skip:!0,contains:["self"]}]}],relevance:0},{className:"function",beginKeywords:"function",end:/\{/,excludeEnd:!0,contains:[t.inherit(t.TITLE_MODE,{begin:i}),_],illegal:/\[|%/},{begin:/\$[(.]/},t.METHOD_GUARD,{className:"class",beginKeywords:"class",end:/[{;=]/,excludeEnd:!0,illegal:/[:"\[\]]/,contains:[{beginKeywords:"extends"},t.UNDERSCORE_TITLE_MODE]},{beginKeywords:"constructor",end:/\{/,excludeEnd:!0},{begin:"(get|set)\\s+(?="+i+"\\()",end:/{/,keywords:"get set",contains:[t.inherit(t.TITLE_MODE,{begin:i}),{begin:/\(\)/},_]}],illegal:/#(?!!)/}}}()); +hljs.registerLanguage("json",function(){"use strict";return function(n){var e={literal:"true false null"},i=[n.C_LINE_COMMENT_MODE,n.C_BLOCK_COMMENT_MODE],t=[n.QUOTE_STRING_MODE,n.C_NUMBER_MODE],a={end:",",endsWithParent:!0,excludeEnd:!0,contains:t,keywords:e},l={begin:"{",end:"}",contains:[{className:"attr",begin:/"/,end:/"/,contains:[n.BACKSLASH_ESCAPE],illegal:"\\n"},n.inherit(a,{begin:/:/})].concat(i),illegal:"\\S"},s={begin:"\\[",end:"\\]",contains:[n.inherit(a)],illegal:"\\S"};return t.push(l,s),i.forEach((function(n){t.push(n)})),{name:"JSON",contains:t,keywords:e,illegal:"\\S"}}}()); +hljs.registerLanguage("kotlin",function(){"use strict";return function(e){var n={keyword:"abstract as val var vararg get set class object open private protected public noinline crossinline dynamic final enum if else do while for when throw try catch finally import package is in fun override companion reified inline lateinit init interface annotation data sealed internal infix operator out by constructor super tailrec where const inner suspend typealias external expect actual trait volatile transient native default",built_in:"Byte Short Char Int Long Boolean Float Double Void Unit Nothing",literal:"true false null"},a={className:"symbol",begin:e.UNDERSCORE_IDENT_RE+"@"},i={className:"subst",begin:"\\${",end:"}",contains:[e.C_NUMBER_MODE]},s={className:"variable",begin:"\\$"+e.UNDERSCORE_IDENT_RE},t={className:"string",variants:[{begin:'"""',end:'"""(?=[^"])',contains:[s,i]},{begin:"'",end:"'",illegal:/\n/,contains:[e.BACKSLASH_ESCAPE]},{begin:'"',end:'"',illegal:/\n/,contains:[e.BACKSLASH_ESCAPE,s,i]}]};i.contains.push(t);var r={className:"meta",begin:"@(?:file|property|field|get|set|receiver|param|setparam|delegate)\\s*:(?:\\s*"+e.UNDERSCORE_IDENT_RE+")?"},l={className:"meta",begin:"@"+e.UNDERSCORE_IDENT_RE,contains:[{begin:/\(/,end:/\)/,contains:[e.inherit(t,{className:"meta-string"})]}]},c=e.COMMENT("/\\*","\\*/",{contains:[e.C_BLOCK_COMMENT_MODE]}),o={variants:[{className:"type",begin:e.UNDERSCORE_IDENT_RE},{begin:/\(/,end:/\)/,contains:[]}]},d=o;return d.variants[1].contains=[o],o.variants[1].contains=[d],{name:"Kotlin",aliases:["kt"],keywords:n,contains:[e.COMMENT("/\\*\\*","\\*/",{relevance:0,contains:[{className:"doctag",begin:"@[A-Za-z]+"}]}),e.C_LINE_COMMENT_MODE,c,{className:"keyword",begin:/\b(break|continue|return|this)\b/,starts:{contains:[{className:"symbol",begin:/@\w+/}]}},a,r,l,{className:"function",beginKeywords:"fun",end:"[(]|$",returnBegin:!0,excludeEnd:!0,keywords:n,illegal:/fun\s+(<.*>)?[^\s\(]+(\s+[^\s\(]+)\s*=/,relevance:5,contains:[{begin:e.UNDERSCORE_IDENT_RE+"\\s*\\(",returnBegin:!0,relevance:0,contains:[e.UNDERSCORE_TITLE_MODE]},{className:"type",begin://,keywords:"reified",relevance:0},{className:"params",begin:/\(/,end:/\)/,endsParent:!0,keywords:n,relevance:0,contains:[{begin:/:/,end:/[=,\/]/,endsWithParent:!0,contains:[o,e.C_LINE_COMMENT_MODE,c],relevance:0},e.C_LINE_COMMENT_MODE,c,r,l,t,e.C_NUMBER_MODE]},c]},{className:"class",beginKeywords:"class interface trait",end:/[:\{(]|$/,excludeEnd:!0,illegal:"extends implements",contains:[{beginKeywords:"public protected internal private constructor"},e.UNDERSCORE_TITLE_MODE,{className:"type",begin://,excludeBegin:!0,excludeEnd:!0,relevance:0},{className:"type",begin:/[,:]\s*/,end:/[<\(,]|$/,excludeBegin:!0,returnEnd:!0},r,l]},t,{className:"meta",begin:"^#!/usr/bin/env",end:"$",illegal:"\n"},{className:"number",begin:"\\b(0[bB]([01]+[01_]+[01]+|[01]+)|0[xX]([a-fA-F0-9]+[a-fA-F0-9_]+[a-fA-F0-9]+|[a-fA-F0-9]+)|(([\\d]+[\\d_]+[\\d]+|[\\d]+)(\\.([\\d]+[\\d_]+[\\d]+|[\\d]+))?|\\.([\\d]+[\\d_]+[\\d]+|[\\d]+))([eE][-+]?\\d+)?)[lLfF]?",relevance:0}]}}}()); +hljs.registerLanguage("less",function(){"use strict";return function(e){var n="([\\w-]+|@{[\\w-]+})",a=[],s=[],t=function(e){return{className:"string",begin:"~?"+e+".*?"+e}},r=function(e,n,a){return{className:e,begin:n,relevance:a}},i={begin:"\\(",end:"\\)",contains:s,relevance:0};s.push(e.C_LINE_COMMENT_MODE,e.C_BLOCK_COMMENT_MODE,t("'"),t('"'),e.CSS_NUMBER_MODE,{begin:"(url|data-uri)\\(",starts:{className:"string",end:"[\\)\\n]",excludeEnd:!0}},r("number","#[0-9A-Fa-f]+\\b"),i,r("variable","@@?[\\w-]+",10),r("variable","@{[\\w-]+}"),r("built_in","~?`[^`]*?`"),{className:"attribute",begin:"[\\w-]+\\s*:",end:":",returnBegin:!0,excludeEnd:!0},{className:"meta",begin:"!important"});var c=s.concat({begin:"{",end:"}",contains:a}),l={beginKeywords:"when",endsWithParent:!0,contains:[{beginKeywords:"and not"}].concat(s)},o={begin:n+"\\s*:",returnBegin:!0,end:"[;}]",relevance:0,contains:[{className:"attribute",begin:n,end:":",excludeEnd:!0,starts:{endsWithParent:!0,illegal:"[<=$]",relevance:0,contains:s}}]},g={className:"keyword",begin:"@(import|media|charset|font-face|(-[a-z]+-)?keyframes|supports|document|namespace|page|viewport|host)\\b",starts:{end:"[;{}]",returnEnd:!0,contains:s,relevance:0}},d={className:"variable",variants:[{begin:"@[\\w-]+\\s*:",relevance:15},{begin:"@[\\w-]+"}],starts:{end:"[;}]",returnEnd:!0,contains:c}},b={variants:[{begin:"[\\.#:&\\[>]",end:"[;{}]"},{begin:n,end:"{"}],returnBegin:!0,returnEnd:!0,illegal:"[<='$\"]",relevance:0,contains:[e.C_LINE_COMMENT_MODE,e.C_BLOCK_COMMENT_MODE,l,r("keyword","all\\b"),r("variable","@{[\\w-]+}"),r("selector-tag",n+"%?",0),r("selector-id","#"+n),r("selector-class","\\."+n,0),r("selector-tag","&",0),{className:"selector-attr",begin:"\\[",end:"\\]"},{className:"selector-pseudo",begin:/:(:)?[a-zA-Z0-9\_\-\+\(\)"'.]+/},{begin:"\\(",end:"\\)",contains:c},{begin:"!important"}]};return a.push(e.C_LINE_COMMENT_MODE,e.C_BLOCK_COMMENT_MODE,g,d,o,b),{name:"Less",case_insensitive:!0,illegal:"[=>'/<($\"]",contains:a}}}()); +hljs.registerLanguage("lua",function(){"use strict";return function(e){var t={begin:"\\[=*\\[",end:"\\]=*\\]",contains:["self"]},a=[e.COMMENT("--(?!\\[=*\\[)","$"),e.COMMENT("--\\[=*\\[","\\]=*\\]",{contains:[t],relevance:10})];return{name:"Lua",keywords:{$pattern:e.UNDERSCORE_IDENT_RE,literal:"true false nil",keyword:"and break do else elseif end for goto if in local not or repeat return then until while",built_in:"_G _ENV _VERSION __index __newindex __mode __call __metatable __tostring __len __gc __add __sub __mul __div __mod __pow __concat __unm __eq __lt __le assert collectgarbage dofile error getfenv getmetatable ipairs load loadfile loadstring module next pairs pcall print rawequal rawget rawset require select setfenv setmetatable tonumber tostring type unpack xpcall arg self coroutine resume yield status wrap create running debug getupvalue debug sethook getmetatable gethook setmetatable setlocal traceback setfenv getinfo setupvalue getlocal getregistry getfenv io lines write close flush open output type read stderr stdin input stdout popen tmpfile math log max acos huge ldexp pi cos tanh pow deg tan cosh sinh random randomseed frexp ceil floor rad abs sqrt modf asin min mod fmod log10 atan2 exp sin atan os exit setlocale date getenv difftime remove time clock tmpname rename execute package preload loadlib loaded loaders cpath config path seeall string sub upper len gfind rep find match char dump gmatch reverse byte format gsub lower table setn insert getn foreachi maxn foreach concat sort remove"},contains:a.concat([{className:"function",beginKeywords:"function",end:"\\)",contains:[e.inherit(e.TITLE_MODE,{begin:"([_a-zA-Z]\\w*\\.)*([_a-zA-Z]\\w*:)?[_a-zA-Z]\\w*"}),{className:"params",begin:"\\(",endsWithParent:!0,contains:a}].concat(a)},e.C_NUMBER_MODE,e.APOS_STRING_MODE,e.QUOTE_STRING_MODE,{className:"string",begin:"\\[=*\\[",end:"\\]=*\\]",contains:[t],relevance:5}])}}}()); +hljs.registerLanguage("makefile",function(){"use strict";return function(e){var i={className:"variable",variants:[{begin:"\\$\\("+e.UNDERSCORE_IDENT_RE+"\\)",contains:[e.BACKSLASH_ESCAPE]},{begin:/\$[@%`]+/}]}]}]};return{name:"HTML, XML",aliases:["html","xhtml","rss","atom","xjb","xsd","xsl","plist","wsf","svg"],case_insensitive:!0,contains:[{className:"meta",begin:"",relevance:10,contains:[a,i,t,s,{begin:"\\[",end:"\\]",contains:[{className:"meta",begin:"",contains:[a,s,i,t]}]}]},e.COMMENT("\x3c!--","--\x3e",{relevance:10}),{begin:"<\\!\\[CDATA\\[",end:"\\]\\]>",relevance:10},n,{className:"meta",begin:/<\?xml/,end:/\?>/,relevance:10},{className:"tag",begin:")",end:">",keywords:{name:"style"},contains:[c],starts:{end:"",returnEnd:!0,subLanguage:["css","xml"]}},{className:"tag",begin:")",end:">",keywords:{name:"script"},contains:[c],starts:{end:"<\/script>",returnEnd:!0,subLanguage:["javascript","handlebars","xml"]}},{className:"tag",begin:"",contains:[{className:"name",begin:/[^\/><\s]+/,relevance:0},c]}]}}}()); +hljs.registerLanguage("markdown",function(){"use strict";return function(n){const e={begin:"<",end:">",subLanguage:"xml",relevance:0},a={begin:"\\[.+?\\][\\(\\[].*?[\\)\\]]",returnBegin:!0,contains:[{className:"string",begin:"\\[",end:"\\]",excludeBegin:!0,returnEnd:!0,relevance:0},{className:"link",begin:"\\]\\(",end:"\\)",excludeBegin:!0,excludeEnd:!0},{className:"symbol",begin:"\\]\\[",end:"\\]",excludeBegin:!0,excludeEnd:!0}],relevance:10},i={className:"strong",contains:[],variants:[{begin:/_{2}/,end:/_{2}/},{begin:/\*{2}/,end:/\*{2}/}]},s={className:"emphasis",contains:[],variants:[{begin:/\*(?!\*)/,end:/\*/},{begin:/_(?!_)/,end:/_/,relevance:0}]};i.contains.push(s),s.contains.push(i);var c=[e,a];return i.contains=i.contains.concat(c),s.contains=s.contains.concat(c),{name:"Markdown",aliases:["md","mkdown","mkd"],contains:[{className:"section",variants:[{begin:"^#{1,6}",end:"$",contains:c=c.concat(i,s)},{begin:"(?=^.+?\\n[=-]{2,}$)",contains:[{begin:"^[=-]*$"},{begin:"^",end:"\\n",contains:c}]}]},e,{className:"bullet",begin:"^[ \t]*([*+-]|(\\d+\\.))(?=\\s+)",end:"\\s+",excludeEnd:!0},i,s,{className:"quote",begin:"^>\\s+",contains:c,end:"$"},{className:"code",variants:[{begin:"(`{3,})(.|\\n)*?\\1`*[ ]*"},{begin:"(~{3,})(.|\\n)*?\\1~*[ ]*"},{begin:"```",end:"```+[ ]*$"},{begin:"~~~",end:"~~~+[ ]*$"},{begin:"`.+?`"},{begin:"(?=^( {4}|\\t))",contains:[{begin:"^( {4}|\\t)",end:"(\\n)$"}],relevance:0}]},{begin:"^[-\\*]{3,}",end:"$"},a,{begin:/^\[[^\n]+\]:/,returnBegin:!0,contains:[{className:"symbol",begin:/\[/,end:/\]/,excludeBegin:!0,excludeEnd:!0},{className:"link",begin:/:\s*/,end:/$/,excludeBegin:!0}]}]}}}()); +hljs.registerLanguage("nginx",function(){"use strict";return function(e){var n={className:"variable",variants:[{begin:/\$\d+/},{begin:/\$\{/,end:/}/},{begin:"[\\$\\@]"+e.UNDERSCORE_IDENT_RE}]},a={endsWithParent:!0,keywords:{$pattern:"[a-z/_]+",literal:"on off yes no true false none blocked debug info notice warn error crit select break last permanent redirect kqueue rtsig epoll poll /dev/poll"},relevance:0,illegal:"=>",contains:[e.HASH_COMMENT_MODE,{className:"string",contains:[e.BACKSLASH_ESCAPE,n],variants:[{begin:/"/,end:/"/},{begin:/'/,end:/'/}]},{begin:"([a-z]+):/",end:"\\s",endsWithParent:!0,excludeEnd:!0,contains:[n]},{className:"regexp",contains:[e.BACKSLASH_ESCAPE,n],variants:[{begin:"\\s\\^",end:"\\s|{|;",returnEnd:!0},{begin:"~\\*?\\s+",end:"\\s|{|;",returnEnd:!0},{begin:"\\*(\\.[a-z\\-]+)+"},{begin:"([a-z\\-]+\\.)+\\*"}]},{className:"number",begin:"\\b\\d{1,3}\\.\\d{1,3}\\.\\d{1,3}\\.\\d{1,3}(:\\d{1,5})?\\b"},{className:"number",begin:"\\b\\d+[kKmMgGdshdwy]*\\b",relevance:0},n]};return{name:"Nginx config",aliases:["nginxconf"],contains:[e.HASH_COMMENT_MODE,{begin:e.UNDERSCORE_IDENT_RE+"\\s+{",returnBegin:!0,end:"{",contains:[{className:"section",begin:e.UNDERSCORE_IDENT_RE}],relevance:0},{begin:e.UNDERSCORE_IDENT_RE+"\\s",end:";|{",returnBegin:!0,contains:[{className:"attribute",begin:e.UNDERSCORE_IDENT_RE,starts:a}],relevance:0}],illegal:"[^\\s\\}]"}}}()); +hljs.registerLanguage("objectivec",function(){"use strict";return function(e){var n=/[a-zA-Z@][a-zA-Z0-9_]*/,_={$pattern:n,keyword:"@interface @class @protocol @implementation"};return{name:"Objective-C",aliases:["mm","objc","obj-c"],keywords:{$pattern:n,keyword:"int float while char export sizeof typedef const struct for union unsigned long volatile static bool mutable if do return goto void enum else break extern asm case short default double register explicit signed typename this switch continue wchar_t inline readonly assign readwrite self @synchronized id typeof nonatomic super unichar IBOutlet IBAction strong weak copy in out inout bycopy byref oneway __strong __weak __block __autoreleasing @private @protected @public @try @property @end @throw @catch @finally @autoreleasepool @synthesize @dynamic @selector @optional @required @encode @package @import @defs @compatibility_alias __bridge __bridge_transfer __bridge_retained __bridge_retain __covariant __contravariant __kindof _Nonnull _Nullable _Null_unspecified __FUNCTION__ __PRETTY_FUNCTION__ __attribute__ getter setter retain unsafe_unretained nonnull nullable null_unspecified null_resettable class instancetype NS_DESIGNATED_INITIALIZER NS_UNAVAILABLE NS_REQUIRES_SUPER NS_RETURNS_INNER_POINTER NS_INLINE NS_AVAILABLE NS_DEPRECATED NS_ENUM NS_OPTIONS NS_SWIFT_UNAVAILABLE NS_ASSUME_NONNULL_BEGIN NS_ASSUME_NONNULL_END NS_REFINED_FOR_SWIFT NS_SWIFT_NAME NS_SWIFT_NOTHROW NS_DURING NS_HANDLER NS_ENDHANDLER NS_VALUERETURN NS_VOIDRETURN",literal:"false true FALSE TRUE nil YES NO NULL",built_in:"BOOL dispatch_once_t dispatch_queue_t dispatch_sync dispatch_async dispatch_once"},illegal:"/,end:/$/,illegal:"\\n"},e.C_LINE_COMMENT_MODE,e.C_BLOCK_COMMENT_MODE]},{className:"class",begin:"("+_.keyword.split(" ").join("|")+")\\b",end:"({|$)",excludeEnd:!0,keywords:_,contains:[e.UNDERSCORE_TITLE_MODE]},{begin:"\\."+e.UNDERSCORE_IDENT_RE,relevance:0}]}}}()); +hljs.registerLanguage("perl",function(){"use strict";return function(e){var n={$pattern:/[\w.]+/,keyword:"getpwent getservent quotemeta msgrcv scalar kill dbmclose undef lc ma syswrite tr send umask sysopen shmwrite vec qx utime local oct semctl localtime readpipe do return format read sprintf dbmopen pop getpgrp not getpwnam rewinddir qq fileno qw endprotoent wait sethostent bless s|0 opendir continue each sleep endgrent shutdown dump chomp connect getsockname die socketpair close flock exists index shmget sub for endpwent redo lstat msgctl setpgrp abs exit select print ref gethostbyaddr unshift fcntl syscall goto getnetbyaddr join gmtime symlink semget splice x|0 getpeername recv log setsockopt cos last reverse gethostbyname getgrnam study formline endhostent times chop length gethostent getnetent pack getprotoent getservbyname rand mkdir pos chmod y|0 substr endnetent printf next open msgsnd readdir use unlink getsockopt getpriority rindex wantarray hex system getservbyport endservent int chr untie rmdir prototype tell listen fork shmread ucfirst setprotoent else sysseek link getgrgid shmctl waitpid unpack getnetbyname reset chdir grep split require caller lcfirst until warn while values shift telldir getpwuid my getprotobynumber delete and sort uc defined srand accept package seekdir getprotobyname semop our rename seek if q|0 chroot sysread setpwent no crypt getc chown sqrt write setnetent setpriority foreach tie sin msgget map stat getlogin unless elsif truncate exec keys glob tied closedir ioctl socket readlink eval xor readline binmode setservent eof ord bind alarm pipe atan2 getgrent exp time push setgrent gt lt or ne m|0 break given say state when"},t={className:"subst",begin:"[$@]\\{",end:"\\}",keywords:n},s={begin:"->{",end:"}"},r={variants:[{begin:/\$\d/},{begin:/[\$%@](\^\w\b|#\w+(::\w+)*|{\w+}|\w+(::\w*)*)/},{begin:/[\$%@][^\s\w{]/,relevance:0}]},i=[e.BACKSLASH_ESCAPE,t,r],a=[r,e.HASH_COMMENT_MODE,e.COMMENT("^\\=\\w","\\=cut",{endsWithParent:!0}),s,{className:"string",contains:i,variants:[{begin:"q[qwxr]?\\s*\\(",end:"\\)",relevance:5},{begin:"q[qwxr]?\\s*\\[",end:"\\]",relevance:5},{begin:"q[qwxr]?\\s*\\{",end:"\\}",relevance:5},{begin:"q[qwxr]?\\s*\\|",end:"\\|",relevance:5},{begin:"q[qwxr]?\\s*\\<",end:"\\>",relevance:5},{begin:"qw\\s+q",end:"q",relevance:5},{begin:"'",end:"'",contains:[e.BACKSLASH_ESCAPE]},{begin:'"',end:'"'},{begin:"`",end:"`",contains:[e.BACKSLASH_ESCAPE]},{begin:"{\\w+}",contains:[],relevance:0},{begin:"-?\\w+\\s*\\=\\>",contains:[],relevance:0}]},{className:"number",begin:"(\\b0[0-7_]+)|(\\b0x[0-9a-fA-F_]+)|(\\b[1-9][0-9_]*(\\.[0-9_]+)?)|[0_]\\b",relevance:0},{begin:"(\\/\\/|"+e.RE_STARTERS_RE+"|\\b(split|return|print|reverse|grep)\\b)\\s*",keywords:"split return print reverse grep",relevance:0,contains:[e.HASH_COMMENT_MODE,{className:"regexp",begin:"(s|tr|y)/(\\\\.|[^/])*/(\\\\.|[^/])*/[a-z]*",relevance:10},{className:"regexp",begin:"(m|qr)?/",end:"/[a-z]*",contains:[e.BACKSLASH_ESCAPE],relevance:0}]},{className:"function",beginKeywords:"sub",end:"(\\s*\\(.*?\\))?[;{]",excludeEnd:!0,relevance:5,contains:[e.TITLE_MODE]},{begin:"-\\w\\b",relevance:0},{begin:"^__DATA__$",end:"^__END__$",subLanguage:"mojolicious",contains:[{begin:"^@@.*",end:"$",className:"comment"}]}];return t.contains=a,s.contains=a,{name:"Perl",aliases:["pl","pm"],keywords:n,contains:a}}}()); +hljs.registerLanguage("php",function(){"use strict";return function(e){var r={begin:"\\$+[a-zA-Z_-ÿ][a-zA-Z0-9_-ÿ]*"},t={className:"meta",variants:[{begin:/<\?php/,relevance:10},{begin:/<\?[=]?/},{begin:/\?>/}]},a={className:"string",contains:[e.BACKSLASH_ESCAPE,t],variants:[{begin:'b"',end:'"'},{begin:"b'",end:"'"},e.inherit(e.APOS_STRING_MODE,{illegal:null}),e.inherit(e.QUOTE_STRING_MODE,{illegal:null})]},n={variants:[e.BINARY_NUMBER_MODE,e.C_NUMBER_MODE]},i={keyword:"__CLASS__ __DIR__ __FILE__ __FUNCTION__ __LINE__ __METHOD__ __NAMESPACE__ __TRAIT__ die echo exit include include_once print require require_once array abstract and as binary bool boolean break callable case catch class clone const continue declare default do double else elseif empty enddeclare endfor endforeach endif endswitch endwhile eval extends final finally float for foreach from global goto if implements instanceof insteadof int integer interface isset iterable list new object or private protected public real return string switch throw trait try unset use var void while xor yield",literal:"false null true",built_in:"Error|0 AppendIterator ArgumentCountError ArithmeticError ArrayIterator ArrayObject AssertionError BadFunctionCallException BadMethodCallException CachingIterator CallbackFilterIterator CompileError Countable DirectoryIterator DivisionByZeroError DomainException EmptyIterator ErrorException Exception FilesystemIterator FilterIterator GlobIterator InfiniteIterator InvalidArgumentException IteratorIterator LengthException LimitIterator LogicException MultipleIterator NoRewindIterator OutOfBoundsException OutOfRangeException OuterIterator OverflowException ParentIterator ParseError RangeException RecursiveArrayIterator RecursiveCachingIterator RecursiveCallbackFilterIterator RecursiveDirectoryIterator RecursiveFilterIterator RecursiveIterator RecursiveIteratorIterator RecursiveRegexIterator RecursiveTreeIterator RegexIterator RuntimeException SeekableIterator SplDoublyLinkedList SplFileInfo SplFileObject SplFixedArray SplHeap SplMaxHeap SplMinHeap SplObjectStorage SplObserver SplObserver SplPriorityQueue SplQueue SplStack SplSubject SplSubject SplTempFileObject TypeError UnderflowException UnexpectedValueException ArrayAccess Closure Generator Iterator IteratorAggregate Serializable Throwable Traversable WeakReference Directory __PHP_Incomplete_Class parent php_user_filter self static stdClass"};return{aliases:["php","php3","php4","php5","php6","php7"],case_insensitive:!0,keywords:i,contains:[e.HASH_COMMENT_MODE,e.COMMENT("//","$",{contains:[t]}),e.COMMENT("/\\*","\\*/",{contains:[{className:"doctag",begin:"@[A-Za-z]+"}]}),e.COMMENT("__halt_compiler.+?;",!1,{endsWithParent:!0,keywords:"__halt_compiler"}),{className:"string",begin:/<<<['"]?\w+['"]?$/,end:/^\w+;?$/,contains:[e.BACKSLASH_ESCAPE,{className:"subst",variants:[{begin:/\$\w+/},{begin:/\{\$/,end:/\}/}]}]},t,{className:"keyword",begin:/\$this\b/},r,{begin:/(::|->)+[a-zA-Z_\x7f-\xff][a-zA-Z0-9_\x7f-\xff]*/},{className:"function",beginKeywords:"fn function",end:/[;{]/,excludeEnd:!0,illegal:"[$%\\[]",contains:[e.UNDERSCORE_TITLE_MODE,{className:"params",begin:"\\(",end:"\\)",excludeBegin:!0,excludeEnd:!0,keywords:i,contains:["self",r,e.C_BLOCK_COMMENT_MODE,a,n]}]},{className:"class",beginKeywords:"class interface",end:"{",excludeEnd:!0,illegal:/[:\(\$"]/,contains:[{beginKeywords:"extends implements"},e.UNDERSCORE_TITLE_MODE]},{beginKeywords:"namespace",end:";",illegal:/[\.']/,contains:[e.UNDERSCORE_TITLE_MODE]},{beginKeywords:"use",end:";",contains:[e.UNDERSCORE_TITLE_MODE]},{begin:"=>"},a,n]}}}()); +hljs.registerLanguage("php-template",function(){"use strict";return function(n){return{name:"PHP template",subLanguage:"xml",contains:[{begin:/<\?(php|=)?/,end:/\?>/,subLanguage:"php",contains:[{begin:"/\\*",end:"\\*/",skip:!0},{begin:'b"',end:'"',skip:!0},{begin:"b'",end:"'",skip:!0},n.inherit(n.APOS_STRING_MODE,{illegal:null,className:null,contains:null,skip:!0}),n.inherit(n.QUOTE_STRING_MODE,{illegal:null,className:null,contains:null,skip:!0})]}]}}}()); +hljs.registerLanguage("plaintext",function(){"use strict";return function(t){return{name:"Plain text",aliases:["text","txt"],disableAutodetect:!0}}}()); +hljs.registerLanguage("properties",function(){"use strict";return function(e){var n="[ \\t\\f]*",t="("+n+"[:=]"+n+"|[ \\t\\f]+)",a="([^\\\\:= \\t\\f\\n]|\\\\.)+",s={end:t,relevance:0,starts:{className:"string",end:/$/,relevance:0,contains:[{begin:"\\\\\\n"}]}};return{name:".properties",case_insensitive:!0,illegal:/\S/,contains:[e.COMMENT("^\\s*[!#]","$"),{begin:"([^\\\\\\W:= \\t\\f\\n]|\\\\.)+"+t,returnBegin:!0,contains:[{className:"attr",begin:"([^\\\\\\W:= \\t\\f\\n]|\\\\.)+",endsParent:!0,relevance:0}],starts:s},{begin:a+t,returnBegin:!0,relevance:0,contains:[{className:"meta",begin:a,endsParent:!0,relevance:0}],starts:s},{className:"attr",relevance:0,begin:a+n+"$"}]}}}()); +hljs.registerLanguage("python",function(){"use strict";return function(e){var n={keyword:"and elif is global as in if from raise for except finally print import pass return exec else break not with class assert yield try while continue del or def lambda async await nonlocal|10",built_in:"Ellipsis NotImplemented",literal:"False None True"},a={className:"meta",begin:/^(>>>|\.\.\.) /},i={className:"subst",begin:/\{/,end:/\}/,keywords:n,illegal:/#/},s={begin:/\{\{/,relevance:0},r={className:"string",contains:[e.BACKSLASH_ESCAPE],variants:[{begin:/(u|b)?r?'''/,end:/'''/,contains:[e.BACKSLASH_ESCAPE,a],relevance:10},{begin:/(u|b)?r?"""/,end:/"""/,contains:[e.BACKSLASH_ESCAPE,a],relevance:10},{begin:/(fr|rf|f)'''/,end:/'''/,contains:[e.BACKSLASH_ESCAPE,a,s,i]},{begin:/(fr|rf|f)"""/,end:/"""/,contains:[e.BACKSLASH_ESCAPE,a,s,i]},{begin:/(u|r|ur)'/,end:/'/,relevance:10},{begin:/(u|r|ur)"/,end:/"/,relevance:10},{begin:/(b|br)'/,end:/'/},{begin:/(b|br)"/,end:/"/},{begin:/(fr|rf|f)'/,end:/'/,contains:[e.BACKSLASH_ESCAPE,s,i]},{begin:/(fr|rf|f)"/,end:/"/,contains:[e.BACKSLASH_ESCAPE,s,i]},e.APOS_STRING_MODE,e.QUOTE_STRING_MODE]},l={className:"number",relevance:0,variants:[{begin:e.BINARY_NUMBER_RE+"[lLjJ]?"},{begin:"\\b(0o[0-7]+)[lLjJ]?"},{begin:e.C_NUMBER_RE+"[lLjJ]?"}]},t={className:"params",variants:[{begin:/\(\s*\)/,skip:!0,className:null},{begin:/\(/,end:/\)/,excludeBegin:!0,excludeEnd:!0,contains:["self",a,l,r,e.HASH_COMMENT_MODE]}]};return i.contains=[r,l,a],{name:"Python",aliases:["py","gyp","ipython"],keywords:n,illegal:/(<\/|->|\?)|=>/,contains:[a,l,{beginKeywords:"if",relevance:0},r,e.HASH_COMMENT_MODE,{variants:[{className:"function",beginKeywords:"def"},{className:"class",beginKeywords:"class"}],end:/:/,illegal:/[${=;\n,]/,contains:[e.UNDERSCORE_TITLE_MODE,t,{begin:/->/,endsWithParent:!0,keywords:"None"}]},{className:"meta",begin:/^[\t ]*@/,end:/$/},{begin:/\b(print|exec)\(/}]}}}()); +hljs.registerLanguage("python-repl",function(){"use strict";return function(n){return{aliases:["pycon"],contains:[{className:"meta",starts:{end:/ |$/,starts:{end:"$",subLanguage:"python"}},variants:[{begin:/^>>>(?=[ ]|$)/},{begin:/^\.\.\.(?=[ ]|$)/}]}]}}}()); +hljs.registerLanguage("ruby",function(){"use strict";return function(e){var n="[a-zA-Z_]\\w*[!?=]?|[-+~]\\@|<<|>>|=~|===?|<=>|[<>]=?|\\*\\*|[-/+%^&*~`|]|\\[\\]=?",a={keyword:"and then defined module in return redo if BEGIN retry end for self when next until do begin unless END rescue else break undef not super class case require yield alias while ensure elsif or include attr_reader attr_writer attr_accessor",literal:"true false nil"},s={className:"doctag",begin:"@[A-Za-z]+"},i={begin:"#<",end:">"},r=[e.COMMENT("#","$",{contains:[s]}),e.COMMENT("^\\=begin","^\\=end",{contains:[s],relevance:10}),e.COMMENT("^__END__","\\n$")],c={className:"subst",begin:"#\\{",end:"}",keywords:a},t={className:"string",contains:[e.BACKSLASH_ESCAPE,c],variants:[{begin:/'/,end:/'/},{begin:/"/,end:/"/},{begin:/`/,end:/`/},{begin:"%[qQwWx]?\\(",end:"\\)"},{begin:"%[qQwWx]?\\[",end:"\\]"},{begin:"%[qQwWx]?{",end:"}"},{begin:"%[qQwWx]?<",end:">"},{begin:"%[qQwWx]?/",end:"/"},{begin:"%[qQwWx]?%",end:"%"},{begin:"%[qQwWx]?-",end:"-"},{begin:"%[qQwWx]?\\|",end:"\\|"},{begin:/\B\?(\\\d{1,3}|\\x[A-Fa-f0-9]{1,2}|\\u[A-Fa-f0-9]{4}|\\?\S)\b/},{begin:/<<[-~]?'?(\w+)(?:.|\n)*?\n\s*\1\b/,returnBegin:!0,contains:[{begin:/<<[-~]?'?/},e.END_SAME_AS_BEGIN({begin:/(\w+)/,end:/(\w+)/,contains:[e.BACKSLASH_ESCAPE,c]})]}]},b={className:"params",begin:"\\(",end:"\\)",endsParent:!0,keywords:a},d=[t,i,{className:"class",beginKeywords:"class module",end:"$|;",illegal:/=/,contains:[e.inherit(e.TITLE_MODE,{begin:"[A-Za-z_]\\w*(::\\w+)*(\\?|\\!)?"}),{begin:"<\\s*",contains:[{begin:"("+e.IDENT_RE+"::)?"+e.IDENT_RE}]}].concat(r)},{className:"function",beginKeywords:"def",end:"$|;",contains:[e.inherit(e.TITLE_MODE,{begin:n}),b].concat(r)},{begin:e.IDENT_RE+"::"},{className:"symbol",begin:e.UNDERSCORE_IDENT_RE+"(\\!|\\?)?:",relevance:0},{className:"symbol",begin:":(?!\\s)",contains:[t,{begin:n}],relevance:0},{className:"number",begin:"(\\b0[0-7_]+)|(\\b0x[0-9a-fA-F_]+)|(\\b[1-9][0-9_]*(\\.[0-9_]+)?)|[0_]\\b",relevance:0},{begin:"(\\$\\W)|((\\$|\\@\\@?)(\\w+))"},{className:"params",begin:/\|/,end:/\|/,keywords:a},{begin:"("+e.RE_STARTERS_RE+"|unless)\\s*",keywords:"unless",contains:[i,{className:"regexp",contains:[e.BACKSLASH_ESCAPE,c],illegal:/\n/,variants:[{begin:"/",end:"/[a-z]*"},{begin:"%r{",end:"}[a-z]*"},{begin:"%r\\(",end:"\\)[a-z]*"},{begin:"%r!",end:"![a-z]*"},{begin:"%r\\[",end:"\\][a-z]*"}]}].concat(r),relevance:0}].concat(r);c.contains=d,b.contains=d;var g=[{begin:/^\s*=>/,starts:{end:"$",contains:d}},{className:"meta",begin:"^([>?]>|[\\w#]+\\(\\w+\\):\\d+:\\d+>|(\\w+-)?\\d+\\.\\d+\\.\\d(p\\d+)?[^>]+>)",starts:{end:"$",contains:d}}];return{name:"Ruby",aliases:["rb","gemspec","podspec","thor","irb"],keywords:a,illegal:/\/\*/,contains:r.concat(g).concat(d)}}}()); +hljs.registerLanguage("rust",function(){"use strict";return function(e){var n="([ui](8|16|32|64|128|size)|f(32|64))?",t="drop i8 i16 i32 i64 i128 isize u8 u16 u32 u64 u128 usize f32 f64 str char bool Box Option Result String Vec Copy Send Sized Sync Drop Fn FnMut FnOnce ToOwned Clone Debug PartialEq PartialOrd Eq Ord AsRef AsMut Into From Default Iterator Extend IntoIterator DoubleEndedIterator ExactSizeIterator SliceConcatExt ToString assert! assert_eq! bitflags! bytes! cfg! col! concat! concat_idents! debug_assert! debug_assert_eq! env! panic! file! format! format_args! include_bin! include_str! line! local_data_key! module_path! option_env! print! println! select! stringify! try! unimplemented! unreachable! vec! write! writeln! macro_rules! assert_ne! debug_assert_ne!";return{name:"Rust",aliases:["rs"],keywords:{$pattern:e.IDENT_RE+"!?",keyword:"abstract as async await become box break const continue crate do dyn else enum extern false final fn for if impl in let loop macro match mod move mut override priv pub ref return self Self static struct super trait true try type typeof unsafe unsized use virtual where while yield",literal:"true false Some None Ok Err",built_in:t},illegal:""}]}}}()); +hljs.registerLanguage("scss",function(){"use strict";return function(e){var t={className:"variable",begin:"(\\$[a-zA-Z-][a-zA-Z0-9_-]*)\\b"},i={className:"number",begin:"#[0-9A-Fa-f]+"};return e.CSS_NUMBER_MODE,e.QUOTE_STRING_MODE,e.APOS_STRING_MODE,e.C_BLOCK_COMMENT_MODE,{name:"SCSS",case_insensitive:!0,illegal:"[=/|']",contains:[e.C_LINE_COMMENT_MODE,e.C_BLOCK_COMMENT_MODE,{className:"selector-id",begin:"\\#[A-Za-z0-9_-]+",relevance:0},{className:"selector-class",begin:"\\.[A-Za-z0-9_-]+",relevance:0},{className:"selector-attr",begin:"\\[",end:"\\]",illegal:"$"},{className:"selector-tag",begin:"\\b(a|abbr|acronym|address|area|article|aside|audio|b|base|big|blockquote|body|br|button|canvas|caption|cite|code|col|colgroup|command|datalist|dd|del|details|dfn|div|dl|dt|em|embed|fieldset|figcaption|figure|footer|form|frame|frameset|(h[1-6])|head|header|hgroup|hr|html|i|iframe|img|input|ins|kbd|keygen|label|legend|li|link|map|mark|meta|meter|nav|noframes|noscript|object|ol|optgroup|option|output|p|param|pre|progress|q|rp|rt|ruby|samp|script|section|select|small|span|strike|strong|style|sub|sup|table|tbody|td|textarea|tfoot|th|thead|time|title|tr|tt|ul|var|video)\\b",relevance:0},{className:"selector-pseudo",begin:":(visited|valid|root|right|required|read-write|read-only|out-range|optional|only-of-type|only-child|nth-of-type|nth-last-of-type|nth-last-child|nth-child|not|link|left|last-of-type|last-child|lang|invalid|indeterminate|in-range|hover|focus|first-of-type|first-line|first-letter|first-child|first|enabled|empty|disabled|default|checked|before|after|active)"},{className:"selector-pseudo",begin:"::(after|before|choices|first-letter|first-line|repeat-index|repeat-item|selection|value)"},t,{className:"attribute",begin:"\\b(src|z-index|word-wrap|word-spacing|word-break|width|widows|white-space|visibility|vertical-align|unicode-bidi|transition-timing-function|transition-property|transition-duration|transition-delay|transition|transform-style|transform-origin|transform|top|text-underline-position|text-transform|text-shadow|text-rendering|text-overflow|text-indent|text-decoration-style|text-decoration-line|text-decoration-color|text-decoration|text-align-last|text-align|tab-size|table-layout|right|resize|quotes|position|pointer-events|perspective-origin|perspective|page-break-inside|page-break-before|page-break-after|padding-top|padding-right|padding-left|padding-bottom|padding|overflow-y|overflow-x|overflow-wrap|overflow|outline-width|outline-style|outline-offset|outline-color|outline|orphans|order|opacity|object-position|object-fit|normal|none|nav-up|nav-right|nav-left|nav-index|nav-down|min-width|min-height|max-width|max-height|mask|marks|margin-top|margin-right|margin-left|margin-bottom|margin|list-style-type|list-style-position|list-style-image|list-style|line-height|letter-spacing|left|justify-content|initial|inherit|ime-mode|image-orientation|image-resolution|image-rendering|icon|hyphens|height|font-weight|font-variant-ligatures|font-variant|font-style|font-stretch|font-size-adjust|font-size|font-language-override|font-kerning|font-feature-settings|font-family|font|float|flex-wrap|flex-shrink|flex-grow|flex-flow|flex-direction|flex-basis|flex|filter|empty-cells|display|direction|cursor|counter-reset|counter-increment|content|column-width|column-span|column-rule-width|column-rule-style|column-rule-color|column-rule|column-gap|column-fill|column-count|columns|color|clip-path|clip|clear|caption-side|break-inside|break-before|break-after|box-sizing|box-shadow|box-decoration-break|bottom|border-width|border-top-width|border-top-style|border-top-right-radius|border-top-left-radius|border-top-color|border-top|border-style|border-spacing|border-right-width|border-right-style|border-right-color|border-right|border-radius|border-left-width|border-left-style|border-left-color|border-left|border-image-width|border-image-source|border-image-slice|border-image-repeat|border-image-outset|border-image|border-color|border-collapse|border-bottom-width|border-bottom-style|border-bottom-right-radius|border-bottom-left-radius|border-bottom-color|border-bottom|border|background-size|background-repeat|background-position|background-origin|background-image|background-color|background-clip|background-attachment|background-blend-mode|background|backface-visibility|auto|animation-timing-function|animation-play-state|animation-name|animation-iteration-count|animation-fill-mode|animation-duration|animation-direction|animation-delay|animation|align-self|align-items|align-content)\\b",illegal:"[^\\s]"},{begin:"\\b(whitespace|wait|w-resize|visible|vertical-text|vertical-ideographic|uppercase|upper-roman|upper-alpha|underline|transparent|top|thin|thick|text|text-top|text-bottom|tb-rl|table-header-group|table-footer-group|sw-resize|super|strict|static|square|solid|small-caps|separate|se-resize|scroll|s-resize|rtl|row-resize|ridge|right|repeat|repeat-y|repeat-x|relative|progress|pointer|overline|outside|outset|oblique|nowrap|not-allowed|normal|none|nw-resize|no-repeat|no-drop|newspaper|ne-resize|n-resize|move|middle|medium|ltr|lr-tb|lowercase|lower-roman|lower-alpha|loose|list-item|line|line-through|line-edge|lighter|left|keep-all|justify|italic|inter-word|inter-ideograph|inside|inset|inline|inline-block|inherit|inactive|ideograph-space|ideograph-parenthesis|ideograph-numeric|ideograph-alpha|horizontal|hidden|help|hand|groove|fixed|ellipsis|e-resize|double|dotted|distribute|distribute-space|distribute-letter|distribute-all-lines|disc|disabled|default|decimal|dashed|crosshair|collapse|col-resize|circle|char|center|capitalize|break-word|break-all|bottom|both|bolder|bold|block|bidi-override|below|baseline|auto|always|all-scroll|absolute|table|table-cell)\\b"},{begin:":",end:";",contains:[t,i,e.CSS_NUMBER_MODE,e.QUOTE_STRING_MODE,e.APOS_STRING_MODE,{className:"meta",begin:"!important"}]},{begin:"@(page|font-face)",lexemes:"@[a-z-]+",keywords:"@page @font-face"},{begin:"@",end:"[{;]",returnBegin:!0,keywords:"and or not only",contains:[{begin:"@[a-z-]+",className:"keyword"},t,e.QUOTE_STRING_MODE,e.APOS_STRING_MODE,i,e.CSS_NUMBER_MODE]}]}}}()); +hljs.registerLanguage("shell",function(){"use strict";return function(s){return{name:"Shell Session",aliases:["console"],contains:[{className:"meta",begin:"^\\s{0,3}[/\\w\\d\\[\\]()@-]*[>%$#]",starts:{end:"$",subLanguage:"bash"}}]}}}()); +hljs.registerLanguage("sql",function(){"use strict";return function(e){var t=e.COMMENT("--","$");return{name:"SQL",case_insensitive:!0,illegal:/[<>{}*]/,contains:[{beginKeywords:"begin end start commit rollback savepoint lock alter create drop rename call delete do handler insert load replace select truncate update set show pragma grant merge describe use explain help declare prepare execute deallocate release unlock purge reset change stop analyze cache flush optimize repair kill install uninstall checksum restore check backup revoke comment values with",end:/;/,endsWithParent:!0,keywords:{$pattern:/[\w\.]+/,keyword:"as abort abs absolute acc acce accep accept access accessed accessible account acos action activate add addtime admin administer advanced advise aes_decrypt aes_encrypt after agent aggregate ali alia alias all allocate allow alter always analyze ancillary and anti any anydata anydataset anyschema anytype apply archive archived archivelog are as asc ascii asin assembly assertion associate asynchronous at atan atn2 attr attri attrib attribu attribut attribute attributes audit authenticated authentication authid authors auto autoallocate autodblink autoextend automatic availability avg backup badfile basicfile before begin beginning benchmark between bfile bfile_base big bigfile bin binary_double binary_float binlog bit_and bit_count bit_length bit_or bit_xor bitmap blob_base block blocksize body both bound bucket buffer_cache buffer_pool build bulk by byte byteordermark bytes cache caching call calling cancel capacity cascade cascaded case cast catalog category ceil ceiling chain change changed char_base char_length character_length characters characterset charindex charset charsetform charsetid check checksum checksum_agg child choose chr chunk class cleanup clear client clob clob_base clone close cluster_id cluster_probability cluster_set clustering coalesce coercibility col collate collation collect colu colum column column_value columns columns_updated comment commit compact compatibility compiled complete composite_limit compound compress compute concat concat_ws concurrent confirm conn connec connect connect_by_iscycle connect_by_isleaf connect_by_root connect_time connection consider consistent constant constraint constraints constructor container content contents context contributors controlfile conv convert convert_tz corr corr_k corr_s corresponding corruption cos cost count count_big counted covar_pop covar_samp cpu_per_call cpu_per_session crc32 create creation critical cross cube cume_dist curdate current current_date current_time current_timestamp current_user cursor curtime customdatum cycle data database databases datafile datafiles datalength date_add date_cache date_format date_sub dateadd datediff datefromparts datename datepart datetime2fromparts day day_to_second dayname dayofmonth dayofweek dayofyear days db_role_change dbtimezone ddl deallocate declare decode decompose decrement decrypt deduplicate def defa defau defaul default defaults deferred defi defin define degrees delayed delegate delete delete_all delimited demand dense_rank depth dequeue des_decrypt des_encrypt des_key_file desc descr descri describ describe descriptor deterministic diagnostics difference dimension direct_load directory disable disable_all disallow disassociate discardfile disconnect diskgroup distinct distinctrow distribute distributed div do document domain dotnet double downgrade drop dumpfile duplicate duration each edition editionable editions element ellipsis else elsif elt empty enable enable_all enclosed encode encoding encrypt end end-exec endian enforced engine engines enqueue enterprise entityescaping eomonth error errors escaped evalname evaluate event eventdata events except exception exceptions exchange exclude excluding execu execut execute exempt exists exit exp expire explain explode export export_set extended extent external external_1 external_2 externally extract failed failed_login_attempts failover failure far fast feature_set feature_value fetch field fields file file_name_convert filesystem_like_logging final finish first first_value fixed flash_cache flashback floor flush following follows for forall force foreign form forma format found found_rows freelist freelists freepools fresh from from_base64 from_days ftp full function general generated get get_format get_lock getdate getutcdate global global_name globally go goto grant grants greatest group group_concat group_id grouping grouping_id groups gtid_subtract guarantee guard handler hash hashkeys having hea head headi headin heading heap help hex hierarchy high high_priority hosts hour hours http id ident_current ident_incr ident_seed identified identity idle_time if ifnull ignore iif ilike ilm immediate import in include including increment index indexes indexing indextype indicator indices inet6_aton inet6_ntoa inet_aton inet_ntoa infile initial initialized initially initrans inmemory inner innodb input insert install instance instantiable instr interface interleaved intersect into invalidate invisible is is_free_lock is_ipv4 is_ipv4_compat is_not is_not_null is_used_lock isdate isnull isolation iterate java join json json_exists keep keep_duplicates key keys kill language large last last_day last_insert_id last_value lateral lax lcase lead leading least leaves left len lenght length less level levels library like like2 like4 likec limit lines link list listagg little ln load load_file lob lobs local localtime localtimestamp locate locator lock locked log log10 log2 logfile logfiles logging logical logical_reads_per_call logoff logon logs long loop low low_priority lower lpad lrtrim ltrim main make_set makedate maketime managed management manual map mapping mask master master_pos_wait match matched materialized max maxextents maximize maxinstances maxlen maxlogfiles maxloghistory maxlogmembers maxsize maxtrans md5 measures median medium member memcompress memory merge microsecond mid migration min minextents minimum mining minus minute minutes minvalue missing mod mode model modification modify module monitoring month months mount move movement multiset mutex name name_const names nan national native natural nav nchar nclob nested never new newline next nextval no no_write_to_binlog noarchivelog noaudit nobadfile nocheck nocompress nocopy nocycle nodelay nodiscardfile noentityescaping noguarantee nokeep nologfile nomapping nomaxvalue nominimize nominvalue nomonitoring none noneditionable nonschema noorder nopr nopro noprom nopromp noprompt norely noresetlogs noreverse normal norowdependencies noschemacheck noswitch not nothing notice notnull notrim novalidate now nowait nth_value nullif nulls num numb numbe nvarchar nvarchar2 object ocicoll ocidate ocidatetime ociduration ociinterval ociloblocator ocinumber ociref ocirefcursor ocirowid ocistring ocitype oct octet_length of off offline offset oid oidindex old on online only opaque open operations operator optimal optimize option optionally or oracle oracle_date oradata ord ordaudio orddicom orddoc order ordimage ordinality ordvideo organization orlany orlvary out outer outfile outline output over overflow overriding package pad parallel parallel_enable parameters parent parse partial partition partitions pascal passing password password_grace_time password_lock_time password_reuse_max password_reuse_time password_verify_function patch path patindex pctincrease pctthreshold pctused pctversion percent percent_rank percentile_cont percentile_disc performance period period_add period_diff permanent physical pi pipe pipelined pivot pluggable plugin policy position post_transaction pow power pragma prebuilt precedes preceding precision prediction prediction_cost prediction_details prediction_probability prediction_set prepare present preserve prior priority private private_sga privileges procedural procedure procedure_analyze processlist profiles project prompt protection public publishingservername purge quarter query quick quiesce quota quotename radians raise rand range rank raw read reads readsize rebuild record records recover recovery recursive recycle redo reduced ref reference referenced references referencing refresh regexp_like register regr_avgx regr_avgy regr_count regr_intercept regr_r2 regr_slope regr_sxx regr_sxy reject rekey relational relative relaylog release release_lock relies_on relocate rely rem remainder rename repair repeat replace replicate replication required reset resetlogs resize resource respect restore restricted result result_cache resumable resume retention return returning returns reuse reverse revoke right rlike role roles rollback rolling rollup round row row_count rowdependencies rowid rownum rows rtrim rules safe salt sample save savepoint sb1 sb2 sb4 scan schema schemacheck scn scope scroll sdo_georaster sdo_topo_geometry search sec_to_time second seconds section securefile security seed segment select self semi sequence sequential serializable server servererror session session_user sessions_per_user set sets settings sha sha1 sha2 share shared shared_pool short show shrink shutdown si_averagecolor si_colorhistogram si_featurelist si_positionalcolor si_stillimage si_texture siblings sid sign sin size size_t sizes skip slave sleep smalldatetimefromparts smallfile snapshot some soname sort soundex source space sparse spfile split sql sql_big_result sql_buffer_result sql_cache sql_calc_found_rows sql_small_result sql_variant_property sqlcode sqldata sqlerror sqlname sqlstate sqrt square standalone standby start starting startup statement static statistics stats_binomial_test stats_crosstab stats_ks_test stats_mode stats_mw_test stats_one_way_anova stats_t_test_ stats_t_test_indep stats_t_test_one stats_t_test_paired stats_wsr_test status std stddev stddev_pop stddev_samp stdev stop storage store stored str str_to_date straight_join strcmp strict string struct stuff style subdate subpartition subpartitions substitutable substr substring subtime subtring_index subtype success sum suspend switch switchoffset switchover sync synchronous synonym sys sys_xmlagg sysasm sysaux sysdate sysdatetimeoffset sysdba sysoper system system_user sysutcdatetime table tables tablespace tablesample tan tdo template temporary terminated tertiary_weights test than then thread through tier ties time time_format time_zone timediff timefromparts timeout timestamp timestampadd timestampdiff timezone_abbr timezone_minute timezone_region to to_base64 to_date to_days to_seconds todatetimeoffset trace tracking transaction transactional translate translation treat trigger trigger_nestlevel triggers trim truncate try_cast try_convert try_parse type ub1 ub2 ub4 ucase unarchived unbounded uncompress under undo unhex unicode uniform uninstall union unique unix_timestamp unknown unlimited unlock unnest unpivot unrecoverable unsafe unsigned until untrusted unusable unused update updated upgrade upped upper upsert url urowid usable usage use use_stored_outlines user user_data user_resources users using utc_date utc_timestamp uuid uuid_short validate validate_password_strength validation valist value values var var_samp varcharc vari varia variab variabl variable variables variance varp varraw varrawc varray verify version versions view virtual visible void wait wallet warning warnings week weekday weekofyear wellformed when whene whenev wheneve whenever where while whitespace window with within without work wrapped xdb xml xmlagg xmlattributes xmlcast xmlcolattval xmlelement xmlexists xmlforest xmlindex xmlnamespaces xmlpi xmlquery xmlroot xmlschema xmlserialize xmltable xmltype xor year year_to_month years yearweek",literal:"true false null unknown",built_in:"array bigint binary bit blob bool boolean char character date dec decimal float int int8 integer interval number numeric real record serial serial8 smallint text time timestamp tinyint varchar varchar2 varying void"},contains:[{className:"string",begin:"'",end:"'",contains:[{begin:"''"}]},{className:"string",begin:'"',end:'"',contains:[{begin:'""'}]},{className:"string",begin:"`",end:"`"},e.C_NUMBER_MODE,e.C_BLOCK_COMMENT_MODE,t,e.HASH_COMMENT_MODE]},e.C_BLOCK_COMMENT_MODE,t,e.HASH_COMMENT_MODE]}}}()); +hljs.registerLanguage("swift",function(){"use strict";return function(e){var i={keyword:"#available #colorLiteral #column #else #elseif #endif #file #fileLiteral #function #if #imageLiteral #line #selector #sourceLocation _ __COLUMN__ __FILE__ __FUNCTION__ __LINE__ Any as as! as? associatedtype associativity break case catch class continue convenience default defer deinit didSet do dynamic dynamicType else enum extension fallthrough false fileprivate final for func get guard if import in indirect infix init inout internal is lazy left let mutating nil none nonmutating open operator optional override postfix precedence prefix private protocol Protocol public repeat required rethrows return right self Self set static struct subscript super switch throw throws true try try! try? Type typealias unowned var weak where while willSet",literal:"true false nil",built_in:"abs advance alignof alignofValue anyGenerator assert assertionFailure bridgeFromObjectiveC bridgeFromObjectiveCUnconditional bridgeToObjectiveC bridgeToObjectiveCUnconditional c compactMap contains count countElements countLeadingZeros debugPrint debugPrintln distance dropFirst dropLast dump encodeBitsAsWords enumerate equal fatalError filter find getBridgedObjectiveCType getVaList indices insertionSort isBridgedToObjectiveC isBridgedVerbatimToObjectiveC isUniquelyReferenced isUniquelyReferencedNonObjC join lazy lexicographicalCompare map max maxElement min minElement numericCast overlaps partition posix precondition preconditionFailure print println quickSort readLine reduce reflect reinterpretCast reverse roundUpToAlignment sizeof sizeofValue sort split startsWith stride strideof strideofValue swap toString transcode underestimateCount unsafeAddressOf unsafeBitCast unsafeDowncast unsafeUnwrap unsafeReflect withExtendedLifetime withObjectAtPlusZero withUnsafePointer withUnsafePointerToObject withUnsafeMutablePointer withUnsafeMutablePointers withUnsafePointer withUnsafePointers withVaList zip"},n=e.COMMENT("/\\*","\\*/",{contains:["self"]}),t={className:"subst",begin:/\\\(/,end:"\\)",keywords:i,contains:[]},a={className:"string",contains:[e.BACKSLASH_ESCAPE,t],variants:[{begin:/"""/,end:/"""/},{begin:/"/,end:/"/}]},r={className:"number",begin:"\\b([\\d_]+(\\.[\\deE_]+)?|0x[a-fA-F0-9_]+(\\.[a-fA-F0-9p_]+)?|0b[01_]+|0o[0-7_]+)\\b",relevance:0};return t.contains=[r],{name:"Swift",keywords:i,contains:[a,e.C_LINE_COMMENT_MODE,n,{className:"type",begin:"\\b[A-Z][\\wÀ-ʸ']*[!?]"},{className:"type",begin:"\\b[A-Z][\\wÀ-ʸ']*",relevance:0},r,{className:"function",beginKeywords:"func",end:"{",excludeEnd:!0,contains:[e.inherit(e.TITLE_MODE,{begin:/[A-Za-z$_][0-9A-Za-z$_]*/}),{begin://},{className:"params",begin:/\(/,end:/\)/,endsParent:!0,keywords:i,contains:["self",r,a,e.C_BLOCK_COMMENT_MODE,{begin:":"}],illegal:/["']/}],illegal:/\[|%/},{className:"class",beginKeywords:"struct protocol class extension enum",keywords:i,end:"\\{",excludeEnd:!0,contains:[e.inherit(e.TITLE_MODE,{begin:/[A-Za-z$_][\u00C0-\u02B80-9A-Za-z$_]*/})]},{className:"meta",begin:"(@discardableResult|@warn_unused_result|@exported|@lazy|@noescape|@NSCopying|@NSManaged|@objc|@objcMembers|@convention|@required|@noreturn|@IBAction|@IBDesignable|@IBInspectable|@IBOutlet|@infix|@prefix|@postfix|@autoclosure|@testable|@available|@nonobjc|@NSApplicationMain|@UIApplicationMain|@dynamicMemberLookup|@propertyWrapper)\\b"},{beginKeywords:"import",end:/$/,contains:[e.C_LINE_COMMENT_MODE,n]}]}}}()); +hljs.registerLanguage("typescript",function(){"use strict";const e=["as","in","of","if","for","while","finally","var","new","function","do","return","void","else","break","catch","instanceof","with","throw","case","default","try","switch","continue","typeof","delete","let","yield","const","class","debugger","async","await","static","import","from","export","extends"],n=["true","false","null","undefined","NaN","Infinity"],a=[].concat(["setInterval","setTimeout","clearInterval","clearTimeout","require","exports","eval","isFinite","isNaN","parseFloat","parseInt","decodeURI","decodeURIComponent","encodeURI","encodeURIComponent","escape","unescape"],["arguments","this","super","console","window","document","localStorage","module","global"],["Intl","DataView","Number","Math","Date","String","RegExp","Object","Function","Boolean","Error","Symbol","Set","Map","WeakSet","WeakMap","Proxy","Reflect","JSON","Promise","Float64Array","Int16Array","Int32Array","Int8Array","Uint16Array","Uint32Array","Float32Array","Array","Uint8Array","Uint8ClampedArray","ArrayBuffer"],["EvalError","InternalError","RangeError","ReferenceError","SyntaxError","TypeError","URIError"]);return function(r){var t={$pattern:"[A-Za-z$_][0-9A-Za-z$_]*",keyword:e.concat(["type","namespace","typedef","interface","public","private","protected","implements","declare","abstract","readonly"]).join(" "),literal:n.join(" "),built_in:a.concat(["any","void","number","boolean","string","object","never","enum"]).join(" ")},s={className:"meta",begin:"@[A-Za-z$_][0-9A-Za-z$_]*"},i={className:"number",variants:[{begin:"\\b(0[bB][01]+)n?"},{begin:"\\b(0[oO][0-7]+)n?"},{begin:r.C_NUMBER_RE+"n?"}],relevance:0},o={className:"subst",begin:"\\$\\{",end:"\\}",keywords:t,contains:[]},c={begin:"html`",end:"",starts:{end:"`",returnEnd:!1,contains:[r.BACKSLASH_ESCAPE,o],subLanguage:"xml"}},l={begin:"css`",end:"",starts:{end:"`",returnEnd:!1,contains:[r.BACKSLASH_ESCAPE,o],subLanguage:"css"}},E={className:"string",begin:"`",end:"`",contains:[r.BACKSLASH_ESCAPE,o]};o.contains=[r.APOS_STRING_MODE,r.QUOTE_STRING_MODE,c,l,E,i,r.REGEXP_MODE];var d={begin:"\\(",end:/\)/,keywords:t,contains:["self",r.QUOTE_STRING_MODE,r.APOS_STRING_MODE,r.NUMBER_MODE]},u={className:"params",begin:/\(/,end:/\)/,excludeBegin:!0,excludeEnd:!0,keywords:t,contains:[r.C_LINE_COMMENT_MODE,r.C_BLOCK_COMMENT_MODE,s,d]};return{name:"TypeScript",aliases:["ts"],keywords:t,contains:[r.SHEBANG(),{className:"meta",begin:/^\s*['"]use strict['"]/},r.APOS_STRING_MODE,r.QUOTE_STRING_MODE,c,l,E,r.C_LINE_COMMENT_MODE,r.C_BLOCK_COMMENT_MODE,i,{begin:"("+r.RE_STARTERS_RE+"|\\b(case|return|throw)\\b)\\s*",keywords:"return throw case",contains:[r.C_LINE_COMMENT_MODE,r.C_BLOCK_COMMENT_MODE,r.REGEXP_MODE,{className:"function",begin:"(\\([^(]*(\\([^(]*(\\([^(]*\\))?\\))?\\)|"+r.UNDERSCORE_IDENT_RE+")\\s*=>",returnBegin:!0,end:"\\s*=>",contains:[{className:"params",variants:[{begin:r.UNDERSCORE_IDENT_RE},{className:null,begin:/\(\s*\)/,skip:!0},{begin:/\(/,end:/\)/,excludeBegin:!0,excludeEnd:!0,keywords:t,contains:d.contains}]}]}],relevance:0},{className:"function",beginKeywords:"function",end:/[\{;]/,excludeEnd:!0,keywords:t,contains:["self",r.inherit(r.TITLE_MODE,{begin:"[A-Za-z$_][0-9A-Za-z$_]*"}),u],illegal:/%/,relevance:0},{beginKeywords:"constructor",end:/[\{;]/,excludeEnd:!0,contains:["self",u]},{begin:/module\./,keywords:{built_in:"module"},relevance:0},{beginKeywords:"module",end:/\{/,excludeEnd:!0},{beginKeywords:"interface",end:/\{/,excludeEnd:!0,keywords:"interface extends"},{begin:/\$[(.]/},{begin:"\\."+r.IDENT_RE,relevance:0},s,d]}}}()); +hljs.registerLanguage("yaml",function(){"use strict";return function(e){var n="true false yes no null",a="[\\w#;/?:@&=+$,.~*\\'()[\\]]+",s={className:"string",relevance:0,variants:[{begin:/'/,end:/'/},{begin:/"/,end:/"/},{begin:/\S+/}],contains:[e.BACKSLASH_ESCAPE,{className:"template-variable",variants:[{begin:"{{",end:"}}"},{begin:"%{",end:"}"}]}]},i=e.inherit(s,{variants:[{begin:/'/,end:/'/},{begin:/"/,end:/"/},{begin:/[^\s,{}[\]]+/}]}),l={end:",",endsWithParent:!0,excludeEnd:!0,contains:[],keywords:n,relevance:0},t={begin:"{",end:"}",contains:[l],illegal:"\\n",relevance:0},g={begin:"\\[",end:"\\]",contains:[l],illegal:"\\n",relevance:0},b=[{className:"attr",variants:[{begin:"\\w[\\w :\\/.-]*:(?=[ \t]|$)"},{begin:'"\\w[\\w :\\/.-]*":(?=[ \t]|$)'},{begin:"'\\w[\\w :\\/.-]*':(?=[ \t]|$)"}]},{className:"meta",begin:"^---s*$",relevance:10},{className:"string",begin:"[\\|>]([0-9]?[+-])?[ ]*\\n( *)[\\S ]+\\n(\\2[\\S ]+\\n?)*"},{begin:"<%[%=-]?",end:"[%-]?%>",subLanguage:"ruby",excludeBegin:!0,excludeEnd:!0,relevance:0},{className:"type",begin:"!\\w+!"+a},{className:"type",begin:"!<"+a+">"},{className:"type",begin:"!"+a},{className:"type",begin:"!!"+a},{className:"meta",begin:"&"+e.UNDERSCORE_IDENT_RE+"$"},{className:"meta",begin:"\\*"+e.UNDERSCORE_IDENT_RE+"$"},{className:"bullet",begin:"\\-(?=[ ]|$)",relevance:0},e.HASH_COMMENT_MODE,{beginKeywords:n,keywords:{literal:n}},{className:"number",begin:"\\b[0-9]{4}(-[0-9][0-9]){0,2}([Tt \\t][0-9][0-9]?(:[0-9][0-9]){2})?(\\.[0-9]*)?([ \\t])*(Z|[-+][0-9][0-9]?(:[0-9][0-9])?)?\\b"},{className:"number",begin:e.C_NUMBER_RE+"\\b"},t,g,s],c=[...b];return c.pop(),c.push(i),l.contains=c,{name:"YAML",case_insensitive:!0,aliases:["yml","YAML"],contains:b}}}()); +hljs.registerLanguage("armasm",function(){"use strict";return function(s){const e={variants:[s.COMMENT("^[ \\t]*(?=#)","$",{relevance:0,excludeBegin:!0}),s.COMMENT("[;@]","$",{relevance:0}),s.C_LINE_COMMENT_MODE,s.C_BLOCK_COMMENT_MODE]};return{name:"ARM Assembly",case_insensitive:!0,aliases:["arm"],keywords:{$pattern:"\\.?"+s.IDENT_RE,meta:".2byte .4byte .align .ascii .asciz .balign .byte .code .data .else .end .endif .endm .endr .equ .err .exitm .extern .global .hword .if .ifdef .ifndef .include .irp .long .macro .rept .req .section .set .skip .space .text .word .arm .thumb .code16 .code32 .force_thumb .thumb_func .ltorg ALIAS ALIGN ARM AREA ASSERT ATTR CN CODE CODE16 CODE32 COMMON CP DATA DCB DCD DCDU DCDO DCFD DCFDU DCI DCQ DCQU DCW DCWU DN ELIF ELSE END ENDFUNC ENDIF ENDP ENTRY EQU EXPORT EXPORTAS EXTERN FIELD FILL FUNCTION GBLA GBLL GBLS GET GLOBAL IF IMPORT INCBIN INCLUDE INFO KEEP LCLA LCLL LCLS LTORG MACRO MAP MEND MEXIT NOFP OPT PRESERVE8 PROC QN READONLY RELOC REQUIRE REQUIRE8 RLIST FN ROUT SETA SETL SETS SN SPACE SUBT THUMB THUMBX TTL WHILE WEND ",built_in:"r0 r1 r2 r3 r4 r5 r6 r7 r8 r9 r10 r11 r12 r13 r14 r15 pc lr sp ip sl sb fp a1 a2 a3 a4 v1 v2 v3 v4 v5 v6 v7 v8 f0 f1 f2 f3 f4 f5 f6 f7 p0 p1 p2 p3 p4 p5 p6 p7 p8 p9 p10 p11 p12 p13 p14 p15 c0 c1 c2 c3 c4 c5 c6 c7 c8 c9 c10 c11 c12 c13 c14 c15 q0 q1 q2 q3 q4 q5 q6 q7 q8 q9 q10 q11 q12 q13 q14 q15 cpsr_c cpsr_x cpsr_s cpsr_f cpsr_cx cpsr_cxs cpsr_xs cpsr_xsf cpsr_sf cpsr_cxsf spsr_c spsr_x spsr_s spsr_f spsr_cx spsr_cxs spsr_xs spsr_xsf spsr_sf spsr_cxsf s0 s1 s2 s3 s4 s5 s6 s7 s8 s9 s10 s11 s12 s13 s14 s15 s16 s17 s18 s19 s20 s21 s22 s23 s24 s25 s26 s27 s28 s29 s30 s31 d0 d1 d2 d3 d4 d5 d6 d7 d8 d9 d10 d11 d12 d13 d14 d15 d16 d17 d18 d19 d20 d21 d22 d23 d24 d25 d26 d27 d28 d29 d30 d31 {PC} {VAR} {TRUE} {FALSE} {OPT} {CONFIG} {ENDIAN} {CODESIZE} {CPU} {FPU} {ARCHITECTURE} {PCSTOREOFFSET} {ARMASM_VERSION} {INTER} {ROPI} {RWPI} {SWST} {NOSWST} . @"},contains:[{className:"keyword",begin:"\\b(adc|(qd?|sh?|u[qh]?)?add(8|16)?|usada?8|(q|sh?|u[qh]?)?(as|sa)x|and|adrl?|sbc|rs[bc]|asr|b[lx]?|blx|bxj|cbn?z|tb[bh]|bic|bfc|bfi|[su]bfx|bkpt|cdp2?|clz|clrex|cmp|cmn|cpsi[ed]|cps|setend|dbg|dmb|dsb|eor|isb|it[te]{0,3}|lsl|lsr|ror|rrx|ldm(([id][ab])|f[ds])?|ldr((s|ex)?[bhd])?|movt?|mvn|mra|mar|mul|[us]mull|smul[bwt][bt]|smu[as]d|smmul|smmla|mla|umlaal|smlal?([wbt][bt]|d)|mls|smlsl?[ds]|smc|svc|sev|mia([bt]{2}|ph)?|mrr?c2?|mcrr2?|mrs|msr|orr|orn|pkh(tb|bt)|rbit|rev(16|sh)?|sel|[su]sat(16)?|nop|pop|push|rfe([id][ab])?|stm([id][ab])?|str(ex)?[bhd]?|(qd?)?sub|(sh?|q|u[qh]?)?sub(8|16)|[su]xt(a?h|a?b(16)?)|srs([id][ab])?|swpb?|swi|smi|tst|teq|wfe|wfi|yield)(eq|ne|cs|cc|mi|pl|vs|vc|hi|ls|ge|lt|gt|le|al|hs|lo)?[sptrx]?(?=\\s)"},e,s.QUOTE_STRING_MODE,{className:"string",begin:"'",end:"[^\\\\]'",relevance:0},{className:"title",begin:"\\|",end:"\\|",illegal:"\\n",relevance:0},{className:"number",variants:[{begin:"[#$=]?0x[0-9a-f]+"},{begin:"[#$=]?0b[01]+"},{begin:"[#$=]\\d+"},{begin:"\\b\\d+"}],relevance:0},{className:"symbol",variants:[{begin:"^[ \\t]*[a-z_\\.\\$][a-z0-9_\\.\\$]+:"},{begin:"^[a-z_\\.\\$][a-z0-9_\\.\\$]+"},{begin:"[=#]\\w+"}],relevance:0}]}}}()); +hljs.registerLanguage("d",function(){"use strict";return function(e){var a={$pattern:e.UNDERSCORE_IDENT_RE,keyword:"abstract alias align asm assert auto body break byte case cast catch class const continue debug default delete deprecated do else enum export extern final finally for foreach foreach_reverse|10 goto if immutable import in inout int interface invariant is lazy macro mixin module new nothrow out override package pragma private protected public pure ref return scope shared static struct super switch synchronized template this throw try typedef typeid typeof union unittest version void volatile while with __FILE__ __LINE__ __gshared|10 __thread __traits __DATE__ __EOF__ __TIME__ __TIMESTAMP__ __VENDOR__ __VERSION__",built_in:"bool cdouble cent cfloat char creal dchar delegate double dstring float function idouble ifloat ireal long real short string ubyte ucent uint ulong ushort wchar wstring",literal:"false null true"},d="((0|[1-9][\\d_]*)|0[bB][01_]+|0[xX]([\\da-fA-F][\\da-fA-F_]*|_[\\da-fA-F][\\da-fA-F_]*))",n="\\\\(['\"\\?\\\\abfnrtv]|u[\\dA-Fa-f]{4}|[0-7]{1,3}|x[\\dA-Fa-f]{2}|U[\\dA-Fa-f]{8})|&[a-zA-Z\\d]{2,};",t={className:"number",begin:"\\b"+d+"(L|u|U|Lu|LU|uL|UL)?",relevance:0},_={className:"number",begin:"\\b(((0[xX](([\\da-fA-F][\\da-fA-F_]*|_[\\da-fA-F][\\da-fA-F_]*)\\.([\\da-fA-F][\\da-fA-F_]*|_[\\da-fA-F][\\da-fA-F_]*)|\\.?([\\da-fA-F][\\da-fA-F_]*|_[\\da-fA-F][\\da-fA-F_]*))[pP][+-]?(0|[1-9][\\d_]*|\\d[\\d_]*|[\\d_]+?\\d))|((0|[1-9][\\d_]*|\\d[\\d_]*|[\\d_]+?\\d)(\\.\\d*|([eE][+-]?(0|[1-9][\\d_]*|\\d[\\d_]*|[\\d_]+?\\d)))|\\d+\\.(0|[1-9][\\d_]*|\\d[\\d_]*|[\\d_]+?\\d)(0|[1-9][\\d_]*|\\d[\\d_]*|[\\d_]+?\\d)|\\.(0|[1-9][\\d_]*)([eE][+-]?(0|[1-9][\\d_]*|\\d[\\d_]*|[\\d_]+?\\d))?))([fF]|L|i|[fF]i|Li)?|"+d+"(i|[fF]i|Li))",relevance:0},r={className:"string",begin:"'("+n+"|.)",end:"'",illegal:"."},i={className:"string",begin:'"',contains:[{begin:n,relevance:0}],end:'"[cwd]?'},s=e.COMMENT("\\/\\+","\\+\\/",{contains:["self"],relevance:10});return{name:"D",keywords:a,contains:[e.C_LINE_COMMENT_MODE,e.C_BLOCK_COMMENT_MODE,s,{className:"string",begin:'x"[\\da-fA-F\\s\\n\\r]*"[cwd]?',relevance:10},i,{className:"string",begin:'[rq]"',end:'"[cwd]?',relevance:5},{className:"string",begin:"`",end:"`[cwd]?"},{className:"string",begin:'q"\\{',end:'\\}"'},_,t,r,{className:"meta",begin:"^#!",end:"$",relevance:5},{className:"meta",begin:"#(line)",end:"$",relevance:5},{className:"keyword",begin:"@[a-zA-Z_][a-zA-Z_\\d]*"}]}}}()); +hljs.registerLanguage("handlebars",function(){"use strict";function e(...e){return e.map(e=>(function(e){return e?"string"==typeof e?e:e.source:null})(e)).join("")}return function(n){const a={"builtin-name":"action bindattr collection component concat debugger each each-in get hash if in input link-to loc log lookup mut outlet partial query-params render template textarea unbound unless view with yield"},t=/\[.*?\]/,s=/[^\s!"#%&'()*+,.\/;<=>@\[\\\]^`{|}~]+/,i=e("(",/'.*?'/,"|",/".*?"/,"|",t,"|",s,"|",/\.|\//,")+"),r=e("(",t,"|",s,")(?==)"),l={begin:i,lexemes:/[\w.\/]+/},c=n.inherit(l,{keywords:{literal:"true false undefined null"}}),o={begin:/\(/,end:/\)/},m={className:"attr",begin:r,relevance:0,starts:{begin:/=/,end:/=/,starts:{contains:[n.NUMBER_MODE,n.QUOTE_STRING_MODE,n.APOS_STRING_MODE,c,o]}}},d={contains:[n.NUMBER_MODE,n.QUOTE_STRING_MODE,n.APOS_STRING_MODE,{begin:/as\s+\|/,keywords:{keyword:"as"},end:/\|/,contains:[{begin:/\w+/}]},m,c,o],returnEnd:!0},g=n.inherit(l,{className:"name",keywords:a,starts:n.inherit(d,{end:/\)/})});o.contains=[g];const u=n.inherit(l,{keywords:a,className:"name",starts:n.inherit(d,{end:/}}/})}),b=n.inherit(l,{keywords:a,className:"name"}),h=n.inherit(l,{className:"name",keywords:a,starts:n.inherit(d,{end:/}}/})});return{name:"Handlebars",aliases:["hbs","html.hbs","html.handlebars","htmlbars"],case_insensitive:!0,subLanguage:"xml",contains:[{begin:/\\\{\{/,skip:!0},{begin:/\\\\(?=\{\{)/,skip:!0},n.COMMENT(/\{\{!--/,/--\}\}/),n.COMMENT(/\{\{!/,/\}\}/),{className:"template-tag",begin:/\{\{\{\{(?!\/)/,end:/\}\}\}\}/,contains:[u],starts:{end:/\{\{\{\{\//,returnEnd:!0,subLanguage:"xml"}},{className:"template-tag",begin:/\{\{\{\{\//,end:/\}\}\}\}/,contains:[b]},{className:"template-tag",begin:/\{\{#/,end:/\}\}/,contains:[u]},{className:"template-tag",begin:/\{\{(?=else\}\})/,end:/\}\}/,keywords:"else"},{className:"template-tag",begin:/\{\{\//,end:/\}\}/,contains:[b]},{className:"template-variable",begin:/\{\{\{/,end:/\}\}\}/,contains:[h]},{className:"template-variable",begin:/\{\{/,end:/\}\}/,contains:[h]}]}}}()); +hljs.registerLanguage("haskell",function(){"use strict";return function(e){var n={variants:[e.COMMENT("--","$"),e.COMMENT("{-","-}",{contains:["self"]})]},i={className:"meta",begin:"{-#",end:"#-}"},a={className:"meta",begin:"^#",end:"$"},s={className:"type",begin:"\\b[A-Z][\\w']*",relevance:0},l={begin:"\\(",end:"\\)",illegal:'"',contains:[i,a,{className:"type",begin:"\\b[A-Z][\\w]*(\\((\\.\\.|,|\\w+)\\))?"},e.inherit(e.TITLE_MODE,{begin:"[_a-z][\\w']*"}),n]};return{name:"Haskell",aliases:["hs"],keywords:"let in if then else case of where do module import hiding qualified type data newtype deriving class instance as default infix infixl infixr foreign export ccall stdcall cplusplus jvm dotnet safe unsafe family forall mdo proc rec",contains:[{beginKeywords:"module",end:"where",keywords:"module where",contains:[l,n],illegal:"\\W\\.|;"},{begin:"\\bimport\\b",end:"$",keywords:"import qualified as hiding",contains:[l,n],illegal:"\\W\\.|;"},{className:"class",begin:"^(\\s*)?(class|instance)\\b",end:"where",keywords:"class family instance where",contains:[s,l,n]},{className:"class",begin:"\\b(data|(new)?type)\\b",end:"$",keywords:"data family type newtype deriving",contains:[i,s,l,{begin:"{",end:"}",contains:l.contains},n]},{beginKeywords:"default",end:"$",contains:[s,l,n]},{beginKeywords:"infix infixl infixr",end:"$",contains:[e.C_NUMBER_MODE,n]},{begin:"\\bforeign\\b",end:"$",keywords:"foreign import export ccall stdcall cplusplus jvm dotnet safe unsafe",contains:[s,e.QUOTE_STRING_MODE,n]},{className:"meta",begin:"#!\\/usr\\/bin\\/env runhaskell",end:"$"},i,a,e.QUOTE_STRING_MODE,e.C_NUMBER_MODE,s,e.inherit(e.TITLE_MODE,{begin:"^[_a-z][\\w']*"}),n,{begin:"->|<-"}]}}}()); +hljs.registerLanguage("julia",function(){"use strict";return function(e){var r="[A-Za-z_\\u00A1-\\uFFFF][A-Za-z_0-9\\u00A1-\\uFFFF]*",t={$pattern:r,keyword:"in isa where baremodule begin break catch ccall const continue do else elseif end export false finally for function global if import importall let local macro module quote return true try using while type immutable abstract bitstype typealias ",literal:"true false ARGS C_NULL DevNull ENDIAN_BOM ENV I Inf Inf16 Inf32 Inf64 InsertionSort JULIA_HOME LOAD_PATH MergeSort NaN NaN16 NaN32 NaN64 PROGRAM_FILE QuickSort RoundDown RoundFromZero RoundNearest RoundNearestTiesAway RoundNearestTiesUp RoundToZero RoundUp STDERR STDIN STDOUT VERSION catalan e|0 eu|0 eulergamma golden im nothing pi γ π φ ",built_in:"ANY AbstractArray AbstractChannel AbstractFloat AbstractMatrix AbstractRNG AbstractSerializer AbstractSet AbstractSparseArray AbstractSparseMatrix AbstractSparseVector AbstractString AbstractUnitRange AbstractVecOrMat AbstractVector Any ArgumentError Array AssertionError Associative Base64DecodePipe Base64EncodePipe Bidiagonal BigFloat BigInt BitArray BitMatrix BitVector Bool BoundsError BufferStream CachingPool CapturedException CartesianIndex CartesianRange Cchar Cdouble Cfloat Channel Char Cint Cintmax_t Clong Clonglong ClusterManager Cmd CodeInfo Colon Complex Complex128 Complex32 Complex64 CompositeException Condition ConjArray ConjMatrix ConjVector Cptrdiff_t Cshort Csize_t Cssize_t Cstring Cuchar Cuint Cuintmax_t Culong Culonglong Cushort Cwchar_t Cwstring DataType Date DateFormat DateTime DenseArray DenseMatrix DenseVecOrMat DenseVector Diagonal Dict DimensionMismatch Dims DirectIndexString Display DivideError DomainError EOFError EachLine Enum Enumerate ErrorException Exception ExponentialBackOff Expr Factorization FileMonitor Float16 Float32 Float64 Function Future GlobalRef GotoNode HTML Hermitian IO IOBuffer IOContext IOStream IPAddr IPv4 IPv6 IndexCartesian IndexLinear IndexStyle InexactError InitError Int Int128 Int16 Int32 Int64 Int8 IntSet Integer InterruptException InvalidStateException Irrational KeyError LabelNode LinSpace LineNumberNode LoadError LowerTriangular MIME Matrix MersenneTwister Method MethodError MethodTable Module NTuple NewvarNode NullException Nullable Number ObjectIdDict OrdinalRange OutOfMemoryError OverflowError Pair ParseError PartialQuickSort PermutedDimsArray Pipe PollingFileWatcher ProcessExitedException Ptr QuoteNode RandomDevice Range RangeIndex Rational RawFD ReadOnlyMemoryError Real ReentrantLock Ref Regex RegexMatch RemoteChannel RemoteException RevString RoundingMode RowVector SSAValue SegmentationFault SerializationState Set SharedArray SharedMatrix SharedVector Signed SimpleVector Slot SlotNumber SparseMatrixCSC SparseVector StackFrame StackOverflowError StackTrace StepRange StepRangeLen StridedArray StridedMatrix StridedVecOrMat StridedVector String SubArray SubString SymTridiagonal Symbol Symmetric SystemError TCPSocket Task Text TextDisplay Timer Tridiagonal Tuple Type TypeError TypeMapEntry TypeMapLevel TypeName TypeVar TypedSlot UDPSocket UInt UInt128 UInt16 UInt32 UInt64 UInt8 UndefRefError UndefVarError UnicodeError UniformScaling Union UnionAll UnitRange Unsigned UpperTriangular Val Vararg VecElement VecOrMat Vector VersionNumber Void WeakKeyDict WeakRef WorkerConfig WorkerPool "},a={keywords:t,illegal:/<\//},n={className:"subst",begin:/\$\(/,end:/\)/,keywords:t},o={className:"variable",begin:"\\$"+r},i={className:"string",contains:[e.BACKSLASH_ESCAPE,n,o],variants:[{begin:/\w*"""/,end:/"""\w*/,relevance:10},{begin:/\w*"/,end:/"\w*/}]},l={className:"string",contains:[e.BACKSLASH_ESCAPE,n,o],begin:"`",end:"`"},s={className:"meta",begin:"@"+r};return a.name="Julia",a.contains=[{className:"number",begin:/(\b0x[\d_]*(\.[\d_]*)?|0x\.\d[\d_]*)p[-+]?\d+|\b0[box][a-fA-F0-9][a-fA-F0-9_]*|(\b\d[\d_]*(\.[\d_]*)?|\.\d[\d_]*)([eEfF][-+]?\d+)?/,relevance:0},{className:"string",begin:/'(.|\\[xXuU][a-zA-Z0-9]+)'/},i,l,s,{className:"comment",variants:[{begin:"#=",end:"=#",relevance:10},{begin:"#",end:"$"}]},e.HASH_COMMENT_MODE,{className:"keyword",begin:"\\b(((abstract|primitive)\\s+)type|(mutable\\s+)?struct)\\b"},{begin:/<:/}],n.contains=a.contains,a}}()); +hljs.registerLanguage("nim",function(){"use strict";return function(e){return{name:"Nim",aliases:["nim"],keywords:{keyword:"addr and as asm bind block break case cast const continue converter discard distinct div do elif else end enum except export finally for from func generic if import in include interface is isnot iterator let macro method mixin mod nil not notin object of or out proc ptr raise ref return shl shr static template try tuple type using var when while with without xor yield",literal:"shared guarded stdin stdout stderr result true false",built_in:"int int8 int16 int32 int64 uint uint8 uint16 uint32 uint64 float float32 float64 bool char string cstring pointer expr stmt void auto any range array openarray varargs seq set clong culong cchar cschar cshort cint csize clonglong cfloat cdouble clongdouble cuchar cushort cuint culonglong cstringarray semistatic"},contains:[{className:"meta",begin:/{\./,end:/\.}/,relevance:10},{className:"string",begin:/[a-zA-Z]\w*"/,end:/"/,contains:[{begin:/""/}]},{className:"string",begin:/([a-zA-Z]\w*)?"""/,end:/"""/},e.QUOTE_STRING_MODE,{className:"type",begin:/\b[A-Z]\w+\b/,relevance:0},{className:"number",relevance:0,variants:[{begin:/\b(0[xX][0-9a-fA-F][_0-9a-fA-F]*)('?[iIuU](8|16|32|64))?/},{begin:/\b(0o[0-7][_0-7]*)('?[iIuUfF](8|16|32|64))?/},{begin:/\b(0(b|B)[01][_01]*)('?[iIuUfF](8|16|32|64))?/},{begin:/\b(\d[_\d]*)('?[iIuUfF](8|16|32|64))?/}]},e.HASH_COMMENT_MODE]}}}()); +hljs.registerLanguage("nix",function(){"use strict";return function(e){var n={keyword:"rec with let in inherit assert if else then",literal:"true false or and null",built_in:"import abort baseNameOf dirOf isNull builtins map removeAttrs throw toString derivation"},i={className:"subst",begin:/\$\{/,end:/}/,keywords:n},t={className:"string",contains:[i],variants:[{begin:"''",end:"''"},{begin:'"',end:'"'}]},s=[e.NUMBER_MODE,e.HASH_COMMENT_MODE,e.C_BLOCK_COMMENT_MODE,t,{begin:/[a-zA-Z0-9-_]+(\s*=)/,returnBegin:!0,relevance:0,contains:[{className:"attr",begin:/\S+/}]}];return i.contains=s,{name:"Nix",aliases:["nixos"],keywords:n,contains:s}}}()); +hljs.registerLanguage("r",function(){"use strict";return function(e){var n="([a-zA-Z]|\\.[a-zA-Z.])[a-zA-Z0-9._]*";return{name:"R",contains:[e.HASH_COMMENT_MODE,{begin:n,keywords:{$pattern:n,keyword:"function if in break next repeat else for return switch while try tryCatch stop warning require library attach detach source setMethod setGeneric setGroupGeneric setClass ...",literal:"NULL NA TRUE FALSE T F Inf NaN NA_integer_|10 NA_real_|10 NA_character_|10 NA_complex_|10"},relevance:0},{className:"number",begin:"0[xX][0-9a-fA-F]+[Li]?\\b",relevance:0},{className:"number",begin:"\\d+(?:[eE][+\\-]?\\d*)?L\\b",relevance:0},{className:"number",begin:"\\d+\\.(?!\\d)(?:i\\b)?",relevance:0},{className:"number",begin:"\\d+(?:\\.\\d*)?(?:[eE][+\\-]?\\d*)?i?\\b",relevance:0},{className:"number",begin:"\\.\\d+(?:[eE][+\\-]?\\d*)?i?\\b",relevance:0},{begin:"`",end:"`",relevance:0},{className:"string",contains:[e.BACKSLASH_ESCAPE],variants:[{begin:'"',end:'"'},{begin:"'",end:"'"}]}]}}}()); +hljs.registerLanguage("scala",function(){"use strict";return function(e){var n={className:"subst",variants:[{begin:"\\$[A-Za-z0-9_]+"},{begin:"\\${",end:"}"}]},a={className:"string",variants:[{begin:'"',end:'"',illegal:"\\n",contains:[e.BACKSLASH_ESCAPE]},{begin:'"""',end:'"""',relevance:10},{begin:'[a-z]+"',end:'"',illegal:"\\n",contains:[e.BACKSLASH_ESCAPE,n]},{className:"string",begin:'[a-z]+"""',end:'"""',contains:[n],relevance:10}]},s={className:"type",begin:"\\b[A-Z][A-Za-z0-9_]*",relevance:0},t={className:"title",begin:/[^0-9\n\t "'(),.`{}\[\]:;][^\n\t "'(),.`{}\[\]:;]+|[^0-9\n\t "'(),.`{}\[\]:;=]/,relevance:0},i={className:"class",beginKeywords:"class object trait type",end:/[:={\[\n;]/,excludeEnd:!0,contains:[{beginKeywords:"extends with",relevance:10},{begin:/\[/,end:/\]/,excludeBegin:!0,excludeEnd:!0,relevance:0,contains:[s]},{className:"params",begin:/\(/,end:/\)/,excludeBegin:!0,excludeEnd:!0,relevance:0,contains:[s]},t]},l={className:"function",beginKeywords:"def",end:/[:={\[(\n;]/,excludeEnd:!0,contains:[t]};return{name:"Scala",keywords:{literal:"true false null",keyword:"type yield lazy override def with val var sealed abstract private trait object if forSome for while throw finally protected extends import final return else break new catch super class case package default try this match continue throws implicit"},contains:[e.C_LINE_COMMENT_MODE,e.C_BLOCK_COMMENT_MODE,a,{className:"symbol",begin:"'\\w[\\w\\d_]*(?!')"},s,l,i,e.C_NUMBER_MODE,{className:"meta",begin:"@[A-Za-z]+"}]}}}()); +hljs.registerLanguage("x86asm",function(){"use strict";return function(s){return{name:"Intel x86 Assembly",case_insensitive:!0,keywords:{$pattern:"[.%]?"+s.IDENT_RE,keyword:"lock rep repe repz repne repnz xaquire xrelease bnd nobnd aaa aad aam aas adc add and arpl bb0_reset bb1_reset bound bsf bsr bswap bt btc btr bts call cbw cdq cdqe clc cld cli clts cmc cmp cmpsb cmpsd cmpsq cmpsw cmpxchg cmpxchg486 cmpxchg8b cmpxchg16b cpuid cpu_read cpu_write cqo cwd cwde daa das dec div dmint emms enter equ f2xm1 fabs fadd faddp fbld fbstp fchs fclex fcmovb fcmovbe fcmove fcmovnb fcmovnbe fcmovne fcmovnu fcmovu fcom fcomi fcomip fcomp fcompp fcos fdecstp fdisi fdiv fdivp fdivr fdivrp femms feni ffree ffreep fiadd ficom ficomp fidiv fidivr fild fimul fincstp finit fist fistp fisttp fisub fisubr fld fld1 fldcw fldenv fldl2e fldl2t fldlg2 fldln2 fldpi fldz fmul fmulp fnclex fndisi fneni fninit fnop fnsave fnstcw fnstenv fnstsw fpatan fprem fprem1 fptan frndint frstor fsave fscale fsetpm fsin fsincos fsqrt fst fstcw fstenv fstp fstsw fsub fsubp fsubr fsubrp ftst fucom fucomi fucomip fucomp fucompp fxam fxch fxtract fyl2x fyl2xp1 hlt ibts icebp idiv imul in inc incbin insb insd insw int int01 int1 int03 int3 into invd invpcid invlpg invlpga iret iretd iretq iretw jcxz jecxz jrcxz jmp jmpe lahf lar lds lea leave les lfence lfs lgdt lgs lidt lldt lmsw loadall loadall286 lodsb lodsd lodsq lodsw loop loope loopne loopnz loopz lsl lss ltr mfence monitor mov movd movq movsb movsd movsq movsw movsx movsxd movzx mul mwait neg nop not or out outsb outsd outsw packssdw packsswb packuswb paddb paddd paddsb paddsiw paddsw paddusb paddusw paddw pand pandn pause paveb pavgusb pcmpeqb pcmpeqd pcmpeqw pcmpgtb pcmpgtd pcmpgtw pdistib pf2id pfacc pfadd pfcmpeq pfcmpge pfcmpgt pfmax pfmin pfmul pfrcp pfrcpit1 pfrcpit2 pfrsqit1 pfrsqrt pfsub pfsubr pi2fd pmachriw pmaddwd pmagw pmulhriw pmulhrwa pmulhrwc pmulhw pmullw pmvgezb pmvlzb pmvnzb pmvzb pop popa popad popaw popf popfd popfq popfw por prefetch prefetchw pslld psllq psllw psrad psraw psrld psrlq psrlw psubb psubd psubsb psubsiw psubsw psubusb psubusw psubw punpckhbw punpckhdq punpckhwd punpcklbw punpckldq punpcklwd push pusha pushad pushaw pushf pushfd pushfq pushfw pxor rcl rcr rdshr rdmsr rdpmc rdtsc rdtscp ret retf retn rol ror rdm rsdc rsldt rsm rsts sahf sal salc sar sbb scasb scasd scasq scasw sfence sgdt shl shld shr shrd sidt sldt skinit smi smint smintold smsw stc std sti stosb stosd stosq stosw str sub svdc svldt svts swapgs syscall sysenter sysexit sysret test ud0 ud1 ud2b ud2 ud2a umov verr verw fwait wbinvd wrshr wrmsr xadd xbts xchg xlatb xlat xor cmove cmovz cmovne cmovnz cmova cmovnbe cmovae cmovnb cmovb cmovnae cmovbe cmovna cmovg cmovnle cmovge cmovnl cmovl cmovnge cmovle cmovng cmovc cmovnc cmovo cmovno cmovs cmovns cmovp cmovpe cmovnp cmovpo je jz jne jnz ja jnbe jae jnb jb jnae jbe jna jg jnle jge jnl jl jnge jle jng jc jnc jo jno js jns jpo jnp jpe jp sete setz setne setnz seta setnbe setae setnb setnc setb setnae setcset setbe setna setg setnle setge setnl setl setnge setle setng sets setns seto setno setpe setp setpo setnp addps addss andnps andps cmpeqps cmpeqss cmpleps cmpless cmpltps cmpltss cmpneqps cmpneqss cmpnleps cmpnless cmpnltps cmpnltss cmpordps cmpordss cmpunordps cmpunordss cmpps cmpss comiss cvtpi2ps cvtps2pi cvtsi2ss cvtss2si cvttps2pi cvttss2si divps divss ldmxcsr maxps maxss minps minss movaps movhps movlhps movlps movhlps movmskps movntps movss movups mulps mulss orps rcpps rcpss rsqrtps rsqrtss shufps sqrtps sqrtss stmxcsr subps subss ucomiss unpckhps unpcklps xorps fxrstor fxrstor64 fxsave fxsave64 xgetbv xsetbv xsave xsave64 xsaveopt xsaveopt64 xrstor xrstor64 prefetchnta prefetcht0 prefetcht1 prefetcht2 maskmovq movntq pavgb pavgw pextrw pinsrw pmaxsw pmaxub pminsw pminub pmovmskb pmulhuw psadbw pshufw pf2iw pfnacc pfpnacc pi2fw pswapd maskmovdqu clflush movntdq movnti movntpd movdqa movdqu movdq2q movq2dq paddq pmuludq pshufd pshufhw pshuflw pslldq psrldq psubq punpckhqdq punpcklqdq addpd addsd andnpd andpd cmpeqpd cmpeqsd cmplepd cmplesd cmpltpd cmpltsd cmpneqpd cmpneqsd cmpnlepd cmpnlesd cmpnltpd cmpnltsd cmpordpd cmpordsd cmpunordpd cmpunordsd cmppd comisd cvtdq2pd cvtdq2ps cvtpd2dq cvtpd2pi cvtpd2ps cvtpi2pd cvtps2dq cvtps2pd cvtsd2si cvtsd2ss cvtsi2sd cvtss2sd cvttpd2pi cvttpd2dq cvttps2dq cvttsd2si divpd divsd maxpd maxsd minpd minsd movapd movhpd movlpd movmskpd movupd mulpd mulsd orpd shufpd sqrtpd sqrtsd subpd subsd ucomisd unpckhpd unpcklpd xorpd addsubpd addsubps haddpd haddps hsubpd hsubps lddqu movddup movshdup movsldup clgi stgi vmcall vmclear vmfunc vmlaunch vmload vmmcall vmptrld vmptrst vmread vmresume vmrun vmsave vmwrite vmxoff vmxon invept invvpid pabsb pabsw pabsd palignr phaddw phaddd phaddsw phsubw phsubd phsubsw pmaddubsw pmulhrsw pshufb psignb psignw psignd extrq insertq movntsd movntss lzcnt blendpd blendps blendvpd blendvps dppd dpps extractps insertps movntdqa mpsadbw packusdw pblendvb pblendw pcmpeqq pextrb pextrd pextrq phminposuw pinsrb pinsrd pinsrq pmaxsb pmaxsd pmaxud pmaxuw pminsb pminsd pminud pminuw pmovsxbw pmovsxbd pmovsxbq pmovsxwd pmovsxwq pmovsxdq pmovzxbw pmovzxbd pmovzxbq pmovzxwd pmovzxwq pmovzxdq pmuldq pmulld ptest roundpd roundps roundsd roundss crc32 pcmpestri pcmpestrm pcmpistri pcmpistrm pcmpgtq popcnt getsec pfrcpv pfrsqrtv movbe aesenc aesenclast aesdec aesdeclast aesimc aeskeygenassist vaesenc vaesenclast vaesdec vaesdeclast vaesimc vaeskeygenassist vaddpd vaddps vaddsd vaddss vaddsubpd vaddsubps vandpd vandps vandnpd vandnps vblendpd vblendps vblendvpd vblendvps vbroadcastss vbroadcastsd vbroadcastf128 vcmpeq_ospd vcmpeqpd vcmplt_ospd vcmpltpd vcmple_ospd vcmplepd vcmpunord_qpd vcmpunordpd vcmpneq_uqpd vcmpneqpd vcmpnlt_uspd vcmpnltpd vcmpnle_uspd vcmpnlepd vcmpord_qpd vcmpordpd vcmpeq_uqpd vcmpnge_uspd vcmpngepd vcmpngt_uspd vcmpngtpd vcmpfalse_oqpd vcmpfalsepd vcmpneq_oqpd vcmpge_ospd vcmpgepd vcmpgt_ospd vcmpgtpd vcmptrue_uqpd vcmptruepd vcmplt_oqpd vcmple_oqpd vcmpunord_spd vcmpneq_uspd vcmpnlt_uqpd vcmpnle_uqpd vcmpord_spd vcmpeq_uspd vcmpnge_uqpd vcmpngt_uqpd vcmpfalse_ospd vcmpneq_ospd vcmpge_oqpd vcmpgt_oqpd vcmptrue_uspd vcmppd vcmpeq_osps vcmpeqps vcmplt_osps vcmpltps vcmple_osps vcmpleps vcmpunord_qps vcmpunordps vcmpneq_uqps vcmpneqps vcmpnlt_usps vcmpnltps vcmpnle_usps vcmpnleps vcmpord_qps vcmpordps vcmpeq_uqps vcmpnge_usps vcmpngeps vcmpngt_usps vcmpngtps vcmpfalse_oqps vcmpfalseps vcmpneq_oqps vcmpge_osps vcmpgeps vcmpgt_osps vcmpgtps vcmptrue_uqps vcmptrueps vcmplt_oqps vcmple_oqps vcmpunord_sps vcmpneq_usps vcmpnlt_uqps vcmpnle_uqps vcmpord_sps vcmpeq_usps vcmpnge_uqps vcmpngt_uqps vcmpfalse_osps vcmpneq_osps vcmpge_oqps vcmpgt_oqps vcmptrue_usps vcmpps vcmpeq_ossd vcmpeqsd vcmplt_ossd vcmpltsd vcmple_ossd vcmplesd vcmpunord_qsd vcmpunordsd vcmpneq_uqsd vcmpneqsd vcmpnlt_ussd vcmpnltsd vcmpnle_ussd vcmpnlesd vcmpord_qsd vcmpordsd vcmpeq_uqsd vcmpnge_ussd vcmpngesd vcmpngt_ussd vcmpngtsd vcmpfalse_oqsd vcmpfalsesd vcmpneq_oqsd vcmpge_ossd vcmpgesd vcmpgt_ossd vcmpgtsd vcmptrue_uqsd vcmptruesd vcmplt_oqsd vcmple_oqsd vcmpunord_ssd vcmpneq_ussd vcmpnlt_uqsd vcmpnle_uqsd vcmpord_ssd vcmpeq_ussd vcmpnge_uqsd vcmpngt_uqsd vcmpfalse_ossd vcmpneq_ossd vcmpge_oqsd vcmpgt_oqsd vcmptrue_ussd vcmpsd vcmpeq_osss vcmpeqss vcmplt_osss vcmpltss vcmple_osss vcmpless vcmpunord_qss vcmpunordss vcmpneq_uqss vcmpneqss vcmpnlt_usss vcmpnltss vcmpnle_usss vcmpnless vcmpord_qss vcmpordss vcmpeq_uqss vcmpnge_usss vcmpngess vcmpngt_usss vcmpngtss vcmpfalse_oqss vcmpfalsess vcmpneq_oqss vcmpge_osss vcmpgess vcmpgt_osss vcmpgtss vcmptrue_uqss vcmptruess vcmplt_oqss vcmple_oqss vcmpunord_sss vcmpneq_usss vcmpnlt_uqss vcmpnle_uqss vcmpord_sss vcmpeq_usss vcmpnge_uqss vcmpngt_uqss vcmpfalse_osss vcmpneq_osss vcmpge_oqss vcmpgt_oqss vcmptrue_usss vcmpss vcomisd vcomiss vcvtdq2pd vcvtdq2ps vcvtpd2dq vcvtpd2ps vcvtps2dq vcvtps2pd vcvtsd2si vcvtsd2ss vcvtsi2sd vcvtsi2ss vcvtss2sd vcvtss2si vcvttpd2dq vcvttps2dq vcvttsd2si vcvttss2si vdivpd vdivps vdivsd vdivss vdppd vdpps vextractf128 vextractps vhaddpd vhaddps vhsubpd vhsubps vinsertf128 vinsertps vlddqu vldqqu vldmxcsr vmaskmovdqu vmaskmovps vmaskmovpd vmaxpd vmaxps vmaxsd vmaxss vminpd vminps vminsd vminss vmovapd vmovaps vmovd vmovq vmovddup vmovdqa vmovqqa vmovdqu vmovqqu vmovhlps vmovhpd vmovhps vmovlhps vmovlpd vmovlps vmovmskpd vmovmskps vmovntdq vmovntqq vmovntdqa vmovntpd vmovntps vmovsd vmovshdup vmovsldup vmovss vmovupd vmovups vmpsadbw vmulpd vmulps vmulsd vmulss vorpd vorps vpabsb vpabsw vpabsd vpacksswb vpackssdw vpackuswb vpackusdw vpaddb vpaddw vpaddd vpaddq vpaddsb vpaddsw vpaddusb vpaddusw vpalignr vpand vpandn vpavgb vpavgw vpblendvb vpblendw vpcmpestri vpcmpestrm vpcmpistri vpcmpistrm vpcmpeqb vpcmpeqw vpcmpeqd vpcmpeqq vpcmpgtb vpcmpgtw vpcmpgtd vpcmpgtq vpermilpd vpermilps vperm2f128 vpextrb vpextrw vpextrd vpextrq vphaddw vphaddd vphaddsw vphminposuw vphsubw vphsubd vphsubsw vpinsrb vpinsrw vpinsrd vpinsrq vpmaddwd vpmaddubsw vpmaxsb vpmaxsw vpmaxsd vpmaxub vpmaxuw vpmaxud vpminsb vpminsw vpminsd vpminub vpminuw vpminud vpmovmskb vpmovsxbw vpmovsxbd vpmovsxbq vpmovsxwd vpmovsxwq vpmovsxdq vpmovzxbw vpmovzxbd vpmovzxbq vpmovzxwd vpmovzxwq vpmovzxdq vpmulhuw vpmulhrsw vpmulhw vpmullw vpmulld vpmuludq vpmuldq vpor vpsadbw vpshufb vpshufd vpshufhw vpshuflw vpsignb vpsignw vpsignd vpslldq vpsrldq vpsllw vpslld vpsllq vpsraw vpsrad vpsrlw vpsrld vpsrlq vptest vpsubb vpsubw vpsubd vpsubq vpsubsb vpsubsw vpsubusb vpsubusw vpunpckhbw vpunpckhwd vpunpckhdq vpunpckhqdq vpunpcklbw vpunpcklwd vpunpckldq vpunpcklqdq vpxor vrcpps vrcpss vrsqrtps vrsqrtss vroundpd vroundps vroundsd vroundss vshufpd vshufps vsqrtpd vsqrtps vsqrtsd vsqrtss vstmxcsr vsubpd vsubps vsubsd vsubss vtestps vtestpd vucomisd vucomiss vunpckhpd vunpckhps vunpcklpd vunpcklps vxorpd vxorps vzeroall vzeroupper pclmullqlqdq pclmulhqlqdq pclmullqhqdq pclmulhqhqdq pclmulqdq vpclmullqlqdq vpclmulhqlqdq vpclmullqhqdq vpclmulhqhqdq vpclmulqdq vfmadd132ps vfmadd132pd vfmadd312ps vfmadd312pd vfmadd213ps vfmadd213pd vfmadd123ps vfmadd123pd vfmadd231ps vfmadd231pd vfmadd321ps vfmadd321pd vfmaddsub132ps vfmaddsub132pd vfmaddsub312ps vfmaddsub312pd vfmaddsub213ps vfmaddsub213pd vfmaddsub123ps vfmaddsub123pd vfmaddsub231ps vfmaddsub231pd vfmaddsub321ps vfmaddsub321pd vfmsub132ps vfmsub132pd vfmsub312ps vfmsub312pd vfmsub213ps vfmsub213pd vfmsub123ps vfmsub123pd vfmsub231ps vfmsub231pd vfmsub321ps vfmsub321pd vfmsubadd132ps vfmsubadd132pd vfmsubadd312ps vfmsubadd312pd vfmsubadd213ps vfmsubadd213pd vfmsubadd123ps vfmsubadd123pd vfmsubadd231ps vfmsubadd231pd vfmsubadd321ps vfmsubadd321pd vfnmadd132ps vfnmadd132pd vfnmadd312ps vfnmadd312pd vfnmadd213ps vfnmadd213pd vfnmadd123ps vfnmadd123pd vfnmadd231ps vfnmadd231pd vfnmadd321ps vfnmadd321pd vfnmsub132ps vfnmsub132pd vfnmsub312ps vfnmsub312pd vfnmsub213ps vfnmsub213pd vfnmsub123ps vfnmsub123pd vfnmsub231ps vfnmsub231pd vfnmsub321ps vfnmsub321pd vfmadd132ss vfmadd132sd vfmadd312ss vfmadd312sd vfmadd213ss vfmadd213sd vfmadd123ss vfmadd123sd vfmadd231ss vfmadd231sd vfmadd321ss vfmadd321sd vfmsub132ss vfmsub132sd vfmsub312ss vfmsub312sd vfmsub213ss vfmsub213sd vfmsub123ss vfmsub123sd vfmsub231ss vfmsub231sd vfmsub321ss vfmsub321sd vfnmadd132ss vfnmadd132sd vfnmadd312ss vfnmadd312sd vfnmadd213ss vfnmadd213sd vfnmadd123ss vfnmadd123sd vfnmadd231ss vfnmadd231sd vfnmadd321ss vfnmadd321sd vfnmsub132ss vfnmsub132sd vfnmsub312ss vfnmsub312sd vfnmsub213ss vfnmsub213sd vfnmsub123ss vfnmsub123sd vfnmsub231ss vfnmsub231sd vfnmsub321ss vfnmsub321sd rdfsbase rdgsbase rdrand wrfsbase wrgsbase vcvtph2ps vcvtps2ph adcx adox rdseed clac stac xstore xcryptecb xcryptcbc xcryptctr xcryptcfb xcryptofb montmul xsha1 xsha256 llwpcb slwpcb lwpval lwpins vfmaddpd vfmaddps vfmaddsd vfmaddss vfmaddsubpd vfmaddsubps vfmsubaddpd vfmsubaddps vfmsubpd vfmsubps vfmsubsd vfmsubss vfnmaddpd vfnmaddps vfnmaddsd vfnmaddss vfnmsubpd vfnmsubps vfnmsubsd vfnmsubss vfrczpd vfrczps vfrczsd vfrczss vpcmov vpcomb vpcomd vpcomq vpcomub vpcomud vpcomuq vpcomuw vpcomw vphaddbd vphaddbq vphaddbw vphadddq vphaddubd vphaddubq vphaddubw vphaddudq vphadduwd vphadduwq vphaddwd vphaddwq vphsubbw vphsubdq vphsubwd vpmacsdd vpmacsdqh vpmacsdql vpmacssdd vpmacssdqh vpmacssdql vpmacsswd vpmacssww vpmacswd vpmacsww vpmadcsswd vpmadcswd vpperm vprotb vprotd vprotq vprotw vpshab vpshad vpshaq vpshaw vpshlb vpshld vpshlq vpshlw vbroadcasti128 vpblendd vpbroadcastb vpbroadcastw vpbroadcastd vpbroadcastq vpermd vpermpd vpermps vpermq vperm2i128 vextracti128 vinserti128 vpmaskmovd vpmaskmovq vpsllvd vpsllvq vpsravd vpsrlvd vpsrlvq vgatherdpd vgatherqpd vgatherdps vgatherqps vpgatherdd vpgatherqd vpgatherdq vpgatherqq xabort xbegin xend xtest andn bextr blci blcic blsi blsic blcfill blsfill blcmsk blsmsk blsr blcs bzhi mulx pdep pext rorx sarx shlx shrx tzcnt tzmsk t1mskc valignd valignq vblendmpd vblendmps vbroadcastf32x4 vbroadcastf64x4 vbroadcasti32x4 vbroadcasti64x4 vcompresspd vcompressps vcvtpd2udq vcvtps2udq vcvtsd2usi vcvtss2usi vcvttpd2udq vcvttps2udq vcvttsd2usi vcvttss2usi vcvtudq2pd vcvtudq2ps vcvtusi2sd vcvtusi2ss vexpandpd vexpandps vextractf32x4 vextractf64x4 vextracti32x4 vextracti64x4 vfixupimmpd vfixupimmps vfixupimmsd vfixupimmss vgetexppd vgetexpps vgetexpsd vgetexpss vgetmantpd vgetmantps vgetmantsd vgetmantss vinsertf32x4 vinsertf64x4 vinserti32x4 vinserti64x4 vmovdqa32 vmovdqa64 vmovdqu32 vmovdqu64 vpabsq vpandd vpandnd vpandnq vpandq vpblendmd vpblendmq vpcmpltd vpcmpled vpcmpneqd vpcmpnltd vpcmpnled vpcmpd vpcmpltq vpcmpleq vpcmpneqq vpcmpnltq vpcmpnleq vpcmpq vpcmpequd vpcmpltud vpcmpleud vpcmpnequd vpcmpnltud vpcmpnleud vpcmpud vpcmpequq vpcmpltuq vpcmpleuq vpcmpnequq vpcmpnltuq vpcmpnleuq vpcmpuq vpcompressd vpcompressq vpermi2d vpermi2pd vpermi2ps vpermi2q vpermt2d vpermt2pd vpermt2ps vpermt2q vpexpandd vpexpandq vpmaxsq vpmaxuq vpminsq vpminuq vpmovdb vpmovdw vpmovqb vpmovqd vpmovqw vpmovsdb vpmovsdw vpmovsqb vpmovsqd vpmovsqw vpmovusdb vpmovusdw vpmovusqb vpmovusqd vpmovusqw vpord vporq vprold vprolq vprolvd vprolvq vprord vprorq vprorvd vprorvq vpscatterdd vpscatterdq vpscatterqd vpscatterqq vpsraq vpsravq vpternlogd vpternlogq vptestmd vptestmq vptestnmd vptestnmq vpxord vpxorq vrcp14pd vrcp14ps vrcp14sd vrcp14ss vrndscalepd vrndscaleps vrndscalesd vrndscaless vrsqrt14pd vrsqrt14ps vrsqrt14sd vrsqrt14ss vscalefpd vscalefps vscalefsd vscalefss vscatterdpd vscatterdps vscatterqpd vscatterqps vshuff32x4 vshuff64x2 vshufi32x4 vshufi64x2 kandnw kandw kmovw knotw kortestw korw kshiftlw kshiftrw kunpckbw kxnorw kxorw vpbroadcastmb2q vpbroadcastmw2d vpconflictd vpconflictq vplzcntd vplzcntq vexp2pd vexp2ps vrcp28pd vrcp28ps vrcp28sd vrcp28ss vrsqrt28pd vrsqrt28ps vrsqrt28sd vrsqrt28ss vgatherpf0dpd vgatherpf0dps vgatherpf0qpd vgatherpf0qps vgatherpf1dpd vgatherpf1dps vgatherpf1qpd vgatherpf1qps vscatterpf0dpd vscatterpf0dps vscatterpf0qpd vscatterpf0qps vscatterpf1dpd vscatterpf1dps vscatterpf1qpd vscatterpf1qps prefetchwt1 bndmk bndcl bndcu bndcn bndmov bndldx bndstx sha1rnds4 sha1nexte sha1msg1 sha1msg2 sha256rnds2 sha256msg1 sha256msg2 hint_nop0 hint_nop1 hint_nop2 hint_nop3 hint_nop4 hint_nop5 hint_nop6 hint_nop7 hint_nop8 hint_nop9 hint_nop10 hint_nop11 hint_nop12 hint_nop13 hint_nop14 hint_nop15 hint_nop16 hint_nop17 hint_nop18 hint_nop19 hint_nop20 hint_nop21 hint_nop22 hint_nop23 hint_nop24 hint_nop25 hint_nop26 hint_nop27 hint_nop28 hint_nop29 hint_nop30 hint_nop31 hint_nop32 hint_nop33 hint_nop34 hint_nop35 hint_nop36 hint_nop37 hint_nop38 hint_nop39 hint_nop40 hint_nop41 hint_nop42 hint_nop43 hint_nop44 hint_nop45 hint_nop46 hint_nop47 hint_nop48 hint_nop49 hint_nop50 hint_nop51 hint_nop52 hint_nop53 hint_nop54 hint_nop55 hint_nop56 hint_nop57 hint_nop58 hint_nop59 hint_nop60 hint_nop61 hint_nop62 hint_nop63",built_in:"ip eip rip al ah bl bh cl ch dl dh sil dil bpl spl r8b r9b r10b r11b r12b r13b r14b r15b ax bx cx dx si di bp sp r8w r9w r10w r11w r12w r13w r14w r15w eax ebx ecx edx esi edi ebp esp eip r8d r9d r10d r11d r12d r13d r14d r15d rax rbx rcx rdx rsi rdi rbp rsp r8 r9 r10 r11 r12 r13 r14 r15 cs ds es fs gs ss st st0 st1 st2 st3 st4 st5 st6 st7 mm0 mm1 mm2 mm3 mm4 mm5 mm6 mm7 xmm0 xmm1 xmm2 xmm3 xmm4 xmm5 xmm6 xmm7 xmm8 xmm9 xmm10 xmm11 xmm12 xmm13 xmm14 xmm15 xmm16 xmm17 xmm18 xmm19 xmm20 xmm21 xmm22 xmm23 xmm24 xmm25 xmm26 xmm27 xmm28 xmm29 xmm30 xmm31 ymm0 ymm1 ymm2 ymm3 ymm4 ymm5 ymm6 ymm7 ymm8 ymm9 ymm10 ymm11 ymm12 ymm13 ymm14 ymm15 ymm16 ymm17 ymm18 ymm19 ymm20 ymm21 ymm22 ymm23 ymm24 ymm25 ymm26 ymm27 ymm28 ymm29 ymm30 ymm31 zmm0 zmm1 zmm2 zmm3 zmm4 zmm5 zmm6 zmm7 zmm8 zmm9 zmm10 zmm11 zmm12 zmm13 zmm14 zmm15 zmm16 zmm17 zmm18 zmm19 zmm20 zmm21 zmm22 zmm23 zmm24 zmm25 zmm26 zmm27 zmm28 zmm29 zmm30 zmm31 k0 k1 k2 k3 k4 k5 k6 k7 bnd0 bnd1 bnd2 bnd3 cr0 cr1 cr2 cr3 cr4 cr8 dr0 dr1 dr2 dr3 dr8 tr3 tr4 tr5 tr6 tr7 r0 r1 r2 r3 r4 r5 r6 r7 r0b r1b r2b r3b r4b r5b r6b r7b r0w r1w r2w r3w r4w r5w r6w r7w r0d r1d r2d r3d r4d r5d r6d r7d r0h r1h r2h r3h r0l r1l r2l r3l r4l r5l r6l r7l r8l r9l r10l r11l r12l r13l r14l r15l db dw dd dq dt ddq do dy dz resb resw resd resq rest resdq reso resy resz incbin equ times byte word dword qword nosplit rel abs seg wrt strict near far a32 ptr",meta:"%define %xdefine %+ %undef %defstr %deftok %assign %strcat %strlen %substr %rotate %elif %else %endif %if %ifmacro %ifctx %ifidn %ifidni %ifid %ifnum %ifstr %iftoken %ifempty %ifenv %error %warning %fatal %rep %endrep %include %push %pop %repl %pathsearch %depend %use %arg %stacksize %local %line %comment %endcomment .nolist __FILE__ __LINE__ __SECT__ __BITS__ __OUTPUT_FORMAT__ __DATE__ __TIME__ __DATE_NUM__ __TIME_NUM__ __UTC_DATE__ __UTC_TIME__ __UTC_DATE_NUM__ __UTC_TIME_NUM__ __PASS__ struc endstruc istruc at iend align alignb sectalign daz nodaz up down zero default option assume public bits use16 use32 use64 default section segment absolute extern global common cpu float __utf16__ __utf16le__ __utf16be__ __utf32__ __utf32le__ __utf32be__ __float8__ __float16__ __float32__ __float64__ __float80m__ __float80e__ __float128l__ __float128h__ __Infinity__ __QNaN__ __SNaN__ Inf NaN QNaN SNaN float8 float16 float32 float64 float80m float80e float128l float128h __FLOAT_DAZ__ __FLOAT_ROUND__ __FLOAT__"},contains:[s.COMMENT(";","$",{relevance:0}),{className:"number",variants:[{begin:"\\b(?:([0-9][0-9_]*)?\\.[0-9_]*(?:[eE][+-]?[0-9_]+)?|(0[Xx])?[0-9][0-9_]*\\.?[0-9_]*(?:[pP](?:[+-]?[0-9_]+)?)?)\\b",relevance:0},{begin:"\\$[0-9][0-9A-Fa-f]*",relevance:0},{begin:"\\b(?:[0-9A-Fa-f][0-9A-Fa-f_]*[Hh]|[0-9][0-9_]*[DdTt]?|[0-7][0-7_]*[QqOo]|[0-1][0-1_]*[BbYy])\\b"},{begin:"\\b(?:0[Xx][0-9A-Fa-f_]+|0[DdTt][0-9_]+|0[QqOo][0-7_]+|0[BbYy][0-1_]+)\\b"}]},s.QUOTE_STRING_MODE,{className:"string",variants:[{begin:"'",end:"[^\\\\]'"},{begin:"`",end:"[^\\\\]`"}],relevance:0},{className:"symbol",variants:[{begin:"^\\s*[A-Za-z._?][A-Za-z0-9_$#@~.?]*(:|\\s+label)"},{begin:"^\\s*%%[A-Za-z0-9_$#@~.?]*:"}],relevance:0},{className:"subst",begin:"%[0-9]+",relevance:0},{className:"subst",begin:"%!S+",relevance:0},{className:"meta",begin:/^\s*\.[\w_-]+/}]}}}()); \ No newline at end of file diff --git a/index.html b/index.html new file mode 100644 index 00000000..60e3e927 --- /dev/null +++ b/index.html @@ -0,0 +1,237 @@ + + + + + + Introduction - JellyTau Documentation + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+ + + + + + + + + + + + + + + + + + + +
+ +
+ + + + + + + + +
+
+

JellyTau Documentation

+

Cross-platform Jellyfin client — business logic in a Rust backend, +SvelteKit + TypeScript frontend, talking over Tauri v2 IPC.

+ +

This site is published automatically from master by the publish-docs CI job.

+ +
+ + +
+
+ + + +
+ + + + + + + + + + + + + + + + + + +
+ + diff --git a/mark.min.js b/mark.min.js new file mode 100644 index 00000000..16362318 --- /dev/null +++ b/mark.min.js @@ -0,0 +1,7 @@ +/*!*************************************************** +* mark.js v8.11.1 +* https://markjs.io/ +* Copyright (c) 2014–2018, Julian Kühnel +* Released under the MIT license https://git.io/vwTVl +*****************************************************/ +!function(e,t){"object"==typeof exports&&"undefined"!=typeof module?module.exports=t():"function"==typeof define&&define.amd?define(t):e.Mark=t()}(this,function(){"use strict";var e="function"==typeof Symbol&&"symbol"==typeof Symbol.iterator?function(e){return typeof e}:function(e){return e&&"function"==typeof Symbol&&e.constructor===Symbol&&e!==Symbol.prototype?"symbol":typeof e},t=function(e,t){if(!(e instanceof t))throw new TypeError("Cannot call a class as a function")},n=function(){function e(e,t){for(var n=0;n1&&void 0!==arguments[1])||arguments[1],i=arguments.length>2&&void 0!==arguments[2]?arguments[2]:[],o=arguments.length>3&&void 0!==arguments[3]?arguments[3]:5e3;t(this,e),this.ctx=n,this.iframes=r,this.exclude=i,this.iframesTimeout=o}return n(e,[{key:"getContexts",value:function(){var e=[];return(void 0!==this.ctx&&this.ctx?NodeList.prototype.isPrototypeOf(this.ctx)?Array.prototype.slice.call(this.ctx):Array.isArray(this.ctx)?this.ctx:"string"==typeof this.ctx?Array.prototype.slice.call(document.querySelectorAll(this.ctx)):[this.ctx]:[]).forEach(function(t){var n=e.filter(function(e){return e.contains(t)}).length>0;-1!==e.indexOf(t)||n||e.push(t)}),e}},{key:"getIframeContents",value:function(e,t){var n=arguments.length>2&&void 0!==arguments[2]?arguments[2]:function(){},r=void 0;try{var i=e.contentWindow;if(r=i.document,!i||!r)throw new Error("iframe inaccessible")}catch(e){n()}r&&t(r)}},{key:"isIframeBlank",value:function(e){var t="about:blank",n=e.getAttribute("src").trim();return e.contentWindow.location.href===t&&n!==t&&n}},{key:"observeIframeLoad",value:function(e,t,n){var r=this,i=!1,o=null,a=function a(){if(!i){i=!0,clearTimeout(o);try{r.isIframeBlank(e)||(e.removeEventListener("load",a),r.getIframeContents(e,t,n))}catch(e){n()}}};e.addEventListener("load",a),o=setTimeout(a,this.iframesTimeout)}},{key:"onIframeReady",value:function(e,t,n){try{"complete"===e.contentWindow.document.readyState?this.isIframeBlank(e)?this.observeIframeLoad(e,t,n):this.getIframeContents(e,t,n):this.observeIframeLoad(e,t,n)}catch(e){n()}}},{key:"waitForIframes",value:function(e,t){var n=this,r=0;this.forEachIframe(e,function(){return!0},function(e){r++,n.waitForIframes(e.querySelector("html"),function(){--r||t()})},function(e){e||t()})}},{key:"forEachIframe",value:function(t,n,r){var i=this,o=arguments.length>3&&void 0!==arguments[3]?arguments[3]:function(){},a=t.querySelectorAll("iframe"),s=a.length,c=0;a=Array.prototype.slice.call(a);var u=function(){--s<=0&&o(c)};s||u(),a.forEach(function(t){e.matches(t,i.exclude)?u():i.onIframeReady(t,function(e){n(t)&&(c++,r(e)),u()},u)})}},{key:"createIterator",value:function(e,t,n){return document.createNodeIterator(e,t,n,!1)}},{key:"createInstanceOnIframe",value:function(t){return new e(t.querySelector("html"),this.iframes)}},{key:"compareNodeIframe",value:function(e,t,n){if(e.compareDocumentPosition(n)&Node.DOCUMENT_POSITION_PRECEDING){if(null===t)return!0;if(t.compareDocumentPosition(n)&Node.DOCUMENT_POSITION_FOLLOWING)return!0}return!1}},{key:"getIteratorNode",value:function(e){var t=e.previousNode();return{prevNode:t,node:null===t?e.nextNode():e.nextNode()&&e.nextNode()}}},{key:"checkIframeFilter",value:function(e,t,n,r){var i=!1,o=!1;return r.forEach(function(e,t){e.val===n&&(i=t,o=e.handled)}),this.compareNodeIframe(e,t,n)?(!1!==i||o?!1===i||o||(r[i].handled=!0):r.push({val:n,handled:!0}),!0):(!1===i&&r.push({val:n,handled:!1}),!1)}},{key:"handleOpenIframes",value:function(e,t,n,r){var i=this;e.forEach(function(e){e.handled||i.getIframeContents(e.val,function(e){i.createInstanceOnIframe(e).forEachNode(t,n,r)})})}},{key:"iterateThroughNodes",value:function(e,t,n,r,i){for(var o,a=this,s=this.createIterator(t,e,r),c=[],u=[],l=void 0,h=void 0;void 0,o=a.getIteratorNode(s),h=o.prevNode,l=o.node;)this.iframes&&this.forEachIframe(t,function(e){return a.checkIframeFilter(l,h,e,c)},function(t){a.createInstanceOnIframe(t).forEachNode(e,function(e){return u.push(e)},r)}),u.push(l);u.forEach(function(e){n(e)}),this.iframes&&this.handleOpenIframes(c,e,n,r),i()}},{key:"forEachNode",value:function(e,t,n){var r=this,i=arguments.length>3&&void 0!==arguments[3]?arguments[3]:function(){},o=this.getContexts(),a=o.length;a||i(),o.forEach(function(o){var s=function(){r.iterateThroughNodes(e,o,t,n,function(){--a<=0&&i()})};r.iframes?r.waitForIframes(o,s):s()})}}],[{key:"matches",value:function(e,t){var n="string"==typeof t?[t]:t,r=e.matches||e.matchesSelector||e.msMatchesSelector||e.mozMatchesSelector||e.oMatchesSelector||e.webkitMatchesSelector;if(r){var i=!1;return n.every(function(t){return!r.call(e,t)||(i=!0,!1)}),i}return!1}}]),e}(),o=function(){function e(n){t(this,e),this.opt=r({},{diacritics:!0,synonyms:{},accuracy:"partially",caseSensitive:!1,ignoreJoiners:!1,ignorePunctuation:[],wildcards:"disabled"},n)}return n(e,[{key:"create",value:function(e){return"disabled"!==this.opt.wildcards&&(e=this.setupWildcardsRegExp(e)),e=this.escapeStr(e),Object.keys(this.opt.synonyms).length&&(e=this.createSynonymsRegExp(e)),(this.opt.ignoreJoiners||this.opt.ignorePunctuation.length)&&(e=this.setupIgnoreJoinersRegExp(e)),this.opt.diacritics&&(e=this.createDiacriticsRegExp(e)),e=this.createMergedBlanksRegExp(e),(this.opt.ignoreJoiners||this.opt.ignorePunctuation.length)&&(e=this.createJoinersRegExp(e)),"disabled"!==this.opt.wildcards&&(e=this.createWildcardsRegExp(e)),e=this.createAccuracyRegExp(e),new RegExp(e,"gm"+(this.opt.caseSensitive?"":"i"))}},{key:"escapeStr",value:function(e){return e.replace(/[\-\[\]\/\{\}\(\)\*\+\?\.\\\^\$\|]/g,"\\$&")}},{key:"createSynonymsRegExp",value:function(e){var t=this.opt.synonyms,n=this.opt.caseSensitive?"":"i",r=this.opt.ignoreJoiners||this.opt.ignorePunctuation.length?"\0":"";for(var i in t)if(t.hasOwnProperty(i)){var o=t[i],a="disabled"!==this.opt.wildcards?this.setupWildcardsRegExp(i):this.escapeStr(i),s="disabled"!==this.opt.wildcards?this.setupWildcardsRegExp(o):this.escapeStr(o);""!==a&&""!==s&&(e=e.replace(new RegExp("("+this.escapeStr(a)+"|"+this.escapeStr(s)+")","gm"+n),r+"("+this.processSynonyms(a)+"|"+this.processSynonyms(s)+")"+r))}return e}},{key:"processSynonyms",value:function(e){return(this.opt.ignoreJoiners||this.opt.ignorePunctuation.length)&&(e=this.setupIgnoreJoinersRegExp(e)),e}},{key:"setupWildcardsRegExp",value:function(e){return(e=e.replace(/(?:\\)*\?/g,function(e){return"\\"===e.charAt(0)?"?":""})).replace(/(?:\\)*\*/g,function(e){return"\\"===e.charAt(0)?"*":""})}},{key:"createWildcardsRegExp",value:function(e){var t="withSpaces"===this.opt.wildcards;return e.replace(/\u0001/g,t?"[\\S\\s]?":"\\S?").replace(/\u0002/g,t?"[\\S\\s]*?":"\\S*")}},{key:"setupIgnoreJoinersRegExp",value:function(e){return e.replace(/[^(|)\\]/g,function(e,t,n){var r=n.charAt(t+1);return/[(|)\\]/.test(r)||""===r?e:e+"\0"})}},{key:"createJoinersRegExp",value:function(e){var t=[],n=this.opt.ignorePunctuation;return Array.isArray(n)&&n.length&&t.push(this.escapeStr(n.join(""))),this.opt.ignoreJoiners&&t.push("\\u00ad\\u200b\\u200c\\u200d"),t.length?e.split(/\u0000+/).join("["+t.join("")+"]*"):e}},{key:"createDiacriticsRegExp",value:function(e){var t=this.opt.caseSensitive?"":"i",n=this.opt.caseSensitive?["aàáảãạăằắẳẵặâầấẩẫậäåāą","AÀÁẢÃẠĂẰẮẲẴẶÂẦẤẨẪẬÄÅĀĄ","cçćč","CÇĆČ","dđď","DĐĎ","eèéẻẽẹêềếểễệëěēę","EÈÉẺẼẸÊỀẾỂỄỆËĚĒĘ","iìíỉĩịîïī","IÌÍỈĨỊÎÏĪ","lł","LŁ","nñňń","NÑŇŃ","oòóỏõọôồốổỗộơởỡớờợöøō","OÒÓỎÕỌÔỒỐỔỖỘƠỞỠỚỜỢÖØŌ","rř","RŘ","sšśșş","SŠŚȘŞ","tťțţ","TŤȚŢ","uùúủũụưừứửữựûüůū","UÙÚỦŨỤƯỪỨỬỮỰÛÜŮŪ","yýỳỷỹỵÿ","YÝỲỶỸỴŸ","zžżź","ZŽŻŹ"]:["aàáảãạăằắẳẵặâầấẩẫậäåāąAÀÁẢÃẠĂẰẮẲẴẶÂẦẤẨẪẬÄÅĀĄ","cçćčCÇĆČ","dđďDĐĎ","eèéẻẽẹêềếểễệëěēęEÈÉẺẼẸÊỀẾỂỄỆËĚĒĘ","iìíỉĩịîïīIÌÍỈĨỊÎÏĪ","lłLŁ","nñňńNÑŇŃ","oòóỏõọôồốổỗộơởỡớờợöøōOÒÓỎÕỌÔỒỐỔỖỘƠỞỠỚỜỢÖØŌ","rřRŘ","sšśșşSŠŚȘŞ","tťțţTŤȚŢ","uùúủũụưừứửữựûüůūUÙÚỦŨỤƯỪỨỬỮỰÛÜŮŪ","yýỳỷỹỵÿYÝỲỶỸỴŸ","zžżźZŽŻŹ"],r=[];return e.split("").forEach(function(i){n.every(function(n){if(-1!==n.indexOf(i)){if(r.indexOf(n)>-1)return!1;e=e.replace(new RegExp("["+n+"]","gm"+t),"["+n+"]"),r.push(n)}return!0})}),e}},{key:"createMergedBlanksRegExp",value:function(e){return e.replace(/[\s]+/gim,"[\\s]+")}},{key:"createAccuracyRegExp",value:function(e){var t=this,n=this.opt.accuracy,r="string"==typeof n?n:n.value,i="";switch(("string"==typeof n?[]:n.limiters).forEach(function(e){i+="|"+t.escapeStr(e)}),r){case"partially":default:return"()("+e+")";case"complementary":return"()([^"+(i="\\s"+(i||this.escapeStr("!\"#$%&'()*+,-./:;<=>?@[\\]^_`{|}~¡¿")))+"]*"+e+"[^"+i+"]*)";case"exactly":return"(^|\\s"+i+")("+e+")(?=$|\\s"+i+")"}}}]),e}(),a=function(){function a(e){t(this,a),this.ctx=e,this.ie=!1;var n=window.navigator.userAgent;(n.indexOf("MSIE")>-1||n.indexOf("Trident")>-1)&&(this.ie=!0)}return n(a,[{key:"log",value:function(t){var n=arguments.length>1&&void 0!==arguments[1]?arguments[1]:"debug",r=this.opt.log;this.opt.debug&&"object"===(void 0===r?"undefined":e(r))&&"function"==typeof r[n]&&r[n]("mark.js: "+t)}},{key:"getSeparatedKeywords",value:function(e){var t=this,n=[];return e.forEach(function(e){t.opt.separateWordSearch?e.split(" ").forEach(function(e){e.trim()&&-1===n.indexOf(e)&&n.push(e)}):e.trim()&&-1===n.indexOf(e)&&n.push(e)}),{keywords:n.sort(function(e,t){return t.length-e.length}),length:n.length}}},{key:"isNumeric",value:function(e){return Number(parseFloat(e))==e}},{key:"checkRanges",value:function(e){var t=this;if(!Array.isArray(e)||"[object Object]"!==Object.prototype.toString.call(e[0]))return this.log("markRanges() will only accept an array of objects"),this.opt.noMatch(e),[];var n=[],r=0;return e.sort(function(e,t){return e.start-t.start}).forEach(function(e){var i=t.callNoMatchOnInvalidRanges(e,r),o=i.start,a=i.end;i.valid&&(e.start=o,e.length=a-o,n.push(e),r=a)}),n}},{key:"callNoMatchOnInvalidRanges",value:function(e,t){var n=void 0,r=void 0,i=!1;return e&&void 0!==e.start?(r=(n=parseInt(e.start,10))+parseInt(e.length,10),this.isNumeric(e.start)&&this.isNumeric(e.length)&&r-t>0&&r-n>0?i=!0:(this.log("Ignoring invalid or overlapping range: "+JSON.stringify(e)),this.opt.noMatch(e))):(this.log("Ignoring invalid range: "+JSON.stringify(e)),this.opt.noMatch(e)),{start:n,end:r,valid:i}}},{key:"checkWhitespaceRanges",value:function(e,t,n){var r=void 0,i=!0,o=n.length,a=t-o,s=parseInt(e.start,10)-a;return(r=(s=s>o?o:s)+parseInt(e.length,10))>o&&(r=o,this.log("End range automatically set to the max value of "+o)),s<0||r-s<0||s>o||r>o?(i=!1,this.log("Invalid range: "+JSON.stringify(e)),this.opt.noMatch(e)):""===n.substring(s,r).replace(/\s+/g,"")&&(i=!1,this.log("Skipping whitespace only range: "+JSON.stringify(e)),this.opt.noMatch(e)),{start:s,end:r,valid:i}}},{key:"getTextNodes",value:function(e){var t=this,n="",r=[];this.iterator.forEachNode(NodeFilter.SHOW_TEXT,function(e){r.push({start:n.length,end:(n+=e.textContent).length,node:e})},function(e){return t.matchesExclude(e.parentNode)?NodeFilter.FILTER_REJECT:NodeFilter.FILTER_ACCEPT},function(){e({value:n,nodes:r})})}},{key:"matchesExclude",value:function(e){return i.matches(e,this.opt.exclude.concat(["script","style","title","head","html"]))}},{key:"wrapRangeInTextNode",value:function(e,t,n){var r=this.opt.element?this.opt.element:"mark",i=e.splitText(t),o=i.splitText(n-t),a=document.createElement(r);return a.setAttribute("data-markjs","true"),this.opt.className&&a.setAttribute("class",this.opt.className),a.textContent=i.textContent,i.parentNode.replaceChild(a,i),o}},{key:"wrapRangeInMappedTextNode",value:function(e,t,n,r,i){var o=this;e.nodes.every(function(a,s){var c=e.nodes[s+1];if(void 0===c||c.start>t){if(!r(a.node))return!1;var u=t-a.start,l=(n>a.end?a.end:n)-a.start,h=e.value.substr(0,a.start),f=e.value.substr(l+a.start);if(a.node=o.wrapRangeInTextNode(a.node,u,l),e.value=h+f,e.nodes.forEach(function(t,n){n>=s&&(e.nodes[n].start>0&&n!==s&&(e.nodes[n].start-=l),e.nodes[n].end-=l)}),n-=l,i(a.node.previousSibling,a.start),!(n>a.end))return!1;t=a.end}return!0})}},{key:"wrapGroups",value:function(e,t,n,r){return r((e=this.wrapRangeInTextNode(e,t,t+n)).previousSibling),e}},{key:"separateGroups",value:function(e,t,n,r,i){for(var o=t.length,a=1;a-1&&r(t[a],e)&&(e=this.wrapGroups(e,s,t[a].length,i))}return e}},{key:"wrapMatches",value:function(e,t,n,r,i){var o=this,a=0===t?0:t+1;this.getTextNodes(function(t){t.nodes.forEach(function(t){t=t.node;for(var i=void 0;null!==(i=e.exec(t.textContent))&&""!==i[a];){if(o.opt.separateGroups)t=o.separateGroups(t,i,a,n,r);else{if(!n(i[a],t))continue;var s=i.index;if(0!==a)for(var c=1;c + + + + + JellyTau Documentation + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+ + + + + + + + + + + + + + + + + + + +
+ +
+ + + + + + + + +
+
+

JellyTau Documentation

+

Cross-platform Jellyfin client — business logic in a Rust backend, +SvelteKit + TypeScript frontend, talking over Tauri v2 IPC.

+ +

This site is published automatically from master by the publish-docs CI job.

+

Requirements Specification

+

This document captures JellyTau's user requirements, software requirements, +traceability matrix, test traceability, and known technical debt.

+

For a narrative overview of the system design, see +docs/architecture/. For development workflows, see the +README and scripts/README.md.

+

1. User Requirements

+
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
IDRequirementPriorityStatus
UR-001Run the app on multiple platforms (Linux, Android)HighIn Progress
UR-002Access media when online or offlineHighDone
UR-003Play videosHighDone
UR-004Play audio uninterruptedHighDone
UR-005Control media playback (pause, play, skip, scrub)HighDone
UR-006Control media when device is on lock screen or via BLE headsetsMediumDone (Android); not implemented on Linux — see IR-005
UR-007Navigate media in libraryHighDone
UR-008Search media across librariesHighDone
UR-009Connect to Jellyfin to access mediaHighDone
UR-010Control playback of Jellyfin remote sessionsLowDone
UR-011Download media on demandMediumDone
UR-012Login info shall be stored securely and persistentlyHighDone
UR-013View and manage downloaded mediaMediumDone
UR-014Make and edit playlists of music that sync back to JellyfinMediumDone
UR-015View and manage current audio queue (add, reorder tracks)MediumDone
UR-016Change system settings while playing (brightness, volume)LowPlanned
UR-017Like or unlike audio, albums, movies, etc.MediumDone
UR-018Choose to download series, albums, songs, artist discographyMediumDone
UR-019Resume playback from where you left off (movies, shows, albums)HighDone
UR-020Select subtitles for video contentHighDone
UR-021Select audio track for video contentHighDone
UR-022Control streaming quality and transcoding settingsMediumPlanned
UR-023View "Next Up" / Continue Watching on home screen; auto-play next episode with countdown popup and configurable episode limitMediumDone
UR-024View recently added content on serverMediumDone
UR-025Sync watch history and progress back to JellyfinHighDone
UR-026Sleep timer for audio and video playback (roller UI, time/track/episode modes)LowDone
UR-027Audio equalizer for sound customizationLowDone (Linux; Android pending device verification)
UR-028Navigate to artist/album by tapping names in now playing viewHighDone
UR-029Toggle between grid and list view in libraryMediumDone
UR-030Quick genre browsing and filteringMediumDone
UR-031Crossfade between audio tracksLowNot implemented (blocked — see DR-034)
UR-032Gapless playback for seamless album listeningMediumDone (Linux; Android pending device verification)
UR-033Volume normalization to prevent volume jumps between tracksLowDone (Linux; Android pending device verification)
UR-034Rich home screen with hero banners, carousels, and personalized sectionsHighDone
UR-035View cast/crew (actors, directors) on movie/show detail pagesHighDone
UR-036Navigate to actor/person page showing their filmographyMediumDone
UR-037Visually appealing video library with poster grids and metadataHighDone
UR-038Movie/show detail page with backdrop, ratings, and rich metadataHighDone
UR-039Navigate between main sections via bottom navigation barHighDone
UR-040Keep a video's audio playing when the app is backgrounded or the screen is locked, stopping video decode until the app returns to the foreground (per-player toggle; Android)MediumDone (pending device verification)
UR-041Continue watching locally-playing video in a floating picture-in-picture window when leaving the app (Android) — PiP applies to video only, never to audio playback, library/menu browsing, or remote/cast sessionsMediumDone
UR-042Authenticate to a server and manage the session lifecycle (connect, log in, Quick Connect, background session verification, re-authenticate, log out)HighDone
UR-043Automatically detect server reachability and switch between online and offline operation without user interventionHighDone
UR-044Pin downloaded media so it is protected from automatic cache evictionLowDone
UR-045Predictively pre-cache likely-next media (queue lookahead and album affinity) within a storage budgetLowDone
UR-046Group multiple remote players into a synchronized playback group (LMS SyncGroups)LowDone
UR-047Manage multiple Jellyfin servers (add, list, remove) and switch the active server/accountMediumPlanned (backend store done; switcher UI pending)
UR-048See the next episodes of a series directly below the episode/series being viewed, above cast and similar-shows content, so continuing a show is the shortest path (see ux-flows.md §5B)HighDone
UR-049Search is scoped by where it was started — inside a library it searches that library, from Home/library-root/search-tab it searches everything — with the scope shown as filter chips under the search bar that preselect from context and can be changed without retyping (see ux-flows.md §6.1)HighImplemented
UR-050Reorder search result groups (Songs, Albums, Artists, Movies, TV Shows) by drag and drop in settings, so the media a user cares about most appears first (see ux-flows.md §6.3)MediumImplemented
UR-051Browse library pages in a consistent layout where card shape signals media type (square music, poster video, thumbnail episode), ordinal content stays listed, and the grid/list preference persists across pages (see ux-flows.md §5A)MediumPartial (implemented; toggle not reachable from settings)
UR-052While offline, library pages show only media available on the device by default, with an opt-in toggle that additionally reveals the cached server catalog as greyed-out entries which can be queued for download on the next reconnectHighDone
UR-053Restrict media downloads to unmetered networks via a "WiFi Only" setting: when enabled, queued downloads are held while the device is on cellular or a metered connection (including metered WiFi hotspots) and resume automatically once an unmetered network is availableMediumDone (pending device verification)
UR-054Reach account actions (Settings, Downloads, Display preferences, Sign out) from every authenticated screen via a single account menu anchored to the user's name, identical on desktop and mobile (see ux-flows.md §1.2)HighDone
UR-055Browse downloaded media as an offline-scoped library — reusing the same library grids, cards, and detail pages as online browsing, showing only libraries/containers with downloaded content — with the transfer-progress list demoted to a secondary "Transfers" view (see ux-flows.md §7.2)HighDone
UR-056See how much disk each downloaded item/album/series consumes, in familiar rounded units shown on the card and detail page, with a device total on the Downloaded surface and a reclaim amount stated at the point of removal (see ux-flows.md §7.3.1)MediumDone
UR-057Settings apply the instant a control is changed — no "Save" button and no save/dirty state — so leaving the page never loses a change; sliders show a live readout while dragging but persist on release (see ux-flows.md §8.1)MediumDone
UR-058On the home screen, a tap on a media card opens the item (movie/episode detail page, or the series Episode Focus View for episodes) rather than starting playback; a long-press starts "play now" after a confirm; an episode detail/focus page links back to its parent series and season (see ux-flows.md §5B.5 and §5B.1)MediumDone
UR-059Skipping to the next episode records the episode left behind as fully watched rather than saving a mid-episode resume point — skipping means "done with this one", not "stopped here" — and Continue Watching hides episodes the viewer has already moved past (a partial position behind that series' next-up episode), so the row only ever offers genuinely unfinished mediaMediumDone
UR-060Search results are ordered by how well they match: a name that starts with the query outranks one matching mid-word (typing "parks" finds "Parks and Recreation" before "Sparks of Love"), and at equal match quality a container outranks its contents (a series before its episodes). Results are grouped into distinct categories — TV Shows, Episodes, Movies, Songs, Albums, Artists and People — so a show never competes with its own episodes for the same slot, and searching an actor's name reaches their bioHighDone
UR-061Double tapping the video skips within it — right half jumps forward 30 seconds, left half jumps back 10 seconds — with an on-screen indicator naming the amount. A double tap leaves the play state unchanged — playing jumps and keeps playing, paused jumps and stays paused — because the second tap re-toggles what the first tap toggled (see DR-098); the skip lands relative to the position the player actually reports, and repeated double taps accumulate rather than all skipping from the same spotMediumDone
UR-062Opening a TV series lands the viewer where they are in it, not at season 1: the series page scrolls the current season into view and highlights the current episode, and the hero button opens that episode (labelled Resume S2E4 / Play S1E1). "Current" means the episode in progress, else the server's Next Up for that series, else the first unwatched episode, else the first — resolved by the backend so it also works offline. A season is never a page of its own: every route that names a season lands on the series with that season in view, so the episodes of all seasons are always one continuous scrollable listHighDone
UR-063Each video library is one page, not three. Browsing (hero, Continue Watching, Next Up, Recently Added, genre rows), the full title grid, and the genre browser are tabs of /library/tv and /library/movies rather than separate routes with inconsistent names (/library/tv/shows vs /library/movies/all, /library/shows/genres vs /library/movies/genres). The old routes redirect so existing links keep workingMediumDone
UR-064Watch history can be erased, per series and per season, from the series page. Clearing marks every episode inside unwatched and clears resume positions, so the show returns to "never watched" and reopens on its premiere. It asks for confirmation first (it cannot be undone) and requires a connection to the server, since history cleared only locally would be undone by the next syncMediumDone
UR-065Search answers from a locally indexed copy of the library, so results appear as fast as the device can query rather than at the speed of a round trip to the server, and the same results are found with the server unreachable. A background job keeps the index current — refreshing on a schedule rather than only at app start, dropping media removed from the server, and covering everything the result groups can show (including artists and people). The server is still queried in the background so media added since the last index still turns up, merged in without reordering what is already on screenHighImplemented
UR-066The app's own chrome stays clear of the device's system chrome. On Android the bottom navigation sits above the navigation/gesture bar instead of underneath it, the header clears the status bar, and full-screen video and audio playback keep their controls inside the usable screen — clear of the gesture bar and, in landscape, of the display notch. This must hold across navigation modes (gesture and 3-button) and rotation, not only on the handsets it happened to be tested onHighDone
UR-067Favourited media can be found again. A Favourites page lists everything favourited across all libraries, scoped by tabs (All / Movies / Shows / Music); the home screen carries favourite rows for movies, shows and music, hidden when a category is empty; and each library page can be filtered to favourites in place. Without this the like button writes to a store nothing readsMediumDone
UR-068Anything the app shows can be favourited where it is shown — from a movie, series, episode, album, artist or playlist page, and from any card in a grid or carousel — not only from the player while the item happens to be playingMediumDone
UR-069Favourite state agrees with the server in both directions. An item favourited in another Jellyfin client shows as favourited here without being touched, and an item favourited here while the server is unreachable reaches the server once it returns — without the user going back to the screen where they marked itMediumDone
UR-070Playback quality is the viewer's choice: the player offers the bitrates the server can produce for what is playing, and changing one resumes at the same point with the same audio and subtitle tracks. Because the chosen rendition can change at any moment, nothing that streams for playback is treated as a stored copy unless it happens to be byte-identical to the real fileMediumProposed
UR-071Media the viewer is watching can be kept, by a whole-file download that runs in the background independently of playback and at its own quality, so it is unaffected by bitrate changes. Where the streamed bytes already are that file (direct play), they are kept rather than fetched twice. A completed download is then played from disk rather than streamed againMediumProposed
UR-073Watched state is something the viewer can set, not only something playback records. Any episode, season, series or movie can be marked watched — or unwatched again — from where it is shown, without sitting through it or erasing its history wholesale. Marking a season or series covers the episodes inside it, and works with the server unreachableMediumDone
UR-072Each page opens where a page should open. Moving to a new screen starts at the top of it, and going Back returns the viewer to the place they left — their position in a long library grid or home screen, not the top of it. A page never inherits the scroll position of the page before itMediumDone
UR-075Artwork is shown at the shape it was made in. Where a screen presents a set of things side by side — the libraries on the library page and on home — they are laid out as a mosaic: rows of a common height in which each tile is as wide as its own picture, rather than a grid that crops every cover to one box. Favourites are reachable per category from that same mosaic, beside the library they belong to, not only as one undifferentiated listMediumDone
UR-076Music browsing shows only what the listener considers music. A Jellyfin server commonly keeps podcasts, audiobooks, sound effects or sample packs in their own folders inside a music library; those folders can be excluded by choice, once, and every music surface — library grids, artist and album listings, genre rows, search and the home screen — then agrees on what is in scope. The choice is by folder, not by a name the app happens to recognise, so a folder called anything at all can be excluded and an item is never dropped because its title matched a wordMediumDone
UR-077The app can update itself, or tell the user how. Somebody who installed an AppImage or ran the Windows installer had no upgrade path at all: nothing in the app ever mentioned that a newer version existed, and the release notes were the only announcement. On Linux and Windows the app checks a signed manifest, offers the new version with its notes, and installs and relaunches on request — the signature check is the point, since it is what stops a substituted download from being installed by the app itself. Android cannot do this (an app may not overwrite its own APK; that is the package installer's job) and is given the honest alternative, a link to the releases page, rather than a button that would throwMediumDone
UR-078JellyTau keeps a record of what it did, and can hand it over. The app forgot everything the moment it exited: the backend logged to stdout only — which a user launching from a desktop icon never sees, and which on Android is not logcat, so the Rust half was invisible on the platform carrying the hardest bugs. A crash left nothing at all. Logs are now written to a size-capped rotating file, a panic is recorded before the process dies, the frontend's messages land in the same timeline as the backend's, and Settings exports the lot as one file to attach to a bug report. Nothing is transmitted anywhere — the user attaches it themselves, which is also what keeps this from being telemetry. Access tokens and passwords never reach the fileMediumDone
UR-074Video streaming can be held to a bandwidth budget the viewer sets, rather than spent at whatever rate the server would otherwise send. A ceiling chosen once — from the source's own bitrate down to a rung that still plays on a poor connection — governs every video the app opens, live TV included, and survives a restart, so a metered connection is not quietly drained by the next thing played. A single video can be moved to a different ceiling from the player, resuming where it was, without disturbing that defaultMediumDone
+
+
+

2. Software Requirements

+

2.1 Integration Requirements

+

External system integrations and platform-specific implementations.

+
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
IDRequirementCategoryTraces ToStatus
IR-001Build system supporting multiple targets (Linux, Android)BuildUR-001Done
IR-002Build scripts for Android and LinuxBuildUR-001Done
IR-003Integration of libmpv for Linux playbackPlaybackUR-003, UR-004Done
IR-004Integration of ExoPlayer for Android playbackPlaybackUR-003, UR-004In Progress (basic playback works, audio settings missing)
IR-005MPRIS D-Bus integration for Linux lockscreen/media controlsPlatformUR-006Planned — genuinely absent: no mpris/souvlaki/zbus/dbus code or dependency in the project (zbus appears in Cargo.lock only transitively, via tauri-plugin-opener), and no navigator.mediaSession use in the frontend. player::update_lockscreen_metadata is a no-op off Android. UR-006 is therefore Android-only
IR-006Android MediaSession integration for lockscreen controlsPlatformUR-006Done
IR-007Bluetooth AVRCP integration via system media sessionPlatformUR-006Planned
IR-008Android audio focus handling (pause on call)PlatformUR-004, UR-006Done
IR-009Jellyfin API client for authenticationAPIUR-009, UR-012Done
IR-010Jellyfin API client for library browsingAPIUR-007, UR-008Done
IR-011Jellyfin API client for playback streamingAPIUR-003, UR-004Done
IR-012Jellyfin Sessions API for remote playback controlAPIUR-010Done
IR-021Android MediaRouter integration for remote volume in system panelPlatformUR-010, UR-016Planned
IR-013SQLite integration for local databaseStorageUR-002, UR-011Done
IR-014Secure credential storage (keyring/keychain)SecurityUR-012Done
IR-015Jellyfin API client for playback progress reportingAPIUR-019, UR-025Done
IR-016Jellyfin API client for subtitle/audio track infoAPIUR-020, UR-021Done
IR-017Jellyfin API client for transcoding parametersAPIUR-022Planned
IR-018Subtitle rendering and selection in the video playback backends: ExoPlayer sideloads each track as a MediaItem.SubtitleConfiguration and selects by text-track-group position (Android), and the WebKitGTK HTML5 <video> element renders <track kind="subtitles"> children carrying data-stream-index (Linux). Originally scoped to libmpv, which never implemented it: MpvBackend is the audio-only backend here and does not override PlayerBackend::set_subtitle_track, so the default not_implemented() still stands there. UR-020 is satisfied by the two paths above rather than by MPVPlaybackUR-020Done
IR-019Audio track selection in the video playback backends: ExoPlayer switches track by index natively (Android), while the HTML5 <video> path cannot switch a track in the element and instead re-opens the stream at the chosen AudioStreamIndex and resumes at the same position (Linux) — the two outcomes AudioTrackSwitchResponse distinguishes. Originally scoped to libmpv, which never implemented it: MpvBackend does not override PlayerBackend::set_audio_track, so the default not_implemented() still stands there. UR-021 is satisfied by the two paths above rather than by MPVPlaybackUR-021Done
IR-020libmpv/ExoPlayer equalizer integrationPlaybackUR-027Done (Linux/MPV and Android/audiofx.Equalizer; Android pending device verification)
IR-022Jellyfin API client for person/cast dataAPIUR-035, UR-036Done
IR-023Database schema for person/cast cachingStorageUR-035, UR-036Done
IR-024Jellyfin API client for home screen data (featured, continue watching)APIUR-034Done
IR-025Android background-audio handoff: WebView <video> → native ExoPlayer foreground service on background/lock, and back on foreground (audio continues, video decode stops)PlatformUR-040Done (pending device verification)
IR-026Android picture-in-picture: auto-enter on user-leave-hint via enterPictureInPictureMode, only while a local video surface is actively rendering (never for audio-only playback, menu/library browsing, or remote/cast sessions — enforced by the native canEnterPip guard, re-checked at leave time); aspect-ratio sizing; a play/pause RemoteAction that reflects live player play/pause state (updated whenever playback state changes, not only on button press); WebView hide/restore on mode changePlatformUR-041Done
IR-027Jellyfin /System/Info/Public reachability probe used as an offline→online recovery detectorAPIUR-043Done
IR-028Jellyfin/LMS SyncGroups API client (list, create, join, unsync, dissolve sync groups)APIUR-046Done
IR-029Android ConnectivityManager/NetworkCapabilities transport probe with a NetworkCallback change subscription, surfaced to the frontend via the AndroidNetworkType JS bridge and the jellytau-network-changed WebView event (requires ACCESS_NETWORK_STATE)PlatformUR-053Done (pending device verification)
IR-030Scheduled full-catalog crawl of every library (Recursive=true, paged) feeding the local index, driven by a Rust background task and the ConnectivityMonitor reconnect signal rather than by the frontendStorageUR-065Implemented
IR-031Android WindowInsets bridge: an OnApplyWindowInsetsListener on the decor view reports `systemBars()displayCutout()in CSS pixels, pushed into the WebView asjt-insetCSS custom properties plus ajellytau-insets-changedevent, and pullable via theAndroidInsets` JS bridgePlatformUR-066
IR-032Whole-file background download of the item being played, reusing the existing resumable download worker and the Range-capable /Videos/{id}/stream.mp4 endpoint; plus per-platform read-through caching hooks (ExoPlayer CacheDataSource, mpv stream-record) for direct-play sessions onlyStorageUR-071Proposed
+
+
+

Where a UR is met by a different mechanism than its IR anticipated. Several +integration requirements were written when libmpv was expected to be the single +playback backend. It is not: MpvBackend is the audio-only backend, Linux +plays video through a WebKitGTK HTML5 <video> element (HLS/h264), and Android +plays through ExoPlayer. So:

+
    +
  • UR-020 / UR-021 (subtitle and audio track selection) are Done, but not by +MPV — MpvBackend overrides neither PlayerBackend::set_subtitle_track nor +set_audio_track, leaving the trait's not_implemented() default. IR-018 and +IR-019 have been re-scoped to the backends that actually deliver them +(ExoPlayer sideloaded SubtitleConfigurations and native track switching; +HTML5 <track> children and stream re-open at the chosen AudioStreamIndex) +and marked Done on that basis. IT-008 / IT-009 were re-worded to match.
  • +
  • UR-006 (lockscreen / BLE headset control) is Done on Android only, via +MediaSessionCompat (IR-006) and ExoPlayer/AudioManager focus (IR-008). +IR-005 (MPRIS) remains Planned because it genuinely does not exist — there is +no MPRIS/D-Bus code or dependency in the project, and +player::update_lockscreen_metadata is a no-op off Android. UR-006's status +was corrected rather than IR-005's.
  • +
+
+

2.2 Jellyfin API Requirements

+

API endpoints and data contracts required for Jellyfin integration.

+
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
IDRequirementEndpoint CategoryTraces ToStatus
JA-001Server connection and discoverySystemUR-009Done
JA-002User authentication (username/password)UsersUR-009, UR-012Done
JA-003Get user library viewsUserViewsUR-007Done
JA-004Get library items (paginated)ItemsUR-007Done
JA-005Get item details and metadataItemsUR-007Done
JA-006Search across librariesItemsUR-008Done
JA-007Get playback info and stream URLMediaInfoUR-003, UR-004Done
JA-008Get available subtitles for itemMediaInfoUR-020Done
JA-009Get available audio tracks for itemMediaInfoUR-021Done
JA-010Report playback startSessionsUR-025Done
JA-011Report playback progress (periodic)SessionsUR-025Done
JA-012Report playback stoppedSessionsUR-025Done
JA-013Get resume position for itemUserDataUR-019Done
JA-014Get "Next Up" itemsShowsUR-023Done
JA-015Get "Continue Watching" itemsItemsUR-023Done
JA-016Get recently added itemsItemsUR-024Done
JA-017Mark item as favoriteUserDataUR-017Done
JA-018Remove item from favoritesUserDataUR-017Done
JA-019Get/create/update playlistsPlaylistsUR-014Done
JA-020Add/remove items from playlistPlaylistsUR-014Done
JA-021Get active sessions listSessionsUR-010Done
JA-022Send playback commands to remote session (play/pause/stop)SessionsUR-010Done
JA-023Send seek command to remote sessionSessionsUR-010Done
JA-024Send next/previous track commands to remote sessionSessionsUR-010Done
JA-025Play specific item on remote sessionSessionsUR-010Done
JA-026Send volume/mute commands to remote sessionSessionsUR-010Done
JA-027Get transcoding optionsMediaInfoUR-022Planned
JA-028Get image/artwork URLsImagesUR-007Done
JA-029Get cast/crew for item (actors, directors)ItemsUR-035Done
JA-030Get person details and filmographyPersonsUR-036Done
JA-031Get items by person (actor/director filmography)ItemsUR-036Done
JA-032Get audio-only stream URL for a video item (selected audio-stream index)MediaInfoUR-040Done
JA-033Query favourite items (Filters=IsFavorite, recursive, scoped by item type)ItemsUR-067Done
JA-034Read UserData (favourite, played, resume position) from item responsesUserDataUR-069Done
JA-035Mark item played (POST /Users/{userId}/PlayedItems/{itemId})UserDataUR-025Done
JA-036Query next-up episodes excluding in-progress ones (/Shows/NextUp with EnableResumable=false)ShowsUR-059Done
+
+

2.3 Development Requirements

+

Internal architecture, components, and application logic.

+
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
IDRequirementCategoryTraces ToStatus
DR-001Player state machine (idle, loading, playing, paused, seeking, error)PlayerUR-005Done
DR-002MediaItem struct tracking source, location, duration, metadataPlayerUR-003, UR-004Done
DR-003Source-agnostic media abstraction (Remote, Local, DirectUrl)PlayerUR-002, UR-011Done
DR-004PlayerBackend trait for platform-agnostic playbackPlayerUR-003, UR-004Done
DR-005Queue manager with shuffle, repeat, historyPlayerUR-005, UR-015Done
DR-006Audio pre-caching for seamless track transitionsPlayerUR-004Planned
DR-007Library browsing screens (grid view, search, filters)UIUR-007, UR-008Done
DR-008Album/Series detail view with track listingUIUR-007Done
DR-009Audio player UI (mini player, full screen)UIUR-005Done
DR-010Video player UI (fullscreen, controls overlay)UIUR-003, UR-005Done
DR-011Search bar with cross-library searchUIUR-008Done
DR-012Local database for media metadata cacheStorageUR-002Done
DR-013Repository pattern for online/offline data accessStorageUR-002Done
DR-014Offline mutation queue for sync-back operationsStorageUR-002, UR-014, UR-017Done
DR-015Download manager with queue and progress trackingStorageUR-011, UR-018Done
DR-016Thumbnail caching and sync with serverStorageUR-007Done
DR-017"Manage Downloads" screen for local media managementUIUR-013Done
DR-018Download buttons on library/album/player screensUIUR-011, UR-018Done
DR-019Playlist creation and editing UIUIUR-014Done
DR-020Queue management UI (add, remove, reorder)UIUR-015Done
DR-021Like/favorite functionality on media itemsUIUR-017Done
DR-022Resume position tracking and restoration on playPlayerUR-019Done
DR-023Subtitle selection UI in video playerUIUR-020Done
DR-024Audio track selection UI in video playerUIUR-021Done
DR-025Quality/transcoding settings UIUIUR-022Planned
DR-026"Continue Watching" / "Next Up" home sectionUIUR-023Done
DR-027"Recently Added" home sectionUIUR-024Done
DR-028Playback progress sync service (periodic reporting)PlayerUR-025Done
DR-029Sleep timer with roller UI, time/track/episode modes, and auto-stop (audio + video players)PlayerUR-026Done
DR-049Auto-play episode limit (configurable max episodes per session)PlayerUR-023Done
DR-050Reusable scroll picker (roller) componentUIUR-026Done
DR-030Equalizer UI with presets and custom bandsUIUR-027Done
DR-031Clickable artist/album links in now playing viewUIUR-028Done
DR-032List view option for library browsing (albums, artists)UIUR-029Done
DR-033Genre browsing screen with quick filtersUIUR-030Done
DR-034Crossfade engine with configurable duration (0-12s)PlayerUR-031Not implemented (blocked on MPV: single-stream audio chain; acrossfade needs 2 inputs — see docs/specs/playback-backend-unification.md)
DR-035Gapless playback between sequential tracksPlayerUR-032Done (Linux via MPV; Android via pauseAtEndOfMediaItems — pending device verification)
DR-036Volume normalization with preset levels (Loud/Normal/Quiet)PlayerUR-033Done (Linux via MPV dynaudnorm; Android via LoudnessEnhancer — pending device verification)
DR-037Remote session browser and control UIUIUR-010Done
DR-038Home screen with hero banner carousel (featured/continue watching)UIUR-034Done
DR-039Home screen horizontal carousels (recently added, recommendations)UIUR-034, UR-024Done
DR-040Cast/crew section on movie/show detail pagesUIUR-035Done
DR-041Person/actor detail page with filmography gridUIUR-036Done
DR-042Video library grid with poster cards, year, and rating badgesUIUR-037Done
DR-043Movie/show detail page with backdrop hero, synopsis, and metadataUIUR-038Done
DR-044Horizontal scrolling actor/cast row with profile imagesUIUR-035Done
DR-045Bottom navigation bar with Home, Library, Search buttonsUIUR-039Done
DR-046Dedicated search page with input and resultsUIUR-039Done
DR-047Next episode auto-play popup with configurable countdown and episode limitPlayerUR-023Done
DR-048Video settings (auto-play toggle, countdown duration, episode limit)SettingsUR-023, UR-026Done
DR-051Background-audio toggle button in the video player controls (suppresses auto-PiP while enabled)UIUR-040Done (pending device verification)
DR-052Background-audio handoff state machine: on background/lock tear down the WebView <video>/HLS decode and start native audio-only playback at the current position; on foreground return position and resume <video>; exactly one audio source active at every transition (no dual audio)PlayerUR-040Done (pending device verification)
DR-053PictureInPictureManager: canEnterPip gate (local video surface actively rendering — false for audio, browsing, and remote/cast), aspect-ratio clamp, a RemoteAction play/pause receiver whose icon reflects live player state (refreshed on every playback-state change while in PiP, not only on button press), WebView hide/restore, surface re-fit on exit; plus the AndroidPictureInPicture JS bridge and the PiP button (shown only when PiP is supported) in the video playerUIUR-041Done
DR-054Auth manager and session lifecycle: connect-to-server, login, Quick Connect verification poll (start/stop), session get/set, background session verifier, re-authenticate, logoutAuthUR-042Done
DR-055ConnectivityMonitor deriving reachability from real repository traffic, with online/offline state, mark-reachable/unreachable reporting, and a probe-based recovery poller active only while offlineConnectivityUR-043Done
DR-056Download pinning (pin/unpin/is-pinned) that excludes an item from smart-cache evictionStorageUR-044Done
DR-057Smart cache manager: album-affinity tracking, queue-lookahead pre-cache, storage-limit enforcement, config, stats, and recommendationsStorageUR-045Done
DR-058Remote sync-group control (LMS SyncGroups): list, create, unsync a player, dissolve a groupPlayerUR-046Done
DR-059Playback-mode transfer state machine: get/set current mode, transferring guard, transfer-to-remote / transfer-to-local, remote session statusPlayerUR-010Done
DR-060Multi-server store and active-account selection: save/get/delete server, save/get user, set/get active user (per-server), active-session resolutionStorageUR-047Partial (store done; server-switcher UI pending)
DR-061Episode Focus View: episode hero followed immediately by the "More Episodes" strip — a forward-biased window (~3 before / ~6 after) around the current episode, spanning season boundaries in series order, with the current episode present and badged, per-card resume progress and watched state, and click-to-swap focus (no playback)UIUR-048Done
DR-062Detail-page section ordering: continuation content precedes discovery content — Episode Focus View renders hero → episode strip → cast → similar; Series renders hero → seasons/episodes → cast → similarUIUR-048Done
DR-063Search scope taxonomy owned by Rust: SearchScope (All / Music / Movies / TV) crosses IPC as an opaque enum and SearchScope::item_types() expands it to Jellyfin item types, resolved once in repository_search before the cache and server paths diverge so online and offline filter identically; All expands to no filter rather than the union of the other scopes (which would drop People and folders). The frontend maps the originating route to a scope (resolveSearchScope, presentation) and never names an item type for searchBackendUR-049Implemented
DR-064Scope chip row rendered under the search bar on both the search page and the in-library header search: preselected from context, horizontally scrollable, re-runs the search preserving the query on changeUIUR-049Implemented
DR-065Thread SearchOptions.includeItemTypes through library.search() so the global/header search honours scope (backend online + offline paths already support it)UIUR-049Implemented
DR-066Persisted search result group order with a drag-and-drop settings list, keyboard-accessible reordering, a shipped default (see DR-091 for the current group set and order), and empty-group omissionSettingsUR-050Implemented
DR-067SearchResults renders groups in the user-configured order rather than hardcoded markup order, without altering intra-group rankingUIUR-050Implemented
DR-068Library card shape by media type: 1:1 square for music (circular mask for artists), 2:3 poster for movies/series/seasons, 16:9 for episodes and collection foldersUIUR-051Done
DR-069Responsive library grid (2/3/4/5/6 columns across base→xl) with two-line truncated card text and artwork-overlay progress/watched stateUIUR-051Done
DR-070Global persisted grid/list view preference honoured by browse pages, suppressed for ordinal content (album tracks, season episodes)UIUR-051, UR-029Partial (persisted store + page-header toggle; no settings entry)
DR-075Shared AccountMenu component: identity header (user + server), Downloads / Settings / Display entries, divider, Sign out last; anchored to the username/avatar trigger and identical on desktop and mobileUIUR-054Done
DR-076App shell exposes the header (and therefore the account menu) on every authenticated non-immersive route, including /, /search, and /downloads; only /player/* and /login remain chrome-freeUIUR-054Done
DR-077Display section in Settings binding the existing persisted grid/list viewMode store, giving the preference a discoverable homeSettingsUR-054, UR-029Done
DR-078Catalog-visibility gate spanning the "Show all server media" toggle → set_show_server_catalogINCLUDE_CATALOG_BROWSE → the synced-catalog UNION branch of offline get_items. Visibility resolves to serverReachable || showServerCatalog, so offline with the toggle off lists downloaded/local media onlyStorageUR-052, UR-002Done
DR-079isConnected derives from backend-reported server reachability alone; navigator.onLine is advisory and may only trigger a recheck, never force or clear the offline state (a reachable LAN server while the browser reports offline, and an unreachable server on a live link, must both resolve correctly)ConnectivityUR-052, UR-043Done
DR-080With the catalog-browse gate off, an empty offline get_items result is authoritative "no downloads here" and must be returned as-is; the hybrid repository must not treat it as a cache miss and fall through to the serverStorageUR-052, UR-013Done
DR-074WiFi-only download gate: NetworkState/NetworkType transport model reported from the platform via set_network_state, checked in pump_download_queue before starting any pending row (cellular/metered/unknown fail closed, WiFi and Ethernet require NOT_METERED); blocked rows stay pending and re-pump on network change, with a waitingForNetwork event driving the "Waiting for WiFi" notice. Also wires the previously inert Smart Caching / Queue Pre-caching / WiFi Only settings toggles to CacheConfigDownloadsUR-053Done (pending device verification)
DR-081/downloads split into a default Downloaded browse view and a secondary Transfers activity view, with a view switch and a Transfers badge shown only while transfers are activeUIUR-055Done
DR-082Offline-scoped browse entry point in the repository client: browse downloaded content only (offline repository get_items/get_libraries — downloaded items plus their containers) independent of server reachability, without merging server catalogStorageUR-055Done
DR-083Downloaded browse reuses library grids, cards, and detail pages via the offline-scoped source; omits libraries/containers with no downloaded content; badges partially- vs fully-downloaded containers; play uses the local file; remove available at item/album/season/series levelUIUR-055Done
DR-084Transfers view renders only in-flight rows (downloading/queued/paused/failed/waiting-for-WiFi) with Pause/Resume/Cancel/Retry; completed transfers leave the view and appear in DownloadedUIUR-055Done
DR-085Per-item on-disk size: stat downloaded files, aggregate to album/season/series subtotals and a device total, format in consistent rounded human units; surface size on cards and detail pages, the device total on the Downloaded surface, and a reclaim figure in the remove confirmationDownloadsUR-056Done
DR-086Settings page persists each control on change via per-group writers (playerSetAudioSettings / playerSetVideoSettings / updateCacheConfig) rather than a batch Save action; slider controls persist on change (pointer release) not each input tick; no Save button, saving, or saveMessage stateSettingsUR-057Done
DR-087MediaCard gains an onLongPress prop with pointer-based long-press detection (~500 ms hold, cancelled on >10 px move so carousel scroll is unaffected, trailing click suppressed); home carousels wire tap→detail/focus routing and long-press→confirm→player; episode taps route to /library/<seriesId>?episode=<id>; the bare-episode detail page links to its parent series/seasonUIUR-058Done
DR-088Skip-to-next-episode marks the outgoing episode played (markAsPlayed) instead of reporting a stop position, and arms a one-shot suppression consumed by the player's stop handler so VideoPlayer's post-navigation unmount stop report cannot overwrite the 100% progress with the partial positionUIUR-059Done
DR-089Continue Watching suppresses resume entries superseded by Next Up: an in-progress episode whose series has a next-up entry strictly later in series order (season, then episode) is dropped from the Home and TV rows; movies, series without a next-up entry, and items with unknown/mixed episode ordering are always keptUIUR-059Done
DR-090Relevance ranking in Rust (domain/search_rank.rs): results sort by match position (prefix → word-start → mid-word substring → no name match) then by media kind (containers before their contents), stably so the backend's own relevance breaks ties. Applied in repository_search to both the instant cache result and the merged cache+server union, so the list does not reshuffle when server results landBackendUR-060Done
DR-091Search result groups split TV into separate Shows and Episodes groups and add a People group (default order: Shows → Episodes → Movies → Songs → Albums → Artists → People); a stored tvShows order from before the split expands in place to shows+episodes so an upgrading user keeps their arrangementUIUR-060Done
DR-092Video tap gestures resolve in tapGestures.ts (pure, unit-tested) rather than inline in VideoPlayer.svelte: registerTap classifies each tap and the component acts on it immediately — togglePlayPause for a first tap, or seek (+30 s right / −10 s left) plus a re-toggle for a second tap inside DOUBLE_TAP_WINDOW_MS (300 ms). A consumed pair resets the state, and a swipe forgets the tap. The deferral this originally used was removed in DR-098, which also covers suppressing the compatibility click the browser synthesizes after a touch tap. resolveSeekTarget converts the delta to the absolute position the facade requires, clamped per DR-095 and chained off a still-in-flight pendingSeekTarget so back-to-back skips accumulate instead of all resolving against a not-yet-updated positionUIUR-061Done
DR-094Frontend boundary tripwire (scripts/check-frontend-boundary.sh) detects Jellyfin item-type array literals anywhere in src/ rather than only inline at an includeItemTypes: query site, so a category→type mapping cannot evade the check by being assigned to a named const (the evasion that let the scoped-search leak pass CI); requires two adjacent type literals so single-type presentation and item.type === inspection stay legal, and caps the allowlist to force taxonomy into Rust instead of accumulating exceptionsTooling-Done
DR-098Video tap gestures act immediately — no deferral, no timer, and only first/second taps exist. A first tap toggles play/pause; a second tap inside DOUBLE_TAP_WINDOW_MS seeks and toggles again, so the two toggles cancel and a double tap preserves the play state (playing → jump and keep playing; paused → jump and stay paused). This replaces a design that deferred the first tap behind a 300 ms timer so a second tap could cancel it: the timer cleared its own handle before invoking the toggle, which reopened the tapTimeout !== null guard in handleVideoClick meant to suppress the compatibility click Android's WebView synthesizes after a touch — the late click then toggled a second time, producing a pause/unpause loop (long-press was unaffected, which is what identified the tap path). Click suppression no longer depends on the timer: handleVideoClick ignores detail === 0 and any click within TOUCH_CLICK_SUPPRESS_MS of a touch tap. A swipe undoes the touchstart toggle exactly once (latched on swipeGestureActive) so brightness swipes never change play state. Click suppression is shared by every click target layered over the video via isSynthesizedTouchClick, not just the <video>: pausing renders a full-screen play-overlay button, so the synthesized click lands on that and an unguarded handler there resumed immediately — pausing appeared impossible while unpausing worked, because unpausing removes the overlayUIUR-061Done
DR-099The video seek bar is usable by touch. Two Android-only defects made dragging or tapping it move the thumb without moving playback. (a) Gesture hijack: the container-level gesture layer skips touchstart on a control (DR-098) but kept handling touchmove, so a seek-bar drag was measured against the previous gesture's start point — a huge bogus vertical delta that read as a brightness swipe, dimmed the screen to the 0.3 floor, and fired a spurious play/pause "correction" mid-drag. A gesture is now latched at touchstart (playerGestureActive) and touchmove ignores anything not latched, since re-checking the move target cannot recover a start point that was never recorded. (b) Commit signal: the seek was committed only from change, which Android's WebView does not reliably fire for a touch interaction on a range input — the thumb moved to the tapped position and no seek ever ran. touchend/mouseup now commit as well; input arms a one-shot latch so whichever release signal arrives first commits and the other is a no-op. seekRelative shares the same commitSeek entry point instead of fabricating a synthetic change eventUIUR-005, UR-061Done
DR-097Transport authority (play/pause/toggle) lives in Rust for webview-rendered media, not just native. The controller tracks the state the HTML5 element reports (html5_playing, fed by report_html5_state, which now stores rather than only re-emitting); play/pause/toggle_playback consult it and drive the element by emitting a ControlCommand that playerEvents.handleControlCommand executes against the active adapter. A stopped/idle report clears it so the native backend (MPV/ExoPlayer) regains authority for music. The frontend facade no longer short-circuits transport into the adapter: adapter.toggle() previously decided play-vs-pause by reading el.paused off the DOM, a value that flips transiently while an element buffers or settles a seek — so two intents ~150 ms apart read different values, performed opposing actions, and self-sustained a play/pause loop needing no further input (observed on Android with a fully-buffered readyState=4 networkState=1 element). Same "backend decides, adapter executes the primitive" split as player_seek_videoPlayerUR-005Done
DR-096Html5PlayerAdapter.play() is resilient to stall recovery: an in-flight attempt is memoised so concurrent callers (UI plus hls.js gap-controller recovery) share one element.play() instead of stacking calls, and an AbortError ("play() request was interrupted by a call to pause()") is logged at debug rather than pushed to host.onError. The browser raises it whenever a pending play promise is superseded by a pause/seek/source change, which hls.js does routinely while nudging past a stall — reporting it surfaced a player error roughly once per second for the whole stall and left the UI stuck showing pausedPlayerUR-005Done
DR-095Seek targets clamp strictly inside the media (clampSeekTarget, END_SEEK_MARGIN_SECONDS = 6 s ≈ one HLS segment) instead of to the exact duration. Landing on the duration makes hls.js request the segment whose start time lies past the end of the media (e.g. a 6330.324 s item → segment 1055 starting at 6336.33 s), which Jellyfin never produces; the fetch times out and hls.js' gap-controller stalls at the last buffered position, presenting as "unpausing or skipping bounces straight back to paused". Applied on both seek paths — the relative-skip resolveSeekTarget and the seek-bar drag, whose range input max is the duration itself — and floored at 0 so media shorter than the margin still seeks to the startUIUR-061Done
DR-100Leaving a video and re-entering it renders the video player, never the audio one. Both halves of the /player/[id] decision are pure and unit-tested in playerSurface.ts. (a) shouldReuseActivePlayback excludes video: the "already playing, just show the UI" shortcut (added for expanding the audio mini player) returns before a stream URL is fetched, which is fine for audio — the backend owns the stream and the route only mirrors it — but leaves <VideoPlayer> with nothing to render. Closing a webview-rendered video deliberately emits no stopped state (that would break the autoplay handoff, see DR-047), so the Rust controller still reports that movie/episode as its loaded media and re-entering the same item hit the shortcut. (b) resolvePlayerSurface maps video-without-a-stream-URL to pending (spinner) instead of falling through to <AudioPlayer>, so no future path can put video content in the audio surface. Video now always takes the full load path, which fetches the stream URL and applies the stored resume positionUIUR-005Done
DR-101"Where is this viewer in this series" is resolved in Rust, not the frontend. repository_get_series_episodes performs the season fan-out (get_items(series_id) → seasons → get_items(season_id), plus the flat-series fallback for shows whose children are episodes rather than season folders) and returns them in series order — season index ascending, episode index ascending, specials (season 0) after every numbered season. repository_get_series_current_episode layers the pure policy pick_current_episode over that list: an in-progress episode wins (earliest in series order on a tie — it is literally where playback stopped, and Next Up would skip past it), then the server's Next Up for that series, then the first unwatched episode, then the first. The third rung is the offline path, not dead code: OfflineRepository::get_next_up_episodes returns an empty vec, so without it the feature would be online-only. A failing Next Up or resume lookup degrades to empty rather than failing the call. repository_get_next_up_episodes had accepted a series_id since it was written and no caller had ever passed oneRepositoryUR-062Done
DR-102The series detail page anchors on that answer. It calls repositoryGetSeriesEpisodes once instead of fanning out over seasons in TypeScript (the fan-out and its flat-series fallback were domain knowledge in the presentation layer), groups the returned episodes under season headers by parentIndexNumber, and passes the resolved current episode to SeasonSectionEpisodeRow, which renders a highlight ring and scrolls itself into view. The hero button navigates to /library/<seriesId>?episode=<currentId> — the Episode Focus View, where an explicit Play/Resume commits — per ux-flows §5B.5: Play on a container is navigation, Play on a leaf commits. It previously resolved $libraryItems[0], the first season by SortName, and navigated to /player/<seasonId>, which the player route bounced back to /library/<seasonId> — so Play on a series played nothing and landed on the season-1 pageUIUR-062Done
DR-103A season is not a destination. /library/<seasonId> redirects to /library/<seriesId>#season-<indexNumber>, the anchor SeasonSection renders, so a season link scrolls the series' continuous episode list rather than opening a page. Every inbound link follows: the episode breadcrumb, handleItemClick case "season", the TV landing page's case "Season", and DownloadedBrowse. A season carrying no seriesId (deep link into a stale cache) still renders the generic view so the user is never stranded. This removes a surface that had no route of its own — it fell through the detail page's kind chain to the generic "Contents" poster grid, contradicting ux-flows §5A.2 (episodes must be a row list), and clicking an episode there opened a bare Episode page, which §5B.1 forbidsUIUR-062Done
DR-104The "More Episodes" strip spans the whole series in series order, per ux-flows §5B.2's cross-season continuity rule: at the end of a season the window runs on into the next season's first episodes instead of dead-ending. adjacentEpisodes previously filtered the pool to parentIndexNumber === current.parentIndexNumber and sorted by indexNumber alone, so the window could never leave the current season — and, when episodes of several seasons did reach it, sorting by episode number alone interleaved them. Cards crossing a season boundary are labelled SxEy rather than a bare episode number so the jump is legibleUIUR-062Done
DR-105Video library routes collapse to one per library. /library/tv and /library/movies render browse / all-titles / genres as in-page tabs driven by ?view=, omitted for the default browse (the convention searchRouteUrl already uses for the all scope); resolveLibraryView is pure and unit-tested. The four legacy routes become redirect-only +page.ts loads rather than deletions, because GenreTags links to them and users have them in history; resolveSearchScope keeps its /library/shows branch for the same reason. The "Browse" tile grid at the bottom of both landing pages is removed — it was a second navigation affordance to the same destinations the carousels' "Show all" links already reachUIUR-063Done
DR-106Erasing watch history goes through the repository, not the local cache: clear_watch_history(item_id) maps to Jellyfin's DELETE /Users/{userId}/PlayedItems/{itemId}, which clears the played flag and zeroes the resume position, and which the server applies recursively to a folder — so one call handles a whole series or season. OfflineRepository returns RepoError::Offline rather than clearing locally, because history diverged only on the device would be silently undone by the next sync; the button disables itself while the server is unreachable. ClearHistoryButton is shared by the series hero and each SeasonSection header, confirms before acting (there is no undo), and reloads the page on success so the recomputed current episode — the premiere, for a fully cleared series — is what the viewer seesRepositoryUR-064Done
DR-107Seasons on the series page are collapsible, and only the current season is expanded on load — the one holding the episode DR-101 resolved. A show with ten seasons otherwise renders every episode of every season at once, burying the one episode the viewer came for under hundreds of rows. Expansion state is per season and pure (initialExpandedSeasons in seriesNavigation.ts): the current season, or the first season when there is no current episode, so a never-watched show still opens on season 1 rather than fully collapsed. A ?episode= deep link expands that episode's season too. Toggling is local and not persisted — it is a reading position, not a preferenceUIUR-062Done
DR-108The instant (cache) leg of repository_search searches the synced catalog, not just downloads. OfflineRepository::search replaces its downloaded_items CTE with the available_items CTE get_items already uses — the same downloads branches plus a synced_at IS NOT NULL branch gated on the same include_catalog_browse() flag — so search and browse cannot diverge on what is visible. Online (flag true) search reads the whole index and answers before any HTTP request completes; offline with "Show all server media" off (flag false) it stays downloads-only, unchanged. Requires no frontend change, since the flag is already set correctly for all three states. The include_item_types filter is switched from string interpolation to bound parameters, as SearchOptions is settable from the frontend and not only from SearchScopeBackendUR-065Implemented
DR-109Index freshness is a Rust-owned policy, not a frontend startup call. A tokio task ticks every 30 min and runs a full pass when a repository is active, the server is reachable, and last_catalog_sync (already persisted to app_settings, previously read only for a UI hint) is older than CATALOG_INDEX_TTL (6 h); the ConnectivityMonitor reconnect signal re-evaluates the same condition immediately. An AtomicBool prevents concurrent passes, replacing offlineCatalog.ts's syncInProgress — the frontend trigger is removed rather than left alongside, since two triggers with one guard each is how double-crawls happen. RepositoryManager gains an active-handle slot so the task has something to run against. Progress is emitted as the kebab-case catalog-index-eventBackendUR-065Implemented
DR-110Index hygiene. save_to_cache switches from INSERT OR REPLACE INTO items to ON CONFLICT(id) DO UPDATE: REPLACE fires no AFTER DELETE trigger unless recursive_triggers is on (it is not — only foreign_keys and journal_mode are set), so items_ad never ran, and because items.id is a TEXT PRIMARY KEY each replacement also took a fresh rowid and appended a second items_fts entry — a duplicate index per sync, invisible in results but permanently degrading MATCH. The upsert preserves the rowid items_fts keys on and fires items_au; migration 021_rebuild_items_fts clears orphans on existing installs. Separately, a post-crawl sweep deletes synced-but-not-downloaded rows a successful library crawl did not return, so media removed from the server stops being searchable; it skips items with completed downloads and skips any library whose crawl errored, because items.parent_id is ON DELETE CASCADE and a partial crawl would cascade away a whole seriesStorageUR-065Implemented
DR-111The index covers what the result groups render: CATALOG_ITEM_TYPES gains MusicArtist and Playlist, and migration 022_people_fts adds a people_fts virtual table over the existing people table (which had no FTS, and is populated incidentally by item-detail fetches) with the same trigger pattern as items_fts. OfflineRepository::search UNIONs people_fts matches in as Person items when the resolved scope admits them — i.e. SearchScope::All, which expands to no filter (DR-063). Without this, the Artists and People groups UR-060 mandates can only ever be filled by the server legStorageUR-065, UR-060Implemented
DR-112Safe-area insets come from native, not from env() alone. env(safe-area-inset-*) is 0px without viewport-fit=cover (missing from app.html, so every safe-area rule in the app was already a no-op), and even with it Android WebView maps only the display cutout — never the status bar or navigation bar. Since enableEdgeToEdge() plus targetSdk 36 make edge-to-edge unconditional, the WebView always spans the system bars, so CSS could not learn about them by any route. WindowInsetsBridge reads the real insets and publishes jt-inset custom properties; app.css folds them with env() via max() into --safe-*, which is the only thing components may pad from. Ownership is exactly one element per edge: the app shell takes top/left/right, and BottomUi takes bottom wherever it renders (shellReservesBottomInset hands it back to the shell on routes with no bottom UI) so the padding sits inside BottomUi's surface box and the colour extends behind the gesture bar. The full-screen players inset their control layers only, leaving video and artwork edge-to-edge. The theme's fitsSystemWindows=true — which claimed the opposite and was overridden at runtime and ignored at this target SDK — is removedUIUR-066Done
DR-113MediaItem.user_data is populated from the server instead of being hardcoded None. JellyfinItem gains a UserData field (#[serde(alias = "UserData")] → the existing UserData type) and to_media_item maps it, so every list and detail response carries favourite/played/resume state. UserData is named explicitly in the Fields= list rather than relying on Jellyfin's default. Without this no card or detail page can render a favourite it did not itself set, and the mini player's per-track storageGetPlaybackProgress fetch is the only way to colour one heartRepositoryUR-069Done
DR-114Server favourite state is mirrored into the local user_data table by OfflineRepository::save_to_cache — the single choke point every cached server result passes through — so offline browsing and the offline Favourites page see the same favourites as the server. The upsert carries pending_sync = 0 and is guarded by WHERE user_data.pending_sync = 0, which is the conflict rule: a toggle made offline is never overwritten by a stale server value before it has been pushedStorageUR-069Done
DR-115Cross-library favourites query: a get_favorites(scope, options) repository method plus the repository_get_favorites command. Online issues Filters=IsFavorite&Recursive=true with IncludeItemTypes expanded from SearchScope::item_types() in Rust (the frontend sends the opaque scope, never a type list — DR-063); offline reads items ⨝ user_data (is_favorite = 1) under the same include_catalog_browse() gate as browsing; hybrid races cache against server like get_items — saving server results through to the cache on a miss, so the favourites page does not re-query the server every visit and the DR-114 mirror is filled on a fresh install — and applies the DR-080 rule that an empty offline result is authoritative when the gate is off. The command falls back to this read when nothing is cached, rather than painting an empty state it will correct a round trip later. A separate method rather than get_items because favourites span libraries and get_items is ParentId-shapedRepositoryUR-067Done
DR-116GetItemsOptions.favorites_only filters an existing library listing in place — online by appending Filters=IsFavorite, offline by joining user_data into the existing available_items CTE so the downloads-only gate still applies. This is what backs the per-library favourites toggle, and composes with the genre and item-type filters already thereRepositoryUR-067Done
DR-117The Favourites page (/library/favorites) renders favourites across libraries with All / Movies / Shows / Music scope tabs, reusing LibraryViewTabs + LibraryGrid + MediaCard so card shape still follows the media (§5A.1) and a mixed All tab reads as posters, squares and thumbnails side by side. Each tab sends a SearchScope value and nothing else. Reached from the library overview and from "See all" on the home rowsUIUR-067Done
DR-118Home carries favourite rows for movies, shows and music, loaded via repository_get_favorites per scope and rendered below Recently Added. A row with no items does not render at all, so a fresh install shows no empty favourite rowsUIUR-067Done
DR-119FavoriteButton is mounted wherever a whole item is shown — movie/series/episode detail heroes, album/artist/playlist headers, and as a MediaCard artwork overlay — and a favorites store holds in-session optimistic state so un-hearting on one surface updates every other without a refetch. Resolution order is store override ?? item.userData?.isFavorite ?? false. On a card the heart is its own button and stops propagation, so hearting never also opens, plays, or triggers the §5B.5 long-press; it is suppressed on server-only (greyed) cardsUIUR-068Done
DR-120Favourite toggles made while offline reach the server. A Rust drain, triggered by the ConnectivityMonitor offline→online transition, pushes every user_data row with pending_sync = 1 and clears the flag on success, leaving failures pending for the next transition. It lives in Rust rather than the frontend because a frontend drain dies with the component that started it. Both the drain and the hybrid background refresh emit the kebab-case favorites-changed event ({ itemIds }) so open views update — without it a favourite marked on another client appears only on the second visit to a page, since the cache-first read returns local rows and the server refresh is invisible to the frontend. Supersedes the unused syncService.queueFavorite, which is deleted rather than left as a second queueBackendUR-069Done
DR-121Player quality selector: Rust reports the bitrates available for the current media source and owns the quality→transcode-parameter mapping (the one get_video_download_url already holds — playback calls into it rather than restating it, or the two tables drift). Changing quality re-negotiates the stream URL and resumes at the current position with audio/subtitle selection preserved. On Linux, video re-negotiates within HLS: returning stream.mp4 is the documented cause of transcoded playback never starting. The frontend renders the list and remembers the choice; it does not decide what the choice resolves toUIUR-070Proposed
DR-122The playback path is ephemeral. Streamed bytes are never persisted unless DR-124 rules them keepable, and any in-flight capture is abandoned — partial file deleted, never promoted — the moment the viewer changes quality, because a capture spanning a rendition change is a splice of two encodings rather than a playable filePlaybackUR-070Proposed
DR-123The download path is independent of playback: a whole-file fetch through the existing download manager at one canonical quality (default original, the direct static copy) over the Range-capable /Videos/{id}/stream.mp4, unaffected by bitrate changes and completing into an ordinary downloads row so offline browsing and refresh_queue_local_sources pick it up unchanged. Prerequisite: downloaded video is currently never played locally — repository_get_video_stream_url goes straight to the online repo and the player route calls it with no local check, so a completed video download is still streamed. Without that fix nothing in this spec is observable for videoRepositoryUR-071In Progress
DR-124Streamed bytes are kept only where they are the download artifact — a direct-play session. Android uses ExoPlayer SimpleCache/CacheDataSource keyed by item and media-source id so renditions cannot collide, sharing the existing smart-cache storage budget rather than opening a second one over the same disk; Linux audio uses mpv stream-record, abandoned on seek because it is documented as intended for linear streams and seeking breaks the recording. Transcoded Linux video is not captured: HLS segments are not a file, and assembling one needs ffmpeg, which is not a dependency and which CI may not install at job time — DR-123 covers that case insteadPlaybackUR-071Proposed
DR-125A capture is promoted to a completed downloads row only when it covers the whole resource; partials stay evictable cache. A new downloads.source_rendition column records the negotiated quality/container/codec (NULL for the existing paths, which are always original) so a captured transcode and a real download are distinguishable rows and an "upgrade to original" remains possible. A quality change never touches a file that already exists — not a permanent download, and not a completed temporary one, both of which stay valid copies of the rendition they hold. It invalidates only an in-flight capture or background download of cached media, which is abandoned and restarted at the newly chosen quality, because a capture spanning a rendition change is a splice of two encodings rather than a playable fileStorageUR-071Proposed
DR-126Cache eviction only reclaims the temporary tier. evict_lru_async selected every completed download ordered by completed_at ASC with no download_source filter, so hitting the 10 GB storage limit deleted the oldest download — typically a film saved deliberately for offline — to make room for a newly precached track. It now evicts only COALESCE(download_source, 'user') = 'auto' rows; COALESCE rather than a bare equality because rows predating migration 012 can be NULL and unknown provenance must be treated as the user's, never as disposable. Freeing less than requested is the correct outcome when only user downloads remain — the caller reports "unable to free enough space" instead of silently deleting themStorageUR-071Done
DR-127A cache entry is a download with a shorter life: same downloads row and same file handling, distinguished by download_source = 'auto' plus an expiry, so there is one storage model rather than a cache and a download library that can disagree. Temporary rows are reclaimed on whichever comes first — the life limit elapsing, or eviction under space pressure (DR-126). Permanent ('user') rows have no expiry. A temporary row can be promoted to permanent by the user choosing to keep it, which only clears the expiry and flips the source; the bytes never moveStorageUR-071Done
DR-128Audio-only playback of downloaded media reads the local file rather than fetching an audio-only stream. No transcode is involved or wanted: the Linux backend already runs MPV with video: no, so handing it the downloaded video file decodes the audio track and ignores the video, and ExoPlayer disables its video renderer equivalently. Transcoding to a separate audio artifact would cost CPU and battery, need an encoder the project does not ship, and produce a second file to keep in step — for no gain over simply not decoding the videoPlaybackUR-071Done
DR-129A stream that stops delivering is recovered, not treated as terminal. Two failure shapes, because the streams differ. (a) Phantom end — the background audio-only handoff uses a progressive mp3 transcode over plain HTTP, chunked and therefore length-less, so a dropped connection reaches the player as end-of-input and ExoPlayer reports STATE_ENDED indistinguishably from the real end. The item's runtime is the only thing that can tell them apart: an end reported more than a tolerance short of it (comparing the absolute position — handoff base plus the player's relative position) is a truncation. Left unhandled, playback parked in STATE_ENDED and the next play intent from the lockscreen, notification or a Bluetooth reconnect seeks an ended player to position 0 — the user-visible "the episode randomly restarted". (b) Recoverable error — music (/Audio/{id}/stream?Static=true) and video (/Videos/{id}/master.m3u8) declare their length, so the player detects the truncation itself and raises an error; the frontend's handler stopped playback outright, turning a hiccup into silence. Both resume the current item in place (never via play_item, which would replace the queue with a single item and lose the album), the error path after a per-attempt backoff. Seekable streams are re-prepared at the URL they already have and seeked; the length-less transcode, which cannot be seeked, has StartTimeTicks rewritten into its existing URL so the user's audio-track selection survives and recovery needs no network round-trip. Only Remote sources qualify — a local file cannot fail from the network. A shared budget of consecutive attempts at the same position, refilled whenever playback progresses, stops an unreachable server from loopingPlaybackUR-040, UR-004Done
DR-130A backend's position and duration must survive the end of the file they describe. MPV exposes time-pos/duration as properties of the loaded file, so at EOF it unloads and both stop resolving — the accessors reported 0.0/unknown at exactly the moment end-of-file handling asks where playback reached, and any position-versus-runtime check would have read every natural end as a truncation. The poll thread records the last reading and the accessors fall back to it. Linux resilience is layered on the same principle that the stream, not the player, is what failed: MPV is configured with ffmpeg reconnection (stream-lavf-o, network-timeout) so ordinary blips never surface, and EndFile(ERROR) — previously a bare log, which left playback halted while the UI still showed "playing" — is emitted as a recoverable error. Because MpvBackend is constructed before PlayerController exists, it cannot decide in-process like the Android JNI callback: the frontend echoes the error into player_recover_stream, which keeps the decision in Rust (the same shape as PlaybackEndedplayer_on_playback_ended). Android reports errors it has already declined as unrecoverable, so the echo never asks twicePlaybackUR-004, UR-040Done
DR-131The offline mutation queue is drained. sync_queue had producers and no consumer: PlaybackReporter::queue_for_sync writes a row for every start/stop/mark-played that cannot reach the server, sync_mark_processing/_completed/_failed were registered commands with no callers, and no Rust task processed the table — so queued watch positions never reached Jellyfin and the offline banner's count only ever grew. A drain hangs off the same connectivity:reconnected transition as DR-120 (in Rust, because a drain started by a component dies with it) and replays rows oldest-first, so a stale start cannot move the server's resume position backwards after a later stop. update_progress replays as stopped at N rather than as progress — replaying a mid-playback report hours later would claim the item is still playing — and payloads are read in both dialects that exist in users' databases (position_ticks from Rust, camelCase positionMs from the frontend helper). A failed row stays queued for the next reconnect; after MAX_SYNC_ATTEMPTS it is abandoned and stops counting, because a row nothing can ever push is what turns the queue into a counter that only grows. An unreachable server is not counted as an attempt at all — the row goes back to pending untouched — so opening the app offline a few times cannot abandon good rows; only a server that answers and refuses spends the budget. The drain also runs once at startup, because a queue built in a previous session would otherwise sit untouched for a whole run whenever the server was reachable the entire time and no offline→online transition ever fired. Requires MediaRepository::mark_played (JA-035) — the previous stand-in reported a stop at i64::MAXBackendUR-025, UR-002Done
DR-132The pending-sync count is answerable. The offline banner's badge read "N pending sync(s)" and led nowhere, so it was taken for pending transfers and looked for on the Downloads page — which lists the downloads table and structurally cannot show sync_queue rows. The badge becomes a button opening the queue it counts: each row's operation, the item's title (resolved by a LEFT JOIN items in sync_get_pending, not a per-row frontend fetch), when it was queued, and the error of anything failing, plus a "Sync now" that runs the DR-131 drain on demand. The same list is a Settings section, because a row that keeps failing is still queued when the server is reachable and no banner is on screen. The drain emits sync-queue-changed so the badge updates on reconnect instead of lagging by up to one 10s pollUIUR-025Done
DR-133A downloaded file has exactly one on-disk path, and the row that names it is authoritative. downloads.file_path starts relative to the storage root, but the worker rewrites it to the absolute path it actually wrote when the transfer completes — so a completed row is already rooted. The video player's offline branch rooted it a second time, handing the asset protocol /data/user/0/app//data/user/0/app/videos/x.mp4; the webview reported MEDIA_ERR_SRC_NOT_SUPPORTED with NETWORK_NO_SOURCE, so every downloaded video failed to play while audio — which resolves the same column through Rust's resolve_local_media_path, without re-rooting — played fine. The join is absolute-aware (POSIX, Windows drive letters and UNC) so rows written before completion still resolvePlaybackUR-071Done
DR-134The webview can actually fetch the local files it is handed. convertFileSrc rewrites a path to http://asset.localhost/… unconditionally, but Tauri only answers that origin when the protocol-asset cargo feature is compiled in and app.security.assetProtocol.enable is set — neither was, so every such URL reached a protocol with no handler and the webview reported NETWORK_NO_SOURCE. This silently defeated both offline video (<video src>) and the cached-thumbnail path in imageCache, which fails soft to the server copy and so hid the breakage whenever the server was reachable. The scope was $APPDATA/** — the storage root under which the database, downloads/ and the thumbnail cache all live — rather than an unrestricted grant; DR-198 narrows it further to $APPDATA/thumbnails/**, since DR-137 moved downloaded media off this protocol and thumbnails are all it still servesSecurityUR-071Done
DR-140An audio track is pinned only when the user picked one. Jellyfin's MediaStream.Index is global across every stream in a media source, so index 0 is the video stream on virtually all files — yet AudioStreamIndex=0 was sent as "the first audio track" on the HLS transcode URL, the background audio-only handoff URL, the direct-play fallback URL, and the PlaybackInfo negotiation body. A server that honours the request literally then transcodes the video stream into the audio slot and the result plays as a picture with no sound; only servers that silently correct the index hid the bug, which is why it presented as "some videos have no audio". The parameter is now omitted whenever no track has been chosen, so the server resolves the source's DefaultAudioStreamIndex; an explicit selection from player_switch_audio_track is still carried through unchanged. On the static=true direct-play URL it is dropped outright — the original file is served untouched, so the parameter could only misleadPlaybackUR-004, UR-040Done
DR-147One search input per screen, and the URL is the search's single source of truth. The header bar rendered only under /library/** and merely navigated to /search (DR-063), so a desktop search handed the user to a screen whose input was a different element — the header box cleared itself and vanished, and the page's own box took over mid-word. That page then re-derived its input from ?q= against library.searchQuery on every store write, so the next keystroke re-ran the effect and snapped the text back to the query the header had sent (and a scope chip back to the URL's scope); entering from the bottom-nav Search tab skipped it only because the effect early-returned on an empty query. The bar now renders on /search too (showHeaderSearch) and is the sole md+ input — the page's own input is md:hidden — and on that route it republishes the query into the URL with replaceState, so a whole session of typing costs one history entry. The page consumes that URL once per distinct value (seedFromSearchUrl against a non-reactive applied marker) instead of continuously reconciling it, and the scope chips publish through the same URL so the bar and the chips cannot disagree. Landing on /search with a seeded query focuses the bar and puts the caret at the end, because the box the user was typing in belonged to the unmounted routeUIUR-049, UR-054Done
DR-142An episode has exactly one surface, and it is complete. Two divergent renderings existed: EpisodeFocusView (reached from Continue Watching, the series episode list, the TV landing page and Downloads — i.e. every real entry point) offered only Play and Favourite, while the bare /library/<episodeId> page nobody routed to carried the download button, the series/season breadcrumbs and the cast section. Opening an episode the normal way therefore silently lost the ability to download it. The Focus View is now the single surface and carries the full §5B.2 composition — hero action row Play / Download / Favourite, series name and SxEy badge as links back to the series and to that season's anchor, then genres → cast → similar shows below the episode strip, never above it (DR-062). /library/<episodeId> redirects into it (episodeRedirectTarget, the same rule seasons follow under DR-103), and an episode with no seriesId renders the same component series-less rather than falling back to a second, lesser page. The focused episode is fetched in full rather than reused from the season fan-out, because that is a list query and carries neither cast nor genres — the sections would have rendered empty. The strip hides itself when the episode has no siblings, a card that only shows the episode you are already on being noiseUIUR-048, UR-058Done
DR-141The device profile states how many channels the audio route can actually voice. MediaCodecList answers "can this device decode 5.1", which is not the question that decides whether the user hears anything — a phone decodes an AC-3 5.1 track happily and still has two channels to play it out of. With no MaxAudioChannels in the profile, Jellyfin was free to direct-play the multichannel track, and the result is device dependent: a failed AudioSink configuration (silence) or dialogue folded into surround channels that go nowhere. media3's AudioCapabilities.maxChannelCount for the current route is reported over JNI alongside the codec lists, and bounds both the direct-play profile and the transcoding profiles, so the server downmixes rather than shipping channels the sink cannot take. Codecs are never removed from the profile — a device with genuine surround output keeps direct-playing it. A missing or zero reading means "route not yet established", not "no audio", and falls back to stereo, the one capability every sink hasPlaybackUR-004Done
DR-145Video playback starts only once the app actually holds audio focus. Video manages focus by hand (handleAudioFocus=false, because ExoPlayer's automatic handling is reserved for the audio path), and the request's three outcomes were all treated as success: AUDIOFOCUS_REQUEST_DELAYED — which setAcceptsDelayedFocusGain(true) explicitly invites, and which means the system is withholding our audio until it calls back — and an outright REQUEST_FAILED were logged and then followed by playWhenReady = true. The picture rolled with no sound, indistinguishable to the user from a broken stream. Playback is now held when focus is not granted and started from the AUDIOFOCUS_GAIN callback; an explicit play() re-requests focus rather than resuming into a stream the system is still muting, guarded by a held-focus flag so repeated plays do not leak focus requests. A LOSS clears the pending flag, so an unrelated later GAIN cannot start playback the user never asked forPlaybackUR-004Done
DR-146The no-audio-track fallback picks a track the renderer can actually play. When ExoPlayer selected no audio track, the recovery forced group 0 / track 0 unconditionally — but the most likely reason nothing was selected is that this very track cannot be decoded on this device, so the override reinstated the silence it was meant to fix. It now scans the groups for the first isTrackSupported track and overrides to that, and clears setTrackTypeDisabled(TRACK_TYPE_AUDIO) because audio may equally have been off at the type level, which an override alone does not undo. When no group holds a supported track the condition is logged as an error — the server was expected to transcode — rather than leaving a silent video with no explanation in the logPlaybackUR-004Done
DR-148The video direct-play profile advertises only what the webview can decode. The audio codec list comes from MediaCodecList, which describes ExoPlayer — but video does not play through ExoPlayer on either platform: Android force-renders every video in the webview <video> element (the interim override in VideoPlayer.svelte, because the native SurfaceView sits behind an opaque webview) and Linux always has. Chromium and WebKit decode a far narrower set than the platform does, and the gap is widest on devices whose vendor licenses Dolby: a phone shipping /vendor/etc/media_codecs_dolby_audio.xml reports ac3,eac3, so Jellyfin direct-played an E-AC-3 track with static=true and the webview built a video decoder and no audio decoder at all — full picture, no sound. The defect is triggered by capability, not the lack of it, which is why it reproduced on one Motorola while a Fairphone and an Honor tablet played the same file on the same build: a device without the Dolby decoder never claims the codec, so the server transcodes to AAC and it plays. video_audio_codecs narrows the platform list to the webview-decodable set (aac,mp3,opus,vorbis,flac) for the video direct-play profile only — the audio-only profile keeps the full list, since that playback really is the native player's and narrowing it would transcode music that plays perfectly well. A list with nothing decodable still claims aac rather than going out empty, because a profile that claims nothing invites the server to give up instead of transcoding. The video codec list is deliberately untouched: HEVC direct-plays through the webview correctly, so the constraint is specific to audioPlaybackUR-004Done
DR-149The client decides whether its own renderer can decode the audio, rather than trusting the server's negotiation. Advertising a webview-shaped profile (DR-148) turned out to be necessary but not sufficient: Jellyfin 10.11.5 enforces a DirectPlayProfile's Container and VideoCodec — excluding either returns SupportsDirectPlay: false with TranscodeReasons=ContainerNotSupported / VideoCodecNotSupported — but ignores its AudioCodec, offering an E-AC-3 track for direct play against a profile listing only aac,flac,mp3,opus,vorbis. Neither a VideoAudio CodecProfile forbidding the codec nor a MaxAudioChannels: 2 against a 6-channel track changes the answer, so no profile the client can send fixes it and the picture plays silent. The negotiated source's audio is therefore checked locally against what the webview decodes, and an undecodable track forces the existing h264/aac HLS transcode URL regardless of the server saying direct play is fine — direct_play and needs_transcoding are corrected to match, so the frontend and the reporting path agree with the URL actually used. The track judged is the one the server would serve: the default, or the first when nothing is marked default, since a supported track further down the list is not the one that plays. A source with no audio streams, or a stream whose codec the server did not name, is left alone — forcing a transcode on a guess spends server CPU on files that already playPlaybackUR-004Done
DR-150Android video renders on the native ExoPlayer surface behind a transparent WebView, behind the experimentalNativeVideo opt-in. Rust already reported use_html5_element: false on Android, but two frontend overrides discarded it — createAdapter() hardcoded "html5", and VideoPlayer.svelte forced useHtml5Element = true and stopped the native backend player_play_item had just started. The flag is a suppressor, never a promoter: off forces HTML5 even where Rust says native, so an in-progress spike cannot ship as the default, but it can never select native where Rust reported HTML5 (Linux cannot composite behind WebKitGTK, so promoting there is a black screen). Compositing requires clearing two independent opaque layers, and clearing only one leaves audio over a black picture — the WebView widget background and window drawable from Kotlin (AndroidVideoSurface.setTransparent), and the page's html/body and app-shell background from CSS (data-native-video). Transparency is declared in tauri.android.conf.json rather than the base config, because a transparent window on Linux has nothing behind it, and is toggled per playback session rather than set once, because a permanently transparent window shows the launcher through the rest of the appPlaybackUR-003, UR-004Done (behind experimentalNativeVideo, default on since DR-194/DR-196)
DR-151The player's video SurfaceView actually reaches the view hierarchy. JellyTauPlayer.setActivity() had zero callers, so currentActivity was always null and autoAttachSurface() returned at "Cannot attach surface - no Activity reference". The surface was created and handed to ExoPlayer but never added to the content view, so native video decoded to a surface that was never on screen — independent of any webview transparency. MainActivity.onCreate now supplies the reference, which also revives PiP on the video path: canEnterPip() gates on isVideoSurfaceAttached(), which had been permanently falsePlaybackUR-003, UR-041Done
DR-152Platform playback facilities are reported by Rust, not sniffed from the user agent. webviewAudio.ts re-derived "does this platform have a native audio backend" by matching navigator.userAgent against android/linux — a second copy of the cfg! gate the backends are compiled under, free to drift from it. player_get_capabilities now returns usesWebviewAudio and supportsNativeVideo from the same cfg gates, and the frontend consumes them; the settings toggle for native video is hidden entirely where the platform cannot support itPlayerUR-003, UR-005Done
DR-153The git tag is the single source of truth for a release version. The version lived in four files (package.json, tauri.conf.json, Cargo.toml, Cargo.lock) that had to be hand-edited in lockstep, and CI's release job rewrote exactly one of them — so a tagged build produced an installer named for the tag wrapped around package metadata naming the previous release, while the Linux job had no version step at all and shipped whatever was committed. scripts/set-version.sh writes all four from one argument and is the only thing that does; every release job calls it with the tag. The Android versionCode is derived in the same place as 1000 + major*10000 + minor*100 + patch, which is monotonic in semver order and clears the 1000 floor already installed in the field — a lower code than the installed one makes Android refuse the update. A prerelease suffix is stripped before that arithmetic, which would otherwise abort the script, and a non-tag ref (CI passes ${GITHUB_REF#refs/tags/} unconditionally) falls back to git describe rather than failing a branch buildBuild-Done
DR-154A watch position that cannot reach the server is queued, not dropped. sync_queue and its drain (DR-131) were built, tested and running, but the stop-report path never fed them: HybridRepository::report_playback_stopped is a bare pass-through to the online repository ("Playback reporting goes directly to server"), and on failure the error surfaced to a frontend catch whose own comment read "Server error - could queue, but for now just log". Both producers that would have queued it — PlaybackReporter::queue_for_sync in Rust and syncService.queuePlaybackProgress on the frontend — have no callers on the playback path, so closing a video while the server was unreachable lost the resume point outright even though user_data.pending_sync was dutifully set to 1 and nothing ever drains that flag for positions (unlike favourites, DR-120). The command layer now enqueues a report_playback_stopped row whenever the push fails, which the existing drain already knows how to parse and replay. The pending row for an item is superseded in place rather than appended to: progress is reported every 10s, so a server that stays down would otherwise add a row per tick, all of them obsoleted by the newest — the unbounded queue DR-131 exists to prevent. Only pending/failed rows are superseded, because an abandoned row has been given up on and reviving it would restore that same growing counter. Queueing is best-effort and never fails the command: the local position is already saved, so a failed queue write must not be reported as a lost positionBackendUR-025, UR-002Done
DR-155A watch position set on another device reaches this one. The resume check reads the local user_data row and nothing else, but mirror_user_data — the only path by which server UserData lands in that table — mirrored is_favorite alone, and returned early whenever that field was absent, which is exactly the shape of an ordinary watched episode. So playback_position_ticks was write-only from this device's perspective: watch 40 minutes in a browser, open JellyTau, and it resumed from whatever this device last saw or offered no resume at all — the same user-visible symptom as DR-150's Android bug, from an unrelated cause, which is why resume read as broadly flaky. The mirror now carries the position alongside the favourite flag under the same pending_sync = 0 conflict rule, so a local position still waiting to be pushed is never pulled backwards by a server that has not yet heard where we got to; COALESCE(excluded.x, user_data.x) means a field the server omitted keeps its stored value rather than being nulled, and a row with neither field is still skipped rather than fabricated as zeroes. Mirroring alone was not sufficient: get_item — the call the player route makes — returned the cached copy on a hit and never consulted the server, so for an already-cached item the mirror never ran. It now refreshes in the background on a cache hit (race_with_refresh, the reusable form of what get_items already did inline), which is why browsing a season picked up other devices' state while opening the episode directly did not. The refreshed value lands for the next read, the cache-first race still answering immediatelyBackendUR-025, UR-002Done
DR-156A page no longer inherits the previous page's scroll position. The shell keeps its scrollers alive across navigation by design — the root layout, the home page and the library layout each own a flex-1 overflow-y-auto box that outlives the route inside it, which is what lets BottomUi be a flex sibling rather than a measured overlay — but the element therefore never remounts and its scrollTop survives the route change. SvelteKit's own scroll restoration could not help: it saves and restores window scroll, and in this app the window never scrolls at all, so there was no scroll handling of any kind. The symptom was that opening an item from half-way down a library grid dropped the viewer half-way down the detail page, and returning to the grid landed at the top of it — exactly backwards. ScrollMemory (pure, one instance per container, keyed on path + query so a genre-filtered grid keeps its own place) records the offset a route is left at in beforeNavigate and decides in afterNavigate: link/goto/form reset to the top, popstate restores that route's saved offset, and the initial enter is left alone. Deciding does not consume the offset, so a route returned to more than once restores each time. Applied via the scrollContainer action on all three scrollersUIUR-072Done
DR-160Picture-in-picture works on the path that actually plays video. PiP shrinks the whole Activity, so canEnterPip demanded a native ExoPlayer SurfaceView be attached and rendering — isPlayingVideo() && getSurfaceView() != null && isVideoSurfaceAttached(). But the native path sits behind experimentalNativeVideo, which defaulted to off, so in the shipping configuration video played in the WebView's <video> element and all three conditions were false. enterPip bailed with "Not entering PiP: no local video playing" every single time: the button was offered (gated only on OS capability) and could not work, however it was pressed. The manager now accepts either surface. The frontend reports the element through AndroidPictureInPicture.setHtml5VideoState(active, width, height, playing) — intrinsic size because the PiP window's aspect ratio came from the letterboxed surface's measured bounds, which do not exist here, and play state because ExoPlayer.isPlaying is false on this path and the PiP play/pause action would be frozen on "Play" mid-playback. Two behaviours invert when the WebView is the video: it must stay visible in PiP rather than be hidden (hideWebView is now gated on the native path — hiding it would leave an empty black window), and the play/pause RemoteAction has to reach the element, so the receiver dispatches jellytau-pip-play/jellytau-pip-pause DOM events instead of driving ExoPlayer. jellytau-pip-entered/-exited let the player strip its own chrome, since controls, title and gradients would otherwise be rendered into a window a couple of inches wide. The <video> is deregistered on teardown so PiP is never offered over a video that has goneUIUR-041Done (pending device verification)
DR-167Each downloaded library shows only its own media. Cached items carry no link back to their library — library_id and parent_id are NULL on every row ([[offline-libraries-never-cached]]) — so get_downloaded_items matched the library branch with EXISTS (SELECT 1 FROM libraries l WHERE l.id = ?), which asserts only that the requested library exists and never constrains the item to it. Opening any downloaded library therefore listed every downloaded top-level item on the server: films under Music, albums under TV. The sibling query that decides which libraries appear already carried the right rule — a collection_typeitem_type mapping — so the two disagreed about the same question. That mapping is now the named constant LIBRARY_HOLDS_ITEM, used by both, and a library of unknown collection type still keeps everything rather than being emptied by a rule that cannot classify it. The taxonomy stays in Rust, never the frontendDownloadsUR-055Done
DR-168Pause and resume actually stop and restart the bytes. pause_download wrote status = 'paused' and did nothing else, and no cancellation existed anywhere in the download stack — no token, no flag, no abort — so the streaming task ran on, kept writing, and overwrote the row with completed/failed when it finished: the row flicked to "paused" and undid itself. resume_download had the mirror defect, flipping the row to pending without calling pump_download_queue; the pump runs when something calls it rather than polling, so a resumed download sat untouched until an unrelated event happened to pump the queue. A per-download stop flag (download::stop) is the missing half — a module-level registry because the two sides never meet, the command holding Tauri state and the worker running detached in async_runtime::spawn. The worker reads it between chunks and on retry (so a pause is not swallowed by a 45-second backoff), flushes, and returns Stopped, which is deliberately not retryable and not recorded as a failure: the .part file is left intact because that is exactly what the resume's Range request continues from. Registering returns a fresh flag, or a resumed download would inherit the pause that stopped it and halt instantly. Cancel and clear_stale_downloads signal it too, so neither deletes a file still being writtenDownloadsUR-055Done
DR-169Partial files are actually reaped. The worker named its sidecar with Path::with_extension("part"), which replaces the extension — movie.mp4 became movie.part — while every cleanup path deleted "{file_path}.part", i.e. movie.mp4.part. The two never matched, so the partial file of every cancelled or failed download stayed on disk indefinitely, invisible to the disk-usage totals because no downloads row pointed at it. partial_path appends instead, is the single definition both the writer and the cleaners use, and incidentally removes a collision the old form had, where movie.mp4 and movie.mkv mapped to one movie.partDownloadsUR-055Done
DR-173Downloading an album queues the whole album, and every track it queued is findable offline afterwards. Two independent gaps left an album with a handful of its tracks on the device while the button reported the album as downloaded. First, download_album took its track list from items WHERE album_id = ? — the local catalog cache. Jellyfin does not return AlbumId on every listing endpoint, so tracks cached by one of those endpoints sit in items with a NULL album_id and are invisible to that query; on the reporter's database three whole albums (18, 12 and 9 tracks) had it NULL on every track, so "download album" would have queued nothing for them, and a partially-linked album queued only the linked subset. Second, the frontend then resolved one stream URL per track from its own list and paired it with the returned row ids by position — a pairing with no basis, since the ids came back in the backend's index_number order over a different set of rows, so a row could be handed another track's URL and any track past the end of the shorter list was never started at all; on Android that loop also stopped wherever the webview was suspended. The same album_id is what OfflineRepository::get_items joins a track to its album on, so a track that did download stayed invisible under its album offline — the two halves of the same missing link. The operation now belongs to Rust end to end: HybridRepository::get_album_tracks asks the server what the album contains (cache-first get_items is right for browsing and wrong for deciding what to download) and errors offline so the caller falls back to the ungated local catalog, keeping the queue-while-offline flow; queue_album_tracks writes the album link onto every track it queues — queuing a track is the statement that it belongs to the album, rather than something to hope a listing endpoint recorded — and the stream URLs are resolved here through the existing reconnect resolver, now scoped to the rows just queued so one album cannot start every unrelated pending row. Nothing crosses the IPC boundary but the album id. Re-queuing a broken album heals it: the missing tracks are added and the tracks already on disk get their link. download_series/download_season still derive their episode lists from the cache the same way and want the same treatmentDownloadsUR-018, UR-055Done
DR-170Downloads at a chosen bitrate are no longer corrupted by their own retries. Only the original preset asks for Static=true; every other rung requests a transcode, which Jellyfin serves chunked, with no Content-Length, and cannot byte-seek — so it ignores Range and answers 200 with the whole stream from the beginning rather than 206 with the requested tail. The worker sent the Range header whenever a .part existed and appended the body unconditionally, so each retry and each resume concatenated a fresh copy of the entire transcode onto the bytes already on disk: the file grew past its real size and would not play, which is why "downloads for different bitrates" stayed broken after the videoBitRate casing fix (DR-adc460f3) corrected the request. resume_offset makes the response decide — append only on a 206, otherwise truncate and take the stream from the top — and the total size is computed from that offset rather than from a partial length the server never agreed toDownloadsUR-071Done
DR-172Native Android video is opt-in again, because as a default it shipped as audio with no picture. DR-161 flipped experimentalNativeVideo on so picture-in-picture could shrink a real video surface; on a device that produced sound and a blank screen. The decode path was never the problem — logcat showed ExoPlayer running (Position update ticks) and feeding a live SurfaceView with an active BufferQueue. The compositing was: the SurfaceView sits behind the WebView, and the step that clears the opaque layers above it never took effect, with WebView transparent = false logged and = true never appearing. So the video rendered correctly the whole time, behind an opaque page. This is exactly the defect the flag existed to contain — VideoPlayer.scrubRegression.test.ts had recorded that "the native SurfaceView has never been visible through the webview" — and enabling it by default shipped a verified decode path on top of an unverified display path. Reverting costs nothing that matters: PiP does not depend on it (DR-160 drives PiP from the WebView <video>), and working video outranks PiP showing a native surface. The flag stays available in Settings, now described as incomplete rather than as a performance win, and the scrub-regression mocks that were made explicit under DR-161 are kept explicit so those tests state which path they guard rather than inheriting a default that has now moved twice. Fixing the compositing is the prerequisite for trying this default againUIUR-003, UR-004, UR-041Done
DR-171A downloaded video keeps audio the device can actually decode. original quality asked for Static=true, which hands back the source file byte-for-byte — E-AC-3/AC-3/DTS/TrueHD track included — and video is rendered on both platforms by the webview <video> element, which decodes none of them. Streaming already knew this: DR-149 judges the track the server would serve against WEBVIEW_AUDIO_CODECS and forces a transcode over Jellyfin's own direct-play offer, because 10.11.5 honours a DirectPlayProfile's container and video codec but ignores its audio codec. The download path never consulted that policy, so the same film had sound when streamed and played as picture in silence once downloaded — and offline a download is the only source a video has, so there was no working path left to fall back to. The rule is now one rule: served_audio_codec picks the track the server will serve (the default, or the first when none is marked) and both callers judge it, the streaming verdict staying a bool and the download path needing the codec itself so it can say what to re-encode. Only the audio is re-encoded — allowVideoStreamCopy=true keeps an h264 source's picture byte-for-byte and no bitrate or resolution cap is added, so original still means original quality; a source the webview could not have rendered anyway (HEVC) becomes h264 as a side effect, which is the only form of it that would have played. The decision is per item rather than blanket because the transcode costs the byte-range resumability Static=true gives the download worker (see DR-170 for what a chunked, length-less response does to a resume), so a file whose audio already plays keeps the direct copy. An unknown codec — item not fetchable, or the server named none — changes nothing: the policy only ever adds a transcode, so it cannot make a working download worse. The codec set judged against is the webview's, not the platform's, even though DR-161 made ExoPlayer the Android default: experimentalNativeVideo is a user setting, a downloaded file outlives whatever it was set to when the file arrived, and the narrow list is the only one that holds on both sides of it — at the cost of a Dolby-licensed device re-encoding a track its ExoPlayer could have played. resolve_video_download_url is the single entrance for all three resolution sites (the frontend's per-item command, the bulk series/season enqueue, and the offline-queued resume), since the pure builder cannot look a codec up and a caller that forgets to is exactly how the silent downloads shipped. Files already downloaded stay silent — the bytes on disk are the wrong bytes and only a re-download replaces themDownloadsUR-071, UR-004Done
DR-162Video streams are opened against a bandwidth ceiling the user chose, instead of a fixed allowance nobody could change. Every video URL carried MaxStreamingBitrate=20000000/VideoBitrate=18000000, PlaybackInfo negotiated at 20 Mbps, and the device profile advertised 999999999 — so on a metered or slow connection the only lever was not watching. StreamingQuality is a ladder of ceilings (Original, 20/10/8/4/2/1 Mbps, 720 kbps) in which a step is not a label but a bundle of transcode parameters: the total ceiling, the audio share of it, and the resolution that budget can carry. It lives in Rust because those numbers are Jellyfin encoding vocabulary — the frontend names a variant and reads labels back over player_get_streaming_qualities, the same arrangement as the EQ preset curves. The video bitrate is the total minus the audio share, so the two together honour the cap rather than overshooting it by the size of the audio track, and MaxHeight falls with the ladder so a small budget is not spent on pixels it cannot afford. The cap has to reach the negotiation, not only the transcode URL: max_static_bitrate in the device profile is what makes the server refuse to direct-play a source fatter than the ceiling, and without it a 30 Mbps remux is handed over untouched and every URL parameter downstream is moot — which is why it is applied at all four places that decide bandwidth (the HLS builder, PlaybackInfo, open_live_stream, and the background-audio handoff, which takes the lower of the cap and its own 384 kbps). The ceiling is process-wide rather than a field on OnlineRepository, mirroring INCLUDE_CATALOG_BROWSE: it is a preference about this device's connection, it must survive a repository rebuilt on re-login, and every builder plus the negotiation have to agree on it or the cap leaks. Settings owns the durable default and is the only writer to app_settings — persisted unlike the rest of VideoSettings, because a limit set for a metered connection that silently reverts to uncapped on the next launch spends the user's data with no changed setting to show for it — and it is restored at startup from the async runtime, defaulting to uncapped if the read fails so a database problem degrades to the old behaviour rather than to an arbitrary limit. The in-player menu is the per-video override: a cap is a property of the stream the server is producing, so it cannot be applied to one already in flight — player_set_stream_quality re-opens the stream at the new quality and resumes at the current position, reloading a native backend itself and handing HTML5 a URL for the same reloadSource primitive the audio-track switch uses, so no strategy branch lives in the UI. It deliberately does not persist. This gives UR-070 its resume-at-the-same-point mechanism; the server-offered per-item rendition list that requirement also asks for remains proposedPlaybackUR-074, UR-070Done
DR-174Tiles of mixed shapes are laid out justified rather than gridded. A CSS grid gives every cell one box, so on a page holding square music covers, 16:9 library backdrops and 2:3 posters at once, everything that is not the chosen shape is cropped to it — the home shortcut strip was explicitly forcing aspect="video" on music libraries for exactly this reason, which lined the row up by cutting the covers down. layoutMosaic packs tiles into rows of a shared height and gives each its own width from its own aspect ratio: it adds tiles to a row until the height needed to fill the container has fallen to the target, closes the row there (so rows land at or below the target, never above), and justifies the row to the container width by absorbing the rounding remainder into its widest tile, where a pixel is least visible. The last row is deliberately not justified — with one tile left over, filling the width would inflate it to a banner — so it sits at the target height, left-aligned. Ratios are clamped to a band, which costs a crop on genuine outliers and stops one panorama owning a row or one very tall image shrinking to a sliver. It is a pure module with no DOM: the component supplies only the two things the DOM knows — the measured container width, and the artwork's decoded aspect ratio, reported by CachedImage so the layout uses the shape an image actually has rather than the one its item type implies. Those measurements are committed in one debounced batch rather than per image, because artwork arrives over several hundred milliseconds and re-packing on each arrival would shuffle the grid under the pointer repeatedly. Labels are drawn over the bottom of each tile rather than beneath it: a caption below sits outside the computed box, and one that wraps to two lines would break the row alignment the layout exists to provideUIUR-075Done
DR-175A library knows which favourites category it belongs to, and the frontend does not work it out. The mosaic offers a favourites tile per category beside its library, which needs a collection-type → category answer; deriving it in Svelte would have re-created the exact leak SearchScope::item_types was extracted to close (docs/specs/scoped-search-boundary.md) — one table of Jellyfin vocabulary, differing only in which vocabulary. SearchScope::for_collection_type maps movies/tvshows/music and returns None for everything else, so a Live TV or books library gets no tile at all rather than one opening an unfiltered list; All is never derived from a library, being the cross-library entry offered beside them rather than a property of one. Library::new stamps the result onto every library at construction — a constructor rather than a struct literal precisely so a derived field cannot be forgotten at one of the four sites — and it rides to the frontend as an optional favoritesScope, absent rather than null when there is none. The UI's remaining share is presentation only: what to call the tile, where to put it, and showing a category's tile once however many libraries share it, since two movie libraries have one favourites list between themUIUR-075, UR-067Done
DR-176The server is never asked to burn a subtitle into the picture. PlaybackInfo omitted SubtitleStreamIndex, which does not mean "none" — the server then honours the source's default/forced flag and picks a track itself. On a source whose default subtitle is image-based (PGS/DVD/DVB) that track cannot go out as a sidecar, so the server falls back to SubtitleMethod=Encode and composites it into the video. The cost lands on the video, not the subtitle: burn-in rules out remuxing, so an HEVC stream the device could have taken untouched is re-encoded frame by frame. Observed on an HEVC + E-AC-3 + PGSSUB episode, where only the audio actually needed transcoding: the server could not sustain the re-encode in real time, the buffer never grew past a single segment, and playback stalled every few seconds — taking seeking with it, since each seek restarted the encoder and cost seconds before the first frame. The fix is to request SubtitleStreamIndex=-1 explicitly and to advertise every text format we can render (srt/subrip/ass/ssa/vtt) as External, so a subtitle can only ever arrive as a sidecar. Nothing is lost, because the app already fetches subtitle tracks itself and draws them over the video (UR-020) — the server's composited copy was always redundant. Image-based tracks are consequently not offered, which is honest rather than a regression: the renderer cannot composite a bitmap, and the previous behaviour paid for them by making the whole stream unwatchable. Both halves of that hold at the layer that can enforce them. The sentinel travels on the stream URL as well as in the negotiation, because the negotiation is not what opens most streams — a quality switch, a transcoded seek and an audio-track switch each rebuild the URL on their own, and an omitted index there lets the server pick the default track back up out of whatever session state it still holds. And "not offered" is enforced where the offer is made: each subtitle stream crosses the boundary carrying the backend's verdict on whether it can arrive as a sidecar, so the picker lists only tracks the app can draw instead of showing an entry that ticks and displays nothing. Only an explicit "no" hides a track, so a stream carrying no verdict behaves as beforePlaybackUR-020, UR-004Done
DR-177Each video transcode this device opens is its own server-side job, and the one it replaces is stopped. Jellyfin keys a transcode job by device and play session, and every stream URL the app built carried the same hardcoded DeviceId with no PlaySessionId at all — so the second stream for an item was indistinguishable from the first. Re-opening a stream is not rare: a mid-playback quality switch (UR-074), a transcoded seek and an audio-track switch all do it, each leaving the previous ffmpeg running. Observed on-device when switching bitrate mid-film: the server served the new playlist, then rejected the new job's segments with 400 hls1/main/0.ts while the two jobs contended for one transcode path, and playback stalled — reproducible against the server, where a second stream for a live job's item alternates between serving bytes and 400ing per attempt, which is what made it read as flaky rather than broken. begin_video_play_session mints a session id per open and reports the one it supersedes; the URL builder stops that job (DELETE /Videos/ActiveEncodings, un-retried and best-effort — a slow stop must not delay playback, and the new stream no longer collides either way) before returning. Placing it in the URL builder rather than in each caller means every re-open path is covered by construction. Two client faults made the same incident worse and are fixed with it: the fatal-HLS-error handler added the transcode seek offset to a position that already included it, so past roughly the halfway mark of a film any transient network error cleared the "near end" threshold and was reported as end-of-stream — turning a recoverable stall into a skip to the next item, exactly when a quality switch had just made the offset large; and the HTML5 reload primitive resolved on its own canplay timeout, so a reload the server never served reported success, leaving the picker showing a quality that was not playing and the caller with nothing to revertPlaybackUR-074, UR-004Done
DR-178Every position that leaves the app is read from the controller, not from a backend that may not be playing anything. PlayerController::position() forwards to the native backend, which is authoritative for exactly one of the three ways this app renders media. On the webview path — the shipping default for video on both platforms — nothing is loaded into that backend at all: the <video> element is the player, its ticks were re-emitted to the frontend and then dropped, and the backend answered 0 forever. During a background-audio handoff the base that converts the stream's relative timeline to the episode's is applied once at the native tick boundary (DR-159), so before ExoPlayer's first tick nothing has applied it and the reading is 0 there too. Both holes surfaced as the same user-visible bug through different doors: returning to the foreground while the audio-only transcode was still opening handed the frontend 0.0, and the video reloaded at StartTimeTicks=0 — the episode restarting from the beginning — while the Stopped report that followed wrote that zero to Jellyfin as the resume point. absolute_position() answers for all three paths: the maximum of the backend's reading, the last position webview-rendered media reported, and the handoff base. The maximum is exact rather than a heuristic, because at most one term is ever meaningful at a time and the base is a floor the stream cannot physically be behind. duration() gains the same fallback for the same reason. The element's reading is cleared wherever it stops being the player — teardown, a handoff taking over, a different item loading — so it can never be attributed to what plays nextPlayerUR-005, UR-025, UR-040Done (pending device verification)
DR-179Jellyfin is told what was played: progress while it plays, and a stop when it ends. A device trace of 35 minutes' playback requested /Sessions/Playing/Progress zero times and sent 14 Stopped reports, every one of them at position 0. Three faults, one subject. Progress never left the device: the frontend service writes it to the local DB by design, and nothing on the Rust side reported it for webview-rendered media — so the server learned a position only when the player was closed, and a crash or a swipe-away cost the session. It is now reported from the controller's own position ticks, through the 30s throttler it already owned and shares with the native audio path, which covers all three rendering paths in one place instead of adding a second frequent IPC caller. Zero-position stops were sent: Jellyfin stores the reported position as the resume point, so a zero does not merely fail to inform, it instructs the server to forget — and no zero was ever real, each one coming from asking a player that was not rendering the media (see DR-178). They are withheld; one landed 40s after the frontend had correctly reported 15:22 for the same episode, overwriting it. A finished episode reported nothing at all: Jellyfin decides "watched" from the stop report and its percentage, and in background audio-only mode nobody sends one — the webview is suspended and its element was torn down at the handoff, while the backend advances to the next episode without a word about the one that ended, so an episode listened to end-to-end on the lockscreen never counted as watched. on_playback_ended now reports it stopped at its runtime (not the last tick, which can be seconds short or, on a handoff whose ticks stopped early, nowhere near the end) before any advance, since after one the queue's current item is the next episode. Scoped to the audio-only handoff, the case the frontend provably cannot cover, so foreground playback keeps its single existing report; music ending natively remains unreported and wants its own change. The reporting seam is a PlaybackReportSink the controller sends to, which also collapses three copies of the spawn-a-task-and-hope block into one and is what let all of this be written as failing tests rather than found on a device a second timePlayerUR-025, UR-005, UR-040Done (pending device verification)
DR-180A background-audio handoff of a downloaded episode starts where the video left off. The handoff prefers a local file over the audio-only stream (DR-128), but the two begin in different places and were treated alike: a stream is built with StartTimeTicks, so the server makes the handoff point that stream's zero and the base is the handoff position with no seek — while a file has no such parameter and begins at the episode's own zero, so basing it at the handoff position claimed minutes of audio that were about to play from the beginning. Backgrounding a downloaded episode therefore restarted it while the lockscreen scrubber, dutifully adding the base, showed the position it should have been at. background_audio_plan splits the two: a file gets no base and a real seek, a stream keeps the base and no seek (seeking one would skip past the content by the handoff position again). The same distinction settles an inbound seek — seek_absolute re-opens a streamed handoff at the requested position because a chunked length-less transcode cannot honour a seek, which is not true of local media, and resume_stream_at refuses a non-remote source outright, so routing a lockscreen scrub of a downloaded episode through it failed the seek rather than performing itPlayerUR-040, UR-071Done (pending device verification)
DR-181A resumed transcode plays. Every video stream URL carried the resume position as StartTimeTicks, which is correct for a progressive response and fatal for an HLS one: Jellyfin builds each segment URI by echoing the master playlist's query string into it, and its segment handler opens by rejecting any request carrying StartTimeTicks > 0 (ArgumentException400). One position on the playlist therefore 400s every hls1/main/N.ts behind it, so hls.js exhausted its retries and gave up — presenting as an episode that will not resume while the same episode from the beginning is fine, the > 0 being exactly why the beginning survived. The parameter is also unnecessary there: a playlist spans the whole item and asking for segment N is the seek, which the server transcodes from. So it is removed from the URL builder entirely rather than conditionalised — the builder has one caller shape and no way to know whether the response will be segmented — and the position becomes what it always was for HLS, a seek issued once the player has loaded: the seek path reloads at zero and seeks the element, and the resume path lets the player seek itself. The progressive /Audio/universal builder used by the background-audio handoff is a different endpoint with no segments and keeps its StartTimeTicks, which is why an audio-only handoff resumes correctly and a video one did notPlaybackUR-004, UR-074Done
DR-182Native video shows a picture. The poster/title card is an opaque bg-black overlay drawn over the whole video area while isMediaReady is false, and every signal that clears it is emitted by the HTML5 <video> element — canplay, loadedmetadata, hls.js FRAG_BUFFERED, the playing event, and two readyState timeouts. The native path renders no such element ({#if !!useHtml5Element}), so on Android nothing could ever clear it: ExoPlayer decoded to a live SurfaceView behind a black div for the entire session. That is DR-172's "audio with no picture" report, and it is indistinguishable on screen from the compositing failure DR-172 attributed it to — which is why the flag was reverted rather than fixed. Both the overlay and the native branch date from the original POC commit, so the native path has never been able to reveal itself; the 2026-08-11 device verification predates neither and does not contradict this, since a spike run that never reached a steady state would not have shown it. The backend's own events are the equivalent signals and nativeSignalRevealsVideo is the rule for reading them: state === "playing" mirrors the element's playing event, and a position tick carrying a real position or duration mirrors the readyState backstops, covering a first state event that is dropped or arrives before the listener is attached. buffering/paused/stopped/error deliberately do not qualify — revealing on error would replace the title card with a transparent hole showing the launcher through the app. The rule is a pure module rather than a branch inside the component because the decision that was missing is exactly the part worth guarding, and the component needs a DOM and a mounted player to exerciseUIUR-003, UR-004, UR-041Done
DR-183The JavaScript bridges are installed before the page that uses them loads. WebView binds an injected object into JS at page-load time: an addJavascriptInterface call landing after the page has loaded does not appear to that page. They were installed from configureWebViewForMedia, which finds the WebView by walking the view tree 500 ms after onCreate — a race against Tauri's own page load, and one that is permanent when lost, because the identity guard added for DR-097's stale-proxy bug then declines to re-inject on every later resume pass. The whole set (AndroidVideoSurface, AndroidPictureInPicture, AndroidBackgroundAudio, AndroidNetworkType, AndroidImmersive, AndroidInsets) would simply be absent from window, and silently: every call site optional-chains the bridge, so a missing one is a no-op rather than an error. This is a candidate explanation for DR-172's other piece of evidence — WebView transparent = false logged, = true never appearing, i.e. the enable call never reaching Kotlin at all. WryActivity.setWebView() calls the onWebViewCreate hook immediately before wry issues the first loadUrl (confirmed in wry 0.55's main_pipe.rs, where the setWebView JNI call precedes load_url), so a bridge installed there is bound by the time any page runs. The hook can fire during super.onCreate(), before the rest of our own onCreate, so only work needing nothing but the WebView moves into it — insets stay in configureWebViewForMedia, which runs later and on every resume. The tree-walk path is kept as a fallback, and enableNativeVideoCompositing now logs an explicit error when the bridge is missing, so the ambiguity that left DR-172 unresolved cannot recur silentlyAndroidUR-003, UR-004, UR-040, UR-041Done
DR-184The video SurfaceView leaves the view hierarchy when the video does. VideoOverlayManager.detachVideoSurface had no callers anywhere in the tree — the mirror of the DR-151 defect, where setActivity had none — so attachVideoSurface was one-way: JellyTauPlayer.clearVideoSurface() dropped its surfaceView reference and cleared ExoPlayer's without removing the view, leaving it parented to the content view for the life of the process, with the next native video adding another SurfaceView beneath it. The stack was invisible while the WebView was opaque, which is why it went unnoticed. Two consequences outlive the leak: isVideoSurfaceAttached() gates PictureInPictureManager.canEnterPip through isNativeVideoPath(), so it reported an attached surface forever after the first native video (saved from offering PiP over nothing only by the isPlayingVideo() check beside it), and every abandoned surface held its OnLayoutChangeListener on the content view. Detach is called from clearVideoSurface, which covers stop, the switch to audio, and the background-audio handoff, and always runs on the main thread because every caller is already inside a mainHandler.post. It removes the view from its own parent rather than looking the content view up from an Activity reference, so an Activity recreated underneath it cannot strand the viewAndroidUR-003, UR-041Done
DR-185The app shell stops painting over the video surface. app.css clears the page's opaque layers for native video through three selectors, and one of them — html[data-native-video="active"] [data-app-shell] — was written against an attribute no component has ever set, in any commit. The shell is +layout.svelte's root div, which paints --color-background across the entire viewport; VideoPlayer is fixed inset-0 z-50 and correctly makes itself transparent on the native path, but it stacks above the shell, so the WebView still composited the shell's opaque background over the whole screen and the SurfaceView behind it could never be seen. This is the missing half of the compositing DR-172 went looking for: the spec's own layer table lists this layer as "cleared by data-native-video → app.css", which was written but never wired, and html/body being genuinely transparent made the CSS look correct in isolation. The failure is invisible three ways over — the CSS is valid, the selector is plausible, and a rule matching nothing looks exactly like a rule matching something already transparent — while the symptom (black screen, audio fine) is identical to a real compositing failure, which is how it survived DR-150 through DR-172. Fixed by setting the attribute the rule was written for, and guarded by asserting the relationship rather than the rule: every attribute the compositing block targets must be set somewhere in the app, so a selector aimed at nothing fails the suite instead of failing silently on a deviceUIUR-003, UR-004, UR-041Done
DR-186The play overlay comes down when the backend plays. isPlaying was assigned once from the player_play_item response and thereafter only by the player://state-changed listener — a channel the backend never emits, the same dead wire that DR-182's first fix was mistakenly hung on. On the native path the flag therefore froze at whatever the initial response said: with ExoPlayer playing, the UI still believed it was paused, so the bg-black/30 play-button overlay stayed raised across the whole video area and the transport button kept showing ▶. The video was simultaneously dimmed and covered while it played, which reads as "the overlay never goes away" and is easily mistaken for a second compositing fault. The mirror reads the same player store playerEvents.ts feeds, which is what the architecture already says is authoritative — the player reports state, the UI consumes it — and is gated to the native path so HTML5 keeps its element-event wiring, which is authoritative thereUIUR-003, UR-005Done
DR-187The system bars go away with the player, not only with the fullscreen button. enterImmersive() had exactly one caller, toggleFullscreen(), so opening the player left the status and navigation bars painted over it until the user pressed a button most never press. On the native path this is worse than cosmetic: the SurfaceView fills the content view, so the bars sit directly on top of the video. The player is a full-screen surface by construction — fixed inset-0 z-50 over a MATCH_PARENT surface — so entry is the right moment. Called synchronously in onMount before any await, per the native-mode pitfall, and paired with the exitImmersive() already unconditional in onDestroy, so a player torn down while immersive cannot leave the rest of the app without barsUIUR-066, UR-003Done
DR-188Native Android video is ready to be the default except for the background-audio handoff, and the flip therefore waits. The picture defects behind DR-172 are all found, fixed and device-verified — DR-185 (the app shell painted over the surface through a CSS rule targeting an attribute nothing set), DR-182 (nothing could lift the poster card on a path with no <video> element), DR-183 (the JS bridges raced the page load, so setTransparent(true) could never arrive), DR-184 (the SurfaceView was never detached), plus DR-186 and DR-187, the two UI defects only this path could reveal. On a device logcat now carries WebView transparent = true and Marking media ready with video on screen, which is the pair DR-172 went looking for and could not find, and skip, seek and rotation were exercised by hand. Turning the default on then surfaced a different unverified sub-path: the background-audio handoff could only return through the HTML5 element, so coming back from the lockscreen left playback dead, and the flip waited for that rather than shipping a verified sub-path over an unverified one as DR-161 had. The default is now on. The two defects holding it back are fixed and device-verified — DR-196 (the handoff return restarts the renderer that is actually on screen) and DR-194 (the letterbox bars are painted rather than retaining stale framebuffer content) — with the evidence this default has been held to since DR-161: an audio handoff at 69:54 returning to video playing at 70:18, and clean bars across playback, the control bar and a rotation round-trip. An explicit stored choice still wins in both directions, so an opt-out survives the flip (the stored value is null-checked rather than compared to "true", which would have silently re-enabled it for everyone who turned it off)AndroidUR-003, UR-004Done
DR-189The control bar comes down on a touchscreen. Its hide timer was armed from exactly one place — the player container's onmousemove — and a touchscreen never fires mousemove, so on Android the bar was never scheduled to hide and sat over the video for the whole film. It went unnoticed for as long as the native video surface was itself invisible (DR-172/DR-185): with nothing behind it to obscure, a permanent control bar reads as the UI rather than as a defect. Two changes, because there were two faults. revealControls() replaces handleMouseMove and is called on entry and on every touch interaction as well as on mouse movement, so touch arms the countdown. And the countdown became an $effect over the state rather than a one-shot timer armed by the input event: the first attempt armed a timer on entry, three seconds later playback had not started, shouldHideControls correctly declined, and nothing ever re-armed it — the timer has to follow the conditions that permit hiding, which arrive on their own schedule. The decision itself is shouldHideControls in controlsVisibility.ts, pure and separated from the clock and the DOM, because what was wrong here was the conditions and not the setTimeout: the bar stays up while paused (a user who paused by tapping the surface has no other way back), mid-seek (the position readout is the point of the bar then), and while any track/subtitle/quality menu is open (the menus are anchored to the bar, so hiding it would take the open menu with it)UIUR-003, UR-066Done
DR-191Forcing the WebView overlay to redraw from the Activity, because with the ExoPlayer SurfaceView beneath it the overlay's ordinary damage stopped reaching the screen: the page kept mutating — the clock text every second, the control bar's opacity going to 0 — while the display held whatever frame it last presented, over video that animated perfectly. Not a state defect; the live DOM showed the slider advancing 476 → 479 across three seconds behind a screen showing neither. Only structural changes got through, which is why the play overlay always appeared to work (an {#if} block, added and removed) while the progress bar never did, and why rotation lost the transport UI. A CSS animation cannot help, since opacity animates on the compositor without repainting the layer. Superseded by DR-192: this drove postInvalidateOnAnimation in a loop, which treats the symptom — the cause is the SurfaceView's separate layer, and removing that removes the need. Kept as the record of how the mechanism was identifiedAndroidUR-003, UR-004Superseded by DR-192
DR-195Play/pause works on the native path, because the frontend stops claiming a webview element is playing when there is none. html5_playing is Rust's record of "a webview <video> is active and in this state", and toggle_playback, play and pause all route transport to that element whenever it is set. The player route mirrored element state into it unconditionally — from handleReportStart and, fatally, from handleReportProgress, which VideoPlayer calls on a 10-second interval — so on the native path the frontend re-declared every ten seconds that an element was playing when none existed, and every transport intent was emitted into the void. The pause button was dead from the on-screen tap, from the control bar, and from a direct player_toggle invocation, while seek and skip kept working because player_seek_video decides elsewhere; that asymmetry is the signature. It also explains the flashing, since the control bar and the JRay overlay both key off isPlaying, which was being contradicted on every interval tick. DR-193 clearing the flag at load was necessary but insufficient on its own — the interval put it straight back. The mirror now lives in mirrorElementStateToRust in VideoPlayer, gated on useHtml5Element, which is the only place that knows whether an element renders at all; the route cannot tell the two paths apart, which is precisely how it came to lie. Confirmed on device by ADB: surface tap and control bar each pause (position frozen across repeated samples, transport label flipped) and resumePlaybackUR-005, UR-003Done
DR-196Returning from background audio brings the picture back on the native path, because the return now restarts the renderer that is actually on screen. The two paths resume by different means: the webview <video> reloads off its stream URL, watched by an $effect that reinitialises HLS and lets canplay drive the seek — while ExoPlayer owns no element and nothing watches the URL on its behalf, so its playback is only ever started by an explicit player_play_item + adapter load, issued once from onMount. exitBackgroundAudioHandoff did only the URL assignment, for both paths, so on the native path it restarted nothing: player_exit_background_audio had already stopped the handoff's audio player, leaving the backend holding no item at all. The symptom is a black screen with a play overlay pinned at 0:00, a seek bar at zero, and a play button that does nothing — the process alive and the frontend still logging, since nothing crashed; the transition was simply dropped. The branch is decided by planHandoffReturn (pure, in backgroundAudioHandoff.ts), which also folds in shouldResumeOnForeground so a lockscreen pause during the handoff still wins over the snapshot taken on the way out. Subtitle configurations are reused from the ones resolved at mount, since ExoPlayer sideloads them as MediaItem.SubtitleConfigurations and cannot accept one after prepare(). Verified on device: handoff to audio at 69:54, return restored video playing at 70:18PlaybackUR-040, UR-003Done
DR-197Continue Watching and Next Up stop showing the same episode. Jellyfin's /Shows/NextUp defaults EnableResumable=true, which returns a partially-watched episode as its own series' next up — precisely the episode /Items/Resume already returns — so the Home "Next Episode" row and the TV landing's Next Up row duplicated Continue Watching card for card. build_next_up_endpoint sends EnableResumable=false, and because servers predating that parameter ignore it, filterInProgressNextUpItems also drops any next-up entry whose id appears in the resume list. It is the mirror of DR-089 and lives beside it: same presentation-layer de-duplication over two lists the frontend already holds, no Jellyfin taxonomy involved. The resume filter still reads its frontier from the unfiltered Next Up list, so removing in-progress entries cannot resurrect a stale resume card. The division is then exact: Continue Watching offers episodes the viewer has started and not finished, Next Up offers the episode after the ones they finishedRepositoryUR-059Done
DR-200The lockscreen notification is exempt from POST_NOTIFICATIONS, because of the session token, not because it belongs to a foreground service — and the difference is what the code now records. POST_NOTIFICATIONS was declared in the manifest and requested nowhere, so on Android 13+ it sat permanently denied; an audit read that as a threat to UR-006, since the media notification is what carries the lockscreen transport controls. It is not. Android's own wording is that the permission covers "non-exempt (including Foreground Services (FGS)) notifications", with denied users seeing FGS notices "in the Task Manager but [not] in the notification drawer" — so an FGS notification is explicitly not exempt — while separately "Notifications related to media sessions are exempt from this behavior change". The platform predicate is Notification.isMediaNotification(), which requires MediaStyle and a non-null EXTRA_MEDIA_SESSION, and it is byte-identical across API 33–36. NotificationManagerService uses it to decide whether to drop the post, and SystemUI's media carousel (MediaDataProcessor.onNotificationAdded) is gated on the same predicate — so a token-less notification is not merely absent from the shade, it never reaches the notification listener and the lockscreen/Quick-Settings controls do not exist at all. Confirmed on device (HONOR ROD2-W09, Android 16 / SDK 36): appops POST_NOTIFICATION: ignore, granted=false, and the service simultaneously isForeground=true with foregroundNoti=Notification(category=transport actions=3 vis=PUBLIC). So no runtime permission request is added — a prompt the app does not need is a prompt that can be permanently denied for nothing — and no checkSelfPermission gate is placed on startForeground, which would trade a cosmetic problem for the "did not then call Service.startForeground()" kill. What is added is the guard that matches the real precondition: mediaSessionCompat?.sessionToken is a null-safe call, and the exemption hangs entirely on it, so both builders now bind the token once and log an error if it is ever null while the permission is denied — converting a failure that is invisible unless the tester happened to deny the permission (most grant it reflexively) into a logcat line. The manifest declaration is kept, unrequested, and documented: media3 does not need it (media3-session declares no permissions and the MediaSessionService guide asks only for the two FOREGROUND_SERVICE ones), but the exemption covers media and self-managed-call notifications only, so a download-completion notice (UR-011) would be an ordinary notification and silently dropped — keeping the declaration is what makes adding one a one-file changeAndroidUR-006Done
DR-201A lockscreen skip means different things depending on what is playing, and the backend decides which. onSkipToNext/onSkipToPrevious forwarded a bare "next"/"previous" to Rust, which always advanced the queue — correct for music, wrong for a video whose audio is running through a background-audio handoff (UR-040), where the buttons should scrub. Pressing skip to re-hear a line jumped to the next episode instead. resolve_skip_action in player/seek.rs maps the command to either Advance or SeekTo, and is_background_audio_active() is the whole test: the handoff exists only for video, and an episode played through it reports MediaType::Audio, so media type cannot distinguish the case. Forward jumps 30s, back 10s — asymmetric because the back button replays dialogue just missed rather than travels — and both clamp to [0, duration], since a negative offset is rejected by backends and a seek past the end reads as EOF and would advance, the very outcome being prevented. Routed through the same spawn-then-seek_absolute path as the scrubber, because a handoff seek re-opens the stream and must not run under the blocking lock (DR-159). The Kotlin keeps sending the same opaque command; only the PlaybackStateCompat gains ACTION_FAST_FORWARD/ACTION_REWIND so the system draws seek affordances rather than skip arrows that lie about what they doPlaybackUR-040, UR-006Done
DR-202Video keeps the display awake. Android counts its display timeout from the last user input, and watching something is exactly the case where there is none, so the screen dimmed and slept mid-film unless the user kept tapping it. Nothing held it: FLAG_KEEP_SCREEN_ON appeared nowhere in the app, and neither renderer supplies a hold for free — ExoPlayer's setWakeMode is a CPU/wifi wake lock that says nothing about the display, and it draws into the TextureView this app owns (DR-192) rather than media3's PlayerView, which is the widget that would otherwise set keepScreenOn itself; the WebView <video> path is no better, because the display wake lock Chrome takes for video lives in the browser layer and not in an embedded WebView. ScreenWakeManager toggles FLAG_KEEP_SCREEN_ON on the Activity window — window-scoped, so it stops applying the moment the app is not visible and cannot outlive a crash the way an explicitly acquired PowerManager.WakeLock can, and it needs no permission (the manifest's WAKE_LOCK is the media service's). The two rendering paths are independent holders OR-ed in the pure ScreenWakeState: the native path follows onIsPlayingChanged plus surface teardown, so the hold tracks what ExoPlayer reports rather than what the UI intends, and the webview path reuses the setHtml5VideoState report the frontend already sends for PiP (DR-160) rather than adding a bridge. Audio is deliberately not a holder — playing music with the screen off is the point of that path — so the hold is gated on the media type being video, and it is dropped on pause, on stop, on surface teardown, and on a new WebView, since a page that goes away never sends its own final active = false. Also the repo's first Kotlin JVM unit tests: ScreenWakeState is framework-free so the decision is testable off-device with ./gradlew :app:testUniversalDebugUnitTest. Verified on device (FP5, native path): IS PLAYING CHANGED: truekeepScreenOn = true 17 ms later and fl=KEEP_SCREEN_ON on the window in dumpsys, a pause releasing it and the resume re-taking it. The webview path is unverifiedAndroidUR-003, UR-004Done
DR-203The background-audio handoff stops silently rewinding to the point it started. A player retry is only a retry if it can resume where the load failed, and ExoPlayer decides that in ProgressiveMediaPeriod.configureRetry: it keeps the load position when the content length is known or the extractor produced a seek map with a duration, and otherwise assumes the source is live — the data at the URL is taken to have changed, so every sample queue is reset and the URL is re-requested from offset 0. The handoff transcode (/Audio/{id}/universal?Container=mp3&TranscodingProtocol=http, DR-129) satisfies neither condition: chunked, so no Content-Length, and a live mp3 encode carries no Xing header, so the duration is unset — on device every position tick reads <position> / 0.0. Its URL carries StartTimeTicks = the handoff point, so "from offset 0" is the handoff point, and after any transient load error playback resumed there and ran on normally. Nothing was reported: a successful retry raises no error and no STATE_ENDED, so neither arm of DR-129 was ever consulted, no onPositionDiscontinuity handler existed, and the app's only trace of it was a position that went backwards — which is why it read as random, since it needs a network blip to land while a load is in flight rather than while the ~50s buffer covers it, and why it survived the two earlier fixes for the same symptom (DR-129's phantom end, DR-159's relative-timeline leak). The decision is Rust's: player_retry_restarts_stream marks a Remote audio-only video item, and loadWithMetadata carries the answer to Kotlin, where the pure StreamRetryDecision holds it for a DefaultLoadErrorHandlingPolicy subclass that returns C.TIME_UNSET — which makes onLoadError answer DONT_RETRY_FATAL before reaching configureRetry. The rewind therefore becomes a recoverable error, and recoverable_error_resume already knows what to do with one: re-open at the position playback actually reached, StartTimeTicks rewritten, with backoff and the shared attempt budget. Every other source keeps the player's retry, because a static file and an HLS playlist both declare their timeline and are resumed in place. A onPositionDiscontinuity handler is added for the log line alone, so a recurrence is visible rather than invisible — loud for DISCONTINUITY_REASON_INTERNAL, which is the rewind's own signature, and quiet for the backwards jump a resume's re-prepare legitimately makes. Reproduced and verified on device (FP5), same procedure both times: background-audio handoff, 60s to fill the buffer, a 45s radio outage, then watch. Before — the outage passed unnoticed and 3.5 minutes later, with nothing logged in between, BUFFERINGREADY → position 1165.4s840.3s, exactly the handoff base, no error and no STATE_ENDED; the same log line reports Media ready! Duration: -9.223372036854776E15, which is C.TIME_UNSET and the precondition itself. AfterLoad error on a stream that cannot be resumed in place — declining the player's retry at the outage, playback continuing undisturbed off the buffer for 69s (a fatal load error is only raised when the renderer next needs data), then ERROR_CODE_IO_NETWORK_CONNECTION_FAILEDre-opening at 785.6s in 2sREADY, playing on from 785.6s with no rewind in the following 7 minutesPlaybackUR-040, UR-004Done
DR-199The webview stops undoing the network security config. MainActivity.configureWebViewSettings set mixedContentMode = MIXED_CONTENT_ALWAYS_ALLOW together with allowFileAccess = true and allowContentAccess = true, which is a blanket cleartext opt-in reached by hand — exactly the thing network_security_config.xml exists to prevent and its own comment warns against (DR-138). Nothing needed any of the three. file:// is never loaded: cached thumbnails go through convertFileSrc, which on Android resolves to http://asset.localhost/… and is answered by wry's request interceptor rather than the filesystem, and downloaded media goes over the loopback HTTP server (DR-137), which exists precisely because the asset/file route cannot stream a large file. content:// is never loaded either — the manifest's FileProvider is for outbound share intents, not webview navigation. And mixed content never arises: Tauri serves the UI from http://tauri.localhost (use_https_scheme defaults false and is not set in tauri.conf.json), while both 127.0.0.1 and asset.localhost are loopback/.localhost origins that Chromium treats as potentially trustworthy, so they are not mixed content to begin with. A plain-HTTP remote Jellyfin server would be, but the network security config already rejects it before any mixed-content check runs — so ALWAYS_ALLOW bought nothing and only widened the hole. COMPATIBILITY_MODE rather than NEVER_ALLOW is a deliberate hedge and not the default — the platform default at targetSdk 21+ is NEVER_ALLOW — because none of this can be verified anywhere but a device, and compatibility mode keeps passive content (images) working if the analysis missed a path. allowFileAccess = false restores the targetSdk-30+ default; allowContentAccess = false is a genuine tightening (its default is true) and is the first thing to look at if something that used to render stops. The two files now cross-reference each other so the pair cannot drift apart againSecurityUR-071Done (pending device verification)
DR-194Stale pixels in the letterbox bars — the rotation "flash of the previous frame", a ghost control bar stranded in the top bar, each new clock digit drawn over the last (35:42 with the 1 still showing through the 2), and menus (sleep timer, quality) leaving their imprint behind. One cause for all of it: nothing painted the bars. The window surface is opaque (the theme is not translucent), and for an opaque surface HWUI deliberately does not clear the damaged region before replaying a frame — it assumes the view hierarchy covers every pixel. That hierarchy is window background → video TextureView → transparent WebView, and fitSurfaceToScreen sizes the TextureView to the letterboxed video rect, so the bars were the window background's alone to paint. setTransparent(true) cleared that background to TRANSPARENT, leaving the bars painted by nobody and whatever was last in the framebuffer surviving in them. Fixed by keeping the window background opaque black while compositing; the WebView's own background is what lets the video through, and the TextureView is drawn on top of the window background, so an opaque one cannot hide it. Three earlier fixes aimed at the window's rotation animation and at TextureView frame-retention (two postOnAnimation hops, an onSurfaceTextureUpdated reveal, then ROTATION_ANIMATION_JUMPCUT + FLAG_FULLSCREEN) all missed, because the pixels were never the animation's; the alpha-hiding among them made it worse by blanking the one view that reliably paints its own rect. Those are removed, FLAG_FULLSCREEN included — it fought edge-to-edge insets for no gain. Verified on device: ghosting reproduced with native video on, then absent after the fix, across playback, the control bar and a rotation round-tripAndroidUR-003, UR-066Done
DR-193Play/pause reaches the player that is actually rendering. toggle_playback, play and pause all route to the webview element when is_html5_active(), which is html5_playing.is_some() — a flag written only by the element's own state reports and cleared only when it reports "stopped"/"idle" (or on a background-audio handoff). An element that went away without that final report, or webview-rendered music earlier in the same process, therefore left the flag set, and on Android's native video path every transport intent was emitted as a ControlCommand at an element that no longer existed: the pause button did nothing, from the on-screen tap and from the control bar alike, while seek and skip kept working because player_seek_video decides elsewhere. Whether it happened at all depended on what had played before, which is exactly what made it read as flaky rather than broken. load_and_play — the native load path, and the one the HTML5 video path deliberately avoids via set_current_item — now clears the flag, because loading into the native backend is the statement that native renders this item. Nothing is lost on the webview path: an element re-establishes its own authority the moment it reports again, so this is the existing "element is gone" semantics applied where it can be known directly rather than inferred from a report that may never arrivePlaybackUR-005, UR-003Done
DR-192Native video presents through a TextureView, not a SurfaceView. A SurfaceView renders on its own layer outside the app window and punches a transparent region through it; everything drawn above that hole — for us the entire Svelte UI in a transparent WebView — depends on that composition path, and Android's own graphics documentation states that "overlays do not currently work correctly with SurfaceView or TextureView". The consequences were four symptoms of one cause (DR-191): a frozen progress bar, controls that would not fade, rotation losing the transport UI, and overlays that lingered after the DOM removed them. A TextureView is an ordinary view whose frames are drawn as a texture in the window's normal rendering pass, so there is no second layer and no transparent region, and the WebView above composites like it would over any other view — which is why media3 offers surface_type="texture_view" and why it is the standard remedy for ExoPlayer overlay problems. The trade is accepted rather than hidden: TextureView costs more power and memory than SurfaceView and adds a frame of latency, but hardware decode through MediaCodec is untouched, so the reason native video exists survives it. setVideoTextureView installs ExoPlayer's own SurfaceTextureListener, so the old SurfaceHolder.Callback wiring is deleted rather than ported — adding a listener of ours would displace it and the video would never appear. PiP needs no change, since a TextureView is a View and the aspect-ratio probe reads its measured boundsAndroidUR-003, UR-004, UR-041Done
DR-190The background-audio handoff can return to the native path. Everything that restores playback on the way back is written around the WebView <video>: applyPendingForegroundSeek returns early on !videoElement, the HLS re-init $effect returns early on !useHtml5Element, and pendingForegroundSeek/pendingForegroundPlay — which own the post-handoff position and play/pause — are consumed only by handleCanPlay and markMediaReady, an element event and a path that reaches the same guard. On the native path there is no element, so exitBackgroundAudioHandoff completes, clears handoffState, blanks and reassigns currentStreamUrl to force an effect that will not run, and nothing ever restarts ExoPlayer: the user returns from the lockscreen to a dead player. This never showed while the path was opt-in and its picture was invisible anyway. The return needs the native equivalent of the element reload — re-issue the item to the backend, seek to the position player_exit_background_audio reports, then honour wasPlaying — routed through the adapter rather than the element, so both paths restore through one contractPlaybackUR-040, UR-003Superseded by DR-196
DR-161Native video is the default, so picture-in-picture has a real surface. DR-160 makes PiP work on the HTML5 path, but that path can only ever shrink the UI into the PiP window; showing the video itself needs the SurfaceView behind the WebView, which is what experimentalNativeVideo gates. The flag now defaults to on when the user has never chosen, with an explicit stored choice still winning in both directions so anyone who turned it off keeps it off. This is a deliberate acceptance of risk: the flag existed because the native path was an unfinished spike, and VideoPlayer.scrubRegression.test.ts documents its history — a native init that flipped to HTML5 mid-lifecycle and left seeks going down one path while ExoPlayer played on another. Those tests pin the flag-off interim override (native response overridden to HTML5, backend stopped once), which the default no longer selects, so they now mock the flag off rather than inherit it: they still guard that path, but they no longer describe what ships. The native scrub/seek path is consequently not covered by the suite and needs device verificationUIUR-041, UR-003Needs device verification
DR-159The background-audio handoff stops leaking its relative timeline. The handoff plays the episode as a relative stream — the audio-only URL is built with StartTimeTicks = the position the screen was locked at, so ExoPlayer's zero is the handoff point — and background_audio_base holds the offset that turns one back into a real position. The base was a display-only correction, applied in exactly two places (the lockscreen scrubber and the internal truncation maths) while every other consumer worked in the relative timeline treating the number as absolute. Each crossing threw away exactly base seconds, which is why the jump-back distance varied with where the screen was locked and read as random. Three crossings were live: progress reporting to Jellyfin sent the relative position every 30s, so the server was told real − base — and since DR-155 now mirrors the server's position back and refreshes on a cache hit, that regressed value returned as the resume point (lock at 40 min, listen to 90, reopen at 50); lockscreen seeks went out absolute and came back relative, against a chunked length-less transcode that cannot honour a seek at all, so a clamped seek landed at stream zero; and media3's own seekToDefaultPosition/seekBack/seekForward bypassed the ForwardingPlayer wrapper entirely, reaching the real ExoPlayer — Util.handlePlayButtonAction seeking an ended player to the relative zero being the same mechanism as DR-129's truncation bug through a different door. The fix converts once, at the boundary: JellyTauPlayer's position tick adds the base (and shifts the duration with it, since the stream's own length is only what remains) before either nativeOnPositionUpdate or the lockscreen sees it, so position updates, progress reports, the frontend and the truncation check all speak the episode's timeline and none needs to know a handoff happened. The base is consequently removed from claim_stream_resume, truncated_stream_resume_position and player_exit_background_audio, where adding it now double-counts, and the lockscreen's positionOffsetMs addition goes with it (the field remains, read-only, as the tick's input). Inbound seeks go the other way: seek_absolute is the new boundary for every outside seek, re-opening the stream at the requested position via resume_stream_at when a handoff is active — which is what onSeekTo had claimed for months in a comment describing code that did not exist — and an ordinary seek otherwise. seekToDefaultPosition is swallowed rather than forwarded, since Rust already owns what "play after the stream ended" means and the play() that follows reaches it. Exit reads the position before clearing either base, or a tick landing in between hands back a relative onePlayerUR-040, UR-005, UR-025Done (pending device verification)
DR-158A watched toggle, on the episode row, the season header, the series and movie hero, and the Episode Focus View. Both halves of the backend already existed and neither had a caller: mark_played (POST /PlayedItems) was reachable only from the sync drain replaying rows the reporter had queued, and clear_watch_history (DELETE /PlayedItems) only from the destructive "erase this series' history" button — so the sole way to mark something watched was to play it. Jellyfin applies both recursively over a season or series, so the container case needs no client-side fan-out online. Offline it does: storage_set_watched writes the item and its descendants (drawn from items by parent_id/album_id/season_id/series_id, so an uncached id selects nothing and the statement no-ops instead of raising a foreign-key error), because otherwise marking a season watched with no server would tick the season and leave every episode inside it unwatched. It is deliberately separate from storage_mark_played, which stays the single-item "this finished playing" path that increments play_count. Un-marking clears the resume position as well as the flag, matching the server. QueuedOp::MarkUnplayed gives the queue the missing direction — pushing as clear_watch_history — so the toggle works offline both ways rather than only one; without it un-marking would have been the half that needed a connection. The button is an everyday toggle, so unlike ClearHistoryButton it does not confirm, and it holds an optimistic state because the caller's watched prop only catches up after a reload (a season means a round trip, during which the button would otherwise appear to ignore the tap)UIUR-073Done
DR-157Full-screen video on Android actually goes full screen. toggleFullscreen called document.documentElement.requestFullscreen() and nothing else, which inside an Android WebView does not touch the Activity window — it expands the element within a viewport that already spans the whole screen, because enableEdgeToEdge() is called in onCreate and SDK 36 ignores the opt-out. So the control did nothing visible while the status bar and navigation/gesture bar stayed painted over the video, and (unlike DR-112's chrome-clearance work, which is about reserving space for the bars) here the bars should not be there at all. ImmersiveModeBridge hides them via WindowInsetsControllerCompat with BEHAVIOR_SHOW_TRANSIENT_BARS_BY_SWIPE, so an edge swipe brings them back transiently over the video instead of resizing the window mid-playback, and the system's own gestures stay reachable. Exposed as the AndroidImmersive bridge and posted to the main thread, since @JavascriptInterface methods arrive on a WebView binder thread. requestFullscreen() is kept for the platforms where it does work, but its rejection is caught rather than allowed to abort the immersive call. Restoring is wired to three paths, not one: leaving fullscreen, Escape (which previously called document.exitFullscreen() directly, bypassing the flag and the bars), and onDestroy — the bars belong to the Activity, so a player torn down while immersive would strand every screen behind it without them. The --jt-inset-* properties need no special handling: hiding the bars fires the decor view's inset listener with zeroes and WindowInsetsBridge republishes themUIUR-066Done
DR-143Flipping the offline downloaded-only gate actually re-queries the listing. The gate (DR-078) is a process-wide flag in Rust consulted only while a query runs, but no library surface re-queried when its inputs changed: useServerReachabilityReload fires only on the offline → online transition, and GenericMediaListPage, GenericGenreBrowser and the favourites page never even called its checkServerReachability. So going offline left the full server catalog on screen under a now-closed gate, and toggling "Show all server media" only greyed cards — MediaCard.isServerOnly is a pure frontend derivation that updates instantly — without adding or removing a single row. The filter therefore read as "shows everything until I filter, then greys some of it" while the backend gate was correct and simply never exercised. catalogFilterVersion is the refetch signal: pushCatalogVisibility now awaits set_show_server_catalog and bumps the version only after the backend accepts the new flag, since a reload racing the push would re-query under the old gate and undo itself. A failed push clears lastIncludeCatalog instead of latching it, so the next identical transition is retried rather than skipped as a no-op and left permanently disagreeing with the backend. useOfflineFilterReload subscribes pages to that signal, skipping the value they already loaded under; it is wired into both generic list components and the movies/music/tv/favourites landing pages and the /library/[id] detail pageUIUR-052Done
DR-135A download's media type comes from the item, not a default. download_item — the path a media card uses to queue an item while offline — never records media_type, and the reconnect resolver read that NULL as 'audio', so a movie queued from a card had its URL resolved by get_audio_stream_url. The file that landed on disk was an audio-only transcode, which is why a "downloaded" film could never play offline no matter how the path or protocol was fixed. The resolver now falls back to the item's own item_type (VIDEO_ITEM_TYPES in Rust, so the frontend never learns which types are video) and only defaults to audio when the item is not cached locally. An explicit media_type on the row still winsDownloadsUR-071, UR-052Done
DR-136Rows already downloaded under the audio default are repaired, not just prevented. They are identifiable after the fact — no media_type, but a video item — so on reconnect they are reset to pending with their audio URL cleared and re-resolved by DR-135's corrected logic, overwriting the audio file in place. Without this the fix is invisible to anyone who had already queued a film: the row still reads "downloaded" and still fails to play. Rows carrying an explicit media_type and genuine audio downloads are left untouchedDownloadsUR-071Done
DR-137Local media is served to the player over a loopback HTTP server, not the asset protocol. Tauri's asset protocol answers a request carrying no Range header by reading the whole file into memory, and only advertises Accept-Ranges: bytes from inside its range branch — so the first request never learns ranges exist and a multi-gigabyte body is attempted instead. Chromium abandoned it with PIPELINE_ERROR_READ after ~31s, which reached the user as "downloaded video does not play offline". Real HTTP on 127.0.0.1 is chosen over a custom URI scheme deliberately: range support becomes a property of the transport rather than depending on whether a platform's webview forwards Range to a custom scheme. No response ever exceeds a 4 MiB chunk and bodies stream from the file handle, so memory is bounded regardless of file size. Because loopback is shared between apps on Android, the server binds 127.0.0.1 only and every URL carries a random per-session token; paths are additionally confined to the app data directory, so a leaked URL cannot read outside it. This is stage 1 of making the server the single media origin — remote passthrough and download-while-watching are deliberately out of scope herePlaybackUR-071Done
DR-138Loopback is exempted from Android's cleartext ban, and nothing else is. Release builds set usesCleartextTraffic="false", so the webview's request to the local media server (DR-137) was rejected by network security policy before any I/O — <video> failed in the same millisecond as loadstart, with NETWORK_NO_SOURCE and no server-side log at all, which is why it looked identical to a missing file. A network-security-config resource permits cleartext for 127.0.0.1 only and keeps base-config cleartextTrafficPermitted="false", so a remote server must still be HTTPS; this is deliberately not a blanket opt-in. The manifest attribute is ignored once the config is present, so the config is the single authority. sync-android-sources.sh also had to learn to copy res/xml, which it skipped — the manifest references the resource, so a missed copy fails the resource link rather than degrading quietlySecurityUR-071Done
DR-093Traceability coverage gate derives its requirement denominators from requirements.md at run time rather than hardcoded literals: countDefinedRequirements counts an ID only where it leads a markdown table row (ignoring the "Traces To" column and prose) and deduplicates IDs listed both in the definition tables and in the §3 traceability matrix; computeCoverage reports the intersection of traced and defined IDs so an ID traced in code but absent from requirements.md is surfaced as orphaned instead of inflating the ratio past 100%. UT/IT test identifiers are excluded as a separate taxonomy. CI and bun run traces:coverage share this computation and fail on both a sub-threshold and an impossible >100% resultTooling-Done
DR-204A leveled logging facade for the frontend, replacing raw console.* calls. One module owns the log sinks, so a level (error/warn/info/debug) decides at run time what is emitted rather than every call site deciding permanently at authoring time: a release build stays quiet, a developer chasing a playback bug turns the player's debug output on without editing and rebuilding, and nothing that reaches the console is written by a console.log nobody can find again. Scoped loggers carry the subsystem in the message, so a filtered console is usable while a player, a download worker and a store are all talkingTooling-Done
DR-205ESLint + Prettier run as a gate over the frontend, so lint and formatting are decided once by configuration rather than per reviewer. Formatting is not a matter of opinion at review time, and the classes of bug a linter sees (unused bindings, floating promises, accidental globals) should never reach a human reviewer at all. Wired as an npm script so the same command runs locally and in CI, matching how check:boundary and the traceability gate already workTooling-Done
DR-206The Rust toolchain is pinned in-repo (rust-toolchain.toml) and the pin is what both a developer's machine and CI use. Without it, cargo fmt --check and cargo clippy are run by whatever version each host happens to have, so a formatting or lint result differs between a laptop and the builder image and CI fails on a diff that was clean locally — the failure mode is a red build nobody can reproduce. The builder image carries the pinned toolchain, so pinning is a declaration, not a CI-time install (see the no-toolchain-installs rule)Tooling-Done
DR-207A pre-commit hook runs the "Before Committing" gates — frontend checks and tests, cargo fmt, clippy, the boundary tripwire and the traceability checks — so the gates are enforced at the commit rather than discovered in CI. The gates already exist and are already documented; what is missing is that nothing runs them, which makes compliance a matter of memory. The hook is the mechanism that makes the documented list actually bindingTooling-Done
DR-208Documentation link integrity is checked mechanically (scripts/check-doc-links.sh): every relative markdown link in every tracked .md must resolve to a file that exists on disk. This is a real defect class, not hygiene — the generated traceability matrix shipped ~2,800 dead file links because it was written to docs/ while its hrefs were repo-root-relative, and nothing noticed for months because no check existed and nobody clicks 2,800 links. The check validates paths, deliberately not anchors or external URLs: anchor resolution needs a markdown renderer's slug rules and network checks make the gate flaky, so both are out of scope and stated as such in the scriptTooling-Done
DR-209Library folders are excluded from music browsing server-side, by folder id, replacing a hardcoded frontend filter that dropped anything whose name contained "Podcasts". The name filter was wrong in three separate ways: it encoded a domain classification in the presentation layer, it matched on a title rather than on what an item is (so an album legitimately called "Podcasts" vanished while a podcast folder named anything else did not), and it applied only where someone had remembered to call it, so the same library was in scope on one screen and out of scope on the next. Excluded folder ids are stored as user configuration and applied by the repository layer to every music query — libraries, artists, albums, genres, search and the home rows — so scope is decided in one place and is the same everywhereRepositoryUR-076Done
DR-210Thumbnail cache writes are confined to the cache directory. The filename was built from item_id, image_type and tag, but only tag was sanitised — and Path::join neither folds .. nor keeps the base when handed an absolute path, so a value arriving verbatim from server JSON decided where a file landed. The tag's existing rule (non-alphanumerics become _) now applies to all three parts, and the resolved path is checked with starts_with(cache_dir) at the point of use. The database keeps the raw key and the resolved path, so lookups still match and pre-existing rows still resolve. Not exploitable as shipped — server URLs must be HTTPS and Android blocks cleartext, so the id comes from a server the user chose to trust — the value is making the write path consistent with how caller-supplied paths are handled elsewhereStorageUR-012Done
DR-211Download paths are confined to the download root. file_path and target_dir reached PathBuf::join unchecked from the frontend, and mark_download_completed persisted a caller-supplied path later passed to remove_file. A correct sanitiser already existed and download_item_and_start used it, but download_item is itself a command accepting file_path raw, so the guard was bypassable rather than absent — the fix moves it inside instead of adding a second one. Sanitising is per path component: whole-string sanitising would rewrite downloads/x.mp3 to downloads_x.mp3 and relocate every existing download. Confinement happens after the join, since a join with an absolute second half discards the rootDownloadsUR-011Done
DR-212Query and URL construction bind or encode their inputs. Three sites interpolated caller-supplied values directly: the offline get_items item-type filter built IN ('a','b') by string formatting, build_get_items_endpoint wrote ParentId/IncludeItemTypes/SortBy/SortOrder into a URL unencoded, and player_set_volume accepted NaN and out-of-range floats. Each is a consistency defect rather than a novel one — the same file already did it correctly a few lines away (parameter placeholders in search, urlencoding::encode for genres, clamp in every player backend). List separators stay unencoded and encoding is per element, because Jellyfin splits these parameters on the commaRepositoryUR-007, UR-065Done
DR-213Containerised builds hand their artifacts back to the host user. The compose services bind-mount the repo and run as root — their caches live at /root/.cargo and /root/.bun, so a non-root container user cannot write them — which leaves root-owned files accumulating in the developer's working tree: 11,124 of them when this was found, enough that cargo clean and scripts/clean.sh failed with EACCES and a plain cargo build died part-way, since build scripts compile for the host and land in target/debug even during a cross-build. Ownership is restored at the end of each containerised build, reading the intended owner from the checkout so no uid needs plumbing through. Running the containers as the host uid is the tidier fix and remains open; it needs the cache volumes relocated off /root firstTooling-Done
DR-214The app identifies itself correctly everywhere a user or a package manager reads its name. productName was the scaffold's lowercase jellytau, which is what the Android release build showed under its icon and what the deb/rpm/NSIS bundles carried as their display name — invisible in development because build.gradle.kts overrides the label to "JellyTau Debug" for the debug build type, so the install a developer looks at daily was the only correctly-cased one. mainBinaryName pins the executable filename so nothing that resolves a path by name has to change. strings.xml moves into the canonical android tree, where sync-android-sources.sh already copies res/values/*.xml, so the fix survives regenerating gen/. Bundle metadata (publisher, copyright, category, descriptions, licence) was entirely absent, which is why the packages shipped with no maintainer or description — the hand-written Arch PKGBUILD and .desktop had all of it, so only the generated packaging was wrongPackaging-Done
DR-215Frontend test coverage is a ratcheted CI gate rather than a number nobody looks at. test:coverage had been configured since the suite was created and was silently broken: @vitest/coverage-v8 resolved to 4.1.10, whose peer range pins vitest exactly, while package.json asked for >=1.0.0 <5.0.0 and got 4.0.16 — so every invocation died on a missing BaseCoverageProvider export and no coverage figure had been produced in months. Fixing the range is half the requirement; the other half is that a measured figure that gates nothing decays the same way an unrun script does. Thresholds sit a few points under the measured result (statements 54.6, branches 48.7, functions 49.6, lines 55.1 when this landed) and only ever move up, matching MIN_THRESHOLD in the traceability gate and the eslint --max-warnings ratchet. The absolute numbers are held down by .svelte components, which this project deliberately does not test directly — the pattern is to extract the logic to a plain module and test thatTooling-Done
DR-216Dependencies are gated on known vulnerabilities and on licence compatibility, and the build graph is pinned to what is actually shipped. The project had no scanning of any kind: nothing checked the ~500-crate Rust graph or the JS packages against an advisory feed, and nothing checked that everything redistributed inside an MIT-licensed bundle permits it. The first run found eight vulnerabilities and one unsoundness — bytes, four in rustls-webpki, time, two in quick-xml, rand — every one closed by a cargo update nobody had reason to run. cargo deny (src-tauri/deny.toml) now runs in CI over advisories, licences, bans and sources. Two structural fixes matter as much as the gate: the graph is scoped to the targets actually shipped, so an advisory against an Apple-only path is correctly absent rather than ignored by ID; and the one git dependency (libmpv) is pinned by revision instead of by branch, since a branch means any cargo update silently substitutes new upstream code in the one dependency that is unsigned and links a C library into the player. Licence findings are recorded rather than waved through — libmpv/libmpv-sys are LGPL-2.1, which the app satisfies by dynamic linking, and that carries obligations (keep the linkage dynamic; ship libmpv's licence text with any bundle carrying the .so)Tooling-Done
DR-217In-app update, desktop only, over a manifest we control. tauri-plugin-updater and tauri-plugin-process are compiled for everything except Android/iOS — spelled as a target-triple cfg rather than cfg(desktop), which Cargo does not evaluate in a [target.'cfg(…)'] table and which therefore drops the dependency silently, surfacing much later as "Permission updater:default not found". The release workflow signs updater artifacts with a minisign key held in Gitea secrets and publishes latest.json to a dedicated updater branch, read over Gitea's raw-file URL: this instance serves /releases/download/<tag>/<asset> but returns 404 for /releases/latest/download/<asset>, so there is no stable latest-release URL to point at, and the docs branch is force-pushed by publish-docs.yml so it cannot host the manifest either. Bundle targets gain appimage, which the release notes had been advertising for months while tauri.conf.json never built it — the artifact step globbed for *.AppImage, found nothing, and said nothingToolingUR-077Done
DR-218Persistent, redacted logging and a diagnostics export. tauri-plugin-log replaces the env_logger stdout-only init, giving a rotating 5 MB file, a webview target in dev, and — the single largest gain — logcat on Android, where env_logger's stdout went nowhere. Redaction runs in the log formatter, not at export: a credential in a file on the device is already a disclosure, so stripping it on the way out would be too late; the exporter redacts a second time to cover files written by older builds. api_key/X-Emby-Token/Authorization/"AccessToken"/Token="…" all reduce to [REDACTED] while host, item ids and filenames are deliberately kept — a bundle scrubbed of those is one nobody can debug from. The server URL is reduced to scheme and host, dropping any embedded user:pass@. The panic hook chains to the previous hook rather than replacing it, because utils/lock.rs installs a silencing hook around tests that provoke poisoned locks on purpose. The chosen level persists to disk and is re-applied at startup, since reproducing a bug usually means restarting into it. The frontend facade keeps its untouched console.* pass-through (DR-204) and additionally forwards a stringified copy at info and above, so one file holds both halves of the app in order — which is what makes a race between them legible after the factToolingUR-078Done
DR-219Release notes are the reviewed CHANGELOG entry, not a generated draft. Every release from v0.0.1 to v0.9.1 published the same ~1,050 bytes of generic install instructions whose "What's New" section said "See CHANGELOG.md" — a link that does not resolve from a release page. Thirty-five releases, byte-identical, telling a reader nothing about what changed. The workflow now publishes the ## <version> section of CHANGELOG.md and fails the release if that section is absent, since notes that say nothing are worse than a build that waits for two sentences. release:notes is printed into the job log as a drafting aid but is deliberately not published: CLAUDE.md calls its output "a reviewed draft, not a final changelog", and publishing it unreviewed proved why — a range containing a repo-wide formatting sweep resolved to nearly the entire requirement matrix and produced notes claiming one release had added the whole application. The script now skips cosmetic commits (chore(format), chore(deps), style) when deriving a range's files, and says how many it skipped rather than silently reporting a smaller setTooling-Done
DR-220A release ships only its own artifacts. src-tauri/target/*/release/bundle/ is not versioned, cargo never cleans it, and the CI runner reuses the target directory — so the copy step's bundle/**/*-setup.exe glob collected every installer ever built there. Every release from v0.1.0 to v0.8.2 shipped its predecessors': sixteen Windows installers on v0.8.2, thirteen of them stale, and a download list on v0.5.0 reaching back to 0.1.0. It went unnoticed for eight months because there was nothing to notice — the upload loop reported success, the files were real, and the page looked busy rather than wrong. It stopped only when an unrelated cache change wiped the runner's target dir, leaving the defect dormant rather than fixed. Both desktop builds now clear the bundle directory first, so a stale file cannot exist to be copied — filtering the copy by version would have hidden it instead. scripts/check-release-artifacts.sh is the backstop for the next route nobody predicts: it runs before the SBOM, the checksums and the upload, and refuses to publish when any artifact's embedded version disagrees with the tagTooling-Done
DR-221The release path is exercised before a tag exists. Nothing in build-and-test.yml runs tauri build — only a tag does — so a whole class of breakage was invisible until release day, and two instances of it were sitting on master at once. Tauri refuses to build when a plugin's Rust crate and npm package differ by minor version, which the updater and logging work had introduced (tauri-plugin-log 2.8.0 against @tauri-apps/plugin-log 2.9.0) while cargo check, clippy, the tests and svelte-check all passed; both sides are now pinned exactly rather than by caret, since a caret is what let them separate, and CI runs tauri info to compare them without building. The AppImage target had never once been built: linuxdeploy carries a strip too old to parse the .relr.dyn section modern toolchains emit, so bundling failed on every library — and Ubuntu 23.10+ links with -z pack-relative-relocs by default, so the builder image fails the same way a modern Arch host does. NO_STRIP=true is linuxdeploy's documented escape hatch; the cost is a larger, unstripped bundle. Both were found by building the target locally before tagging rather than by publishing a release that could not buildTooling-Done
DR-222Build tooling matches the package manager the project declares. scripts/build-android.sh ran npm install on its clean-build path — in a bun project, where packageManager says bun and bun.lock is the committed lockfile. npm ignores that lockfile, re-resolves the whole tree from package.json, and writes a package-lock.json that .gitignore then hides. That is not a style preference: the JS halves of the Tauri plugins are pinned exactly against Cargo.lock because the CLI refuses to build when a plugin's crate and package differ by minor version, and a silent re-resolve is precisely how they drift apart. It survived because clean builds are rare — the shape shared by nearly every defect found preparing v0.10.0, where the code running on every commit was healthy and the code running on a release, a tag or a clean build had no guard at all. scripts/check-tooling.sh fails on any npm/yarn/pnpm invocation or foreign lockfileTooling-Done
DR-223The Android JavaVM and Application are published into ndk_context by this crate, not by a transitive dependency. Seven call sites (five in credentials.rs, two in lib.rs) read that process-global to reach JNI, and nothing here ever set it — tao did, three levels below anything this project names in Cargo.toml. tao 0.35.3 moved those pointers into a private struct and stopped publishing them, so the Tauri 2.11 upgrade made the first credential read abort the process on every launch: PANIC ... android context was not initialized. Our code had not changed; an undocumented side effect of the windowing layer had gone. The invariant is now owned here rather than assumed: JNI_OnLoad captures the JavaVM as the shared library loads, and the Application is resolved lazily via ActivityThread.currentApplication() and pinned as a global reference for the process lifetime — the Application rather than the Activity, since that is what SecureStorage.initialize() immediately reduces its argument to. Failure degrades to the encrypted-file credential path and is logged, rather than aborting. Found only by installing on a device: nothing in CI runs the appSecurityUR-012Done
DR-224Backgrounding the app obeys the background-audio toggle on every renderer. The toggle (UR-040) was built for the WebView <video> path, where losing visibility kills the decode: it chose between handing off to a native audio stream and letting playback stop. Native video then became the default renderer (DR-188), and on that path playback runs through ExoPlayer inside a MediaSessionService — a foreground media service whose purpose is to keep playing while the app is hidden. Nothing paused it and nothing in the codebase paused on background, so locking the screen kept the audio going whether or not the toggle was on: the toggle governed a handoff that no longer had a gap to bridge, and users got background playback they never asked for. The decision now lives in Rust (player/background_policy.rs) and both renderers obey it: a video with the toggle off pauses, with the toggle on hands off to audio, music is never paused by backgrounding, and picture-in-picture keeps playing because the window is still on screen (UR-041). It takes no renderer parameter on purpose — the split between the two paths is what produced the defectPlayerUR-040Done
DR-198The webview runs under a real Content-Security-Policy, and the asset protocol is scoped to the one directory it still serves. csp was null, which disables CSP entirely: any script that reached the web layer — through a future {@html}, a dependency, or a devtools paste — would have inherited the whole IPC surface, and with it the user's session. script-src 'self' (Tauri injects a nonce for SvelteKit's inline bootstrap script at build time, so no 'unsafe-inline' is needed) plus object-src/frame-src 'none' and base-uri 'self' is the part that is genuinely restrictive. img-src/media-src/connect-src cannot be: the Jellyfin origin is typed in by the user at run time and is commonly plain http on a LAN, so they allow http:/https: — a wide grant for data, but one that still bars file:, filesystem: and scripting schemes, and leaves script-src untouched. style-src keeps 'unsafe-inline' because Svelte compiles style="…" attributes (including app.html's display: contents wrapper) into markup; this is safe only while no <style> element survives into index.html, since a nonce there would make Tauri's injection outrank — and therefore void — 'unsafe-inline'. worker-src blob: and media-src blob: are hls.js: it demuxes in a worker built from a blob and attaches MSE through URL.createObjectURL. asset: and http://asset.localhost are the same protocol under the two naming schemes convertFileSrc emits (custom scheme on Linux/macOS, http host on Windows/Android); ipc:/http://ipc.localhost is the invoke transport, which would otherwise be blocked by connect-src. A run-time CSP naming the server origin exactly was rejected: Tauri computes the header from immutable config when it serves the HTML, so it would mean rebuilding config and reloading the webview on every server change, for a policy the user can already point anywhere. The asset-protocol scope narrows from $APPDATA/** to $APPDATA/thumbnails/** — since DR-137 moved downloaded media to the loopback server, imageCache is the only convertFileSrc caller left, so the database and the encrypted-token fallback file no longer sit inside the grantSecurityUR-012, UR-071Done
+
+
+

3. Traceability Matrix

+

User Requirements to Software Requirements

+
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
User ReqIntegration RequirementsDevelopment Requirements
UR-001IR-001, IR-002-
UR-002IR-013DR-003, DR-012, DR-013, DR-014
UR-003IR-003, IR-004, IR-011DR-002, DR-004, DR-010, DR-182, DR-183, DR-184, DR-185, DR-186, DR-187, DR-188, DR-190, DR-191, DR-192, DR-193, DR-194, DR-195, DR-196
UR-004IR-003, IR-004, IR-008, IR-011DR-002, DR-004, DR-006, DR-129, DR-171, DR-176, DR-177, DR-181, DR-182, DR-183, DR-185, DR-188, DR-203
UR-005-DR-001, DR-005, DR-009, DR-178, DR-179, DR-186, DR-193, DR-195
UR-006IR-005, IR-006, IR-007, IR-008DR-200, DR-201
UR-007IR-010DR-007, DR-008, DR-016
UR-008IR-010DR-007, DR-011
UR-009IR-009, IR-010, IR-011-
UR-010IR-012, IR-021DR-037, DR-059
UR-011IR-013DR-003, DR-015, DR-018
UR-012IR-009, IR-014DR-198
UR-013IR-013DR-017
UR-014IR-010DR-014, DR-019
UR-015-DR-005, DR-020
UR-016--
UR-017-DR-014, DR-021
UR-018IR-013DR-015, DR-018, DR-173
UR-019IR-015DR-022
UR-020IR-016, IR-018DR-023, DR-176
UR-021IR-016, IR-019DR-024
UR-022IR-017DR-025
UR-023IR-010DR-026, DR-047, DR-048, DR-049
UR-024IR-010DR-027
UR-025IR-015DR-028, DR-131, DR-132, DR-178, DR-179
UR-026-DR-029, DR-048, DR-050
UR-027IR-020DR-030
UR-028-DR-031
UR-029-DR-032
UR-030IR-010DR-033
UR-031-DR-034
UR-032-DR-035
UR-033-DR-036
UR-034IR-010, IR-024DR-038, DR-039
UR-035IR-022, IR-023DR-040, DR-044
UR-036IR-022, IR-023DR-041
UR-037IR-010DR-042
UR-038IR-010DR-043
UR-039-DR-045, DR-046
UR-040IR-025DR-051, DR-052, DR-129, DR-130, DR-159, DR-178, DR-179, DR-180, DR-183, DR-190, DR-196, DR-201, DR-203
UR-041IR-026DR-053, DR-160, DR-161, DR-172, DR-182, DR-183, DR-184, DR-185, DR-188
UR-042IR-009, IR-014DR-054
UR-043IR-027DR-055
UR-044-DR-056
UR-045-DR-057
UR-046IR-028DR-058
UR-047IR-013DR-060
UR-048-DR-061, DR-062, DR-142
UR-049IR-010DR-063, DR-064, DR-065, DR-147
UR-050-DR-066, DR-067
UR-051-DR-068, DR-069, DR-070
UR-052IR-027DR-078, DR-079, DR-080, DR-143
UR-053IR-029DR-074
UR-054-DR-075, DR-076, DR-077, DR-147
UR-055-DR-081, DR-082, DR-083, DR-084, DR-167, DR-168, DR-169, DR-173
UR-056-DR-085
UR-057-DR-086
UR-058-DR-087, DR-142
UR-060-DR-090, DR-091, DR-111
UR-061-DR-092
UR-062-DR-101, DR-102, DR-103, DR-104, DR-107
UR-063-DR-105
UR-064-DR-106
UR-065IR-030DR-108, DR-109, DR-110, DR-111
UR-066IR-031DR-112, DR-157, DR-187, DR-194
UR-067-DR-115, DR-116, DR-117, DR-118
UR-068-DR-119
UR-069-DR-113, DR-114, DR-120
UR-070-DR-121, DR-122
UR-071IR-032DR-123, DR-124, DR-125, DR-126, DR-127, DR-128, DR-133, DR-134, DR-135, DR-136, DR-137, DR-138, DR-170, DR-171, DR-180, DR-198, DR-199
UR-072-DR-156
UR-073-DR-158
UR-074-DR-162, DR-177, DR-181
UR-075-DR-174, DR-175
UR-076-DR-209
UR-077-DR-217
UR-078-DR-218
+
+
+

4. Test Traceability

+

Unit Tests to Software Requirements

+
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
Test IDTest DescriptionTraces ToStatus
UT-001Player state transitionsDR-001Pending
UT-002MediaItem source URL resolutionDR-002, DR-003Pending
UT-003Queue next/previous navigationDR-005Pending
UT-004Queue shuffle order generationDR-005Pending
UT-005Queue repeat mode behaviorDR-005Pending
UT-006Jellyfin authentication flowIR-009Pending
UT-007Jellyfin library items parsingIR-010Pending
UT-008Repository pattern online/offline switchingDR-013Pending
UT-009Offline mutation queue persistenceDR-014Pending
UT-010Download queue managementDR-015Done
UT-011Resume position storage and retrievalDR-022Pending
UT-012Sleep timer countdown logicDR-029Pending
UT-013Playback progress reporting throttlingDR-028Pending
UT-014Database open and in-memory modeIR-013, DR-012Done
UT-015Database migrations run successfullyIR-013, DR-012Done
UT-016All database tables createdIR-013, DR-012Done
UT-017FTS5 search table createdIR-013, DR-012Done
UT-018Server CRUD operationsIR-013, DR-012Done
UT-019User CRUD operationsIR-013, DR-012Done
UT-020Cascade delete server removes usersIR-013, DR-012Done
UT-021Item insert and FTS searchIR-013, DR-012Done
UT-022User data playback position storageIR-013, DR-012, DR-022Done
UT-023Sync queue operationsIR-013, DR-014Done
UT-024Downloads table operationsIR-013, DR-015Done
UT-025Migrations are idempotentIR-013, DR-012Done
UT-026NullBackend volume default valueDR-004Done
UT-027NullBackend set volumeDR-004Done
UT-028NullBackend volume clamping (high/low)DR-004Done
UT-029NullBackend volume boundary valuesDR-004Done
UT-030PlayerController volume defaultDR-004, DR-009Done
UT-031PlayerController set volumeDR-004, DR-009Done
UT-032PlayerController muted defaultDR-004, DR-009Done
UT-033PlayerController volume delegates to backendDR-004, DR-009Done
UT-034Download event serialization roundtripDR-015Done
UT-035Download event completed serializationDR-015Done
UT-036Download event failed serializationDR-015Done
UT-037Download worker exponential backoffDR-015Done
UT-038Download worker error retryable checkDR-015Done
UT-039Download manager creationDR-015Done
UT-040Download manager set max concurrentDR-015Done
UT-041Download info serializationDR-015Done
UT-042Download command filename sanitizationDR-015, DR-018Done
UT-043Download command filename extension preservationDR-015, DR-018Done
UT-044Offline item serializationDR-017Done
UT-045Smart cache default configDR-015Done
UT-046Smart cache album affinity trackingDR-015Done
UT-047Smart cache queue precache configDR-015Done
UT-048Smart cache storage limit checkDR-015Done
UT-049Playlist create (offline)DR-019, JA-019Done
UT-050Playlist delete (offline)DR-019, JA-019Done
UT-051Playlist rename (offline)DR-019, JA-019Done
UT-052Playlist get items (offline)DR-019, JA-019Done
UT-053Playlist add items (offline)DR-019, JA-020Done
UT-054Playlist remove items (offline)DR-019, JA-020Done
UT-055Playlist reorder items (offline)DR-019, JA-020Done
UT-056Playlist entry serializationDR-019, JA-019Done
UT-057Playlist Tauri command param naming (camelCase)DR-019, JA-019, JA-020Done
UT-058Playlist repository client methodsDR-019, JA-019, JA-020Done
UT-059Audio-only stream URL builder for a video item (selected audio-stream index)JA-032, DR-052Pending
UT-060Background-audio handoff state machine (background→audio, foreground→video; no dual audio)DR-052Pending
UT-061Background-audio Tauri command param naming (camelCase)DR-052Pending
UT-062setBackgroundAudioEnabled reports whether the native bridge was actually reached (missing bridge, stale proxy, throwing method) so a dead bridge cannot look armedUR-040, IR-025, DR-051Done
UT-067Offline get_items gates the synced-catalog UNION on the catalog-browse flag (downloads only when off, full catalog when on)DR-078Done
UT-068Catalog visibility resolves to serverReachable || showServerCatalog, and is pushed to the backend on every change of either inputDR-078, DR-079Done
UT-069isConnected follows backend reachability alone: false when the server is unreachable on a live link, true for a reachable server while navigator.onLine is falseDR-079Done
UT-070Hybrid get_items returns an empty offline result as-is when the catalog-browse gate is off, without querying the serverDR-080Done
UT-066WiFi-only download gate: cellular and metered WiFi blocked, unmetered WiFi/Ethernet allowed, unknown/none fail closed, desktop default ungated; plus the frontend network reporter (transport reporting, change subscription, teardown, fail-open queries)DR-074Done
UT-071Byte-size formatter: zero/negative/non-finite → "0 B"; decimal unit thresholds; 2–3 significant-figure banding; trailing-zero trimming; largest-unit capDR-085Done
UT-072Downloaded-only browse returns a downloaded leaf and its container, filtered to the requested album parent; a non-downloaded sibling is omittedDR-082, DR-083Done
UT-073An empty downloaded-only browse is authoritative — no rows, no error — regardless of the catalog-browse flagDR-082Done
UT-074Only libraries with downloaded content are listed; an empty one is omittedDR-082Done
UT-075Disk usage reports a leaf's own size, a container's summed descendants, and reconciles the device total with the sum of leavesDR-085Done
UT-076Downloaded library browse lists album containers, not their individual tracks; drilling into the album returns the tracksDR-082, DR-083Done
UT-077Downloaded TV library browse lists the series, not seasons/episodes; drilling returns the season then the episodeDR-082, DR-083Done
UT-078A downloaded leaf with no cached container (e.g. a movie) still surfaces at the library levelDR-082, DR-083Done
UT-079Each EQ preset returns a 10-band gain curve within range; Flat is all zeros; Bass Boost lifts lows and leaves highs flatDR-030Done
UT-080with_equalizer_normalised clamps out-of-range gains and forces the band vector to exactly 10 entries (pad short, truncate long)DR-030Done
UT-081Old persisted AudioSettings JSON without EQ fields loads as disabled + flatDR-030Done
UT-082EQ fields serialize as camelCase (equalizerEnabled/equalizerBands) and round-tripDR-030Done
UT-083EQ filter entries are empty when disabled or when the curve is flat (clears the af filter)IR-020Done
UT-084Enabled EQ builds one peaking equalizer per non-zero band at the right frequency and gain inside a single lavfi chainIR-020Done
UT-085A first tap resolves to togglePlayPause immediately — no deferral and no timerDR-092, DR-098Done
UT-086A second tap inside the window seeks (+30 s right half, −10 s left half) with the matching feedback side and re-toggles play/pause, so the two toggles cancel and the play state is unchanged by a double tapDR-092, DR-098Done
UT-087A tap after the window, and the tap following a consumed pair, are each fresh first taps that toggle (there is no third-tap case); repeated double taps keep seeking; cancel() makes the next tap a first tap so an interpreted swipe cannot seekDR-092, DR-098Done
UT-088resolveSeekTarget applies the delta to the reported position, clamps into [0, duration - END_SEEK_MARGIN_SECONDS], chains off an in-flight pending target so rapid skips accumulate, and ignores that target once the player reports past itDR-092, DR-095Done
UT-089A touch drag on the video seek bar seeks to the dragged position, never toggles play/pause, and never alters brightness — the container gesture layer stays out of a control drag entirelyDR-098, DR-099Done
UT-090The seek bar commits its seek on touchend even when the engine never fires change, and commits exactly once when both signals arriveDR-099Done
UT-091Transport intents (play/pause/toggle) reach the backend even while a video adapter is registered, and never call the adapter's own play/pause/toggle — the webview must not decide play-vs-pause from the DOMDR-097Done
UT-092shouldReuseActivePlayback reuses backend playback for an already-loaded audio track but never for video, and never when an explicit start position or a next-episode restart was requestedDR-100Done
UT-093resolvePlayerSurface returns video only with a stream URL, pending for video whose stream URL is still missing (never audio), and audio for audio contentDR-100Done
UT-094parseNativeInsets accepts the bridge's JSON or a decoded object, and coerces missing/negative/non-finite edges to 0 rather than emitting NaNpx (which would invalidate the whole padding declaration)DR-112Done
UT-095safeAreaCssVars/applySafeAreaInsets emit px-suffixed jt-inset custom properties for all four edgesDR-112Done
UT-096readNativeInsets returns null with no bridge and survives a stale WebView proxy (missing or throwing get) instead of throwing out of layout initIR-031, DR-112Done
UT-097initSafeArea primes the document on start, re-applies on jellytau-insets-changed (rotation, nav-mode switch), unsubscribes on teardown, and writes nothing without a bridge so env() still wins on iOS/desktopIR-031, DR-112Done
UT-098shellReservesBottomInset gives the bottom inset to BottomUi wherever one renders and to the app shell only on routes without one, so the gesture bar is never ignored nor double-paddedDR-112Done
UT-099A Jellyfin item payload carrying UserData.IsFavorite maps to MediaItem.user_data.is_favoriteDR-113, JA-034Done
UT-100OnlineRepository::get_favorites builds Filters=IsFavorite + Recursive=true + the scope's IncludeItemTypes, and omits the type filter entirely for SearchScope::AllDR-115, JA-033Done
UT-101OfflineRepository::get_favorites returns only is_favorite = 1 rows, honours the scope type filter, and stays downloads-only when the catalog-browse gate is offDR-115Done
UT-102The save_to_cache favourite mirror does not overwrite a row with pending_sync = 1DR-114Done
UT-103The reconnect drain pushes pending favourites, clears pending_sync, and leaves failed rows pendingDR-120Done
UT-104get_items with favorites_only filters online (endpoint) and offline (SQL)DR-116Done
UT-105favorites store precedence: override beats userData.isFavorite beats falseDR-119Done
UT-106Un-favouriting removes an item from a favourites listing viewDR-117, DR-119Done
UT-107The hybrid background refresh emits favorites-changed only for ids whose favourite state actually flippedDR-120Done
UT-109Search covers synced-but-not-downloaded items when catalog browse is on, and stays downloads-only when offDR-108Done
UT-110Search item-type filter is bound, not interpolated: a quote-bearing type neither errors nor widens resultsDR-108Done
UT-111FTS prefix queries quote each token, so apostrophes/hyphens/slashes are data; empty or punctuation-only input returns no rows rather than erroringDR-108Done
UT-112Repeated catalog passes leave one items_fts entry per item, not one per passDR-110Done
UT-113The stale-catalog sweep removes vanished synced rows, keeps downloaded ones, keeps uncrawled types, and stays scoped to one serverDR-110Done
UT-114Cached people are reachable from unscoped search and excluded from scoped searchDR-111Done
UT-115Re-index staleness policy: never-indexed and unparseable timestamps are due, fresh ones are not, future ones are notDR-109Done
UT-116resolve_local_media_path returns a completed download's file, and None for an in-progress download, a row whose file has been deleted, or an unknown itemDR-123Done
UT-118resolveVideoSource prefers a downloaded file, never marks a local file as needing transcoding, and falls back to streaming for a blank pathDR-123Done
UT-119The audio-only handoff picks a downloaded file over the audio-only stream URL, preserving the Jellyfin id for progress syncDR-128Done
UT-120Expiry reclaim takes only expired temporary entries: derived from completed_at+TTL, honouring an expires_at override, never a user download, and disabled by a zero TTLDR-127Done
UT-108LRU eviction reclaims only 'auto' downloads and never a user's own, even when the user's is the oldestDR-126Done
UT-117A background audio-only stream cut short resumes where it died instead of ending the episode; a real end still advances; the absolute position is compared against the runtime; retries at a stuck position give up. A recoverable error resumes music and video too, with growing backoff, leaving the rest of the queue intact and the seekable stream's URL untouched; local and DirectUrl sources are excludedDR-129Done
UT-124downloadedFilePath leaves a completed download's absolute path alone (POSIX and Windows) and only roots one that is still relativeDR-133Done
UT-125A NULL media_type resolves from the item type — Movie and Episode as video, a track as audio — an uncached item still defaults to audio, and an explicit media_type overrides the itemDR-135Done
UT-126Requeueing takes only video rows downloaded under the audio default, clearing their URL, and leaves correctly-typed video rows and real audio downloads aloneDR-136Done
UT-127The media server bounds and confines every response: a range-less request yields one chunk rather than the whole file, no range exceeds the chunk cap, explicit/open-ended/suffix ranges resolve correctly, a range past the end is unsatisfiable rather than clamped, a malformed header falls back to the first chunk, path traversal and unrelated absolute paths are refused, a wrong or absent token is rejected, and content type comes from the extension then the magic bytesDR-137Done
UT-121An EOF reads as the last observed timestamp, not zero: live readings win while the file is loaded, a not-yet-established duration is not recorded as a real zero, a seek updates the position before the next poll, and loading a new file clears the previous one'sDR-130Done
UT-122The sync-queue drain pushes queued playback reports oldest-first, defers failures for the next reconnect, abandons a row after MAX_SYNC_ATTEMPTS, ignores other users' rows, and parses both payload dialectsDR-131Done
UT-123Pending-sync rows describe themselves: every queueable operation has a label, an unknown one still renders, the item title falls back to its id, and rows list oldest-firstDR-132Done
UT-130Video and background-audio stream URLs omit AudioStreamIndex when no track was chosen, and carry the exact index when one wasDR-140Done
UT-131The Episode Focus View hero offers a download controlDR-142Done
UT-132The series name links to the series and the SxEy badge to that season's anchorDR-142Done
UT-133Cast renders below the "More Episodes" strip, never above itDR-062, DR-142Done
UT-134The episode strip is hidden when the episode has no siblingsDR-142Done
UT-135An episode with no seriesId still renders the Focus View, with title, Play and downloadDR-142Done
UT-136episodeRedirectTarget sends a bare episode page into its series' Focus View, and returns null with no seriesDR-142Done
UT-137Going offline with the toggle off pushes the closed gate and bumps catalogFilterVersionDR-143Done
UT-138The version bumps only after set_show_server_catalog resolves, never beforeDR-143Done
UT-139A failed visibility push is retried on the next identical transition rather than latchedDR-143Done
UT-140useOfflineFilterReload skips the value a page already loaded under and reloads on each later changeDR-143Done
UT-141The advertised channel cap: an unknown or zero reading falls back to stereo, a real route keeps its channels, an absurd driver reading is capped at 7.1, and mono is taken at its wordDR-141Done
UT-148Forcing a transcode from the client: an undecodable default track forces one, a decodable track does not, the default track decides rather than the first, the first decides when nothing is marked default, and neither an audio-less source nor an unnamed codec is second-guessedDR-149Done
UT-149createAdapter returns the native adapter only when Rust reports native AND experimentalNativeVideo is on; the flag off forces HTML5 even when Rust says native, and the flag on never promotes a platform Rust reported as HTML5DR-150Done
UT-150set-version.sh stamps all four manifests without touching dependency versions, and the Android versionCode is monotonic across an upgrade sequence, clears the 1000 floor, and survives a prerelease suffixDR-153Done
UT-151An unreportable stop lands in the queue and is pushed by the existing drain; re-queueing the same item supersedes the earlier position rather than adding a row, distinct items keep their own positions, and an abandoned row is not revived by a later reportDR-154Done
UT-152Caching a server result mirrors its watch position locally — including for an item carrying a position but no favourite flag — without inventing a row for an item the server reported no user data for, and without pulling a still-unsynced local position backwardsDR-155Done
UT-162Each downloaded library lists only its own media: the music library shows the album and neither the film nor the series, the movie library only the film, the TV library only the seriesDR-163Done
UT-163partial_path appends rather than replacing the extension, so it matches what the cleanup paths delete, keeps two sources for one title apart, and still produces a sidecar for an extension-less targetDR-165Done
UT-170queue_album_tracks queues a row for every track of the album — including tracks the cache holds without an album_id and tracks it has never seen at all — links each one to its album so offline browsing can find it, returns the row ids in track order, and is idempotent: re-queuing fills the gaps without duplicating rows or resetting a completed track. cached_album_tracks (the offline fallback) finds tracks by either album link and does not sweep in another album'sDR-173Done
UT-171resolve_pending_download_urls restricted to a set of row ids resolves only those rows and leaves other pending rows untouched, and an empty id set resolves nothing rather than sweeping everythingDR-173Done
UT-172album_file_names gives every track of an album its own file: a title repeated within the album (deluxe edition, two discs) is disambiguated by track number and item id instead of the second download overwriting the first, an unambiguous title keeps its own name, and path separators in a title are sanitised so a track cannot escape the album directoryDR-173Done
UT-164resume_offset appends only when the server answered 206; a 200 after a Range request restarts the file, because that body is the whole streamDR-166Done
UT-165A registered download starts unflagged, signal sets the flag its worker reads, signalling an unregistered id reports not-in-flight, clear forgets it, and re-registering drops a previous stop so a resumed download does not halt instantlyDR-164Done
UT-166original quality re-encodes audio the webview cannot decode (E-AC-3/AC-3/DTS/TrueHD) to AAC without capping bitrate or resolution, keeps the Static=true direct copy for audio that plays here (AAC/MP3/Opus/Vorbis/FLAC) and for an unknown codec, leaves the explicit quality presets untouched, and picks the served track by the same default-or-first rule the streaming verdict usesDR-171Done
UT-155A seek during a background-audio handoff re-opens the stream at the requested absolute position (StartTimeTicks) and rebases the handoff to it, while a seek outside a handoff stays an ordinary seek and invents no baseDR-159Done
UT-154mark_unplayed parses to QueuedOp::MarkUnplayed and is rejected without an item id, and a queued un-mark drains to the server as clear_watch_historyDR-158Done
UT-156A capped step reaches the transcode URL as all four of its parts (total ceiling, the video/audio split summing to the cap, and a MaxHeight), the uncapped default keeps the historical 20/18 Mbps allowance and constrains no resolution, and the background-audio handoff takes the lower of the cap and its own 384 kbpsDR-162Done
UT-157The quality ladder is internally consistent — video + audio equals the cap at every step, audio never consumes the budget, only Original is uncapped — descends in bitrate, resolution and audio share together, and round-trips through the serde token it is persisted asDR-162Done
UT-158Justified rows fill the container width exactly and never overflow it, every tile in a row shares one height, and each tile's width follows its own aspect ratio — a 16:9 tile coming out more than twice the width of a 2:3 tile at the same heightDR-174Done
UT-159The awkward cases of the packing: a short last row is left at the target height rather than stretched across the container, a last row that would overflow is brought down, an extreme ratio is clamped instead of taking a row to itself, a missing or nonsensical ratio falls back to square instead of collapsing the tile, an unmeasured container renders nothing rather than 1px tiles, and every tile is placed exactly once in orderDR-174Done
UT-160The default row height suits its container: it grows with the width, stays inside its bounds, and at phone width still fits two 16:9 tiles side by sideDR-174Done
UT-161A collection type maps to its favourites scope (movies/tvshows/music), every other kind — Live TV, channels, box sets, books, unknown — maps to none rather than to All, and a constructed library carries the scope across the wire as favoritesScope, omitted entirely when it has noneDR-175Done
UT-167The mosaic's composition: the cross-library favourites entry leads, each library is followed by its own category tile pointing at that category's tab, a category shared by two libraries still yields one tile, a library kind favourites do not carve up yields none, a scope the page offers no tab for is ignored, and every tile is uniquely keyedDR-174, DR-175Done
UT-168Subtitles are negotiated as sidecars, never burned in: the requested SubtitleStreamIndex is the explicit "none" sentinel (-1) rather than omitted, every text format we can render (srt/subrip/ass/ssa/vtt) is advertised as External, and the burn-in verdict is by format — text never forces it, image formats (PGSSUB, dvdsub) always do, case-insensitively. The same sentinel rides the stream URL itself, so a stream re-opened without a fresh negotiation cannot inherit a subtitle. And the verdict reaches the picker: a subtitle stream carries supportsExternalDelivery — set only for subtitles, false for a bitmap format and for one the server left unnamed — which drops the tracks the app could never draw from the menu, the <track> children and the native play request alike, without even fetching their URLs, while a stream carrying no verdict at all is still offeredDR-176Done
UT-173Every video stream URL carries a PlaySessionId, each open mints a fresh one, and the open reports the session it superseded so that job can be stoppedDR-177Done
UT-174A fatal HLS network error is read against the absolute position: mid-film — including after a quality switch, where the seek offset carries the whole resume position — it is retried rather than reported as the end of the stream, the last tenth of a known runtime is treated as the end, an unknown runtime retries, and retries stop once the budget is spentDR-177Done
UT-175A stream reload that never becomes playable is reported as a failure instead of resolving as success, so the caller can revert its selection rather than leave the UI claiming a stream that is not playingDR-177Done
UT-176A handoff's position is floored at its base: with no tick yet landed the exit position is the point the screen was locked at rather than 0, and once ticks are flowing (the base already applied natively) it is not added twiceDR-178Done
UT-177Webview-rendered media's reported position and duration are the controller's, and are dropped the moment that element stops being the player — on teardown, and when a handoff takes overDR-178Done
UT-178A stop report at position 0 is withheld rather than sent (it would clear the resume point), while a real position is still reported from either rendering path — the element's on the webview path, the backend's on the native oneDR-179Done
UT-179An audio-only episode that ends naturally is reported stopped at its runtime, so Jellyfin marks it played; a truncated stream, which is about to be re-opened, reports nothingDR-179Done
UT-180Position ticks report progress to the server, throttled to one report per item per window rather than one per tickDR-179Done
UT-181The handoff plan matches its source: a downloaded file takes no base and a seek, a stream takes the base and no seek, and a handoff at 0:00 takes neither; a downloaded handoff's absolute seek stays an ordinary seek instead of a stream rebuildDR-180Done
UT-153Scroll handling per navigation kind: a forward move always lands at the top even when the previous page was scrolled and even when the target was visited before, Back restores that route's own saved offset (and the top when it has none), offsets are kept per route rather than shared, a repeated Back still restores, and the initial load leaves the container aloneDR-156Done
UT-142The audio codecs offered for video direct play: a Dolby device's real MediaCodecList output drops ac3/eac3, AMR and raw PCM are dropped too, a fully-supported list is passed through untouched, a list with nothing decodable still claims aac, and stray spacing or casing does not decide whether the user gets soundDR-148Done
UT-143Subtitle URLs resolve to plain strings before they reach the markup (never a Promise), unresolvable tracks are dropped, a stale selection collapses to "Off", and a server-default track is never auto-selectedUR-020, DR-023Done
UT-144VideoPlayer actually renders <track kind="subtitles"> children carrying data-stream-index, with no default attribute and no async getSubtitleUrl() bound to srcUR-020, DR-023Done
UT-145The frontend's subtitle payload survives the IPC hop: a camelCase PlayItemRequest carrying subtitles deserializes, create_media_item lands them on MediaItem.subtitles in the order sent, and a request without the field still defaults to emptyUR-020, IR-016Done
UT-146The subtitle JSON serialized across the JNI boundary uses the keys JellyTauPlayer.load() reads — url, language, label and mime_type, never mimeTypeUR-020, IR-016, JA-008Done
UT-147The native subtitle payload and the track-selection index come from the same resolved list: the wire shape keeps mime_type and stream order, playerPlayItem actually sends it, and the index is a position in the sent list (so a track whose URL failed to resolve cannot shift the others) rather than the menu's row numberUR-020, IR-016Done
UT-182An HLS video URL never carries StartTimeTicks — with a position supplied or not — while the master playlist, codec, media source and chosen audio track still ride on itDR-181Done
UT-183A reloaded stream is resumed by seeking the element to the absolute position with the transcode offset cleared to zero — never by carrying the position as an offset base, which since DR-181 would display the position while playing the item from its start — and a reload to 0:00 waits for no seekDR-181Done
UT-184The native reveal rule fires on state === "playing" and on a position tick carrying a position or a duration, and on nothing else — not buffering, paused, stopped, ended or error, not an empty tick, and not a negative positionDR-182Done
UT-188The control-bar auto-hide rule permits hiding only during uninterrupted playback: it declines while paused, while a seek is in flight, and while a track/subtitle/quality menu is open — asserted against the pure shouldHideControls rule rather than a clock or a DOMDR-189Done
UT-189On the native path the player never calls player_report_state — driven through the real 10-second progress interval under fake timers, which is the call site that mattered; asserting on a freshly mounted player passes with the guard deleted and guards nothingDR-195Done
UT-187On the native path the play overlay follows the backend: it clears when the backend resumes after a pause and is raised again when the backend pauses, and the system bars are hidden on player entry rather than only by the fullscreen buttonDR-186, DR-187Done
UT-186Every attribute the native-video compositing block in app.css targets is set somewhere in the app — [data-app-shell] in particular — so a selector aimed at nothing fails the suite instead of failing silently on a deviceDR-185Done
UT-185Mounted on the native path (backend reports native, opt-in flag on, no <video> element rendered and the backend not stopped), VideoPlayer keeps the poster card up until the backend reports something, drops it on a playing state or a position tick with a duration, and keeps it up through error and stoppedDR-182Done
UT-190build_next_up_endpoint sends EnableResumable=false with the user and limit, and no SeriesId filter when none was requestedDR-197, JA-036Done
UT-191A per-series next-up query keeps SeriesId and the resumable exclusion, and defaults the limitDR-197Done
UT-192filterInProgressNextUpItems drops an episode present in the resume list, keeps the genuinely unstarted next episode, leaves the rest of the row intact, and is a no-op when nothing is in progressDR-197Done
UT-193The shipped Tauri security config stays restrictive: csp is set, script-src carries no 'unsafe-inline'/'unsafe-eval'/wildcard, object-src/frame-src are 'none', the directives playback needs (asset scheme, loopback, blob:, ipc:) are present, and the asset-protocol scope covers only the thumbnail cache — never the storage root that holds the databaseDR-198Done
UT-194Normal audio (no background-audio handoff) keeps queue advance on both skip buttonsDR-201Done
UT-195In background-audio mode a skip scrubs +30s/-10s instead of advancing the queue — the reported defectDR-201Done
UT-196Skipping back near the start clamps to zero rather than seeking negativeDR-201Done
UT-197Skipping forward near the end clamps to the duration rather than running past it into an EOF-driven advanceDR-201Done
UT-198An unknown duration still scrubs and still refuses to go negativeDR-201Done
UT-199The screen-wake decision: video playing holds the display, pausing releases it, audio playing never holds it, a webview element going inactive releases even without a pause report, either renderer alone is enough to hold, and teardown drops bothDR-202Done
UT-201The logging facade gates by level: a message below the active level is not emitted at all, one at or above it reaches the sink, changing the level at run time changes what passes without touching the call sites, and a scoped logger tags its output with the subsystemDR-204Proposed
UT-202Generated traceability-matrix file links resolve from docs/: an emitted href, resolved against the directory traceability.md is written to, points at a file that exists on disk; the visible link text stays repo-root-relative; the #Lnn anchor survives; and a bare repo-root href — the regression that made every link 404 as docs/<path> — is rejectedDR-093Done
UT-203Library folder exclusion filters by id, not by name: an excluded folder's items are absent from a music query, an item whose title merely contains an excluded folder's name is kept, and clearing the exclusion restores the itemsDR-209Proposed
UT-204Thumbnail cache writes stay inside the cache directory: a traversal-style and an absolute item_id both fail to produce a file outside it, a filename made only of already-safe characters is byte-identical to the one the previous code produced, and an odd id still round-trips through get_cached_pathDR-210Done
UT-205Queued download paths cannot escape the download root — traversal, absolute and .. forms are refused — while the four real path shapes the app builds, including the absolute one download_series produces, come back unchanged; and a completed download cannot register a file outside the rootDR-211Done
UT-206The offline item-type filter is bound rather than interpolated (a value containing a quote and OR 1=1 matches nothing instead of disabling the WHERE), build_get_items_endpoint percent-encodes its values while preserving the commas Jellyfin splits on, and volume normalisation clamps out-of-range input and maps NaN to a finite valueDR-212Done
UT-200The stream a player could only restart is refused its retry: the handoff transcode answers yes to player_retry_restarts_stream while music, video and a downloaded episode answer no, and the Kotlin decision starts permissive, flips on a non-resumable load, and is restored by the next ordinary oneDR-203Done
UT-207The hero banner's rotation timer restarts from the moment of a manual change: a swipe 5.5s into a 6s interval waits a further 6s instead of firing the leftover 500ms, repeated restarts never stack timers, and stop() ends rotationDR-038Done
UT-208The update decision: each numeric version field is compared in order, the installed version is not offered to itself, a leading v is tolerated because that is how the tags are written, a pre-release sorts below the release of the same number so 0.9.2-rc1 is not offered to somebody on 0.9.2, a missing patch field reads as zero rather than NaN, mobile reports link-only while desktop reports install, and absent release notes normalise to null rather than undefinedDR-217Done
UT-209Redaction and forwarding. Rust: every credential shape reduces to [REDACTED] while the host, username and neighbouring parameters survive; redaction is idempotent, leaves ordinary lines alone, does not fire on the word "token" in prose, and does not panic on multi-byte input; a server URL keeps only scheme and host and drops an embedded user:pass@; an unparseable level falls back to info rather than failing at startup. Frontend: info and above forward while debug does not, a message the level filter suppressed is not forwarded, a throwing forwarder neither propagates nor prevents the console write, and an Error renders as name and message rather than the {} that JSON.stringify producesDR-218Done
UT-210Cosmetic-commit detection for release notes: a chore(format), chore(deps) or style subject is skipped when deriving a range's changed files, while fix, feat, ci, docs, a bare chore: and chore(release): are kept; and the word "format" appearing later in a subject ("fix(duration): format times over 24 hours") does not make a real fix look cosmeticDR-219Done
UT-211The background decision: a video with the toggle off pauses (the reported defect, where the media service kept playing regardless), a video with it on hands off to audio, music keeps playing whatever the toggle says because it has no picture to lose, picture-in-picture keeps playing in every combination since the window is still visible, and the answer does not vary by rendererDR-224Done
+
+

Integration Tests

+
+ + + + + + + + + + + + + + + +
Test IDTest DescriptionTraces ToStatus
IT-001End-to-end authentication with Jellyfin serverIR-009, UR-009Pending
IT-002Library browsing and item loadingIR-010, UR-007Pending
IT-003Audio playback via libmpvIR-003, UR-004Pending
IT-004Video playback via libmpvIR-003, UR-003Pending
IT-005MPRIS lockscreen controls on LinuxIR-005, UR-006Pending
IT-006Offline mode with local databaseIR-013, UR-002Pending
IT-007Media download and local playbackDR-015, UR-011Pending
IT-008Subtitle track selection on the video backends (ExoPlayer sideloaded tracks; HTML5 <track> children) — not via libmpv, which does not implement itIR-018, UR-020Pending
IT-009Audio track selection on the video backends (ExoPlayer track switch; HTML5 stream re-open at the chosen AudioStreamIndex) — not via libmpv, which does not implement itIR-019, UR-021Pending
IT-010Playback progress sync to JellyfinIR-015, UR-025Pending
IT-011Resume playback from server positionIR-015, UR-019Pending
IT-012Equalizer bands via libmpvIR-020, UR-027Pending
IT-013Background-audio handoff on Android: background/lock continues audio via native service and stops video decode; foreground resumes video at positionIR-025, UR-040Pending
IT-016Offline library listing end-to-end: with the server unreachable, a library page lists only downloaded media with the toggle off, and additionally reveals greyed-out cached catalog entries with the toggle onUR-052, DR-078, DR-079, DR-080Done
IT-017A download queued from a greyed-out offline catalog entry persists and is resolved and started on reconnectUR-052, UR-011Done
+
+
+

5. Technical Debt

+

Open items carried over from the v0.6.0 codebase audit

+

The 2026-08-16 audit (v0.6.0, commit be907b49) was a point-in-time snapshot +with no status markers, and by v0.8.2 most of it had been either fixed or +overtaken. It was retired rather than left to rot into a document that +half-describes the code: what survived it is the table below, which is now the +record. Each row is self-contained — the audit is not needed to act on it.

+

What was dropped as demonstrably closed, so it is not re-raised: the CSP and +asset-protocol scope findings (now DR-198), cloud backup and credential restore, +the WebView mixed-content override (DR-199), POST_NOTIFICATIONS and the +media-session exemption (DR-200), the jvmTarget 1.8 pin (now 17), the +half-declared Android TV leanback category (removed), the untraced-but-Done +requirements and the contradictory UR/IR statuses (re-scoped in §2.1), the 50% +traceability gate (ratcheted, and gated on a live denominator by DR-093), the +flaky offlineCatalog test, the clippy warning backlog (cleared, and cargo fmt --check plus clippy now run in CI), and the "820 production unwrap()s" +figure — a measurement error that counted test modules, corrected in the audit +itself to ~19 and standing at 27 today, none of them in a command handler. The +three Runtime::new().unwrap() sites that genuinely matter survive as row 5.

+

Ordered by what would hurt most if left.

+
+

Closed 2026-08-17: the R8-minified release APK was validated on device. +That was the last item gating confidence in the v0.8.0 release itself; R8 +stripping JNI-loaded classes has broken release builds here before, and +v0.8.0 added a new Kotlin path (onFastForward/onRewind) that the +unminified debug pass did not cover.

+
+
+ + + + + + + + + + + +
#ItemWhy it mattersSize
1Android 16 Local Network ProtectionsThe rare platform change that could stop the app working at all: JellyTau's core function is reaching a Jellyfin server that, for most users, is on the LAN. Opt-in for testing in Android 16, enforcement signalled for a later release — so nothing is broken today and no device test will surface it. Far cheaper to handle before it is mandatory. An Android 16 device is already to hand to test the opt-in flag againstM
2The traceability matrix cannot see Kotlinscripts/extract-traces.ts walks only src, src-tauri/src and scripts, so every TRACES: comment in src-tauri/android/** is invisible — pre-existing ones included. A whole platform is unmeasured, which is plausibly why the Android IRs sat untagged for so long, and it means the 90% coverage figure is computed over a codebase that excludes the Android treeS
3Delete the asset protocol outrightIt is not narrowly used, it is unused. getCachedImageUrl has no production callers (only its own test file), so convertFileSrc never executes; images arrive as base64 data: URIs from image_get_url. Confirmed on device: zero asset.localhost requests across a full browsing session. Dropping protocol-asset and the assetProtocol block retires the surface instead of shrinking it, and imageCache.ts goes with itS
4Tighten img-srcThe v0.8.0 CSP grants img-src … http: https: on the premise that thumbnails are fetched direct-from-server by the webview. They are not (see #3). With no webview-side server image loads anywhere in src/, 'self' data: blob: should suffice. Needs its own device pass — a wrong img-src blanks every image, silentlyS
5Three Runtime::new().unwrap() in playback-critical threadssession_poller/mod.rs:102, player/mpv_backend.rs:424, player/android/mod.rs:761. A panic strands the app offline with nothing surfaced, freezes the scrubber mid-playback, or kills progress reporting across a JNI boundary. One shared helper returning Option<Runtime> and logging on failure retires all three. (The wider "820 unwraps" figure was a measurement error — the real count is 19, and none are in command handlers)S
6Confirm the playback service rejects unknown callersJellyTauPlaybackService is exported="true" with a MediaSessionService intent filter — conventional for Media3, but it means any app on the device can attempt to bind and drive playback. The session's onConnect should reject unknown packages. (Predictive back, raised alongside this, was verified working on device and needs nothing)S
7Media3 is several minor versions behindPinned at 1.5.0 across exoplayer/hls/session/common. Much of this app's hard-won behaviour lives in ExoPlayer edge cases — truncated progressive streams, background-audio handoff, HLS resume — so its bug-fix releases have unusually high value here. Schedule with a device pass over the playback regression listM
8Shipped desktop bundles have no update pathdeb/rpm/nsis are built but tauri-plugin-updater is absent, so every desktop user upgrades by manually fetching a package — in practice a long tail of installs pinned to whatever they first downloaded. Add the updater with a signed manifest, or document the manual path so the omission is deliberateM
9DR-042 overstates what shipsIt promises "poster cards, year, and rating badges", but MediaCard.svelte renders only productionYear; CommunityRating/OfficialRating appear solely as sort keys, never as a badge. Either build the badge or correct the requirement text — a requirement that describes unbuilt behaviour is worse than an untraced oneS
10Stray duplicate JellyTauPlayer.ktA copy exists at src-tauri/android/app/src/main/java/.../player/JellyTauPlayer.kt, outside the canonical src-tauri/android/src tree that sync-android-sources.sh reads. Two files with one name in a tree with a strict canonical-source rule is a trap for the next editS
11Six modules carry a disproportionate share of the complexitysrc-tauri/src/player/mod.rs (4,732 lines), src-tauri/src/repository/offline.rs (4,705), src-tauri/src/repository/online.rs (3,760), src-tauri/src/commands/player/mod.rs (3,327), src-tauri/src/commands/download/mod.rs (3,238) and src/lib/components/player/VideoPlayer.svelte (2,786) — all still growing. The cost is not the line count itself, it is that these are the same modules CLAUDE.md's Gotchas section keeps having to warn about: the deadlock rule about locking in event callbacks, the AutoplayDecision scrutinee, the "no lifecycle calls after an await in onMount" rule, the HLS master.m3u8 rule, the download concurrency cap. A file that needs a standing warning in the project's onboarding document is a file whose invariants are no longer local to it, and every such warning is a rule a newcomer has to be told rather than one the structure enforces. Recorded, not scheduled — a speculative refactor of six files this size buys nothing on its own. The trigger is the next time one of them needs substantial work: splitting it then is likely cheaper than growing it, and each rule that moves from Gotchas into a module boundary is one fewer thing to rememberL
+
+

Linux Keyring Integration Workaround

+

Issue: The keyring-rs crate (v3.x) has issues with retrieving credentials from the Linux Secret Service API, despite successfully saving them.

+

Symptoms:

+
    +
  • Credentials are saved to the system keyring successfully (verified with secret-tool search)
  • +
  • Retrieval via the keyring-rs library fails with NoEntry error
  • +
  • Session restoration fails on app restart even though credentials exist
  • +
+

Root Cause: +The keyring-rs library's Linux backend doesn't correctly retrieve entries from the Secret Service that it previously stored. This appears to be a bug in how the library interfaces with the Secret Service D-Bus API.

+

Current Workaround: +We bypass the keyring-rs library on Linux and use direct system calls to secret-tool:

+
    +
  • Save: secret-tool store --label <label> service <service> username <username>
  • +
  • Retrieve: secret-tool lookup service <service> username <username>
  • +
  • Delete: secret-tool clear service <service> username <username>
  • +
+

Implementation: +See src-tauri/src/credentials.rs for the +Linux-specific secret-tool save/get/delete paths.

+

Future Fix:

+
    +
  • Monitor keyring-rs for bug fixes in future versions
  • +
  • Consider alternative secure storage libraries
  • +
  • Test if newer versions of keyring-rs (v4.x+) resolve the issue
  • +
  • Once fixed, remove the Linux-specific workaround and use the cross-platform keyring-rs API
  • +
+

Impact:

+
    +
  • Low - The workaround is functionally equivalent to proper keyring integration
  • +
  • Credentials are stored securely in the system keyring
  • +
  • Session restoration works correctly
  • +
  • Only affects Linux; macOS and Windows use the standard keyring-rs implementation
  • +
+

Dependencies:

+
    +
  • Requires secret-tool to be installed on Linux systems (part of libsecret-tools package)
  • +
  • Already available on most Linux distributions by default
  • +
+
+

Platform Playback Backend Parity (Linux vs Android)

+

Issue: The Linux (MPV) and Android (ExoPlayer) playback backends have diverged in feature implementation and architecture patterns.

+

Symptoms (as first recorded; the audio half is now closed — see Status):

+
    +
  • Audio settings (gapless playback, volume normalization, equalizer) worked on Linux but not on Android
  • +
  • Position update frequency differs between platforms (Linux: 250ms polling, Android: on-demand callbacks)
  • +
  • Thread safety models differ (Linux: Arc<Mutex<>>, Android: global OnceLock statics)
  • +
+

Root Cause: +The PlayerBackend trait defines optional audio settings methods with default empty implementations. MpvBackend overrode these with MPV property commands; ExoPlayerBackend took the silent defaults, so the Settings › Audio panel rendered controls that did nothing on Android. ExoPlayerBackend now overrides them too, but the trait default is still a silent Ok(()) — a backend that omits the method still reports success rather than failing loudly.

+

Affected Files:

+ +

Feature Parity Matrix:

+
+ + + + + + + + +
FeatureLinux (MPV)Android (ExoPlayer)Status
Basic playbackParity
Volume controlParity
SeekParity
CrossfadeNot implemented (blocked on MPV)
Gapless playback⚠️Implemented, pending on-device verification
Volume normalization⚠️Implemented (LoudnessEnhancer — gain stage, approximate vs MPV's dynaudnorm), pending on-device verification
Equalizer (10-band)⚠️Implemented (resampled onto device bands), pending on-device verification
Position updates250msOn-demandInconsistent
+
+

Status (see docs/architecture/05-platform-backends.md, "Audio settings on ExoPlayer"):

+
    +
  1. set_audio_settings() implemented in ExoPlayerBackend (JSON over JNI)
  2. +
  3. ✅ Gapless via ExoPlayer's pauseAtEndOfMediaItems
  4. +
  5. ✅ Volume normalization via LoudnessEnhancer
  6. +
  7. ✅ Equalizer via android.media.audiofx.Equalizer, canonical 10 bands +resampled onto the device's band centres
  8. +
  9. Not yet verified on a physical device — the EQ/normalization effects +depend on device-specific AudioEffect availability and band layouts
  10. +
  11. ⬜ Flip the trait's set_audio_settings default from Ok(()) to +Err(not_implemented()) so a backend that omits it fails loudly instead of +silently reporting success. Deferred until (5) confirms the Android path works
  12. +
  13. ⬜ Standardize position update frequency across platforms
  14. +
+

Crossfade is deliberately absent: it is unimplemented on every platform and +architecturally blocked on MPV, so building it on Android alone would invert the +parity gap. (The previously suggested ConcatenatingMediaSource is also +deprecated in current Media3.)

+

Impact:

+
    +
  • Medium - Android users lack audio enhancement features advertised in requirements
  • +
  • User experience differs between platforms
  • +
  • UR-032 (Gapless), UR-033 (Normalization) and UR-027 (Equalizer) are now +implemented on Android as well as Linux, pending on-device verification
  • +
  • UR-031 (Crossfade) works nowhere — see DR-034
  • +
+

Traces To: IR-004, UR-031, UR-032, UR-033, DR-034, DR-035, DR-036

+
+

Frontend Playback Code Duplication

+

Issue: Playback control handlers and state derivations are duplicated between AudioPlayer.svelte and MiniPlayer.svelte.

+

Symptoms:

+
    +
  • Identical try-catch wrapped handler functions in both components (~44 lines duplicated)
  • +
  • Same $derived state merging logic for local/remote playback in both components
  • +
  • Position conversion (ticks ↔ seconds) scattered across multiple files
  • +
+

Affected Files:

+ +

Duplicated Code:

+
// These handlers are identical in both AudioPlayer and MiniPlayer:
+handlePlayPause(), handleNext(), handlePrevious(),
+handleToggleShuffle(), handleCycleRepeat(), handleVolumeChange()
+
+// These derived states use identical logic:
+displayMedia, displayIsPlaying, displayPosition, displayDuration
+
+

Future Fix:

+
    +
  1. +

    Create src/lib/utils/playbackUnits.ts:

    +
    export const TICKS_PER_SECOND = 10_000_000;
    +export const secondsToTicks = (s: number) => Math.floor(s * TICKS_PER_SECOND);
    +export const ticksToSeconds = (t: number) => t / TICKS_PER_SECOND;
    +
    +
  2. +
  3. +

    Create src/lib/composables/useMergedPlaybackState.svelte.ts:

    +
      +
    • Export displayMedia, displayIsPlaying, displayPosition, displayDuration
    • +
    • Single source of truth for merged local/remote state
    • +
    +
  4. +
  5. +

    Simplify handler wrappers using a utility:

    +
    export const withErrorHandler = (fn: () => Promise<void>, context: string) =>
    +  async () => { try { await fn(); } catch (e) { console.error(`${context}:`, e); } };
    +
    +
  6. +
+

Impact:

+
    +
  • Low - Code works correctly but violates DRY principle
  • +
  • Maintenance burden when logic needs to change
  • +
  • Risk of handlers diverging over time
  • +
+

Traces To: DR-009

+

Code Traceability Matrix

+

Generated: 8/22/2026, 10:23:05 AM

+

Summary

+
    +
  • Total Files Scanned: 389
  • +
  • Total TRACES Found: 988
  • +
  • Requirements Covered: +
      +
    • User Requirements (UR): 76
    • +
    • Integration Requirements (IR): 25
    • +
    • Development Requirements (DR): 189
    • +
    • Jellyfin API Requirements (JA): 35
    • +
    +
  • +
+

Requirements by Type

+

User Requirements (UR)

+
UR-002, UR-003, UR-004, UR-005, UR-006, UR-007, UR-008, UR-009, UR-010, UR-011, UR-012, UR-013, UR-014, UR-015, UR-016, UR-017, UR-018, UR-019, UR-020, UR-021, UR-022, UR-023, UR-024, UR-025, UR-026, UR-027, UR-028, UR-029, UR-030, UR-031, UR-032, UR-033, UR-034, UR-035, UR-036, UR-037, UR-038, UR-039, UR-040, UR-041, UR-042, UR-043, UR-044, UR-045, UR-046, UR-047, UR-048, UR-049, UR-050, UR-051, UR-052, UR-053, UR-054, UR-055, UR-056, UR-057, UR-058, UR-059, UR-060, UR-061, UR-062, UR-063, UR-064, UR-065, UR-066, UR-067, UR-068, UR-069, UR-071, UR-072, UR-073, UR-074, UR-075, UR-076, UR-077, UR-078
+
+

Integration Requirements (IR)

+
IR-003, IR-004, IR-006, IR-008, IR-009, IR-010, IR-011, IR-012, IR-013, IR-014, IR-015, IR-016, IR-018, IR-019, IR-020, IR-021, IR-022, IR-023, IR-024, IR-025, IR-026, IR-027, IR-028, IR-030, IR-031
+
+

Development Requirements (DR)

+
DR-001, DR-002, DR-003, DR-004, DR-005, DR-006, DR-007, DR-009, DR-010, DR-011, DR-012, DR-013, DR-014, DR-015, DR-016, DR-017, DR-018, DR-020, DR-021, DR-022, DR-023, DR-024, DR-025, DR-026, DR-027, DR-028, DR-029, DR-030, DR-032, DR-033, DR-034, DR-035, DR-036, DR-037, DR-038, DR-039, DR-040, DR-041, DR-042, DR-043, DR-044, DR-045, DR-047, DR-048, DR-049, DR-050, DR-051, DR-052, DR-053, DR-054, DR-055, DR-056, DR-057, DR-058, DR-059, DR-060, DR-061, DR-062, DR-063, DR-064, DR-065, DR-066, DR-067, DR-068, DR-069, DR-070, DR-074, DR-075, DR-076, DR-077, DR-078, DR-079, DR-080, DR-081, DR-082, DR-083, DR-084, DR-085, DR-086, DR-087, DR-088, DR-089, DR-090, DR-091, DR-092, DR-093, DR-095, DR-096, DR-097, DR-098, DR-099, DR-100, DR-101, DR-102, DR-103, DR-104, DR-105, DR-106, DR-107, DR-108, DR-109, DR-110, DR-111, DR-112, DR-113, DR-114, DR-115, DR-116, DR-117, DR-118, DR-119, DR-120, DR-123, DR-126, DR-127, DR-128, DR-129, DR-130, DR-131, DR-132, DR-133, DR-134, DR-135, DR-136, DR-137, DR-140, DR-141, DR-142, DR-143, DR-147, DR-148, DR-149, DR-150, DR-151, DR-152, DR-153, DR-154, DR-155, DR-156, DR-157, DR-158, DR-159, DR-160, DR-162, DR-167, DR-168, DR-169, DR-170, DR-171, DR-173, DR-174, DR-175, DR-176, DR-177, DR-178, DR-179, DR-180, DR-181, DR-182, DR-183, DR-185, DR-186, DR-188, DR-189, DR-193, DR-195, DR-196, DR-197, DR-198, DR-201, DR-203, DR-204, DR-205, DR-206, DR-207, DR-209, DR-210, DR-211, DR-212, DR-213, DR-215, DR-216, DR-217, DR-218, DR-219, DR-220, DR-221, DR-223, DR-224
+
+

Jellyfin API Requirements (JA)

+
JA-001, JA-002, JA-003, JA-004, JA-005, JA-006, JA-007, JA-008, JA-009, JA-010, JA-011, JA-012, JA-013, JA-014, JA-015, JA-016, JA-017, JA-018, JA-019, JA-020, JA-021, JA-022, JA-023, JA-024, JA-025, JA-026, JA-028, JA-029, JA-030, JA-031, JA-032, JA-033, JA-034, JA-035, JA-036
+
+

Detailed Mapping

+

IR-003

+

Locations: 2 file(s)

+ +

IR-004

+

Locations: 1 file(s)

+ +

IR-006

+

Locations: 4 file(s)

+ +

IR-008

+

Locations: 1 file(s)

+ +

IR-009

+

Locations: 6 file(s)

+ +

IR-010

+

Locations: 3 file(s)

+ +

IR-011

+

Locations: 1 file(s)

+ +

IR-012

+

Locations: 2 file(s)

+ +

IR-013

+

Locations: 3 file(s)

+ +

IR-014

+

Locations: 10 file(s)

+ +

IR-015

+

Locations: 1 file(s)

+ +

IR-016

+

Locations: 18 file(s)

+ +

IR-018

+

Locations: 3 file(s)

+ +

IR-019

+

Locations: 5 file(s)

+ +

IR-020

+

Locations: 8 file(s)

+ +

IR-021

+

Locations: 1 file(s)

+ +

IR-022

+

Locations: 4 file(s)

+ +

IR-023

+

Locations: 1 file(s)

+ +

IR-024

+

Locations: 9 file(s)

+ +

IR-025

+

Locations: 2 file(s)

+ +

IR-026

+

Locations: 1 file(s)

+ +

IR-027

+

Locations: 1 file(s)

+ +

IR-028

+

Locations: 1 file(s)

+ +

IR-030

+

Locations: 2 file(s)

+ +

IR-031

+

Locations: 2 file(s)

+ +

DR-001

+

Locations: 17 file(s)

+ +

DR-002

+

Locations: 2 file(s)

+ +

DR-003

+

Locations: 2 file(s)

+ +

DR-004

+

Locations: 16 file(s)

+ +

DR-005

+

Locations: 13 file(s)

+ +

DR-006

+

Locations: 2 file(s)

+ +

DR-007

+

Locations: 9 file(s)

+ +

DR-009

+

Locations: 12 file(s)

+ +

DR-010

+

Locations: 1 file(s)

+ +

DR-011

+

Locations: 9 file(s)

+ +

DR-012

+

Locations: 7 file(s)

+ +

DR-013

+

Locations: 6 file(s)

+ +

DR-014

+

Locations: 4 file(s)

+ +

DR-015

+

Locations: 5 file(s)

+ +

DR-016

+

Locations: 3 file(s)

+ +

DR-017

+

Locations: 3 file(s)

+ +

DR-018

+

Locations: 1 file(s)

+ +

DR-020

+

Locations: 8 file(s)

+ +

DR-021

+

Locations: 4 file(s)

+ +

DR-022

+

Locations: 1 file(s)

+ +

DR-023

+

Locations: 15 file(s)

+ +

DR-024

+

Locations: 14 file(s)

+ +

DR-025

+

Locations: 1 file(s)

+ +

DR-026

+

Locations: 6 file(s)

+ +

DR-027

+

Locations: 2 file(s)

+ +

DR-028

+

Locations: 20 file(s)

+ +

DR-029

+

Locations: 10 file(s)

+ +

DR-030

+

Locations: 18 file(s)

+ +

DR-032

+

Locations: 1 file(s)

+ +

DR-033

+

Locations: 2 file(s)

+ +

DR-034

+

Locations: 2 file(s)

+ +

DR-035

+

Locations: 7 file(s)

+ +

DR-036

+

Locations: 12 file(s)

+ +

DR-037

+

Locations: 12 file(s)

+ +

DR-038

+

Locations: 13 file(s)

+ +

DR-039

+

Locations: 8 file(s)

+ +

DR-040

+

Locations: 3 file(s)

+ +

DR-041

+

Locations: 2 file(s)

+ +

DR-042

+

Locations: 4 file(s)

+ +

DR-043

+

Locations: 1 file(s)

+ +

DR-044

+

Locations: 1 file(s)

+ +

DR-045

+

Locations: 1 file(s)

+ +

DR-047

+

Locations: 15 file(s)

+ +

DR-048

+

Locations: 6 file(s)

+ +

DR-049

+

Locations: 1 file(s)

+ +

DR-050

+

Locations: 1 file(s)

+ +

DR-051

+

Locations: 3 file(s)

+ +

DR-052

+

Locations: 20 file(s)

+ +

DR-053

+

Locations: 1 file(s)

+ +

DR-054

+

Locations: 2 file(s)

+ +

DR-055

+

Locations: 2 file(s)

+ +

DR-056

+

Locations: 1 file(s)

+ +

DR-057

+

Locations: 1 file(s)

+ +

DR-058

+

Locations: 1 file(s)

+ +

DR-059

+

Locations: 2 file(s)

+ +

DR-060

+

Locations: 1 file(s)

+ +

DR-061

+

Locations: 2 file(s)

+ +

DR-062

+

Locations: 6 file(s)

+ +

DR-063

+

Locations: 17 file(s)

+ +

DR-064

+

Locations: 5 file(s)

+ +

DR-065

+

Locations: 2 file(s)

+ +

DR-066

+

Locations: 5 file(s)

+ +

DR-067

+

Locations: 3 file(s)

+ +

DR-068

+

Locations: 1 file(s)

+ +

DR-069

+

Locations: 1 file(s)

+ +

DR-070

+

Locations: 1 file(s)

+ +

DR-074

+

Locations: 17 file(s)

+ +

DR-075

+

Locations: 1 file(s)

+ +

DR-076

+

Locations: 2 file(s)

+ +

DR-077

+

Locations: 2 file(s)

+ +

DR-078

+

Locations: 7 file(s)

+ +

DR-079

+

Locations: 2 file(s)

+ +

DR-080

+

Locations: 2 file(s)

+ +

DR-081

+

Locations: 2 file(s)

+ +

DR-082

+

Locations: 21 file(s)

+ +

DR-083

+

Locations: 19 file(s)

+ +

DR-084

+

Locations: 2 file(s)

+ +

DR-085

+

Locations: 15 file(s)

+ +

DR-086

+

Locations: 1 file(s)

+ +

DR-087

+

Locations: 3 file(s)

+ +

DR-088

+

Locations: 2 file(s)

+ +

DR-089

+

Locations: 4 file(s)

+ +

DR-090

+

Locations: 1 file(s)

+ +

DR-091

+

Locations: 2 file(s)

+ +

DR-092

+

Locations: 2 file(s)

+ +

DR-093

+

Locations: 9 file(s)

+ +

DR-095

+

Locations: 1 file(s)

+ +

DR-096

+

Locations: 1 file(s)

+ +

DR-097

+

Locations: 9 file(s)

+ +

DR-098

+

Locations: 4 file(s)

+ +

DR-099

+

Locations: 2 file(s)

+ +

DR-100

+

Locations: 2 file(s)

+ +

DR-101

+

Locations: 8 file(s)

+ +

DR-102

+

Locations: 7 file(s)

+ +

DR-103

+

Locations: 6 file(s)

+ +

DR-104

+

Locations: 1 file(s)

+ +

DR-105

+

Locations: 10 file(s)

+ +

DR-106

+

Locations: 7 file(s)

+ +

DR-107

+

Locations: 2 file(s)

+ +

DR-108

+

Locations: 5 file(s)

+ +

DR-109

+

Locations: 6 file(s)

+ +

DR-110

+

Locations: 8 file(s)

+ +

DR-111

+

Locations: 5 file(s)

+ +

DR-112

+

Locations: 6 file(s)

+ +

DR-113

+

Locations: 4 file(s)

+ +

DR-114

+

Locations: 2 file(s)

+ +

DR-115

+

Locations: 16 file(s)

+ +

DR-116

+

Locations: 11 file(s)

+ +

DR-117

+

Locations: 8 file(s)

+ +

DR-118

+

Locations: 5 file(s)

+ +

DR-119

+

Locations: 15 file(s)

+ +

DR-120

+

Locations: 19 file(s)

+ +

DR-123

+

Locations: 8 file(s)

+ +

DR-126

+

Locations: 2 file(s)

+ +

DR-127

+

Locations: 6 file(s)

+ +

DR-128

+

Locations: 3 file(s)

+ +

DR-129

+

Locations: 9 file(s)

+ +

DR-130

+

Locations: 7 file(s)

+ +

DR-131

+

Locations: 16 file(s)

+ +

DR-132

+

Locations: 12 file(s)

+ +

DR-133

+

Locations: 2 file(s)

+ +

DR-134

+

Locations: 2 file(s)

+ +

DR-135

+

Locations: 5 file(s)

+ +

DR-136

+

Locations: 3 file(s)

+ +

DR-137

+

Locations: 18 file(s)

+ +

DR-140

+

Locations: 3 file(s)

+ +

DR-141

+

Locations: 2 file(s)

+ +

DR-142

+

Locations: 11 file(s)

+ +

DR-143

+

Locations: 11 file(s)

+ +

DR-147

+

Locations: 5 file(s)

+ +

DR-148

+

Locations: 1 file(s)

+ +

DR-149

+

Locations: 3 file(s)

+ +

DR-150

+

Locations: 4 file(s)

+ +

DR-151

+

Locations: 1 file(s)

+ +

DR-152

+

Locations: 2 file(s)

+ +

DR-153

+

Locations: 1 file(s)

+ +

DR-154

+

Locations: 7 file(s)

+ +

DR-155

+

Locations: 5 file(s)

+ +

DR-156

+

Locations: 2 file(s)

+ +

DR-157

+

Locations: 1 file(s)

+ +

DR-158

+

Locations: 6 file(s)

+ +

DR-159

+

Locations: 3 file(s)

+ +

DR-160

+

Locations: 1 file(s)

+ +

DR-162

+

Locations: 39 file(s)

+ +

DR-167

+

Locations: 2 file(s)

+ +

DR-168

+

Locations: 6 file(s)

+ +

DR-169

+

Locations: 2 file(s)

+ +

DR-170

+

Locations: 2 file(s)

+ +

DR-171

+

Locations: 9 file(s)

+ +

DR-173

+

Locations: 17 file(s)

+ +

DR-174

+

Locations: 8 file(s)

+ +

DR-175

+

Locations: 9 file(s)

+ +

DR-176

+

Locations: 24 file(s)

+ +

DR-177

+

Locations: 9 file(s)

+ +

DR-178

+

Locations: 13 file(s)

+ +

DR-179

+

Locations: 15 file(s)

+ +

DR-180

+

Locations: 6 file(s)

+ +

DR-181

+

Locations: 11 file(s)

+ +

DR-182

+

Locations: 4 file(s)

+ +

DR-183

+

Locations: 1 file(s)

+ +

DR-185

+

Locations: 1 file(s)

+ +

DR-186

+

Locations: 1 file(s)

+ +

DR-188

+

Locations: 2 file(s)

+ +

DR-189

+

Locations: 5 file(s)

+ +

DR-193

+

Locations: 2 file(s)

+ +

DR-195

+

Locations: 1 file(s)

+ +

DR-196

+

Locations: 3 file(s)

+ +

DR-197

+

Locations: 8 file(s)

+ +

DR-198

+

Locations: 3 file(s)

+ +

DR-201

+

Locations: 7 file(s)

+ +

DR-203

+

Locations: 2 file(s)

+ +

DR-204

+

Locations: 4 file(s)

+ +

DR-205

+

Locations: 1 file(s)

+ +

DR-206

+

Locations: 1 file(s)

+ +

DR-207

+

Locations: 2 file(s)

+ +

DR-209

+

Locations: 44 file(s)

+ +

DR-210

+

Locations: 8 file(s)

+ +

DR-211

+

Locations: 10 file(s)

+ +

DR-212

+

Locations: 9 file(s)

+ +

DR-213

+

Locations: 1 file(s)

+ +

DR-215

+

Locations: 1 file(s)

+ +

DR-216

+

Locations: 1 file(s)

+ +

DR-217

+

Locations: 5 file(s)

+ +

DR-218

+

Locations: 16 file(s)

+ +

DR-219

+

Locations: 2 file(s)

+ +

DR-220

+

Locations: 1 file(s)

+ +

DR-221

+

Locations: 2 file(s)

+ +

DR-223

+

Locations: 5 file(s)

+ +

DR-224

+

Locations: 5 file(s)

+ +

JA-001

+

Locations: 1 file(s)

+ +

JA-002

+

Locations: 2 file(s)

+ +

JA-003

+

Locations: 1 file(s)

+ +

JA-004

+

Locations: 3 file(s)

+ +

JA-005

+

Locations: 2 file(s)

+ +

JA-006

+

Locations: 2 file(s)

+ +

JA-007

+

Locations: 1 file(s)

+ +

JA-008

+

Locations: 8 file(s)

+ +

JA-009

+

Locations: 2 file(s)

+ +

JA-010

+

Locations: 2 file(s)

+ +

JA-011

+

Locations: 2 file(s)

+ +

JA-012

+

Locations: 2 file(s)

+ +

JA-013

+

Locations: 5 file(s)

+ +

JA-014

+

Locations: 4 file(s)

+ +

JA-015

+

Locations: 4 file(s)

+ +

JA-016

+

Locations: 2 file(s)

+ +

JA-017

+

Locations: 1 file(s)

+ +

JA-018

+

Locations: 1 file(s)

+ +

JA-019

+

Locations: 1 file(s)

+ +

JA-020

+

Locations: 1 file(s)

+ +

JA-021

+

Locations: 6 file(s)

+ +

JA-022

+

Locations: 3 file(s)

+ +

JA-023

+

Locations: 3 file(s)

+ +

JA-024

+

Locations: 2 file(s)

+ +

JA-025

+

Locations: 4 file(s)

+ +

JA-026

+

Locations: 3 file(s)

+ +

JA-028

+

Locations: 1 file(s)

+ +

JA-029

+

Locations: 3 file(s)

+ +

JA-030

+

Locations: 3 file(s)

+ +

JA-031

+

Locations: 3 file(s)

+ +

JA-032

+

Locations: 10 file(s)

+ +

JA-033

+

Locations: 7 file(s)

+ +

JA-034

+

Locations: 3 file(s)

+ +

JA-035

+

Locations: 2 file(s)

+ +

JA-036

+

Locations: 2 file(s)

+ +

UR-003

+

Locations: 46 file(s)

+ +

UR-004

+

Locations: 65 file(s)

+ +

UR-005

+

Locations: 103 file(s)

+ +

UR-008

+

Locations: 4 file(s)

+ +

UR-009

+

Locations: 14 file(s)

+ +

UR-018

+

Locations: 20 file(s)

+ +

UR-019

+

Locations: 18 file(s)

+ +

UR-020

+

Locations: 51 file(s)

+ +

UR-021

+

Locations: 13 file(s)

+ +

UR-023

+

Locations: 32 file(s)

+ +

UR-025

+

Locations: 68 file(s)

+ +

UR-026

+

Locations: 19 file(s)

+ +

UR-027

+

Locations: 23 file(s)

+ +

UR-034

+

Locations: 19 file(s)

+ +

UR-040

+

Locations: 75 file(s)

+ +

UR-041

+

Locations: 6 file(s)

+ +

UR-053

+

Locations: 17 file(s)

+ +

UR-055

+

Locations: 54 file(s)

+ +

UR-056

+

Locations: 17 file(s)

+ +

UR-062

+

Locations: 17 file(s)

+ +

UR-064

+

Locations: 7 file(s)

+ +

UT-010

+

Locations: 1 file(s)

+ +

UT-024

+

Locations: 1 file(s)

+ +

UT-052

+

Locations: 1 file(s)

+ +

UT-060

+

Locations: 4 file(s)

+ +

UT-061

+

Locations: 9 file(s)

+ +

UT-062

+

Locations: 1 file(s)

+ +

UT-066

+

Locations: 1 file(s)

+ +

UT-071

+

Locations: 1 file(s)

+ +

UT-085

+

Locations: 2 file(s)

+ +

UT-086

+

Locations: 2 file(s)

+ +

UT-087

+

Locations: 2 file(s)

+ +

UT-088

+

Locations: 1 file(s)

+ +

UT-089

+

Locations: 1 file(s)

+ +

UT-090

+

Locations: 1 file(s)

+ +

UT-091

+

Locations: 2 file(s)

+ +

UT-092

+

Locations: 3 file(s)

+ +

UT-093

+

Locations: 2 file(s)

+ +

UT-094

+

Locations: 1 file(s)

+ +

UT-095

+

Locations: 1 file(s)

+ +

UT-096

+

Locations: 1 file(s)

+ +

UT-097

+

Locations: 1 file(s)

+ +

UT-098

+

Locations: 1 file(s)

+ +

UT-105

+

Locations: 3 file(s)

+ +

UT-106

+

Locations: 3 file(s)

+ +

UT-117

+

Locations: 8 file(s)

+ +

UT-118

+

Locations: 1 file(s)

+ +

UT-119

+

Locations: 2 file(s)

+ +

UT-123

+

Locations: 2 file(s)

+ +

UT-124

+

Locations: 1 file(s)

+ +

UT-131

+

Locations: 1 file(s)

+ +

UT-132

+

Locations: 1 file(s)

+ +

UT-133

+

Locations: 1 file(s)

+ +

UT-134

+

Locations: 1 file(s)

+ +

UT-135

+

Locations: 1 file(s)

+ +

UT-136

+

Locations: 1 file(s)

+ +

UT-137

+

Locations: 1 file(s)

+ +

UT-138

+

Locations: 1 file(s)

+ +

UT-139

+

Locations: 1 file(s)

+ +

UT-149

+

Locations: 2 file(s)

+ +

UT-158

+

Locations: 1 file(s)

+ +

UT-159

+

Locations: 1 file(s)

+ +

UT-160

+

Locations: 1 file(s)

+ +

UT-181

+

Locations: 6 file(s)

+ +

UT-182

+

Locations: 5 file(s)

+ +

UR-067

+

Locations: 42 file(s)

+ +

IT-013

+

Locations: 4 file(s)

+ +

UT-186

+

Locations: 1 file(s)

+ +

UT-192

+

Locations: 1 file(s)

+ +

UT-211

+

Locations: 4 file(s)

+ +

UR-006

+

Locations: 8 file(s)

+ +

UR-013

+

Locations: 2 file(s)

+ +

UR-015

+

Locations: 13 file(s)

+ +

UR-016

+

Locations: 2 file(s)

+ +

UR-022

+

Locations: 1 file(s)

+ +

UR-024

+

Locations: 3 file(s)

+ +

UR-043

+

Locations: 2 file(s)

+ +

UR-049

+

Locations: 22 file(s)

+ +

UR-050

+

Locations: 9 file(s)

+ +

UR-071

+

Locations: 64 file(s)

+ +

UR-073

+

Locations: 6 file(s)

+ +

UR-074

+

Locations: 48 file(s)

+ +

IT-003

+

Locations: 1 file(s)

+ +

IT-004

+

Locations: 1 file(s)

+ +

IT-016

+

Locations: 1 file(s)

+ +

UT-003

+

Locations: 1 file(s)

+ +

UT-004

+

Locations: 1 file(s)

+ +

UT-005

+

Locations: 1 file(s)

+ +

UT-012

+

Locations: 1 file(s)

+ +

UT-026

+

Locations: 2 file(s)

+ +

UT-027

+

Locations: 1 file(s)

+ +

UT-028

+

Locations: 1 file(s)

+ +

UT-029

+

Locations: 1 file(s)

+ +

UT-030

+

Locations: 1 file(s)

+ +

UT-031

+

Locations: 1 file(s)

+ +

UT-032

+

Locations: 1 file(s)

+ +

UT-033

+

Locations: 1 file(s)

+ +

UT-051

+

Locations: 1 file(s)

+ +

UT-059

+

Locations: 3 file(s)

+ +

UT-067

+

Locations: 1 file(s)

+ +

UT-072

+

Locations: 1 file(s)

+ +

UT-073

+

Locations: 1 file(s)

+ +

UT-074

+

Locations: 1 file(s)

+ +

UT-075

+

Locations: 1 file(s)

+ +

UT-076

+

Locations: 1 file(s)

+ +

UT-077

+

Locations: 1 file(s)

+ +

UT-078

+

Locations: 1 file(s)

+ +

UT-079

+

Locations: 1 file(s)

+ +

UT-080

+

Locations: 1 file(s)

+ +

UT-081

+

Locations: 1 file(s)

+ +

UT-082

+

Locations: 1 file(s)

+ +

UT-099

+

Locations: 3 file(s)

+ +

UT-100

+

Locations: 6 file(s)

+ +

UT-101

+

Locations: 4 file(s)

+ +

UT-102

+

Locations: 2 file(s)

+ +

UT-104

+

Locations: 7 file(s)

+ +

UT-107

+

Locations: 6 file(s)

+ +

UT-108

+

Locations: 2 file(s)

+ +

UT-109

+

Locations: 1 file(s)

+ +

UT-110

+

Locations: 1 file(s)

+ +

UT-111

+

Locations: 3 file(s)

+ +

UT-112

+

Locations: 2 file(s)

+ +

UT-113

+

Locations: 2 file(s)

+ +

UT-114

+

Locations: 2 file(s)

+ +

UT-116

+

Locations: 4 file(s)

+ +

UT-120

+

Locations: 2 file(s)

+ +

UT-130

+

Locations: 3 file(s)

+ +

UT-140

+

Locations: 1 file(s)

+ +

UT-151

+

Locations: 7 file(s)

+ +

UT-170

+

Locations: 8 file(s)

+ +

UR-002

+

Locations: 22 file(s)

+ +

UR-007

+

Locations: 19 file(s)

+ +

UR-011

+

Locations: 11 file(s)

+ +

UR-029

+

Locations: 5 file(s)

+ +

UR-030

+

Locations: 2 file(s)

+ +

UR-031

+

Locations: 2 file(s)

+ +

UR-032

+

Locations: 7 file(s)

+ +

UR-035

+

Locations: 7 file(s)

+ +

UR-036

+

Locations: 5 file(s)

+ +

UR-037

+

Locations: 4 file(s)

+ +

UR-038

+

Locations: 1 file(s)

+ +

UR-042

+

Locations: 2 file(s)

+ +

UR-046

+

Locations: 1 file(s)

+ +

UR-047

+

Locations: 1 file(s)

+ +

UR-048

+

Locations: 6 file(s)

+ +

UR-051

+

Locations: 2 file(s)

+ +

UR-052

+

Locations: 23 file(s)

+ +

UR-057

+

Locations: 1 file(s)

+ +

UR-058

+

Locations: 11 file(s)

+ +

UR-063

+

Locations: 10 file(s)

+ +

UR-065

+

Locations: 25 file(s)

+ +

UR-068

+

Locations: 14 file(s)

+ +

UR-069

+

Locations: 24 file(s)

+ +

UR-075

+

Locations: 12 file(s)

+ +

UR-076

+

Locations: 44 file(s)

+ +

UR-078

+

Locations: 15 file(s)

+ +

UT-145

+

Locations: 5 file(s)

+ +

UT-146

+

Locations: 3 file(s)

+ +

UT-167

+

Locations: 1 file(s)

+ +

UT-168

+

Locations: 24 file(s)

+ +

UR-028

+

Locations: 2 file(s)

+ +

UR-054

+

Locations: 7 file(s)

+ +

UR-066

+

Locations: 11 file(s)

+ +

UT-174

+

Locations: 2 file(s)

+ +

UR-061

+

Locations: 4 file(s)

+ +

UT-143

+

Locations: 3 file(s)

+ +

UT-144

+

Locations: 3 file(s)

+ +

UT-147

+

Locations: 8 file(s)

+ +

UT-175

+

Locations: 1 file(s)

+ +

UT-184

+

Locations: 2 file(s)

+ +

UT-185

+

Locations: 2 file(s)

+ +

UT-187

+

Locations: 1 file(s)

+ +

UT-188

+

Locations: 5 file(s)

+ +

UT-189

+

Locations: 1 file(s)

+ +

UR-017

+

Locations: 11 file(s)

+ +

UR-039

+

Locations: 1 file(s)

+ +

UT-207

+

Locations: 2 file(s)

+ +

UR-010

+

Locations: 16 file(s)

+ +

UT-183

+

Locations: 2 file(s)

+ +

UR-012

+

Locations: 21 file(s)

+ +

UR-059

+

Locations: 11 file(s)

+ +

UT-208

+

Locations: 1 file(s)

+ +

UR-060

+

Locations: 7 file(s)

+ +

UR-072

+

Locations: 1 file(s)

+ +

UR-077

+

Locations: 4 file(s)

+ +

UT-201

+

Locations: 2 file(s)

+ +

UT-206

+

Locations: 9 file(s)

+ +

UT-209

+

Locations: 1 file(s)

+ +

UR-033

+

Locations: 12 file(s)

+ +

UR-045

+

Locations: 1 file(s)

+ +

IT-017

+

Locations: 1 file(s)

+ +

UT-014

+

Locations: 2 file(s)

+ +

UT-015

+

Locations: 2 file(s)

+ +

UT-016

+

Locations: 2 file(s)

+ +

UT-017

+

Locations: 2 file(s)

+ +

UT-018

+

Locations: 2 file(s)

+ +

UT-019

+

Locations: 2 file(s)

+ +

UT-020

+

Locations: 2 file(s)

+ +

UT-021

+

Locations: 2 file(s)

+ +

UT-022

+

Locations: 2 file(s)

+ +

UT-023

+

Locations: 2 file(s)

+ +

UT-025

+

Locations: 2 file(s)

+ +

UT-042

+

Locations: 1 file(s)

+ +

UT-043

+

Locations: 1 file(s)

+ +

UT-044

+

Locations: 1 file(s)

+ +

UT-083

+

Locations: 1 file(s)

+ +

UT-084

+

Locations: 1 file(s)

+ +

UT-103

+

Locations: 6 file(s)

+ +

UT-115

+

Locations: 2 file(s)

+ +

UT-121

+

Locations: 2 file(s)

+ +

UT-122

+

Locations: 11 file(s)

+ +

UT-125

+

Locations: 3 file(s)

+ +

UT-126

+

Locations: 2 file(s)

+ +

UT-127

+

Locations: 11 file(s)

+ +

UT-141

+

Locations: 2 file(s)

+ +

UT-142

+

Locations: 1 file(s)

+ +

UT-148

+

Locations: 3 file(s)

+ +

UT-150

+

Locations: 1 file(s)

+ +

UT-152

+

Locations: 4 file(s)

+ +

UT-154

+

Locations: 2 file(s)

+ +

UT-155

+

Locations: 3 file(s)

+ +

UT-156

+

Locations: 4 file(s)

+ +

UT-157

+

Locations: 3 file(s)

+ +

UT-161

+

Locations: 4 file(s)

+ +

UT-162

+

Locations: 1 file(s)

+ +

UT-163

+

Locations: 1 file(s)

+ +

UT-164

+

Locations: 1 file(s)

+ +

UT-166

+

Locations: 7 file(s)

+ +

UT-171

+

Locations: 2 file(s)

+ +

UT-172

+

Locations: 3 file(s)

+ +

UT-173

+

Locations: 5 file(s)

+ +

UT-176

+

Locations: 3 file(s)

+ +

UT-177

+

Locations: 6 file(s)

+ +

UT-178

+

Locations: 3 file(s)

+ +

UT-179

+

Locations: 4 file(s)

+ +

UT-180

+

Locations: 3 file(s)

+ +

UT-190

+

Locations: 2 file(s)

+ +

UT-191

+

Locations: 2 file(s)

+ +

UT-193

+

Locations: 1 file(s)

+ +

UT-194

+

Locations: 1 file(s)

+ +

UT-195

+

Locations: 1 file(s)

+ +

UT-196

+

Locations: 1 file(s)

+ +

UT-197

+

Locations: 1 file(s)

+ +

UT-198

+

Locations: 1 file(s)

+ +

UT-200

+

Locations: 2 file(s)

+ +

UT-202

+

Locations: 2 file(s)

+ +

UT-203

+

Locations: 10 file(s)

+ +

UT-205

+

Locations: 10 file(s)

+ +

UR-014

+

Locations: 1 file(s)

+ +

UR-044

+

Locations: 1 file(s)

+ +

UT-204

+

Locations: 8 file(s)

+ +

UT-210

+

Locations: 1 file(s)

+ +

Requirement Traceability CI/CD Pipeline

+

This document explains the automated requirement traceability validation system for JellyTau.

+

Overview

+

The CI/CD pipeline automatically validates that code changes are properly traced to requirements. This ensures:

+
    +
  • ✅ Requirements are implemented with clear traceability
  • +
  • ✅ No requirement coverage regressions
  • +
  • ✅ Code changes are linked to specific requirements
  • +
  • ✅ Quality metrics are tracked over time
  • +
+

Gitea Actions Workflows

+

Traceability validation lives in .gitea/workflows/traceability-check.yml:

+
    +
  • ✅ Automatic trace extraction
  • +
  • ✅ Coverage validation against minimum threshold (88%, ratcheted)
  • +
  • ✅ Modified file checking
  • +
  • ✅ Artifact preservation
  • +
  • ✅ Summary reports
  • +
+

Runs on: Every push and pull request to master/main/develop

+

A second workflow, traceability.yml, previously duplicated this one as a +"GitHub-compatible alternative". It was removed: CI here is Gitea Actions, and +its only unique step (PR comments via actions/github-script) depended on the +GitHub REST client, which Gitea does not provide. To add PR comments, post to +Gitea's /api/v1/repos/{owner}/{repo}/issues/{index}/comments from +traceability-check.yml rather than reviving the old file.

+

What Gets Validated

+

1. Trace Extraction

+
bun run traces:json > traces-report.json
+
+

Extracts all TRACES comments from:

+
    +
  • TypeScript files (src/**/*.ts)
  • +
  • Svelte components (src/**/*.svelte)
  • +
  • Rust code (src-tauri/src/**/*.rs)
  • +
  • Test files
  • +
+

2. Coverage Thresholds

+

The workflow checks:

+
    +
  • Minimum overall coverage: 88% (MIN_THRESHOLD)
  • +
+

Denominators are derived from docs/requirements.md at run time — they are +never hardcoded here or in the workflow. Run bun run traces:coverage for the +current per-type breakdown; any number written into this document is a snapshot +that will drift.

+
+

Why this matters. The workflow used to divide by frozen literals +(UR/39, IR/24, DR/48, JA/3, total 114) while requirements.md had grown past +200. It reported 158% coverage, so the 50% threshold was unreachable and +the job could not fail regardless of how far coverage dropped. See +The fix derives the denominators from requirements.md at run time.

+
+

Coverage is the intersection of traced and defined IDs: an ID that appears in +a TRACES: comment but is not defined in requirements.md is reported as +orphaned and does not count toward coverage. UT/IT test identifiers are a +separate taxonomy and are excluded entirely.

+

The workflow fails and blocks merge if coverage drops below the threshold — +or if it computes above 100%, which can only mean the gate is miscounting.

+

Ratchet policy

+

MIN_THRESHOLD only ever goes up. It is deliberately set a few points below +the coverage actually achieved (88 against a real ~90%), so a genuine regression +trips it. It previously sat at 50 while true coverage was 86%: nearly half the +matrix could have rotted before CI objected. It was ratcheted 50 → 82 when that +was found, and 82 → 88 once coverage had held above 88% for several releases.

+

When coverage rises durably, raise the threshold to just under the new figure. +Never lower it to make a red build pass — add the missing TRACES comments +instead. The same number lives in MIN_COVERAGE_PERCENT in +scripts/extract-traces.ts (so bun run traces:coverage gates locally on the +same bar); scripts/extract-traces.test.ts fails if the two drift apart.

+

2b. Dangling requirement IDs

+
bun run traces:validate
+
+

Every ID named by a TRACES: comment must be defined as a table row in +docs/requirements.md. The extractor used to accept any well-formed ID +silently, so a typo or a rename that missed a call site passed unnoticed — +DR-189 and UT-188 were referenced from three source files, defined nowhere, +for months.

+

This check spans all six ID types (UR/IR/DR/JA/UT/IT), unlike the coverage +orphaned list above, which considers only the four requirement types so that +UT/IT noise cannot bury a real typo in the ratio's reporting. The workflow step +fails the build on any dangling ID and prints each offender with the files +that reference it.

+

3. Modified File Checking

+

On pull requests, the workflow:

+
    +
  1. Detects all changed TypeScript/Svelte/Rust files
  2. +
  3. Warns if new/modified files lack TRACES comments
  4. +
  5. Suggests the TRACES format for missing comments
  6. +
+

How to Add Traces to New Code

+

When you add new code or modify existing code, include TRACES comments:

+

TypeScript/Svelte Example

+
// TRACES: UR-005, UR-026 | DR-029
+export function handlePlayback() {
+  // Implementation...
+}
+
+

Rust Example

+
#![allow(unused)]
+fn main() {
+/// TRACES: UR-005 | DR-001
+pub fn player_state_changed(state: PlayerState) {
+    // Implementation...
+}
+}
+

Test Example

+
#![allow(unused)]
+fn main() {
+// TRACES: UR-005 | DR-001 | UT-026, UT-027
+#[cfg(test)]
+mod tests {
+    // Tests...
+}
+}
+

TRACES Format

+
TRACES: [UR-###, ...] | [IR-###, ...] | [DR-###, ...] | [JA-###, ...]
+
+
    +
  • UR-### - User Requirements (features users see)
  • +
  • IR-### - Integration Requirements (API/platform integration)
  • +
  • DR-### - Development Requirements (internal architecture)
  • +
  • JA-### - Jellyfin API Requirements (Jellyfin API usage)
  • +
+

Examples:

+
    +
  • // TRACES: UR-005 - Single requirement
  • +
  • // TRACES: UR-005, UR-026 - Multiple of same type
  • +
  • // TRACES: UR-005 | DR-029 - Multiple types
  • +
  • // TRACES: UR-005, UR-026 | DR-001, DR-029 | UT-001 - Complex
  • +
+

Workflow Behavior

+

On Push to Main Branch

+
    +
  1. ✅ Extracts all traces from code
  2. +
  3. ✅ Validates coverage is >= 88%
  4. +
  5. ✅ Generates full traceability report
  6. +
  7. ✅ Saves report as artifact
  8. +
+

On Pull Request

+
    +
  1. ✅ Extracts all traces
  2. +
  3. ✅ Validates coverage >= 88%
  4. +
  5. ✅ Checks modified files for TRACES
  6. +
  7. ✅ Warns if new code lacks TRACES
  8. +
  9. ✅ Suggests proper format
  10. +
  11. ✅ Generates report artifact
  12. +
+

Failure Scenarios

+

The workflow fails (blocks merge) if:

+
    +
  • Coverage drops below 88%
  • +
  • A TRACES: comment names an ID docs/requirements.md does not define
  • +
  • JSON extraction fails
  • +
  • Invalid trace format
  • +
+

The workflow warns (but doesn't block) if:

+
    +
  • New files lack TRACES comments
  • +
  • Coverage drops (but still above threshold)
  • +
+

Viewing Reports

+

In Gitea Actions UI

+
    +
  1. Go to Actions tab
  2. +
  3. Click the Traceability Validation workflow run
  4. +
  5. Download traceability-reports artifact
  6. +
  7. View: +
      +
    • traces-report.json - Raw trace data
    • +
    • docs/traceability.md - Formatted report
    • +
    +
  8. +
+

Locally

+
# Extract current traces
+bun run traces:json | jq '.byType'
+
+# Generate full report
+bun run traces:markdown
+cat docs/traceability.md
+
+

Coverage Goals

+

Current Status

+

Run bun run traces:coverage — it prints the live figure and exits non-zero +below threshold. Numbers are deliberately not pinned here; the previous snapshot +in this section (51%, 56/114) was stale by roughly 100 requirements and was what +made the broken CI arithmetic look plausible for so long.

+

As of August 2026 overall coverage is ~90%.

+

Targets

+
    +
  • Short term (Sprint): Maintain ≥88% overall (the current ratchet)
  • +
  • Medium term (Month): Hold above 90% and ratchet the gate to match
  • +
  • Long term (Release): Reach 95% coverage with focus on: +
      +
    • IR requirements (API clients)
    • +
    • JA requirements (Jellyfin API endpoints)
    • +
    • Remaining UR/DR requirements
    • +
    +
  • +
+

Improving Coverage

+

For Missing User Requirements (UR)

+
    +
  1. Review README.md for unimplemented features
  2. +
  3. Add TRACES to code that implements them
  4. +
  5. Focus on high-priority features (High/Medium priority)
  6. +
+

For Missing Integration Requirements (IR)

+
    +
  1. Add TRACES to Jellyfin API client methods
  2. +
  3. Add TRACES to platform-specific backends (Android/Linux)
  4. +
  5. Link to corresponding Jellyfin API endpoints
  6. +
+

For Missing Development Requirements (DR)

+
    +
  1. Add TRACES to UI components in src/lib/components/
  2. +
  3. Add TRACES to composables in src/lib/composables/
  4. +
  5. Add TRACES to player backend in src-tauri/src/player/
  6. +
+

For Jellyfin API Requirements (JA)

+
    +
  1. Add TRACES to Jellyfin API wrapper methods
  2. +
  3. Document which endpoints map to which requirements
  4. +
  5. Link to Jellyfin API documentation
  6. +
+

Example PR Checklist

+

When submitting a pull request:

+
    +
  • +All new code has TRACES comments linking to requirements
  • +
  • +TRACES format is correct: // TRACES: UR-001 | DR-002
  • +
  • +Workflow passes (coverage ≥ 88%)
  • +
  • +No coverage regressions
  • +
  • +Artifact traceability report was generated
  • +
+

Troubleshooting

+

"Coverage below minimum threshold"

+

Problem: Workflow fails with coverage < 88%

+

Solution:

+
    +
  1. Run bun run traces:json locally
  2. +
  3. Check which requirements are traced
  4. +
  5. Add TRACES to untraced code sections
  6. +
  7. Re-run extraction to verify
  8. +
+

"New files without TRACES"

+

Problem: Workflow warns about new files lacking TRACES

+

Solution:

+
    +
  1. Add TRACES comments to all new code
  2. +
  3. Format: // TRACES: UR-001 | DR-002
  4. +
  5. Map code to specific requirements from README.md
  6. +
  7. Re-push
  8. +
+

"Invalid JSON format"

+

Problem: Trace extraction produces invalid JSON

+

Solution:

+
    +
  1. Check for malformed TRACES comments
  2. +
  3. Run locally: bun run traces:json
  4. +
  5. Look for parsing errors
  6. +
  7. Fix and retry
  8. +
+

Integration with Development

+

Before Committing

+
# Check your traces
+bun run traces:json | jq '.byType'
+
+# Regenerate report
+bun run traces:markdown
+
+# Verify traces syntax
+grep "TRACES:" src/**/*.ts src/**/*.rs
+
+

In Your IDE

+

Add a file watcher to regenerate traces on save:

+
{
+  "fileWatcher.watchPatterns": [
+    "src/**/*.ts",
+    "src/**/*.svelte",
+    "src-tauri/src/**/*.rs"
+  ],
+  "fileWatcher.command": "bun run traces:markdown"
+}
+
+

Git Hooks

+

Add a pre-push hook to validate traces:

+
#!/bin/bash
+# .git/hooks/pre-push
+bun run traces:json > /dev/null
+if [ $? -ne 0 ]; then
+  echo "❌ Invalid TRACES format"
+  exit 1
+fi
+
+

References

+ +

Support

+

For issues or questions:

+
    +
  1. Check this document
  2. +
  3. Review example traces in src/lib/stores/
  4. +
  5. Check existing TRACES comments for format
  6. +
  7. Review workflow logs in Gitea Actions
  8. +
+
+

Last Updated: 2026-02-13

+

TRACES Quick Reference Guide

+

What are TRACES?

+

TRACES are requirement identifiers embedded in code comments to track which requirements are implemented where.

+

Format: // TRACES: UR-001, UR-002 | DR-003

+

Quick Examples

+

TypeScript

+
// TRACES: UR-005, UR-026 | DR-029
+export function handlePlayback() { }
+
+/**
+ * Resume playback from saved position
+ * TRACES: UR-019 | DR-022
+ */
+export async function resumePlayback(itemId: string) { }
+
+

Svelte

+
<!-- TRACES: UR-007, UR-008 | DR-007 -->
+<script>
+  export let items = [];
+</script>
+
+

Rust

+
#![allow(unused)]
+fn main() {
+/// TRACES: UR-005 | DR-001
+pub enum PlayerState { ... }
+
+#[test]
+fn test_queue_next() {
+    // TRACES: UR-005 | DR-005 | UT-003
+}
+}
+

Requirement Types

+
+ + + + + + +
TypeMeaningExample
URUser RequirementUR-005: Control media playback
IRIntegration RequirementIR-003: LibMPV integration
DRDevelopment RequirementDR-001: Player state machine
JAJellyfin API RequirementJA-007: Get playback info
UTUnit TestUT-001: Player state transitions
ITIntegration TestIT-003: Audio playback via libmpv
+
+

Where to Find Requirements

+
    +
  1. User Requirements (UR): requirements.md
  2. +
  3. Integration Requirements (IR): requirements.md
  4. +
  5. Development Requirements (DR): requirements.md
  6. +
  7. Jellyfin API (JA): requirements.md
  8. +
+

How to Add TRACES

+

Step 1: Find the Requirement

+

Look up the requirement in README.md or the traceability matrix.

+

Example: UR-005: Control media playback (pause, play, skip, scrub)

+

Step 2: Add Comment

+

Add TRACES comment at the top of the function/type/module:

+
// TRACES: UR-005
+export async function playMedia(itemId: string) {
+  // Implementation
+}
+
+

Step 3: Run Extraction

+

Verify the trace is captured:

+
bun run traces:json | jq '.requirements | keys | grep "UR-005"'
+
+

Common Patterns

+

Single Requirement

+
// TRACES: UR-005
+function handlePlay() { }
+
+

Multiple Requirements, Same Type

+
// TRACES: UR-005, UR-026, UR-019
+function handlePlaybackState() { }
+
+

Multiple Types

+
// TRACES: UR-005, UR-026 | DR-029
+function autoplayNextEpisode() { }
+
+

Test Coverage

+
// TRACES: UR-005 | UT-001
+#[test]
+fn test_player_state_transition() { }
+
+

Modules/Files

+
/**
+ * Player event handling
+ * TRACES: UR-005, UR-019, UR-023 | DR-001, DR-028
+ */
+
+

Validation

+

Check Your Changes

+
# View current coverage
+bun run traces:json | jq '.byType'
+
+# Generate full report
+bun run traces:markdown
+
+# Check specific requirement
+bun run traces:json | jq '.requirements."UR-005"'
+
+

Before Committing

+
    +
  1. Ensure all new code has TRACES
  2. +
  3. Format is correct: // TRACES: ...
  4. +
  5. Requirements exist in docs/requirements.mdbun run traces:validate
  6. +
  7. No typos in requirement IDs (same command catches them)
  8. +
+

CI/CD Validation

+

The workflow automatically checks:

+
    +
  • ✅ Coverage stays >= 88% (a ratchet — raise it, never lower it)
  • +
  • ✅ Every traced ID is defined in docs/requirements.md
  • +
  • ✅ New files have TRACES
  • +
  • ✅ JSON format is valid
  • +
  • ✅ Reports are generated
  • +
+

See traceability-ci.md for details.

+

Tips & Tricks

+ +
# Find all code tracing to UR-005
+bun run traces:json | jq '.requirements."UR-005"'
+
+# List all tests
+bun run traces:json | jq '.requirements | keys | map(select(startswith("UT")))'
+
+

Update Your Editor

+

VS Code:

+
{
+  "editor.wordBasedSuggestions": false,
+  "editor.suggest.custom": [
+    {
+      "name": "TRACES Format",
+      "insertText": "// TRACES: $1",
+      "insertTextRules": "InsertAsSnippet"
+    }
+  ]
+}
+
+

Find Untraced Code

+
# Files modified without TRACES
+git diff --name-only | xargs grep -L "TRACES:" | head -10
+
+

FAQ

+

Q: Do I need TRACES on every function? +A: Only for code that implements requirements. Internal helpers don't need TRACES.

+

Q: Can I use TRACES on multiple related functions? +A: Yes! Add at the file/module level or on individual functions.

+

Q: What if code doesn't relate to any requirement? +A: Leave it untraced. TRACES are for requirement-driven development.

+

Q: How often should I regenerate reports? +A: Automatically on push (CI/CD). Manually after changes: bun run traces:markdown

+

Q: Can I trace to requirements that aren't implemented yet? +A: Yes! TRACES show your implementation plan.

+

See Also

+ +
+

Quick Start:

+
    +
  1. Add // TRACES: UR-XXX to new code
  2. +
  3. Run bun run traces:markdown
  4. +
  5. Check docs/traceability.md
  6. +
  7. Submit PR - workflow validates automatically!
  8. +
+

JellyTau Software Architecture

+

This document describes the current architecture of JellyTau, a cross-platform Jellyfin client built with Tauri, SvelteKit, and Rust.

+

Last Updated: 2026-06-20

+

Architecture Overview

+

JellyTau uses a client-server architecture: business logic lives in a comprehensive Rust backend, while a UI-rich Svelte frontend handles presentation and interaction.

+

Architecture Principles

+
    +
  • Business Logic in Rust: Core logic — playback, repository, sync, downloads, connectivity — lives in Rust for performance, reliability, and type safety.
  • +
  • Presentation in Svelte: The frontend (~20.5k non-test lines) owns UI, layout, navigation, and interaction state and invokes Rust commands. It is intentionally UI-heavy, not a thin wrapper. Largest pieces: components + routes (~14.6k lines), stores (~3.4k), api/services/utils (~2.4k); VideoPlayer.svelte alone is ~1.6k lines.
  • +
  • Events + Polling hybrid: Rust emits events the frontend listens to, and the UI also polls status on short intervals in a few hot spots (e.g. queue status in library/+layout.svelte, playback progress in VideoPlayer.svelte).
  • +
  • Unified player boundary: UI components control playback only through the frontend facade src/lib/player/index.ts (playerController), never by calling commands.player* directly. Webview-rendered HTML5 video reports its state back into Rust via src/lib/player/html5Adapter.ts and the player_report_* commands, so the PlayerController stays the single source of truth in both native (MPV/ExoPlayer) and HTML5 modes (see 05-platform-backends.md).
  • +
  • Handle-Based Resources: UUID handles for stateful Rust objects.
  • +
  • Cache-First: Parallel queries with intelligent fallback.
  • +
  • Single source of truth for reachability: Server reachability is derived from the outcome of real repository traffic, not a side-channel poller. The OnlineRepository reports each server result to the ConnectivityMonitor (classified via RepoError), which applies a time-window debounce before declaring the server offline and recovers instantly on the first success. The standalone /System/Info/Public probe runs only while offline, as a recovery detector for idle sessions.
  • +
  • Poison-tolerant locking: Shared std::sync state is accessed via the MutexSafe/RwLockSafe helpers in utils/lock.rs, which recover a poisoned lock instead of cascading a panic across the player.
  • +
  • Graceful backend init: If a native player backend (MPV/ExoPlayer) fails to initialize, the app falls back to a no-op backend and emits a backend-init-failed event rather than crashing.
  • +
+
flowchart TB
+    subgraph Frontend["Svelte Frontend"]
+        subgraph Stores["Stores (Thin Wrappers)"]
+            auth["auth"]
+            player["player"]
+            queue["queue"]
+            library["library"]
+            connectivity["connectivity"]
+            playbackMode["playbackMode"]
+        end
+        subgraph Components
+            playerComp["player/"]
+            libraryComp["library/"]
+            Search["Search"]
+        end
+        subgraph Routes
+            routeLibrary["/library"]
+            routePlayer["/player"]
+            routeRoot["/"]
+        end
+        subgraph API["API Layer (Thin Client)"]
+            RepositoryClient["RepositoryClient<br/>(Handle-based)"]
+            JellyfinClient["JellyfinClient<br/>(Helper)"]
+        end
+    end
+
+    Frontend -->|"Tauri IPC (invoke)"| Backend
+
+    subgraph Backend["Rust Backend (Business Logic)"]
+        subgraph Commands["Tauri Commands (90+)"]
+            PlayerCmds["player.rs"]
+            RepoCmds["repository.rs (27)"]
+            PlaybackModeCmds["playback_mode.rs (5)"]
+            StorageCmds["storage.rs"]
+            ConnectivityCmds["connectivity.rs (7)"]
+        end
+
+        subgraph Core["Core Modules"]
+            MediaSessionManager["MediaSessionManager<br/>(Audio/Movie/TvShow/Idle)"]
+
+            PlayerController["PlayerController<br/>+ PlayerBackend<br/>+ QueueManager"]
+
+            Repository["Repository Layer<br/>HybridRepository (cache-first)<br/>OnlineRepository (HTTP)<br/>OfflineRepository (SQLite)"]
+
+            PlaybackModeManager["PlaybackModeManager<br/>(Local/Remote/Idle)"]
+
+            ConnectivityMonitor["ConnectivityMonitor<br/>(Adaptive polling)"]
+
+            HttpClient["HttpClient<br/>(Exponential backoff retry)"]
+        end
+
+        subgraph Storage["Storage Layer"]
+            DatabaseService["DatabaseService<br/>(Async trait)"]
+            SQLite["SQLite Database<br/>(13 tables)"]
+        end
+
+        Commands --> Core
+        Core --> Storage
+        Repository --> HttpClient
+        Repository --> DatabaseService
+        Repository -->|"reports server outcome<br/>(success / RepoError)"| ConnectivityMonitor
+    end
+
+
+

The Repository --> ConnectivityMonitor edge is the source of truth for the offline/online banner: every server request the user actually makes updates reachability. The monitor's own polling is now an offline-only recovery probe (see 07-connectivity.md).

+
+
+

Detailed Documentation

+

Each major subsystem is documented in its own file in this directory:

+
+ + + + + + + + + +
DocumentContents
01 - Rust BackendMedia session state machine, player state machine, playback mode, media items, queue manager, favorites (marking + browsing), player backend trait, player controller, playlist system, domain vocabulary owned by Rust (search scope, library exclusions, streaming quality ladder), background workers (catalog indexer, drains), Tauri commands
02 - Svelte FrontendStore structure, music library navigation, playback reporting, repository architecture, playback mode system, database service abstraction, component hierarchy, MiniPlayer, sleep timer, auto-play, navigation guard, playlist management UI, library mosaic, series/episode navigation, downloaded browse, safe-area insets, native-video store, logging
03 - Data FlowRepository query flow (cache-first), locally-indexed search, playback initiation, playback mode transfer, queue navigation, volume control
04 - Type Sync & ThreadingRust/TypeScript type synchronization, Tauri v2 IPC parameter naming convention, thread safety patterns
05 - Platform BackendsPlayer events system, HTML5 video adapter, MpvBackend (Linux), ExoPlayerBackend (Android) incl. audio settings parity, native video compositing, MediaSession & remote volume, album art caching, backend initialization
06 - Downloads & OfflineDownload manager, download worker, smart caching engine, one storage model (cache entries are downloads), offline catalog visibility, download/offline commands, player integration, frontend store, UI components
07 - ConnectivityHTTP client with retry logic, connectivity monitor, network resilience architecture
08 - Database DesignEntity relationships, all table definitions (servers, users, libraries, items, user_data, downloads, media_streams, sync_queue, thumbnails, playlists), key queries, data flow diagrams, storage estimates
09 - SecurityAuthentication token storage, secure storage module, network security, webview CSP + asset-protocol scope, path confinement and input binding, local data protection
+
+
+

File Structure Summary

+
src-tauri/src/
+├── lib.rs                    # Tauri app setup, state initialization
+├── commands/                 # Tauri command handlers (~245 #[tauri::command] fns)
+│   ├── mod.rs               # Command exports
+│   ├── player/              # Player commands: queue, remote, session, settings, timers
+│   ├── repository.rs        # Repository commands (items, search, favourites, disk usage)
+│   ├── catalog.rs           # Catalog sync + the background index pass
+│   ├── favorites.rs         # Offline favourite drain
+│   ├── library.rs           # Library listing + folder exclusions
+│   ├── playlist.rs          # Playlist commands
+│   ├── playback_mode.rs     # Local/remote transfer
+│   ├── playback_reporting.rs
+│   ├── connectivity.rs      # Connectivity commands
+│   ├── storage/             # Storage & database commands: people, series_prefs, thumbnails
+│   ├── download/            # Download commands: mod, pinning, smart_cache
+│   ├── offline.rs           # Offline commands
+│   ├── device.rs            # Device id / capabilities
+│   ├── sessions.rs          # Remote sessions
+│   ├── sync.rs              # Sync queue commands
+│   └── sync_drain.rs        # Background sync-queue drain
+├── repository/              # Repository pattern implementation
+│   ├── mod.rs               # MediaRepository trait, handle management
+│   ├── types.rs             # RepoError, Library, MediaItem, etc.
+│   ├── hybrid.rs            # HybridRepository with cache-first racing
+│   ├── online.rs            # OnlineRepository (HTTP API)
+│   └── offline.rs           # OfflineRepository (SQLite queries)
+├── playback_mode/           # Playback mode manager
+│   └── mod.rs               # PlaybackMode enum, transfer logic
+├── connectivity/            # Connectivity monitoring
+│   └── mod.rs               # ConnectivityMonitor, adaptive polling
+├── jellyfin/                # Jellyfin API client
+│   ├── mod.rs               # Module exports
+│   ├── http_client.rs       # HTTP client with retry logic
+│   └── client.rs            # JellyfinClient for API calls
+├── storage/                 # Database layer
+│   ├── mod.rs               # Database struct, migrations
+│   ├── db_service.rs        # DatabaseService trait (async wrapper)
+│   ├── schema.rs            # Table definitions
+│   └── queries/             # Query modules
+├── download/                # Download manager module
+│   ├── mod.rs               # DownloadManager, DownloadInfo, DownloadTask
+│   ├── worker.rs            # DownloadWorker, HTTP streaming, retry logic
+│   ├── events.rs            # DownloadEvent enum
+│   └── cache.rs             # SmartCache, CacheConfig, LRU eviction
+└── player/                  # Player subsystem
+    ├── mod.rs               # PlayerController
+    ├── session.rs           # MediaSessionManager, MediaSessionType
+    ├── state.rs             # PlayerState, PlayerEvent
+    ├── media.rs             # MediaItem, MediaSource, MediaType
+    ├── queue.rs             # QueueManager, RepeatMode
+    ├── backend.rs           # PlayerBackend trait, NullBackend
+    ├── events.rs            # PlayerStatusEvent, TauriEventEmitter
+    ├── mpv/                 # Linux MPV backend
+    │   ├── mod.rs           # MpvBackend implementation
+    │   └── event_loop.rs    # Dedicated thread for MPV operations
+    └── android/             # Android ExoPlayer backend
+        └── mod.rs           # ExoPlayerBackend + JNI bindings
+
+src/lib/
+├── api/                     # Thin API layer (~200 lines total)
+│   ├── types.ts             # TypeScript type definitions
+│   ├── repository-client.ts # RepositoryClient wrapper (~100 lines)
+│   ├── client.ts            # JellyfinClient (helper for streaming)
+│   └── sessions.ts          # SessionsApi (remote session control)
+├── player/                  # Unified player boundary (frontend)
+│   ├── index.ts             # playerController facade — the only write-side entry point for playback
+│   └── html5Adapter.ts      # Reports webview <video> DOM events back into Rust (player_report_*)
+├── services/
+│   ├── playerEvents.ts      # Tauri event listener for player events
+│   └── playbackReporting.ts # Thin wrapper (~50 lines)
+├── stores/                  # Thin reactive wrappers over Rust commands
+│   ├── index.ts             # Re-exports
+│   ├── auth.ts              # Auth store (calls Rust commands)
+│   ├── player.ts            # Player store
+│   ├── queue.ts             # Queue store
+│   ├── library.ts           # Library store
+│   ├── playbackMode.ts      # Playback mode store (~150 lines)
+│   ├── connectivity.ts      # Connectivity store (~250 lines)
+│   └── downloads.ts         # Downloads store with event listeners
+└── components/
+    ├── Search.svelte
+    ├── player/              # Player UI components
+    ├── playlist/            # Playlist modals (Create, AddTo)
+    ├── sessions/            # Remote session control UI
+    ├── downloads/           # Download UI components
+    └── library/             # Library UI components + PlaylistDetailView
+
+

Key Architecture Changes

+

What moved to Rust (~3,500 lines of business logic):

+
    +
  1. HTTP Client (338 lines) - Retry logic with exponential backoff
  2. +
  3. Connectivity Monitor (301 lines) - Reachability derived from real repository traffic, time-window debounce, offline-only recovery probe, event emission
  4. +
  5. Repository Pattern (1061 lines) - Cache-first hybrid with parallel racing
  6. +
  7. Database Service - Async wrapper preventing UI freezing
  8. +
  9. Playback Mode (303 lines) - Local/remote transfer coordination
  10. +
+

Svelte/TypeScript frontend (~20.5k non-test lines, plus ~9.6k test lines):

+
    +
  • Components + routes (~14.6k lines) — UI and presentation
  • +
  • Stores (~3.4k lines) — reactive state that invokes Rust commands and listens for events
  • +
  • api / services / utils (~2.4k lines) — typed clients, event listeners, conversion helpers
  • +
+

The frontend is genuinely UI-heavy; business decisions live in Rust, but the UI owns layout, navigation, and interaction state.

+

Total Commands: ~245 #[tauri::command] functions across 17 command modules +(~58k lines of Rust, ~37k non-test lines of TypeScript/Svelte).

+
+

Counts and line totals in this file are periodic snapshots, not gates — the +authority is the tree. Regenerate with +grep -rc '#\[tauri::command\]' src-tauri/src and wc -l.

+
+

Rust Backend Architecture

+

Location: src-tauri/src/

+

Media Session State Machine

+

Location: src-tauri/src/player/session.rs

+

The media session tracks the high-level playback context (what kind of media is being consumed) and persists beyond individual playback states. This enables persistent UI (miniplayer for audio) and proper transitions between content types.

+

Architecture Note: The session manager is a separate app-level state manager (not inside PlayerController), coordinated by the commands layer. This maintains clean separation of concerns.

+
stateDiagram-v2
+    [*] --> Idle
+
+    Idle --> AudioActive : play_queue(audio)
+    Idle --> MovieActive : play_item(movie)
+    Idle --> TvShowActive : play_item(episode)
+
+    state "Audio Session" as AudioSession {
+        [*] --> AudioActive
+        AudioActive --> AudioInactive : playback_ended
+        AudioInactive --> AudioActive : resume/play
+        AudioActive --> AudioActive : next/previous
+    }
+
+    state "Movie Session" as MovieSession {
+        [*] --> MovieActive
+        MovieActive --> MovieInactive : playback_ended
+        MovieInactive --> MovieActive : resume
+    }
+
+    state "TV Show Session" as TvShowSession {
+        [*] --> TvShowActive
+        TvShowActive --> TvShowInactive : playback_ended
+        TvShowInactive --> TvShowActive : next_episode/resume
+    }
+
+    AudioSession --> Idle : dismiss/clear_queue
+    AudioSession --> MovieSession : play_item(movie)
+    AudioSession --> TvShowSession : play_item(episode)
+
+    MovieSession --> Idle : dismiss/playback_complete
+    MovieSession --> AudioSession : play_queue(audio)
+
+    TvShowSession --> Idle : dismiss/series_complete
+    TvShowSession --> AudioSession : play_queue(audio)
+
+    note right of Idle
+        No active media session
+        Queue may exist but not playing
+        No miniplayer/video player shown
+    end note
+
+    note right of AudioSession
+        SHOW: Miniplayer (always visible)
+        - Active: Play/pause/skip controls enabled
+        - Inactive: Play button to resume queue
+        Persists until explicit dismiss
+    end note
+
+    note right of MovieSession
+        SHOW: Full video player
+        - Active: Video playing/paused
+        - Inactive: Resume dialog
+        Auto-dismiss when playback ends
+    end note
+
+    note right of TvShowSession
+        SHOW: Full video player + Next Episode UI
+        - Active: Video playing/paused
+        - Inactive: Next episode prompt
+        Auto-dismiss when series ends
+    end note
+
+

Session State Enum:

+
#![allow(unused)]
+fn main() {
+#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
+#[serde(tag = "type", rename_all = "snake_case")]
+pub enum MediaSessionType {
+    /// No active session - browsing library
+    Idle,
+
+    /// Audio playback session (music, audiobooks, podcasts)
+    /// Persists until explicitly dismissed
+    Audio {
+        /// Last/current track being played
+        last_item: Option<MediaItem>,
+        /// True = playing/paused, False = stopped/ended
+        is_active: bool,
+    },
+
+    /// Movie playback (single video, auto-dismiss on end)
+    Movie {
+        item: MediaItem,
+        is_active: bool,  // true = playing/paused, false = ended
+    },
+
+    /// TV show playback (supports next episode auto-advance)
+    TvShow {
+        item: MediaItem,
+        series_id: String,
+        is_active: bool,  // true = playing/paused, false = ended
+    },
+}
+}
+

State Transitions & Rules:

+
+ + + + + + + + + + + + +
From StateEventTo StateUI BehaviorNotes
Idleplay_queue(audio)Audio (active)Show miniplayerCreates audio session
Idleplay_item(movie)Movie (active)Show video playerCreates movie session
Idleplay_item(episode)TvShow (active)Show video playerCreates TV session
Audio (active)playback_endedAudio (inactive)Miniplayer stays visibleQueue preserved
Audio (inactive)play/resumeAudio (active)Miniplayer enabledResume from queue
Audio (active/inactive)dismissIdleHide miniplayerClear session
Audio (active/inactive)play_item(movie)Movie (active)Switch to video playerReplace session
Movie (active)playback_endedIdleHide video playerAuto-dismiss
Movie (active)dismissIdleHide video playerUser dismiss
TvShow (active)playback_endedTvShow (inactive)Show next episode UIWait for user choice
TvShow (inactive)next_episodeTvShow (active)Play next episodeStay in session
TvShow (inactive)series_completeIdleHide video playerNo more episodes
+
+

Key Design Decisions:

+
    +
  1. Audio Sessions Persist: Miniplayer stays visible even when queue ends, allows easy resume
  2. +
  3. Video Sessions Auto-Dismiss: Movies auto-close when finished (unless paused)
  4. +
  5. Single Active Session: Playing new content type replaces current session
  6. +
  7. Explicit Dismiss for Audio: User must click close button to clear audio session
  8. +
  9. Session != PlayerState: Session is higher-level, PlayerState tracks playing/paused/seeking
  10. +
+

Edge Cases Handled:

+
    +
  • Album finishes: Session goes inactive, miniplayer shows last track with play disabled
  • +
  • User wants to dismiss: Close button clears session -> Idle
  • +
  • Switch content types: New session replaces old (audio -> movie)
  • +
  • Paused for extended time: Session persists indefinitely
  • +
  • Playback errors: Session stays inactive, allows retry
  • +
  • Queue operations while idle: Queue exists but no session created until play
  • +
+

Player State Machine (Low-Level Playback)

+

Location: src-tauri/src/player/state.rs

+

The player uses a deterministic state machine with 6 states (operates within a media session):

+
stateDiagram-v2
+    [*] --> Idle
+    Idle --> Loading : Load
+    Loading --> Playing : MediaLoaded
+    Playing --> Paused : Pause
+    Paused --> Playing : Play
+    Paused --> Seeking : Seek
+    Seeking --> Playing : PositionUpdate
+    Playing --> Idle : Stop
+    Paused --> Idle : Stop
+    Idle --> Error : Error
+    Loading --> Error : Error
+    Playing --> Error : Error
+    Paused --> Error : Error
+    Seeking --> Error : Error
+
+    state Playing {
+        [*] : position, duration
+    }
+    state Paused {
+        [*] : position, duration
+    }
+    state Seeking {
+        [*] : target
+    }
+    state Error {
+        [*] : error message
+    }
+
+

State Enum:

+
#![allow(unused)]
+fn main() {
+pub enum PlayerState {
+    Idle,
+    Loading { media: MediaItem },
+    Playing { media: MediaItem, position: f64, duration: f64 },
+    Paused { media: MediaItem, position: f64, duration: f64 },
+    Seeking { media: MediaItem, target: f64 },
+    Error { media: Option<MediaItem>, error: String },
+}
+}
+

Event Enum:

+
#![allow(unused)]
+fn main() {
+pub enum PlayerEvent {
+    Load(MediaItem),
+    Play,
+    Pause,
+    Stop,
+    Seek(f64),
+    Next,
+    Previous,
+    MediaLoaded(f64),      // duration
+    PositionUpdate(f64),   // position
+    PlaybackEnded,
+    Error(String),
+}
+}
+

Playback Mode State Machine

+

Location: src-tauri/src/playback_mode/mod.rs

+

The playback mode manages whether media is playing locally on the device or remotely on another Jellyfin session (TV, browser, etc.):

+
stateDiagram-v2
+    [*] --> Idle
+
+    Idle --> Local : play_queue()
+    Idle --> Remote : transfer_to_remote(session_id)
+
+    Local --> Remote : transfer_to_remote(session_id)
+    Local --> Idle : stop()
+
+    Remote --> Local : transfer_to_local()
+    Remote --> Idle : session_disconnected()
+    Remote --> Idle : stop()
+
+    state Local {
+        [*] : Playing on device
+        [*] : ExoPlayer active
+        [*] : Volume buttons -> device
+    }
+
+    state Remote {
+        [*] : Controlling session
+        [*] : session_id
+        [*] : Volume buttons -> remote
+        [*] : Android: VolumeProvider active
+    }
+
+    state Idle {
+        [*] : No active playback
+    }
+
+

State Enum:

+
#![allow(unused)]
+fn main() {
+pub enum PlaybackMode {
+    Local,                           // Playing on local device
+    Remote { session_id: String },   // Controlling remote Jellyfin session
+    Idle,                            // No active playback
+}
+}
+

State Transitions:

+
+ + + + + + + +
FromEventToSide Effects
Idleplay_queue()LocalStart local playback
Idletransfer_to_remote(session_id)RemoteSend queue to remote session
Localtransfer_to_remote(session_id)RemoteStop local, send queue to remote, enable remote volume (Android)
Localstop()IdleStop local playback
Remotetransfer_to_local()LocalGet remote state, stop remote, start local at same position, disable remote volume
Remotestop()IdleStop remote playback, disable remote volume
Remotesession_disconnected()IdleSession lost, disable remote volume
+
+

Integration with Player State Machine:

+
    +
  • When PlaybackMode = Local: Player state machine is active (Idle/Loading/Playing/Paused/etc.)
  • +
  • When PlaybackMode = Remote: Player state is typically Idle (remote session controls playback)
  • +
  • When PlaybackMode = Idle: Player state is Idle
  • +
+

Android Volume Control Integration:

+

When transitioning to Remote mode on Android:

+
    +
  1. Call enable_remote_volume(initial_volume)
  2. +
  3. VolumeProviderCompat intercepts hardware volume buttons
  4. +
  5. PlaybackStateCompat is set to STATE_PLAYING (shows volume UI)
  6. +
  7. Volume commands routed to remote session via Jellyfin API
  8. +
+

When transitioning away from Remote mode:

+
    +
  1. Call disable_remote_volume()
  2. +
  3. Volume buttons return to controlling device volume
  4. +
  5. PlaybackStateCompat set to STATE_NONE
  6. +
  7. VolumeProviderCompat is cleared
  8. +
+

Media Item & Source

+

Location: src-tauri/src/player/media.rs

+
#![allow(unused)]
+fn main() {
+pub struct MediaItem {
+    pub id: String,
+    pub title: String,
+    pub artist: Option<String>,
+    pub album: Option<String>,
+    pub duration: Option<f64>,
+    pub artwork_url: Option<String>,
+    pub media_type: MediaType,
+    pub source: MediaSource,
+}
+
+pub enum MediaType {
+    Audio,
+    Video,
+}
+
+pub enum MediaSource {
+    Remote {
+        stream_url: String,
+        jellyfin_item_id: String,
+    },
+    Local {
+        file_path: PathBuf,
+        jellyfin_item_id: Option<String>,
+    },
+    DirectUrl {
+        url: String,
+    },
+}
+}
+

The MediaSource enum enables:

+
    +
  • Remote: Streaming from Jellyfin server
  • +
  • Local: Downloaded/cached files (future offline support)
  • +
  • DirectUrl: Direct URLs (channel plugins, external sources)
  • +
+

Queue Manager

+

Location: src-tauri/src/player/queue.rs

+
#![allow(unused)]
+fn main() {
+pub struct QueueManager {
+    items: Vec<MediaItem>,
+    current_index: Option<usize>,
+    shuffle: bool,
+    repeat: RepeatMode,
+    shuffle_order: Vec<usize>,  // Fisher-Yates permutation
+    history: Vec<usize>,        // For back navigation in shuffle
+}
+
+pub enum RepeatMode {
+    Off,
+    All,
+    One,
+}
+}
+

Queue Navigation Logic:

+
flowchart TB
+    QM[QueueManager]
+    QM --> Shuffle
+    QM --> Repeat
+    QM --> History
+
+    subgraph Shuffle["Shuffle Mode"]
+        ShuffleOff["OFF<br/>next() returns index + 1"]
+        ShuffleOn["ON<br/>next() follows shuffle_order[]"]
+    end
+
+    subgraph Repeat["Repeat Mode"]
+        RepeatOff["OFF<br/>next() at end: -> None"]
+        RepeatAll["ALL<br/>next() at end: -> wrap to index 0"]
+        RepeatOne["ONE<br/>next() returns same item"]
+    end
+
+    subgraph History["History"]
+        HistoryDesc["Used for previous()<br/>in shuffle mode"]
+    end
+
+

Favorites System

+

Location:

+
    +
  • Commands: src-tauri/src/commands/favorites.rs (offline drain), +src-tauri/src/commands/repository.rs (query + toggle), +src-tauri/src/commands/storage/ (local user_data writes)
  • +
  • Repository: get_favorites on the trait, implemented by online.rs, +offline.rs and hybrid.rs
  • +
  • Frontend: src/lib/services/favorites.ts, +src/lib/components/FavoriteButton.svelte, /library/favorites
  • +
+

Favouriting has two halves that are easy to confuse: marking an item, which +has existed since UR-017, and browsing what was marked, which arrived with +UR-067…069 (DR-113 … DR-120). Both go through the repository, not around it.

+

Marking

+

Optimistic local write, then server sync:

+
flowchart TB
+    UI[FavoriteButton] -->|Click| Service[toggleFavorite]
+    Service -->|"1. Optimistic"| LocalDB[("SQLite user_data<br/>is_favorite, pending_sync")]
+    Service -->|"2. Sync"| Repo[Repository]
+    Repo -->|POST / DELETE| JellyfinAPI["/Users/{id}/FavoriteItems/{itemId}"]
+    Service -->|"3. Mark synced"| LocalDB
+    Drain["spawn_favorites_drain<br/>(background task)"] -->|"pending_sync = 1"| Repo
+
+
    +
  1. The local row is updated immediately, so the heart fills without a round trip.
  2. +
  3. The repository is asked to mark or unmark on the server.
  4. +
  5. On success pending_sync is cleared; on failure the row stays pending.
  6. +
  7. A background drain (spawn_favorites_drain, started in lib.rs setup) +retries pending rows, so a favourite marked offline still reaches the server +(DR-120). This is the same pattern as the sync-queue drain — see +Background workers.
  8. +
+

Browsing

+

get_favorites(scope, options) answers "what did this user favourite", across +libraries, with the scope owned by Rust — the frontend sends a +SearchScope variant and never names +an item type. HybridRepository splits it the same way it splits every query:

+
+ + + +
MethodUsed for
get_favorites_cache_onlyThe instant leg — the local user_data join
get_favorites_server_onlyThe reconciliation leg
get_favoritesCache-first with server merge, per the repository's usual policy
+
+

GetItemsOptions.favorites_only is the other entry point: it filters an +existing library listing rather than starting a cross-library query (DR-116), +which is what a library page's favourites filter uses.

+

Server favourite state is mirrored into the local user_data table on catalog +sync (DR-113/DR-114), so a favourite marked in another Jellyfin client shows up +here — before this, MediaItem.user_data was left empty and no query anywhere +asked for favourites.

+

Tauri commands:

+
+ + + + +
CommandDescription
repository_get_favoritesCross-library favourites for a scope
repository_mark_favorite / repository_unmark_favoriteToggle on the server, through the repository
storage_toggle_favoriteLocal optimistic write (is_favorite, pending_sync)
storage_mark_syncedClear pending_sync after a successful server write
+
+

Frontend surfaces (DR-117 … DR-119): the /library/favorites page with a +scope selector, favourite rows on home (favoriteMovies / favoriteShows / +favoriteMusic in stores/home.ts), a favourites tile per category in the +library mosaic, and FavoriteButton mounted wherever a whole item is shown — +movie, series, episode, album, artist and playlist detail views as well as the +mini player.

+

Player Backend Trait

+

Location: src-tauri/src/player/backend.rs

+
#![allow(unused)]
+fn main() {
+pub trait PlayerBackend: Send + Sync {
+    fn load(&mut self, media: &MediaItem) -> Result<(), PlayerError>;
+    fn play(&mut self) -> Result<(), PlayerError>;
+    fn pause(&mut self) -> Result<(), PlayerError>;
+    fn stop(&mut self) -> Result<(), PlayerError>;
+    fn seek(&mut self, position: f64) -> Result<(), PlayerError>;
+    fn set_volume(&mut self, volume: f32) -> Result<(), PlayerError>;
+    fn position(&self) -> f64;
+    fn duration(&self) -> Option<f64>;
+    fn state(&self) -> PlayerState;
+    fn is_loaded(&self) -> bool;
+    fn volume(&self) -> f32;
+}
+}
+

Implementations:

+ +

Player Controller

+

Location: src-tauri/src/player/mod.rs

+

The PlayerController orchestrates playback:

+
#![allow(unused)]
+fn main() {
+pub struct PlayerController {
+    backend: Arc<Mutex<Box<dyn PlayerBackend>>>,
+    queue: Arc<Mutex<QueueManager>>,
+    muted: bool,
+    sleep_timer: Arc<Mutex<SleepTimerState>>,
+    autoplay_settings: Arc<Mutex<AutoplaySettings>>,
+    autoplay_episode_count: Arc<Mutex<u32>>,  // Session-based counter
+    repository: Arc<Mutex<Option<Arc<dyn MediaRepository>>>>,
+    event_emitter: Arc<Mutex<Option<Arc<dyn PlayerEventEmitter>>>>,
+    // ... other fields
+}
+}
+

Key Methods:

+
    +
  • play_item(item): Load and play single item (resets autoplay counter)
  • +
  • play_queue(items, start_index): Load queue and start playback (resets autoplay counter)
  • +
  • next() / previous(): Queue navigation (resets autoplay counter)
  • +
  • toggle_shuffle() / cycle_repeat(): Mode changes
  • +
  • set_sleep_timer(mode) / cancel_sleep_timer(): Sleep timer control
  • +
  • on_playback_ended(): Autoplay decision making (checks sleep timer, episode limit, queue)
  • +
+

Playlist System

+

Location: src-tauri/src/commands/playlist.rs, src-tauri/src/repository/

+

TRACES: UR-014 | JA-019 | JA-020

+

The playlist system provides full CRUD operations for Jellyfin playlists with offline support through the cache-first repository pattern.

+

Types:

+
#![allow(unused)]
+fn main() {
+/// A media item within a playlist, with its distinct playlist entry ID
+#[derive(Debug, Clone, Serialize, Deserialize)]
+#[serde(rename_all = "camelCase")]
+pub struct PlaylistEntry {
+    /// Jellyfin's PlaylistItemId (distinct from the media item ID)
+    pub playlist_item_id: String,
+    #[serde(flatten)]
+    pub item: MediaItem,
+}
+
+/// Result of creating a new playlist
+#[derive(Debug, Clone, Serialize, Deserialize)]
+#[serde(rename_all = "camelCase")]
+pub struct PlaylistCreatedResult {
+    pub id: String,
+}
+}
+

Key Design Decision: PlaylistEntry wraps a MediaItem with a distinct playlist_item_id. This is critical because removing items from a playlist requires the playlist entry ID (not the media item ID), since the same track can appear multiple times.

+

MediaRepository Trait Methods:

+
#![allow(unused)]
+fn main() {
+async fn create_playlist(&self, name: &str, item_ids: Option<Vec<String>>) -> Result<PlaylistCreatedResult, RepoError>;
+async fn delete_playlist(&self, playlist_id: &str) -> Result<(), RepoError>;
+async fn rename_playlist(&self, playlist_id: &str, name: &str) -> Result<(), RepoError>;
+async fn get_playlist_items(&self, playlist_id: &str) -> Result<Vec<PlaylistEntry>, RepoError>;
+async fn add_to_playlist(&self, playlist_id: &str, item_ids: Vec<String>) -> Result<(), RepoError>;
+async fn remove_from_playlist(&self, playlist_id: &str, entry_ids: Vec<String>) -> Result<(), RepoError>;
+async fn move_playlist_item(&self, playlist_id: &str, item_id: &str, new_index: u32) -> Result<(), RepoError>;
+}
+

Cache Strategy:

+
    +
  • Write operations (create, delete, rename, add, remove, move): Delegate directly to online repository
  • +
  • Read operation (get_playlist_items): Uses cache-first parallel racing (100ms cache timeout, server fallback)
  • +
  • Background cache update after server fetch via save_playlist_items_to_cache()
  • +
+

Playlist Tauri Commands:

+
+ + + + + + + +
CommandParametersReturns
playlist_createhandle, name, item_ids?PlaylistCreatedResult
playlist_deletehandle, playlist_id()
playlist_renamehandle, playlist_id, name()
playlist_get_itemshandle, playlist_idVec<PlaylistEntry>
playlist_add_itemshandle, playlist_id, item_ids()
playlist_remove_itemshandle, playlist_id, entry_ids()
playlist_move_itemhandle, playlist_id, item_id, new_index()
+
+

Tauri Commands (Player)

+

Location: src-tauri/src/commands/player.rs

+
+ + + + + + + + + + + + + + + + + + + + + + + +
CommandParametersReturns
player_play_itemPlayItemRequestPlayerStatus
player_play_queueitems, start_index, shufflePlayerStatus
player_play-PlayerStatus
player_pause-PlayerStatus
player_toggle-PlayerStatus
player_stop-PlayerStatus
player_next-PlayerStatus
player_previous-PlayerStatus
player_seekposition: f64PlayerStatus
player_set_volumevolume: f32PlayerStatus
player_toggle_shuffle-QueueStatus
player_cycle_repeat-QueueStatus
player_get_status-PlayerStatus
player_get_queue-QueueStatus
player_get_session-MediaSessionType
player_dismiss_session-()
player_set_sleep_timermode: SleepTimerMode()
player_cancel_sleep_timer-()
player_set_video_settingssettings: VideoSettingsVideoSettings
player_get_video_settings-VideoSettings
player_set_autoplay_settingssettings: AutoplaySettingsAutoplaySettings
player_get_autoplay_settings-AutoplaySettings
player_on_playback_ended-()
+
+

Domain Vocabulary Owned by Rust

+

The frontend is presentation-only and must not encode Jellyfin's taxonomy — the +rule in CLAUDE.md and +scoped-search-boundary.md. These are the +places where that vocabulary actually lives.

+

Search scope and the taxonomy boundary

+

Location: src-tauri/src/repository/types.rs

+

SearchScope is the canonical example the boundary rule is taught from. The +frontend sends an opaque variant; Rust expands it into Jellyfin item types:

+
#![allow(unused)]
+fn main() {
+pub enum SearchScope { All, Music, Movies, Tv }
+
+impl SearchScope {
+    /// The Jellyfin item types this scope requests, or `None` for `All`.
+    pub fn item_types(self) -> Option<Vec<String>> { … }
+
+    /// The scope a library of this Jellyfin `CollectionType` belongs to.
+    pub fn for_collection_type(collection_type: &str) -> Option<SearchScope> { … }
+}
+}
+

Two details that are load-bearing:

+
    +
  • All returns None, not the union of every listed type. An explicit +includeItemTypes list filters out anything not named in it, so a union would +silently drop People, folders, and any type nobody enumerated. Callers must +omit the filter entirely on None.
  • +
  • for_collection_type maps a Jellyfin CollectionType to a favourites +category (DR-175). It changes when Jellyfin renames a collection type, not +when the library page is redesigned — which is the test for whether something +belongs on this side of the boundary.
  • +
+

⚠️ The result side has not moved yet. GROUP_ITEM_TYPES in +src/lib/utils/searchScope.ts still maps result groups to item types in the +frontend, and check:boundary does not match its shape. Tracked as Stage 2 of +scoped-search-boundary-implementation.md.

+

Library exclusions

+

Location: src-tauri/src/repository/exclusions.rs (TRACES: UR-076 | DR-209)

+

Folders the user has chosen to keep out of music browsing — a "Podcasts" folder +inside a music library being the canonical case. Excluded by item id, not by +name, in a process-wide RwLock<Vec<String>> restored from the database at +startup, and applied by the repository layer to every music query (libraries, +artists, albums, genres, search, home rows).

+

The id is normalised (trim, strip -, lowercase) because Jellyfin writes the +same GUID both dashed and undashed depending on the endpoint. The predecessor was +a frontend filter matching the English string "Podcasts" — wrong in three ways at +once, and the reason this lives in the repository.

+

The set is process-wide rather than a field on a repository for the same reason +as online::STREAMING_QUALITY: it is a preference about this user's browsing, +not about a server session, so it must survive a repository being rebuilt on +re-login.

+

Streaming quality ladder

+

Location: src-tauri/src/settings.rs (TRACES: UR-074 | DR-162)

+

StreamingQuality is a bandwidth ladder (Original, 20/10/8/4/2/1 Mbps, +720 kbps), not a resolution picker: it exists to fit a connection, and the +resolution cap is chosen from the bitrate so the encoder does not spend a small +budget on pixels it cannot afford.

+
+ + + + +
MethodAnswers
max_bitrate()Total bits/s (video + audio), None for Original
audio_bitrate()The audio share — shrinks down the ladder, so 384 kbps is not a third of the budget at the bottom
video_bitrate()Total minus audio, so the two together honour the ceiling
max_height()Resolution ceiling that suits the bitrate
+
+

The ceiling goes to PlaybackInfo as MaxStreamingBitrate and into the +device profile. Sending it there — not just on the transcode URL — is what makes +the cap real: a stream the server decides to direct play is served at the +source file's own bitrate, and no URL parameter afterwards can reduce it.

+

The frontend names a variant and nothing else; the labels the picker shows are +served over IPC by player_get_streaming_qualities.

+

Background workers

+

Three long-lived tasks are spawned from the Tauri setup hook in lib.rs. All +three exist because when something happens is a backend policy, not something +a page load should decide.

+
+ + + +
WorkerLocationResponsibility
spawn_catalog_indexercommands/catalog.rsKeeps the local FTS5 catalog fresh (DR-109, IR-030)
spawn_favorites_draincommands/favorites.rsRetries favourite toggles made while offline (DR-120)
spawn_sync_queue_draincommands/sync_drain.rsDrains the offline mutation queue (DR-131)
+
+

Catalog indexer

+

Replaces the frontend's startup-only syncCatalog() call. It ticks on +CATALOG_INDEX_TICK and runs a pass when three things hold: a repository exists, +the server is reachable, and the index is due per index_is_due. A tick is +nearly free — one indexed app_settings lookup — which is what makes it +responsive to events it cannot subscribe to, such as signing in: a fresh install +would otherwise sit unindexed until the next scheduled pass.

+

index_is_due treats both "never indexed" and an unparseable stored timestamp as +due; a corrupt timestamp should trigger a re-index, not silently freeze the +catalog. A failed pass is never fatal — it leaves the existing index in place and +warns. Progress is emitted on CATALOG_INDEX_EVENT for the staleness hint in the +UI.

+

Svelte Frontend Architecture

+

Store Structure

+

Location: src/lib/stores/

+
flowchart TB
+    subgraph Stores
+        subgraph auth["auth.ts"]
+            AuthState["AuthState<br/>- user<br/>- serverUrl<br/>- token<br/>- isLoading"]
+        end
+        subgraph playerStore["player.ts"]
+            PlayerStoreState["PlayerState<br/>- kind<br/>- media<br/>- position<br/>- duration"]
+        end
+        subgraph queueStore["queue.ts"]
+            QueueState["QueueState<br/>- items<br/>- index<br/>- shuffle<br/>- repeat"]
+        end
+        subgraph libraryStore["library.ts"]
+            LibraryState["LibraryState<br/>- libraries<br/>- items<br/>- loading"]
+        end
+        subgraph Derived["Derived Stores"]
+            DerivedList["isAuthenticated, currentUser<br/>isPlaying, isPaused, currentMedia<br/>hasNext, hasPrevious, isShuffle<br/>libraryItems, isLibraryLoading"]
+        end
+    end
+
+

Music Library Architecture

+

Category-Based Navigation:

+

JellyTau's music library uses a category-based navigation system with a dedicated landing page that routes users to specialized views for different content types.

+

Route Structure:

+
graph TD
+    Music["/library/music<br/>(Landing page with category cards)"]
+    Tracks["Tracks<br/>(List view only)"]
+    Artists["Artists<br/>(Grid view)"]
+    Albums["Albums<br/>(Grid view)"]
+    Playlists["Playlists<br/>(Grid view)"]
+    Genres["Genres<br/>(Genre browser)"]
+
+    Music --> Tracks
+    Music --> Artists
+    Music --> Albums
+    Music --> Playlists
+    Music --> Genres
+
+

View Enforcement:

+

Ordinal content (where position carries meaning) is always a list. Everything +else honours the user's persisted grid/list preference — see +ux-flows.md §5A.2.

+
+ + + + + + + +
Content TypeView ModeToggle VisibleComponent Used
TracksList (forced — ordinal)NoTrackList
ArtistsUser preferenceYesLibraryGrid
AlbumsUser preferenceYesLibraryGrid
PlaylistsUser preferenceYesLibraryGrid
GenresUser preference (both levels)YesLibraryGrid
Album Detail TracksList (forced — ordinal)NoTrackList
Season EpisodesList (forced — ordinal)NoSeasonSection
+
+

TrackList Component:

+

The TrackList component (src/lib/components/library/TrackList.svelte) is a dedicated component for displaying songs in list format:

+
    +
  • No Thumbnails: Track numbers only (transform to play button on hover)
  • +
  • Desktop Layout: Table with columns: #, Title, Artist, Album, Duration
  • +
  • Mobile Layout: Compact rows with track number and metadata
  • +
  • Configurable Columns: showArtist and showAlbum props control column visibility
  • +
  • Click Behavior: Clicking a track plays it and queues all filtered tracks
  • +
+

Example Usage:

+
<TrackList
+  tracks={filteredTracks}
+  loading={loading}
+  showArtist={true}
+  showAlbum={true}
+/>
+
+

LibraryGrid view mode:

+

LibraryGrid reads the global viewMode store (persisted to localStorage) +and renders LibraryListView or the card grid accordingly. The showViewToggle +prop controls whether the toggle buttons appear in the page header; the grid +itself always follows the stored preference.

+

A forceGrid prop previously existed to pin pages to grid regardless of +preference. No caller ever passed it, so it was removed — pages that were +documented as "forced grid" have in practice always honoured the toggle.

+

Playback Reporting Service

+

Location: src/lib/services/playbackReporting.ts

+

The playback reporting service ensures playback progress is synced to both the Jellyfin server AND the local SQLite database. This dual-write approach enables:

+
    +
  • Offline "Continue Watching" functionality
  • +
  • Sync queue for when network is unavailable
  • +
  • Consistent progress across app restarts
  • +
+
sequenceDiagram
+    participant VideoPlayer
+    participant PlaybackService as playbackReporting.ts
+    participant LocalDB as Local SQLite<br/>(Tauri Commands)
+    participant Jellyfin as Jellyfin Server
+
+    VideoPlayer->>PlaybackService: reportPlaybackProgress(itemId, position)
+
+    par Local Storage (always works)
+        PlaybackService->>LocalDB: invoke("storage_update_playback_progress")
+        LocalDB-->>PlaybackService: Ok (pending_sync = true)
+    and Server Sync (if online)
+        PlaybackService->>Jellyfin: POST /Sessions/Playing/Progress
+        Jellyfin-->>PlaybackService: Ok
+        PlaybackService->>LocalDB: invoke("storage_mark_synced")
+    end
+
+

Service Functions:

+
    +
  • reportPlaybackStart(itemId, positionSeconds) - Called when playback begins
  • +
  • reportPlaybackProgress(itemId, positionSeconds, isPaused) - Called periodically (every 10s)
  • +
  • reportPlaybackStopped(itemId, positionSeconds) - Called when player closes or video ends
  • +
+

Tauri Commands:

+
+ + + + +
CommandDescription
storage_update_playback_progressUpdate position in local DB (marks pending_sync = true)
storage_mark_playedMark item as played, increment play count
storage_get_playback_progressGet stored progress for an item
storage_mark_syncedClear pending_sync flag after successful server sync
+
+

Database Schema Notes:

+
    +
  • The user_data table stores playback progress using Jellyfin IDs directly (as TEXT)
  • +
  • Playback progress can be tracked even when the full item metadata hasn't been downloaded yet
  • +
+

Resume Playback Feature:

+
    +
  • When loading media for playback, the app checks local database for saved progress
  • +
  • If progress exists (>30 seconds watched and <90% complete), shows resume dialog
  • +
  • User can choose to "Resume" from saved position or "Start from Beginning"
  • +
  • For video: Uses startTimeSeconds parameter in stream URL to begin transcoding from resume point
  • +
  • For audio: Seeks to resume position after loading via MPV backend
  • +
  • Implemented in src/routes/player/[id]/+page.svelte
  • +
+

Repository Architecture (Rust-Based)

+

Location: src-tauri/src/repository/

+
classDiagram
+    class MediaRepository {
+        <<trait>>
+        +get_libraries()
+        +get_items(parent_id, options)
+        +get_item(item_id)
+        +search(query, options)
+        +get_latest_items(parent_id, limit)
+        +get_resume_items(parent_id, limit)
+        +get_next_up_episodes(series_id, limit)
+        +get_genres(parent_id)
+        +get_playback_info(item_id)
+        +report_playback_start(item_id, position_ticks)
+        +report_playback_progress(item_id, position_ticks, is_paused)
+        +report_playback_stopped(item_id, position_ticks)
+        +mark_favorite(item_id)
+        +unmark_favorite(item_id)
+        +get_person(person_id)
+        +get_items_by_person(person_id, options)
+        +get_image_url(item_id, image_type, options)
+        +create_playlist(name, item_ids)
+        +delete_playlist(playlist_id)
+        +rename_playlist(playlist_id, name)
+        +get_playlist_items(playlist_id)
+        +add_to_playlist(playlist_id, item_ids)
+        +remove_from_playlist(playlist_id, entry_ids)
+        +move_playlist_item(playlist_id, item_id, new_index)
+    }
+
+    class OnlineRepository {
+        -http_client: Arc~HttpClient~
+        -server_url: String
+        -user_id: String
+        -access_token: String
+        -connectivity: Option~Arc~ConnectivityMonitor~~
+        +new()
+        +with_connectivity()
+        -report_outcome()
+    }
+
+    class OfflineRepository {
+        -db_service: Arc~DatabaseService~
+        -server_id: String
+        -user_id: String
+        +new()
+        +cache_library()
+        +cache_items()
+        +cache_item()
+    }
+
+    class HybridRepository {
+        -online: Arc~OnlineRepository~
+        -offline: Arc~OfflineRepository~
+        +new()
+        -parallel_race()
+        -cache_with_timeout()
+    }
+
+    MediaRepository <|.. OnlineRepository
+    MediaRepository <|.. OfflineRepository
+    MediaRepository <|.. HybridRepository
+
+    HybridRepository --> OnlineRepository
+    HybridRepository --> OfflineRepository
+
+

Key Implementation Details:

+
    +
  1. +

    Cache-First Racing Strategy (hybrid.rs):

    +
      +
    • Runs cache (SQLite) and server (HTTP) queries in parallel
    • +
    • Cache has 100ms timeout
    • +
    • Returns cache result if it has meaningful content
    • +
    • Falls back to server result otherwise
    • +
    • Background cache updates planned
    • +
    • Connectivity feedback: OnlineRepository reports the outcome of every server request to the ConnectivityMonitor (classified via RepoError). This is the source of truth for the offline/online banner — see 07-connectivity.md. The frontend connectivity store is a pure reflection of the resulting events; navigator.onLine is only an advisory hint that triggers an immediate recheck.
    • +
    +
  2. +
  3. +

    Handle-Based Resource Management (repository.rs commands):

    +
    #![allow(unused)]
    +fn main() {
    +// Frontend creates repository with UUID handle
    +repository_create(server_url, user_id, access_token, server_id) -> String (UUID)
    +
    +// All operations use handle for identification
    +repository_get_libraries(handle: String) -> Vec<Library>
    +repository_get_items(handle: String, ...) -> SearchResult
    +
    +// Cleanup when done
    +repository_destroy(handle: String)
    +}
    +
      +
    • Enables multiple concurrent repository instances
    • +
    • Thread-safe with Arc<Mutex<HashMap<String, Arc<HybridRepository>>>>
    • +
    • No global state conflicts
    • +
    +
  4. +
  5. +

    Frontend API Layer (src/lib/api/repository-client.ts):

    +
      +
    • Thin TypeScript wrapper over Rust commands
    • +
    • Maintains handle throughout session
    • +
    • All methods: invoke<T>("repository_operation", { handle, ...args })
    • +
    • ~100 lines (down from 1061 lines)
    • +
    +
  6. +
+

Playback Mode System

+

Location: src-tauri/src/playback_mode/mod.rs

+

The playback mode system manages transitions between local device playback and remote Jellyfin session control:

+
#![allow(unused)]
+fn main() {
+pub enum PlaybackMode {
+    Local,                     // Playing on local device
+    Remote { session_id: String }, // Controlling remote session
+    Idle,                      // Not playing
+}
+
+pub struct PlaybackModeManager {
+    current_mode: PlaybackMode,
+    player_controller: Arc<Mutex<PlayerController>>,
+    jellyfin_client: Arc<JellyfinClient>,
+}
+}
+

Key Operations:

+
    +
  1. +

    Transfer to Remote (transfer_to_remote(session_id)):

    +
    sequenceDiagram
    +    participant UI
    +    participant Manager as PlaybackModeManager
    +    participant Player as PlayerController
    +    participant Jellyfin as Jellyfin API
    +
    +    UI->>Manager: transfer_to_remote(session_id)
    +    Manager->>Player: Extract queue items
    +    Manager->>Manager: Get Jellyfin IDs from queue
    +    Manager->>Jellyfin: POST /Sessions/{id}/Playing
    +    Note over Jellyfin: Start playback with queue
    +    Manager->>Jellyfin: POST /Sessions/{id}/Playing/Seek
    +    Note over Jellyfin: Seek to current position
    +    Manager->>Player: Stop local playback
    +    Manager->>Manager: Set mode to Remote
    +
    +
  2. +
  3. +

    Transfer to Local (transfer_to_local(item_id, position_ticks)):

    +
      +
    • Stops remote session playback
    • +
    • Prepares local player to resume
    • +
    • Sets mode to Local
    • +
    +
  4. +
+

Tauri Commands (playback_mode.rs):

+
    +
  • playback_mode_get_current() -> Returns current PlaybackMode
  • +
  • playback_mode_transfer_to_remote(session_id) -> Async transfer
  • +
  • playback_mode_transfer_to_local(item_id, position_ticks) -> Async transfer back
  • +
  • playback_mode_is_transferring() -> Check transfer state
  • +
  • playback_mode_set(mode) -> Direct mode setting
  • +
+

Frontend Store (src/lib/stores/playbackMode.ts):

+
    +
  • Thin wrapper calling Rust commands
  • +
  • Maintains UI state (isTransferring, transferError)
  • +
  • Listens to mode change events from Rust
  • +
+

Database Service Abstraction

+

Location: src-tauri/src/storage/db_service.rs

+

Async database interface wrapping synchronous rusqlite to prevent blocking the Tokio runtime:

+
#![allow(unused)]
+fn main() {
+#[async_trait]
+pub trait DatabaseService: Send + Sync {
+    async fn execute(&self, query: Query) -> Result<usize, DatabaseError>;
+    async fn execute_batch(&self, queries: Vec<Query>) -> Result<(), DatabaseError>;
+    async fn query_one<T, F>(&self, query: Query, mapper: F) -> Result<T, DatabaseError>
+        where F: FnOnce(&Row) -> Result<T> + Send + 'static;
+    async fn query_optional<T, F>(&self, query: Query, mapper: F) -> Result<Option<T>, DatabaseError>
+        where F: FnOnce(&Row) -> Result<T> + Send + 'static;
+    async fn query_many<T, F>(&self, query: Query, mapper: F) -> Result<Vec<T>, DatabaseError>
+        where F: Fn(&Row) -> Result<T> + Send + 'static;
+    async fn transaction<F, T>(&self, f: F) -> Result<T, DatabaseError>
+        where F: FnOnce(Transaction) -> Result<T> + Send + 'static;
+}
+
+pub struct RusqliteService {
+    connection: Arc<Mutex<Connection>>,
+}
+
+impl DatabaseService for RusqliteService {
+    async fn execute(&self, query: Query) -> Result<usize, DatabaseError> {
+        let conn = self.connection.clone();
+        tokio::task::spawn_blocking(move || {
+            // Execute query on blocking thread pool
+        }).await?
+    }
+    // ... other methods use spawn_blocking
+}
+}
+

Key Benefits:

+
    +
  • No Freezing: All blocking DB ops run in thread pool via spawn_blocking
  • +
  • Type Safety: QueryParam enum prevents SQL injection
  • +
  • Future Proof: Easy to swap to native async DB (tokio-rusqlite)
  • +
  • Testable: Can mock DatabaseService for tests
  • +
+

Usage Pattern:

+
#![allow(unused)]
+fn main() {
+// Before (blocking - causes UI freeze)
+let conn = database.connection();
+let conn = conn.lock().unwrap();  // BLOCKS
+conn.query_row(...)  // BLOCKS
+
+// After (async - no freezing)
+let db_service = database.service();
+let query = Query::with_params("SELECT ...", vec![...]);
+db_service.query_one(query, |row| {...}).await  // spawn_blocking internally
+}
+

Component Hierarchy

+
graph TD
+    subgraph Routes["Routes (src/routes/)"]
+        LoginPage["Login Page"]
+        LibLayout["Library Layout"]
+        LibDetail["Album/Series Detail"]
+        MusicCategory["Music Category Landing"]
+        Tracks["Tracks"]
+        Artists["Artists"]
+        Albums["Albums"]
+        Playlists["Playlists"]
+        Genres["Genres"]
+        Downloads["Downloads Page"]
+        Settings["Settings Page"]
+        PlayerPage["Player Page"]
+    end
+
+    subgraph PlayerComps["Player Components"]
+        AudioPlayer["AudioPlayer"]
+        VideoPlayer["VideoPlayer"]
+        MiniPlayer["MiniPlayer"]
+        Controls["Controls"]
+        Queue["Queue"]
+        SleepTimerModal["SleepTimerModal"]
+        SleepTimerIndicator["SleepTimerIndicator"]
+    end
+
+    subgraph SessionComps["Sessions Components"]
+        CastButton["CastButton"]
+        SessionModal["SessionPickerModal"]
+        SessionCard["SessionCard"]
+        SessionsList["SessionsList"]
+        RemoteControls["RemoteControls"]
+    end
+
+    subgraph LibraryComps["Library Components"]
+        LibGrid["LibraryGrid"]
+        LibListView["LibraryListView"]
+        TrackList["TrackList"]
+        PlaylistDetail["PlaylistDetailView"]
+        DownloadBtn["DownloadButton"]
+        MediaCard["MediaCard"]
+    end
+
+    subgraph PlaylistComps["Playlist Components"]
+        CreatePlaylistModal["CreatePlaylistModal"]
+        AddToPlaylistModal["AddToPlaylistModal"]
+    end
+
+    subgraph CommonComps["Common Components"]
+        ScrollPicker["ScrollPicker"]
+    end
+
+    subgraph OtherComps["Other Components"]
+        Search["Search"]
+        FavoriteBtn["FavoriteButton"]
+        DownloadItem["DownloadItem"]
+    end
+
+    LibLayout --> PlayerComps
+    LibLayout --> LibDetail
+    MusicCategory --> Tracks
+    MusicCategory --> Artists
+    MusicCategory --> Albums
+    MusicCategory --> Playlists
+    MusicCategory --> Genres
+    LibDetail --> LibraryComps
+    Playlists --> PlaylistComps
+    Playlists --> PlaylistDetail
+    Downloads --> DownloadItem
+    PlayerPage --> PlayerComps
+
+    MiniPlayer --> CastButton
+    CastButton --> SessionModal
+    SleepTimerModal --> ScrollPicker
+    PlayerComps --> LibraryComps
+
+

MiniPlayer Behavior

+

Location: src/lib/components/player/MiniPlayer.svelte

+

The MiniPlayer is a persistent bottom bar for audio playback that supports touch gestures and playback controls.

+

Touch Gesture Handling:

+

The MiniPlayer uses touch events to distinguish between taps (on controls) and swipe-up gestures (to expand to full player page):

+
function handleTouchStart(e: TouchEvent) {
+  touchStartX = e.touches[0].clientX;
+  touchStartY = e.touches[0].clientY;
+  touchEndX = touchStartX;   // Initialize to start position
+  touchEndY = touchStartY;   // Prevents taps being treated as swipes
+  isSwiping = true;
+}
+
+

Key Design Decision: touchEndX/touchEndY must be initialized to the start position in handleTouchStart. Without this, a pure tap (no touchmove event fired) would compute the swipe distance against (0,0), making every tap look like a massive swipe-up and inadvertently navigating to the player page.

+

Skip Button State:

+

The MiniPlayer's next/previous buttons are enabled based on appState.hasNext/hasPrevious, which are updated by playerEvents.ts calling invoke("player_get_queue") on every StateChanged event from the backend.

+

Sleep Timer Architecture

+

Location: src-tauri/src/player/sleep_timer.rs, src-tauri/src/player/mod.rs

+

TRACES: UR-026 | DR-029

+

The sleep timer supports three modes for stopping playback:

+
#![allow(unused)]
+fn main() {
+#[serde(tag = "kind", rename_all = "camelCase")]
+pub enum SleepTimerMode {
+    Off,
+    Time { end_time: i64 },      // Unix timestamp in milliseconds
+    EndOfTrack,                    // Stop after current track/episode
+    Episodes { remaining: u32 },   // Stop after N more episodes
+}
+}
+

Timer Modes:

+
+ + + +
ModeTriggerHow It Stops
TimeUser selects 15/30/45/60 min via roller UIBackground timer thread stops backend when remaining_seconds == 0; also checked at track boundaries in on_playback_ended()
EndOfTrackUser clicks "End of current track"Checked in on_playback_ended(), returns AutoplayDecision::Stop
EpisodesUser selects 1-10 episodesdecrement_episode() in on_playback_ended(), stops when counter reaches 0
+
+

Time-Based Timer Flow:

+
sequenceDiagram
+    participant UI as SleepTimerModal
+    participant Store as sleepTimer store
+    participant Rust as PlayerController
+    participant Thread as Timer Thread
+    participant Backend as PlayerBackend
+
+    UI->>Store: setTimeTimer(30)
+    Store->>Rust: invoke("player_set_sleep_timer", {mode})
+    Rust->>Rust: Set SleepTimerMode::Time { end_time }
+    Rust->>UI: Emit SleepTimerChanged event
+
+    loop Every 1 second
+        Thread->>Thread: update_remaining_seconds()
+        Thread->>UI: Emit SleepTimerChanged (countdown)
+        alt remaining_seconds == 0
+            Thread->>Backend: stop()
+            Thread->>UI: Emit SleepTimerChanged (Off)
+        end
+    end
+
+

Frontend Components:

+
    +
  • ScrollPicker (src/lib/components/common/ScrollPicker.svelte): Reusable scroll-wheel picker using CSS scroll-snap-type: y mandatory. Configurable items, visible count, and item height. Used by SleepTimerModal for time selection.
  • +
  • SleepTimerModal (src/lib/components/player/SleepTimerModal.svelte): Modal with three sections - time picker (roller), end of track button, episode counter. Time section uses ScrollPicker with 15/30/45/60 min options. Accepts optional mediaType prop to override queue-based detection (used by VideoPlayer since video playback clears the audio queue).
  • +
  • SleepTimerIndicator (src/lib/components/player/SleepTimerIndicator.svelte): Compact indicator showing active timer status with countdown.
  • +
  • Sleep buttons: Clock icon buttons on AudioPlayer header, Controls bar, MiniPlayer, and VideoPlayer control bar. Shows clock icon when inactive, SleepTimerIndicator when active.
  • +
+

Key Design Decisions:

+
    +
  1. All logic in Rust: Frontend only displays state and invokes commands
  2. +
  3. Background timer thread: Handles time-based countdown independently of track boundaries
  4. +
  5. Dual stop mechanism for Time mode: Timer thread stops mid-track; on_playback_ended() catches edge case at track boundary
  6. +
  7. Event-driven UI updates: Timer thread emits SleepTimerChanged every second for countdown display
  8. +
+

Auto-Play Episode Limit

+
+

⚠️ Autoplay is season-bounded. player/mod.rs:fetch_next_episode_for_item +does not cross a season boundary, so autoplay stops at the end of a season even +though the "More Episodes" strip runs past it. Fixing it should reuse +repository_get_series_episodes, but it touches the playback state machine and +the Android JNI advance path (see the AutoplayDecision deadlock note in +CLAUDE.md) — its own change, not a drive-by.

+
+

Location: src-tauri/src/player/mod.rs, src-tauri/src/player/autoplay.rs, src-tauri/src/settings.rs

+

TRACES: UR-023 | DR-049

+

Limits how many episodes auto-play consecutively before requiring manual intervention.

+

Settings:

+
#![allow(unused)]
+fn main() {
+// In AutoplaySettings (runtime, in PlayerController)
+pub struct AutoplaySettings {
+    pub enabled: bool,
+    pub countdown_seconds: u32,
+    pub max_episodes: u32,  // 0 = unlimited
+}
+
+// In VideoSettings (persisted, settings page)
+pub struct VideoSettings {
+    pub auto_play_next_episode: bool,
+    pub auto_play_countdown_seconds: u32,
+    pub auto_play_max_episodes: u32,  // 0 = unlimited
+}
+}
+

Session-Based Counter:

+

The autoplay_episode_count field in PlayerController tracks consecutive auto-played episodes:

+
    +
  • Incremented: In on_playback_ended() when auto-playing next episode
  • +
  • Reset: On any manual user action (play_item(), play_queue(), next(), previous())
  • +
  • Limit check: When max_episodes > 0 and count >= max_episodes, the popup shows with auto_advance: false - user must manually click "Play Now" to continue
  • +
+
flowchart TB
+    PlaybackEnded["on_playback_ended()"] --> CheckEpisode{"Is video<br/>episode?"}
+    CheckEpisode -->|"No"| AudioFlow["Audio queue logic"]
+    CheckEpisode -->|"Yes"| FetchNext["Fetch next episode"]
+    FetchNext --> IncrementCount["increment_autoplay_count()"]
+    IncrementCount --> CheckLimit{"max_episodes > 0<br/>AND count >= max?"}
+    CheckLimit -->|"No"| ShowPopup["ShowNextEpisodePopup<br/>auto_advance: true"]
+    CheckLimit -->|"Yes"| ShowPopupManual["ShowNextEpisodePopup<br/>auto_advance: false"]
+    ShowPopupManual --> UserClick["User clicks 'Play Now'"]
+    UserClick --> PlayItem["play_item() -> resets counter"]
+
+

Settings Sync:

+

VideoSettings (settings page) and AutoplaySettings (PlayerController runtime) are synced via player_set_video_settings, which updates both the VideoSettingsWrapper state and calls controller.set_autoplay_settings().

+

Database: Migration 016 adds autoplay_max_episodes INTEGER DEFAULT 0 to user_player_settings.

+

Settings UI: Button grid with options: Unlimited, 1, 2, 3, 5, 10 episodes. Visible only when auto-play is enabled.

+

Player Page Navigation Guard

+

Location: src/routes/player/[id]/+page.svelte

+

When the user navigates to the full player page (e.g., by swiping up on MiniPlayer), the loadAndPlay function checks whether the track is already playing before initiating new playback:

+
const alreadyPlayingMedia = get(storeCurrentMedia);
+if (alreadyPlayingMedia?.id === id && !startPosition) {
+  // Track already playing - show UI without restarting playback
+  // Fetch queue status for hasNext/hasPrevious
+  return;
+}
+
+

Why This Matters: Without this guard, navigating to the player page would restart playback with a single-track queue, destroying the existing album/playlist queue that the backend is playing. The Rust backend maintains the full queue (visible on the Android lock screen), but the frontend loadAndPlay function would overwrite it by calling player_play_tracks with just the current track.

+

Playlist Management UI

+

TRACES: UR-014 | JA-019 | JA-020

+

Location: src/lib/components/playlist/, src/lib/components/library/PlaylistDetailView.svelte

+

The playlist UI provides full CRUD operations for Jellyfin playlists with offline sync support.

+

Components:

+
    +
  • +

    CreatePlaylistModal (src/lib/components/playlist/CreatePlaylistModal.svelte):

    +
      +
    • Modal for creating new playlists with a name input
    • +
    • Accepts optional initialItemIds to pre-populate with tracks
    • +
    • Keyboard support: Enter to create, Escape to close
    • +
    • Navigates to new playlist detail page on creation
    • +
    +
  • +
  • +

    AddToPlaylistModal (src/lib/components/playlist/AddToPlaylistModal.svelte):

    +
      +
    • Modal listing all existing playlists to add tracks to
    • +
    • "New Playlist" button for inline creation flow
    • +
    • Shows playlist artwork via CachedImage
    • +
    • Loading state with skeleton placeholders
    • +
    +
  • +
  • +

    PlaylistDetailView (src/lib/components/library/PlaylistDetailView.svelte):

    +
      +
    • Full playlist detail page with artwork, name, track count, total duration
    • +
    • Click-to-rename with inline editing
    • +
    • Play all / shuffle play buttons
    • +
    • Delete with confirmation dialog
    • +
    • Per-track removal buttons
    • +
    • Uses TrackList component for track display
    • +
    • Passes { type: "playlist", playlistId, playlistName } context to player
    • +
    +
  • +
  • +

    Playlists Page (src/routes/library/music/playlists/+page.svelte):

    +
      +
    • Grid view using GenericMediaListPage
    • +
    • Floating action button (FAB) to create new playlists
    • +
    • Search by playlist name
    • +
    +
  • +
+

Frontend API Methods (src/lib/api/repository-client.ts):

+
    +
  • createPlaylist(name, itemIds?) -> PlaylistCreatedResult
  • +
  • deletePlaylist(playlistId)
  • +
  • renamePlaylist(playlistId, name)
  • +
  • getPlaylistItems(playlistId) -> PlaylistEntry[]
  • +
  • addToPlaylist(playlistId, itemIds)
  • +
  • removeFromPlaylist(playlistId, entryIds)
  • +
  • movePlaylistItem(playlistId, itemId, newIndex)
  • +
+

Offline Sync (src/lib/services/syncService.ts): +All playlist mutations are queued for offline sync:

+
    +
  • queuePlaylistCreate, queuePlaylistDelete, queuePlaylistRename
  • +
  • queuePlaylistAddItems, queuePlaylistRemoveItems, queuePlaylistReorderItem
  • +
+

App Shell and Chrome

+

Location: src/lib/utils/layoutShell.ts (pure rules), +src/lib/components/AppHeader.svelte, +src/lib/components/account/AccountMenu.svelte, BottomUi.svelte +TRACES: UR-054 | DR-075, DR-076, DR-077

+

Account actions used to be reachable only from /library/* — the header +that hosted them belonged to the library layout, the bottom nav offered Home / +Search / Library, and the desktop username was inert text. From /, /search +or /downloads there was no route to Settings or Sign out at all. The header is +now shared and rendered from the root layout.

+

Visibility rules

+

All four rules are pure functions in layoutShell.ts, so the contract is +unit-testable rather than a scattering of $derived booleans that drift per +route and platform (which is what they were):

+
+ + + + +
FunctionRule
showBottomNavEvery authenticated route except /player/* and /login
showGlobalMiniPlayerEverything except /player/*, /login, /settings. Not gated on platform or /library — the root owns the mini player everywhere, so the library route must never render a second one
routeOwnsLayout/library, /player/, /login render their own full-height flex column; everything else renders into the root scroller
showGlobalHeaderAuthenticated, not a layout-owning route, not /settings (the user is already there)
+
+

The structural fix worth not undoing

+

The "last row hidden behind the nav" bug is solved structurally, not by +measurement: the bottom UI is an in-flow flex child below the scroller +(BottomUi.svelte), so the scroller is physically bounded above it and cannot +render behind it. There is no measurement and no reserved padding. If you +restructure the shell, preserve the scroll containment — reintroducing padding +math reintroduces the bug.

+

AccountMenu

+

One component for both breakpoints, anchored to the username/avatar (a real +button with aria-expanded, not a bare three-dot icon). Fixed item order: +identity block (user + server) → Downloads, Settings, Display → divider → Sign +out, destructive and last. Dismissal is backdrop click, Escape, and focus +return to the trigger.

+

The identity block falls back to the bare host of the server URL when the server +has no human-readable name, so it always shows something server-identifying.

+

Settings' Display section and the library page-header toggle are two views onto +the same persisted viewMode store (jellytau-view-mode) — no second state, +no migration, and they stay in sync for free.

+

Library Mosaic

+

Location: src/lib/components/library/libraryMosaic.ts (pure), +MosaicGrid.svelte, MosaicTile.svelte +TRACES: UR-075, UR-067 | DR-174, DR-175

+

The library overview and the home "Your Libraries" strip are a mosaic, not a +grid: rows share one height and each tile is as wide as its own artwork is, so a +square music cover, a 16:9 library backdrop and a 2:3 poster sit in the same row +at their own proportions instead of all three being cropped into whichever box a +grid picked.

+

libraryMosaic.ts is deliberately pure — it takes the libraries and returns the +tiles to draw, so ordering and de-duplication are unit-testable rather than +buried in markup. Tiles start at an assumed aspect (square, 16:9) and a +measured image overrides it in MosaicGrid.

+

Note what this file does not decide: which favourites category a library +belongs to. That is Jellyfin vocabulary and arrives on the library itself as +favoritesScope, from SearchScope::for_collection_type in Rust (see +01-rust-backend.md). +The frontend only decides what to call it and where to put it.

+

Series and Episode Navigation

+

Location: src/lib/components/library/SeasonSection.svelte, +EpisodeFocusView.svelte, episodeStrip.ts (pure) +TRACES: UR-062 … UR-064 | DR-101 … DR-107

+

Opening a series lands the viewer where they actually are in it. "Where is this +viewer in this series" is resolved in Rust (DR-101), not by the page: the +series detail page asks the repository and anchors on the answer — the current +season expanded, the current episode highlighted and scrolled into view, and a +hero button labelled Resume S2E4 / Play S1E1.

+

A season is not a destination: /library/<seasonId> redirects to its series +(DR-103). Video library routes collapse to one per library (DR-105).

+

episodeStrip.ts holds the pure logic for the "More Episodes" strip, extracted +from the component because it had three distinct bugs that markup made +untestable: the strip collapsing to just the current episode while real siblings +existed, number-less episodes all matching as "current" (undefined === undefined), and the window dead-ending at a season boundary instead of running +past it. It matches by id first and only falls back to season+episode number when +both numbers are known on both sides.

+

Downloaded Browse

+

Location: src/lib/services/downloadedCatalog.ts, +src/lib/components/downloads/DownloadedBrowse.svelte +TRACES: UR-055, UR-056 | DR-081 … DR-085

+

/downloads is two views: Downloaded (the default) — the library filtered to +what is on the device, reusing the same grids, cards and detail pages as online +browsing — and Transfers, the in-flight progress rows demoted to a secondary +tab.

+

downloadedCatalog reads the offline-only browse path on the repository, +never the hybrid merge. That is the point: an empty result means "nothing +downloaded here", never "server unreachable", so the view is authoritative +regardless of connectivity. It also owns disk usage — a per-item/container byte +map plus the device total, aggregated by the backend from downloads.file_size +(DR-085).

+

Safe-area Insets

+

Location: src/app.css, WindowInsetsBridge.kt +TRACES: UR-066 | DR-112, IR-031

+

The Android WebView does not reliably report system-bar insets through +env(safe-area-inset-*). Native WindowInsets (systemBars() | displayCutout()) are therefore pushed in as CSS custom properties, and every +edge takes the larger of the two sources:

+
--safe-top: max(env(safe-area-inset-top, 0px), var(--jt-inset-top, 0px));
+
+

Two rules keep this from going wrong: one owner per edge (two components both +padding the top edge double-pads it), and no nested h-screen — a full-height +child inside a full-height parent that has already consumed the inset overflows +by exactly the inset.

+

Unlike addJavascriptInterface, the inset push only writes CSS properties, so it +can safely be re-sent on resume.

+

Native Video Store

+

Location: src/lib/stores/nativeVideo.ts +TRACES: UR-003, UR-004 | DR-188

+

Two separate concerns live here, deliberately:

+
    +
  • experimentalNativeVideo — the user-facing opt-in flag, defaulting to on. +Rust already decides which backend this platform has (useHtml5Element from +player_play_item); this flag only suppresses that decision. It never turns +native on where Rust says HTML5. An explicit stored choice wins in both +directions, so someone who opted out is not re-enabled by a default flip — +hence the null check rather than a bare === "true".
  • +
  • nativeVideoActive — whether a native surface is on screen right now. +Setting it toggles data-native-video on <html>, which is what the CSS in +app.css keys off to clear the app's opaque backgrounds. It is deliberately +not derived from the flag: the backgrounds must come back the moment the +player unmounts.
  • +
+

See 05-platform-backends.md +for what is behind the WebView.

+

Logging

+

Location: src/lib/utils/logger.ts +TRACES: DR-204

+

The frontend's equivalent of the Rust log crate: four levels +(debug < info < warn < error), a compile-environment default (dev → debug, +production → warn), and a runtime override that is the moral equivalent of +RUST_LOG. Scoped loggers carry the subsystem in the message, so a filtered +console stays usable while a player, a download worker and a store are all +talking.

+

Production deliberately keeps warn and error: this is a client talking to a +server that may or may not be there, and a silent failure is worse to support +than a noisy console. Only the chatter is suppressed.

+

no-console is an ESLint error, with the sink module itself the only +exception, so a raw console.* cannot re-appear.

+

Data Flow

+

Repository Query Flow (Cache-First)

+
sequenceDiagram
+    participant UI as Svelte Component
+    participant Client as RepositoryClient (TS)
+    participant Rust as Tauri Command
+    participant Hybrid as HybridRepository
+    participant Cache as OfflineRepository (SQLite)
+    participant Server as OnlineRepository (HTTP)
+    participant Conn as ConnectivityMonitor
+
+    UI->>Client: getItems(parentId)
+    Client->>Rust: invoke("repository_get_items", {handle, parentId})
+    Rust->>Hybrid: get_items()
+
+    par Parallel Racing
+        Hybrid->>Cache: get_items() with 100ms timeout
+        Hybrid->>Server: get_items() (no timeout)
+    end
+
+    Note over Server,Conn: Every server request reports its outcome
+    alt Server succeeds (or answers with 4xx/5xx)
+        Server->>Conn: mark_reachable() (server is up)
+    else Network failure / timeout
+        Server->>Conn: mark_unreachable() (debounced)
+    end
+
+    alt Cache returns with content
+        Cache-->>Hybrid: Result with items
+        Hybrid-->>Rust: Return cache result
+    else Cache timeout or empty
+        Server-->>Hybrid: Fresh result
+        Hybrid-->>Rust: Return server result
+    end
+
+    Rust-->>Client: SearchResult
+    Client-->>UI: items[]
+    Note over UI: Reactive update
+
+

Key Points:

+
    +
  • Cache queries have 100ms timeout for responsiveness
  • +
  • Server queries always run for fresh data
  • +
  • Cache wins if it has meaningful content
  • +
  • Automatic fallback to server if cache is empty/stale
  • +
  • Background cache updates (planned)
  • +
  • Connectivity side-effect: each server request feeds the ConnectivityMonitor, which is the source of truth for the offline/online banner (see 07-connectivity.md). A server-answered error (401/404/5xx) still counts as reachable — only network failures, sustained past a debounce window, flip the app to offline.
  • +
+

Search Flow (Locally Indexed)

+

TRACES: UR-065 | DR-108 … DR-111, IR-030

+

Search does not depend on a per-keystroke round trip to Jellyfin. The instant leg +reads the local SQLite catalog, which is already synced and already +FTS5-indexed, so results appear as fast as SQLite can answer — online or offline. +The server query stays, demoted to a background reconciliation that merges in +late results.

+
sequenceDiagram
+    participant UI as Search UI
+    participant Rust as repository_search
+    participant Cache as Local catalog (FTS5)
+    participant Server as Jellyfin
+    participant Indexer as spawn_catalog_indexer
+
+    UI->>Rust: search(query, scope)
+    Rust->>Cache: FTS5 query, scope expanded by SearchScope::item_types()
+    Cache-->>UI: instant results
+    Rust->>Server: reconciliation query (background)
+    Server-->>UI: search-event with late/merged results
+    Note over Indexer,Cache: Independent of any query:<br/>scheduled crawl keeps the index fresh,<br/>prunes items deleted on the server
+
+

Key points:

+
    +
  • The scope is opaque on the wire. The frontend sends a SearchScope +variant; Rust expands it to item types +(01-rust-backend.md).
  • +
  • Index freshness is a Rust policy, not a frontend startup call — a scheduled +background pass, not "whatever was synced when the app last launched" +(DR-109). See +Background workers.
  • +
  • Index hygiene matters as much as freshness: the catalog save path uses +INSERT OR REPLACE and the crawl prunes rows for content deleted on the +server, or search keeps returning items that no longer exist (DR-110).
  • +
  • The index covers exactly the types the result groups render (DR-111) — +including Artists, which the crawl must reach or the Artists group is silently +always empty.
  • +
+

Deliberately not done, with reasons:

+
    +
  • Incremental indexing (Jellyfin's MinDateLastSaved). A full crawl is +what makes the deletion sweep sound — it yields the authoritative id set per +library, and an incremental pass cannot detect deletions. Worth revisiting if +full crawls prove slow on large libraries; measure first.
  • +
  • Removing the server leg. The reconciliation query stays.
  • +
+
+

⚠️ Two dead search implementations still exist: storage_search_items +(commands/storage/mod.rs) and offline_search (commands/offline.rs). Both +are registered in lib.rs and exported to bindings.ts; neither is called +from the frontend. Deleting them is correct and unclaimed.

+
+

Playback Initiation Flow

+
sequenceDiagram
+    participant User
+    participant AudioPlayer
+    participant Tauri as Tauri IPC
+    participant Command as player_play_item()
+    participant Controller as PlayerController
+    participant Backend as PlayerBackend
+    participant Store as Frontend Store
+
+    User->>AudioPlayer: clicks play
+    AudioPlayer->>Tauri: invoke("player_play_item", {item})
+    Tauri->>Command: player_play_item()
+    Command->>Command: Convert PlayItemRequest -> MediaItem
+    Command->>Controller: play_item(item)
+    Controller->>Backend: load(item)
+    Note over Backend: State -> Loading
+    Controller->>Backend: play()
+    Note over Backend: State -> Playing
+    Controller-->>Command: Ok(())
+    Command-->>Tauri: PlayerStatus {state, position, duration, volume}
+    Tauri-->>AudioPlayer: status
+    AudioPlayer->>Store: player.setPlaying(media, position, duration)
+    Note over Store: UI updates reactively
+
+

Playback Mode Transfer Flow

+
sequenceDiagram
+    participant UI as Cast Button
+    participant Store as playbackMode store
+    participant Rust as Tauri Command
+    participant Manager as PlaybackModeManager
+    participant Player as PlayerController
+    participant Jellyfin as Jellyfin API
+
+    UI->>Store: transferToRemote(sessionId)
+    Store->>Rust: invoke("playback_mode_transfer_to_remote", {sessionId})
+    Rust->>Manager: transfer_to_remote()
+
+    Manager->>Player: Get current queue
+    Player-->>Manager: Vec<MediaItem>
+    Manager->>Manager: Extract Jellyfin IDs
+
+    Manager->>Jellyfin: POST /Sessions/{id}/Playing<br/>{itemIds, startIndex}
+    Jellyfin-->>Manager: 200 OK
+
+    Manager->>Jellyfin: POST /Sessions/{id}/Playing/Seek<br/>{positionTicks}
+    Jellyfin-->>Manager: 200 OK
+
+    Manager->>Player: stop()
+    Manager->>Manager: mode = Remote {sessionId}
+
+    Manager-->>Rust: Ok(())
+    Rust-->>Store: PlaybackMode
+    Store->>UI: Update cast icon
+
+

Queue Navigation Flow

+
flowchart TB
+    User["User clicks Next"] --> Invoke["invoke('player_next')"]
+    Invoke --> ControllerNext["controller.next()"]
+    ControllerNext --> QueueNext["queue.next()<br/>- Check repeat mode<br/>- Check shuffle<br/>- Update history"]
+
+    QueueNext --> None["None<br/>(at end)"]
+    QueueNext --> Some["Some(next)"]
+    QueueNext --> Same["Same<br/>(repeat one)"]
+
+    Some --> PlayItem["play_item(next)<br/>Returns new status"]
+
+

Volume Control Flow

+
sequenceDiagram
+    participant User
+    participant Slider as Volume Slider
+    participant Handler as handleVolumeChange()
+    participant Tauri as Tauri IPC
+    participant Command as player_set_volume
+    participant Controller as PlayerController
+    participant Backend as MpvBackend/NullBackend
+    participant Events as playerEvents.ts
+    participant Store as Player Store
+    participant UI
+
+    User->>Slider: adjusts (0-100)
+    Slider->>Handler: oninput event
+    Handler->>Handler: Convert 0-100 -> 0.0-1.0
+    Handler->>Tauri: invoke("player_set_volume", {volume})
+    Tauri->>Command: player_set_volume
+    Command->>Controller: set_volume(volume)
+    Controller->>Backend: set_volume(volume)
+    Backend->>Backend: Clamp to 0.0-1.0
+    Note over Backend: MpvBackend: Send to MPV loop
+    Backend-->>Tauri: emit "player-event"
+    Tauri-->>Events: VolumeChanged event
+    Events->>Store: player.setVolume(volume)
+    Store-->>UI: Reactive update
+    Note over UI: Both AudioPlayer and<br/>MiniPlayer stay in sync
+
+

Key Implementation Details:

+
    +
  • Volume is stored in the backend (NullBackend/MpvBackend)
  • +
  • PlayerController.volume() delegates to backend
  • +
  • get_player_status() returns controller.volume() (not hardcoded)
  • +
  • Frontend uses normalized 0.0-1.0 scale, UI shows 0-100
  • +
+

Type Synchronization & Thread Safety

+

PlayerState (Rust <-> TypeScript)

+

Rust:

+
#![allow(unused)]
+fn main() {
+pub enum PlayerState {
+    Idle,
+    Loading { media: MediaItem },
+    Playing { media: MediaItem, position: f64, duration: f64 },
+    Paused { media: MediaItem, position: f64, duration: f64 },
+    Seeking { media: MediaItem, target: f64 },
+    Error { media: Option<MediaItem>, error: String },
+}
+}
+

TypeScript:

+
type PlayerState =
+  | { kind: "idle" }
+  | { kind: "loading"; media: MediaItem }
+  | { kind: "playing"; media: MediaItem; position: number; duration: number }
+  | { kind: "paused"; media: MediaItem; position: number; duration: number }
+  | { kind: "seeking"; media: MediaItem; target: number }
+  | { kind: "error"; media: MediaItem | null; error: string };
+
+

MediaItem Serialization

+
#![allow(unused)]
+fn main() {
+// Rust (serde serialization)
+#[derive(Serialize, Deserialize)]
+pub struct MediaItem {
+    pub id: String,
+    pub title: String,
+    #[serde(skip_serializing_if = "Option::is_none")]
+    pub artist: Option<String>,
+    // ...
+}
+}
+
// TypeScript
+interface MediaItem {
+  id: string;
+  title: string;
+  artist?: string;
+  // ...
+}
+
+

Tauri v2 IPC Parameter Naming Convention

+

CRITICAL: Tauri v2's #[tauri::command] macro automatically converts snake_case Rust parameter names to camelCase for the frontend. All invoke() calls must use camelCase for top-level parameters.

+

Rule: Rust fn cmd(repository_handle: String) -> Frontend sends { repositoryHandle: "..." }

+
// CORRECT - Tauri v2 auto-converts snake_case -> camelCase
+await invoke("player_play_tracks", {
+  repositoryHandle: "handle-123",     // Rust: repository_handle
+  request: { trackIds: ["id1"], startIndex: 0 }
+});
+
+await invoke("remote_send_command", {
+  sessionId: "session-123",           // Rust: session_id
+  command: "PlayPause"
+});
+
+await invoke("pin_item", {
+  itemId: "item-123"                  // Rust: item_id
+});
+
+// WRONG - snake_case causes "invalid args request" error on Android
+await invoke("player_play_tracks", {
+  repository_handle: "handle-123",    // Will fail!
+});
+
+

Parameter Name Mapping (Rust -> Frontend):

+
+ + + + + + + +
Rust ParameterFrontend ParameterUsed By
repository_handlerepositoryHandleplayer_play_tracks, player_add_track_by_id, player_play_album_track
session_idsessionIdremote_send_command, remote_play_on_session, remote_session_seek
item_iditemIdpin_item, unpin_item
current_item_idcurrentItemIdplayback_mode_transfer_to_local
position_tickspositionTicksplayback_mode_transfer_to_local, remote_session_seek
item_idsitemIdsremote_play_on_session
start_indexstartIndexremote_play_on_session
+
+

Nested struct fields use #[serde(rename_all = "camelCase")] separately - this is serde deserialization, not the command macro. Both layers convert independently.

+

Test Coverage: Integration tests in src/lib/utils/tauriIntegration.test.ts validate all invoke calls use correct camelCase parameter names.

+

Rust Backend Thread Safety

+
#![allow(unused)]
+fn main() {
+// Shared state wrapped in Arc<Mutex<>>
+pub struct PlayerController {
+    backend: Arc<Mutex<Box<dyn PlayerBackend>>>,
+    queue: Arc<Mutex<QueueManager>>,
+    // ...
+}
+
+// Tauri state wrapper
+pub struct PlayerStateWrapper(pub Mutex<PlayerController>);
+
+// Command handler pattern
+#[tauri::command]
+pub fn player_play(state: State<PlayerStateWrapper>) -> Result<PlayerStatus, String> {
+    let mut controller = state.0.lock().unwrap();  // Acquire lock
+    controller.play()?;                             // Operate
+    Ok(get_player_status(&controller))              // Lock released
+}
+}
+

Frontend Stores

+

Svelte stores are inherently reactive and thread-safe for UI updates:

+
const { subscribe, update } = writable<PlayerStore>(initialState);
+
+// Atomic updates
+function setPlaying(media: MediaItem, position: number, duration: number) {
+  update(state => ({
+    ...state,
+    state: { kind: "playing", media, position, duration }
+  }));
+}
+
+

Platform-Specific Player Backends

+

Player Events System

+

Location: src-tauri/src/player/events.rs

+

The player uses a push-based event system to notify the frontend of state changes:

+
#![allow(unused)]
+fn main() {
+pub enum PlayerStatusEvent {
+    /// Playback position updated (emitted periodically during playback)
+    PositionUpdate { position: f64, duration: f64 },
+
+    /// Player state changed
+    StateChanged { state: String, media_id: Option<String> },
+
+    /// Media has finished loading and is ready to play
+    MediaLoaded { duration: f64 },
+
+    /// Playback has ended naturally
+    PlaybackEnded,
+
+    /// Buffering state changed
+    Buffering { percent: u8 },
+
+    /// An error occurred during playback
+    Error { message: String, recoverable: bool },
+
+    /// Volume changed
+    VolumeChanged { volume: f32, muted: bool },
+
+    /// Sleep timer state changed
+    SleepTimerChanged {
+        mode: SleepTimerMode,
+        remaining_seconds: u32,
+    },
+
+    /// Show next episode popup with countdown
+    ShowNextEpisodePopup {
+        current_episode: MediaItem,
+        next_episode: MediaItem,
+        countdown_seconds: u32,
+        auto_advance: bool,
+    },
+
+    /// Countdown tick (emitted every second during autoplay countdown)
+    CountdownTick { remaining_seconds: u32 },
+
+    /// Queue changed (items added, removed, reordered, or playback mode changed)
+    QueueChanged {
+        items: Vec<MediaItem>,
+        current_index: Option<usize>,
+        shuffle: bool,
+        repeat: RepeatMode,
+        has_next: bool,
+        has_previous: bool,
+    },
+
+    /// Media session changed (activity context changed: Audio/Movie/TvShow/Idle)
+    SessionChanged { session: MediaSessionType },
+}
+}
+

Events are emitted via Tauri's event system:

+
flowchart LR
+    subgraph Backend["Player Backend"]
+        MPV["MPV/ExoPlayer"]
+    end
+
+    subgraph EventSystem["Event System"]
+        Emitter["TauriEventEmitter<br/>emit()"]
+        Bus["Tauri Event Bus<br/>'player-event'"]
+    end
+
+    subgraph Frontend["Frontend"]
+        Listener["playerEvents.ts<br/>Frontend Listener"]
+        Store["Player Store Update<br/>(position, state, etc)"]
+    end
+
+    MPV --> Emitter --> Bus --> Listener --> Store
+
+

Frontend Listener (src/lib/services/playerEvents.ts):

+
    +
  • Listens for player-event Tauri events
  • +
  • Updates player/queue stores based on event type
  • +
  • Auto-advances to next track on PlaybackEnded
  • +
  • On StateChanged events, calls invoke("player_get_queue") to update appState.hasNext/hasPrevious -- this enables MiniPlayer skip button state
  • +
+

Important: The command is player_get_queue (returns QueueStatus with hasNext/hasPrevious). There is no player_get_queue_status command.

+

HTML5 Video Adapter (webview-rendered video)

+

Location: src/lib/player/html5Adapter.ts, src/lib/player/index.ts, report commands in +src-tauri/src/commands/player/timers.rs

+

Video on desktop (Linux WebKitGTK) — and, per current interim behavior, Android — is rendered by an +HTML5 <video>/HLS element inside the webview. libmpv is initialized audio-only (vo=null, +video=false), so the native backend cannot render or observe this element. The <video> is therefore +the real player, living outside Rust's reach.

+

To keep the PlayerController the single source of truth (matching the audio path), the HTML5 element +is treated as a dumb output device that reports back into Rust, rather than an independent state +authority:

+
flowchart LR
+    subgraph Webview["Webview"]
+        Video["HTML5 <video> / HLS.js"]
+        Adapter["html5Adapter.ts<br/>(reports DOM events)"]
+    end
+    subgraph Backend["Rust"]
+        Cmds["player_report_state<br/>player_report_position<br/>player_report_media_loaded"]
+        Controller["PlayerController"]
+        Emitter["TauriEventEmitter"]
+    end
+    subgraph Frontend["Frontend"]
+        Events["playerEvents.ts"]
+        Store["player store"]
+    end
+
+    Video -->|DOM events| Adapter --> Cmds --> Controller --> Emitter --> Events --> Store
+
+

Key points:

+
    +
  • The adapter re-emits the same PlayerStatusEvents (StateChanged, PositionUpdate, MediaLoaded) +the native backends emit, so playerEvents.ts needs no HTML5-specific branch — HTML5 is just +another event source feeding the existing pipeline.
  • +
  • Position reports are throttled (~250ms) to match the MPV cadence and avoid flooding IPC from the +60fps RAF loop.
  • +
  • Boundary rule: UI components never touch the report commands or videoElement state directly. +Playback control goes through the unified facade src/lib/player/index.ts (playerController); +HTML5 state reporting goes through html5Adapter.ts. This restores the documented invariant +("frontend only displays state and invokes commands") for the video path.
  • +
+

MpvBackend (Linux)

+

Location: src-tauri/src/player/mpv/

+

The MPV backend uses libmpv for audio playback on Linux. Since MPV handles are not Send, all operations occur on a dedicated thread.

+
flowchart TB
+    subgraph MainThread["Main Thread"]
+        MpvBackend["MpvBackend<br/>- command_tx<br/>- shared_state<br/>- shutdown"]
+        Commands["Commands:<br/>Load, Play, Pause<br/>Stop, Seek, SetVolume"]
+    end
+
+    subgraph EventLoopThread["MPV Event Loop Thread"]
+        EventLoop["event_loop.rs<br/>- MPV Handle<br/>- command_rx<br/>- Event Emitter"]
+        TauriEmitter["TauriEventEmitter"]
+    end
+
+    MpvBackend -->|"MpvCommand"| EventLoop
+    MpvBackend <-->|"Arc<Mutex<>>"| EventLoop
+    EventLoop -->|"Events"| TauriEmitter
+    TauriEmitter --> FrontendStore["Frontend Store"]
+
+

Key Components:

+
#![allow(unused)]
+fn main() {
+// Command enum sent to event loop thread
+pub enum MpvCommand {
+    Load { url: String, media: MediaItem },
+    Play,
+    Pause,
+    Stop,
+    Seek(f64),
+    SetVolume(f32),
+    Quit,
+}
+
+// Shared state between main thread and event loop
+pub struct MpvSharedState {
+    pub state: PlayerState,
+    pub position: f64,
+    pub duration: Option<f64>,
+    pub volume: f32,
+    pub is_loaded: bool,
+    pub current_media: Option<MediaItem>,
+}
+}
+

Event Loop (event_loop.rs):

+
    +
  • Initializes MPV with audio-only config (vo=null, video=false)
  • +
  • Observes properties: time-pos, duration, pause, volume
  • +
  • Emits position updates every 250ms during playback
  • +
  • Processes commands from channel (non-blocking)
  • +
  • Handles MPV events: FileLoaded, EndFile, PropertyChange
  • +
+

ExoPlayerBackend (Android)

+

Location: src-tauri/src/player/android/ and Kotlin sources

+

The ExoPlayer backend uses Android's Media3/ExoPlayer library via JNI.

+
flowchart TB
+    subgraph RustNative["Rust (Native)"]
+        ExoBackend["ExoPlayerBackend<br/>- player_ref<br/>- shared_state"]
+        NativeFuncs["JNI Callbacks<br/>nativeOnPosition...<br/>nativeOnState...<br/>nativeOnMediaLoaded<br/>nativeOnPlaybackEnd"]
+        TauriEmitter2["TauriEventEmitter"]
+    end
+
+    subgraph KotlinJVM["Kotlin (JVM)"]
+        JellyTauPlayer["JellyTauPlayer<br/>- ExoPlayer<br/>- Player.Listener"]
+    end
+
+    ExoBackend -->|"JNI Calls"| JellyTauPlayer
+    JellyTauPlayer -->|"Callbacks"| NativeFuncs
+    NativeFuncs --> TauriEmitter2
+    TauriEmitter2 --> FrontendStore2["Frontend Store"]
+
+

Kotlin Player (JellyTauPlayer.kt):

+
class JellyTauPlayer(context: Context) {
+    private val exoPlayer: ExoPlayer
+    private var positionUpdateJob: Job?
+
+    // Methods callable from Rust via JNI
+    fun load(url: String, mediaId: String)
+    fun play()
+    fun pause()
+    fun stop()
+    fun seek(positionSeconds: Double)
+    fun setVolume(volume: Float)
+
+    // Native callbacks to Rust
+    private external fun nativeOnPositionUpdate(position: Double, duration: Double)
+    private external fun nativeOnStateChanged(state: String, mediaId: String?)
+    private external fun nativeOnMediaLoaded(duration: Double)
+    private external fun nativeOnPlaybackEnded()
+}
+
+

JNI Callbacks (Rust):

+
#![allow(unused)]
+fn main() {
+#[no_mangle]
+pub extern "system" fn Java_com_dtourolle_jellytau_player_JellyTauPlayer_nativeOnPositionUpdate(
+    _env: JNIEnv, _class: JClass, position: jdouble, duration: jdouble
+) {
+    // Update shared state
+    // Emit PlayerStatusEvent::PositionUpdate
+}
+}
+

Audio settings on ExoPlayer

+

TRACES: UR-027, UR-032, UR-033 | DR-030, DR-035, DR-036

+

PlayerBackend declares set_audio_settings with a default Ok(()) body. For a +long time ExoPlayerBackend took that default, so Settings › Audio rendered +controls that silently did nothing on Android — the parity gap recorded in +requirements.md, +now closed.

+

The settings cross to Kotlin as JSON over JNI, not as a wide signature, so new +fields do not change the method signature — the same approach load() uses for +subtitles:

+
#![allow(unused)]
+fn main() {
+fn set_audio_settings(&mut self, settings: &AudioSettings) -> Result<(), PlayerError> {
+    let json = audio_settings_jni_payload(settings)?;
+    env.call_method(&self.player_ref, "setAudioSettings", "(Ljava/lang/String;)V", …)?;
+    // Store the sanitised form, so audio_settings() reflects what was applied.
+    self.shared_state.lock_safe().audio_settings =
+        settings.clone().with_crossfade_clamped().with_equalizer_normalised();
+}
+}
+

Kotlin owns the mechanics — attaching AudioEffects to the audio session — while +the canonical band layout and preset curves stay in Rust:

+
+ + + + +
FeatureAndroid mechanismNotes
GaplesspauseAtEndOfMediaItems
Volume normalizationLoudnessEnhancerA gain stage — approximate next to MPV's dynaudnorm
Equalizerandroid.media.audiofx.EqualizerThe canonical 10 bands are resampled onto the device's own band centres
CrossfadeUnimplemented on every platform (DR-034), architecturally blocked on MPV. Building it on Android alone would invert the parity gap
+
+

Two things are deliberately still open: the effects are not yet verified on a +physical device (AudioEffect availability and band layouts are device-specific), +and the trait default is still a silent Ok(()) rather than an error, so a backend +that omits the method still reports success. Flipping that default waits on the +device verification.

+

The equalizer, and where its vocabulary lives

+

TRACES: UR-027 | DR-030, IR-020

+

The canonical band layout (EQ_BANDS) and the preset curves live in +settings.rs, not in either backend and not in the UI: a preset is a gain +curve defined by the band layout, and the layout is a property of the audio +engine rather than of the picker that renders it. Presets are Flat, Rock, Pop, +Jazz, Classical, Bass Boost, Treble Boost and Vocal, all conservative (within +±8 dB) so they stack safely with volume normalization.

+
+ + +
PlatformMechanism
LinuxOne ffmpeg two-pole peaking equalizer filter per band, composed by build_af_filter into MPV's af property alongside the normalization filter: equalizer=f=31:width_type=o:width=1:g=5
Androidandroid.media.audiofx.Equalizer, with the canonical 10 bands resampled onto whatever band centres the device actually has
+
+

Gains are normalised (with_equalizer_normalised) before use, and bands beyond +EQ_BANDS are ignored, so a malformed settings payload cannot produce a filter +chain of unbounded length.

+

Background Audio Handoff (Android)

+

TRACES: UR-040 | IR-025, DR-051, DR-052, DR-178 … DR-180, DR-196, DR-203

+

Keeping a video's audio alive when the app is backgrounded or the screen +locks, while video decode stops. Two verified facts drive the whole design:

+
    +
  1. An Android WebView <video> does not keep playing audio once the app is +backgrounded — the system throttles the WebView and media pauses.
  2. +
  3. Keeping audio alive in the background requires a native foreground media +service, which already exists for music (JellyTauPlaybackService + +JellyTauPlayer + MediaSessionCompat).
  4. +
+

So this is a handoff, not "keep the WebView alive": on background, tear down +the current renderer and play the same item audio-only through the native +service; on foreground, hand back. In the project's one-directional playback +model this is a change of which player is authoritative, and the position must +transfer cleanly across it.

+
sequenceDiagram
+    participant App as App backgrounded
+    participant FE as VideoPlayer
+    participant Rust as player_enter/exit_background_audio
+    participant Exo as Native audio service
+
+    App-->>FE: jellytau-background (DOM CustomEvent)
+    FE->>Rust: enter(item, position, audioStreamIndex)
+    Rust->>Exo: play audio-only at position
+    Note over Exo: lockscreen + notification, existing MediaSession
+    App-->>FE: jellytau-foreground
+    FE->>Rust: exit() -> final position
+    Rust-->>FE: position
+    FE->>FE: restart the renderer that is on screen
+
+

Details that were each a shipped defect:

+
    +
  • Position is absolute. Transcoded HLS tracks time as +videoElement.currentTime + seekOffset (the element resets to 0 after each +transcode reload). computeHandoffPosition sums both terms; using the element +time alone rewinds by the offset.
  • +
  • A downloaded episode takes no base URL and an ordinary seek (DR-180); a +stream takes the base and no seek; a handoff at 0:00 takes neither.
  • +
  • The return must restart the renderer that is actually on screen (DR-196). +The two paths resume by different means — the webview <video> reloads off its +stream URL, watched by an $effect; ExoPlayer owns no element and nothing +watches the URL for it, so it needs an explicit re-issue. Doing only the URL +assignment restarted nothing on the native path and left a black screen with a +play button that did nothing.
  • +
  • wasPlaying is captured on the way out so play/pause survives the round +trip, and the handoff does not silently rewind (DR-203).
  • +
  • Mutually exclusive with PiP. Toggle on → setAutoEnterEnabled(false); +toggle off → PiP on background, the status quo. The frontend re-asserts the +value whenever the toggle changes and on unmount, so a stale setting cannot +leak into the next player.
  • +
  • The pure arithmetic and state transitions live in +backgroundAudioHandoff.ts, free of Svelte and the DOM, so they are testable +without mounting the player.
  • +
+

Native signals background/foreground to the frontend as DOM CustomEvents +(jellytau-background / jellytau-foreground); the frontend carries the toggle +state to native through the AndroidBackgroundAudio bridge. No-op on every +non-Android platform.

+

Native Video Compositing (Android)

+

TRACES: UR-003, UR-004 | DR-150 … DR-152, DR-182 … DR-196

+

Android can render video on the native ExoPlayer surface behind a transparent +Tauri WebView, with the Svelte controls drawn over it. This is on by default; +the HTML5 <video> path remains the fallback and is not being removed. The +default has been flipped and reverted twice and each revert has a named cause — +the per-defect record is in requirements.md (DR-150 … DR-196).

+
flowchart TB
+    subgraph Window["One Android window"]
+        Texture["TextureView (index 0)<br/>ExoPlayer video"]
+        WebView["Tauri WebView (above)<br/>transparent, Svelte controls"]
+    end
+    Rust["ExoPlayerBackend"] -->|JNI| Player["JellyTauPlayer"]
+    Player --> Texture
+    MainActivity -->|"setTransparent(true)"| WebView
+    VideoOverlayManager -->|"attach / detach"| Texture
+
+

Load-bearing details, each of which was a shipped defect:

+
    +
  • TextureView, not SurfaceView (DR-192). A SurfaceView renders on its own +layer outside the app window and punches a transparent hole through it; +everything drawn above that hole — for us the whole UI — depends on that +composition path, which Android's own documentation says does not reliably +work. A TextureView makes "behind" ordinary view z-order within one window.
  • +
  • Attached at index 0 by VideoOverlayManager, and detached when the video +goes (DR-184) — a surface left in the hierarchy outlives its player.
  • +
  • Bridges are installed before the page that uses them (DR-183). +addJavascriptInterface must run once per WebView instance and a call that +lands after the page has loaded never reaches it, so setTransparent(true) +could be dropped entirely.
  • +
  • The app shell stops painting over the surface (DR-185). app.css clears +its opaque backgrounds off [data-native-video]; before that, a CSS rule +targeted an attribute nothing ever set, so the fix looked applied and was not.
  • +
  • The poster card can lift on a path with no <video> element (DR-182) — the +native reveal fires on a playing state or a position tick carrying a position +or duration, and on nothing else.
  • +
  • Letterbox bars are painted, not left holding whatever was last in the +framebuffer (DR-194).
  • +
  • There is deliberately no audio-focus bridge: manual focus requests from the +WebView competed with Chromium's AudioFocusDelegate and with ExoPlayer, and +the resulting AUDIOFOCUS_LOSS paused playback.
  • +
+

Related Kotlin pieces in the same window: PictureInPictureManager (DR-160/161), +ScreenWakeManager (DR-202 — Android counts its display timeout from touch +events, which a playing video does not generate), ImmersiveModeBridge and +WindowInsetsBridge (IR-031/DR-112 — see +02-svelte-frontend.md).

+

Android MediaSession & Remote Volume Control

+

Location: JellyTauPlaybackService.kt

+

JellyTau uses a dual MediaSession architecture for Android to support both Media3 playback controls and remote volume control:

+
flowchart TB
+    subgraph Service["JellyTauPlaybackService"]
+        MediaSession["Media3 MediaSession<br/>- Lockscreen controls<br/>- Media notifications<br/>- Play/Pause/Next/Previous"]
+
+        MediaSessionCompat["MediaSessionCompat<br/>- Remote volume control<br/>- Hardware button interception"]
+
+        VolumeProvider["VolumeProviderCompat<br/>- onSetVolumeTo()<br/>- onAdjustVolume()"]
+
+        MediaSessionCompat --> VolumeProvider
+    end
+
+    subgraph Hardware["System"]
+        VolumeButtons["Hardware Volume Buttons"]
+        Lockscreen["Lockscreen Controls"]
+        Notification["Media Notification"]
+    end
+
+    subgraph Rust["Rust Backend"]
+        JNI["JNI Callbacks<br/>nativeOnRemoteVolumeChange()"]
+        PlaybackMode["PlaybackModeManager<br/>send_remote_volume_command()"]
+        JellyfinAPI["Jellyfin API<br/>session_set_volume()"]
+    end
+
+    VolumeButtons --> VolumeProvider
+    Lockscreen --> MediaSession
+    Notification --> MediaSession
+
+    VolumeProvider --> JNI
+    JNI --> PlaybackMode
+    PlaybackMode --> JellyfinAPI
+
+

Architecture Rationale:

+

JellyTau maintains both MediaSession types because they serve different purposes:

+
    +
  1. Media3 MediaSession: Handles lockscreen/notification playback controls (play/pause/next/previous)
  2. +
  3. MediaSessionCompat: Intercepts hardware volume button presses for remote playback control
  4. +
+

When in remote playback mode (controlling a Jellyfin session on another device):

+
    +
  • Volume buttons are routed through VolumeProviderCompat
  • +
  • Volume changes are sent to the remote session via Jellyfin API
  • +
  • System volume UI shows the remote session's volume level
  • +
+

Remote Volume Flow:

+
sequenceDiagram
+    participant User
+    participant VolumeButton as Hardware Volume Button
+    participant VolumeProvider as VolumeProviderCompat
+    participant JNI as nativeOnRemoteVolumeChange
+    participant PlaybackMode as PlaybackModeManager
+    participant Jellyfin as Jellyfin Server
+    participant RemoteSession as Remote Session (TV/Browser)
+
+    User->>VolumeButton: Press Volume Up
+    VolumeButton->>VolumeProvider: onAdjustVolume(ADJUST_RAISE)
+    VolumeProvider->>VolumeProvider: remoteVolumeLevel += 2
+    VolumeProvider->>VolumeProvider: currentVolume = remoteVolumeLevel
+    VolumeProvider->>JNI: nativeOnRemoteVolumeChange("VolumeUp", level)
+    JNI->>PlaybackMode: send_remote_volume_command("VolumeUp", level)
+    PlaybackMode->>Jellyfin: POST /Sessions/{id}/Command/VolumeUp
+    Jellyfin->>RemoteSession: Set volume to new level
+    RemoteSession-->>User: Volume changes on TV/Browser
+
+

Key Implementation Details:

+

Enabling Remote Volume (enableRemoteVolume()):

+
fun enableRemoteVolume(initialVolume: Int) {
+    volumeProvider = object : VolumeProviderCompat(
+        VolumeProviderCompat.VOLUME_CONTROL_ABSOLUTE,
+        100,  // Max volume
+        initialVolume
+    ) {
+        override fun onSetVolumeTo(volume: Int) {
+            remoteVolumeLevel = volume.coerceIn(0, 100)
+            nativeOnRemoteVolumeChange("SetVolume", remoteVolumeLevel)
+        }
+
+        override fun onAdjustVolume(direction: Int) {
+            when (direction) {
+                AudioManager.ADJUST_RAISE -> {
+                    remoteVolumeLevel = (remoteVolumeLevel + 2).coerceAtMost(100)
+                    nativeOnRemoteVolumeChange("VolumeUp", remoteVolumeLevel)
+                    currentVolume = remoteVolumeLevel
+                }
+                AudioManager.ADJUST_LOWER -> {
+                    remoteVolumeLevel = (remoteVolumeLevel - 2).coerceAtLeast(0)
+                    nativeOnRemoteVolumeChange("VolumeDown", remoteVolumeLevel)
+                    currentVolume = remoteVolumeLevel
+                }
+            }
+        }
+    }
+
+    mediaSessionCompat.setPlaybackToRemote(volumeProvider)
+}
+
+

Disabling Remote Volume (disableRemoteVolume()):

+
fun disableRemoteVolume() {
+    mediaSessionCompat.setPlaybackToLocal(AudioManager.STREAM_MUSIC)
+    volumeProvider = null
+}
+
+

Rust Integration (src-tauri/src/player/android/mod.rs):

+
#![allow(unused)]
+fn main() {
+/// Enable remote volume control on Android
+pub fn enable_remote_volume(initial_volume: i32) -> Result<(), String> {
+    start_playback_service()?;
+    let service_instance = get_playback_service_instance()?;
+    env.call_method(&service_instance, "enableRemoteVolume", "(I)V",
+        &[JValue::Int(initial_volume)])?;
+    Ok(())
+}
+}
+

Dependencies (src-tauri/android/build.gradle.kts):

+
dependencies {
+    implementation("androidx.media3:media3-session:1.5.1")  // Media3 MediaSession
+    implementation("androidx.media:media:1.7.0")            // MediaSessionCompat
+}
+
+

Integration with Playback Mode:

+

Remote volume is automatically enabled/disabled during playback mode transfers:

+
#![allow(unused)]
+fn main() {
+// In PlaybackModeManager::transfer_to_remote()
+#[cfg(target_os = "android")]
+{
+    if let Err(e) = crate::player::enable_remote_volume(50) {
+        log::warn!("Failed to enable remote volume: {}", e);
+    }
+}
+
+// In PlaybackModeManager::transfer_to_local()
+#[cfg(target_os = "android")]
+{
+    if let Err(e) = crate::player::disable_remote_volume() {
+        log::warn!("Failed to disable remote volume: {}", e);
+    }
+}
+}
+

Android Album Art Caching

+

Location: src-tauri/android/src/main/java/com/dtourolle/jellytau/player/AlbumArtCache.kt

+

Album art caching provides efficient bitmap storage for lock screen notifications with automatic LRU eviction and memory management.

+
flowchart TB
+    subgraph JellyTauPlayer["JellyTauPlayer.kt"]
+        LoadMedia["loadWithMetadata()<br/>- Store artworkUrl<br/>- Launch async download"]
+        AsyncDownload["Coroutine<br/>- Non-blocking<br/>- Dispatchers.IO"]
+    end
+
+    subgraph Cache["AlbumArtCache.kt"]
+        MemoryCache["LruCache<String, Bitmap><br/>- 1/8 of heap<br/>- ~12-16MB typical<br/>- 50-100 albums capacity"]
+        Download["Download & Scale<br/>- 512x512 max<br/>- Exponential backoff"]
+        ErrorHandle["Error Handling<br/>- Graceful fallback<br/>- Auto-retry"]
+    end
+
+    subgraph Service["JellyTauPlaybackService.kt"]
+        UpdateMeta["updateMediaMetadata()<br/>- Accept Bitmap parameter<br/>- Add METADATA_KEY_ALBUM_ART"]
+        Notification["Notification<br/>- setLargeIcon()<br/>- Lock screen display"]
+    end
+
+    LoadMedia --> AsyncDownload
+    AsyncDownload --> MemoryCache
+    MemoryCache --> Download
+    Download --> ErrorHandle
+    AsyncDownload --> UpdateMeta
+    UpdateMeta --> Notification
+
+

AlbumArtCache Singleton:

+
class AlbumArtCache(context: Context) {
+    private val memoryCache = object : LruCache<String, Bitmap>(cacheSize) {
+        override fun sizeOf(key: String, bitmap: Bitmap): Int {
+            return bitmap.byteCount / 1024  // Size in KB
+        }
+    }
+
+    suspend fun getArtwork(url: String): Bitmap? {
+        memoryCache.get(url)?.let { return it }
+        return downloadAndCache(url)
+    }
+
+    private suspend fun downloadAndCache(url: String): Bitmap? =
+        withContext(Dispatchers.IO) {
+            // HTTP download with 5s timeout
+            // Scale to 512x512 max
+            // Auto-evict LRU if needed
+        }
+}
+
+

Integration Flow:

+
    +
  1. +

    Track Load (loadWithMetadata()):

    +
      +
    • Store artwork URL in currentArtworkUrl
    • +
    • Reset bitmap to null
    • +
    • Start playback immediately (non-blocking)
    • +
    +
  2. +
  3. +

    Async Download (Background Coroutine):

    +
      +
    • Check cache: instant hit if available
    • +
    • Network miss: download, scale, cache
    • +
    • Auto-retry on network failure with exponential backoff
    • +
    • Graceful fallback if artwork unavailable
    • +
    +
  4. +
  5. +

    Notification Update:

    +
      +
    • Pass bitmap to updatePlaybackServiceNotification()
    • +
    • Add to MediaMetadataCompat with METADATA_KEY_ALBUM_ART
    • +
    • Display as large icon in notification
    • +
    • Show on lock screen
    • +
    +
  6. +
+

Memory Management:

+
+ + + + + + +
MetricValue
Cache Size1/8 of heap (12-16MB typical)
Max Resolution512x512 pixels
Capacity~50-100 album arts
Eviction PolicyLRU (Least Recently Used)
LifetimeIn-memory only (app session)
Network Timeout5 seconds per download
+
+

Performance Characteristics:

+
    +
  • Cache Hit: ~1ms (in-memory retrieval)
  • +
  • Cache Miss: ~200-500ms (download + scale)
  • +
  • Playback Impact: Zero (async downloads)
  • +
  • Memory Overhead: Max 16MB (auto-eviction)
  • +
  • Error Recovery: Automatic with exponential backoff
  • +
+

Backend Initialization

+

Location: src-tauri/src/lib.rs

+

Backend selection is platform-specific:

+
#![allow(unused)]
+fn main() {
+fn create_player_backend(app_handle: tauri::AppHandle) -> Box<dyn PlayerBackend> {
+    let event_emitter = Arc::new(TauriEventEmitter::new(app_handle));
+
+    #[cfg(target_os = "linux")]
+    {
+        match MpvBackend::new(event_emitter.clone()) {
+            Ok(backend) => return Box::new(backend),
+            Err(e) => eprintln!("MPV init failed: {}", e),
+        }
+    }
+
+    #[cfg(target_os = "android")]
+    {
+        // ExoPlayer requires Activity context, initialized separately
+    }
+
+    // Fallback
+    Box::new(NullBackend::new())
+}
+}
+

Download Manager & Offline Architecture

+

Overview

+

Location: src-tauri/src/download/

+

The download manager provides offline media support with priority-based queue management, progress tracking, retry logic, and smart caching.

+
flowchart TB
+    subgraph Frontend["Frontend"]
+        DownloadButton["DownloadButton.svelte"]
+        DownloadsPage["/downloads"]
+        DownloadsStore["downloads.ts store"]
+    end
+
+    subgraph Backend["Rust Backend"]
+        Commands["Download Commands"]
+        DownloadManager["DownloadManager"]
+        DownloadWorker["DownloadWorker"]
+        SmartCache["SmartCache Engine"]
+    end
+
+    subgraph Storage["Storage"]
+        SQLite[("SQLite DB")]
+        MediaFiles[("Downloaded Files")]
+    end
+
+    DownloadButton -->|"invoke('download_item')"| Commands
+    DownloadsPage -->|"invoke('get_downloads')"| Commands
+    Commands --> DownloadManager
+    DownloadManager --> DownloadWorker
+    DownloadManager --> SmartCache
+    DownloadWorker -->|"HTTP Stream"| MediaFiles
+    DownloadWorker -->|"Events"| DownloadsStore
+    Commands <--> SQLite
+    SmartCache <--> SQLite
+
+

Download Worker

+

Location: src-tauri/src/download/worker.rs

+

The download worker handles HTTP streaming with retry logic and resume support:

+
#![allow(unused)]
+fn main() {
+pub struct DownloadWorker {
+    client: reqwest::Client,
+    max_retries: u32,
+}
+
+pub struct DownloadTask {
+    pub id: i64,
+    pub item_id: String,
+    pub user_id: String,
+    pub priority: i32,
+    pub url: String,
+    pub target_path: PathBuf,
+    pub mime_type: Option<String>,
+    pub expected_size: Option<i64>,
+}
+}
+

Retry Strategy:

+
    +
  • Exponential backoff: 5s, 15s, 45s
  • +
  • Maximum 3 retry attempts
  • +
  • HTTP Range requests for resume support
  • +
  • Progress events emitted every 1MB
  • +
+

Download Flow:

+
sequenceDiagram
+    participant UI
+    participant Command as download_item
+    participant DB as SQLite
+    participant Worker as DownloadWorker
+    participant Jellyfin as Jellyfin Server
+    participant Store as downloads store
+
+    UI->>Command: download_item(itemId, userId)
+    Command->>DB: INSERT INTO downloads
+    Command->>Worker: Start download task
+    Worker->>Jellyfin: GET /Items/{id}/Download
+
+    loop Progress Updates
+        Jellyfin->>Worker: Stream chunks
+        Worker->>Worker: Write to .part file
+        Worker->>Store: Emit progress event
+        Store->>UI: Update progress bar
+    end
+
+    Worker->>Worker: Rename .part to final
+    Worker->>DB: UPDATE status='completed'
+    Worker->>Store: Emit completed event
+    Store->>UI: Show completed
+
+

Smart Caching Engine

+

Location: src-tauri/src/download/cache.rs

+

The smart caching system provides predictive downloads based on listening patterns:

+
#![allow(unused)]
+fn main() {
+pub struct SmartCache {
+    config: Arc<Mutex<CacheConfig>>,
+    album_play_history: Arc<Mutex<HashMap<String, Vec<String>>>>,
+}
+
+pub struct CacheConfig {
+    pub queue_precache_enabled: bool,
+    pub queue_precache_count: usize,        // Default: 5
+    pub album_affinity_enabled: bool,
+    pub album_affinity_threshold: usize,    // Default: 3
+    pub storage_limit: u64,                 // Default: 10GB
+    pub wifi_only: bool,                    // Default: true
+}
+}
+

Caching Strategies:

+
    +
  1. Queue Pre-caching: Auto-download next 5 tracks when playing (WiFi only)
  2. +
  3. Album Affinity: If user plays 3+ tracks from album, cache entire album
  4. +
  5. LRU Eviction: Remove least recently accessed when storage limit reached
  6. +
+
flowchart TB
+    Play["Track Played"] --> CheckQueue{"Queue<br/>Pre-cache?"}
+    CheckQueue -->|"Yes"| CacheNext5["Download<br/>Next 5 Tracks"]
+
+    Play --> TrackHistory["Track Play History"]
+    TrackHistory --> CheckAlbum{"3+ Tracks<br/>from Album?"}
+    CheckAlbum -->|"Yes"| CacheAlbum["Download<br/>Full Album"]
+
+    CacheNext5 --> CheckStorage{"Storage<br/>Limit?"}
+    CacheAlbum --> CheckStorage
+    CheckStorage -->|"Exceeded"| EvictLRU["Evict LRU Items"]
+    CheckStorage -->|"OK"| Download["Queue Download"]
+
+

One Storage Model: Cache Entries Are Downloads

+

TRACES: UR-071 | DR-126, DR-127

+

A cache entry is a download with a shorter life: the same downloads row and +the same file handling, distinguished by download_source plus an expiry. There +is one storage model rather than a cache and a download library that can +disagree about what is on disk.

+
+ + +
download_sourceLifeReclaimed by
'auto' (temporary)Expiry, or eviction under space pressureBoth
'user' (permanent)No expiryNeither
+
+

Eviction only reclaims the temporary tier. evict_lru_async originally +selected every completed download ordered by completed_at ASC with no source +filter, so hitting the storage limit deleted the oldest download — typically a +film saved deliberately for offline — to make room for a newly precached track. +It now evicts only COALESCE(download_source, 'user') = 'auto' rows. +COALESCE rather than a bare equality is load-bearing: rows predating the +migration can be NULL, and unknown provenance must be treated as the user's, +never as disposable. Freeing less than requested is the correct outcome when +only user downloads remain — the caller reports "unable to free enough".

+

A temporary row can be promoted to permanent when the user chooses to keep +it. That only clears the expiry and flips the source; the bytes never move.

+

Offline Catalog Visibility

+

TRACES: UR-052 | DR-078, DR-079, DR-080

+

Offline, a library page shows only media on the device. A "Show all server +media" toggle additionally reveals the cached server catalog, greyed out and +queueable for download on reconnect.

+

The gate is a process-global INCLUDE_CATALOG_BROWSE in +repository/offline.rs, written by the set_show_server_catalog command. It +gates the synced-catalog leg of get_items; without it the toggle rendered but +every server item still appeared, which is the defect the spec was written for. +isConnected derives from backend-reported reachability alone (DR-079) — see +07-connectivity.md.

+

Per-item disk usage comes from repository_get_download_disk_usage +(DownloadDiskUsage), aggregated from downloads.file_size — used by the +Downloaded browse cards, detail pages, the device total and the remove +confirmation (DR-085).

+

Download Commands

+

Location: src-tauri/src/commands/download/mod.rs (the commands below), pinning.rs, smart_cache.rs

+
+ + + + + + + + + + + + +
CommandParametersDescription
download_itemitem_id, user_id, file_pathQueue single item download
download_albumalbum_id, user_idQueue all tracks in album
get_downloadsuser_id, status_filterGet download list
pause_downloaddownload_idPause active download
resume_downloaddownload_idResume paused download
cancel_downloaddownload_idCancel and delete partial
delete_downloaddownload_idDelete completed download
download_video / download_series / download_seasonitem idsQueue video content
get_download_storage_statsuser_idDevice totals for the downloads screen
delete_album_downloads / delete_downloads_under / delete_all_downloadscontainer idBulk removal
pin_item / unpin_item / is_item_pinneditem_idProtect metadata from a cache clear
set_max_concurrent_downloadsmaxWorker concurrency (3 by default)
+
+

Offline Commands

+

Location: src-tauri/src/commands/offline.rs

+
+ + + +
CommandParametersDescription
offline_is_availableitem_idCheck if item downloaded
offline_get_itemsuser_idGet all offline items
offline_searchuser_id, querySearch downloaded items
+
+

Player Integration

+

Location: src-tauri/src/commands/player.rs (modified)

+

The player checks for local downloads before streaming:

+
#![allow(unused)]
+fn main() {
+fn create_media_item(req: PlayItemRequest, db: Option<&DatabaseWrapper>) -> MediaItem {
+    let local_path = db.and_then(|db_wrapper| {
+        check_for_local_download(db_wrapper, &jellyfin_id).ok().flatten()
+    });
+
+    let source = if let Some(path) = local_path {
+        MediaSource::Local {
+            file_path: PathBuf::from(path),
+            jellyfin_item_id: Some(jellyfin_id.clone())
+        }
+    } else {
+        MediaSource::Remote {
+            stream_url: req.stream_url,
+            jellyfin_item_id: jellyfin_id.clone()
+        }
+    };
+
+    MediaItem { source, /* ... */ }
+}
+}
+

Frontend Downloads Store

+

Location: src/lib/stores/downloads.ts

+
interface DownloadsState {
+  downloads: Record<number, DownloadInfo>;
+  activeCount: number;
+  queuedCount: number;
+}
+
+const downloads = createDownloadsStore();
+
+// Actions
+downloads.downloadItem(itemId, userId, filePath)
+downloads.downloadAlbum(albumId, userId)
+downloads.pause(downloadId)
+downloads.resume(downloadId)
+downloads.cancel(downloadId)
+downloads.delete(downloadId)
+downloads.refresh(userId, statusFilter)
+
+// Derived stores
+export const activeDownloads = derived(downloads, ($d) =>
+  Object.values($d.downloads).filter((d) => d.status === 'downloading')
+);
+
+

Event Handling:

+

The store listens to Tauri events for real-time updates:

+
listen<DownloadEvent>('download-event', (event) => {
+  const payload = event.payload;
+
+  switch (payload.type) {
+    case 'started':
+      // Update status to 'downloading'
+    case 'progress':
+      // Update progress and bytes_downloaded
+    case 'completed':
+      // Update status to 'completed', progress to 1.0
+    case 'failed':
+      // Update status to 'failed', store error message
+  }
+});
+
+

Download UI Components

+

DownloadButton (src/lib/components/library/DownloadButton.svelte):

+
    +
  • Multiple states: available, downloading, completed, failed, paused
  • +
  • Circular progress ring during download
  • +
  • Size variants: sm, md, lg
  • +
  • Integrated into TrackList with showDownload={true} prop
  • +
+

DownloadItem (src/lib/components/downloads/DownloadItem.svelte):

+
    +
  • Individual download list item with progress bar
  • +
  • Action buttons: pause, resume, cancel, delete
  • +
  • Status indicators with color coding
  • +
+

Downloads Page (src/routes/downloads/+page.svelte):

+
    +
  • Active/Completed tabs
  • +
  • Bulk actions: Pause All, Resume All, Clear Completed
  • +
  • Empty states with helpful instructions
  • +
+

Database Schema

+

downloads table:

+
CREATE TABLE downloads (
+    id              INTEGER PRIMARY KEY AUTOINCREMENT,
+    item_id         TEXT NOT NULL,
+    user_id         TEXT NOT NULL,
+    file_path       TEXT,
+    file_size       INTEGER,
+    mime_type       TEXT,
+    status          TEXT DEFAULT 'pending',  -- pending, downloading, completed, failed, paused
+    progress        REAL DEFAULT 0.0,
+    bytes_downloaded INTEGER DEFAULT 0,
+    priority        INTEGER DEFAULT 0,
+    error_message   TEXT,
+    retry_count     INTEGER DEFAULT 0,
+    queued_at       TEXT DEFAULT CURRENT_TIMESTAMP,
+    started_at      TEXT,
+    completed_at    TEXT
+);
+
+CREATE INDEX idx_downloads_queue
+  ON downloads(status, priority DESC, queued_at ASC)
+  WHERE status IN ('pending', 'downloading');
+
+

Connectivity & Network Architecture

+

HTTP Client with Retry Logic

+

Location: src-tauri/src/jellyfin/http_client.rs

+

The HTTP client provides automatic retry with exponential backoff for network resilience:

+
#![allow(unused)]
+fn main() {
+pub struct HttpClient {
+    client: reqwest::Client,
+    config: HttpConfig,
+}
+
+pub struct HttpConfig {
+    pub timeout: Duration,        // Default: 30s (large library queries can be slow)
+    pub max_retries: u32,         // Default: 3
+}
+}
+
+

Note: ordinary requests use the 30s timeout above. The connectivity recovery probe (ping) uses a shorter, dedicated 5s timeout so an unreachable server is detected quickly while offline.

+
+

Retry Strategy:

+
    +
  • Retry delays: 1s, 2s, 4s (exponential backoff)
  • +
  • Retries on: Network errors, 5xx server errors
  • +
  • No retry on: 4xx client errors, 401/403 authentication errors
  • +
+

Error Classification:

+
#![allow(unused)]
+fn main() {
+pub enum ErrorKind {
+    Network,        // Connection failures, timeouts, DNS errors
+    Authentication, // 401/403 responses
+    Server,         // 5xx server errors
+    Client,         // Other 4xx errors
+}
+}
+

Connectivity Monitor

+

Location: src-tauri/src/connectivity/mod.rs

+

The connectivity monitor is the single source of truth for server reachability. Its primary signal is the outcome of real repository traffic — every server request the user actually makes. A standalone /System/Info/Public probe is kept only as an offline recovery detector.

+

Source of truth: repository traffic

+

OnlineRepository reports the result of each server request to the monitor, classified via RepoError:

+
+ + + + + + +
Repository outcomeMeaningEffect on reachability
Ok(_)Server answered successfullyMark reachable (instant recovery)
Err(Authentication)Server answered with 401/403Mark reachable (server is up; request was rejected)
Err(NotFound)Server answered with 404Mark reachable (server is up)
Err(Server)Server answered with 5xx / bad bodyMark reachable (server is up)
Err(Network)Connection failure / timeout / DNSCandidate for offline (see debounce)
Err(Database)Local cache error onlyNo effect (not a server signal)
+
+

This classification fixes the previous bug where a successful /System/Info/Public ping reported "online" even while the user's authenticated data calls were failing — and vice versa.

+

Time-window debounce (offline) + instant recovery (online)

+

To stop the banner from flapping on a single dropped request, the transition to offline is debounced over a time window:

+
    +
  • On the first Network failure, the monitor records first_failure_at.
  • +
  • It flips is_server_reachable = false only once Network failures have persisted continuously for OFFLINE_CONFIRM_WINDOW (5s) with no intervening success.
  • +
  • Any success (or server-answered error) clears first_failure_at and immediately marks reachable.
  • +
+

Recovery is therefore instant and asymmetric: one good response brings the app back online, but a brief blip never trips the banner.

+

Offline-only recovery probe

+
flowchart TB
+    Repo["OnlineRepository"] -->|"success / RepoError"| Monitor["ConnectivityMonitor"]
+    Monitor --> State{"is_server_reachable?"}
+    State -->|"Online"| NoProbe["No background polling<br/>(real traffic is the signal)"]
+    State -->|"Offline"| Probe["5s /System/Info/Public probe<br/>(recovery detector)"]
+    Probe -->|"reachable again"| Monitor
+    Monitor -->|"on change"| Emit["Emit connectivity:changed<br/>+ connectivity:reconnected"]
+    Emit --> Frontend["Frontend Store → banner"]
+
+

While online, there is no background polling — real requests keep the state fresh. While offline, the fast 5s probe runs so an idle app still detects the server returning even when no user traffic is flowing.

+

Features:

+
    +
  • Traffic-driven: Reachability follows the requests the user actually makes.
  • +
  • Time-window debounce: Offline declared only after OFFLINE_CONFIRM_WINDOW (5s) of sustained network failure; recovery is instant.
  • +
  • Offline-only probe: 5s /System/Info/Public probe runs only while offline.
  • +
  • Event Emission: Emits connectivity:changed and connectivity:reconnected events.
  • +
  • Thread-Safe: Uses Arc<RwLock<>> for shared state.
  • +
+

Tauri Commands:

+
+ + + + + + + +
CommandDescription
connectivity_check_serverManual reachability check (also used by the frontend's advisory navigator.onLine hint)
connectivity_set_server_urlUpdate monitored server URL
connectivity_get_statusGet current connectivity status
connectivity_start_monitoringStart the offline recovery probe
connectivity_stop_monitoringStop the probe
connectivity_mark_reachableMark reachable — driven by OnlineRepository on every server success
connectivity_mark_unreachableMark unreachable — driven by OnlineRepository on RepoError::Network (subject to debounce)
+
+

Frontend Integration:

+
// The store is a pure reflection of backend events — it no longer decides
+// reachability itself. navigator.onLine is advisory: it triggers an immediate
+// recheck rather than forcing the offline state.
+listen<{ isReachable: boolean }>("connectivity:changed", (event) => {
+  updateConnectivityState(event.payload.isReachable);
+});
+
+

Network Resilience Architecture

+

The connectivity system provides resilience through multiple layers:

+
    +
  1. HTTP Client Layer: Automatic retry with exponential backoff
  2. +
  3. Connectivity Monitoring: Reachability derived from real repository traffic, with an offline-only recovery probe
  4. +
  5. Frontend Integration: Offline mode detection and UI updates (a pure reflection of backend events)
  6. +
  7. Sync Queue: Offline mutations queued for later (see 06-downloads-and-offline.md)
  8. +
+

Design Principles:

+
    +
  • Single source of truth: Reachability follows the outcome of real requests, classified via RepoError; the frontend store and the probe never compete to decide it.
  • +
  • Fail Fast: Don't retry 4xx errors (client errors, authentication).
  • +
  • Fail Slow: Retry network and 5xx errors with increasing delays.
  • +
  • Debounced offline, instant online: Declare offline only after a sustained failure window; recover on the first success.
  • +
  • Probe only when needed: Background polling runs only while offline, as a recovery detector.
  • +
  • Event-Driven: Frontend reacts to connectivity changes via events.
  • +
+

Offline Database Design

+

Entity Relationship Diagram

+
erDiagram
+    servers ||--o{ users : "has"
+    servers ||--o{ libraries : "has"
+    libraries ||--o{ items : "contains"
+    items ||--o{ items : "parent_of"
+    items ||--o{ user_data : "has"
+    items ||--o{ downloads : "has"
+    items ||--o{ media_streams : "has"
+    items ||--o{ thumbnails : "has"
+    users ||--o{ user_data : "owns"
+    users ||--o{ downloads : "owns"
+    users ||--o{ sync_queue : "owns"
+
+    servers {
+        int id PK
+        string jellyfin_id UK
+        string name
+        string url
+        string version
+        datetime last_sync
+    }
+
+    users {
+        int id PK
+        string jellyfin_id
+        int server_id FK
+        string name
+        boolean is_active
+    }
+
+    libraries {
+        int id PK
+        string jellyfin_id
+        int server_id FK
+        string name
+        string collection_type
+        string image_tag
+    }
+
+    items {
+        int id PK
+        string jellyfin_id
+        int server_id FK
+        int library_id FK
+        int parent_id FK
+        string type
+        string name
+        string sort_name
+        string overview
+        int production_year
+        float community_rating
+        string official_rating
+        int runtime_ticks
+        string primary_image_tag
+        string backdrop_image_tag
+        string album_id
+        string album_name
+        string album_artist
+        json artists
+        json genres
+        int index_number
+        int parent_index_number
+        string premiere_date
+        json metadata_json
+        datetime created_at
+        datetime updated_at
+        datetime last_sync
+    }
+
+    user_data {
+        int id PK
+        int item_id FK
+        int user_id FK
+        int position_ticks
+        int play_count
+        boolean is_favorite
+        boolean played
+        datetime last_played
+        datetime updated_at
+        datetime synced_at
+    }
+
+    downloads {
+        int id PK
+        int item_id FK
+        int user_id FK
+        string file_path
+        int file_size
+        string status
+        float progress
+        int priority
+        string error_message
+        datetime created_at
+        datetime completed_at
+    }
+
+    media_streams {
+        int id PK
+        int item_id FK
+        int stream_index
+        string type
+        string codec
+        string language
+        string display_title
+        boolean is_default
+        boolean is_forced
+        boolean is_external
+    }
+
+    sync_queue {
+        int id PK
+        int user_id FK
+        string operation
+        string entity_type
+        string entity_id
+        json payload
+        datetime created_at
+        int attempts
+        datetime last_attempt
+        string status
+    }
+
+    thumbnails {
+        int id PK
+        int item_id FK
+        string image_type
+        string image_tag
+        string file_path
+        int width
+        int height
+        datetime cached_at
+    }
+
+

Table Definitions

+

servers

+

Stores connected Jellyfin server information.

+
CREATE TABLE servers (
+    id              INTEGER PRIMARY KEY AUTOINCREMENT,
+    jellyfin_id     TEXT NOT NULL UNIQUE,
+    name            TEXT NOT NULL,
+    url             TEXT NOT NULL,
+    version         TEXT,
+    last_sync       DATETIME,
+    created_at      DATETIME DEFAULT CURRENT_TIMESTAMP,
+    updated_at      DATETIME DEFAULT CURRENT_TIMESTAMP
+);
+
+

users

+

Stores user accounts per server. Access tokens are stored separately in secure storage (see 09-security.md).

+
CREATE TABLE users (
+    id              INTEGER PRIMARY KEY AUTOINCREMENT,
+    jellyfin_id     TEXT NOT NULL,
+    server_id       INTEGER NOT NULL REFERENCES servers(id) ON DELETE CASCADE,
+    name            TEXT NOT NULL,
+    is_active       BOOLEAN DEFAULT 0,
+    created_at      DATETIME DEFAULT CURRENT_TIMESTAMP,
+    updated_at      DATETIME DEFAULT CURRENT_TIMESTAMP,
+    UNIQUE(jellyfin_id, server_id)
+);
+
+

libraries

+

Stores library/collection metadata.

+
CREATE TABLE libraries (
+    id              INTEGER PRIMARY KEY AUTOINCREMENT,
+    jellyfin_id     TEXT NOT NULL,
+    server_id       INTEGER NOT NULL REFERENCES servers(id) ON DELETE CASCADE,
+    name            TEXT NOT NULL,
+    collection_type TEXT,
+    image_tag       TEXT,
+    sort_order      INTEGER DEFAULT 0,
+    last_sync       DATETIME,
+    created_at      DATETIME DEFAULT CURRENT_TIMESTAMP,
+    updated_at      DATETIME DEFAULT CURRENT_TIMESTAMP,
+    UNIQUE(jellyfin_id, server_id)
+);
+
+CREATE INDEX idx_libraries_server ON libraries(server_id);
+
+

items

+

Main table for all media items (movies, episodes, albums, songs, etc.).

+
CREATE TABLE items (
+    id                   INTEGER PRIMARY KEY AUTOINCREMENT,
+    jellyfin_id          TEXT NOT NULL,
+    server_id            INTEGER NOT NULL REFERENCES servers(id) ON DELETE CASCADE,
+    library_id           INTEGER REFERENCES libraries(id) ON DELETE SET NULL,
+    parent_id            INTEGER REFERENCES items(id) ON DELETE CASCADE,
+
+    -- Basic metadata
+    type                 TEXT NOT NULL,
+    name                 TEXT NOT NULL,
+    sort_name            TEXT,
+    overview             TEXT,
+
+    -- Media info
+    production_year      INTEGER,
+    community_rating     REAL,
+    official_rating      TEXT,
+    runtime_ticks        INTEGER,
+
+    -- Images
+    primary_image_tag    TEXT,
+    backdrop_image_tag   TEXT,
+
+    -- Audio-specific
+    album_id             TEXT,
+    album_name           TEXT,
+    album_artist         TEXT,
+    artists              TEXT,                   -- JSON array
+
+    -- Series/Season-specific
+    index_number         INTEGER,
+    parent_index_number  INTEGER,
+    series_id            TEXT,
+    series_name          TEXT,
+    season_id            TEXT,
+
+    -- Additional
+    genres               TEXT,                   -- JSON array
+    premiere_date        TEXT,
+    metadata_json        TEXT,
+
+    -- Sync tracking
+    created_at           DATETIME DEFAULT CURRENT_TIMESTAMP,
+    updated_at           DATETIME DEFAULT CURRENT_TIMESTAMP,
+    last_sync            DATETIME,
+
+    UNIQUE(jellyfin_id, server_id)
+);
+
+-- Performance indexes
+CREATE INDEX idx_items_server ON items(server_id);
+CREATE INDEX idx_items_library ON items(library_id);
+CREATE INDEX idx_items_parent ON items(parent_id);
+CREATE INDEX idx_items_type ON items(type);
+CREATE INDEX idx_items_album ON items(album_id);
+CREATE INDEX idx_items_series ON items(series_id);
+CREATE INDEX idx_items_name ON items(name COLLATE NOCASE);
+
+-- Full-text search
+CREATE VIRTUAL TABLE items_fts USING fts5(
+    name,
+    overview,
+    artists,
+    album_name,
+    album_artist,
+    content='items',
+    content_rowid='id'
+);
+
+-- Triggers to keep FTS in sync
+CREATE TRIGGER items_ai AFTER INSERT ON items BEGIN
+    INSERT INTO items_fts(rowid, name, overview, artists, album_name, album_artist)
+    VALUES (new.id, new.name, new.overview, new.artists, new.album_name, new.album_artist);
+END;
+
+CREATE TRIGGER items_ad AFTER DELETE ON items BEGIN
+    INSERT INTO items_fts(items_fts, rowid, name, overview, artists, album_name, album_artist)
+    VALUES ('delete', old.id, old.name, old.overview, old.artists, old.album_name, old.album_artist);
+END;
+
+CREATE TRIGGER items_au AFTER UPDATE ON items BEGIN
+    INSERT INTO items_fts(items_fts, rowid, name, overview, artists, album_name, album_artist)
+    VALUES ('delete', old.id, old.name, old.overview, old.artists, old.album_name, old.album_artist);
+    INSERT INTO items_fts(rowid, name, overview, artists, album_name, album_artist)
+    VALUES (new.id, new.name, new.overview, new.artists, new.album_name, new.album_artist);
+END;
+
+

media_streams

+

Stores subtitle and audio track information for items.

+
CREATE TABLE media_streams (
+    id              INTEGER PRIMARY KEY AUTOINCREMENT,
+    item_id         INTEGER NOT NULL REFERENCES items(id) ON DELETE CASCADE,
+    stream_index    INTEGER NOT NULL,
+    type            TEXT NOT NULL,
+    codec           TEXT,
+    language        TEXT,
+    display_title   TEXT,
+    is_default      BOOLEAN DEFAULT 0,
+    is_forced       BOOLEAN DEFAULT 0,
+    is_external     BOOLEAN DEFAULT 0,
+    path            TEXT,
+    UNIQUE(item_id, stream_index)
+);
+
+CREATE INDEX idx_media_streams_item ON media_streams(item_id);
+
+

user_data

+

Stores per-user data for items (favorites, progress, play count).

+
CREATE TABLE user_data (
+    id              INTEGER PRIMARY KEY AUTOINCREMENT,
+    item_id         INTEGER NOT NULL REFERENCES items(id) ON DELETE CASCADE,
+    user_id         INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
+
+    -- Playback state
+    position_ticks  INTEGER DEFAULT 0,
+    play_count      INTEGER DEFAULT 0,
+    played          BOOLEAN DEFAULT 0,
+    last_played     DATETIME,
+
+    -- User preferences
+    is_favorite     BOOLEAN DEFAULT 0,
+    user_rating     REAL,
+
+    -- Sync tracking
+    updated_at      DATETIME DEFAULT CURRENT_TIMESTAMP,
+    synced_at       DATETIME,
+    needs_sync      BOOLEAN DEFAULT 0,
+
+    UNIQUE(item_id, user_id)
+);
+
+CREATE INDEX idx_user_data_item ON user_data(item_id);
+CREATE INDEX idx_user_data_user ON user_data(user_id);
+CREATE INDEX idx_user_data_needs_sync ON user_data(needs_sync) WHERE needs_sync = 1;
+CREATE INDEX idx_user_data_favorites ON user_data(user_id, is_favorite) WHERE is_favorite = 1;
+CREATE INDEX idx_user_data_in_progress ON user_data(user_id, position_ticks)
+    WHERE position_ticks > 0 AND played = 0;
+
+

downloads

+

Tracks downloaded media files.

+
CREATE TABLE downloads (
+    id              INTEGER PRIMARY KEY AUTOINCREMENT,
+    item_id         INTEGER NOT NULL REFERENCES items(id) ON DELETE CASCADE,
+    user_id         INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
+
+    file_path       TEXT,
+    file_size       INTEGER,
+    file_hash       TEXT,
+
+    status          TEXT NOT NULL DEFAULT 'pending',
+    progress        REAL DEFAULT 0,
+    bytes_downloaded INTEGER DEFAULT 0,
+
+    transcode_profile TEXT,
+
+    priority        INTEGER DEFAULT 0,
+    error_message   TEXT,
+    retry_count     INTEGER DEFAULT 0,
+
+    created_at      DATETIME DEFAULT CURRENT_TIMESTAMP,
+    started_at      DATETIME,
+    completed_at    DATETIME,
+    expires_at      DATETIME,
+
+    UNIQUE(item_id, user_id)
+);
+
+CREATE INDEX idx_downloads_status ON downloads(status);
+CREATE INDEX idx_downloads_user ON downloads(user_id);
+CREATE INDEX idx_downloads_queue ON downloads(status, priority DESC, created_at ASC)
+    WHERE status IN ('pending', 'downloading');
+
+

sync_queue

+

Stores mutations to sync back to server when online.

+
CREATE TABLE sync_queue (
+    id              INTEGER PRIMARY KEY AUTOINCREMENT,
+    user_id         INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
+
+    operation       TEXT NOT NULL,
+    entity_type     TEXT NOT NULL,
+    entity_id       TEXT NOT NULL,
+    payload         TEXT,
+
+    status          TEXT DEFAULT 'pending',
+    attempts        INTEGER DEFAULT 0,
+    max_attempts    INTEGER DEFAULT 5,
+    last_attempt    DATETIME,
+    error_message   TEXT,
+
+    created_at      DATETIME DEFAULT CURRENT_TIMESTAMP,
+    completed_at    DATETIME
+);
+
+CREATE INDEX idx_sync_queue_status ON sync_queue(status, created_at ASC)
+    WHERE status = 'pending';
+CREATE INDEX idx_sync_queue_user ON sync_queue(user_id);
+
+

thumbnails

+

Caches downloaded artwork.

+
CREATE TABLE thumbnails (
+    id              INTEGER PRIMARY KEY AUTOINCREMENT,
+    item_id         INTEGER NOT NULL REFERENCES items(id) ON DELETE CASCADE,
+    image_type      TEXT NOT NULL,
+    image_tag       TEXT,
+    file_path       TEXT NOT NULL,
+    width           INTEGER,
+    height          INTEGER,
+    file_size       INTEGER,
+    cached_at       DATETIME DEFAULT CURRENT_TIMESTAMP,
+    last_accessed   DATETIME DEFAULT CURRENT_TIMESTAMP,
+    UNIQUE(item_id, image_type, width)
+);
+
+CREATE INDEX idx_thumbnails_item ON thumbnails(item_id);
+CREATE INDEX idx_thumbnails_lru ON thumbnails(last_accessed ASC);
+
+

playlists (for local/synced playlists)

+
CREATE TABLE playlists (
+    id              INTEGER PRIMARY KEY AUTOINCREMENT,
+    jellyfin_id     TEXT,
+    user_id         INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
+    name            TEXT NOT NULL,
+    description     TEXT,
+    is_local_only   BOOLEAN DEFAULT 0,
+    created_at      DATETIME DEFAULT CURRENT_TIMESTAMP,
+    updated_at      DATETIME DEFAULT CURRENT_TIMESTAMP,
+    synced_at       DATETIME,
+    needs_sync      BOOLEAN DEFAULT 0
+);
+
+CREATE TABLE playlist_items (
+    id              INTEGER PRIMARY KEY AUTOINCREMENT,
+    playlist_id     INTEGER NOT NULL REFERENCES playlists(id) ON DELETE CASCADE,
+    item_id         INTEGER NOT NULL REFERENCES items(id) ON DELETE CASCADE,
+    sort_order      INTEGER NOT NULL,
+    added_at        DATETIME DEFAULT CURRENT_TIMESTAMP,
+    UNIQUE(playlist_id, item_id)
+);
+
+CREATE INDEX idx_playlist_items_playlist ON playlist_items(playlist_id, sort_order);
+
+

Key Queries

+

Get items for offline library browsing

+
-- Get all albums in a music library
+SELECT * FROM items
+WHERE library_id = ? AND type = 'MusicAlbum'
+ORDER BY sort_name;
+
+-- Get tracks for an album
+SELECT * FROM items
+WHERE album_id = ? AND type = 'Audio'
+ORDER BY parent_index_number, index_number;
+
+

Resume / Continue Watching

+
SELECT i.*, ud.position_ticks, ud.last_played
+FROM items i
+JOIN user_data ud ON ud.item_id = i.id
+WHERE ud.user_id = ?
+  AND ud.position_ticks > 0
+  AND ud.played = 0
+ORDER BY ud.last_played DESC
+LIMIT 20;
+
+ +
SELECT i.* FROM items i
+JOIN items_fts fts ON fts.rowid = i.id
+WHERE items_fts MATCH ?
+ORDER BY rank;
+
+

Download queue management

+
-- Get next item to download
+SELECT d.*, i.name, i.type
+FROM downloads d
+JOIN items i ON i.id = d.item_id
+WHERE d.status = 'pending'
+ORDER BY d.priority DESC, d.created_at ASC
+LIMIT 1;
+
+-- Get download progress for UI
+SELECT
+    d.status,
+    COUNT(*) as count,
+    SUM(d.file_size) as total_size,
+    SUM(d.bytes_downloaded) as downloaded
+FROM downloads d
+WHERE d.user_id = ?
+GROUP BY d.status;
+
+

Sync queue processing

+
-- Get pending sync operations (oldest first)
+SELECT * FROM sync_queue
+WHERE status = 'pending'
+  AND attempts < max_attempts
+ORDER BY created_at ASC
+LIMIT 10;
+
+-- Mark operation complete
+UPDATE sync_queue
+SET status = 'completed', completed_at = CURRENT_TIMESTAMP
+WHERE id = ?;
+
+

Data Flow

+

Online Mode

+
flowchart TB
+    subgraph OnlineMode["Online Mode"]
+        JellyfinServer["Jellyfin Server"]
+        OnlineRepo["OnlineRepo"]
+        SQLite["SQLite"]
+        HybridRepo["HybridRepository"]
+        UI["UI / Stores"]
+
+        JellyfinServer -->|"API Response"| OnlineRepo
+        OnlineRepo -->|"Cache"| SQLite
+        SQLite -->|"Sync"| JellyfinServer
+        OnlineRepo -->|"Response"| HybridRepo
+        SQLite -->|"Fallback"| HybridRepo
+        HybridRepo --> UI
+    end
+
+

Offline Mode

+
flowchart TB
+    subgraph OfflineMode["Offline Mode"]
+        OfflineRepo["OfflineRepo"]
+        SQLite2["SQLite"]
+        SyncQueue["sync_queue<br/>(Queued for later)"]
+        HybridRepo2["HybridRepository"]
+        UI2["UI / Stores"]
+
+        OfflineRepo <-->|"Query"| SQLite2
+        SQLite2 -->|"Mutations"| SyncQueue
+        OfflineRepo --> HybridRepo2
+        HybridRepo2 --> UI2
+    end
+
+

Sync on Reconnect

+
flowchart LR
+    NetworkRestored["Network restored"]
+    SyncService["SyncService"]
+    SyncQueue2["sync_queue"]
+    JellyfinAPI["Jellyfin API"]
+    MarkSynced["Mark synced"]
+
+    NetworkRestored --> SyncService
+    SyncService -->|"Read"| SyncQueue2
+    SyncQueue2 -->|"Send"| JellyfinAPI
+    JellyfinAPI -->|"Success"| MarkSynced
+    MarkSynced --> SyncService
+
+

Storage Estimates

+
+ + + + + +
Content TypeMetadata SizeThumbnail SizeMedia Size
Song~2 KB~50 KB (300px)5-15 MB
Album (12 tracks)~30 KB~100 KB60-180 MB
Movie~5 KB~200 KB1-8 GB
Episode~3 KB~100 KB300 MB - 2 GB
Full music library (5000 songs)~10 MB~250 MB25-75 GB
+
+

Rust Module Structure

+
src-tauri/src/storage/
+├── mod.rs              # Module exports, Database struct
+├── schema.rs           # Table definitions, migrations
+├── models.rs           # Rust structs matching tables
+├── queries/
+│   ├── mod.rs
+│   ├── items.rs        # Item CRUD operations
+│   ├── user_data.rs    # User data operations
+│   ├── downloads.rs    # Download queue operations
+│   └── sync.rs         # Sync queue operations
+└── sync/
+    ├── mod.rs          # SyncService
+    ├── manager.rs      # Background sync manager
+    └── operations.rs   # Individual sync operation handlers
+
+

Security

+

Authentication Token Storage

+

Access tokens are not stored in the SQLite database. Instead, they are stored using platform-native secure storage:

+
flowchart TB
+    LoginSuccess["Login Success"]
+    KeyringCheck{"System Keyring<br/>Available?"}
+    OSCredential["Store in OS Credential Manager<br/>- Linux: libsecret/GNOME Keyring<br/>- macOS: Keychain<br/>- Windows: Credential Manager<br/>- Android: EncryptedSharedPrefs"]
+    EncryptedFallback["Encrypted File Fallback<br/>(AES-256-GCM)"]
+
+    LoginSuccess --> KeyringCheck
+    KeyringCheck -->|"Yes"| OSCredential
+    KeyringCheck -->|"No"| EncryptedFallback
+
+

Key Format:

+
jellytau::{server_id}::{user_id}::access_token
+
+

Rationale:

+
    +
  • Tokens in SQLite would be readable if the database file is accessed
  • +
  • System keyrings provide OS-level encryption and access control
  • +
  • Fallback ensures functionality on minimal systems without a keyring daemon
  • +
+

Secure Storage Module

+

Location: src-tauri/src/secure_storage/ (planned)

+
#![allow(unused)]
+fn main() {
+pub trait SecureStorage: Send + Sync {
+    fn store(&self, key: &str, value: &str) -> Result<(), SecureStorageError>;
+    fn retrieve(&self, key: &str) -> Result<Option<String>, SecureStorageError>;
+    fn delete(&self, key: &str) -> Result<(), SecureStorageError>;
+}
+
+// Platform implementations
+pub struct KeyringStorage;      // Uses keyring crate
+pub struct EncryptedFileStorage; // AES-256-GCM fallback
+}
+

Network Security

+
+ + + + + + +
AspectImplementation
TransportHTTPS required for all Jellyfin API calls
Certificate ValidationSystem CA store (configurable for self-signed)
Token TransmissionBearer token in Authorization header only
Token RefreshHandled by Jellyfin server (long-lived tokens)
Android cleartextres/xml/network_security_config.xml blocks cleartext everywhere except 127.0.0.1 (the loopback media server, DR-137/DR-138). The manifest's usesCleartextTraffic is ignored once the config is present, so the config is the single authority
Android WebViewmixedContentMode = COMPATIBILITY with allowFileAccess/allowContentAccess both false (DR-199). These are the second half of the cleartext policy: ALWAYS_ALLOW re-opened by hand what the network security config closes. Change the two together
+
+

Webview Content Security Policy

+

app.security.csp in tauri.conf.json (TRACES: UR-012, UR-071 | DR-198). It was +null — CSP disabled — which meant any script that reached the web layer +inherited the full IPC surface. Tauri computes the header from this value when it +serves the embedded HTML, injecting a nonce for SvelteKit's inline bootstrap +script, so script-src needs no 'unsafe-inline'.

+
default-src 'self';
+script-src  'self';
+style-src   'self' 'unsafe-inline';
+font-src    'self' data:;
+img-src     'self' data: blob: asset: http://asset.localhost http: https:;
+media-src   'self' blob: asset: http://asset.localhost http://127.0.0.1:* http: https:;
+connect-src 'self' ipc: http://ipc.localhost http: https:;
+worker-src  'self' blob:;
+object-src 'none'; frame-src 'none'; base-uri 'self'; form-action 'self'; frame-ancestors 'none'
+
+
+ + + + + + + + + +
DirectiveWhy
default-src 'self'Everything not named below is same-origin only.
script-src 'self'The genuinely restrictive half. Bundled JS only; Tauri's build-time nonce covers the one inline <script> in index.html. Adding 'unsafe-inline' here would silently do nothing anyway — a nonce in a directive voids it.
style-src 'self' 'unsafe-inline'Svelte compiles style="…" attributes into markup, including app.html's display: contents wrapper, and CSP treats a style attribute as inline. Safe only while no <style> element survives into index.html: Tauri would nonce it, and the nonce would then void 'unsafe-inline'. The production build extracts all CSS to files, so it currently has none.
img-srcThumbnails come from two places: the asset protocol (asset://localhost/… on Linux/macOS, http://asset.localhost/… on Windows/Android — the same protocol, named differently by convertFileSrc) and, on a cache miss, straight from the Jellyfin server. data:/blob: cover inline and generated images.
media-src<video>/<audio> sources: HLS transcodes and progressive streams from the server, the token-guarded loopback media server on http://127.0.0.1:<random port> (DR-137), and blob: for the MSE object URL hls.js attaches.
connect-srcipc: / http://ipc.localhost is Tauri's invoke transport (custom scheme on Linux/macOS, http host on Windows/Android) — without it every command is blocked. http:/https: is hls.js fetching manifests and segments; ordinary API traffic goes through Rust and is not subject to CSP.
worker-src 'self' blob:hls.js runs its demuxer in a worker built from a blob (enableWorker: true). Without blob: it falls back to main-thread demuxing — playback survives but costs more CPU.
object-src, frame-src = 'none'No plugins, no iframes; both are classic injection sinks.
base-uri 'self', form-action 'self', frame-ancestors 'none'Block <base> hijacking, form exfiltration and framing. frame-ancestors is only honoured when the policy is delivered as a header, which is platform-dependent; it is harmless where it is not.
+
+

img-src/media-src/connect-src are deliberately permissive. The Jellyfin +origin is typed in by the user at run time and is routinely plain http on a +LAN, so it cannot be enumerated at build time. http: https: is a wide grant for +data — but it still bars file:, filesystem: and scripting schemes, and it +does not touch script-src, which is where an injected origin would actually +hurt. A run-time policy naming the server exactly was considered and rejected: +Tauri derives the header from immutable config at the moment it serves the HTML, +so it would mean rebuilding the config and reloading the webview whenever the +user adds or switches a server, to constrain a destination the user chooses +anyway.

+

devCsp mirrors the policy with 'unsafe-inline' 'unsafe-eval' on script-src +and ws:/wss: on connect-src, because the Vite dev server injects styles and +code and drives HMR over a websocket. It applies only to tauri dev.

+

Asset protocol scope

+

app.security.assetProtocol.scope is $APPDATA/thumbnails/** — not the storage +root. imageCache.ts is the only convertFileSrc caller left in the frontend: +downloaded media moved to the loopback media server in DR-137, and downloaded +audio is opened by MPV/ExoPlayer directly from its path. The old $APPDATA/** +grant let the webview read the SQLite database and the encrypted-token fallback +file alongside the thumbnails it actually needs.

+

If a new feature hands the webview a local file, widen this scope to that +subdirectory specifically; a path outside it resolves to nothing and the webview +reports NETWORK_NO_SOURCE (which is exactly how DR-134's failure presented).

+

Local Data Protection

+
+ + + + +
Data TypeProtection
Access TokensSystem keyring or encrypted file
Database (SQLite)Plaintext (metadata only, no secrets)
Downloaded MediaFilesystem permissions only
Cached ThumbnailsFilesystem permissions only
+
+

Path Confinement and Input Binding

+

Two classes of defect, both of the same shape: a value that arrived from +outside decided something it should not, at a site whose neighbours a few lines +away already did it correctly.

+

Filesystem path confinement

+
+ + +
SurfaceRuleTRACES
Thumbnail cacheThe filename is built from item_id, image_type and tag; all three are sanitised (non-alphanumerics → _), and the resolved path is checked with starts_with(cache_dir) at the point of useDR-210
Downloadsfile_path and target_dir are sanitised inside download_item itself, not only in download_item_and_start — the latter is what made the existing guard bypassable rather than absentDR-211
+
+

Two mechanics worth remembering, because both are easy to get subtly wrong:

+
    +
  • Path::join neither folds .. nor keeps the base when handed an absolute +path. Confinement therefore has to be checked after the join, not before.
  • +
  • Sanitising is per path component. Whole-string sanitising would rewrite +downloads/x.mp3 to downloads_x.mp3 and relocate every existing download.
  • +
+

The database keeps both the raw key and the resolved path, so lookups still match +and pre-existing rows still resolve.

+

Query and URL construction

+

Caller-supplied values are bound or encoded, never interpolated (DR-212):

+
    +
  • The offline get_items item-type filter uses parameter placeholders rather +than formatting IN ('a','b').
  • +
  • build_get_items_endpoint encodes ParentId / IncludeItemTypes / SortBy / +SortOrder. Encoding is per element and list separators stay unencoded, +because Jellyfin splits these parameters on the comma.
  • +
  • player_set_volume clamps at the command boundary — it previously accepted +NaN and out-of-range floats even though every backend clamps internally.
  • +
+

Security Considerations

+
    +
  1. No Secrets in SQLite: The database contains only non-sensitive metadata
  2. +
  3. Token Isolation: Each user/server combination has a separate token entry
  4. +
  5. Logout Cleanup: Token deletion from secure storage on logout
  6. +
  7. No Token Logging: Tokens are never written to logs or debug output
  8. +
  9. IPC Security: Tauri's IPC uses structured commands, not arbitrary code execution
  10. +
  11. Webview Containment: A restrictive script-src keeps injected script off the IPC surface; the asset protocol is scoped to the thumbnail cache only (see above)
  12. +
+

JellyTau UX Flows & Screen Transitions

+

This document describes the expected user experience flows, screen transitions, and navigation patterns in JellyTau.

+
+

1. Core Navigation Structure

+

1.1 Navigation System

+

JellyTau uses a unified navigation system with a bottom navigation bar visible on all platforms (mobile and desktop) and additional header navigation for desktop.

+

Bottom Navigation Bar (All Platforms - DR-045, UR-039):

+

The bottom navigation bar is the primary navigation and is always visible on all platforms (mobile and desktop) except when:

+
    +
  • Full-screen video player is active
  • +
  • User is on the login screen
  • +
+

Bottom Nav Structure:

+
┌─────────────────────────────────────────┐
+│    [Home]  [Library]  [Search]         │
+└─────────────────────────────────────────┘
+
+

Routes:

+
    +
  • Home/ (home page with carousels and featured content)
  • +
  • Library/library (library selector showing all libraries)
  • +
  • Search/search (dedicated search page)
  • +
+

Note: Available on both mobile and desktop for consistent navigation access.

+

Header Navigation (Desktop):

+

On desktop (md breakpoint and above), the header contains:

+
    +
  • Logo (links to /library)
  • +
  • Navigation links: Home, Library, Downloads, Settings
  • +
  • Search bar (inline)
  • +
  • Account menu (see §1.2)
  • +
+

Mobile Navigation:

+

On mobile, the header contains:

+
    +
  • Logo
  • +
  • Account menu button (see §1.2)
  • +
+

1.2 Account Menu

+

Account-level destinations — the ones that are about the user rather than +about media — live behind a single account menu, anchored to the user's +name/avatar at the right of the header.

+

Contents, in order:

+
┌──────────────────────────┐
+│  Signed in as <name>     │   ← identity, not a menu item
+│  <server host>           │
+├──────────────────────────┤
+│  ⬇  Downloads            │
+│  ⚙  Settings             │
+│  ▦  Display              │   ← grid/list preference (§5A.2)
+├──────────────────────────┤
+│  ⇥  Sign out             │
+└──────────────────────────┘
+
+

Rules:

+
    +
  • One menu, both platforms. Desktop and mobile show the same items in the +same order. A user who learns where Settings lives on one form factor finds +it in the same place on the other.
  • +
  • Anchored to identity. The trigger is the username/avatar, because that is +where users look for account actions. A bare three-dot icon does not signal +"your account".
  • +
  • Sign out is separated by a divider and placed last — it is destructive and +must not sit adjacent to routine navigation.
  • +
  • The menu is reachable from every authenticated screen, not only from +library routes. See §1.3.
  • +
+

Access Points Summary:

+
    +
  • Downloads → header icon (desktop) + account menu (both)
  • +
  • Settings → header nav link (desktop) + account menu (both)
  • +
  • Sign out → account menu only
  • +
+

1.3 Chrome availability

+

The header is shared across chrome-bearing routes. Routes fall into three groups:

+
+ + + + +
Route groupHeaderBottom navAccount menu reachable?
/library/*Yes (own layout, shared AppHeader)YesYes
/, /search, /downloadsYes (root-owned AppHeader)YesYes
/settingsOwn layoutNon/a — already there
/player/*, /loginNoNoNo (by design)
+
+

The rule the app honours: every authenticated, non-immersive screen exposes the +account menu. Only the full-screen player and the login screen are chrome-free.

+

1.4 Known deviations

+

(None — the account-menu and chrome-availability defects tracked here under +UR-054 were resolved. Settings, Downloads, Display, and Sign out are now reachable +from every authenticated non-immersive screen via the shared AccountMenu, the +username/avatar is the menu trigger, desktop and mobile share one menu, and the +Display preference has a Settings entry — UR-029, §5A.4.)

+
+

2. Initial App Launch Flow

+

2.1 First-Time Launch

+
flowchart TB
+    Launch[App Launch] --> CheckAuth{Stored<br/>Credentials?}
+    CheckAuth -->|No| LoginScreen[Login Screen<br/>/login]
+    CheckAuth -->|Yes| AutoLogin[Auto-login]
+
+    LoginScreen --> EnterURL[Enter Server URL]
+    EnterURL --> EnterCreds[Enter Username/Password]
+    EnterCreds --> LoginSuccess{Success?}
+    LoginSuccess -->|No| LoginError[Show Error]
+    LoginError --> EnterCreds
+    LoginSuccess -->|Yes| StoreToken[Store Token in Keyring]
+
+    AutoLogin --> TokenValid{Token Valid?}
+    TokenValid -->|No| LoginScreen
+    TokenValid -->|Yes| HomePage
+
+    StoreToken --> HomePage[Home Page<br/>/]
+
+

Screens:

+
    +
  1. +

    Login Screen (/login)

    +
      +
    • Server URL input
    • +
    • Username input
    • +
    • Password input
    • +
    • "Remember me" checkbox (default: on)
    • +
    • Login button
    • +
    • No header, no bottom nav
    • +
    +
  2. +
  3. +

    Home Page (/)

    +
      +
    • Default landing page after successful login
    • +
    • Shows featured content, carousels, continue watching
    • +
    • No MiniPlayer visible (nothing playing yet)
    • +
    • Bottom nav: Home tab active
    • +
    • Header with navigation links
    • +
    +
  4. +
+

2.2 Subsequent Launches

+
flowchart TB
+    Launch[App Launch] --> LoadAuth[Load Stored Token]
+    LoadAuth --> Validate{Token Valid?}
+    Validate -->|Yes| RestoreState[Restore Last Screen]
+    Validate -->|No| LoginScreen[Login Screen<br/>/login]
+
+    RestoreState --> CheckPlayer{Was Player<br/>Active?}
+    CheckPlayer -->|Yes| ShowMiniPlayer[Show MiniPlayer<br/>at bottom]
+    CheckPlayer -->|No| HideMiniPlayer[No MiniPlayer]
+
+    ShowMiniPlayer --> LastScreen[Last Active Screen<br/>with MiniPlayer]
+    HideMiniPlayer --> HomePage[Home Page<br/>/]
+
+

State Restoration:

+
    +
  • Last viewed screen (route) is restored (defaults to / if none)
  • +
  • If audio was playing, MiniPlayer appears at bottom
  • +
  • Playback state is NOT automatically resumed (user must press play)
  • +
  • Queue is restored if it existed
  • +
+
+

3. Audio Playback Flows

+

3.1 Starting Audio Playback

+
flowchart TB
+    Start[User Action] --> Action{Action Type?}
+
+    Action -->|Click Track| TrackList[TrackList Component]
+    Action -->|Click Album| AlbumDetail[Album Detail Page]
+    Action -->|Click Play on Album| AlbumPlay[Play Album Button]
+
+    TrackList --> PlayTrack[Play Single Track]
+    PlayTrack --> QueueAll[Queue All Filtered Tracks]
+
+    AlbumPlay --> PlayAlbum[Play All Album Tracks]
+    PlayAlbum --> QueueAlbum[Queue Album Tracks]
+
+    QueueAll --> InvokePlay[invoke player_play_queue]
+    QueueAlbum --> InvokePlay
+
+    InvokePlay --> PlayerStarts[Player State: Playing]
+    PlayerStarts --> MiniAppears[MiniPlayer Slides Up<br/>from Bottom]
+
+    MiniAppears --> StayOnPage[User Stays on<br/>Current Screen]
+
+

Entry Points for Audio Playback:

+
    +
  1. +

    TrackList (/library/music/tracks, /library/music/albums/[id])

    +
      +
    • Click track number → Play track + queue all visible tracks
    • +
    • Clicking track #3 in an album → Play track 3, queue tracks 1-10
    • +
    +
  2. +
  3. +

    Album Card (grid views)

    +
      +
    • Click album → Navigate to album detail
    • +
    • Play button on card → Play album immediately
    • +
    +
  4. +
  5. +

    Search Results

    +
      +
    • Click track → Play track + queue search results
    • +
    • Click album → Navigate to album detail
    • +
    +
  6. +
+

MiniPlayer Behavior:

+
    +
  • Slides up from bottom with animation (300ms)
  • +
  • Height: 64px on mobile, 80px on desktop
  • +
  • Shows: artwork, title, artist, play/pause, next, favorite
  • +
  • Stays visible on ALL screens (except video player)
  • +
  • Click anywhere on MiniPlayer → Navigate to full player
  • +
+

Track Highlighting: +When audio is playing, the currently playing track is visually highlighted in track lists and album pages:

+
    +
  • Subtle blue background tint
  • +
  • Left border accent in Jellyfin blue
  • +
  • Title text colored in Jellyfin blue
  • +
  • Desktop: Animated pulsing dots indicator next to title
  • +
  • Mobile: Play arrow (▶) inline with title
  • +
  • Highlight updates automatically when skipping to next/previous track
  • +
+

3.2 MiniPlayer → Full Player Transition

+
flowchart TB
+    Mini[MiniPlayer Visible] --> UserClick{User Action}
+
+    UserClick -->|Click MiniPlayer| NavFullPlayer[Navigate to<br/>/player/[id]]
+    UserClick -->|Swipe Up| SwipeGesture[Swipe Gesture<br/>Planned]
+
+    NavFullPlayer --> FullPlayer[Full Audio Player Screen]
+    SwipeGesture --> FullPlayer
+
+    FullPlayer --> ShowControls[Show Full Controls:<br/>- Large artwork<br/>- Progress bar<br/>- Volume slider<br/>- Queue button<br/>- Shuffle/Repeat<br/>- Favorite button]
+
+    ShowControls --> MiniHidden[MiniPlayer Hidden]
+
+

Full Player Screen (/player/[id])

+
    +
  • Header: Song title, artist (clickable links to artist/album pages)
  • +
  • Artwork: Large album art (centered, dominant)
  • +
  • Progress: Seek bar with current time / total duration
  • +
  • Controls: Previous, Play/Pause, Next (large touch targets)
  • +
  • Secondary Controls: Shuffle, Repeat mode, Queue, Favorite
  • +
  • Volume: Volume slider
  • +
  • Bottom Nav: Still visible (can navigate away while playing)
  • +
  • Back button: Returns to previous screen, MiniPlayer reappears
  • +
+

3.3 Full Player → Back to Browsing

+
flowchart TB
+    FullPlayer[Full Player Screen] --> UserAction{User Action}
+
+    UserAction -->|Back Button / Close| HistoryBack[window.history.back]
+    UserAction -->|Bottom Nav Click| NavOther[Navigate to<br/>Other Screen]
+
+    HistoryBack --> PrevScreen[Return to Previous Screen<br/>in Browser History]
+    NavOther --> NewScreen[Navigate to New Screen]
+
+    PrevScreen --> MiniReappears[MiniPlayer Slides Up<br/>from Bottom]
+    NewScreen --> MiniReappears
+
+    MiniReappears --> PlaybackContinues[Playback Continues<br/>in Background]
+
+

Navigation Behavior:

+
    +
  • Back Button: Uses browser history (window.history.back()) to return to the previous page
  • +
  • Expected behavior: Returns user to the screen they were on before opening full player
  • +
  • Example: User browsing album → clicks track → full player opens → clicks back → returns to album
  • +
+

Key UX Principles:

+
    +
  • Playback Never Stops: Navigating away from player does NOT stop playback
  • +
  • MiniPlayer Persistence: MiniPlayer visible on ALL screens (except video/login)
  • +
  • Queue Preserved: Current queue remains intact
  • +
  • State Restoration: Returning to full player shows same state (position, volume, etc.)
  • +
  • Natural Navigation: Back button behaves as expected (returns to previous page, not just closes modal)
  • +
+
+

4. Video Playback Flows

+

4.1 Starting Video Playback

+
flowchart TB
+    Start[User Action] --> Action{Action Type?}
+
+    Action -->|Click Movie| MovieDetail[Movie Detail Page]
+    Action -->|Click Episode| EpisodeClick[Episode Click]
+    Action -->|Click Play Button| PlayButton[Play Button]
+
+    MovieDetail --> PlayMovie[Play Movie Button]
+    EpisodeClick --> PlayEpisode[Play Episode]
+
+    PlayMovie --> CheckResume{Resume<br/>Position?}
+    PlayEpisode --> CheckResume
+
+    CheckResume -->|Yes, >30s| ShowDialog[Resume Dialog]
+    CheckResume -->|No| DirectPlay[Start from Beginning]
+
+    ShowDialog --> UserChoice{User Choice}
+    UserChoice -->|Resume| ResumePlay[Start at Saved Position]
+    UserChoice -->|Start Over| DirectPlay
+
+    ResumePlay --> FullscreenVideo[Fullscreen Video Player<br/>/player/[id]]
+    DirectPlay --> FullscreenVideo
+
+    FullscreenVideo --> HideUI[Hide All UI:<br/>- No Bottom Nav<br/>- No MiniPlayer<br/>- Fullscreen only]
+
+

Resume Dialog:

+
┌─────────────────────────────────────────┐
+│  Continue Watching?                     │
+│                                         │
+│  [Movie Title]                          │
+│  Resume from 12:34 / 1:45:00           │
+│                                         │
+│  [Start from Beginning]  [Resume]      │
+└─────────────────────────────────────────┘
+
+

4.2 Video Player Screen (IR-003, IR-004, UR-003)

+

Initial State (First 3 seconds):

+
    +
  • Controls visible overlay
  • +
  • Top bar: Back button, title
  • +
  • Bottom bar: Play/Pause, seek bar, time, settings (subtitles, audio track)
  • +
  • Center: Large play/pause button
  • +
+

After 3 Seconds (Idle):

+
    +
  • All controls fade out (500ms animation)
  • +
  • Fullscreen video only
  • +
  • System UI hidden (status bar, nav bar)
  • +
+

User Interaction:

+
    +
  • Tap screen: Controls reappear for 3 seconds
  • +
  • Double tap left side: Rewind 10 seconds (shows animated feedback with "-10" indicator)
  • +
  • Double tap right side: Forward 30 seconds (shows animated feedback with "+30" indicator)
  • +
  • Single tap play/pause is deferred by the 300 ms double-tap window, so a double tap +skips without also toggling pause (UR-061)
  • +
  • Swipe up/down on left side: Adjust brightness (0.3-1.7x, shows brightness indicator with progress bar)
  • +
  • Swipe up/down on right side: Adjust volume (0-100%, shows volume indicator with progress bar)
  • +
  • Keyboard arrows: ← rewind 10s, → forward 30s (desktop/external keyboard)
  • +
  • Keyboard space/K: Toggle play/pause
  • +
  • Keyboard F: Toggle fullscreen
  • +
  • Pinch: Zoom (planned)
  • +
+

4.3 Exiting Video Player

+
flowchart TB
+    VideoPlaying[Video Playing] --> UserAction{User Action}
+
+    UserAction -->|Back Button| StopVideo[Stop Playback]
+    UserAction -->|Home Button| Background[App to Background]
+    UserAction -->|Video Ends| VideoEnd[Playback Ended]
+
+    StopVideo --> SaveProgress[Save Progress<br/>to Local DB + Server]
+    VideoEnd --> SaveComplete[Mark as Watched<br/>Save Progress]
+    Background --> PauseVideo[Pause Video]
+
+    SaveProgress --> ExitFullscreen[Exit Fullscreen]
+    SaveComplete --> AutoNext{Next Episode<br/>Available?}
+
+    AutoNext -->|Yes| ShowCountdown[Show Countdown<br/>Next in 5s...]
+    AutoNext -->|No| ExitFullscreen
+
+    ShowCountdown --> UserCancel{User Cancels?}
+    UserCancel -->|Yes| ExitFullscreen
+    UserCancel -->|No, timeout| PlayNext[Play Next Episode]
+
+    ExitFullscreen --> RestoreUI[Restore UI:<br/>- Bottom Nav<br/>- Previous Screen]
+
+    PlayNext --> VideoPlaying
+
+    PauseVideo --> ShowNotification[Show Notification:<br/>Tap to Resume]
+
+

Auto-Next Overlay:

+
┌─────────────────────────────────────────┐
+│                                         │
+│         [Episode Thumbnail]             │
+│                                         │
+│  Next: S01E02 - Episode Title          │
+│  Starting in 5 seconds...              │
+│                                         │
+│  [Cancel]              [Play Now]      │
+└─────────────────────────────────────────┘
+
+
+

5. Music Library Navigation Flows

+

5.1 Music Category Landing Page

+
flowchart TB
+    LibraryHome[Library Home<br/>/library] --> ClickMusic[Click Music Library]
+
+    ClickMusic --> MusicLanding[Music Landing Page<br/>/library/music]
+
+    MusicLanding --> ShowCategories[Show Category Cards:<br/>- Tracks<br/>- Artists<br/>- Albums<br/>- Playlists<br/>- Genres]
+
+    ShowCategories --> UserClick{User Clicks Category}
+
+    UserClick -->|Tracks| TracksPage[All Tracks Page<br/>/library/music/tracks]
+    UserClick -->|Artists| ArtistsPage[Artists Grid<br/>/library/music/artists]
+    UserClick -->|Albums| AlbumsPage[Albums Grid<br/>/library/music/albums]
+    UserClick -->|Playlists| PlaylistsPage[Playlists Grid<br/>/library/music/playlists]
+    UserClick -->|Genres| GenresPage[Genres Browser<br/>/library/music/genres]
+
+

Category Cards:

+
┌─────────────────────────────────────────┐
+│  ┌──────┐  ┌──────┐  ┌──────┐          │
+│  │ 🎵  │  │ 👤  │  │ 💿  │          │
+│  │Track│  │Artist│  │Album│          │
+│  └──────┘  └──────┘  └──────┘          │
+│  ┌──────┐  ┌──────┐                    │
+│  │ 📝  │  │ 🎭  │                    │
+│  │List │  │Genre│                    │
+│  └──────┘  └──────┘                    │
+└─────────────────────────────────────────┘
+
+

5.2 Albums View Flow

+
flowchart TB
+    AlbumsGrid[Albums Grid<br/>grid/list per §5A] --> UserAction{User Action}
+
+    UserAction -->|Click Album| AlbumDetail[Album Detail Page<br/>/library/[id]]
+    UserAction -->|Click Play on Card| PlayAlbum[Play Album Immediately]
+
+    AlbumDetail --> ShowAlbum[Show Album:<br/>- Album Art<br/>- Title, Artist<br/>- Track List<br/>- Download Button<br/>- Favorite Button]
+
+    ShowAlbum --> TrackAction{User Action}
+
+    TrackAction -->|Click Track| PlayTrack[Play Track + Queue Album]
+    TrackAction -->|Click Artist| NavArtist[Navigate to Artist Page]
+    TrackAction -->|Download Album| DownloadFlow[Download Flow]
+    TrackAction -->|Back Button| BackToGrid[Return to Albums Grid]
+
+

Album Detail Layout:

+
┌─────────────────────────────────────────┐
+│  [←]              [♡] [⬇]               │
+│                                         │
+│      ┌────────────────────┐             │
+│      │                    │             │
+│      │   Album Artwork    │             │
+│      │                    │             │
+│      └────────────────────┘             │
+│                                         │
+│  Album Title                            │
+│  Artist Name (clickable)                │
+│  2024 • 12 tracks • 45:23              │
+│                                         │
+│  [▶ Play]  [🔀 Shuffle]                │
+│                                         │
+│  ─────────────────────────────────────  │
+│  1  Track Title               3:45     │
+│  2  Track Title               4:12     │
+│  3  Track Title               3:28     │
+│  ...                                    │
+└─────────────────────────────────────────┘
+
+

5.3 Artist Navigation

+
flowchart TB
+    ArtistsGrid[Artists Grid] --> ClickArtist[Click Artist]
+
+    ClickArtist --> ArtistPage[Artist Detail Page<br/>/library/artist/[id]]
+
+    ArtistPage --> ShowContent[Show Artist Content:<br/>- Artist Photo<br/>- Biography<br/>- Albums Grid<br/>- Top Tracks<br/>- Similar Artists]
+
+    ShowContent --> UserAction{User Action}
+
+    UserAction -->|Click Album| AlbumDetail[Album Detail Page]
+    UserAction -->|Play Top Tracks| PlayArtist[Play Artist Radio]
+    UserAction -->|Click Similar Artist| OtherArtist[Other Artist Page]
+
+
+

5A. Library Page Layouts

+

Every browse page is one of two shapes: a card grid or a row list. This +section is the rule for which shape a page takes, what a card looks like, and +what the user is allowed to change.

+

5A.1 Card shape follows the media, not the page

+

Card aspect ratio is a property of what the item is, and is never overridden +per-page. This is the single most important layout rule: a user scanning a grid +recognises content type by silhouette before reading a word.

+
+ + + + +
Item typeAspectRationale
Album, Artist, Track, Playlist1:1 squareMatches album art; the universal music convention (Spotify)
Movie, Series, Season2:3 posterMatches printed poster art; the universal video convention (Netflix)
Episode16:9 thumbnailA frame from the episode, not cover art — signals "a thing you watch next"
Library / collection folder16:9Reads as a container, distinct from the items inside it
+
+

Artist cards are square but rendered circular-masked, so artists are +distinguishable from albums at a glance within the same music grid.

+

5A.2 Grid vs. list

+
flowchart TB
+    Page[Library browse page] --> Kind{Content kind}
+
+    Kind -->|Visual-first<br/>albums, artists, movies,<br/>shows, playlists| Grid[Card grid<br/>user may switch to list]
+    Kind -->|Ordinal<br/>tracks in an album,<br/>episodes in a season| List[Row list<br/>always; no toggle]
+
+    Grid --> Toggle[View toggle in page header]
+    Toggle --> Persist[Choice persists globally<br/>across all grid pages]
+
+
    +
  • Grids are the default for anything with cover art worth scanning.
  • +
  • Lists are mandatory, not optional, where position carries meaning — +a track's number within an album, an episode's number within a season. +A grid destroys that ordering cue, so these pages expose no toggle.
  • +
  • The toggle is global, not per-page. A user who prefers dense lists +prefers them everywhere; making them re-set it on each page is friction. +The choice persists across launches.
  • +
+

Responsive columns (grid mode), tuned so cards stay large enough to read +cover art on a phone and don't become postage stamps on a desktop:

+
+ + + + + +
BreakpointColumns
base (phone)2
sm3
md4
lg5
xl6
+
+

5A.3 What a card shows

+
   ┌─────────────┐
+   │             │   ← cover art (aspect per §5A.1)
+   │   artwork   │     • progress bar overlay if partially played
+   │             │     • watched/played check if complete
+   │        [▶]  │     • play affordance on hover/focus
+   └─────────────┘
+   Primary line       ← title, truncated to one line
+   Secondary line     ← artist / year+rating / SxEy — one line, dimmed
+
+
    +
  • Two lines of text maximum. Titles truncate rather than wrap; a card that +grows to fit its title breaks grid alignment and makes scanning harder.
  • +
  • Progress and watched state live on the artwork, not in the text — they +must be readable while scanning, without reading.
  • +
  • Hover/focus reveals play, so a card is both a navigation target and a +playback target without a second control competing for space at rest.
  • +
+

5A.4 Known deviations

+

These are places the implementation currently diverges from the rules above. +They are recorded here so the gap is explicit rather than mistaken for intent.

+
    +
  • The view toggle is discoverable only on a browse page. The preference is +already global and persisted, but the only control that sets it is the pair +of icon buttons in a library page header. Settings has no display section, so +there is nowhere to look for it. (UR-029)
  • +
+
+

5B. Video Detail Page Composition

+

Movie, Series, and Episode detail pages all live at /library/[id]. Which +surface renders is decided by item type plus the ?episode= query param, and +section order is part of the spec — it is what makes "keep watching this +show" the path of least resistance.

+

5B.1 Which surface renders

+
flowchart TB
+    Nav[Navigate to /library/&#91;id&#93;] --> Type{Item type}
+
+    Type -->|Person| Person[PersonDetailView]
+    Type -->|Movie| Movie[Movie detail<br/>§5B.3]
+    Type -->|Series| Ep{?episode= param<br/>present?}
+
+    Ep -->|Yes| Focus[Episode Focus View<br/>§5B.2]
+    Ep -->|No| Series[Series detail<br/>§5B.4]
+
+    Focus -->|Back to series| Series
+    Series -->|Click episode| Focus
+
+

An episode is never browsed as a bare Episode item page. Clicking an +episode anywhere — a series' season list, a Home carousel (§5B.5), etc. — +navigates to /library/<seriesId>?episode=<episodeId>, so the episode is always +shown in the context of its series and the series' full episode list is already +loaded. Should an episode ever arrive without a seriesId (deep link, stale +cache), the bare Episode page renders as a fallback and links back to its parent +series and season by title so the user is never stranded.

+

5B.2 Episode Focus View — section order

+

The next episodes appear directly below the current episode, above cast and +similar shows. Nothing may be inserted between the episode hero and the +episode strip.

+
┌─────────────────────────────────────────────────┐
+│ [←]                                             │
+│   ┌───────────────────────────────────────────┐ │
+│   │            episode backdrop               │ │
+│   │  Series Name                              │ │  ← 1. HERO
+│   │  Episode Title                            │ │
+│   │  S2E4 • 48m • ★8.1                        │ │
+│   │  Overview…                                │ │
+│   │  ▓▓▓▓▓░░░░░  32m left                     │ │
+│   │  [▶ Play]  [⬇]  [♡]                       │ │
+│   └───────────────────────────────────────────┘ │
+│                                                 │
+│  More Episodes                                  │  ← 2. EPISODE STRIP
+│  ┌──────┐┌──────┐┌──────┐┌──────┐               │     (immediately below hero)
+│  │ E3   ││▓E4▓  ││ E5   ││ E6   │  →  scroll    │
+│  │      ││NOW   ││      ││      │               │
+│  └──────┘└──────┘└──────┘└──────┘               │
+│                                                 │
+│  Cast                                           │  ← 3. CAST
+│  ( ○ )( ○ )( ○ )( ○ )                           │
+│                                                 │
+│  More Like This                                 │  ← 4. SIMILAR
+│  ┌────┐┌────┐┌────┐┌────┐                       │
+└─────────────────────────────────────────────────┘
+
+

Rules for the episode strip:

+
    +
  • Position is fixed. Hero → episode strip → cast → similar. The strip sits +between the current episode and every other section; cast and related +content are below it, never above.
  • +
  • Window, not full list. The strip shows a window around the current +episode — roughly 3 before and 6 after — so the immediate next episodes are +visible without scrolling, and earlier ones remain reachable by scrolling +left. It is horizontally scrollable, not a wrapped grid.
  • +
  • Forward bias. More episodes are shown after the current one than +before it: the dominant intent on this screen is "watch the next one."
  • +
  • The current episode is present and marked. It renders in-strip with a +"NOW" badge and a highlight ring, and is not clickable. It anchors the +user's position in the season rather than being hidden.
  • +
  • Cross-season continuity. The window spans the whole series in episode +order, so the strip runs past a season boundary into the next season's first +episodes rather than dead-ending at the end of a season.
  • +
  • Per-episode state. Each card shows a thumbnail, SxEy + title, a resume +progress bar when partially watched, and a watched checkmark when complete.
  • +
  • Clicking an episode swaps focus in place (?episode= changes); it does +not start playback. Playback starts only from the hero's Play button.
  • +
+

5B.3 Movie detail — section order

+
Hero (poster, title, metadata, Play / Download / Favorite)
+  → Crew links (Directed by / Written by / Music by)
+  → Genre tags
+  → Cast
+  → More Like This
+
+

A movie has no continuation set, so cast follows the hero directly.

+

5B.4 Series detail — section order

+
Hero (poster, title, metadata, Resume SxEy / Download / Favorite / Clear history)
+  → Crew links
+  → Genre tags
+  → Seasons (collapsible; only the current season expanded)
+  → Cast
+  → More Like This
+
+

The same principle as §5B.2: episodes come before cast and similar shows. +The reason a user opens a series page is to pick an episode; discovery content +is secondary and sits underneath.

+

Rules for the seasons block (UR-062, UR-064):

+
    +
  • The page opens where the viewer is. The backend resolves the current +episode — in progress, else Next Up, else first unwatched, else the premiere — +and the page scrolls it into view with an Up next badge and a highlight ring. +Never season 1 by default, unless season 1 is where the viewer is.
  • +
  • Seasons collapse; only the current one is expanded. A ten-season show +otherwise renders hundreds of rows and buries the episode the viewer came for. +A collapsed season still names its episode count and watched count, so +progress is readable without expanding it.
  • +
  • The hero button opens, it does not play. It reads Resume S2E4 / +Play S1E1 — naming its target — and navigates to that episode's Focus View, +where Play commits. Play on a container is navigation (§5B.5); Play on a +leaf is the commitment.
  • +
  • A season is never its own page. /library/<seasonId> redirects to +/library/<seriesId>#season-N. Every affordance that names a season — the +episode breadcrumb, a season card in a grid, a Downloads drill-in — lands on +the series with that season in view, so the episodes of all seasons stay one +browsable list.
  • +
  • Watch history is erasable per series (hero) and per season (season +header). It confirms first, cannot be undone, and needs the server. Clearing a +whole series returns it to S1E1 by the same path a never-watched show takes.
  • +
+

5B.5 Home-card interaction — tap opens, long-press plays

+

Cards on the Home screen carousels (Next Movie, Next Episode, Continue +Watching, Recently Added, …) do not play on tap. A plain tap opens the +item; playback is the deliberate, second gesture.

+
+ + + + +
Card kindTap (short)Long-press (~500 ms hold)
MovieMovie detail page (/library/<id>)Confirm → play now (/player/<id>)
EpisodeSeries Episode Focus View (/library/<seriesId>?episode=<id>, per §5B.1)Confirm → play now (/player/<id>)
Series / Season / Album / Artist / Playlist / FolderDetail page (/library/<id>)Same as tap (no single "play now" target)
Channel / live leafPlayer (/player/<id>) — no detail page existsConfirm → play now
+
+

Rationale and rules:

+
    +
  • Tap is navigation, not commitment. Previously a tap on a movie/episode +jumped straight into the player, which made it easy to lose your place in a +half-watched item or start a stream you only meant to inspect. Tap now lands +on the detail/focus page, where Play is an explicit button.
  • +
  • Long-press is the shortcut for "just play it." It surfaces a native +confirm (Play "<name>" now?) before starting playback, so an accidental +hold never blows away a resume position silently.
  • +
  • The long-press must not fight the carousel. Detection cancels if the +pointer moves more than ~10 px (a horizontal scroll of the row), so holding +to scroll never triggers play.
  • +
  • Episodes still obey §5B.1 — a home tap on an episode opens the series +Focus View, never a bare Episode page, so the series context loads.
  • +
+

This behavior lives in MediaCard (onLongPress prop + pointer-based +detection) so any surface can opt in; today the Home carousels are the only +opt-in. Grids and other surfaces keep tap-to-open with no long-press.

+
+

5C. Favourites

+

Favouriting is a two-sided promise: the heart takes the input, and the app must +be able to give it back. This section covers both sides — where you can mark a +favourite, and where marked favourites resurface.

+

See architecture/01-rust-backend.md for the layer +assignment and wire shapes.

+

5C.1 The heart appears wherever an item does

+

A favourite is a property of an item, so the affordance follows the item +rather than living on one privileged screen. Any surface that shows a whole +item shows its heart.

+
+ + + + + + +
SurfaceHeart positionNotes
Movie / Series detail heroIn the button row, after Play and Download§5B.3, §5B.4
Episode Focus View heroSame row as Play / Download§5B.2
Album, Artist, Playlist detailIn the header button row§5.2
Media card (any grid or carousel)Top-right overlay on the artworkHidden on server-only (greyed) cards
Mini playerRight of the track metadataExisting behaviour, unchanged
Full playerSecondary controls row§3.2 — not yet built, see §5C.5
+
+

Rules:

+
    +
  • The heart never competes with the card. On a media card it is its own +button and swallows the tap, so hearting an item never also opens or plays +it, and never triggers the §5B.5 long-press.
  • +
  • State is shown, not guessed. A filled heart means the server considers +the item a favourite (or you just tapped it). An item favourited in Jellyfin +Web, on another device, or by another client renders filled here without +being touched in JellyTau.
  • +
  • Feedback is immediate. The heart fills on tap and a toast confirms; +neither waits for the server round-trip.
  • +
+

5C.2 Three ways back to what you favourited

+

Favourites are not one destination — they are a lens, and the right surface +depends on whether the user is browsing, deciding, or hunting.

+
flowchart TB
+    User[User wants their favourites] --> How{Intent}
+
+    How -->|Passive: show me something| Home[Home carousels<br/>Favourite Movies / Shows / Music]
+    How -->|Deliberate: my whole collection| Page[Favourites page<br/>/library/favorites]
+    How -->|Narrowing: within this library| Filter[Favourites filter<br/>on a library page]
+
+    Home -->|See all| Page
+    Page --> Detail[Item detail page]
+    Filter --> Detail
+
+

Home carousels. Rows for favourite movies, shows and music sit below +Recently Added. A row with nothing in it does not render — a fresh install +shows no empty favourite rows. Each row ends with See all, landing on the +matching tab of the Favourites page.

+

The Favourites page (/library/favorites) is the complete collection, +scoped by tabs:

+
┌─────────────────────────────────────────────────┐
+│ [←]  Favourites                                 │
+│  ┌─────┬────────┬───────┬───────┐               │
+│  │ All │ Movies │ Shows │ Music │  ← scope tabs │
+│  └─────┴────────┴───────┴───────┘               │
+│                                                 │
+│  ┌────┐┌────┐┌────┐┌────┐┌────┐                 │
+│  │ ♥  ││ ♥  ││ ♥  ││ ♥  ││ ♥  │   grid/list    │
+│  └────┘└────┘└────┘└────┘└────┘   per §5A      │
+└─────────────────────────────────────────────────┘
+
+
    +
  • Cards obey §5A in full — shape follows the media, so a mixed All tab reads +as posters, squares and thumbnails side by side rather than one forced shape.
  • +
  • Reached from a card on the library overview (/library) and from See all +on any home favourites row.
  • +
  • Sorted by name. Jellyfin does not record when an item was favourited, so +"recently favourited" is not offerable — see §5C.5.
  • +
  • Empty state, per tab: "Nothing favourited yet — tap the heart on anything +you like."
  • +
+

The in-library filter is for narrowing where the user already is: a +favourites toggle in the header of the Movies, TV and Music browse pages, +filtering the current list in place. It is session-scoped and not persisted — +a sticky filter that silently hides most of a library reads as data loss on the +next launch.

+

5C.3 Removing a favourite removes it everywhere, at once

+

Un-hearting an item on the Favourites page removes its card from the grid +immediately; the same item disappears from the home rows and shows an empty +heart on its detail page without a manual refresh. The reverse holds for +favouriting. There is no confirmation prompt — the action is one tap to undo.

+

5C.4 Offline

+
    +
  • Marking works offline. The heart fills, the toast confirms, and the change +is held locally.
  • +
  • It reaches the server on reconnect, without the user returning to the +screen where they made it.
  • +
  • Browsing offline shows favourites among media on the device, subject to +the same "Show all server media" gate as every other browse surface (§7.2) — +with the gate off, an empty Favourites tab means nothing favourited is +downloaded, and the page does not quietly fall back to the server catalog.
  • +
+

5C.5 Known deviations

+
    +
  • The full player has no heart. §3.2 and §3.3 list a Favorite button among +the full player's secondary controls; it was never built, and this pass does +not add it. The mini player heart above it is the only in-player affordance. +(UR-067)
  • +
  • No "recently favourited" sort. Jellyfin's API does not expose a favourite +timestamp, so favourites can only be ordered by name. Recording the +timestamp locally at toggle time would order this device's favourites only, +which is worse than a consistent name sort.
  • +
  • Music is one tab, not three. The Music scope mixes albums, artists and +tracks in a single grid rather than offering sub-tabs. Acceptable while +favourite counts are small; revisit if the tab becomes unscannable.
  • +
+
+

6. Search Flow

+

Search is context-scoped: what you are looking at when you start a search +determines what the search covers. A search begun inside the Music library +searches music. A search begun from Home or the top-level library page searches +everything. The scope is always shown, and always overridable.

+

6.1 Scope is inherited from context

+
flowchart TB
+    Start[User starts a search] --> Where{Where from?}
+
+    Where -->|Home &#40;/&#41;| All[Scope: All]
+    Where -->|Library root &#40;/library&#41;| All
+    Where -->|Search tab| All
+    Where -->|Inside Music| Music[Scope: Music]
+    Where -->|Inside Movies| Movies[Scope: Movies]
+    Where -->|Inside TV| TV[Scope: TV]
+
+    All --> Chips[Filter chips shown<br/>All chip selected]
+    Music --> Chips2[Filter chips shown<br/>Music chip preselected]
+    Movies --> Chips2
+    TV --> Chips2
+
+    Chips --> Results[Results, grouped by type]
+    Chips2 --> Results
+
+    Results --> Change{User taps a chip}
+    Change --> Rescope[Re-run search at new scope<br/>query preserved]
+    Rescope --> Results
+
+

Rules:

+
    +
  • +

    Context sets the initial chip, never a locked filter. Entering search +from TV preselects the TV chip; the user can tap "All" to widen without +retyping the query. Scope is a starting point, not a cage.

    +
  • +
  • +

    Home, /library, and the search tab all start at "All". These are the +places a user has expressed no narrower intent.

    +
  • +
  • +

    Changing scope preserves the query and re-runs the search. Changing the +query preserves the scope.

    +
  • +
  • +

    Scope maps to item types, resolved at the point of search:

    +
    + + + + +
    ChipincludeItemTypes
    All(unset — every type)
    MusicMusicAlbum, MusicArtist, Audio, Playlist
    MoviesMovie
    TVSeries, Episode
    +
  • +
  • +

    Chips render under the search bar, on both the dedicated search page and +the in-library header search. They are horizontally scrollable if they +overflow, never wrapped onto a second row.

    +
  • +
+

6.2 Search page layout

+
┌─────────────────────────────────────────┐
+│  [🔍 Search...]                  [✕]    │
+│                                         │
+│  ( All ) (•Music•) ( Movies ) ( TV )    │  ← scope chips
+│                                         │
+│  Songs ────────────────────────────     │
+│  ♪ Song Title - Artist          3:45    │
+│  ♪ Song Title - Artist          4:12    │
+│  See all (23)                           │
+│                                         │
+│  Albums ───────────────────────────     │
+│  [Cover] Album Title                    │
+│  See all (8)                            │
+│                                         │
+│  Artists ──────────────────────────     │
+│  ( Photo ) Artist Name                  │
+│  See all (5)                            │
+└─────────────────────────────────────────┘
+
+
    +
  • Results stay grouped by type even when a scope is selected — a Music +search still separates Songs / Albums / Artists.
  • +
  • Each group shows a bounded preview with a See all (n) affordance rather +than an unbounded list, so no single type can bury the others.
  • +
  • Live search is debounced as the user types; a query that becomes empty +clears results rather than searching for the empty string.
  • +
+

6.3 Result group order is user-configurable

+

Which kind of thing a user is usually searching for is personal: a +music-first user wants Songs at the top, a TV-first user wants Shows. Rather +than guessing, the group order is a setting.

+
flowchart TB
+    Settings[Settings → Search] --> List[Draggable list of result groups]
+
+    List --> Drag[User drags a group up or down]
+    Drag --> Persist[Order persisted]
+
+    Persist --> Render[Rendering a result set]
+    Scope[Active scope chip §6.1] --> Render
+
+    Render --> Filter[1 - Drop groups outside the active scope]
+    Filter --> Sort[2 - Sort remaining groups by user order]
+    Sort --> Prune[3 - Omit groups with no results]
+    Prune --> Show[Render]
+
+

Scope and order compose — they are two independent axes. The scope chip +decides which groups are eligible; the settings list decides what sequence +the eligible ones appear in. Order is preserved as a relative ranking, never +renumbered per scope:

+
    +
  • Scope Music with order Movies → Songs → Albums → Artists → TV renders +Songs → Albums → Artists. Movies and TV are filtered out; the surviving +groups keep their relative order.
  • +
  • Scope All with the same setting renders all five in exactly that order.
  • +
  • Changing scope never rewrites the saved order. A user who narrows to +Music and back to All sees their original arrangement intact.
  • +
+

Rules:

+
    +
  • Drag and drop to reorder, in a settings list showing every result group +(Songs, Albums, Artists, Movies, TV Shows).
  • +
  • The order applies to grouped results everywhere — the search page and +the in-library header search alike.
  • +
  • Order is presentation-only. It never changes which results are returned +or how they are ranked within a group, only the sequence groups appear in.
  • +
  • Empty groups are skipped, not gapped. A group with no results is omitted +entirely; it does not reserve space or leave a stray heading.
  • +
  • A sensible default ships (Songs → Albums → Artists → Movies → TV Shows) +so the setting is an adjustment, never a prerequisite.
  • +
  • Keyboard/accessible reordering must exist alongside dragging — a +drag-only control is unusable with a screen reader or without a pointer.
  • +
+

6.4 Known deviations

+

Recorded so the gap between this spec and the build is explicit.

+
    +
  • Scope is not implemented. The in-library header search calls the same +unscoped query as the global search page, so searching inside TV returns +music. The backend already accepts includeItemTypes on both the online and +offline paths, and the per-page list search already uses it — only the global +path ignores it. (UR-049)
  • +
  • Filter chips do not exist on either search surface. (UR-049)
  • +
  • Group order is hardcoded to Music → Movies → TV in the results markup, +with no setting. (UR-050)
  • +
+
+

7. Download Flows

+

7.1 Initiating Downloads

+
flowchart TB
+    User[User on Album/Track Page] --> ClickDownload[Click Download Button]
+
+    ClickDownload --> CheckType{Download Type?}
+
+    CheckType -->|Single Track| DownloadTrack[Download Single File]
+    CheckType -->|Album| DownloadAlbum[Download All Tracks]
+    CheckType -->|Artist| ShowOptions[Show Options Dialog]
+
+    ShowOptions --> UserChoice{User Choice}
+    UserChoice -->|Discography| DownloadAll[Download All Albums]
+    UserChoice -->|Select Albums| AlbumPicker[Album Selection UI]
+
+    DownloadTrack --> QueueDownload[Queue in Download Manager]
+    DownloadAlbum --> QueueMultiple[Queue Multiple Files]
+
+    QueueDownload --> ShowProgress[Show Progress Ring<br/>on Download Button]
+    QueueMultiple --> ShowProgress
+
+    ShowProgress --> DownloadActive[Download Active:<br/>Button shows % complete]
+
+

Download Button States:

+
States:
+1. [⬇] Available     - Gray outline
+2. [○ 45%] Downloading - Blue ring progress
+3. [✓] Downloaded    - Green checkmark
+4. [!] Failed        - Red with retry option
+5. [⏸] Paused        - Yellow pause icon
+
+

7.2 Downloads = a browsable offline library, not a flat list

+

The central idea: "my downloads" is not a list of file-transfer rows — it is +the library, filtered to what's on the device. A user who has downloaded three +seasons of a show and two albums thinks in terms of shows and albums, not +seventy-odd individual episode/track transfers. So the primary Downloads surface +reuses the library browse screens, scoped to downloaded content, and keeps +the transfer-progress list as a secondary "Transfers" view for the act of +downloading.

+

This splits one overloaded page into two clear jobs:

+
+ + +
SurfaceAnswersReuses
Downloaded (browse)"What do I have offline, and let me play it"Library grids, detail pages, cards (§5A)
Transfers (activity)"What is downloading right now, and control it"The existing progress-row list
+
+
flowchart TB
+    Nav[Open Downloads] --> Downloads[/downloads]
+
+    Downloads --> View{View}
+    View -->|Downloaded &#40;default&#41;| Browse[Offline library browse]
+    View -->|Transfers| Activity[Transfer activity list]
+
+    Browse --> Libs[Libraries — only those with<br/>downloaded content]
+    Libs --> Grid[Library grid, offline-scoped<br/>same cards/layout as online §5A]
+    Grid --> Detail[Detail page<br/>same as online]
+    Detail --> Play[Play from local file]
+    Detail --> Remove[Remove download<br/>frees space, keeps browsable? — see rules]
+
+    Activity --> Rows[Per-transfer rows:<br/>downloading / queued / paused / failed /<br/>waiting-for-WiFi]
+    Rows --> Ctl[Pause / Resume / Cancel / Retry]
+
+

Why reuse the library screens (not a bespoke list):

+
    +
  • One mental model. Browsing offline should feel identical to browsing +online — same grids, same card shapes, same detail pages, same play action. +The only difference is what's present, not how it looks.
  • +
  • It already works in the backend. The offline repository's get_items +already returns downloaded items plus their containers (an album with any +downloaded track, a series/season with any downloaded episode). That is a +browsable tree today — see §7.4.
  • +
  • It scales. A flat completed-list becomes unusable at a few dozen items; a +browsable library does not.
  • +
+

7.3 The Downloaded browse surface

+
┌─────────────────────────────────────────────┐
+│  Downloads                                  │
+│  ( Downloaded )  ( Transfers )     ← view switch
+│                                             │
+│  [~ 3.4 GB on device · 12 items]  Manage ▸  │  ← storage summary
+│                                             │
+│  Music                                      │  ← only libraries that
+│  ┌────┐┌────┐┌────┐                          │     have downloaded content
+│  │alb ││alb ││art │                          │
+│  └────┘└────┘└────┘                          │
+│                                             │
+│  TV                                         │
+│  ┌────┐┌────┐                                │
+│  │show││show│                                │
+│  └────┘└────┘                                │
+└─────────────────────────────────────────────┘
+
+

Rules:

+
    +
  • Libraries with nothing downloaded are omitted, not shown empty. If only +music is downloaded, only Music appears.
  • +
  • Cards, grids, and detail pages are the library's own (§5A) — offline +browse is the same components with an offline-scoped data source, never a +parallel re-implementation.
  • +
  • A downloaded badge / "on device" affordance distinguishes fully-downloaded +from partially-downloaded containers (e.g. a season with 6 of 10 episodes).
  • +
  • Disk usage is shown where the user already looks, in familiar units — see +§7.3.1.
  • +
  • Play always plays the local file here; nothing on this surface streams.
  • +
  • Remove is available at every level — item, album/season, series — and +states clearly what it frees. Removing the last downloaded child of a +container removes the container from the browse.
  • +
  • This surface works identically online and offline. It is "what's on the +device," a question whose answer does not depend on connectivity. It must not +wait for, or be emptied by, server reachability.
  • +
+

7.3.1 Disk usage — familiar, in place, not a separate audit

+

Users want to know what each thing costs on disk, but that information has to +feel like the storage views they already know (phone Settings → Storage, a +file browser), not a developer's byte dump.

+
    +
  • Size rides along with the item, on the card and the detail page — a small +secondary label (1.2 GB, 340 MB, 48 MB), never a separate "storage +report" screen the user has to go find.
  • +
  • Containers show their total. A series shows the sum of its downloaded +episodes; an album the sum of its tracks; a season its own subtotal. The +number a user sees on the "Breaking Bad" card is what removing it frees.
  • +
  • Human units, rounded, consistent. Binary or decimal is a choice — pick one +and use it everywhere. Show 2–3 significant figures (1.2 GB, not +1,283,048,192 bytes and not 1.28394 GB).
  • +
  • A single device total sits at the top of the Downloaded surface +(3.4 GB on device · 12 items) so the headline number is answered before the +user scans. It reconciles with the sum of what's listed.
  • +
  • Remove restates the reclaim in the same units at the point of action +("Remove download · frees 1.2 GB"), so the cost of keeping vs. freeing is +legible exactly when the user decides.
  • +
  • Sort/filter by size is a reasonable enhancement ("biggest first" to find +what to clear) but is not required for v1.
  • +
+

The bytes-on-disk per item are a backend fact (the download manager writes the +files and can stat them); this is a display and aggregation task, not new +tracking. See §7.7 deviations for what's missing today.

+

7.4 Transfers (activity) view

+

The existing progress-row list, unchanged in spirit, demoted to a secondary tab. +It is about transfers in flight, so it shows only rows that are doing or +waiting to do something:

+
    +
  • States: downloading (with progress), queued, paused, failed, +waiting-for-WiFi (§7.5).
  • +
  • Controls: Pause / Resume / Cancel / Retry per row; the 3-concurrent cap +and auto-pump are backend concerns and are not surfaced as manual controls.
  • +
  • Completed transfers fall off this view once done — the finished item lives +in Downloaded, not here. A transient "just finished" confirmation is fine; a +permanent completed-list is not (that's what Downloaded is for).
  • +
  • Empty state points at the library: "Nothing downloading. Browse your +library and tap download to save media for offline."
  • +
+

7.5 Navigation & entry points

+
    +
  • Reached via the account menu (§1.2) and, on desktop, the header Downloads +link/icon → /downloads.
  • +
  • /downloads opens on Downloaded by default; Transfers is one tap away +and should draw attention (badge/count) only while transfers are active.
  • +
  • Initiating a download is unchanged (§7.1): the download button lives on +item/album/series detail pages. The Downloads page manages and browses; it is +not where you start a download.
  • +
+

7.7 Known deviations

+

Recorded so the gap between this spec and the build is explicit.

+
    +
  • Downloads is a flat two-tab list today (Active / Completed), rendering one +row per individual transfer with no browsing, grouping, or reuse of the +library screens. Completed downloads never collapse into their album/series. +(UR-055)
  • +
  • No offline-scoped browse entry point exists in the client. All browsing +goes through the hybrid repository, which merges cache and server; there is +no way to ask for "downloaded content only" as a browse surface. The offline +repository supports it (§7.2) but is not reachable independently. (UR-055, +DR-082)
  • +
  • The "on device" storage summary and per-container remove are absent from +the completed list. (UR-055, UR-056)
  • +
  • Per-item disk usage is not displayed anywhere. Cards and detail pages show +no size; there is no device total, no container subtotal, and Remove does not +state what it frees. (UR-056)
  • +
+
+

8. Settings & Account Flows

+

8.1 Settings Navigation

+
flowchart TB
+    User[User] --> NavChoice{Navigation Path}
+
+    NavChoice -->|Desktop| HeaderSettings[Header: Click Settings Link]
+    NavChoice -->|Mobile| OverflowMenu[Click Overflow Menu<br/>→ Settings]
+    NavChoice -->|Direct| TypeURL[Navigate to /settings]
+
+    HeaderSettings --> SettingsPage[Settings Page<br/>/settings]
+    OverflowMenu --> SettingsPage
+    TypeURL --> SettingsPage
+
+    SettingsPage --> ShowSections[Show Sections:<br/>- Account<br/>- Playback<br/>- Downloads<br/>- Appearance<br/>- About]
+
+    ShowSections --> UserClick{User Clicks Section}
+
+    UserClick -->|Account| AccountSettings[Account Settings:<br/>- Server URL<br/>- Username<br/>- Logout button]
+    UserClick -->|Playback| PlaybackSettings[Playback Settings:<br/>- Gapless playback<br/>- Volume normalization<br/>- Crossfade duration]
+    UserClick -->|Downloads| DownloadSettings[Download Settings:<br/>- Max concurrent<br/>- WiFi only<br/>- Storage location<br/>- Auto-cache next tracks]
+    UserClick -->|Appearance| AppearanceSettings[Appearance Settings:<br/>- Dark mode<br/>- Accent color]
+
+

Navigation to Settings:

+
    +
  • Desktop: Click "Settings" link in header navigation
  • +
  • Mobile: Click three-dot overflow menu → Select "Settings"
  • +
  • Direct: Navigate to /settings route
  • +
+

Settings apply instantly. Every control on the Settings page persists the +moment the user changes it — toggling a switch, picking a level, or releasing a +slider writes that setting immediately. There is no "Save" button and no +save/dirty state to reason about; leaving the page never risks losing a change. +Sliders update their live readout while dragging but only persist on release +(change, not each input tick) to avoid flooding the backend.

+

8.2 Logout Flow

+
flowchart TB
+    AnyScreen[Any Screen] --> ClickLogout[Click Logout Button<br/>in Header]
+
+    ClickLogout --> ConfirmDialog[Show Confirmation:<br/>"Log out of [Server]?"]
+
+    ConfirmDialog --> UserConfirm{User Confirms?}
+
+    UserConfirm -->|No| CancelLogout[Cancel - Stay on Current Screen]
+    UserConfirm -->|Yes| StopPlayer[Stop Playback]
+
+    StopPlayer --> ClearToken[Delete Token from Keyring]
+    ClearToken --> ClearState[Clear App State:<br/>- Player state<br/>- Queue<br/>- Current screen]
+
+    ClearState --> NavLogin[Navigate to Login Screen<br/>/login]
+
+    NavLogin --> ShowLogin[Show Login Screen:<br/>- No Header<br/>- No Bottom Nav<br/>- No MiniPlayer]
+
+

Logout Button Location:

+
    +
  • Always visible in header user menu (logout icon)
  • +
  • Accessible from any authenticated screen
  • +
+
+

9. Background & Lock Screen Behavior

+

9.1 Audio Playback in Background (Android)

+
flowchart TB
+    Playing[Audio Playing] --> Background{User Action}
+
+    Background -->|Home Button| AppBackground[App to Background]
+    Background -->|Screen Lock| ScreenLock[Screen Locked]
+
+    AppBackground --> ContinuePlay[Playback Continues]
+    ScreenLock --> ContinuePlay
+
+    ContinuePlay --> ShowNotification[Show Media Notification:<br/>- Artwork<br/>- Title/Artist<br/>- Play/Pause<br/>- Next/Previous]
+
+    ShowNotification --> LockScreen[Lock Screen Controls:<br/>Media Session Integration]
+
+    LockScreen --> UserInteract{User Interaction}
+
+    UserInteract -->|Tap Notification| OpenApp[Open App to Last Screen<br/>with MiniPlayer]
+    UserInteract -->|Lock Screen Controls| SendCommand[Send Command to Player]
+    UserInteract -->|BLE Headset Button| HeadsetControl[AVRCP Command]
+
+

Notification Layout (Android):

+
┌─────────────────────────────────────────┐
+│  [Artwork] Song Title                   │
+│           Artist Name                   │
+│           Album Name                    │
+│                                         │
+│  [⏮]  [⏸]  [⏭]                [✕]      │
+└─────────────────────────────────────────┘
+
+

9.2 Video Playback in Background (Android — PiP & Background Audio)

+

Leaving the app while a local video is playing does not simply pause it. +What happens depends on which background behaviour is active. The two are +mutually exclusive, and both apply only to locally-rendering video — +audio-only playback, library/menu browsing, and remote/cast sessions never +trigger PiP (see decision gate below).

+
flowchart TB
+    Leave[User leaves app<br/>Home / gesture / screen lock] --> Gate{Local video surface<br/>actively rendering?<br/>canEnterPip}
+
+    Gate -->|No — audio, browsing,<br/>or remote/cast| Normal[App backgrounds normally<br/>audio, if any, continues via<br/>media notification &#40;§9.1&#41;]
+
+    Gate -->|Yes| Mode{Background mode armed?}
+
+    Mode -->|Background-audio toggle ON<br/>UR-040| Handoff[Hand off to native audio service<br/>WebView &lt;video&gt; torn down,<br/>video decode stops, audio continues]
+    Mode -->|Default<br/>UR-041| PiP[Auto-enter Picture-in-Picture<br/>on onUserLeaveHint]
+
+    PiP --> PiPWindow[Floating PiP window:<br/>- Video keeps rendering into surface<br/>- WebView hidden<br/>- Play/Pause RemoteAction<br/>  &#40;reflects live player state&#41;]
+
+    PiPWindow --> PiPReturn{User action}
+    PiPReturn -->|Tap window| Restore[Return to full player<br/>WebView restored, surface re-fit]
+    PiPReturn -->|Close window| Stop[Playback stops]
+
+    Handoff --> Foreground[On return to foreground:<br/>resume WebView video at position]
+
+

Key rules:

+
    +
  • Video-only gate. Auto-PiP is guarded by the native canEnterPip check +(local video surface actively rendering). Audio playback and menu/library +browsing background normally; remote/cast sessions render nothing locally, so +a PiP window would be an empty box and is refused. (UR-041, IR-026)
  • +
  • Only one background behaviour at a time. The background-audio toggle +(UR-040) disarms auto-PiP while it is on, so a video is either handed to the +audio service or floated in PiP, never both.
  • +
  • PiP controls track the player. The play/pause RemoteAction in the PiP +window reflects the live player state and updates on every playback-state +change, not only when the button is pressed. (DR-053)
  • +
  • Non-disruptive transition. ExoPlayer keeps rendering into the same +surface across enter/exit, so entering or leaving PiP never interrupts the +video; on exit the surface is re-fit to full-screen bounds. (DR-053)
  • +
+

PiP window (Android):

+
        ┌───────────────────┐
+        │                   │
+        │   ▶ video frame   │
+        │                   │
+        │       [⏸]         │  ← play/pause RemoteAction
+        └───────────────────┘
+   sized to the video's aspect ratio
+
+
+

10. Error States & Edge Cases

+

10.1 Network Loss During Streaming

+
flowchart TB
+    Streaming[Streaming Audio/Video] --> LoseNetwork[Network Connection Lost]
+
+    LoseNetwork --> CheckLocal{Local Copy<br/>Available?}
+
+    CheckLocal -->|Yes| SwitchLocal[Switch to Local Playback<br/>Seamlessly]
+    CheckLocal -->|No| ShowBuffer[Show Buffering Spinner]
+
+    ShowBuffer --> WaitReconnect[Wait for Reconnection<br/>30 second timeout]
+
+    WaitReconnect --> Reconnect{Reconnected?}
+
+    Reconnect -->|Yes| Resume[Resume Streaming]
+    Reconnect -->|No| ShowError[Show Error Toast:<br/>"Unable to stream.<br/>Check connection."]
+
+    ShowError --> OfferRetry[Offer Retry Button]
+    ShowError --> OfferDownload[Offer "Download for Offline"]
+
+

10.2 Server Unreachable

+
flowchart TB
+    Action[User Action Requires Server] --> TryConnect[Attempt Connection]
+
+    TryConnect --> Timeout{Connection<br/>Timeout?}
+
+    Timeout -->|Yes| ShowError[Show Error:<br/>"Server unreachable"]
+    Timeout -->|No| Success[Action Succeeds]
+
+    ShowError --> OfferOptions[Offer Options:<br/>- Retry<br/>- Switch to Offline Mode<br/>- Change Server]
+
+

10.3 Download Failed

+
flowchart TB
+    Downloading[Download in Progress] --> Failure{Failure Type?}
+
+    Failure -->|Network Error| Retry[Auto-retry<br/>with Backoff]
+    Failure -->|Disk Full| ShowDiskError[Show Error:<br/>"Not enough storage"]
+    Failure -->|Server Error| ShowServerError[Show Error:<br/>"Server error"]
+
+    Retry --> RetryCount{Retry Count<br/>< 3?}
+    RetryCount -->|Yes| Downloading
+    RetryCount -->|No| Failed[Mark as Failed]
+
+    ShowDiskError --> Failed
+    ShowServerError --> Failed
+
+    Failed --> UserAction[Show in Downloads:<br/>with Retry Button]
+
+
+

11. Platform-Specific UX Patterns

+

11.1 Android-Specific

+

Hardware Back Button:

+
    +
  • In Full Player: Return to previous screen, show MiniPlayer
  • +
  • In Video Player: Stop playback, exit fullscreen
  • +
  • In Album Detail: Return to library grid
  • +
  • At Library Home: Exit app (show confirmation)
  • +
+

System Volume Buttons:

+
    +
  • While playing audio: Adjust playback volume
  • +
  • While controlling remote session: Adjust remote session volume (shows session name in volume panel)
  • +
  • In menus: Adjust system volume (default behavior)
  • +
+

Share Integration:

+
    +
  • Long-press album/song → Share menu
  • +
  • Options: Share with other apps, Copy link
  • +
+

11.2 Linux Desktop-Specific

+

Keyboard Shortcuts:

+
    +
  • Space: Play/Pause
  • +
  • : Next track
  • +
  • : Previous track
  • +
  • /: Focus search
  • +
  • Ctrl+Q: Quit
  • +
+

Window Behavior:

+
    +
  • Minimize to tray (playback continues)
  • +
  • Close window (show confirmation if playing)
  • +
  • MPRIS integration for desktop media controls
  • +
+

Mouse Interactions:

+
    +
  • Hover over MiniPlayer: Show additional controls (volume, queue peek)
  • +
  • Right-click: Context menu (Add to playlist, Go to artist, Download)
  • +
+
+

12. UX Principles Summary

+

12.1 Core Principles

+
    +
  1. +

    Playback Persistence:

    +
      +
    • Audio playback never stops unless user explicitly stops it
    • +
    • MiniPlayer visible on all screens (except video/login)
    • +
    • Queue and position preserved across navigation
    • +
    +
  2. +
  3. +

    Non-Blocking UI:

    +
      +
    • Downloads happen in background
    • +
    • Sync operations never block user interaction
    • +
    • Optimistic updates (favorite, progress) with background sync
    • +
    +
  4. +
  5. +

    Offline-First:

    +
      +
    • Downloaded content works offline
    • +
    • Seamless switch between online/offline
    • +
    • Progress and preferences saved locally
    • +
    +
  6. +
  7. +

    Progressive Disclosure:

    +
      +
    • Simple defaults, advanced options hidden
    • +
    • Context menus for secondary actions
    • +
    • Settings organized by category
    • +
    +
  8. +
  9. +

    Responsive Design:

    +
      +
    • Mobile-first UI
    • +
    • Desktop enhancements (hover states, keyboard shortcuts)
    • +
    • Tablet: Grid layouts with more columns
    • +
    +
  10. +
+

12.2 Animation & Transitions

+
+ + + + + + +
TransitionDurationEasing
MiniPlayer slide up/down300msease-out
Screen navigation200msease-in-out
Video controls fade500msease-out
Download button state change150msease-in-out
Modal appear200msease-out
Toast notification250msease-in-out
+
+

12.3 Touch Targets (Mobile)

+
+ + + + + + +
ElementMinimum Size
Bottom nav buttons48x48 dp
List item (track, album)Full width x 56 dp
Player controls56x56 dp
MiniPlayerFull width x 64 dp
Download button40x40 dp
Favorite button40x40 dp
+
+
+

13. Future UX Enhancements

+

13.1 Planned Features

+
    +
  1. +

    Gesture Navigation:

    +
      +
    • Swipe up on MiniPlayer → Full player
    • +
    • Swipe down on full player → Back to previous screen
    • +
    • Swipe between tracks in full player
    • +
    +
  2. +
  3. +

    Queue Management UI (DR-020):

    +
      +
    • Drag to reorder
    • +
    • Swipe to remove
    • +
    • Add to queue vs. Play next
    • +
    +
  4. +
  5. +

    Sleep Timer (UR-026):

    +
      +
    • Accessible from full player menu
    • +
    • Presets: 15min, 30min, 1hr, End of track, End of album
    • +
    • Countdown visible in MiniPlayer
    • +
    +
  6. +
  7. +

    Home Screen (UR-034):

    +
      +
    • Hero banner carousel
    • +
    • Continue watching/listening
    • +
    • Recently added
    • +
    • Personalized recommendations
    • +
    +
  8. +
  9. +

    Cast/Remote Control Enhancements:

    +
      +
    • Picture-in-picture for remote sessions
    • +
    • Multi-room audio (play on multiple devices)
    • +
    • Handoff (transfer playback to phone from TV)
    • +
    +
  10. +
+

13.2 Accessibility Enhancements

+
    +
  • Screen reader optimization
  • +
  • High contrast mode
  • +
  • Larger text option
  • +
  • Voice control integration
  • +
  • Haptic feedback for controls
  • +
+
+

This UX flow documentation should be updated as new features are implemented and user feedback is incorporated.

+

Specs index

+

Feature specs for JellyTau. Start a new one from +SPEC-TEMPLATE.md and run it past +SPEC-REVIEW-CHECKLIST.md before accepting it.

+

What lives here

+

Only work that has not shipped. Once a spec is fully implemented its design +is folded into the architecture docs — which are the maintained description of +the build — and the spec file is deleted. Git history keeps the original, +including its rejected alternatives and acceptance criteria; the architecture +docs keep the reasoning that a future change still needs.

+

So: a file in this directory is a promise, not a description. If you want to +know how something works, read +docs/architecture/. If you want to know what is +planned, read here.

+

Status vocabulary

+
+ + + + +
StatusMeaning
ProposedWritten, not accepted. Nothing built.
AcceptedAgreed as the design; implementation not started or not finished.
Partially implementedSome parts shipped; the spec names what is left.
Design authorityNo code of its own — it records a decision later specs act on.
+
+

Next free requirement ids (always re-check +requirements.md before allocating): UR-079, +IR-033, DR-225. Three specs below suggested ids that have since been +taken by other work; each carries a ⚠️ note at the top.

+

Partially implemented

+
+ + + + +
SpecWhat landedWhat is left
frontend-domain-model.mdCatalog surface: MediaKind, from_jellyfin isolated, ticks → msprimaryImageTagimageId (~30 sites); player/session/reporting tick math; stream.type
libmpv2-migration.mdLICENSEThe libmpvlibmpv2 crate swap
read-through-media-cache.mdDR-126…128, DR-133…138 — cache entries are download rows; local playback of downloadsDR-121/122/124/125 — the player quality selector and the read-through capture
scoped-search-boundary-implementation.mdStage 1: SearchScope owned by Rust (DR-063…067)Stage 2: result-side grouping (GROUP_ITEM_TYPES still in searchScope.ts)
+
+

Not started

+
+ + + + + +
SpecBlocked on / note
backend-owned-stream-selection.mdRust owns direct-play-vs-transcode, transport and quality; players consume one StreamSelection. Phase 1 (delete the .m3u8 sniff) stands alone. Unblocks Linux native video.
build-provenance.mdbuild.rs is still bare. ⚠️ suggested id DR-093 is taken.
player-facade-enforcement.md~60 commands.player* sites still outside the facade; no lint rule. ⚠️ suggested id DR-095 is taken.
windows-native-audio-backend.mdBlocked on the libmpv2 swap. ⚠️ suggested id IR-030 is taken.
linux-native-video-spike.mdSpike run 2026-08-21: compositing works on Linux, X11 and Wayland. G1-G6 green bar the Tauri default_vbox() half of G1. Needs an implementation spec that answers adaptive bitrate.
+
+

Design authority

+
+ + + +
SpecRole
playback-backend-unification.mdWhy video cannot unify onto one native engine and audio can. The audio half has since shipped on Android; Windows has not.
scoped-search-boundary.mdThe boundary design the check:boundary rule came from. Stage 1 built.
scoped-search.mdSuperseded in part — its "frontend only, no Rust changes" decision is the leak the boundary spec reversed. UX still current.
+
+

Where the shipped specs went

+

Sixteen specs were folded into the architecture docs and deleted (2026-08-21). +Where to look for each:

+
+ + + + + + + + + + + + + + +
Shipped workNow documented in
Account menu & global chrome02-svelte-frontend.md — App Shell and Chrome
Library mosaic02-svelte-frontend.md — Library Mosaic
Series current-episode navigation02-svelte-frontend.md — Series and Episode Navigation
Downloads as an offline library02-svelte-frontend.md — Downloaded Browse
Favourites browsing01-rust-backend.md — Favorites System
Streaming bitrate cap01-rust-backend.md — Streaming quality ladder
Locally-indexed search03-data-flow.md — Search Flow; 01-rust-backend.md — Background workers
Offline downloaded-only filter06-downloads-and-offline.md — Offline Catalog Visibility
Audio equalizer · Android audio settings parity05-platform-backends.md — Audio settings on ExoPlayer
Android native video spike05-platform-backends.md — Native Video Compositing
Video background audio05-platform-backends.md — Background Audio Handoff
Traceability gate repairtraceability-ci.md
Boundary tripwire hardeningscripts/check-frontend-boundary.sh (its header is the spec)
Playback docs corrections · req-coverage script removalNothing to document — both were corrections that have been applied
+

Spec:

+ +

Status: Proposed +Requirements: +UX spec: +Supersedes / revises: +Destination on completion:

+

Summary

+ +

Motivation

+ +

Layer assignment

+ +
+ + +
Logic / responsibilityLayerWhy it belongs there
Rust
Frontend
+
+

Design

+ +

Out of scope

+ +

Acceptance criteria

+ +
    +
  • +bun run check and bun run test pass.
  • +
  • +cargo fmt clean, cargo clippy clean, bun run test:rust passes (if Rust changed).
  • +
  • +bun run check:boundary passes (no taxonomy leak into the frontend).
  • +
  • +New requirement-implementing code carries // TRACES: comments.
  • +
  • +bindings.ts regenerated if Rust types changed.
  • +
+

Testing

+ +

TRACES

+ +

Notes for the implementer

+ +

Spec review checklist

+

Run a spec past this before accepting it. It exists because JellyTau's +backend/frontend boundary is a stated rule with, historically, no gate — the +rule lived in the architecture docs, but nothing forced a spec author to check a +new design against it, and a "minimal-change" spec quietly leaked domain +taxonomy into the frontend (see scoped-search-boundary.md). +This checklist is the human gate. The CI check +(scripts/check-frontend-boundary.sh) is only a crude tripwire for one leak +signature — it does not replace this.

+

Copy the boxes into the review comment (or the PR) and tick them.

+

Boundary (the one that bites)

+
    +
  • +The spec has a filled-in "Layer assignment" table, and it assigns +logic, not files. A spec without this section is not ready to review.
  • +
  • +No domain vocabulary is placed in the frontend. In particular: Jellyfin +item-type sets that define a category (what "Music"/"TV"/"Movies" means), +query-shaping rules, business rules, reachability/sync policy. If the +frontend names a set of item types to define a category, that is a leak — +it belongs behind an opaque enum the backend expands.
  • +
  • +"The backend already accepts this parameter" was not used as the reason +to place the deciding logic in the frontend. Accepting a parameter ≠ owning +the decision of its value.
  • +
  • +The Scope: / effort framing is not optimizing for "least backend +change." "Frontend only, no Rust changes" is a description, never a goal. +The goal is correct layer placement; sometimes that is more Rust work.
  • +
  • +Ran the litmus test on each borderline responsibility: would it change if +Jellyfin's API changed? → Rust. Only if the UI were redesigned? → +frontend. Borderline defaults to Rust.
  • +
  • +Single-type presentation (itemType: "Movie", "this page shows albums") +is not over-corrected into the backend. The rule targets category +taxonomy, not every mention of a type. Don't invent a backend enum per +list page.
  • +
+

IPC contract

+
    +
  • +Anything crossing the boundary has its wire shape specified.
  • +
  • +camelCase rule accounted for: top-level params auto-convert; nested structs +get #[serde(rename_all = "camelCase")]; tagged unions match tags on both +sides; events are kebab-case. (CLAUDE.md §IPC, +04-type-sync-and-threading.md.)
  • +
  • +Any result that arrives twice (command return and a later event — +e.g. the search cache/server merge) has both payloads in the new shape.
  • +
  • +bindings.ts is regenerated from Rust, not hand-edited.
  • +
+

Requirements & traceability

+
    +
  • +Linked to existing URs, or new URs/DRs are allocated in +requirements.md.
  • +
  • +Requirement-implementing code will carry // TRACES: comments (CLAUDE.md).
  • +
  • +Traceability coverage stays ≥ 88% (the CI gate — a ratchet, so check +bun run traces:coverage rather than trusting this number).
  • +
+

Lifecycle

+
    +
  • +"Destination on completion" names a real architecture doc and section. +This spec file is deleted when it ships; something has to absorb the +design. If nothing fits, the layer assignment is probably unclear — go back +to that table.
  • +
  • +The spec separates the durable half (invariants, rejected alternatives, +the defect a decision exists to prevent) from the disposable half +(phases, migration steps, acceptance criteria). Only the first is folded in.
  • +
  • +Anything listed as out of scope but still worth doing is written where it +will be found after this file is gone — beside the code it concerns.
  • +
+

Conflicts & hygiene

+
    +
  • +If this spec revises/supersedes another, the older spec gets a banner +pointing here — no two specs silently contradicting.
  • +
  • +Acceptance criteria include the standard gates: bun run check, +bun run test, bun run check:boundary, and (if Rust changed) +cargo fmt/cargo clippy/bun run test:rust.
  • +
  • +Notes flag that a parallel Claude session may be active in the repo.
  • +
+
+

If any Boundary box can't be ticked, the spec is not ready — fix the layer +assignment first. Every other section can be negotiated; that one is the whole +reason this file exists.

+

Spec: Playback backend unification — findings and strategy

+

Status: Accepted (analysis; no code changes) +Requirements: IR-004, UR-031, UR-032, UR-033 — revises the "Platform Playback Backend Parity" issue in requirements.md +UX spec: n/a +Supersedes / revises: informed the Android native-video and audio-parity +work (both since shipped — see +05-platform-backends.md) and +windows-native-audio-backend.md, still open

+

Summary

+

This spec records the outcome of an investigation into unifying JellyTau's +playback backends (Linux/MPV, Android/ExoPlayer, Windows/webview) onto a single +engine with hardware acceleration everywhere. The conclusion is that video +cannot be unified onto a native engine, and should not be attempted. Audio +can be, and that is where the remaining specs direct effort.

+

No code changes follow from this spec directly. It exists so the decision is +written down with its evidence, and so a future session does not re-run the same +investigation.

+

Motivation

+

The requirements doc carries a "Platform Playback Backend Parity" issue noting +that audio settings work on Linux but not Android, and proposing eventual +convergence. The natural next question — "should we just run one engine +everywhere?" — needed answering before spending effort on per-backend patches.

+

The investigation also surfaced that several statements in requirements.md and in +code comments are factually wrong. Those corrections are part of the deliverable.

+

Findings

+

1. The current architecture is not what the docs describe

+
+ + + +
PlatformAudioVideo
LinuxMPV (native, audio-only)webview <video> + hls.js
AndroidExoPlayer (native)webview <video> + hls.js
Windowswebview <audio>webview <video> + hls.js
+
+

Two surprises:

+
    +
  • MPV never decodes video. mpv_backend.rs sets video = no and +audio-display = no at construction. Linux video has always been the webview. +Correspondingly, player_play_item deliberately does not load into MPV on +Linux (it calls set_current_item, which only updates the queue).
  • +
  • Android video is also the webview. createAdapter() in +src/lib/player/adapters/index.ts hardcodes const effectiveKind = "html5" +and does void backendKind, discarding the use_html5_element signal that +get_player_status computes in Rust. NativePlayerAdapter is dead code, and +ExoPlayer's SurfaceView path in JellyTauPlayer.kt is unreachable.
  • +
+

So video is already unified — on HTML5, everywhere, by accident of that +hardcode — and on the path without hardware decoding on Android.

+

2. Native video cannot be composited with a Tauri webview

+

This is the load-bearing finding. It is not an mpv limitation; it defeats +every candidate engine identically:

+
    +
  • mpv: tauri-plugin-libmpv's own platform table reads Linux ⚠️ +"Experimental. Window embedding is not working."
  • +
  • GStreamer (wry discussion #284, 2024): "Gstreamer was rendering above the +surface and covering all html elements."
  • +
  • libVLC (tauri discussion #6343, 2024): "I had to render the webview in a +child window though because vlc kept rendering on top of it."
  • +
+

Root cause, from Tauri maintainer amrbashir (tauri#9220, 2024-03-30):

+
+

"we are limited to using Webkit2GTK on Linux and that requires a GTK window. +While possible to add a GTK widget as a child X11 window inside raw X11 window, +this is however a bit hacky and it is not possible on Wayland at all."

+
+

WebKitGTK, WebView2, and Android WebView each draw into their own compositor +surface. A native video surface is either entirely above or entirely below the +webview; it cannot interleave with HTML. Every working example in the ecosystem +is the same hack — a separate child window position-synced to a +getBoundingClientRect() div — which breaks on resize, scroll, and any UI drawn +over the video. For JellyTau that means the controls, subtitle overlay, and +mini-player.

+

The most recent comment on tauri#6343 (2026-05-23) confirms it is still unsolved:

+
+

"I'm faking it and the window is not truly embedded, basically when the parent +moves or resizes I reset the position and size of the libmpv window to align it +with an HTML div."

+
+

The principle to carry forward: audio can unify on a native engine; video +cannot, because video needs a surface and the webview owns the surface.

+
+

Re-opened on Linux (2026-08-21). This finding's general form has since been +falsified on Android — native video now composites behind a transparent Tauri +WebView and ships on by default (see +05-platform-backends.md). +The evidence above is also entirely about foreign-window embedding; mpv's +render API, drawing into a GL context we own inside Tauri's own GTK tree, was +never tested. linux-native-video-spike.md tests +that one claim on Linux. Findings 3-6 below are untouched by it - in +particular finding 3, which is an independent disqualifier a green spike would +not clear.

+
+

3. mpv would regress streaming quality

+

mpv has no adaptive bitrate. It delegates HLS to FFmpeg's demuxer, which +selects one variant at open time and never adapts; mpv#3548 (2016) requested ABR +and it never landed. --hls-bitrate is a static picker defaulting to max.

+

The webview path already has real ABR via hls.js. Moving video to mpv would be a +downgrade on every platform — no graceful degradation on weak networks, and +quality changes requiring teardown and reload.

+
+

Premise in doubt (2026-08-21). "The webview path already has real ABR" +was not verified against the URLs this app actually builds. +get_video_stream_url requests a single rendition (one VideoBitrate, one +MaxHeight), the frontend has no level-handling code (hls.levels, +LEVEL_SWITCH, currentLevel appear nowhere), and this repo implements a +quality switch by re-opening the stream — all of which point to a +single-variant playlist, i.e. no ABR to lose. The decisive test is counting +#EXT-X-STREAM-INF lines in a real master.m3u8; it needs a live server and +has not been run. See +linux-native-video-spike.md.

+
+

4. Crossfade is architecturally blocked on mpv

+

mpv's audio chain is single-stream. FFmpeg's acrossfade is an N→A filter +requiring two input streams, so there is no second input to feed it. Real +crossfade needs two libmpv instances with manually ramped volumes. Upstream +maintainer response (mpv#4512, closed three minutes after opening):

+
+

"No. I also find crossfading stupid and complex, so the likeliness of that +happening is low."

+
+

GStreamer could do it via audiomixer. mpv cannot, at any reasonable cost.

+

5. Engine comparison summary

+
+ + + + + + + +
CriterionmpvGStreamerlibVLC
Webview compositing❌ Linux broken❌ same wall❌ same wall
Adaptive bitrate HLS❌ none✅ adaptivedemux2✅ adaptive module
Rust bindings⚠️ libmpv2 active; our pin is deadgstreamer-rs excellentvlc-rs abandoned (2018)
Windows cross-MSVC⚠️ prebuilt DLL❌ pkg-config vs cargo-xwin❌ no better
Android packaging✅ Maven AAR (used by Findroid)⚠️ Cerbero/NDK, painful✅ mature AAR
ASS/SSA subtitles✅ libass built in✅ libass✅ libass
Crossfade❌ impossibleaudiomixer⚠️ unclear
+
+

Every candidate fails the first row, which is the disqualifying one.

+

6. Two further options ruled out

+

Webview <audio>/<video> everywhere (i.e. delete the native audio backends +too) is dead on Android: navigator.mediaSession is deliberately compiled out +of Android WebView (Chromium CL 2613133003), so lockscreen/media-notification +control would be impossible. Chromium has also never shipped audioTracks. It +remains fine for Windows video, which is what we already do.

+

FFmpeg-direct / Rust-native (ffmpeg-next, rsmpeg, Symphonia) is not +close: the safe bindings do not expose hardware decode at all, ffmpeg-next is +self-declared maintenance-only, and Symphonia lacks HE-AAC and gapless AAC. This +is a multi-person-year path to reach parity with what we already have.

+

7. If libmpv is ever revisited on Android

+

Recorded so the next investigation starts from evidence rather than repeating the +search. The dev.jdtech.mpv:libmpv AAR — maintained by Findroid's author, i.e. +another Jellyfin Android client — was inspected directly:

+
    +
  • libmpv.so exports the full 54-function mpv_* C API with zero Java_ +symbols; JNI is a separate optional ~19 KB libplayer.so. So it is drivable +from Rust without a Java shim. (This is precisely what disqualifies libVLC, +whose Android video path hard-requires a Java AWindow jobject.)
  • +
  • ~23 MB/ABI, versus libVLC's ~46 MB/ABI.
  • +
  • 🔴 The published AAR is built --enable-gpl --enable-version3 — it is +GPLv3, not LGPL. Fine for us (see libmpv2-migration.md), +but it would be a hard constraint for anyone shipping closed source, and an +LGPL rebuild would be your own build to own.
  • +
  • Top unverified risk if anyone tries this: whether libmpv2-sys can +cross-compile for aarch64-linux-android against that prebuilt .so. No +working example of libmpv2 on Android was found.
  • +
+

None of this changes the verdict — the cost is the MediaSession/foreground-service +rewrite, not the bindings.

+

Decision

+
    +
  1. Do not unify video onto a native engine. Video stays in the webview with +hls.js on all platforms. This is not a compromise — it is the configuration +that falls out of the compositing constraint, and it is the only one that +gives us ABR for free.
  2. +
  3. Android native video is worth a bounded spike anyway — not for +unification, but because ExoPlayer's SurfaceView path already exists and +would restore hardware decode plus ASS/SSA subtitles. See +05-platform-backends.md.
  4. +
  5. Audio parity is the real gap and is achievable without touching any of the +above. See 05-platform-backends.md +and windows-native-audio-backend.md.
  6. +
  7. Migrate the dead libmpv pin regardless of any of this. See +libmpv2-migration.md.
  8. +
+

Corrections to existing docs

+

These are factual errors found during the investigation. Fixing them is in scope +for this spec.

+
+ + + + + +
LocationSaysActually
requirements.md UR-031 (line ~44)"Done (Linux only)"Not implemented on any platform.
requirements.md DR-034 (line ~196)"Done (Linux only)"Not implemented anywhere — mpv_backend.rs has a bare // TODO: Implement crossfade via MPV audio filters if needed. Architecturally blocked on mpv (finding 4).
requirements.md parity matrixCrossfade ✅ Linux / ❌ Android❌ / ❌
requirements.md parity matrix(no EQ row)EQ is also Linux-only — build_af_filter/eq_filter_entries exist only in mpv_backend.rs. Same root cause, same fix.
nativeAdapter.ts:11-14Native Android video "blocked upstream by tauri#10152"tauri#10152 is a stale feature request, dead since 2024-07-01. The capability shipped in tauri commit 27d01834 (2024-09-02). Not a blocker.
+
+

Layer assignment

+

No new logic. The one boundary observation worth recording:

+
+ +
Logic / responsibilityLayerWhy it belongs there
Which video backend a platform uses (use_html5_element)RustAlready correctly computed in get_player_status. The frontend currently discards it — that is the bug, not the design. Restoring it means the frontend consumes a backend decision rather than making its own.
+
+

Out of scope

+
    +
  • Any code change. This spec is analysis; the sibling specs carry the work.
  • +
  • iOS/macOS. Not current targets.
  • +
  • Replacing hls.js.
  • +
+

Acceptance criteria

+
    +
  • +requirements.md DR-034 status corrected; parity matrix updated (crossfade ❌/❌, EQ row added).
  • +
  • +Stale tauri#10152 comment in nativeAdapter.ts corrected.
  • +
  • +The four sibling specs exist and are linked from here.
  • +
+

Testing

+

n/a — documentation only.

+

TRACES

+

No new code. Requirement text changes only; DR-034's status line is the one +substantive edit.

+

Notes for the implementer

+
    +
  • The evidence above was gathered in July 2026. The compositing constraint has +been stable since 2021 (wry#284) and is maintainer-declared unfixable, so it is +unlikely to change soon — but if someone revisits this, tauri#6343 and wry#284 +are the threads to re-read first.
  • +
  • A parallel Claude session may be active in this repo — git diff before +"repairing" unexpected changes.
  • +
+

Spec: Linux native video — bounded compositing spike

+

Status: Run 2026-08-21 — compositing works; G5 carries an open crash. +The compositing claim it set out to test is falsified on Linux. See "Result". +This file stays open until the implementation spec exists; ABR is unresolved. +Requirements: none allocated. This spike produces a decision record, not +product code — same shape as +playback-backend-unification.md, which is +Accepted with no requirement ids of its own. Ids are allocated by the +implementation spec that follows a green result. +UX spec: n/a +Supersedes / revises: re-opens finding 2 of +playback-backend-unification.md on Linux only. +Its findings 3, 4, 5 and 6 stand unchallenged and are not in scope here.

+

Destination on completion: +05-platform-backends.md — a "Native +Video Compositing (Linux)" section alongside the existing Android one. The +durable half is the mechanism and the two traps below; the gates and phases are +disposable.

+

Summary

+

Test one falsifiable claim: a native video surface cannot be composited with a +Tauri webview on Linux. The claim is load-bearing — it is why Linux video goes +through an h264 HLS transcode into a WebKitGTK <video> element instead of +decoding directly in the mpv instance we already run. The spike renders one mpv +frame beneath the webview, on both X11 and Wayland, and stops. It ships no +product code and flips no defaults.

+

A green result does not authorise native video on Linux; it authorises +writing the spec that would.

+

Motivation

+

playback-backend-unification.md finding 2 +concluded that native video cannot be composited with a Tauri webview, on +evidence from tauri-plugin-libmpv's platform table, wry#284, tauri#6343, and a +Tauri maintainer's 2024 statement that a GTK widget as a child X11 window is +"a bit hacky and it is not possible on Wayland at all."

+

Two things have changed since that was written, and one thing was never tested.

+

1. The general claim has already been falsified on one platform — by us. +Android now renders ExoPlayer video on a TextureView at index 0 behind a +transparent Tauri WebView, with the Svelte controls drawn over it, on by +default. See +05-platform-backends.md. +That is exactly the composition finding 2 said was impossible, shipped. What +survives of the finding is a narrower, WebKitGTK-specific claim — which is worth +testing on its own terms rather than inheriting.

+

2. A Tauri app now ships Linux native mpv as an active platform. +MaxVideoPlayer (354 commits) embeds +libmpv via EGL + X11 child window / Wayland subsurface, with Linux and macOS +active and Windows only planned — the inverse of the plugin matrix finding 2 +sampled. Its existence does not prove our case works, but it does mean the +Wayland half of the maintainer quote is out of date.

+

3. The render API was never tested. Every source in finding 2 describes +foreign-window embedding: --wid, child windows, a second toplevel +position-synced to a getBoundingClientRect() div. That is a different mechanism +from mpv's render API, where we own the GL context and mpv draws into an FBO +we hand it (mpv_render_context_create / mpv_render_context_render, with an +upstream GTK example). +Tauri v2 exposes WebviewWindow::gtk_window() and default_vbox(), so the +target is a widget inside Tauri's own GTK tree — not a foreign window, not a +second toplevel, and therefore not the thing that was found broken.

+

The prize is direct play: no h264 transcode, hardware decode, libass subtitles, +and no server CPU burned on every Linux play.

+

The blocker a green spike does not clear

+

🔴 Read this before treating a green result as a green light.

+

Finding 3 of the unification spec stands: mpv has no adaptive bitrate. It +delegates HLS to FFmpeg's demuxer, which picks one variant at open and never +adapts. The webview path has real ABR via hls.js. Compositing is necessary for +native video on Linux; it is not sufficient.

+

There is a plausible answer, and this spike exists partly to make it testable: +ABR only matters on the transcode path. A direct-played file has no variant +ladder to adapt between — the adaptation the server offers is the transcode. +So "mpv when the stream is direct-play, HTML5 + hls.js when the server +transcodes" would sidestep finding 3 rather than fight it, and it maps onto a +decision Rust already makes when it builds the stream URL.

+

That is a hypothesis, not a conclusion. It is out of scope here. Record it in +the spike's decision note so the follow-up spec starts from it.

+

Layer assignment

+

The spike introduces no product logic. The table below is the assignment the +follow-up would inherit, written now so a green result cannot drift into +frontend decisions during implementation.

+
+ + + + + +
Logic / responsibilityLayerWhy it belongs there
Which backend renders video on this platform (use_html5_element, supports_native_video)RustAlready there — get_player_status in commands/player/mod.rs computes it from a cfg!. The spike would widen that cfg!, not relocate the decision. The frontend already consumes it via createAdapter.
Whether this stream is direct-play or transcoded, and therefore whether mpv or hls.js renders itRustDomain. It depends on Jellyfin's PlaybackInfo response, container/codec support, and the bitrate cap — all of which change when Jellyfin's API or our quality ladder changes. The frontend must never re-derive it from a URL shape.
Creating, sizing, and destroying the GL surface; the mpv render contextRustOwns the backend and the GTK window handle. There is no presentation decision in it.
Where controls, subtitles, and the mini-player sit above the video, and the letterbox/poster treatmentFrontendPure presentation; changes only if the UI is redesigned. Precisely the split the Android path already uses.
Reserving the video rectangle in layout and marking the shell transparentFrontendPresentation. nativeVideo.ts + the [data-native-video="active"] rule in app.css already do this for Android and are platform-agnostic.
+
+

Borderline row, stated with its tie-breaker: "is the surface currently +attached?" reads like view state, but the Android work found that a surface left +in the hierarchy outlives its player (DR-184). Attachment is backend lifecycle → +Rust, with the frontend told about it, not asked.

+

Design

+

A throwaway branch. No merge to master except the decision note.

+

What gets built

+

One #[cfg(target_os = "linux")] experiment behind a feature flag, in a scratch +binary or an ignored test — not in MpvBackend's constructor path:

+
    +
  1. From app.get_webview_window(...), take gtk_window() and default_vbox().
  2. +
  3. Reparent the webview into a gtk::Overlay: GLArea as the main child, the +webview as the overlay child.
  4. +
  5. Set the webview background to fully transparent (wry does this when +"transparent": true; verify it reaches webkit_web_view_set_background_color).
  6. +
  7. In the GLArea's render signal, drive +mpv_render_context_render with MPV_RENDER_PARAM_OPENGL_FBO pointing at the +FBO GTK bound for us.
  8. +
  9. Play one local file. Draw an opaque HTML element over the video area.
  10. +
+

video = no and audio-display = no are set in +mpv_backend.rs:135-141; +the spike overrides them on its own Mpv handle rather than editing that path.

+

Bindings

+

The current pin is libmpv = { git = "…/libmpv-rs", branch = "master" } — the +dead pin libmpv2-migration.md exists to replace. The +render API lives in libmpv2-sys (mpv_render_context_render); the safe wrapper +was only ever a PR against the old crate. Use libmpv2-sys raw FFI directly in +the spike. Do not block the spike on the migration, and do not let the spike +half-perform it — if the spike goes green the migration becomes a hard +prerequisite of the implementation, which is the ordering +windows-native-audio-backend.md already sits +in.

+

IPC

+

None. The spike crosses no boundary. If it goes green, the follow-up changes only +the value of the existing useHtml5Element / supportsNativeVideo fields — no +new wire shapes, no bindings.ts regeneration.

+

Gates

+

Each is pass/fail with a named failure. Stop at the first red and write it up — +a red result is a successful spike.

+
+ + + + + + +
#QuestionFails if
G1Can a custom GTK widget join Tauri's widget tree and survive the window's lifetime?default_vbox() is absent/unusable, or reparenting the webview breaks input or crashes.
G2Does the webview still paint, with a transparent backdrop, over that widget?The backdrop renders opaque black (wry#1540) or the webview stops repainting (tauri#12800). This is the highest-risk gate.
G3Does mpv render a frame into our FBO?The render context refuses GTK's context, or frames land in the wrong buffer.
G4Does HTML drawn over the video area actually appear over it?Video covers the controls — the exact failure wry#284 and tauri#6343 report. Without this, the whole thing is worthless: our controls, subtitles and mini-player all sit over the video.
G5Does it survive resize, fullscreen, and SPA navigation away and back?Flicker on resize, or a surface that outlives its route.
G6Does it hold on both X11 and Wayland?Either session backend fails. Wayland is the one the 2024 maintainer quote says is impossible — test it first, not last.
+
+

G6 is not a nice-to-have. A result that only holds on X11 is red for a project +shipping to current desktops.

+

Time box

+

If G1–G4 are not all green, stop and write the result up. The value of this spike +is a dated, method-specific answer — including "still no, and here is the +mechanism" — not a working player.

+

Result (2026-08-21)

+

Run on GNOME, kernel 7.1.8, libmpv 2.5.0 (mpv 0.41.0), GTK 3.24.52, WebKitGTK +2.52.6, wry 0.53.5 — the versions src-tauri/Cargo.lock resolves. Spike source: +a ~250-line standalone crate using wry + gtk + libmpv2-sys raw FFI, driving +mpv's render API with an update callback, frame-gated repaints and +report_swap.

+
+ + + + + + +
GateResultObserved mechanism
G1 widget in GTK tree🟡 partialGtkOverlay with GtkGLArea as main child and the wry webview as overlay child works, built directly. Tauri's own default_vbox() was not exercised — see below.
G2 webview paints transparently over it✅ greenwith_transparent(true) alone. No window-level transparency was used or needed.
G3 mpv renders into our FBO✅ greenvo=libmpv + mpv_render_context_create with MPV_RENDER_PARAM_OPENGL_FBO into the FBO GTK binds.
G4 HTML over video✅ greenOpaque panel and a translucent control bar both drew over moving video.
G5 resize / drag / fullscreen🟡 green on appearance, suspect underneathNo flicker, gap or misalignment, and smooth once frame pacing was correct (trap 3). But the only crash observed came from the only session where fullscreen was exercised — see "What is still open".
G6 X11 and Wayland✅ greenIdentical on both; GDK_BACKEND flipped between runs.
+
+

Finding 2 of playback-backend-unification.md +is false on Linux when tested by the render API rather than by foreign-window +embedding. Wayland — the half the 2024 maintainer quote called impossible — is +green.

+

Better than the gate asked for: the translucent bar composited alpha against +the video, not merely opaque-over. Scrims, gradient fades and subtitle backdrops +therefore work, which is most of how a player UI actually looks. mpv also painted +the letterbox bars black on its own — the Android equivalent was a shipped defect +(DR-194).

+

Three traps, each of which cost a debugging cycle

+

Carry these into the implementation; each produced a failure that looked like a +platform limitation and was not.

+
    +
  1. LC_NUMERIC must be reset after gtk::init(), not before. mpv refuses +to start under a non-C numeric locale. mpv_backend.rs already handles this, +but it has no GTK init in front of it; on this path gtk::init() applies the +user's locale afterwards and mpv_create returns null.
  2. +
  3. libepoxy exports GL entry points as data symbols. There is no glFoo +function to resolve — there is epoxy_glFoo, a variable holding a lazily +resolving function pointer. get_proc_address must return the pointer stored +at that symbol; returning the symbol's own address makes mpv jump into +non-executable data and take SIGSEGV/SEGV_ACCERR on the first GL call. The +epoxy crate does this correctly but is unusable — its gl_generator +dependency pulls a yanked xml-rs.
  4. +
  5. Frame pacing is not optional, and its symptom is misleading. Driving +queue_render() off the widget's frame clock on every tick, without calling +mpv_render_context_report_swap after each render, leaves mpv with nothing to +time against. Playback looks fine in a window and judders at fullscreen — +which reads as a compositing or GPU limit and is neither. The fix is to +register mpv_render_context_set_update_callback, redraw only when it says a +frame is ready, and report the swap afterwards. Fullscreen was smooth +immediately once both were in place.
  6. +
+

Hardware decode through the render API

+

Tested by asking mpv what it actually selected (hwdec-current), not what it was +asked for. All three ran 20s clean at a steady 30 fps.

+
+ + + +
hwdechwdec-currentNote
vaapinoDid not engage on this box — silently fell back to software. vainfo is not installed, so the libva driver for the Iris Xe iGPU is likely absent. No render-API error; this looks like a missing driver package, not a compositing limit.
autonvdec-copyHardware decode does work through the render API, on the discrete RTX 3050. Copy-back rather than zero-copy interop.
nonoSoftware. Clean baseline.
+
+

The load-bearing result is the middle row: hardware decode is compatible with +mpv's render API, so the direct-play prize is real and not traded away for +software decoding. Which decoder to prefer is an implementation question — on a +hybrid Intel+NVIDIA laptop auto reached for the discrete GPU in copy-back mode, +which is the least efficient hardware path. An implementation should evaluate +zero-copy VA-API on the iGPU (after confirming the driver is installed) before +accepting auto.

+

hwdec=auto-safe probes Vulkan video decode, which this GPU does not support. +It logs two Failed setup for format vulkan / no frame! pairs at start-up and +then settles on nvdec-copy — the same place auto lands. A first reading of +these logs mistook the start-up pair for a per-frame flood; it is not. Every +run, clean or crashed, contains exactly two. auto-safe is not implicated in +anything.

+

What is still open

+
    +
  • +

    The Tauri half of G1. The spike built its own GtkOverlay. The app must +instead reach WebviewWindow::gtk_window() / default_vbox() and reparent +Tauri's existing webview into an overlay. Low risk — the same widgets, one +extra reparent — but unproven, and it is the only place Tauri-specific +behaviour could still bite.

    +
  • +
  • +

    🔴 ABR — finding 3's premise is in doubt. Finding 3 says mpv would regress +streaming quality because "the webview path already has real ABR via hls.js". +Three pieces of evidence in this repo suggest that is not true of the URLs we +actually build:

    +
      +
    1. get_video_stream_url (repository/online.rs) requests a single +rendition — one VideoBitrate, one MaxStreamingBitrate, one MaxHeight. +Jellyfin transcodes to what it is asked for; it does not build a ladder.
    2. +
    3. The frontend contains no level-handling code at all — no hls.levels, +no LEVEL_SWITCH, no currentLevel. The abrEwma* options in +VideoPlayer.svelte are default tuning with nothing to act on. hls.js is +serving as an HLS demuxer (WebKitGTK cannot play HLS natively), not as an +adaptation engine.
    4. +
    5. That function's own comment describes a quality switch as rebuilding the +URL — "every path that re-opens a stream (quality switch, transcoded seek, +audio-track switch)". Manual selection by stream re-open is what you build +when there is no adaptation, and mpv can do the same thing.
    6. +
    +

    The decisive test has not been run and needs a live server plus an API key: +count #EXT-X-STREAM-INF lines in a real master.m3u8. One line means there +is no ABR to lose and this blocker disappears. More than one means finding 3 +stands and the work below applies.

    +

    If ABR does turn out to be real, it belongs in Rust, not in mpv, and there +are three designs in increasing cost: pick the variant at open; re-open at a +new bitrate on sustained throughput drops (this is the quality-switch path the +app already has, so it is nearly free); or run a local proxy serving mpv a +synthesized single-variant playlist while swapping renditions underneath. The +middle option is almost certainly sufficient.

    +

    Either way the direct-play path still does not exist — every video play +currently goes through the HLS transcode endpoint. Building it is the real +project; the compositing work proven above is the smaller half.

    +
  • +
  • +

    🔴 One unexplained SIGSEGV. A ~180s +run died in a decoder thread (libavcodec -> av_log -> libmpv's log handler +-> libc). No Tauri, wry, WebKitGTK, GTK or GL frame appears anywhere in the +stack, so the fault is on the mpv/ffmpeg side of the process rather than in the +compositing seam.

    +

    Three hypotheses were tested and none reproduced it:

    +
    + + + +
    HypothesisTestResult
    hwdec=auto-safe's Vulkan failures300s soak on auto-safeSurvived. Also based on a misreading — the failures are 2 per run at start-up, not per-frame. Dead.
    Fullscreen transitions recreating the GL context under mpv's render context240s soak, ~120 automated transitionsSurvived, no core dumped.
    Continuous resize thrashing the GL framebuffer240s soak, ~2000 resizesSurvived, no core dumped.
    +
    +

    The crash is therefore unexplained. It was observed exactly once, in the +only session a human interacted with, and did not recur in ~13 minutes of +targeted stress across the three most plausible causes. It is recorded here +rather than dismissed precisely because nothing explains it: an intermittent +fault that nobody can reproduce is worse to inherit than a deterministic one, +not better.

    +

    The underlying concern stands regardless of which test eventually reproduces +it. A SIGSEGV in an unrelated thread is characteristic of memory corruption, +and this spike never calls mpv_render_context_free and never tears down on +unrealize — it has no defence against the GL context being recreated beneath +the render context. That is DR-184 on Android restated: a surface outliving its +player. An implementation must bind the two lifetimes together whether or not +this particular crash is ever explained.

    +

    Therefore G5 is recorded green on appearance only, and this crash is the +single largest piece of unfinished business in the spike. Do not read the green +gates above as "safe to build on" until it is explained or a long soak clears +it.

    +
  • +
  • +

    Long-run stability, seeking, track switching, HDR, and multi-window were not +exercised at all.

    +
  • +
+

Out of scope

+
    +
  • Any change to the shipping Linux video path. experimentalNativeVideo in +adapters/index.ts is a suppressor, never a promoter; the spike must not +change that.
  • +
  • Adaptive bitrate. See "The blocker a green spike does not clear".
  • +
  • Windows and macOS — different mechanisms, and Windows is the easier case, not +the endangered one. See below.
  • +
  • Android. Already shipped; it is the precedent, not the target.
  • +
  • Crossfade, the libmpv2 migration, and the audio-parity work.
  • +
+

Why Windows is unaffected, and cheaper

+

Nothing here can regress Windows. use_html5_element is already a per-platform +cfg! in get_player_status — Android native, everything else HTML5 — so +divergent video paths are the existing design rather than something this +introduces. Windows keeps <video> + hls.js whatever this spike returns.

+

The mechanism does not port: default_vbox(), GtkOverlay and GtkGLArea are +GTK3/WebKitGTK concepts. But the question is already answered more favourably +there. Both mpv plugins list Windows as fully tested and Linux as broken, +because WebView2 honours a transparent background — the "native surface beneath a +transparent webview" approach that fails on WebKitGTK is the one that works on +Windows. That asymmetry is why +windows-native-audio-backend.md can call +Windows "the cleanest available win".

+

Windows' cost is packaging, not compositing: the build cross-compiles with MSVC + +cargo-xwin, so libmpv arrives as a bundled prebuilt DLL (the ⚠️ in finding 5's +comparison table). That cost is already committed for audio. Once the DLL ships +to replace WebviewAudioBackend, Windows video is largely a follow-on.

+

Sequencing, if native video is ever pursued on both:

+
    +
  1. libmpv2-migration.md — prerequisite for either.
  2. +
  3. windows-native-audio-backend.md — already +specced; lands the DLL and a real Windows backend.
  4. +
  5. Windows native video — cheap once 2 exists, and does not need this spike.
  6. +
  7. Linux native video — needs this spike, and runs independently of 1–3.
  8. +
+

Does this add a backend?

+

No — and the trajectory is convergence, not proliferation.

+

create_player_backend in lib.rs already selects between four +PlayerBackend impls by cfg!: MpvBackend (Linux), ExoPlayerBackend +(Android), WebviewAudioBackend (Windows and anything else), and NullBackend +as the graceful-init fallback. The HTML5 video path is not among them — it is a +frontend adapter reporting through player_report_*, not a PlayerBackend.

+

This spike adds none of these. MpvBackend already exists and already runs on +Linux; it merely sets video = no at construction. Giving it video widens an +existing backend rather than introducing an engine.

+

Following the sequence above, the count goes down: replacing +WebviewAudioBackend with mpv on Windows leaves two native engines — mpv +(Linux + Windows) and ExoPlayer (Android) — with native video riding on both.

+

Two is the floor, for a reason worth stating so nobody re-litigates it: Android +cannot drop ExoPlayer even if libmpv runs there, because the foreground service, +MediaSessionCompat and lockscreen control are built on it (finding 7 puts the +cost at that rewrite, not at the bindings). The HTML5 path does not go away +either — it is the transcode/ABR route and the fallback.

+

The trait surface converges too: ExoPlayerBackend already implements the +video-surface lifecycle for Android native compositing, so teaching MpvBackend +video follows a path already walked rather than opening a second one.

+
    +
  • Adopting tauri-plugin-libmpv or tauri-plugin-mpv as dependencies. Both +report Linux window embedding as not working and are small projects +(20 and ~70 commits); read them, do not depend on them.
  • +
+

Acceptance criteria

+

The deliverable is a decision, not a feature.

+
    +
  • +Each of G1–G6 recorded green/red with the observed mechanism, not just +the verdict.
  • +
  • +X11 and Wayland results reported separately, each naming the compositor +and WebKitGTK version tested.
  • +
  • +The direct-play/transcode ABR hypothesis recorded as open, with whatever +the spike learned about it.
  • +
  • +docs/specs/README.md updated — this spec listed, and its row moved or +deleted per the result.
  • +
  • +The Linux claim in the createAdapter doc comment +(adapters/index.ts:12-13) +corrected either way: if red, cite this spike instead of asserting it; if +green, it is wrong and must be rewritten.
  • +
  • +On red: finding 2 of +playback-backend-unification.md gains a +dated note naming the render-API method as also tested, and this file is +deleted. The verdict lives in the design-authority spec, not in a second +file that contradicts nothing.
  • +
  • +On green: an implementation spec exists, allocating ids from +UR-077 / IR-033 / DR-216 (re-check requirements.md — the README's +"next free DR-215" is stale, DR-215 landed), and it must answer ABR before +being accepted.
  • +
  • +No spike code on master. If any lands, the standard gates apply: +bun run check, bun run test, bun run check:boundary, cargo fmt, +cargo clippy, bun run test:rust.
  • +
+

Testing

+

No automated tests. A compositing result is a visual, per-session-backend +observation and cannot be asserted in cargo test or vitest — pretending +otherwise would produce a test that passes on a headless runner and tells us +nothing.

+

Capture a screenshot per gate. G4 specifically: an opaque HTML element over the +video area, photographed showing the video behind it.

+

If it goes green, the implementation spec inherits the testable surface the +Android work already established — nativeVideoLayers.test.ts asserts the +app.css selector list and the data-native-video contract, and both are +platform-agnostic.

+

TRACES

+

None. No requirement-implementing code is produced. The implementation spec that +follows a green result allocates from DR-216 and tags there.

+

Notes for the implementer

+
    +
  • Read playback-backend-unification.md +first, in full. This spike disputes exactly one of its six findings, on one +platform, by one method it did not try. Everything else in it is still binding +— particularly finding 3.
  • +
  • Test Wayland first. It is the gate most likely to be red and the one that +makes the rest moot.
  • +
  • The frontend plumbing already exists from the Android work: createAdapter, +NativePlayerAdapter, nativeVideo.ts, videoSurface.ts, and the +[data-native-video="active"] rule. A green spike is far cheaper to implement +than it would have been a year ago — which is itself part of why the question +is worth re-asking.
  • +
  • The Android record in +05-platform-backends.md +lists six shipped defects from getting this right on one platform. Expect the +Linux equivalents (the surface outliving its player, the shell painting over +it, unpainted letterbox bars) rather than rediscovering them.
  • +
  • A parallel Claude session may be active in this repo — git diff before +"repairing" unexpected changes.
  • +
+

Spec: Backend-owned stream selection

+

Status: Proposed +Requirements: UR-079 (new) → DR-219 … DR-224 (new); implements and extends +DR-121, currently allocated to +read-through-media-cache.md and not started. +Re-check requirements.md before allocating — the ids moved twice while this was +being written (DR max was 215, then 218). +UX spec: the quality selector in VideoPlayer.svelte already exists; this +changes what fills it, not how it looks. +Supersedes / revises: takes DR-121 out of +read-through-media-cache.md, which should keep +only its capture/eviction half. Unblocks +linux-native-video-spike.md.

+

Destination on completion: +01-rust-backend.md — extends the +"Streaming quality ladder" section; and +03-data-flow.md — playback initiation. The +durable half is the layer line and the StreamSelection contract; phases and +acceptance criteria are disposable.

+

Summary

+

Make Rust the single owner of which stream to play — direct play or transcode, +at what ceiling, over what transport — and hand every player backend a +self-describing selection instead of a bare URL. mpv, ExoPlayer and the HTML5 +<video>/hls.js path all become consumers of the same decision rather than three +places that re-derive it.

+

Nothing about how playback looks changes. What changes is that the frontend +stops inferring transport from a URL string, and that direct play becomes +possible at all.

+

Motivation

+

Four concrete problems, all the same shape.

+

1. The frontend sniffs transport out of the URL. +VideoPlayer.svelte:569:

+
const isHlsStream = currentStreamUrl.includes(".m3u8");
+
+

and again inline at line 2364. Rust built that URL and knows exactly what it +is; the frontend re-derives it by substring match. Change the endpoint, add a DASH +path, serve a progressive file, and this silently picks wrong. This is the +boundary rule in miniature — not item-type taxonomy, but the same error: a +domain fact reconstructed in the presentation layer because the wire shape did +not carry it.

+

2. There is no direct-play path. get_video_stream_url always builds an HLS +transcode URL (TranscodingProtocol=hls, VideoCodec=h264 first). Every video +play burns server CPU, even when the file would play untouched. This is the cost +the Linux native-video work exists to remove, and it cannot be removed without a +decision that does not currently exist anywhere in the codebase.

+

3. Quality is a process-wide global. streaming_quality() / +set_streaming_quality() in repository/online.rs read and write a static. +It is not per-session or per-item, so it cannot express "this 4K remux needs a +ceiling, that podcast does not", and two concurrent playbacks would share one +setting.

+

4. Rust cannot say what qualities this media source supports. The selector +is populated from a fixed enum rather than from what the source actually offers. +DR-121 already names this; it has not been built.

+

The prior question

+

Finding 3 of playback-backend-unification.md +holds that hls.js gives us real adaptive bitrate and mpv would lose it. Evidence +in this repo suggests there is no ABR today: a single rendition is requested, +no level-handling code exists anywhere in the frontend, and a quality switch is +implemented by re-opening the stream.

+

Run this before sizing the adaptation work. It needs a live server:

+
curl -s "https://<server>/Videos/<itemId>/master.m3u8?api_key=<key>&…" \
+  | grep -c EXT-X-STREAM-INF
+
+

1 → there is no adaptation to preserve, and the adaptation half of this spec +collapses to "pick well at open". >1 → finding 3 stands and DR-223 applies. +Everything else in this spec is worth doing either way — the ownership +problems above are independent of the answer.

+

Layer assignment

+
+ + + + + + + + +
Logic / responsibilityLayerWhy it belongs there
Direct play vs direct stream vs transcodeRustDepends on Jellyfin's PlaybackInfo, container/codec support and the device profile. Changes when Jellyfin's API or our profile changes → domain, by the litmus test.
Transport of the chosen stream (HLS / progressive / local file)RustRust constructs the URL; it is the only place that knows rather than infers. Today the frontend guesses from .m3u8.
Which qualities this media source can offerRustDerived from the source's own streams and the quality→transcode-parameter mapping that get_video_download_url already holds. DR-121.
The quality ceiling in force, per playback sessionRustDomain state that outlives any one view and must survive a backend swap or a mode transfer. Currently a process-wide static.
Deciding to re-negotiate mid-playback (if adaptation is needed)RustIt performs the HTTP and already derives reachability from real traffic via ConnectivityMonitor. Throughput estimation is the same pattern on the same data — a side-channel probe would repeat the mistake that principle exists to prevent.
Frame-level delivery within the selected stream, including a player's own ABRPlayerExoPlayer has genuine adaptive selection; if Rust hands it a multi-variant playlist it should use it. Rust chooses what to request, never how a player paces bytes. See "The line".
Rendering the selector, showing the current quality, ordering the listFrontendPure presentation over a backend-supplied list.
Poster, letterbox, controls, overlay z-orderFrontendUnchanged.
+
+

The line

+

Rust decides what stream. The player decides how to deliver it.

+

This matters most for ExoPlayer, which already does real adaptive track selection +over HLS. This spec must not reimplement that or fight it — if a multi-variant +playlist reaches ExoPlayer, ExoPlayer adapts and Rust stays out of the way. The +same restraint applies to any future backend that gains the capability. Rust only +steps in where the player has no such ability (mpv) and the server actually +offers a ladder.

+

Borderline row, with its tie-breaker: "which media source of a multi-source item" +looks like a user choice, and its presentation is. The default and the +constraint set are domain → Rust, per the borderline-defaults-to-Rust rule.

+

Design

+

The contract

+

One self-describing selection replaces the bare URL. Nested fields are +camelCase over the wire (#[serde(rename_all = "camelCase")]); the enums are +tagged so the frontend matches a tag instead of parsing a string.

+
#![allow(unused)]
+fn main() {
+#[derive(Serialize, Type)]
+#[serde(rename_all = "camelCase")]
+pub struct StreamSelection {
+    pub url: String,
+    pub transport: Transport,
+    pub playback_kind: PlaybackKind,
+    /// The negotiated rendition; None when direct-playing the source as-is.
+    pub rendition: Option<Rendition>,
+    /// What this media source can offer — fills the selector (DR-121).
+    pub available: Vec<QualityOption>,
+}
+
+#[derive(Serialize, Type)]
+#[serde(tag = "type", rename_all = "camelCase")]
+pub enum Transport { Hls, Progressive, LocalFile }
+
+#[derive(Serialize, Type)]
+#[serde(tag = "type", rename_all = "camelCase")]
+pub enum PlaybackKind { DirectPlay, DirectStream, Transcode }
+}
+

Transport is the field that deletes the .m3u8 sniff. The frontend picks +hls.js on Hls and the element's own loader otherwise — a tag match, not a +substring search.

+

Re-negotiation

+

Rust emits stream-selection-changed (kebab-case, per convention) carrying a new +StreamSelection plus the position to resume at. The existing +playerSetStreamQuality response already has exactly the right shape — a tagged +strategy that tells the caller who reloads, with the backend handling native +itself and handing HTML5 a URL for reloadSource +(index.ts:198). Extend that; do not +invent a second mechanism. It is the one piece of this that is already right.

+

Note the existing wart to preserve or fix deliberately, not accidentally: +tauri-specta keeps those response fields snake_case (new_url), and the facade +comments say so.

+

Phases

+
    +
  1. DR-219 StreamSelection + Transport; delete the .m3u8 sniff. No +behaviour change — pure ownership move, and independently shippable.
  2. +
  3. DR-220 Per-session quality ceiling replacing the online.rs static.
  4. +
  5. DR-221 available populated from the media source (DR-121's substance).
  6. +
  7. DR-222 Direct-play/direct-stream negotiation via PlaybackInfo. This is +the phase that unlocks native video and removes the transcode.
  8. +
  9. DR-223 Adaptation, only if the playlist check says a ladder exists. +Cheapest sufficient design: re-negotiate on sustained throughput drop, reusing +the phase-1 re-negotiation path. A local proxy synthesizing a single-variant +playlist is a last resort, not a starting point.
  10. +
  11. DR-224 ExoPlayer and mpv consume StreamSelection unchanged, proving the +contract is player-agnostic rather than HTML5-shaped.
  12. +
+

Phases 1–4 stand on their own merits with no dependency on the ladder question.

+

Out of scope

+
    +
  • Rendering, compositing, and the Linux native-video work itself. This spec +unblocks linux-native-video-spike.md; it does +not contain it.
  • +
  • Replacing hls.js. It stays as the HLS loader for the webview path.
  • +
  • Reimplementing or overriding ExoPlayer's own adaptive selection. See "The line".
  • +
  • The download/capture half of read-through-media-cache.md +(DR-122, DR-124, DR-125), which keeps its own spec.
  • +
  • Audio. The same argument applies, but video is where the transcode cost is.
  • +
+

Acceptance criteria

+
    +
  • +The .m3u8 substring check is gone from VideoPlayer.svelte (both sites) +and transport comes from the tagged enum.
  • +
  • +bun run check, bun run test, bun run format:check, bun run lint pass.
  • +
  • +cargo fmt clean, cargo clippy -D warnings clean, bun run test:rust passes.
  • +
  • +bun run check:boundary passes — and the reviewer confirms by reading that +no transport/kind decision was reconstructed in src/, since the tripwire +only catches item-type array literals.
  • +
  • +bindings.ts regenerated from Rust, not hand-edited.
  • +
  • +New code carries // TRACES: comments; bun run traces:validate passes and +coverage stays ≥ the CI ratchet.
  • +
  • +The EXT-X-STREAM-INF count is recorded in this spec before DR-223 is +started or dropped.
  • +
  • +DR-121 is removed from read-through-media-cache.md with a pointer here.
  • +
+

Testing

+
    +
  • Rust: PlaybackInfo fixtures → expected PlaybackKind, one per branch +(supported container direct-plays; unsupported codec transcodes; a ceiling +below the source bitrate transcodes even when the codec is fine).
  • +
  • Rust: Transport round-trips through serde with the tag the frontend matches.
  • +
  • Frontend: adapter selection driven by transport, including the case a URL +ending .m3u8 is served as Progressive — that test fails on today's code, +which is the point.
  • +
  • Extend tauriIntegration.test.ts for the new command params (camelCase rule).
  • +
  • No test asserts a URL substring.
  • +
+

TRACES

+
+ + + + + + +
PieceTag
StreamSelection / TransportUR-079 | DR-219
Per-session ceilingUR-074 | DR-220
available from media sourceUR-079 | DR-221, DR-121
Direct-play negotiationUR-079 | DR-222
Adaptation, if builtUR-079 | DR-223
ExoPlayer/mpv consumersUR-003, UR-004 | DR-224
+
+

Notes for the implementer

+
    +
  • Phase 1 is worth doing on its own, even if everything after it is dropped. +It removes a real leak and costs almost nothing.
  • +
  • Do not frame any phase as "no Rust changes required" — that framing is what +produced the leak scoped-search-boundary.md records.
  • +
  • ConnectivityMonitor is the precedent for DR-223: derive network facts from +real traffic, never from a side-channel poller.
  • +
  • A parallel Claude session may be active in this repo — git diff before +"repairing" unexpected changes. Requirement ids in particular moved twice +during the writing of this spec.
  • +
+

Spec: Enforce the unified player boundary

+

Status: Proposed — not started. The count below has not improved: ~60 +commands.player* call sites still live outside src/lib/player/, and no lint +rule enforces the boundary. This remains the one stated design principle with no +automated check. +Requirements: ⚠️ the suggested id DR-095 has since been allocated to seek +clamping — allocate a fresh id (DR-215 or later) on implementation. Relates to +UR-005 and the unified-player-boundary +principle in CLAUDE.md and 02-svelte-frontend.md +UX spec: n/a — refactor, no user-visible change. +Supersedes / revises: n/a

+

Summary

+

The stated principle is that UI controls playback only through +playerController (src/lib/player/index.ts), +never by calling commands.player* directly. There are 52 direct call sites +outside that facade. This spec routes the genuine playback-control calls +through the facade, narrows the principle's wording so it stops forbidding +things it never meant to forbid, and adds the lint rule that keeps it true — +because this rule is the one design principle in the audit with no automated +check at all, and it is also the one that drifted furthest.

+

Motivation

+

Direct commands.player* usage outside src/lib/player/, by file:

+ +

These are not equivalent violations, and treating them as one number is why +the rule has been easy to ignore. Three distinct groups:

+

(a) Genuine violations — playback control with a facade method that already +exists. playerStop ×6, playerPlayTracks ×4, playerSeek ×2, +playerPlayAlbumTrack ×2, playerNext, playerPrevious, playerSkipTo, +playerToggleShuffle, playerCycleRepeat, playerRemoveFromQueue, +playerMoveInQueue, playerAddTrackById, playerAddTracksByIds, +playerSetSubtitleTrack, playerPlayItem. The facade exposes stop(), +seek(), next(), previous(), skipTo(), toggleShuffle(), +cycleRepeat(), removeFromQueue(), moveInQueue(), addTrackById(), +addTracksByIds(), setSubtitleTrack(), playTracks(), playAlbumTrack(), +playItem() — every one of these has a facade equivalent that is simply not +being called. queue.ts is the starkest case: it imports commands directly +and re-implements ten methods the facade already provides.

+

(b) Playback control with no facade method. playerPlayQueue, +playerGetQueue, playerGetStatus, playerEnterBackgroundAudio, +playerExitBackgroundAudio, playerSetSleepTimer, playerCancelSleepTimer, +playerPlayNextEpisode, playerCancelAutoplayCountdown. In scope for the +principle, but currently impossible to comply with — the facade has no surface +for them. A rule that cannot be followed is not being broken so much as it is +unfinished.

+

(c) Not playback control. playerConfigureJellyfin ×3, +playerDisableJellyfin, playerGet/SetAudioSettings, +playerGet/SetVideoSettings, playerGetEqPresets, +playerGet/SetAutoplaySettings, playerGet/SetCacheConfig, +playerPreloadUpcoming. These are configuration and lifecycle calls that happen +to live under the player_ command prefix. The principle is about who is +authoritative for playback state — settings CRUD isn't that.

+

The audit's read: the rule as written is violated 52 times, which makes real +drift indistinguishable from acceptable usage, and that ambiguity is what lets +group (a) persist. Note also that the principle is well-honoured where it +matters most — the read side is clean, with UI reading state exclusively from +the facade's re-exported stores. The write side is what drifted.

+

Layer assignment

+

Frontend-internal refactor. No domain logic moves and nothing new crosses IPC — +the same Rust commands are called, through one module instead of many.

+
+ + + + + +
Logic / responsibilityLayerWhy it belongs there
Playback command dispatch (adapter routing: native vs HTML5)Frontend — src/lib/player/ onlyPresentation-layer plumbing, but must be centralised: the facade picks between the native backend and the HTML5 <video> adapter. A caller bypassing it silently skips that routing.
Playback authority (position, pause, rate, track changes)Rust / the playerUnchanged. The player is authoritative; UI is a consumer. This spec does not touch that direction.
Queue mutation commandsFrontend facade → RustRust owns queue state; the facade is the single call path to it.
Player settings CRUD (EQ, video, autoplay, cache)Frontend, outside the facadeConfiguration, not playback control — read/written on a settings page with no adapter routing. Explicitly carved out below.
Backend→frontend event handlingplayerEvents.tsAlready correct. It is the facade's own plumbing, not a bypassing consumer.
+
+

No Jellyfin taxonomy is involved, so no boundary-leak risk.

+

Design

+

1. Narrow the principle to what it actually means

+

Amend CLAUDE.md and 02-svelte-frontend.md:

+
+

Unified player boundary. UI controls playback — transport, queue +mutation, track selection, playback initiation — only through +playerController. Player configuration commands (player_*_settings, +player_configure_jellyfin, player_*_cache_config, player_preload_upcoming) +are ordinary IPC and may be called directly from settings surfaces.

+
+

This is a clarification, not a relaxation: it makes group (c) explicitly fine so +that a violation count means something. A rule with 52 nominal violations, most +of them acceptable, provides no signal.

+

2. Fill the facade gaps (group b)

+

Add to playerController, each a thin pass-through preserving current +behaviour:

+
playQueue, getQueue, getStatus,
+enterBackgroundAudio, exitBackgroundAudio,
+setSleepTimer, cancelSleepTimer,
+playNextEpisode, cancelAutoplayCountdown,
+
+

Do this first — group (a) cannot be fully migrated while callers still need +a direct import for a neighbouring call, and a file that imports commands for +one reason will keep using it for others.

+

3. Migrate group (a)

+

Mechanical: replace commands.playerX(...) with playerController.x(...). +Highest-value first: queue.ts (10 sites, all direct facade equivalents), then +player/[id]/+page.svelte, VideoPlayer.svelte, sleepTimer.ts, +playbackMode.ts, library/[id]/+page.svelte.

+

Two sites need care rather than substitution:

+
    +
  • playerEvents.ts (playerOnPlaybackEnded, playerStop in the error +path). This module is the facade's event plumbing — the counterpart to +index.ts, inside the boundary conceptually though not by directory. Treat +src/lib/services/playerEvents.ts as inside the boundary and exempt it, +rather than making it call the facade that calls back into it. Record this in +the lint config with the reason.
  • +
  • VideoPlayer.svelte — registers its own adapter via setActiveAdapter. +Its playerStop/playerPlayItem calls interact with adapter lifecycle, and +CLAUDE.md's gotcha ("no lifecycle calls after an await in onMount") applies. +Migrate this file last and on its own, so an Android seek regression is +bisectable to one commit.
  • +
+

4. Add the lint rule (the part that makes it stick)

+

The audit's finding was that principles with working checks held up and +principles without them drifted. This principle has no check. Add +scripts/check-player-boundary.sh, wired as bun run check:player-boundary and +into test-all.sh:

+
# Playback-control commands that MUST go through the facade.
+CONTROL='player(Play|Pause|Toggle|Stop|Seek|Next|Previous|SkipTo|ToggleShuffle|CycleRepeat|RemoveFromQueue|MoveInQueue|SetVolume|ToggleMute|SetSubtitleTrack|SeekVideo|SwitchAudioTrack|PlayTracks|PlayAlbumTrack|PlayItem|PlayQueue|AddTrackById|AddTracksByIds|GetQueue|GetStatus|EnterBackgroundAudio|ExitBackgroundAudio|SetSleepTimer|CancelSleepTimer|PlayNextEpisode|CancelAutoplayCountdown|OnPlaybackEnded)'
+
+# Inside the boundary: the facade and its event plumbing.
+EXEMPT='^src/lib/player/|^src/lib/services/playerEvents\.ts$'
+
+

Flag commands.$CONTROL in non-test src/ files outside EXEMPT. Config +commands are deliberately absent from the list, matching §1 — so the check +encodes the narrowed rule rather than the aspirational one.

+

An ESLint no-restricted-syntax rule would give better editor feedback, but the +project has no ESLint config; a shell check matches the existing +check:boundary precedent and adds no dependency.

+

Out of scope

+
    +
  • Changing playback behaviour — pure refactor.
  • +
  • The one-directional state principle (audited clean; UI reads from facade +stores only).
  • +
  • Moving settings CRUD behind the facade (§1 explicitly carves it out).
  • +
  • Introducing ESLint.
  • +
  • Refactoring VideoPlayer.svelte's 2079 lines generally, beyond its facade +call sites.
  • +
  • The commands.player* calls inside src/lib/player/ — that is the +facade doing its job.
  • +
+

Acceptance criteria

+
    +
  • +playerController exposes the group-(b) methods listed in §2.
  • +
  • +grep -rn "commands\.player" src/ --include='*.ts' --include='*.svelte' | grep -v '^src/lib/player/' | grep -v 'playerEvents\.ts' | grep -v '\.test\.' | grep -v bindings.ts +returns only configuration commands per §1 — no transport, queue, or +playback-initiation call.
  • +
  • +queue.ts no longer imports commands from bindings.
  • +
  • +bun run check:player-boundary exists, is wired into test-all.sh, and +passes.
  • +
  • +The check fails when a commands.playerStop() is added to a non-exempt +file — verify explicitly, as with the other gates in this batch.
  • +
  • +The check does not fail on commands.playerSetAudioSettings() in +settings/+page.svelte (the §1 carve-out works).
  • +
  • +CLAUDE.md and 02-svelte-frontend.md carry the narrowed wording, including +the config carve-out and the playerEvents.ts exemption with its reason.
  • +
  • +No behavioural change: audio and video playback, queue reorder, +shuffle/repeat, sleep timer, background audio, and autoplay all behave as +before on both Linux and Android.
  • +
  • +Android seek and onMount lifecycle still correct after the +VideoPlayer.svelte migration (the known-fragile path).
  • +
  • +bun run check and bun run test pass.
  • +
  • +bun run check:boundary passes.
  • +
  • +Changed code carries // TRACES: comments.
  • +
  • +No Rust change, so no bindings.ts regeneration.
  • +
+

Testing

+

Frontend (bun run test):

+
    +
  • Extend the existing facade tests to cover each new group-(b) method: it +forwards to the right command with the right arguments, and routes to the +active adapter where applicable.
  • +
  • queue.ts tests: assert calls land on playerController, not commands. Mock +the facade — a test that mocks commands would pass either way and guard +nothing.
  • +
  • Keep tauriIntegration.test.ts and the other IPC param-naming tests green; +they cover the camelCase rule this refactor must not disturb.
  • +
+

Manual (no automated coverage for these paths):

+
    +
  • Linux: play/pause/seek/next/prev, queue reorder, shuffle, repeat, sleep timer, +transcoded video (HLS), background audio enter/exit.
  • +
  • Android: the same, plus lockscreen/MediaSession controls, and seek after +entering the player — the specific regression CLAUDE.md warns about.
  • +
+

Because this is a pure refactor, the strongest signal is that no test changes +expectation. A test needing its assertions rewritten means behaviour moved — +investigate rather than update it.

+

TRACES

+

Allocate in requirements.md:

+
    +
  • DR-095 — "UI playback control is routed exclusively through the +playerController facade (src/lib/player/), with playerEvents.ts inside +the boundary as its event plumbing and player configuration commands +explicitly outside it; enforced by scripts/check-player-boundary.sh." +Category: Player. Traces to UR-005. Status: Done on merge.
  • +
+
// src/lib/player/index.ts
+// TRACES: UR-005 | DR-095
+
+

New facade tests take @req-test: UT-089 onward (next free UT is UT-089; +coordinate if landing alongside the sibling specs, which draw from the same +pool).

+

Notes for the implementer

+
    +
  • A parallel Claude session may be active in this repo — git diff before +"repairing" unexpected changes (CLAUDE.md §Gotchas).
  • +
  • Order matters: §2 (fill gaps) → §3 (migrate, VideoPlayer.svelte last and +alone) → §4 (add the check). Adding the check first turns master red.
  • +
  • 🔴 VideoPlayer.svelte: no lifecycle calls after an await in onMount — +it flips to HTML5 mode and breaks Android seek. Do not let a mechanical +substitution introduce an await before a lifecycle call.
  • +
  • The facade's requireHandle() may throw where a raw commands call did not. +Check each migrated call site's error handling rather than assuming the +try/catch still covers the same cases.
  • +
  • playbackMode.ts interacts with remote-mode routing (play_on_session vs +local MPV). Verify remote casting still works after migrating its +playerPlayTracks call.
  • +
  • This spec is deliberately the lowest priority of the audit batch: it is the +largest diff and the only one carrying real regression risk, while the +traceability gate is a few lines and restores a dead safety net.
  • +
+

Spec: Windows native audio backend

+

Status: Proposed — not started. Windows still runs on +WebviewAudioBackend. Blocked on libmpv2-migration.md, +whose crate swap has not landed either. +Requirements: UR-003, UR-027, UR-032, UR-033 → DR-030, DR-035, DR-036; +⚠️ the suggested id IR-030 has since been allocated to the scheduled catalog +crawl — allocate a fresh id (IR-033 or later) on implementation +UX spec: n/a — Settings › Audio already renders the controls +Supersedes / revises: acts on the "audio can unify, video cannot" conclusion in playback-backend-unification.md

+

Summary

+

Give Windows a real native audio backend instead of the current webview +<audio> shim. Windows is the only platform where audio playback has no decoder +of its own: WebviewAudioBackend hands a URL to a frontend <audio> element and +relays transport commands. It cannot set volume, cannot apply any audio setting, +and reports state only via DOM events.

+

Audio needs no rendering surface, so none of the webview-compositing problems +that block unified video apply here. This is the cleanest available win.

+

Motivation

+

WebviewAudioBackend was a deliberate stopgap ("audio-only playback for +platforms without a native audio backend"), and it works — but it has a hard +functional gap. From webview_audio_backend.rs:

+
#![allow(unused)]
+fn main() {
+fn set_volume(&mut self, volume: f32) -> Result<(), PlayerError> {
+    // ...stores locally only; there is no ControlCommand action for volume
+}
+}
+

So volume changes never reach the element; the frontend has to observe the player +store and apply volume itself. set_audio_settings likewise stores values that +nothing consumes — EQ, normalization, and gapless are all inert on Windows.

+

Meanwhile the backend-unification investigation established that a native audio +engine is unproblematic on Windows specifically: tauri-plugin-libmpv lists +Windows as its fully tested platform (in contrast to Linux, where embedding +is broken — but that is a video surface problem, which audio does not have).

+

Layer assignment

+
+ + + + + +
Logic / responsibilityLayerWhy it belongs there
Decoding and playing the audio streamRustPlayback is domain logic; every other platform already decodes in Rust or a native player. The webview shim is the anomaly.
Applying AudioSettings (EQ/normalize/gapless)RustSame AudioSettings contract as MPV/ExoPlayer; band layout and presets stay canonical in settings.rs.
Position/state reportingRustRestores the project's core principle — the player is the authoritative source of state. Today Windows inverts this: the DOM element is authoritative and Rust mirrors it.
VolumeRustCurrently broken precisely because it is split across the boundary.
Rendering the player UIFrontendUnchanged.
+
+

The strongest argument for this change is the third row. CLAUDE.md states +playback state is one-directional with the player authoritative; on Windows that +is currently false, and the player_report_* round-trip exists to paper over it.

+

Design

+

Engine choice

+

Two viable options; libmpv is recommended for consistency with the Linux +audio backend.

+
+ + + + + +
libmpvGStreamer
Windows statustauri-plugin-libmpv reports fully tested✅ works, but…
Rust bindingslibmpv2 6.0.0, activegstreamer-rs 0.25.x, excellent
Cross-MSVC from Linux⚠️ needs prebuilt DLL + import libgstreamer-sys uses pkg-config, fights cargo-xwin
Code reuseMpvBackend logic is directly reusable❌ a second engine to learn
Crossfade capable❌ single-stream chainaudiomixer
+
+

libmpv wins on reuse: MpvBackend's set_audio_settings — the af lavfi graph +built by build_af_filter, eq_filter_entries, normalize_filter_entry — is +platform-independent and would apply unchanged.

+

The one reason to prefer GStreamer is crossfade (UR-031), which mpv structurally +cannot do. If crossfade becomes a priority, revisit; it would then argue for +GStreamer on both Linux and Windows, which is a much larger change.

+

Structure

+

Rename the cfg gate so MpvBackend is no longer Linux-only:

+
#![allow(unused)]
+fn main() {
+// src-tauri/src/player/mod.rs
+#[cfg(any(target_os = "linux", target_os = "windows"))]
+pub mod mpv_backend;
+}
+

MpvBackend::new needs one platform-specific branch: detect_audio_system() +currently probes pactl/pw-cli//proc/asound/cards to pick an ao. On +Windows the equivalent is wasapi (mpv's default), so the detection is a +#[cfg] returning "wasapi" — no probing needed.

+

Everything else — the event loop, the 250ms position thread, the seek-suppression +window, the af filter graph — is unchanged.

+

WebviewAudioBackend stays for other targets (macOS and anything else hitting +the not(any(...)) arm) and as the fallback if libmpv fails to initialize. The +existing emit_backend_init_failed path already handles that gracefully.

+

Build

+

libmpv2-sys is well-suited to cross-compilation: no pkg-config, vendored +headers, pregenerated bindings (no libclang). It emits cargo:rustc-link-lib=mpv +unconditionally, so the build must supply a linkable import library for +x86_64-pc-windows-msvc.

+

Keep the build_libmpv feature off — its Unix path shells out to mpv-build +and explicitly rejects cross-compilation.

+

🔴 Per CLAUDE.md, the prebuilt libmpv must be added to the builder image +(Dockerfile.builder → rebuild + push via scripts/build-builder-image.sh), not +installed at CI job time. libmpv-2.dll must also be bundled into the NSIS +installer via tauri.conf.json's resources.

+

Verified build mechanics

+

The cross-compile path was tested hands-on from Linux (July 2026), not inferred:

+
    +
  • Neither shinchiro nor zhongfly ships an mpv.def or MSVC mpv.lib — only a +MinGW libmpv.dll.a. (Several online sources claim otherwise; they are wrong.)
  • +
  • An MSVC-style import lib can be generated locally with LLVM tools only: +llvm-readobj --coff-exports libmpv-2.dll → synthesize mpv.def → +llvm-dlltool -m i386:x86-64 -d mpv.def -l mpv.lib. llvm-lib /def: produces a +byte-identical result.
  • +
  • A real lld-link link against that import lib succeeds, and the resulting +import table resolves mpv_client_api_version from libmpv-2.dll. lld-link +is the linker cargo-xwin uses, so this is the load-bearing step.
  • +
  • Linking directly against the shipped MinGW libmpv.dll.a also succeeds, so +def-generation may be skippable — but that relies on lld's GNU-archive +tolerance rather than a documented contract. Keep llvm-dlltool as the +fallback.
  • +
  • MinGW origin is not an ABI problem: libmpv exports a pure C ABI, and the x86-64 +Windows calling convention is platform-defined. The upstream note that MSVC +cannot build mpv is frequently misread as "MSVC cannot link libmpv" — that +is not what it says.
  • +
  • 🔴 Never free/realloc across the DLL boundary — use mpv_free.
  • +
+

Build wiring is ordinary: cargo:rustc-link-lib=dylib=mpv plus +cargo:rustc-link-search. Nothing about libmpv conflicts with cargo-xwin.

+

Size and shipping

+

Measured uncompressed: 93 MiB (zhongfly mpv-dev-lgpl-x86_64) vs 112 MiB +(shinchiro, full GPL build); ~26–30 MB compressed in the .7z.

+

Ship the zhongfly LGPL build — smaller, and there is no reason to pull the +GPL variant in for an audio-only use.

+

Import-table inspection confirms no companion DLLs are needed: every +dependency is a system DLL (KERNEL32, USER32, d2d1, DWrite, OPENGL32, +vulkan-1, UCRT api-ms-win-*). One file to bundle.

+

93 MiB is still substantial against a Tauri app's usual few MB. Since we use mpv +audio-only, investigate whether a pruned build (no video decoders, no libplacebo) +is worth producing for the builder image — but treat that as an optimization, +not a blocker.

+

Out of scope

+
    +
  • Windows video. Stays in WebView2 + hls.js — it works and has ABR.
  • +
  • Crossfade (UR-031/DR-034) — not implemented anywhere; needs its own spec.
  • +
  • Replacing WebviewAudioBackend for macOS.
  • +
  • MPRIS/SMTC media-key integration — worth a follow-up, not this spec.
  • +
+

Acceptance criteria

+
    +
  • +Windows build produces a MpvBackend-backed player; backend-init-failed is emitted (not a crash) if libmpv is unavailable.
  • +
  • +Volume control works from the UI — the current hard gap.
  • +
  • +EQ, normalization, and gapless audibly take effect on Windows.
  • +
  • +Position/state originate in Rust; the <audio> element is no longer in the audio path.
  • +
  • +Seek, next/previous, and queue advance work; sleep timer stops playback.
  • +
  • +libmpv-2.dll ships in the NSIS installer and the app runs on a clean Windows VM with no mpv installed.
  • +
  • +Builder image carries the Windows libmpv artefacts; no toolchain install added to any CI step.
  • +
  • +bun run check, bun run test, bun run check:boundary pass.
  • +
  • +cargo fmt clean, cargo clippy clean, bun run test:rust passes.
  • +
  • +New requirement-implementing code carries // TRACES: comments.
  • +
+

Testing

+

Rust: the existing mpv_backend_test.rs and the build_af_filter / +normalize_filter_entry / eq_filter_entries unit tests already cover the +filter-graph logic and are platform-independent — they should pass unchanged +under a Windows cargo check/test. Add a test asserting detect_audio_system() +returns wasapi under cfg(windows).

+

Manual, on Windows: volume, EQ preset change, normalization toggle, gapless +between two tracks, seek, queue advance, sleep timer. Then the packaging test — +install the NSIS output on a clean VM and confirm it launches and plays.

+

Per CLAUDE.md, the volume gap is a bug fix: write a failing test for +"set_volume reaches the backend" before implementing.

+

TRACES

+
    +
  • Windows MpvBackend construction in create_player_backend// TRACES: UR-003 | IR-030
  • +
  • detect_audio_system Windows branch → IR-030
  • +
  • Existing set_audio_settings gains Windows coverage → UR-027, UR-032, UR-033 | DR-030, DR-035, DR-036
  • +
  • Allocate IR-030 in requirements.md ("libmpv integration for Windows audio playback").
  • +
+

Notes for the implementer

+
    +
  • Do this after libmpv2-migration.md — porting the +current dead libmpv git pin to a second platform would double the migration +work.
  • +
  • libmpv2 has broken its API in every major release (4.0 removed command +helpers, 5.0 removed mpv_node, 6.0 changed RenderContext ownership). Pin an +exact version.
  • +
  • Only the render-feature parts of libmpv2 concern video; audio-only use does +not need it, and disabling the default render feature may shrink the build.
  • +
  • A parallel Claude session may be active — git diff first.
  • +
+

Spec: Migrate to libmpv2 and declare the project licence

+

Status: Partially implemented — the LICENSE file has landed (part 2). The +libmpvlibmpv2 swap (part 1) is not done: src-tauri/Cargo.toml still +pins the abandoned crate to a git branch. +Requirements: UR-003 → IR-003 (revises the MPV integration); no new user-facing behaviour +UX spec: n/a +Supersedes / revises: dependency and licensing housekeeping identified in playback-backend-unification.md

+

Summary

+

Two related pieces of housekeeping that block or complicate later work:

+
    +
  1. Replace the abandoned libmpv crate (pinned to a git branch) with the +maintained libmpv2.
  2. +
  3. Add a LICENSE file. The project has none, which leaves its legal status +undefined while it links GPL-licensed libmpv.
  4. +
+

Neither changes user-visible behaviour. Both are prerequisites for +windows-native-audio-backend.md.

+

Motivation

+

The dependency is dead

+
# src-tauri/Cargo.toml
+libmpv = { git = "https://github.com/ParadoxSpiral/libmpv-rs.git", branch = "master" }
+
+
    +
  • crates.io libmpv 2.0.1 was published 2020-09-29.
  • +
  • The upstream repo's last commit was 2023-01-08; nothing since was released.
  • +
  • We pin a git branch, so builds are not reproducible — the same lockfile-less +checkout can resolve differently over time, and CI has no protection if the +branch moves or the repo disappears.
  • +
+

libmpv2 (kohsine/libmpv2-rs) is a maintained fork of exactly this crate: +6.0.0 released 2026-05-12, ~23.5k recent downloads against the original's +~1.1k, releases roughly quarterly since 2024.

+

The project has no licence

+

There is no LICENSE/COPYING file and src-tauri/Cargo.toml has no license +field. The project is open source and will never be commercial, so this is purely +an omission — but it matters because we link libmpv, and "no licence" defaults to +all rights reserved, which is incompatible with distributing a GPL-derived +work.

+

Design

+

Part 1 — licence

+

Use GPLv3. This is forced, not chosen:

+
    +
  • mpv's default build is GPLv2-or-later, so the combined work must be +GPL-compatible.
  • +
  • Apache-2.0 is GPLv2-incompatible (patent-termination and indemnification +clauses) but GPLv3-compatible.
  • +
  • A scan of the dependency tree found Apache-2.0-only crates with no +alternative arm — most importantly tao (Tauri's own windowing crate), +plus sync_wrapper, gethostname, and ring (Apache-2.0 AND ISC).
  • +
+

tao is unavoidable in a Tauri app, so GPLv2 is unavailable. Exercising mpv's +"or later" option puts the combination at GPLv3.

+

Actions:

+
    +
  • Add LICENSE containing the GPLv3 text.
  • +
  • Add license = "GPL-3.0-or-later" to src-tauri/Cargo.toml and license to +package.json.
  • +
  • Note in the README that the binary links libmpv (GPLv2+) and FFmpeg.
  • +
+

Because the project is open source, we use mpv's default GPL build — no +-Dgpl=false, no LGPL FFmpeg build, and none of the LGPL §6 relinking analysis +that a proprietary app would need. We keep VAAPI/VDPAU/X11 and every GPL FFmpeg +filter.

+

🔴 Never build FFmpeg with --enable-nonfree — that produces a binary that is +unredistributable under any licence, open source or not.

+

Part 2 — libmpv → libmpv2

+
# Linux (and later Windows, per the Windows audio spec)
+libmpv2 = "=6.0.0"
+
+

Pin exactly: libmpv2 has broken its API in every major release.

+

Breaking changes to expect, from the changelog:

+
+ + + +
VersionChangeImpact here
4.0.0Removed command helper methods — call mpv.command(...) directlyLow; we already use command/set_property
5.0.0Removed mpv_node support entirely (properties return strings; parse JSON yourself); EventContext folded into Mpv; ProtocolContextProtocolMediumstart_event_loop uses create_event_context(); check whether that call still exists
6.0.0RenderContext::new()Mpv::create_render_context(); 'static bound on OpenGLInitParams; render context now borrows Mpv (fixes a use-after-free)None — we do not use the render API
+
+

The last row matters: we run mpv audio-only (video = no), so the entire render +surface is irrelevant to us. Consider disabling the default render feature to +reduce build surface.

+

The main porting work is the event loop in mpv_backend.rswait_event, +disable_deprecated_events, and the FileLoaded / PlaybackRestart / +PropertyChange / EndFile handling, given 5.0.0 folded EventContext into +Mpv.

+

Everything else — set_property calls, the af filter graph, the 250ms position +thread, the seek-suppression window — should port unchanged.

+

Layer assignment

+

No logic moves. This is a dependency swap plus a licence file; the +PlayerBackend trait boundary is untouched.

+
+ +
Logic / responsibilityLayerWhy it belongs there
mpv event → PlayerStatusEvent mappingRust (unchanged)Already correct; only the binding API beneath it changes.
+
+

Out of scope

+
    +
  • Any behaviour change. If playback behaves differently after this, that is a bug.
  • +
  • Windows support — separate spec, but this must land first.
  • +
  • Adopting the render API. We are audio-only on mpv.
  • +
  • Re-licensing decisions beyond adding the file the project already implies.
  • +
+

Acceptance criteria

+
    +
  • +LICENSE (GPLv3) present; license field set in Cargo.toml and package.json.
  • +
  • +A full dependency-licence audit has been run (cargo install cargo-license && cargo license) and confirms no GPLv3-incompatible dependency. (The scan behind this spec resolved 441 of 575 crates from the local registry cache; the remaining 134 are unverified.)
  • +
  • +libmpv git dependency removed; libmpv2 pinned to an exact version.
  • +
  • +Linux audio playback works identically: play/pause/seek/volume, queue advance, gapless, EQ, normalization, sleep timer.
  • +
  • +Position updates still arrive at 250ms; the 150ms post-seek suppression still prevents the jump-to-zero glitch.
  • +
  • +EndFile still emits PlaybackEnded only for EOF (not STOP/QUIT/ERROR) — autoplay depends on this.
  • +
  • +Builder image updated if the libmpv dev package requirement changed; no toolchain install added to any CI step.
  • +
  • +bun run check, bun run test, bun run check:boundary pass.
  • +
  • +cargo fmt clean, cargo clippy clean, bun run test:rust passes.
  • +
+

Testing

+

The existing mpv_backend_test.rs plus the build_af_filter, +eq_filter_entries, and normalize_filter_entry tests are the regression net — +they must pass unchanged, since none of them touch the binding API.

+

The event loop has no unit tests and is where the risk concentrates. Verify +manually on Linux:

+
    +
  1. Play → pause → play; confirm position does not flash to 0:00 (the known +playing-event regression).
  2. +
  3. Seek mid-track; confirm no jump-to-zero within 150ms.
  4. +
  5. Let a track end naturally; confirm autoplay advances (exercises EndFile EOF).
  6. +
  7. Press stop; confirm autoplay does not advance.
  8. +
  9. Sleep-timer expiry; confirm it stops without triggering autoplay.
  10. +
+

Cases 3–5 are the ones most likely to break silently, and each corresponds to a +bug already fixed once in this codebase.

+

TRACES

+
    +
  • MpvBackend construction / event loop → existing // TRACES: UR-003 | IR-003, unchanged
  • +
  • No new requirement IDs; this is a dependency migration.
  • +
+

Notes for the implementer

+
    +
  • Do this before the Windows audio backend.
  • +
  • Read the 4.0/5.0/6.0 changelogs before writing code — the crate has broken API +in every major release, most recently two months before this spec.
  • +
  • The crates.io repository field for libmpv2 points at kohsine/libmpv-rs, +but the repo was renamed to libmpv2-rs; the old raw URLs 404.
  • +
  • libmpv2-sys ships pregenerated bindings and vendored headers, so no libclang +is needed at build time — relevant to keeping the builder image thin.
  • +
  • A parallel Claude session may be active — git diff before "repairing" +unexpected changes.
  • +
+

Spec: Two-path media — selectable playback bitrate, independent whole-file download

+

Status: Partially implemented. Landed: the cache/download unification +(DR-126, DR-127 — a cache entry is a downloads row with a shorter life, and +eviction only reclaims the temporary tier), local playback of downloaded media +(DR-128), and the one-path/one-row invariants that followed (DR-133 … DR-138). +DR-123 is in progress. Still open: the player quality selector and the +read-through capture itself — DR-121, DR-122, DR-124, DR-125. The separate +settings-level bitrate cap (DR-162, shipped — +01-rust-backend.md) +covers a settings-level +ceiling (DR-162), which serves part of UR-070 but is not the per-playback +selector specified here. +Requirements: UR-070, UR-071 → DR-121, DR-122, DR-123, DR-124, DR-125; IR-032 +UX spec: player quality selector — needs a ux-flows.md section before build +Related: the locally-indexed search and downloaded-browse work, both +shipped — see +03-data-flow.md and +06-downloads-and-offline.md

+

Summary

+

Two things that are today tangled become explicitly separate:

+
    +
  • The playback path streams at a bitrate the viewer can change from the +player. It is ephemeral and its rendition is volatile.
  • +
  • The download path fetches the whole file at one canonical quality, in the +background, independently of whatever playback is doing.
  • +
+

Bytes fetched for playback are kept only when the playback rendition happens +to be the same artifact the download path would produce — i.e. direct play. +Otherwise playback bytes are discarded and the download path does its own fetch.

+

Motivation

+

The appealing version of this — "stream and download at once, switch when enough +has arrived" — breaks the moment the viewer can change bitrate. A capture taken +while the rendition changes underneath it is a splice of two encodings: not a +playable file, and not something that can be honestly recorded as a download. +Once bitrate is selectable, one stream cannot serve both jobs.

+

Separating the paths also removes the thing that made the original idea +expensive: there is no mid-playback source swap to engineer, because the download +never has to take over the live session. It lands on disk and is used at the next +natural boundary — next episode, or next time the item is played.

+

What exists already and is not this: SmartCache predictively downloads other +items, player_preload_upcoming warms the next one, and +refresh_queue_local_sources swaps queue entries to local at boundaries. All of +it concerns items you are not currently playing.

+

Layer assignment

+
+ + + + + + + + +
Logic / responsibilityLayerWhy it belongs there
Available bitrate options for an itemRustDerived from Jellyfin's media sources and playback-info negotiation; changes with the API.
Mapping a chosen bitrate to transcode parametersRustDomain vocabulary. get_video_download_url already owns the quality→params mapping; playback must reuse it, not restate it.
Deciding whether playback bytes are keepable (direct play vs transcode)RustDepends on the negotiated session.
Canonical download qualityRustPolicy over domain data.
Cache eviction, storage budget, sparse-range bookkeepingRustStorage policy.
Promotion to a downloads row, and what invalidates a cache entryRustDomain state.
Rendering the quality selector; remembering the last choiceFrontendPresentation and a view preference. The list comes from Rust.
WiFi-only / opt-in togglesFrontend collects, Rust enforcesThe control is UI; the gate must hold even if the UI never calls.
+
+

Borderline, recorded: the default playback bitrate could look like a user +preference (frontend). It goes to Rust because it must be reconcilable with what +the server can actually produce for a given media source — a preference the +backend has to validate is not a preference the frontend can own alone. The +frontend stores the user's choice; Rust decides what that choice resolves to.

+

Design

+

DR-121 — Bitrate selection in the player

+

The player exposes the qualities Rust reports for the current item. Changing it +re-negotiates the stream URL at the new quality and resumes at the current +position. This is a deliberate, user-initiated interruption — a brief rebuffer is +expected and acceptable, unlike the involuntary swap the earlier design would +have needed.

+

Constraints that must not be broken:

+
    +
  • On Linux, video playback must keep using the HLS master.m3u8 URL. CLAUDE.md +records that returning stream.mp4 means transcoded playback never starts. +A quality change re-negotiates within HLS.
  • +
  • The quality→transcode-parameter mapping already exists in +get_video_download_url (online.rs:1702-1717). +Playback must call into the same mapping. Two copies of that table will drift.
  • +
  • Track selection (audio/subtitle) already survives a stream re-negotiation +elsewhere in the player; a quality change must preserve it too.
  • +
+

DR-122 — The playback path is ephemeral

+

Playback bytes are not persisted unless DR-124 says they are keepable. No partial +capture is ever retained across a quality change: on change, any in-flight capture +for that session is abandoned and its partial file deleted.

+

DR-123 — The download path is independent

+

Downloading the whole file is a separate operation through the existing download +manager, at one canonical quality (default original, the direct static copy), +using /Videos/{id}/stream.mp4 — progressive and Range-capable, which is what +the resumable download worker relies on. It is unaffected by what playback is +doing, and playback is unaffected by it.

+

Once complete it becomes an ordinary download row, so everything already built on +top of downloads — offline browsing, refresh_queue_local_sources, the Downloads +page — picks it up with no further work.

+

Prerequisite: downloaded video is currently never played locally. +repository_get_video_stream_url goes straight to the online repo and +player/[id]/+page.svelte:316 +calls it with no local check — so a completed video download is still streamed. +This must be fixed or the whole feature is invisible for video.

+

DR-124 — Keep playback bytes only when they are the download

+

Capture is enabled only where the played bytes and the canonical download artifact +are the same thing — a direct-play session. Then:

+
+ + + +
PathMechanism
Android / ExoPlayerSimpleCache + CacheDataSource, keyed by item id and media-source id so renditions never collide. LRU evictor sharing the existing smart-cache budget — not a second budget over the same disk.
Linux audio / MPVstream-record, set through the existing set_property plumbing.
Linux video (HLS transcode)Not captured. Segments are not a file; assembling one needs ffmpeg, which is not a dependency and which CI is forbidden from installing at job time. The download path (DR-123) covers this case instead.
+
+

Two abandonment rules, both of which must delete the partial rather than promote +it:

+
    +
  • Seek during an mpv capture. stream-record is documented as intended for +linear streams; seeking breaks the recording. Straight-through listening +captures, scrubbing does not.
  • +
  • Any quality change (DR-122).
  • +
+

DR-125 — Promotion, rendition, and invalidation

+

A capture is promoted to a downloads row (status = 'completed') only when it +covers the whole resource. Partial captures stay cache and remain evictable.

+

A new downloads.source_rendition column records the negotiated +quality/container/codec of whatever produced the bytes; NULL for rows fetched by +the existing paths, which are always original. This is what makes an "upgrade to +original" action possible later, and what stops a 720p capture and a 4K download +being indistinguishable rows.

+

Invalidation. A quality change never touches a file that already exists — +neither a permanent download nor a completed temporary one. Both remain valid +copies of the rendition they hold, and deleting either would throw away bytes +already paid for.

+

What a quality change does invalidate is an in-flight capture or background +download of cached media: it is abandoned and restarted at the newly chosen +quality, because a capture spanning a rendition change is a splice of two +encodings rather than a playable file (DR-122).

+

So the rule is about ongoing work, not stored files. Nothing in this spec +deletes user data.

+

Gating

+

Capture and background download obey the existing WiFi-only gate and storage +budget, and are off unless opted in. Enforcement is in Rust.

+

Out of scope

+
    +
  • Mid-playback switch onto a completing download. Two independent paths make +it unnecessary; the download is used from the next boundary.
  • +
  • Backfilling the unplayed remainder of a capture. Watch 40 minutes and you +have 40 minutes; completing it needs sparse-range bookkeeping and a resumable +tail fetch. The DR-123 download path already produces a complete file, which is +the reason this can wait.
  • +
  • Bundling ffmpeg to make transcoded video capturable. Real option, large +packaging decision, its own proposal.
  • +
  • Routing Linux video playback through stream.mp4. Regresses a documented, +hard-won fix.
  • +
+

Acceptance criteria

+
    +
  • +The player offers the qualities Rust reports, and changing one resumes at +the same position with audio/subtitle selection preserved.
  • +
  • +A quality change abandons any in-flight capture and leaves no partial file.
  • +
  • +A quality change never deletes a downloads row.
  • +
  • +A completed background download of a video is played from disk on the next +play (the DR-123 prerequisite).
  • +
  • +A direct-play session played start-to-finish leaves a complete local file +with no second fetch; replaying it fetches no media bytes.
  • +
  • +Seeking during an mpv capture abandons it; no truncated file is promoted.
  • +
  • +A transcoded Linux video session is never captured, and never partially +promoted.
  • +
  • +Promoted rows record their rendition; existing paths still record +NULL/original.
  • +
  • +Gates hold with the setting off and with the frontend never sending it.
  • +
  • +Eviction cannot delete bytes backing a promoted download row.
  • +
  • +bun run check, bun run test, cargo fmt, cargo clippy, +bun run test:rust, bun run check:boundary pass; bindings.ts +regenerated if Rust types changed.
  • +
+

Testing

+

Rust, table-driven and pure where possible: quality→params resolution shared with +the download path; keepability (direct play vs transcode vs gate off); promotion +(complete → promoted, partial → not, seek-abandoned → not, quality-changed → not); +invalidation (evicts cache, never a download row); rendition round-trip.

+

Android: instrumented — a played direct-play item yields cache entries, and a +replay issues no media network request.

+

Frontend: the quality list renders from backend data with no item-type or +codec taxonomy in src/; the selector's remembered choice is a view preference.

+

TRACES

+
+ + + + + +
PieceTag
Quality selector + re-negotiation// TRACES: UR-070 | DR-121
Ephemeral playback / capture abandonment// TRACES: UR-070 | DR-122
Independent whole-file download + local video playback fix// TRACES: UR-071 | DR-123, IR-032
ExoPlayer cache / mpv stream-record / keepability// TRACES: UR-071 | DR-124
Promotion, source_rendition, invalidation// TRACES: UR-071 | DR-125
+
+

Notes for the implementer

+
    +
  • A parallel Claude session is active in this repo. git diff before +"repairing" anything you did not write.
  • +
  • Do not duplicate the quality→transcode-parameter table. Call the existing one.
  • +
  • Reuse the smart-cache storage budget; two budgets over one disk is how devices +fill up.
  • +
  • The downloads FK to items is relaxed (migration 005) — exercise promotion +for an item that was never cached.
  • +
  • Build DR-123's local-playback fix first. Without it nothing in this spec is +observable for video.
  • +
+

Spec: Context-scoped search with filter chips and configurable group order

+
+

⚠️ Superseded in part by +scoped-search-boundary.md. The "frontend only, +no Rust changes" decision below (§Background 2, §Design "Scope model" and +"Threading scope through the store") left Jellyfin's item-type taxonomy in the +presentation layer, which violates the backend/frontend boundary. The taxonomy +is being moved into Rust. The user-facing behaviour and UX in this spec are +unchanged; only where the scope→item-type mapping and result bucketing live +changes. Read the boundary spec before touching search code.

+

Progress: the scope→item-type mapping now lives in Rust +(SearchScope::item_types()); the frontend sends an opaque scope. Result-side +bucketing (GROUP_ITEM_TYPES) is still frontend-side — see +scoped-search-boundary-implementation.md +§Stage 2.

+
+

Status: Implemented (boundary revision: query side done, result side pending) +Scope: Frontend only. No Rust changes required. (Revised — see banner.) +Requirements: UR-049 → DR-063, DR-064, DR-065; UR-050 → DR-066, DR-067 +(see requirements.md). +UX spec: ux-flows.md §6 — §6.1 scope, §6.2 layout, +§6.3 group order, §6.4 current deviations.

+

Summary

+

Two related changes to search:

+
    +
  1. Scope — a search started inside a library searches that library. +Started from Home, /library, or the search tab, it searches everything. +The active scope shows as a chip row under the search bar, preselected from +context and freely changeable without retyping.
  2. +
  3. Group order — the order result groups appear in (Songs, Albums, Artists, +Movies, TV Shows) becomes a drag-and-drop setting instead of being hardcoded.
  4. +
+

Motivation

+

Searching "office" while browsing TV currently returns music albums, because +both search entry points call the same unscoped query. The user has already +told us what they're looking at; ignoring that makes search feel indiscriminate +and pushes the relevant result below unrelated media.

+

Background: what already exists

+

Verified in code — most of the plumbing is already there. This is +substantially a wiring task, not new infrastructure.

+
    +
  1. +

    SearchOptions already carries the filter. +bindings.ts — +SearchOptions = { limit?, includeItemTypes?, searchTerm? }.

    +
  2. +
  3. +

    Rust already honours include_item_types on both paths — online +(online.rs, in the get_items +options mapping) and offline +(offline.rs, which builds a SQL +type filter from it). Do not add Rust code for filtering.

    +
  4. +
  5. +

    Per-page list search already does this correctly. +GenericMediaListPage.svelte +passes includeItemTypes: [config.itemType] to repo.search(...). Use it as +the reference for the call shape, including the requestId handling.

    +
  6. +
  7. +

    The gap is exactly one function. +library.tssearch(query) takes only a +query and calls repo.search(query, { limit: 10000 }, requestId), dropping +any scope. Both callers +(search/+page.svelte and +library/+layout.svelte) go through +it.

    +
  8. +
  9. +

    Group order is hardcoded in markup. +SearchResults.svelte +categorizes into music{tracks,albums,artists} / movies / tvShows and +renders three fixed sections in source order.

    +
  10. +
  11. +

    Frontend preferences persist via localStorage, per the existing +viewMode precedent in library.ts +(jellytau-view-mode). Follow that pattern — do not add a Rust settings +command for this.

    +
  12. +
+

Design

+

Scope model

+

One SearchScope type, defined once and shared:

+
+ + + + +
ScopeincludeItemTypesChip label
allunsetAll
musicMusicAlbum, MusicArtist, Audio, PlaylistMusic
moviesMovieMovies
tvSeries, EpisodeTV
+
+

all must send no includeItemTypes key rather than a list of every type — +the two are not equivalent for item types not enumerated here (Person, folders).

+

Route → scope resolution (DR-063)

+

A pure function, unit-testable without a DOM:

+
resolveSearchScope(pathname: string): SearchScope
+
+
    +
  • /library/music*music
  • +
  • /library/movies*movies
  • +
  • /library/tv*tv
  • +
  • /, /library, /search, anything else → all
  • +
+

Note /library/shows/genres exists as a route; treat shows as tv. Check the +current route list before finalising — do not assume this table is exhaustive.

+

Scope is a starting point, not a lock (DR-064)

+

The resolved scope sets the initial chip only. Once the user taps a chip, +their choice governs until they leave the search surface. Concretely: derive the +initial value from the route, hold it in component state, and do not re-derive +it on every navigation — otherwise a user who widens to All snaps back to TV.

+

Changing a chip re-runs the current query at the new scope. Changing the query +keeps the current scope.

+

Threading scope through the store (DR-065)

+

Extend the store's search signature to accept an optional scope and pass +includeItemTypes down to repo.search. Preserve the existing behaviour +exactly: the requestId bump, the stale-response guard, the search-event +listener merge, the 10s timeout, and the empty-query clear path. This is an +additive parameter — no caller should break.

+

Group order (DR-066, DR-067)

+

Persist an ordered array of group ids:

+
["songs", "albums", "artists", "movies", "tvShows"]   // shipped default
+
+

Rendering composes scope and order as two independent axes, in this order:

+
    +
  1. drop groups outside the active scope,
  2. +
  3. sort the remainder by the user's saved order,
  4. +
  5. omit groups that came back empty.
  6. +
+

Scope never rewrites the saved order — narrowing to Music and back to All must +restore the user's full arrangement. See ux-flows.md §6.3 for +the worked example.

+

Settings gets a reorderable list. Dragging alone is not sufficient: provide +keyboard-operable move up/down controls with proper labels, or the setting is +unusable with a screen reader and on any pointerless input.

+

Unknown or missing ids in the stored array must not crash rendering — treat the +stored order as a hint, append any group it doesn't mention, and ignore ids that +no longer exist. A user upgrading from a build with fewer groups must not lose +the new ones.

+

Out of scope

+
    +
  • Ranking within a group. Order is presentation-only.
  • +
  • Server-side search ranking or the Jellyfin query itself.
  • +
  • Scope chips on the per-page list search in GenericMediaListPage — that page +is already implicitly scoped by its own itemType.
  • +
  • Any Rust change.
  • +
+

Acceptance criteria

+
    +
  • +Searching from inside Music returns no movies or TV; from inside TV, no music.
  • +
  • +Searching from Home, /library, or the search tab returns all types.
  • +
  • +The chip row renders under the search bar on both the search page and the +in-library header search, with the context-derived chip preselected.
  • +
  • +Tapping a chip re-runs the search with the query preserved; editing the +query preserves the selected chip.
  • +
  • +Tapping "All" from a context-scoped search widens results without retyping.
  • +
  • +Result groups render in the user's configured order, with out-of-scope and +empty groups omitted and relative order preserved.
  • +
  • +Group order is reorderable by drag and by keyboard, persists across +restarts, and ships with the documented default.
  • +
  • +Offline search respects scope (the offline path already filters — verify, +don't reimplement).
  • +
  • +bun run check and bun run test pass.
  • +
+

Testing

+

Follow the existing frontend test conventions (vitest, src/lib/**/*.test.ts).

+
    +
  • resolveSearchScope — pure unit tests over the route table, including the +/library/shows/genres case and unknown routes falling back to all.
  • +
  • Scope → includeItemTypes mapping, asserting all omits the key entirely.
  • +
  • The compose step: scope filter + user order + empty-group omission, including +the "narrow then widen restores order" case and a stored order containing an +unknown id.
  • +
  • Store-level: scoped search forwards includeItemTypes to the repository, and +the existing stale-requestId guard still discards superseded responses.
  • +
+

New requirement-implementing code needs TRACES: comments — see +CLAUDE.md. Suggested tags: the scope resolver and chip row +UR-049 | DR-063, DR-064, the store change UR-049 | DR-065, the settings list +and ordered rendering UR-050 | DR-066, DR-067.

+

Notes for the implementer

+
    +
  • Read ux-flows.md §6 first — it is the behavioural spec; this +document is the implementation plan.
  • +
  • The IPC camelCase rule applies to anything new that crosses the boundary +(CLAUDE.md) — though this change should not add commands.
  • +
  • Another session may be active in this repo. Check git diff before +"repairing" unexpected changes.
  • +
+

Spec: Move search scope taxonomy behind the Rust boundary

+

Status: Design authority — Stage 1 implemented, Stage 2 outstanding. +The scope→item-type mapping now lives in Rust (SearchScope::item_types() in +repository/types.rs, DR-063 … DR-067). The result-side grouping table +(GROUP_ITEM_TYPES in src/lib/utils/searchScope.ts) is still in the +frontend, and check:boundary does not match its shape. Delivery status and +the remaining work live in +scoped-search-boundary-implementation.md; +this spec remains the design authority. +Scope: Rust + Frontend. Revises a decision in +scoped-search.md. +Requirements: UR-049, UR-050 (existing) → new DRs for the boundary move +(allocate on implementation; suggested DR-063/DR-065/DR-067 revisions plus one +new DR for the grouped result shape — see requirements.md). +UX spec: unchanged — ux-flows.md §6. This is a pure +architecture/boundary change with no user-visible behaviour difference.

+

Why this spec exists

+

scoped-search.md shipped scoped search as "frontend only, no +Rust changes." That was the smallest wiring change, and it worked — but it left +Jellyfin's item-type taxonomy encoded in the presentation layer, which +violates the project's core boundary rule ("Svelte frontend — presentation +only"; all business logic in Rust — see CLAUDE.md and +architecture/02-svelte-frontend.md).

+

The offending knowledge lives in +searchScope.ts:

+
const SCOPE_ITEM_TYPES = {
+  music:  ["MusicAlbum", "MusicArtist", "Audio", "Playlist"],
+  movies: ["Movie"],
+  tv:     ["Series", "Episode"],
+};
+const GROUP_ITEM_TYPES = {
+  songs: ["Audio"], albums: ["MusicAlbum"], artists: ["MusicArtist"],
+  movies: ["Movie"], tvShows: ["Series", "Episode"],
+};
+
+

This is a domain definition — "what the category Music means in Jellyfin's +vocabulary" — expressed twice, in the wrong layer. The concrete failure it +creates: the day the backend starts returning a type the frontend never +enumerated (e.g. MusicVideo, or Jellyfin renaming a kind), search silently +drops it from both the query filter and the result buckets, and nothing in the +Rust layer — the actual authority on Jellyfin's API — can correct it. Two +sources of truth that will drift.

+

This must be fixed while the feature is uncommitted, before the leak ships +baked into a released wire contract.

+

What is not a leak (leave it alone)

+

Single concrete-type list pages are not business logic and stay as-is:

+
    +
  • music.ts["MusicAlbum"] / ["Playlist"], movies.ts["Movie"], +tv.ts["Series"]
  • +
  • GenericMediaListPage.svelte[config.itemType]
  • +
  • ArtistDetailView, RelatedItemsSection, AddToPlaylistModal, +PersonDetailView
  • +
+

"This page shows albums" is a legitimate presentation choice expressed through a +generic getItems(parentId, { includeItemTypes }) API. Only the search scope +taxonomy (a semantic category → many types, defined once and reused) crosses +the line. Do not invent a backend enum for every list page — that is +over-abstraction, not cleaner separation.

+

The boundary rule after this change

+
+

The frontend never names a Jellyfin item type in connection with search. +It sends an opaque scope, and receives results already sorted into labelled +groups. The frontend owns only group order (presentation) and +rendering.

+
+

Design

+

Rust owns scope → item-types (query side)

+

Add an opaque enum that crosses IPC, and move the expansion table into Rust:

+
#![allow(unused)]
+fn main() {
+// repository/types.rs
+#[derive(specta::Type, Serialize, Deserialize, Clone, Copy, Debug)]
+#[serde(rename_all = "camelCase")]
+pub enum SearchScope { All, Music, Movies, Tv }
+
+impl SearchScope {
+    /// The Jellyfin item types this scope requests, or None for `All`
+    /// (which must send NO includeItemTypes — see below).
+    pub fn item_types(self) -> Option<Vec<String>> {
+        match self {
+            SearchScope::All    => None,
+            SearchScope::Music  => Some(vec!["MusicAlbum", "MusicArtist", "Audio", "Playlist"]
+                                        .into_iter().map(String::from).collect()),
+            SearchScope::Movies => Some(vec!["Movie".into()]),
+            SearchScope::Tv     => Some(vec!["Series".into(), "Episode".into()]),
+        }
+    }
+}
+}
+

SearchOptions gains scope and the search command resolves it into the +existing include_item_types filter inside Rust, before dispatching to the +online/offline paths (which already honour include_item_types — do not touch +their filtering, per scoped-search.md §Background 2).

+
#![allow(unused)]
+fn main() {
+pub struct SearchOptions {
+    pub limit: Option<usize>,
+    pub search_term: Option<String>,
+    pub scope: Option<SearchScope>,        // NEW
+    // include_item_types stays for the single-type list-page callers,
+    // but the SEARCH command derives it from `scope` when scope is set.
+}
+}
+

Precedence: if scope is set it wins; include_item_types remains for the +non-search getItems callers. Document this so a future reader does not send +both.

+

All sends no filter. Preserve the existing invariant: All must omit +includeItemTypes entirely, not send the union of every enumerated type — types +nobody listed (Person, folders) would otherwise be filtered out. This is why +item_types() returns Option, and the command must skip the filter on None.

+

Rust owns result bucketing (result side)

+

Results arrive pre-grouped. Rust classifies each returned MediaItem into a +group by its type — the GROUP_ITEM_TYPES knowledge, moved to the authority:

+
#![allow(unused)]
+fn main() {
+#[derive(specta::Type, Serialize, Deserialize, Clone, Copy, Debug)]
+#[serde(rename_all = "camelCase")]
+pub enum SearchGroupId { Songs, Albums, Artists, Movies, TvShows }
+
+#[derive(specta::Type, Serialize, Deserialize, Clone, Debug)]
+#[serde(rename_all = "camelCase")]
+pub struct SearchGroup { pub id: SearchGroupId, pub items: Vec<MediaItem> }
+
+#[derive(specta::Type, Serialize, Deserialize, Clone, Debug)]
+#[serde(rename_all = "camelCase")]
+pub struct GroupedSearchResult { pub groups: Vec<SearchGroup> }
+}
+

Rust emits every non-empty group it can classify, in a stable canonical +order. It does not apply the user's ordering or drop out-of-scope groups — +those are presentation and stay frontend-side (see below). Items whose type maps +to no group are omitted from grouped output (same as today's frontend filter).

+

🔴 The search-event wrinkle — both payloads must change

+

Search returns results twice: the command resolves with instant local-cache +results, then the merged cache+server union arrives later via the search-event +listener (see library.ts search() and +architecture/03-data-flow.md). Both the +command return value and the search-event payload must carry +GroupedSearchResult. If only one is converted, the instant results group and +the merged ones do not (or vice versa), and the UI flickers between shapes. This +is the single largest part of the change and the easiest to half-do.

+

What the frontend keeps (all pure presentation)

+

searchScope.ts retains:

+
    +
  • SearchScope type — now sourced from the generated bindings, mirroring the +Rust enum (delete the hand-written union).
  • +
  • SCOPE_LABELS, SEARCH_SCOPES (chip labels / order).
  • +
  • resolveSearchScope(pathname) — route → initial scope. Pure, DOM-free, +unit-tested. Stays exactly as-is.
  • +
  • SearchGroupId (from bindings), GROUP_LABELS.
  • +
  • normalizeGroupOrder, groupsForScope, moveGroup, reorderGroups, +DEFAULT_GROUP_ORDER — group-order persistence and reordering, all +presentation.
  • +
+

searchScope.ts loses:

+
    +
  • SCOPE_ITEM_TYPES, GROUP_ITEM_TYPES (moved to Rust).
  • +
  • scopeItemTypes(), groupItemTypes().
  • +
  • The .type-inspecting body of composeSearchGroups().
  • +
+

composeSearchGroups() shrinks to a presentation composition over Rust's +groups — no .type inspection anywhere:

+
// Take Rust's pre-bucketed groups; drop out-of-scope, sort by saved order,
+// attach labels, omit empties. No Jellyfin type vocabulary.
+composeSearchGroups(groups: SearchGroup[], scope, order): DisplayGroup[]
+
+

GROUP_SCOPE (which group belongs to which scope) is a borderline case: it is +"is Songs part of the Music scope," arguably taxonomy. But because Rust already +filtered the query by scope, out-of-scope groups will simply be empty and +drop out via the empty-omit rule — so the frontend does not strictly need +GROUP_SCOPE for correctness once Rust filters. Recommendation: delete +GROUP_SCOPE and rely on empty-omission; if kept for belt-and-suspenders, treat +it as a display hint, not authority.

+

Frontend call-site changes

+
    +
  • library.ts search(query, scope) sends +{ scope } in SearchOptions instead of computing includeItemTypes. +Everything else (requestId bump, stale guard, 10s timeout, empty-query clear, +event merge) is preserved.
  • +
  • SearchResults.svelte +consumes SearchGroup[] from the store instead of a flat MediaItem[] + +client-side composeSearchGroups(results, …). The store now holds grouped +results.
  • +
  • search/+page.svelte is unchanged in +behaviour; only the type it passes to SearchResults changes.
  • +
+

Out of scope

+
    +
  • Any change to online/offline include_item_types filtering — it already +works; only the source of the type list moves.
  • +
  • Single concrete-type list pages (see "What is not a leak").
  • +
  • Ranking within or across groups.
  • +
  • The UX / chip behaviour / persistence mechanism — all unchanged from +scoped-search.md.
  • +
+

Acceptance criteria

+
    +
  • +No Jellyfin item-type string literal ("MusicAlbum", "Audio", …) remains +in searchScope.ts or any search call path. Verify: +grep -rn '"MusicAlbum"\|"MusicArtist"\|"Audio"\|"Series"\|"Episode"\|"Movie"\|"Playlist"' src/lib/utils/searchScope.ts src/lib/stores/library.ts returns nothing.
  • +
  • +SearchScope and SearchGroupId in the frontend come from the generated +bindings.ts, not hand-written unions.
  • +
  • +Search behaviour is identical to today for the user: same scoping, same +groups, same order, same empty/out-of-scope omission, offline included.
  • +
  • +Both the command return and the search-event payload carry the grouped +shape; no shape flicker between instant and merged results.
  • +
  • +All scope still sends no includeItemTypes (assert in a Rust test).
  • +
  • +Adding a hypothetical new type to a scope requires editing only Rust.
  • +
  • +cargo fmt clean, cargo clippy clean, bun run test:rust passes.
  • +
  • +bun run check and bun run test pass; bindings.ts regenerated and +committed.
  • +
+

Testing

+

Rust (src-tauri, cargo test):

+
    +
  • SearchScope::item_types(): each scope's list, and AllNone.
  • +
  • Search command: scope: Music resolves to the four music types on the query; +scope: All sends no include_item_types.
  • +
  • Bucketing: a mixed Vec<MediaItem> classifies into the right SearchGroupIds; +unknown types are dropped; groups come out in canonical order.
  • +
  • The search-event payload is the grouped shape (guard the wrinkle).
  • +
+

Frontend (vitest, src/lib/**/*.test.ts) — update existing tests:

+
    +
  • librarySearchScope.test.ts currently asserts includeItemTypes on the +outgoing options — rewrite to assert scope is sent instead.
  • +
  • searchScope.test.ts — drop scopeItemTypes/groupItemTypes cases; keep and +extend resolveSearchScope, order normalize/move/reorder, and the new +compose-over-groups (order + empty-omit, no type inspection).
  • +
  • searchGroupOrder.test.ts — unchanged.
  • +
+

TRACES

+

Per CLAUDE.md, tag requirement-implementing code:

+
    +
  • SearchScope enum + item_types() + search command scope resolution: +UR-049 | DR-063 (revised — resolution now Rust-side).
  • +
  • Grouped result shape + bucketing: UR-050 | DR-067 (revised) + a new DR for +the wire shape.
  • +
  • library.ts store change: UR-049 | DR-065 (revised — sends scope not types).
  • +
+

Notes for the implementer

+
    +
  • This spec revises scoped-search.md §Background 2 and +§Design "Scope model / Threading scope through the store," which asserted no +Rust change. Update that spec's status to note the boundary was moved, or add a +banner pointing here — do not leave the two specs contradicting silently.
  • +
  • The IPC camelCase rule applies to the new enums and structs +(CLAUDE.md): #[serde(rename_all = "camelCase")] on structs; +the tagged-enum tag convention if any enum becomes tagged. Add/extend a +tauriIntegration-style test if a new command is introduced.
  • +
  • Regenerate bindings.ts via the tauri-specta build step after changing Rust +types; do not hand-edit it.
  • +
  • Another Claude session may be active in these same files (per project +memory). git diff before repairing anything unexpected; these search files +are exactly the ones a parallel session touched.
  • +
+

Spec: Land the scoped-search boundary fix (implementation)

+

Status: Stage 1 Implemented — Stage 2 (result-side grouping) outstanding +Requirements: UR-049, UR-050 | DR-063, DR-066, DR-067 (existing — no new IDs) +UX spec: n/a — zero user-visible change is the point (see Acceptance criteria). +Supersedes / revises: implements scoped-search-boundary.md, +which specified this fix but was never built. That spec remains the design +authority; this one is the delivery plan and status correction.

+

Summary

+

scoped-search-boundary.md diagnosed a domain-taxonomy +leak, specified the fix in full detail, and became the justification for the +project's boundary rule in CLAUDE.md, the check:boundary tripwire, and the +spec-review checklist. The fix was never implemented. The leak it describes +is still live in main. This spec exists to close that gap and to correct the +record — the codebase currently enforces a rule against a violation it still +contains.

+

Motivation

+

The mapping the rule forbids is present and in use:

+
// src/lib/utils/searchScope.ts:29-32
+const SCOPE_ITEM_TYPES: Record<Exclude<SearchScope, "all">, string[]> = {
+  music: ["MusicAlbum", "MusicArtist", "Audio", "Playlist"],
+  movies: ["Movie"],
+  tv: ["Series", "Episode"],
+};
+
+

This is not dead code. library.ts:262 +calls scopeItemTypes(scope) and puts the result straight into +options.includeItemTypes. Meanwhile there is no SearchScope anywhere in +src-tauri/:

+
$ grep -rn "SearchScope" src-tauri/src --include='*.rs'
+(no output)
+
+

Three things make this the highest-value item found in the design-principles +audit:

+
    +
  1. The rule's own founding incident is unremediated. CLAUDE.md cites this +spec as "the incident this rule came from." A rule whose originating +violation is still shipping is not credible.
  2. +
  3. The tripwire cannot see it. bun run check:boundary passes — it greps for +a multi-type array literal at the query site, and this one is assigned to a +named const and dereferenced elsewhere. Broadening the tripwire is specified +separately by the tripwire hardening (DR-094, shipped); +note that hardening it without landing this fix would turn master red.
  4. +
  5. The spec's own acceptance criterion fails today. "Adding a hypothetical +new type to a scope requires editing only Rust" — adding a type to the Music +scope right now requires editing searchScope.ts.
  6. +
+

Layer assignment

+

Unchanged from scoped-search-boundary.md §Design; +restated so this spec is reviewable on its own.

+
+ + + + + + + +
Logic / responsibilityLayerWhy it belongs there
Scope → Jellyfin item types (musicMusicAlbum, MusicArtist, Audio, Playlist)RustDomain vocabulary. Changes if Jellyfin adds/renames an item type — the litmus test's "yes" case. This is the leak being fixed.
Result item → search group bucketingRustSame taxonomy, result side. Classifying a MediaItem as a Song vs Album is Jellyfin vocabulary, not layout.
All sends no filter at all (≠ union of enumerated types)RustA query-shaping rule with a correctness consequence (Person/folder results would be silently dropped). Belongs with the expansion it qualifies.
Group display order, labels, reordering, persistenceFrontendPure presentation — changes only if the UI is redesigned. Explicitly retained frontend-side.
resolveSearchScope(pathname) — route → initial scopeFrontendRouting/navigation, no Jellyfin vocabulary. Stays exactly as-is.
Chip labels (SCOPE_LABELS), scope order (SEARCH_SCOPES)FrontendDisplay strings over an opaque enum.
GROUP_SCOPE (which group belongs to which scope)DeleteBorderline taxonomy, made redundant: once Rust filters by scope, out-of-scope groups arrive empty and drop via the empty-omit rule. Borderline defaults to Rust; here it defaults to gone.
+
+

The SearchScope and SearchGroupId types come to the frontend from +generated bindings.ts. Naming an opaque enum variant is not taxonomy; knowing +what item types it expands to is.

+

Design

+

Follow scoped-search-boundary.md §Design as +writtenSearchScope enum + item_types() in repository/types.rs, +SearchOptions.scope, SearchGroupId/SearchGroup/GroupedSearchResult, +scope-wins precedence, AllNone → no filter. It is not restated here; +duplicating it would create two drifting copies of the same design.

+

This spec adds only the delivery sequencing that the original left implicit.

+

Staging: land it in two reviewable pieces

+

The original bundles the query side and the result side into one change. That is +a large diff touching Rust types, bindings.ts, the store, and a component, with +the search-event dual-payload hazard in the middle. Split it:

+

Stage 1 — query side (closes the leak). +SearchScope enum, SearchOptions.scope, command resolves scope → +include_item_types in Rust, library.ts sends { scope }, delete +SCOPE_ITEM_TYPES and scopeItemTypes(). Result grouping stays as it is.

+

After Stage 1 the actual boundary violation is gone and +the hardened tripwire (DR-094) can pass.

+

Stage 2 — result side. SearchGroupId/SearchGroup/GroupedSearchResult, +Rust bucketing, both payloads converted, composeSearchGroups() shrunk, +GROUP_ITEM_TYPES/groupItemTypes()/GROUP_SCOPE deleted.

+

Both stages are required for the original spec's acceptance criteria to pass; +Stage 1 alone leaves GROUP_ITEM_TYPES in the frontend. Stage 1 is not a +stopping point — it is a review boundary. Do not mark the parent spec +Implemented until Stage 2 lands.

+

Stage 1 — delivered (July 2026)

+
    +
  • SearchScope enum + item_types() in repository/types.rs; +AllNone → no filter.
  • +
  • SearchOptions.scope with resolve_scope(); scope wins over +include_item_types, which stays for the non-search get_items callers.
  • +
  • repository_search resolves the scope once, before the cache/server split, +so both phases filter identically.
  • +
  • SCOPE_ITEM_TYPES and scopeItemTypes() deleted; searchScope.ts now +re-exports SearchScope from the generated bindings instead of a hand-written +union.
  • +
  • library.ts sends { scope }.
  • +
  • 8 Rust tests (search_scope_tests); the frontend suite now asserts the +opaque scope is sent rather than an item-type list.
  • +
+

Verified: adding "AudioBook" to the Music scope changed zero files under +src/ — the criterion that failed before this work.

+

Stage 2 remains open: GROUP_ITEM_TYPES / groupItemTypes() (result-side +bucketing, single-type-per-group) are still in searchScope.ts, and both search +payloads still carry a flat MediaItem[] rather than GroupedSearchResult.

+

🔴 The search-event dual payload (Stage 2)

+

The original flags this as "the single largest part of the change and the +easiest to half-do." Restating because it is the one thing that silently breaks: +search resolves twice — the command returns instant cache results, then the +merged cache+server union arrives via search-event. Both payloads must carry +GroupedSearchResult. Convert one and the UI flickers between shapes as server +results land.

+

Write the failing test for the event payload first — the command return is the +obvious half, the event is the half that gets forgotten.

+

Note on SearchOptions.scope and specta

+

SearchOptions is already #[serde(rename_all = "camelCase")] with +skip_serializing_if = "Option::is_none". Add scope: Option<SearchScope> +following that pattern so All/absent omits the key. Regenerate bindings.ts +— SearchOptions there is currently +{ limit?, includeItemTypes?, searchTerm? } and must gain scope?. Never +hand-edit it.

+

Out of scope

+
    +
  • Redesigning anything in scoped-search-boundary.md. +If implementation shows the design wrong, revise that spec, don't fork it.
  • +
  • Online/offline include_item_types filtering — already correct; only the +source of the type list moves.
  • +
  • Ranking within or across groups (DR-090 territory).
  • +
  • Chip UX, scope persistence, group-order persistence — unchanged.
  • +
  • The two lesser type-set sites in DownloadedBrowse.svelte and +GenericMediaListPage.svelte, handled in +the hardened tripwire (DR-094, see scripts/check-frontend-boundary.sh).
  • +
  • Broadening the tripwire itself — same sibling spec.
  • +
+

Acceptance criteria

+

Inherits every criterion from scoped-search-boundary.md +§Acceptance criteria. Additionally:

+
    +
  • +grep -rn "SearchScope" src-tauri/src --include='*.rs' returns matches — +the enum exists in Rust (it does not today).
  • +
  • +grep -n "SCOPE_ITEM_TYPES\|scopeItemTypes\|GROUP_ITEM_TYPES\|groupItemTypes" src/lib/utils/searchScope.ts +returns nothing.
  • +
  • +grep -rn "scopeItemTypes" src/ returns nothing — including the +library.ts import and call site.
  • +
  • +SearchOptions in bindings.ts includes scope; regenerated, not +hand-edited.
  • +
  • +Behaviour is byte-identical for the user: same scoping, same groups, +same order, same empty-group omission, offline included. This spec is a +pure refactor — any visible change is a defect.
  • +
  • +All scope sends no includeItemTypes (asserted in a Rust test, not by +inspection).
  • +
  • +Adding a type to the Music scope requires editing only Rust — +demonstrate by making the edit and confirming no src/ file changes.
  • +
  • +scoped-search-boundary.md status flips to Implemented, and +scoped-search.md's "frontend only, no Rust changes" framing gets a +banner pointing at the corrected design.
  • +
  • +bun run check and bun run test pass.
  • +
  • +cargo fmt clean, cargo clippy clean, bun run test:rust passes.
  • +
  • +bun run check:boundary passes.
  • +
  • +Changed code carries // TRACES: comments (IDs below).
  • +
+

Testing

+

Follow scoped-search-boundary.md §Testing. Emphases:

+

Rust (cargo test):

+
    +
  • SearchScope::item_types() per scope; AllNone.
  • +
  • Scope resolution happens before the online/offline split, so both paths +get the same filter — a regression here is invisible until someone searches +offline.
  • +
  • scope set + include_item_types set → scope wins (the documented +precedence; assert it rather than trusting the doc).
  • +
  • Stage 2: mixed Vec<MediaItem> buckets correctly; unknown types dropped; +canonical group order; the search-event payload is the grouped shape.
  • +
+

Frontend (bun run test):

+
    +
  • resolveSearchScope() tests in searchScope.test.ts must pass unchanged — +they cover the part that is not moving, and are the regression net proving the +refactor didn't disturb routing.
  • +
  • library.ts sends { scope } and never includeItemTypes for search.
  • +
  • composeSearchGroups() over fixture SearchGroup[] with no .type +inspection in the implementation.
  • +
+

Offline parity: run a scoped search with the server unreachable and confirm +identical grouping. The offline repository path honours include_item_types +independently, and this is the case most likely to be missed.

+

TRACES

+

No new requirement IDs — this implements existing ones. Retag as the code moves:

+
#![allow(unused)]
+fn main() {
+// src-tauri/src/repository/types.rs
+/// TRACES: UR-049 | DR-063
+pub enum SearchScope { … }
+}
+
// src/lib/utils/searchScope.ts — keep the file header; it retains
+// resolveSearchScope + group-order presentation logic.
+// TRACES: UR-049, UR-050 | DR-063, DR-066, DR-067
+
+

Update DR-063's text in requirements.md to state that scope expansion is owned +by Rust, so the requirement stops describing the leaked design. New Rust tests +take @req-test: UT-089 onward (next free UT is UT-089).

+

Notes for the implementer

+
    +
  • A parallel Claude session may be active in this repo — git diff before +"repairing" unexpected changes (CLAUDE.md §Gotchas).
  • +
  • Read scoped-search-boundary.md first. This +spec is deliberately thin on design; that one is the authority.
  • +
  • Sequence with the sibling specs: Stage 1 here → then +the hardened tripwire (DR-094). Hardening +the tripwire first turns master red on a known-unfixed violation.
  • +
  • git log --oneline -- docs/specs/scoped-search-boundary.md is worth a look +before starting — understanding why the fix stalled may surface a constraint +the spec didn't record.
  • +
  • The user-visible-change count for this spec is zero. If QA reports a +difference in search results, that is a bug in the refactor, not an +improvement.
  • +
+

Spec: Remove Jellyfin-specific models from the frontend

+
+

Implementation status (branch frontend-domain-model, worktree +../JellyTau-domain-model): Catalog surface done. The frontend's item +classification and time units no longer speak Jellyfin:

+
    +
  • domain/ module is the single source of truth; MediaKind enum + isolated +from_jellyfin mapping. The model gained real distinctions the flat +item_type had hidden: LiveChannel / ChannelItem / Channel.
  • +
  • Every catalog item.type === "..."item.kind (0 remaining in src/).
  • +
  • Catalog ticks → milliseconds (durationMs, playbackPositionMs); +formatDuration takes ms; progress bars are unit-consistent.
  • +
  • User-facing type badge → kindLabel().
  • +
  • Old Jellyfin-named fields remain dual-carried on the wire so nothing broke.
  • +
+

Deferred (tracked, not done):

+
    +
  • primaryImageTagimageId rename (naming-only; ~40 sites across catalog + +PlayerMediaItem/MergedMediaItem, the latter needing a Rust image_id +round-trip). Catalog MediaItem already has imageId.
  • +
  • Player/session/reporting tick math (Queue, SessionCard, RemoteControls, +playbackReporting, playerEvents) — crosses storage/Jellyfin command +signatures in ticks; needs those commands to accept ms (phase 4).
  • +
  • stream.type (mediaStreams[].type) — Jellyfin stream vocabulary (phase 4).
  • +
  • Delete playbackUnits.ts / jellyfinFieldMapping.ts once their last +consumers migrate; drop the dual-carried fields once nothing reads them.
  • +
+
+

Status: Partially implemented (catalog surface); see banner. +Requirements: Architectural (boundary integrity — CLAUDE.md core principles). +Allocate new DRs on acceptance; suggested: DR for the domain MediaItem/MediaKind +type, DR for tick/image-tag hoisting, DR for the phased frontend migration +(see requirements.md). Relates to UR-007, UR-008, UR-034. +UX spec: n/a — zero user-visible behaviour change. This is a pure +architecture/boundary migration. +Supersedes / revises: none. Extends the boundary work started in +scoped-search-boundary.md from taxonomy to the +whole media model.

+

Summary

+

The frontend currently consumes Jellyfin's data model directly: MediaItem is a +Jellyfin DTO (runTimeTicks, primaryImageTag, parentIndexNumber, a +stringly-typed type: string carrying Jellyfin's item vocabulary), mirrored via +specta into 36+ frontend files, with 127 item.type === "…" string +comparisons across 23 files and two frontend utility modules +(playbackUnits.ts, jellyfinFieldMapping.ts) doing Jellyfin-specific unit and +field conversion in the presentation layer.

+

This spec defines a provider-neutral domain model, owned by Rust, that the +Jellyfin repository maps into. The frontend consumes only that model. When done, +no Jellyfin vocabulary — item-type strings, ticks, image tags, Jellyfin field +names — remains in src/.

+

Motivation

+

Two concrete problems, one strategic:

+
    +
  1. Boundary violation at scale. Per CLAUDE.md, the frontend is +presentation-only and Rust owns the domain. Today the domain model itself is +Jellyfin's wire shape, propagated unchanged across IPC. The frontend knows what +a "tick" is, what primaryImageTag means, and that "Audio" is a track. That +is domain knowledge in the wrong layer, 36 files deep.
  2. +
  3. Fragility. type: string is unchecked: a typo ("Epis0de") or a Jellyfin +rename fails silently at runtime with no compiler help, across 127 sites. Tick +math (* 10_000_000) duplicated frontend-side is a class of bug the backend +should have already resolved.
  4. +
  5. Strategic (the reason we chose the ambitious target): a neutral domain +model is the precondition for ever supporting a non-Jellyfin backend (Plex, +local files, Subsonic). As long as the UI speaks Jellyfin, that door is welded +shut.
  6. +
+

Layer assignment

+
+ + + + + + + +
Logic / responsibilityLayerWhy it belongs there
Definition of the media domain model (MediaItem, MediaKind)RustThe canonical shape the whole app reasons about; must not be a provider's wire format.
Jellyfin DTO → domain mapping (ticks→ms, image tag→url/id, "Audio"Track, PremiereDatereleaseDate)Rust, in the Jellyfin repositoryProvider-specific translation; changes if Jellyfin changes; is the definition of "how Jellyfin maps to our domain."
Tick arithmetic (playbackUnits.ts)RustA Jellyfin unit. The frontend should never see ticks; it receives durationMs/positionMs.
Sort-field mapping (jellyfinFieldMapping.ts, title→SortName)RustMaps neutral sort keys to Jellyfin query fields — provider vocabulary. Frontend sends a neutral SortKey.
MediaKind classification (is this a track / album / episode?)RustDerived from Jellyfin's item_type; the frontend receives the already-classified kind.
Choosing which kind renders as a card vs a list row; grid/list toggle; group orderFrontendPure presentation over the neutral kind. Changes only if the UI is redesigned.
Navigation decisions (kind === Track && albumId → go to album)FrontendPresentation/routing over neutral fields.
+
+

Borderline calls, resolved:

+
    +
  • MergedMediaItem (the lightweight now-playing projection) is already +half-neutral (title, artist, duration) — it becomes a straightforward +subset of the new domain model, not a special case.
  • +
  • Context discriminators "album", "playlist", "remote" (in TrackList, +playback context, sessions) are already domain-neutral — they are our +vocabulary, not Jellyfin's. They stay as-is; do not confuse them with +item_type. Only the Jellyfin item-type strings move.
  • +
  • mediaStreams[].type === "Audio"/"Subtitle"/"Video" (track selection in +VideoPlayer) is Jellyfin stream vocabulary too, but is lower-risk and +self-contained — deferred to a late phase, not phase 1.
  • +
+

Design

+

Single canonical model, one location, isolated mappings

+

The domain model is defined once, in a dedicated top-level Rust module +src-tauri/src/domain/, and is the single source of truth shared across the +whole app:

+
src-tauri/src/domain/
+  media.rs         canonical MediaItem, MediaKind, and the other media types
+  from_jellyfin.rs Jellyfin DTO -> domain mapping, ISOLATED here
+  mod.rs           re-exports
+        |  tauri-specta (export_typescript_bindings test)
+        v
+  src/lib/api/bindings.ts   generated MediaItem/MediaKind — the frontend copy
+
+
    +
  • One definition. domain::MediaItem is the model. Rust (repositories, +player, downloads) uses it directly. The frontend uses the generated bindings.ts +projection of it. There is no second hand-written copy in either language, so it +cannot drift — "shared between frontend and backend" is realized by generation, +not duplication.
  • +
  • Mappings live beside the model, never in consumers. All provider translation +(JellyfinItemdomain::MediaItem, ticks→ms, image-tag→id, item-type→MediaKind) +lives in domain/from_jellyfin.rs. It is the only place Jellyfin vocabulary +touches the domain type. Adding a second provider later means a new +from_<provider>.rs beside it — the model and every consumer stay untouched.
  • +
  • domain is a top-level module (not under repository/) because MediaItem +is used by player/, download/, and playback_mode/ too — it is not +repository-specific.
  • +
  • The existing JellyfinItem DTO + to_media_item() in +online.rs is the seam that already +exists; it moves into domain/from_jellyfin.rs and is enriched to do real +translation instead of copying item_type through.
  • +
+

The domain model (Rust)

+
#![allow(unused)]
+fn main() {
+// src-tauri/src/domain/media.rs — provider-neutral. NO Jellyfin vocabulary.
+#[derive(specta::Type, Serialize, Deserialize, Clone, Copy, Debug, PartialEq, Eq)]
+#[serde(rename_all = "camelCase")]
+pub enum MediaKind {
+    Track, Album, Artist, Playlist,          // music
+    Movie, Series, Season, Episode,          // video
+    Person,                                  // cast/crew
+    Channel, Folder,                         // containers/live
+}
+
+#[derive(specta::Type, Serialize, Deserialize, Clone, Debug)]
+#[serde(rename_all = "camelCase")]
+pub struct MediaItem {
+    pub id: String,
+    pub name: String,
+    pub kind: MediaKind,          // was: type: String
+    pub is_folder: bool,
+    pub server_id: String,
+
+    // Times in milliseconds — NEVER ticks.
+    pub duration_ms: Option<i64>,        // was: run_time_ticks
+
+    // Image as a resolved identifier the frontend turns into a URL via the
+    // existing image command — no raw Jellyfin tag semantics leak.
+    pub image_id: Option<String>,        // was: primary_image_tag
+    pub backdrop_image_ids: Option<Vec<String>>,
+
+    pub overview: Option<String>,
+    pub genres: Option<Vec<String>>,
+    pub production_year: Option<i32>,
+    pub release_date: Option<String>,    // was: premiere_date (ISO-8601)
+    pub community_rating: Option<f64>,
+    pub official_rating: Option<String>,
+
+    // Relationships — already neutral, kept.
+    pub album_id: Option<String>, pub album_name: Option<String>,
+    pub album_artist: Option<String>, pub artists: Option<Vec<String>>,
+    pub artist_items: Option<Vec<ArtistItem>>,
+    pub series_id: Option<String>, pub series_name: Option<String>,
+    pub season_id: Option<String>, pub season_name: Option<String>,
+
+    // Ordinal position — rename off Jellyfin's index vocabulary.
+    pub track_number: Option<i32>,       // was: index_number
+    pub disc_number: Option<i32>,        // was: parent_index_number
+
+    pub user_data: Option<UserData>,
+    pub media_streams: Option<Vec<MediaStream>>,
+    pub media_sources: Option<Vec<MediaSource>>,
+    pub people: Option<Vec<Person>>,
+}
+}
+

The existing JellyfinItem DTO (already defined in online.rs, deserialized +from the Jellyfin JSON) moves into domain/from_jellyfin.rs and stays +private to that module. Its to_media_item() — today a near-passthrough that +copies item_type straight across — is enriched into the single, tested place +that:

+
    +
  • classifies item_type: StringMediaKind (including the edge cases found in +the audit: "ChannelFolderItem"Channel/Folder by is_folder, +"TvChannel"Channel, "Composer"/"Director"/"Writer"Person, +"Video"Movie or a video leaf). Unknown strings map to Folder or a new +Other variant — decide at implementation; must not panic.
  • +
  • converts run_time_ticksduration_ms (ticks / 10_000).
  • +
  • maps PremiereDaterelease_date, image tags → image ids.
  • +
+

SortKey enum + its Jellyfin field mapping (jellyfinFieldMapping.ts contents) +moves into the Jellyfin repository; the command takes a neutral SortKey.

+

🔴 The search-event / dual-payload rule applies again

+

Every path that returns MediaItem — command returns and the search-event +and any other event payloads — emits the new domain shape. Both sides of a +twice-delivered result must match (same rule as +scoped-search-boundary.md). Grep for MediaItem in +event definitions before declaring a phase done.

+

Frontend after

+
    +
  • MediaItem/MediaKind come from generated bindings.ts.
  • +
  • item.type === "Audio"item.kind === "track" (127 sites, mechanical).
  • +
  • runTimeTicks usages → durationMs; delete playbackUnits.ts (ticks no +longer cross the boundary; keep only any purely-display seconds↔clock helpers if +they exist, which are not Jellyfin-specific).
  • +
  • primaryImageTagimageId through the existing image-URL command.
  • +
  • Delete jellyfinFieldMapping.ts; sort options send a neutral SortKey.
  • +
  • Assert with the boundary tripwire + a new grep (see acceptance).
  • +
+

Phased migration

+

This is too large and too collision-prone for one change. Phases are independently +shippable, each keeps all tests green, and each is a reviewable PR:

+
    +
  1. Establish the domain/ module + enriched mapping, tests — no frontend +change yet. Create src-tauri/src/domain/{media,from_jellyfin,mod}.rs. Move +JellyfinItem/to_media_item in. Add MediaKind and the neutral fields to +domain::MediaItem as additive, defaulted fields, and populate them in the +mapping, while keeping the old Jellyfin-named fields too (dual-carry). The +wire shape is a superset of today's, so the frontend still compiles and +behaves identically. Lands the authority + full mapping unit coverage first, +with zero blast radius on the 52 construction sites (they set the old fields; +new ones default).
  2. +
  3. Flip the wire shape. Commands + events emit the new MediaItem. +Regenerate bindings.ts. Frontend breaks to compile errors — fix them +mechanically (typekind, values "Audio""track", runTimeTicks→ +durationMs, primaryImageTagimageId). This is the big mechanical PR; +bun run check is the driver.
  4. +
  5. Delete the frontend conversion helpers (playbackUnits.ts ticks, +jellyfinFieldMapping.ts) and route sorting through the neutral SortKey.
  6. +
  7. Stream vocabulary (mediaStreams[].type) and any remaining stragglers; +tighten the boundary check to forbid Jellyfin item-type strings in src/ +outside tests.
  8. +
+

Ship 1 → 2 → 3 → 4 as separate PRs. Do not attempt all four at once.

+

Out of scope

+
    +
  • Actually adding a second backend (Plex/Subsonic). This spec only unblocks it.
  • +
  • Changing any user-visible behaviour, layout, or copy.
  • +
  • The player-internal PlayerMediaItem / MediaSessionType shapes, except where +they carry the fields being renamed — align them in phase 2 only if the compiler +demands it.
  • +
  • Context discriminators ("album", "playlist", "remote") — already neutral.
  • +
+

Acceptance criteria

+
    +
  • +No Jellyfin item-type string ("Audio", "MusicAlbum", "Series", …) is +compared against .type/.kind anywhere in src/ (outside tests). Verify: +grep -rIn '\.kind === "\(Audio\|MusicAlbum\|MusicArtist\|Series\|Episode\|Movie\|Playlist\)"' src/ returns nothing.
  • +
  • +No Ticks, runTimeTicks, primaryImageTag, PremiereDate, or Jellyfin +sort-field name (SortName, RunTimeTicks, …) appears in src/ outside +tests. playbackUnits.ts (ticks) and jellyfinFieldMapping.ts are deleted.
  • +
  • +MediaItem/MediaKind/SortKey in the frontend come from bindings.ts.
  • +
  • +The From<JellyfinMediaDto> mapping is total and never panics on an unknown +item type (Rust test with a garbage type string).
  • +
  • +Behaviour is identical: same library/search/home rendering, same sorting, +same navigation, offline included.
  • +
  • +Both command returns and event payloads carry the new shape (no flicker).
  • +
  • +bun run check, bun run test, bun run check:boundary pass; +cargo fmt/cargo clippy/bun run test:rust pass; bindings.ts regenerated.
  • +
+

Testing

+

Rust (cargo test): the From<JellyfinMediaDto> for MediaItem mapping is the +critical surface —

+
    +
  • every known item_type → correct MediaKind (table test over all 20 values +found in the audit, incl. ChannelFolderItem, TvChannel, Composer);
  • +
  • unknown type string → safe fallback, no panic;
  • +
  • run_time_ticksduration_ms (10_000 divisor), boundary/None cases;
  • +
  • SortKey → Jellyfin field mapping (port jellyfinFieldMapping.ts's cases).
  • +
+

Frontend (vitest): update the many tests asserting .type/runTimeTicks; +they become .kind/durationMs. jellyfinFieldMapping/playbackUnits tests are +deleted with their modules. Add a compose/render test proving kind-based +branching matches the old type-based branching for a representative mix.

+

TRACES

+

Per CLAUDE.md: the domain type + mapping +UR-007, UR-008 | <new DR>; the tick/field hoist <new DR>; frontend migration +phases share the DRs of the capability each touches (don't invent per-file DRs).

+

Notes for the implementer

+
    +
  • This is the highest-collision change in the repo's history — it touches 36+ +frontend files and the core Rust types. A parallel Claude session in any media +file will conflict. Strongly prefer a dedicated worktree per phase, and +git diff before repairing anything (CLAUDE.md gotchas / project memory).
  • +
  • Phase 1 deliberately maps back to the old shape so it can land safely ahead of +the disruptive flip. Resist the urge to skip it.
  • +
  • IPC camelCase rules apply to the new enums/structs +(04-type-sync-and-threading.md): +#[serde(rename_all = "camelCase")]; tagged-enum tag convention; regenerate +bindings.ts, never hand-edit.
  • +
  • Reviewed against SPEC-REVIEW-CHECKLIST.md — the +Layer assignment table above is the load-bearing section.
  • +
+

Spec: Build provenance (git describe + build profile)

+

Status: Proposed — not started. src-tauri/build.rs still contains only +tauri_build::build(), and nothing reports a version over IPC. Note that +scripts/set-version.sh has since landed, which changes the "three hand-bumped +files" premise below: versions are now stamped from one place. +Requirements: ⚠️ the suggested id DR-093 has since been allocated to the +traceability coverage gate — allocate a fresh id (DR-215 or later) on +implementation. Build provenance surfaced in-app and in logs; no UR — this is a +diagnostic capability, not a user feature +UX spec: n/a — adds an About block to Settings; no new flow +Supersedes / revises:

+

Summary

+

Make every build say exactly what it is. Today a running JellyTau reports no +version at all — not in the UI, not in the logs — and the only version string in +the tree is the hand-maintained 0.2.0 duplicated across three files.

+

This adds a build.rs-generated provenance string (git describe + short SHA + +dirty flag + debug/release profile), exposes it over IPC, and renders it in a new +Settings › About block. It also removes one of the three hand-bumped version +files.

+

Motivation

+

The concrete problem: when a user reports "the equalizer does nothing on my +device" — which is a live risk for v0.2.0, whose Android audio settings are not +yet device-verified — there is currently no way to tell which build they are +running. Tag? Master? A local debug build from three weeks ago? The bug report +cannot distinguish them.

+

Two smaller irritations this also fixes:

+
    +
  • Debug builds masquerade as releases. 0.2.0 is 0.2.0 whether it came +from a tagged release or bun run tauri dev.
  • +
  • Three files carry the version. package.json, src-tauri/Cargo.toml and +src-tauri/tauri.conf.json must be bumped in lockstep; the release checklist +exists partly to stop them drifting.
  • +
+

What this deliberately does not do

+

The canonical version stays hand-bumped in Cargo.toml. Cargo requires a +literal semver string at manifest-parse time and cannot derive it from git. The +same is true of tauri.conf.json. Attempting to source the release version +from a tag trades a scripted, reviewable bump for a fragile build-time +dependency that breaks in exactly the environment we care most about (CI, in +Docker, from a shallow clone).

+

So: the release version is authored; the build provenance is derived. They +answer different questions — "what release is this?" versus "what commit is this +binary actually built from?" — and only the second benefits from git.

+

Layer assignment

+
+ + + + + +
Logic / responsibilityLayerWhy it belongs there
Capturing git describe / SHA / dirty state at compile timeRust (build.rs)Only the Rust build has a compile step that can shell out to git and bake the result into the binary. A frontend equivalent would report the dev server's state, not the shipped binary's.
Degrading to a sentinel when git is unavailableRust (build.rs)Build-environment concern. Must never fail the build — CI runs in Docker from a shallow clone.
Release version (0.2.0)Rust (Cargo.toml, authored)Domain fact about the product, not derivable from the environment.
Deciding what a build is (release / dev / dirty)RustDomain classification. The frontend must not infer "this is a dev build" from a string shape — it renders what it is told.
Rendering the About block, copy-to-clipboardFrontendPure presentation.
+
+

Borderline row: the release/dev/dirty classification could be done in the +frontend by pattern-matching the describe string. It goes to Rust because that is +a rule about what constitutes a release build, and it would have to change if +the tagging scheme changed — the litmus test in the template puts that in Rust. +Send a typed enum, not a string for the frontend to parse.

+

Design

+

build.rs

+
fn main() {
+    emit_build_provenance();
+    tauri_build::build()
+}
+
+fn emit_build_provenance() {
+    let describe = std::process::Command::new("git")
+        .args(["describe", "--tags", "--always", "--dirty"])
+        .output()
+        .ok()
+        .filter(|o| o.status.success())
+        .and_then(|o| String::from_utf8(o.stdout).ok())
+        .map(|s| s.trim().to_string())
+        .unwrap_or_else(|| "unknown".to_string());
+
+    println!("cargo:rustc-env=JELLYTAU_GIT_DESCRIBE={describe}");
+
+    // Rebuild when HEAD moves or a ref is written, so the string does not go
+    // stale across commits. Guarded: these paths do not exist in a git-less
+    // source tarball, and emitting rerun-if-changed for a missing path would
+    // force a rebuild every time.
+    for p in [".git/HEAD", ".git/refs"] {
+        if std::path::Path::new("../").join(p).exists() {
+            println!("cargo:rerun-if-changed=../{p}");
+        }
+    }
+}
+

🔴 build.rs must never fail the build. Every git call is +.ok()-swallowed; a missing git binary, a shallow clone, or a source tarball all +yield "unknown". A build that breaks because git is absent would be a worse bug +than the one this fixes.

+

Note the ../ prefixes: build.rs runs with CWD at src-tauri/, so the repo's +.git is one level up.

+

The provenance type

+
#![allow(unused)]
+fn main() {
+/// TRACES: DR-093
+#[derive(specta::Type, Serialize)]
+#[serde(rename_all = "camelCase")]
+pub struct BuildInfo {
+    /// Authored release version (Cargo.toml).
+    pub version: String,
+    /// `git describe --tags --always --dirty`, or "unknown".
+    pub git_describe: String,
+    /// What kind of build this is — classified in Rust, not inferred by the UI.
+    pub kind: BuildKind,
+}
+
+/// TRACES: DR-093
+#[derive(specta::Type, Serialize)]
+#[serde(rename_all = "camelCase")]
+pub enum BuildKind {
+    /// Built from a clean, exactly-tagged commit in release mode.
+    Release,
+    /// Release-mode build that is not on a clean tag (e.g. master, or dirty).
+    Untagged,
+    /// debug_assertions build.
+    Development,
+    /// Git state unavailable at build time.
+    Unknown,
+}
+}
+

Classification:

+
#![allow(unused)]
+fn main() {
+let kind = if cfg!(debug_assertions) {
+    BuildKind::Development
+} else if describe == "unknown" {
+    BuildKind::Unknown
+} else if describe.contains('-') {   // "v0.2.0-3-gcb79a37" or "...-dirty"
+    BuildKind::Untagged
+} else {
+    BuildKind::Release
+};
+}
+

Command

+
#![allow(unused)]
+fn main() {
+/// TRACES: DR-093
+#[tauri::command]
+#[specta::specta]
+pub fn get_build_info() -> BuildInfo { … }
+}
+

No parameters, so the camelCase param rule does not apply; the struct fields do +need #[serde(rename_all = "camelCase")] (above). Regenerate bindings.ts.

+

Also log the provenance once at startup, next to the existing init logging — +that is what makes a user-submitted log file self-identifying, which is most of +the value.

+

Settings › About

+

A new block at the bottom of src/routes/settings/+page.svelte, rendering +version, describe string, and a badge for non-release builds. One +copy-to-clipboard button that yields a paste-ready block for bug reports:

+
JellyTau 0.2.0 (v0.2.0-3-gcb79a37-dirty, development)
+linux x86_64
+
+

Platform/arch come from the existing Tauri APIs; do not shell out.

+

Removing one version file

+

tauri.conf.json's "version" field can be omitted, in which case Tauri falls +back to the Cargo version. That takes the bump from three files to two.

+

Verify before adopting: confirm the Android versionName/versionCode and +the NSIS installer version still resolve correctly with the field absent — +Android packaging in particular reads the Tauri config. If either regresses, +keep the field and drop this part; it is a convenience, not the point of the +spec.

+

Out of scope

+
    +
  • Deriving the release version from git tags (see Motivation).
  • +
  • A build-time timestamp. It defeats reproducible builds and adds little over +the commit SHA.
  • +
  • CI provenance/attestation, SBOM, signing.
  • +
  • Displaying the Jellyfin server version (separate concern, already available +from /System/Info).
  • +
+

Acceptance criteria

+
    +
  • +cargo build succeeds with git absent, from a shallow clone, and from a source tarball with no .git — yielding "unknown" in each case, never a build failure.
  • +
  • +A tagged clean release build reports BuildKind::Release; bun run tauri dev reports Development; a dirty tree reports Untagged (release mode) with -dirty in the describe string.
  • +
  • +The describe string changes after a new commit without a manual cargo clean (rerun-if-changed works).
  • +
  • +Provenance is logged once at startup.
  • +
  • +Settings › About renders version + describe + build-kind badge, with working copy-to-clipboard.
  • +
  • +🔴 CI checkouts that build a shippable artifact set fetch-depth: 0, or their artifacts are knowingly stamped unknown. Currently only publish-docs.yml sets it; build-release.yml has five checkouts and build-and-test.yml two, all of which would report unknown as-is.
  • +
  • +No toolchain installed in CI — git is already present in the builder image; nothing new is added.
  • +
  • +bun run check, bun run test, bun run check:boundary pass.
  • +
  • +cargo fmt clean, cargo clippy clean, bun run test:rust passes.
  • +
  • +bindings.ts regenerated.
  • +
  • +DR-093 allocated in requirements.md; new code carries // TRACES:.
  • +
+

Testing

+

Rust: the classification is pure and must be extracted from the command as +classify_build(describe: &str, debug: bool) -> BuildKind so it can be tested +directly. Cover: "v0.2.0"Release; "v0.2.0-3-gcb79a37"Untagged; +"v0.2.0-dirty"Untagged; "unknown"Unknown; debug = true → always +Development regardless of describe.

+

build.rs itself is not unit-testable. Verify its failure path manually by +building with PATH stripped of git, and from a git archive tarball — both +must succeed with "unknown".

+

Frontend: assert the About block renders each BuildKind correctly, and that +it renders the backend-supplied kind rather than re-deriving it from the string +(a test that passes a Release kind with a -dirty describe and asserts the +badge follows the kind would catch that regression).

+

TRACES

+
    +
  • build.rs provenance emission → // TRACES: | DR-093
  • +
  • BuildInfo / BuildKind / classify_build// TRACES: | DR-093
  • +
  • get_build_info command → // TRACES: | DR-093
  • +
  • Settings About block → // TRACES: | DR-093
  • +
  • classify_build tests → UT-BUILD-1
  • +
  • Allocate DR-093 in requirements.md ("Build provenance: git describe and +build profile surfaced in-app and in logs"). Next free DR at time of writing +is DR-093.
  • +
+

Notes for the implementer

+
    +
  • Do the build.rs + command + logging first; the About UI is the smaller half +and the logging alone delivers most of the diagnostic value.
  • +
  • The fetch-depth: 0 change is the easiest part to forget and the one that +makes CI artifacts useless if missed — it is why that acceptance box is +flagged. Weigh it per workflow: test-only jobs do not need it.
  • +
  • Do not add a build timestamp "while you are in there" — see Out of scope.
  • +
  • A parallel Claude session may be active — git diff before "repairing" +unexpected changes.
  • +
+

Build & Release Workflow

+

This document explains the automated build and release process for JellyTau.

+

Overview

+

The CI/CD pipeline automatically:

+
    +
  1. ✅ Runs all tests (frontend + Rust)
  2. +
  3. ✅ Builds Linux binaries (AppImage + DEB)
  4. +
  5. ✅ Builds Android APK and AAB
  6. +
  7. ✅ Creates releases with artifacts
  8. +
  9. ✅ Tags releases with version numbers
  10. +
+

Workflow Triggers

+

Automatic Trigger

+

When you push a version tag:

+
git tag v1.0.0
+git push origin v1.0.0
+
+

The workflow automatically:

+
    +
  1. Runs tests
  2. +
  3. Builds both platforms
  4. +
  5. Creates a GitHub release with artifacts
  6. +
  7. Tags it as release/prerelease based on version
  8. +
+

Manual Trigger

+

In Gitea Actions UI:

+
    +
  1. Go to Actions tab
  2. +
  3. Click Build & Release workflow
  4. +
  5. Click Run workflow
  6. +
  7. Optionally specify a version
  8. +
  9. Workflow runs without creating a release
  10. +
+

Version Tagging

+

Format

+

Version tags follow semantic versioning: v{MAJOR}.{MINOR}.{PATCH}

+

Examples:

+
    +
  • v1.0.0 - Release version
  • +
  • v1.0.0-rc1 - Release candidate (marked as prerelease)
  • +
  • v1.0.0-beta - Beta version (marked as prerelease)
  • +
  • v0.1.0-alpha - Alpha version (marked as prerelease)
  • +
+

Creating a Release

+
# Create and push a version tag
+git tag v1.0.0 -m "Release version 1.0.0"
+git push origin v1.0.0
+
+# Or create from main branch
+git tag -a v1.0.0 -m "Release version 1.0.0" main
+git push origin v1.0.0
+
+

Release Status

+

Versions containing rc, beta, or alpha are marked as prerelease:

+
git tag v1.0.0-rc1      # ⚠️ Prerelease
+git tag v1.0.0-beta     # ⚠️ Prerelease
+git tag v1.0.0-alpha    # ⚠️ Prerelease
+git tag v1.0.0          # ✅ Full release
+
+

Workflow Steps

+

1. Test Phase

+

Runs on all tags and manual triggers:

+
    +
  • Frontend tests (vitest)
  • +
  • Rust tests (cargo test)
  • +
  • TypeScript type checking
  • +
+

Failure: Stops workflow, no build/release

+

2. Build Linux Phase

+

Runs after tests pass:

+
    +
  • Installs system dependencies
  • +
  • Builds with Tauri
  • +
  • Generates: +
      +
    • AppImage - Universal Linux binary
    • +
    • DEB - Debian/Ubuntu package
    • +
    +
  • +
+

Output: artifacts/linux/

+

3. Build Android Phase

+

Runs in parallel with Linux build:

+
    +
  • Installs Android SDK/NDK
  • +
  • Configures Rust for Android targets
  • +
  • Builds with Tauri
  • +
  • Generates: +
      +
    • APK - Android app package (installable)
    • +
    • AAB - Android App Bundle (for Play Store)
    • +
    +
  • +
+

Output: artifacts/android/

+

4. Create Release Phase

+

Runs after both builds succeed (only on version tags):

+
    +
  • Prepares release notes
  • +
  • Downloads build artifacts
  • +
  • Creates GitHub/Gitea release
  • +
  • Uploads all artifacts
  • +
  • Tags as prerelease if applicable
  • +
+

Artifacts

+

Linux Artifacts

+

AppImage

+
    +
  • File: jellytau_*.AppImage
  • +
  • Size: ~100-150 MB
  • +
  • Use: Run directly on any Linux distro
  • +
  • Installation: +
    chmod +x JellyTau_*.AppImage
    +./JellyTau_*.AppImage
    +
    +
  • +
+

DEB Package

+
    +
  • File: JellyTau_*.deb
  • +
  • Size: ~80-120 MB
  • +
  • Use: Install on Debian/Ubuntu/similar
  • +
  • Installation: +
    sudo dpkg -i JellyTau_*.deb
    +jellytau
    +
    +
  • +
  • Note: the Debian package is named jelly-tau (Tauri kebab-cases +productName), while the command stays jellytau. The package declares +Replaces/Conflicts/Provides: jellytau, so upgrading from a release built +before the rename replaces it rather than installing a second copy.
  • +
+

RPM Package

+
    +
  • File: JellyTau-*.rpm
  • +
  • Use: Install on Fedora/openSUSE/similar
  • +
  • Installation: +
    sudo rpm -i JellyTau-*.rpm
    +jellytau
    +
    +
  • +
+

Android Artifacts

+

APK

+
    +
  • File: jellytau-release.apk
  • +
  • Size: ~60-100 MB
  • +
  • Use: Direct installation on Android devices
  • +
  • Installation: +
    adb install jellytau-release.apk
    +# Or sideload via file manager
    +
    +
  • +
+

AAB (Android App Bundle)

+
    +
  • File: jellytau-release.aab
  • +
  • Size: ~50-90 MB
  • +
  • Use: Upload to Google Play Console
  • +
  • Note: Cannot be installed directly; for Play Store distribution
  • +
+

Release Notes

+

Release notes are automatically generated with:

+
    +
  • Version number
  • +
  • Download links
  • +
  • Installation instructions
  • +
  • System requirements
  • +
  • Known issues link
  • +
  • Changelog reference
  • +
+

Build Matrix

+
+ + +
PlatformOSArchitectureFormat
LinuxAnyx86_64AppImage, DEB
Android8.0+arm64, armv7, x86_64APK, AAB
+
+

Troubleshooting

+

Build Fails During Test Phase

+
    +
  1. Check test output in Gitea Actions
  2. +
  3. Run tests locally: bun run test and bun run test:rust
  4. +
  5. Fix failing tests
  6. +
  7. Create new tag with fixed code
  8. +
+

Linux Build Fails

+
    +
  1. Check system dependencies installed
  2. +
  3. Verify Tauri configuration
  4. +
  5. Check cargo dependencies
  6. +
  7. Clear cache: Delete .cargo and target/ directories
  8. +
+

Android Build Fails

+
    +
  1. Check Android SDK/NDK setup
  2. +
  3. Verify Java 17 is installed
  4. +
  5. Check Rust Android targets: rustup target list
  6. +
  7. Clear cache and rebuild
  8. +
+

Release Not Created

+
    +
  1. Tag must start with v (e.g., v1.0.0)
  2. +
  3. Tests must pass
  4. +
  5. Both builds must succeed
  6. +
  7. Check workflow logs for errors
  8. +
+

GitHub Release vs Gitea

+

The workflow uses GitHub Actions SDK but is designed for Gitea. For Gitea-native releases:

+
    +
  1. Workflow creates artifacts
  2. +
  3. Artifacts are available in Actions artifacts
  4. +
  5. Download and manually create Gitea release, or
  6. +
  7. Set up Gitea API integration to auto-publish
  8. +
+

Customization

+

Change Release Notes Template

+

Edit .gitea/workflows/build-release.yml, section Prepare release notes:

+
- name: Prepare release notes
+  id: release_notes
+  run: |
+    # Add your custom release notes format here
+    echo "Custom notes" > release_notes.md
+
+

Add New Platforms

+

To add macOS or Windows builds:

+
    +
  1. Add new build-{platform} job
  2. +
  3. Set appropriate runs-on runner
  4. +
  5. Add platform-specific dependencies
  6. +
  7. Update artifact upload
  8. +
  9. Include in needs: [build-linux, build-android, build-{platform}]
  10. +
+

Change Build Targets

+

Modify Tauri configuration or add targets:

+
- name: Build for Linux
+  run: |
+    # Add target specification
+    bun run tauri build -- --target x86_64-unknown-linux-gnu
+
+

Monitoring

+

Check Status

+
    +
  1. Go to Actions tab in Gitea
  2. +
  3. View Build & Release workflow runs
  4. +
  5. Click specific run to see logs
  6. +
+

Notifications

+

Set up notifications for:

+
    +
  • Build failures
  • +
  • Release creation
  • +
  • Tag pushes
  • +
+

Performance

+

Build Times (Approximate)

+
    +
  • Test phase: 5-10 minutes
  • +
  • Linux build: 10-15 minutes
  • +
  • Android build: 15-20 minutes
  • +
  • Total: 30-45 minutes
  • +
+

Caching

+

Workflow caches:

+
    +
  • Rust dependencies (cargo)
  • +
  • Bun node_modules
  • +
  • Android SDK components
  • +
+

Security

+

Secrets

+

The workflow uses:

+
    +
  • GITHUB_TOKEN - Built-in, no setup needed
  • +
  • No credentials needed for Gitea
  • +
+

Verification

+

To verify build integrity:

+
    +
  1. Download artifacts
  2. +
  3. Verify signatures (if implemented)
  4. +
  5. Check file hashes
  6. +
  7. Test on target platform
  8. +
+

Best Practices

+

Versioning

+
    +
  1. Follow semantic versioning: v{MAJOR}.{MINOR}.{PATCH}
  2. +
  3. Tag releases in git
  4. +
  5. Update CHANGELOG.md before tagging
  6. +
  7. Include release notes in tag message
  8. +
+

Testing Before Release

+
# Local testing before release
+bun run test          # Frontend tests
+bun run test:rust     # Rust tests
+bun run check         # Type checking
+bun run tauri build   # Local build test
+
+

Documentation

+
    +
  1. Update CHANGELOG.md with changes
  2. +
  3. Update README.md with new features
  4. +
  5. Document breaking changes
  6. +
  7. Add migration guide if needed
  8. +
+

Example Release Workflow

+
# 1. Update version in relevant files (package.json, Cargo.toml, etc.)
+vim package.json
+vim src-tauri/tauri.conf.json
+
+# 2. Update CHANGELOG
+vim CHANGELOG.md
+
+# 3. Commit changes
+git add .
+git commit -m "Bump version to v1.0.0"
+
+# 4. Create annotated tag
+git tag -a v1.0.0 -m "Release version 1.0.0
+
+Features:
+- Feature 1
+- Feature 2
+
+Fixes:
+- Fix 1
+- Fix 2"
+
+# 5. Push tag to trigger workflow
+git push origin v1.0.0
+
+# 6. Monitor workflow in Gitea Actions
+# Wait for tests → Linux build → Android build → Release
+
+# 7. Download artifacts and test
+# Visit release page and verify downloads
+
+

References

+ +
+

Last Updated: 2026-02-13

+

Release Checklist

+

Quick reference for creating a JellyTau release.

+

Pre-Release (1-2 days before)

+
    +
  • +Code is on master/main branch
  • +
  • +All feature branches are merged and tested
  • +
  • +No failing tests locally: bun run test and bun run test:rust
  • +
  • +Requirement traceability check passes: bun run traces:json
  • +
  • +Type checking passes: bun run check
  • +
+

Update Version (Day before)

+
    +
  • +

    +Decide on version number (semantic versioning)

    +
      +
    • Example: v1.2.0 (major.minor.patch)
    • +
    • Example: v1.0.0-rc1 (release candidate)
    • +
    • Example: v1.0.0-beta (beta)
    • +
    +
  • +
  • +

    +Update version in files:

    +
    # Check these files for version numbers
    +cat package.json | grep version
    +cat src-tauri/tauri.conf.json | grep version
    +cat src-tauri/Cargo.toml | grep version
    +
    +
  • +
  • +

    +Update CHANGELOG.md:

    +
      +
    • +Add section for new version
    • +
    • +List all features added
    • +
    • +List all bugs fixed
    • +
    • +List breaking changes (if any)
    • +
    • +Add upgrade instructions (if needed)
    • +
    • +Format: Markdown with clear sections
    • +
    +
  • +
  • +

    +Update README.md:

    +
      +
    • +Update any version references
    • +
    • +Update feature list if applicable
    • +
    • +Update requirements if changed
    • +
    +
  • +
  • +

    +Commit changes:

    +
    git add .
    +git commit -m "Bump version to v1.2.0"
    +git push origin master
    +
    +
  • +
+

Final Check Before Release

+
    +
  • +

    +Run full test suite:

    +
    bun run test          # Frontend tests
    +bun run test:rust     # Rust tests
    +bun run check         # Type checking
    +
    +
  • +
  • +

    +Build locally (optional but recommended):

    +
    # Test Linux build
    +bun run tauri build
    +
    +# Test Android build
    +bun run tauri android build
    +
    +
  • +
  • +

    +No uncommitted changes:

    +
    git status  # Should show clean working directory
    +
    +
  • +
+

Release (Tag & Push)

+
# 1. Create annotated tag with release notes
+git tag -a v1.2.0 -m "Release version 1.2.0
+
+Features:
+- New feature 1
+- New feature 2
+
+Fixes:
+- Fixed bug 1
+- Fixed bug 2
+
+Improvements:
+- Performance improvement 1
+- UI improvement 1
+
+Breaking Changes:
+- None (or list if applicable)
+
+Migration:
+- No action required (or include steps if applicable)"
+
+# 2. Push tag to trigger workflow
+git push origin v1.2.0
+
+# 3. Monitor in Gitea Actions
+# Go to Actions tab and watch the workflow run
+
+

During Release (While Workflow Runs)

+
    +
  • +Watch workflow progress in Gitea Actions
  • +
  • +Monitor for test failures
  • +
  • +Monitor for build failures
  • +
  • +Check build logs if any step fails
  • +
+

After Release (Workflow Complete)

+
    +
  • +

    +Download artifacts from release page:

    +
      +
    • +JellyTau_*.AppImage (Linux)
    • +
    • +JellyTau_*.deb (Linux)
    • +
    • +JellyTau-*.rpm (Linux)
    • +
    • +jellytau-release.apk (Android)
    • +
    • +jellytau-release.aab (Android)
    • +
    +
  • +
  • +

    +Basic testing of artifacts:

    +
      +
    • +Linux AppImage runs
    • +
    • +Linux DEB installs and runs
    • +
    • +Android APK installs (via adb or sideload)
    • +
    +
  • +
  • +

    +Verify release page:

    +
      +
    • +Title is correct: "JellyTau vX.Y.Z"
    • +
    • +Release notes are formatted correctly
    • +
    • +All artifacts are uploaded
    • +
    • +Release type is correct (prerelease vs release)
    • +
    +
  • +
  • +

    +Verify integrity metadata (DR-216):

    +
      +
    • +SHA256SUMS is present, and sha256sum -c SHA256SUMS passes in the +directory you downloaded into
    • +
    • +SBOM files are present (*.cdx.json, frontend-dependencies.txt)
    • +
    +
  • +
  • +

    +Verify the update path (DR-217) — this is the step that catches a broken +updater before users hit it, because a bad manifest fails only on their +machine:

    +
      +
    • +latest.json is live and names this version: +curl -s https://gitea.tourolle.paris/dtourolle/jellytau/raw/branch/updater/latest.json | jq .version
    • +
    • +Both platform entries carry a non-empty signature
    • +
    • +The .AppImage.tar.gz, its .sig, and the NSIS .sig are among the +release assets — the manifest points at them
    • +
    • +Install the previous release, launch it, and use Settings → Updates: +it should offer this version, install it, and relaunch
    • +
    • +On Android, Settings → Updates offers the releases page rather than an +install button (the updater plugin is not compiled for that target)
    • +
    +
  • +
  • +

    +Announce release:

    +
      +
    • +Post to relevant channels/communities
    • +
    • +Update website/docs
    • +
    • +Tag contributors if applicable
    • +
    +
  • +
+

Rollback (If Issues Found)

+

If critical issues are found after release:

+
# Option 1: Delete tag locally and remotely
+git tag -d v1.2.0
+git push origin :refs/tags/v1.2.0
+
+# Option 2: Mark as prerelease in release page
+# Then plan immediate patch release (v1.2.1)
+
+# Option 3: Create hotfix branch and release v1.2.1
+git checkout -b hotfix/v1.2.1
+# Fix issues
+git commit -m "Fix critical issue"
+git tag v1.2.1
+git push origin hotfix/v1.2.1 v1.2.1
+
+

Version Examples

+

Major Release

+
v2.0.0 - Major version bump
+- Significant new features
+- Breaking API changes
+- Major UI redesign
+
+

Minor Release

+
v1.2.0 - Feature release
+- New features
+- Backward compatible
+- Bug fixes
+
+

Patch Release

+
v1.1.1 - Bug fix/patch
+- Bug fixes only
+- No new features
+- Backward compatible
+
+

Pre-releases

+
v1.2.0-alpha    - Early development
+v1.2.0-beta     - Late development, feature complete
+v1.2.0-rc1      - Release candidate, minimal fixes only
+
+

File Locations

+

Key files for versioning:

+
    +
  • package.json - Frontend version
  • +
  • src-tauri/tauri.conf.json - Tauri config version
  • +
  • src-tauri/Cargo.toml - Rust version
  • +
  • CHANGELOG.md - Release history
  • +
  • README.md - Project documentation
  • +
+

Troubleshooting

+

Tests Fail Before Release

+
    +
  1. Don't push tag yet
  2. +
  3. Fix failing tests locally
  4. +
  5. Push fixes to master
  6. +
  7. Re-run test suite
  8. +
  9. Then tag and push
  10. +
+

Build Fails in CI

+
    +
  1. Check detailed logs in Gitea Actions
  2. +
  3. Fix issue locally
  4. +
  5. Delete tag: git tag -d v1.2.0 && git push origin :refs/tags/v1.2.0
  6. +
  7. Push fix to master
  8. +
  9. Create new tag with fix
  10. +
+

Release Already Exists

+
    +
  1. If workflow runs twice, artifacts may conflict
  2. +
  3. Check release page
  4. +
  5. If duplicates exist, delete and re-release
  6. +
+

Artifacts Missing

+
    +
  1. Check build logs for errors
  2. +
  3. Verify platform-specific dependencies
  4. +
  5. Delete tag and retry after fixes
  6. +
+

Performance Tips

+
    +
  • Tests: ~5-10 minutes
  • +
  • Linux build: ~10-15 minutes
  • +
  • Android build: ~15-20 minutes
  • +
  • Total release time: ~30-45 minutes
  • +
+

First build takes longer (cache warming). Subsequent releases are faster due to caching.

+

Template: Release Notes

+
## 🎉 JellyTau vX.Y.Z
+
+### ✨ Features
+- New feature 1
+- New feature 2
+
+### 🐛 Bug Fixes
+- Fixed issue #123
+- Fixed issue #456
+
+### 🚀 Performance
+- Improvement 1
+- Improvement 2
+
+### 📱 Downloads
+- [Linux AppImage](#) - Run on any Linux
+- [Linux DEB](#) - Install on Ubuntu/Debian
+- [Android APK](#) - Install on Android devices
+- [Android AAB](#) - For Google Play Store
+
+### 📋 Requirements
+**Linux:** 64-bit, GLIBC 2.29+
+**Android:** 8.0+
+
+### 🔗 Links
+- [Changelog](https://gitea.tourolle.paris/dtourolle/jellytau/src/branch/master/CHANGELOG.md)
+- [Issues](https://gitea.tourolle.paris/dtourolle/jellytau/issues)
+
+---
+Built with Tauri, SvelteKit, and Rust 🦀
+
+

Quick Commands

+
# View existing tags
+git tag -l
+
+# Create release locally (dry run)
+git tag -a v1.2.0 -m "Release v1.2.0" --dry-run
+
+# List commits since last tag
+git log v1.1.0..HEAD --oneline
+
+# Show tag details
+git show v1.2.0
+
+# Rename tag (if needed)
+git tag v1.2.0_old v1.2.0
+git tag -d v1.2.0
+git push origin v1.2.0_old v1.2.0
+
+# Delete tag locally and remotely
+git tag -d v1.2.0
+git push origin :refs/tags/v1.2.0
+
+
+

Tips:

+
    +
  • ✅ Always test locally before release
  • +
  • ✅ Use semantic versioning consistently
  • +
  • ✅ Document changes in CHANGELOG
  • +
  • ✅ Wait for full workflow completion
  • +
  • ✅ Test release artifacts before announcing
  • +
+

Remember: A good release is a tested release! 🚀

+

Desktop packaging (Linux, Arch, Windows)

+

How to produce distributable desktop packages for JellyTau. All three flows can +run in Docker so no host toolchain setup is required. Outputs land in ./dist.

+

One builder image (shared with CI)

+

The deb/rpm and Windows-cross flows build on the unified registry builder +(../Dockerfile.builder → +gitea.tourolle.paris/dtourolle/jellytau-builder), the same image CI uses. It +carries every packaging tool: Android SDK/NDK, rpm/file (Linux bundler), +cargo-xwin + lld + llvm + nsis + the x86_64-pc-windows-msvc rust target +(Windows). There is one dependency source of truth — no per-stage tool +installs.

+

The desktop stages in ../Dockerfile are thin FROM ${BUILDER_IMAGE} environments; the actual build runs at container-run time on +your bind-mounted source (like the dev service), so source edits need no image +rebuild.

+

If you changed Dockerfile.builder (e.g. added a tool), rebuild and push it +first, or the packaging flows use the stale registry image:

+
scripts/build-builder-image.sh           # build + push :latest to the registry
+# ...or iterate locally without pushing:
+docker build -f Dockerfile.builder -t jellytau-builder:latest .
+BUILDER_IMAGE=jellytau-builder:latest bun run docker:build:windows
+
+

Arch uses a separate archlinux image (../Dockerfile.arch) +because makepkg is Arch-specific — it is not part of the unified builder.

+
+ + + +
TargetFormatDocker commandFunctional?
Debian/Ubuntu, Fedora.deb, .rpmbun run docker:build:linux✅ yes
Arch Linux.pkg.tar.zstbun run docker:build:arch✅ yes
WindowsNSIS installer + .exebun run docker:build:windows✅ yes (unsigned)
+
+

Linux: deb + rpm

+

Tauri's bundler produces these natively. The build runs on the existing Ubuntu +builder image (../Dockerfile, desktop-linux-build stage):

+
bun run docker:build:linux            # deb + rpm -> ./dist
+# or, on a host with the Tauri Linux deps installed:
+BUNDLES="deb,rpm" scripts/build-desktop-linux.sh
+
+

Runtime dependency: the app links libmpv (audio) and WebKitGTK (webview + HTML5 +transcoded video). The deb/rpm declare these.

+
+

Note: appimage is also a valid Tauri target if you want a portable bundle — +add it to BUNDLES.

+
+

Arch Linux: pacman package

+

Tauri has no pacman bundle target (as of tauri-cli 2.9.x — valid targets +are deb/rpm/appimage/msi/nsis/app/dmg). So we ship a hand-written PKGBUILD in +../packaging/arch/PKGBUILD and build it with +makepkg on an Arch base image (../Dockerfile.arch):

+
bun run docker:build:arch             # .pkg.tar.zst -> ./dist
+
+

The PKGBUILD is AUR-ready: swap its source=() for a release tarball/VCS URL to +publish. Runtime deps: webkit2gtk-4.1, mpv, gtk3, libayatana-appindicator.

+

makepkg refuses to run as root, so the Docker stage builds as a non-root +builder user. Because the image COPYs the source at build time, the +arch-build compose service does not bind-mount the repo — rebuild the image +to pick up source changes.

+

Windows: NSIS installer cross-compiled from Linux

+

Produces a working (unsigned) NSIS installer + .exe via the official Tauri +cross-compile path — the x86_64-pc-windows-msvc target driven by cargo-xwin. +Video plays via WebView2 and audio via the webview <audio> backend. See +build-windows.md for the full explanation.

+
bun run docker:build:windows                     # NSIS installer + .exe -> ./dist
+WIN_BUNDLES=none bun run docker:build:windows     # exe only, skip bundling
+
+

The Docker windows-cross stage is a thin layer over the builder, which carries +cargo-xwin + lld + llvm + nsis + the x86_64-pc-windows-msvc target. +Cross-compilation is Tauri's "last resort" path (less tested than building on +Windows); a windows-latest CI job is the fallback if it misbehaves.

+

Windows build

+

JellyTau targets Linux and Android primarily, but a working Windows build — +including an NSIS installer cross-compiled from Linux — is produced by the +Docker tooling. It is not yet a first-class release target (no code signing / CI +job / SMTC lockscreen), but it runs and plays media.

+

How playback works on Windows

+
    +
  • Video — renders through the webview HTML5 <video> element (hls.js) on +every platform; on Windows that is WebView2 (Chromium/Edge), which plays HLS + +h264 fine. No Windows-specific code.
  • +
  • Audio-only (music) — the native audio backends are libmpv (Linux) and +ExoPlayer (Android); neither exists on Windows. Instead +create_player_backend() in ../src-tauri/src/lib.rs +uses WebviewAudioBackend on non-Linux/non-Android targets: it hands the stream +URL to a webview <audio> element (see +../src/lib/services/webviewAudio.ts), +which reports state back through the same player_report_* round-trip the video +path uses. Pure Rust + Tauri events.
  • +
+

Cross-compiling from Linux (MSVC + cargo-xwin)

+

We use the official Tauri cross-compile path: +the MSVC target (x86_64-pc-windows-msvc) driven by +cargo-xwin, which downloads the MSVC +CRT / Windows SDK headers and links with lld. MSVC is the target Tauri +officially supports for Windows (mingw/GNU is not), and — unlike GNU — it lets the +Tauri CLI bundle the NSIS installer from a Linux host.

+
+

Why not mingw/GNU? The GNU target does link a valid .exe, but the Tauri CLI +gates --bundles by the host OS unless it recognizes a real Windows build. +--runner cargo-xwin --target x86_64-pc-windows-msvc is what flips it into +Windows mode and enables the nsis/msi bundlers on Linux.

+
+

The builder image (../Dockerfile.builder) bakes in the +whole toolchain: the x86_64-pc-windows-msvc rust target, cargo-xwin, lld, +llvm, and nsis.

+
bun run docker:build:windows            # NSIS installer + .exe -> ./dist
+WIN_BUNDLES=none bun run docker:build:windows   # exe only, skip bundling
+
+

Or directly on a host that has the toolchain:

+
scripts/build-windows-cross.sh                    # nsis installer + exe
+WIN_BUNDLES=none scripts/build-windows-cross.sh   # exe only
+
+

Under the hood the build runs:

+
tauri build --runner cargo-xwin --target x86_64-pc-windows-msvc --bundles nsis
+
+

Outputs:

+
    +
  • .exesrc-tauri/target/x86_64-pc-windows-msvc/release/jellytau.exe
  • +
  • NSIS installer — .../release/bundle/nsis/*-setup.exe
  • +
+

(both copied to ./dist when OUTPUT_DIR is set).

+

Caveats

+
    +
  • Cross-compilation is a last resort per Tauri's own docs — it's less tested +than building on Windows. If it misbehaves, a windows-latest CI job or a +Windows VM building natively (tauri build --bundles nsis) is the fallback.
  • +
  • Code signing is not wired up — the installer is unsigned, so Windows +SmartScreen will warn on first run.
  • +
+

Outstanding for a first-class Windows release

+
    +
  1. Gapless/crossfade + SMTC (lockscreen) — currently no-ops in the webview audio +path.
  2. +
  3. Downloaded (Local source) file playback needs convertFileSrc on the +frontend; streaming works today.
  4. +
  5. Code signing + a Windows packaging CI job.
  6. +
+

Defect windows — which bugs were present when

+

For each fixed defect, the releases it was actually present in. Companion to +CHANGELOG.md, which says what changed; this says how long each +fault had been shipping before it did.

+

"Present since" is the first release containing the defective code, not the +first release where a user could hit it — those differ, sometimes by months, and +the gap is called out where it matters. "How dated" records the evidence, so a +row can be re-checked or disputed:

+
+ + + +
MethodMeaning
pickaxegit log -S<token> on the defective token — the commit that introduced the exact string, then the earliest tag containing it. Strongest evidence.
featureThe defect is inseparable from a feature that landed whole (bad rung in a new algorithm, missing caller in new plumbing), dated to that feature's release.
absenceThe fix adds something that was never there. Dated to when the surrounding code was built, since there is no introducing commit to find. Weakest — treat as "no later than".
+
+

Present since the first release

+

Nine defects date to the initial proof of concept (v0.0.1, 2026-06-23) and shipped +for between two weeks and seven weeks short of two months before anyone hit them. +That is the dominant pattern here: not regressions, but original assumptions that +went unexercised until a later feature leaned on them.

+
+ + + + + + + + + + + + + +
DefectPresent sinceFixed inShipped broken forHow dated
AudioStreamIndex=0 pinned the video stream as the audio track (DR-140)v0.0.1v0.4.6~7 weekspickaxe
Download URL spelled videoBitrate, which Jellyfin does not bind (DR-123)v0.0.1v0.5.1~7 weekspickaxe
pause_download / resume_download were no-ops (DR-168)v0.0.1v0.5.3~7.5 weekspickaxe
.part sidecar named by with_extension, so no cleanup path matched it (DR-169)v0.0.1v0.5.3~7.5 weekspickaxe
Range sent on every retry regardless of the response (DR-170)v0.0.1v0.5.3~7.5 weekspickaxe
/Items/Latest requested with the default GroupItems=falsev0.0.1v0.5.1~7 weekspickaxe
SubtitleStreamIndex omitted from PlaybackInfo, letting the server burn in (DR-176)v0.0.1v0.5.5~8 weekspickaxe
No PlaySessionId, and one hardcoded DeviceId, on every stream URL (DR-177)v0.0.1v0.5.5~8 weekspickaxe
download_item never recorded media_type; NULL read as 'audio' (DR-135)v0.0.1v0.4.6~7 weekspickaxe
download_album read its track list from the local cache (DR-173)v0.0.1v0.5.5~8 weekspickaxe
Device profile carried no MaxAudioChannels (DR-141)v0.0.1v0.4.6~7 weeksabsence
Streaming ceiling fixed at 20 Mbps with no way to lower it (UR-074)v0.0.1v0.5.3 (as a feature)~7.5 weekspickaxe
Hero banner auto-rotation never restarted after a manual swipe (DR-038)v0.0.1v0.9.1~8.5 weekspickaxe
+
+

Why they took so long to surface

+

Four of these were latent until a later feature exercised them, which is why +the fix lands so far from the cause:

+
    +
  • The videoBitrate casing was harmless while every download was original. It +became visible only once a quality picker existed to select against — and then +produced no error, just a full-size file, because Jellyfin discards an unbound +query key silently.
  • +
  • The unconditional Range header was inert for the same reason: original is +the one rung served with a Content-Length and real byte-range support. It +started corrupting files in v0.5.1, the moment the casing fix made +transcoded downloads actually transcode. So the code dates to v0.0.1 and the +corruption to v0.5.1 — a one-release window for the visible symptom.
  • +
  • The missing PlaySessionId only bites when a stream is re-opened for the same +item. Nothing re-opened one until quality switching, transcoded seek and +audio-track switching existed.
  • +
  • The omitted SubtitleStreamIndex only bites on sources whose own default +subtitle track is image-based, since that is what forces the server from +sidecar to burn-in.
  • +
+

Two were masked by soft failure: the asset protocol being disabled (DR-134) +was hidden by the thumbnail cache falling back to the server copy whenever the +server was reachable, and AudioStreamIndex=0 was hidden by servers that +silently correct an out-of-range index — which is exactly why it was reported as +"some videos have no audio" rather than as a bug in the client.

+

Introduced by a feature, fixed later

+
+ + + + + + + + + + + + + +
DefectPresent sinceFixed inHow dated
Native-path resume position never applied (both layers assumed the other seeked)v0.0.9/v0.0.10v0.5.1feature (PlayerAdapter contract)
get_downloaded_items matched "this library exists" rather than constraining the item to it (DR-167)v0.0.17v0.5.3feature (browsable downloaded library)
SCOPE_ITEM_TYPES — the frontend/backend boundary leak (DR-063)v0.0.17v0.2.1pickaxe
check:boundary anchored to the query site, blind to a named const (DR-094)v0.0.17v0.2.1feature (tripwire landed with the leak it missed)
Coverage gate divided by hardcoded denominators, reporting 158% (DR-093)v0.0.1v0.2.1pickaxe
Tap deferral raced the WebView's synthesized click (DR-092 → DR-098)v0.1.5v0.2.7feature (the deferral itself)
Transport for webview media decided from el.paused in the DOM (DR-097)v0.0.9/v0.0.10v0.2.7feature (Html5PlayerAdapter)
pick_current_episode rung 3 returned the first gap, not the furthest watchedv0.3.0v0.5.1feature
mirror_user_data mirrored is_favorite alone and returned early (DR-155)v0.4.0v0.5.1pickaxe
Stop-report path never fed the sync queue that existed for it (DR-154)v0.4.6v0.5.1feature (queue + drain landed with no producer)
Background-audio base applied in two display-only places (DR-159)v0.2.9v0.5.3pickaxe
Positions reported as 0 before the first tick, and always 0 for webview media (DR-178/179/180)v0.5.3v0.5.5feature (DR-159's tick boundary)
Length-less handoff transcode left to the player's own load-error retry, which can only restart it (DR-203)v0.0.16v0.8.2feature (the handoff's progressive-mp3 choice)
+
+

Three of these are worth separating out, because the defect is not a mistake in +the code so much as plumbing that was built and never connected:

+
    +
  • repository_get_next_up_episodes accepted a series_id from the day it was +written, and no caller passed one until v0.3.0.
  • +
  • The sync queue and its drain were built, tested and running in v0.4.6 with +neither of its two would-be producers ever called.
  • +
  • Both halves of the watched-state backend existed with no caller before v0.5.3.
  • +
+

An automated check cannot see any of these — the code is present, tested and +reachable in principle. Only tracing a requirement to a call site catches it.

+

Short windows (one release or less)

+
+ + + + +
DefectPresent sinceFixed inNote
experimentalNativeVideo defaulted on, shipping audio with a blank screen (DR-161 → DR-172)v0.5.3v0.5.4One release. The decode path was fine; the compositing step never ran.
Webview-shaped audio profile insufficient — server ignores a profile's audio codec (DR-149)v0.4.7v0.4.8The v0.4.7 fix for DR-148 was necessary and not sufficient.
Android versionCode floor went stale (minor*100 yielding less than the 5002 already in the field)v0.5.0v0.5.3Caught before a broken APK shipped; no released build was un-installable.
Subtitle sidecar work reverted by a commit assembled from a stale treev0.5.5v0.5.5Never released broken — both commits are in v0.5.5.
+
+

Fixed twice / never actually broken

+
    +
  • +

    Autoplay time reset (v0.0.2). Two commit objects carry this identical +change: dcf08f30 (merged via Gitea PR #3, tagged v0.0.2) and fa7cb6e9 (the +local original). Both have the same parent 674c8e5c and the same diff. A merge +chain pulled fa7cb6e9 and its follow-up 1e599627 into master's history +during v0.5.5, so git log v0.5.4..v0.5.5 lists an autoplay fix that changed no +file in that release — nextEpisodeService.ts is byte-identical across the tag +boundary. The fix shipped in v0.0.2 and has not regressed.

    +

    This is the one case where reading the changelog off git log subjects would +have produced a false entry, and it is a good argument for the project's +practice of deriving release notes from TRACES rather than commit subjects.

    +
  • +
+

Recurring shapes

+

Four causes account for most of the table:

+
    +
  1. An omitted parameter is not a neutral default. SubtitleStreamIndex, +AudioStreamIndex, GroupItems and MaxAudioChannels all had a server-side +default that was actively wrong, and in three of the four the server's choice +was more expensive than the one intended — burn-in forcing a full re-encode +being the extreme case.
  2. +
  3. Silent binding failures. videoBitRate produced no error, no warning and a +plausible-looking file. So did an unbound Range, and so did the coverage gate +dividing by a stale denominator.
  4. +
  5. Two layers each assuming the other acts. Native resume (adapter recorded +the position, backend never seeked), end-of-playback dispatch (two paths, one +unreachable), and the surface/attach split in v0.5.0's native video.
  6. +
  7. A guard keyed on state that moves. The tap deferral keyed suppression on a +timer handle the callback had already cleared; the HTML5 toggle keyed +play-vs-pause on el.paused, which flips while buffering.
  8. +
+

Reproducing this

+

The pickaxe rows can be re-derived directly:

+
git log --oneline --reverse -S'<defective token>' -- src-tauri/src   # introducing commit
+git tag --contains <sha> | sort -V | head -1                          # first release with it
+
+

Blaming the lines a fix removed (git blame at the fix's parent) is faster to run +across many commits but was not used for the rows above: it reliably lands on +whichever commit last touched the adjacent lines, which is usually not the commit +that introduced the defect. It was used only to shortlist candidates.

+

Docker & CI/CD Setup for JellyTau

+

This document explains how to use the Docker configuration and Gitea Act CI/CD pipeline for building and testing JellyTau.

+

Overview

+

The setup includes:

+
    +
  • Dockerfile.builder: Pre-built image with all dependencies (push to your registry)
  • +
  • Dockerfile: Multi-stage build for local testing and building
  • +
  • docker-compose.yml: Orchestration for local development and testing
  • +
  • .gitea/workflows/build-and-test.yml: Automated CI/CD pipeline using pre-built builder image
  • +
+

Quick Start

+

For CI/CD (Gitea Actions):

+
    +
  1. Build and push builder image (see build-builder-image.md)
  2. +
  3. Push to master branch - workflow runs automatically
  4. +
  5. Check Actions tab for results and APK artifacts
  6. +
+

For Local Testing:

+
docker-compose run test       # Run tests
+docker-compose run android-build  # Build APK
+docker-compose run dev        # Interactive shell
+
+

Docker Usage

+

Prerequisites

+
    +
  • Docker Engine 20.10+
  • +
  • Docker Compose 2.0+ (if using docker-compose)
  • +
  • At least 10GB free disk space (for Android SDK and build artifacts)
  • +
+

Building the Docker Image

+
# Build the complete image
+docker build -t jellytau:latest .
+
+# Build specific target
+docker build -t jellytau:test --target test .
+docker build -t jellytau:android --target android-build .
+
+

Using Docker Compose

+

Run Tests Only

+
docker-compose run test
+
+

This will:

+
    +
  1. Install all dependencies
  2. +
  3. Run frontend tests (Vitest)
  4. +
  5. Run Rust backend tests
  6. +
  7. Report results
  8. +
+

Build Android APK

+
docker-compose run android-build
+
+

This will:

+
    +
  1. Run tests first (depends on test service)
  2. +
  3. If tests pass, build the Android APK
  4. +
  5. Output APK files to src-tauri/gen/android/app/build/outputs/apk/
  6. +
+

Interactive Development

+
docker-compose run dev
+
+

This starts an interactive shell with all development tools available. From here you can:

+
bun install
+bun run build
+bun test
+bun run tauri android build --apk true
+
+

Run All Services in Sequence

+
docker-compose up --abort-on-container-exit
+
+

Extracting Build Artifacts

+

After a successful build, APK files are located in:

+
src-tauri/gen/android/app/build/outputs/apk/
+
+

Copy to your host machine:

+
docker cp jellytau-android-build:/app/src-tauri/gen/android/app/build/outputs/apk ./apk-output
+
+

Gitea Act CI/CD Pipeline

+

The .gitea/workflows/build-and-test.yml workflow automates:

+

Single Job: Runs on every push to master and PRs

+
    +
  • Uses pre-built builder image (no setup time)
  • +
  • Installs project dependencies
  • +
  • Runs frontend tests (Vitest)
  • +
  • Runs Rust backend tests
  • +
  • Builds the frontend
  • +
  • Builds the Android APK
  • +
  • Uploads APK as artifact (30-day retention)
  • +
+

The workflow skips markdown files to avoid unnecessary builds.

+

Workflow Triggers

+

The workflow runs on:

+
    +
  • Push to master or main branches
  • +
  • Pull requests to master or main branches
  • +
  • Can be extended with: workflow_dispatch for manual triggers
  • +
+

Setting Up the Builder Image

+

Before using the CI/CD pipeline, you must build and push the builder image:

+
# Build the image
+docker build -f Dockerfile.builder -t jellytau-builder:latest .
+
+# Tag for your registry
+docker tag jellytau-builder:latest gitea.tourolle.paris/dtourolle/jellytau-builder:latest
+
+# Push to registry
+docker push gitea.tourolle.paris/dtourolle/jellytau-builder:latest
+
+

See build-builder-image.md for detailed instructions.

+

Setting Up Gitea Act

+
    +
  1. +

    Ensure builder image is pushed (see above)

    +
  2. +
  3. +

    Push to Gitea repository: +The workflow will automatically trigger on push to master or pull requests

    +
  4. +
  5. +

    View workflow runs in Gitea UI:

    +
      +
    • Navigate to your repository
    • +
    • Go to Actions tab
    • +
    • Click on workflow runs to see logs
    • +
    +
  6. +
  7. +

    Test locally (optional):

    +
    # Install act if needed
    +curl https://gitea.com/actions/setup-act/releases/download/v0.25.0/act-0.25.0-linux-x86_64.tar.gz | tar xz
    +
    +# Run locally (requires builder image to be available)
    +./act push --file .gitea/workflows/build-and-test.yml
    +
    +
  8. +
+

Customizing the Workflow

+

Modify Build Triggers

+

Edit .gitea/workflows/build-and-test.yml to change when builds run:

+
on:
+  push:
+    branches:
+      - master
+      - develop  # Add more branches
+    paths:
+      - 'src/**'  # Only run if src/ changes
+      - 'src-tauri/**'  # Only run if Rust code changes
+
+

Add Notifications

+

Add Slack, Discord, or email notifications on build completion:

+
- name: Notify on success
+  if: success()
+  run: |
+    curl -X POST https://slack-webhook-url...
+
+

Customize APK Upload

+

Modify artifact retention or add to cloud storage:

+
- name: Upload APK to S3
+  uses: actions/s3-sync@v1
+  with:
+    aws_access_key_id: ${{ secrets.AWS_ACCESS_KEY }}
+    aws_secret_access_key: ${{ secrets.AWS_SECRET_KEY }}
+    aws_bucket: my-apk-bucket
+    source_dir: src-tauri/gen/android/app/build/outputs/apk/
+
+

Environment Setup in CI

+

Secret Variables

+

To use secrets in the workflow, set them in Gitea:

+
    +
  1. Go to Repository Settings → Secrets
  2. +
  3. Add secrets like: +
      +
    • AWS_ACCESS_KEY for S3 uploads
    • +
    • SLACK_WEBHOOK_URL for notifications
    • +
    • GITHUB_TOKEN for releases (pre-configured)
    • +
    +
  4. +
+

Troubleshooting

+

Out of Memory During Build

+

Android builds are memory-intensive. If you get OOM errors:

+
# Limit memory in docker-compose
+services:
+  android-build:
+    deploy:
+      resources:
+        limits:
+          memory: 6G
+
+

Or increase Docker's memory allocation in Docker Desktop settings.

+

Android SDK Download Timeout

+

If downloads timeout, increase timeout or download manually:

+
# In container, with longer timeout
+timeout 600 sdkmanager --sdk_root=$ANDROID_HOME ...
+
+

Rust Compilation Errors

+

Make sure Rust is updated:

+
rustup update
+rustup target add aarch64-linux-android armv7-linux-androideabi x86_64-linux-android
+
+

Cache Issues

+

Clear Docker cache and rebuild:

+
docker-compose down -v  # Remove volumes
+docker system prune      # Clean up dangling images
+docker-compose up --build
+
+

Performance Tips

+
    +
  1. Cache Reuse: Both Docker and Gitea Act cache dependencies across runs
  2. +
  3. Parallel Steps: The workflow runs frontend and Rust tests in series; consider parallelizing for faster CI
  4. +
  5. Incremental Builds: Rust and Node caches persist between runs
  6. +
  7. Docker Buildkit: Enable for faster builds: +
    DOCKER_BUILDKIT=1 docker build .
    +
    +
  8. +
+

Security Considerations

+
    +
  • Dockerfile uses ubuntu:24.04 base image from official Docker Hub
  • +
  • NDK is downloaded from official Google servers (verified via HTTPS)
  • +
  • No credentials are stored in the Dockerfile
  • +
  • Use Gitea Secrets for sensitive values (API keys, tokens, etc.)
  • +
  • Lock dependency versions in Cargo.toml and package.json
  • +
+

Next Steps

+
    +
  1. Test locally with docker-compose up
  2. +
  3. Push to your Gitea repository
  4. +
  5. Monitor workflow runs in the Actions tab
  6. +
  7. Configure secrets in repository settings for production builds
  8. +
  9. Set up artifact retention policies (currently 30 days)
  10. +
+

References

+ +

Building and Pushing the JellyTau Builder Image

+

This document explains how to create and push the pre-built builder Docker image to your registry for use in Gitea Act CI/CD.

+

Prerequisites

+
    +
  • Docker installed and running
  • +
  • Access to your Docker registry (e.g., gitea.tourolle.paris)
  • +
  • Docker registry credentials configured (docker login)
  • +
+

Building the Builder Image

+

Step 1: Build the Image Locally

+
# From the project root
+docker build -f Dockerfile.builder -t jellytau-builder:latest .
+
+

This creates a local image with:

+
    +
  • All system dependencies
  • +
  • Rust with Android targets
  • +
  • Android SDK and NDK
  • +
  • Node.js and Bun
  • +
  • All build tools pre-installed
  • +
+

Step 2: Tag for Your Registry

+

Replace gitea.tourolle.paris/dtourolle with your actual registry path:

+
docker tag jellytau-builder:latest gitea.tourolle.paris/dtourolle/jellytau-builder:latest
+
+

Step 3: Login to Your Registry

+

If not already logged in:

+
docker login gitea.tourolle.paris
+
+

Step 4: Push to Registry

+
docker push gitea.tourolle.paris/dtourolle/jellytau-builder:latest
+
+

Complete One-Liner

+
docker build -f Dockerfile.builder -t jellytau-builder:latest . && \
+docker tag jellytau-builder:latest gitea.tourolle.paris/dtourolle/jellytau-builder:latest && \
+docker push gitea.tourolle.paris/dtourolle/jellytau-builder:latest
+
+

Verifying the Build

+

Check that the image was pushed successfully:

+
# List images in your registry (depends on registry API support)
+docker search gitea.tourolle.paris/dtourolle/jellytau-builder
+
+# Or pull and test locally
+docker pull gitea.tourolle.paris/dtourolle/jellytau-builder:latest
+docker run -it gitea.tourolle.paris/dtourolle/jellytau-builder:latest bun --version
+
+

Using in CI/CD

+

The workflow at .gitea/workflows/build-and-test.yml automatically uses:

+
container:
+  image: gitea.tourolle.paris/dtourolle/jellytau-builder:latest
+
+

Once pushed, your CI/CD pipeline will use this pre-built image instead of installing everything during the build, saving significant time.

+

Updating the Builder Image

+

When dependencies change (new Rust version, Android SDK update, etc.):

+
    +
  1. Update Dockerfile.builder with the new configuration
  2. +
  3. Rebuild and push with a new tag:
  4. +
+
docker build -f Dockerfile.builder -t jellytau-builder:v1.2.0 .
+docker tag jellytau-builder:v1.2.0 gitea.tourolle.paris/dtourolle/jellytau-builder:v1.2.0
+docker push gitea.tourolle.paris/dtourolle/jellytau-builder:v1.2.0
+
+
    +
  1. Update the workflow to use the new tag:
  2. +
+
container:
+  image: gitea.tourolle.paris/dtourolle/jellytau-builder:v1.2.0
+
+

Image Contents

+

The builder image includes:

+
    +
  • Base OS: Ubuntu 24.04
  • +
  • Languages: +
      +
    • Rust (stable) with targets: aarch64-linux-android, armv7-linux-androideabi, x86_64-linux-android
    • +
    • Node.js 20.x
    • +
    • OpenJDK 17 (for Android)
    • +
    +
  • +
  • Tools: +
      +
    • Bun package manager
    • +
    • Android SDK 34
    • +
    • Android NDK 27.0.11902837
    • +
    • Build essentials (gcc, make, etc.)
    • +
    • Git, curl, wget
    • +
    • libssl, libclang development libraries
    • +
    +
  • +
  • Pre-configured: +
      +
    • Rust toolchain components (rustfmt, clippy)
    • +
    • Android SDK/NDK environment variables
    • +
    • All paths optimized for building
    • +
    +
  • +
+

Build Time

+

First build takes ~15-20 minutes depending on internet speed (downloads Android SDK/NDK). +Subsequent builds are cached and take seconds.

+

Storage

+

The built image is approximately 4-5 GB. Ensure your registry has sufficient storage.

+

Troubleshooting

+

"Image not found" in CI

+
    +
  • Verify the image name matches exactly in the workflow
  • +
  • Check that the image was successfully pushed: docker push output should show successful layers
  • +
  • Ensure Gitea has access to your registry (check network/firewall)
  • +
+

Build fails with "command not found"

+
    +
  • The image may not have finished pushing. Wait a few moments and retry the CI job.
  • +
  • Check that all layers were pushed successfully in the push output.
  • +
+

Registry authentication in CI

+

If your registry requires credentials in CI:

+
    +
  1. Create a deploy token in your registry
  2. +
  3. Add to Gitea secrets as REGISTRY_USERNAME and REGISTRY_TOKEN
  4. +
  5. Use in workflow:
  6. +
+
- name: Login to Registry
+  run: |
+    docker login gitea.tourolle.paris -u ${{ secrets.REGISTRY_USERNAME }} -p ${{ secrets.REGISTRY_TOKEN }}
+
+

References

+ +

Rust API Reference

+

The full backend API reference is generated by cargo doc (rustdoc).

+

👉 Open the Rust API Reference

+ +
+ + +
+
+ + + +
+ + + + + + + + + + + + + + + + + + + +
+ + diff --git a/release-checklist.html b/release-checklist.html new file mode 100644 index 00000000..2f134e71 --- /dev/null +++ b/release-checklist.html @@ -0,0 +1,613 @@ + + + + + + Release Checklist - JellyTau Documentation + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+ + + + + + + + + + + + + + + + + + + +
+ +
+ + + + + + + + +
+
+

Release Checklist

+

Quick reference for creating a JellyTau release.

+

Pre-Release (1-2 days before)

+
    +
  • +Code is on master/main branch
  • +
  • +All feature branches are merged and tested
  • +
  • +No failing tests locally: bun run test and bun run test:rust
  • +
  • +Requirement traceability check passes: bun run traces:json
  • +
  • +Type checking passes: bun run check
  • +
+

Update Version (Day before)

+
    +
  • +

    +Decide on version number (semantic versioning)

    +
      +
    • Example: v1.2.0 (major.minor.patch)
    • +
    • Example: v1.0.0-rc1 (release candidate)
    • +
    • Example: v1.0.0-beta (beta)
    • +
    +
  • +
  • +

    +Update version in files:

    +
    # Check these files for version numbers
    +cat package.json | grep version
    +cat src-tauri/tauri.conf.json | grep version
    +cat src-tauri/Cargo.toml | grep version
    +
    +
  • +
  • +

    +Update CHANGELOG.md:

    +
      +
    • +Add section for new version
    • +
    • +List all features added
    • +
    • +List all bugs fixed
    • +
    • +List breaking changes (if any)
    • +
    • +Add upgrade instructions (if needed)
    • +
    • +Format: Markdown with clear sections
    • +
    +
  • +
  • +

    +Update README.md:

    +
      +
    • +Update any version references
    • +
    • +Update feature list if applicable
    • +
    • +Update requirements if changed
    • +
    +
  • +
  • +

    +Commit changes:

    +
    git add .
    +git commit -m "Bump version to v1.2.0"
    +git push origin master
    +
    +
  • +
+

Final Check Before Release

+
    +
  • +

    +Run full test suite:

    +
    bun run test          # Frontend tests
    +bun run test:rust     # Rust tests
    +bun run check         # Type checking
    +
    +
  • +
  • +

    +Build locally (optional but recommended):

    +
    # Test Linux build
    +bun run tauri build
    +
    +# Test Android build
    +bun run tauri android build
    +
    +
  • +
  • +

    +No uncommitted changes:

    +
    git status  # Should show clean working directory
    +
    +
  • +
+

Release (Tag & Push)

+
# 1. Create annotated tag with release notes
+git tag -a v1.2.0 -m "Release version 1.2.0
+
+Features:
+- New feature 1
+- New feature 2
+
+Fixes:
+- Fixed bug 1
+- Fixed bug 2
+
+Improvements:
+- Performance improvement 1
+- UI improvement 1
+
+Breaking Changes:
+- None (or list if applicable)
+
+Migration:
+- No action required (or include steps if applicable)"
+
+# 2. Push tag to trigger workflow
+git push origin v1.2.0
+
+# 3. Monitor in Gitea Actions
+# Go to Actions tab and watch the workflow run
+
+

During Release (While Workflow Runs)

+
    +
  • +Watch workflow progress in Gitea Actions
  • +
  • +Monitor for test failures
  • +
  • +Monitor for build failures
  • +
  • +Check build logs if any step fails
  • +
+

After Release (Workflow Complete)

+
    +
  • +

    +Download artifacts from release page:

    +
      +
    • +JellyTau_*.AppImage (Linux)
    • +
    • +JellyTau_*.deb (Linux)
    • +
    • +JellyTau-*.rpm (Linux)
    • +
    • +jellytau-release.apk (Android)
    • +
    • +jellytau-release.aab (Android)
    • +
    +
  • +
  • +

    +Basic testing of artifacts:

    +
      +
    • +Linux AppImage runs
    • +
    • +Linux DEB installs and runs
    • +
    • +Android APK installs (via adb or sideload)
    • +
    +
  • +
  • +

    +Verify release page:

    +
      +
    • +Title is correct: "JellyTau vX.Y.Z"
    • +
    • +Release notes are formatted correctly
    • +
    • +All artifacts are uploaded
    • +
    • +Release type is correct (prerelease vs release)
    • +
    +
  • +
  • +

    +Verify integrity metadata (DR-216):

    +
      +
    • +SHA256SUMS is present, and sha256sum -c SHA256SUMS passes in the +directory you downloaded into
    • +
    • +SBOM files are present (*.cdx.json, frontend-dependencies.txt)
    • +
    +
  • +
  • +

    +Verify the update path (DR-217) — this is the step that catches a broken +updater before users hit it, because a bad manifest fails only on their +machine:

    +
      +
    • +latest.json is live and names this version: +curl -s https://gitea.tourolle.paris/dtourolle/jellytau/raw/branch/updater/latest.json | jq .version
    • +
    • +Both platform entries carry a non-empty signature
    • +
    • +The .AppImage.tar.gz, its .sig, and the NSIS .sig are among the +release assets — the manifest points at them
    • +
    • +Install the previous release, launch it, and use Settings → Updates: +it should offer this version, install it, and relaunch
    • +
    • +On Android, Settings → Updates offers the releases page rather than an +install button (the updater plugin is not compiled for that target)
    • +
    +
  • +
  • +

    +Announce release:

    +
      +
    • +Post to relevant channels/communities
    • +
    • +Update website/docs
    • +
    • +Tag contributors if applicable
    • +
    +
  • +
+

Rollback (If Issues Found)

+

If critical issues are found after release:

+
# Option 1: Delete tag locally and remotely
+git tag -d v1.2.0
+git push origin :refs/tags/v1.2.0
+
+# Option 2: Mark as prerelease in release page
+# Then plan immediate patch release (v1.2.1)
+
+# Option 3: Create hotfix branch and release v1.2.1
+git checkout -b hotfix/v1.2.1
+# Fix issues
+git commit -m "Fix critical issue"
+git tag v1.2.1
+git push origin hotfix/v1.2.1 v1.2.1
+
+

Version Examples

+

Major Release

+
v2.0.0 - Major version bump
+- Significant new features
+- Breaking API changes
+- Major UI redesign
+
+

Minor Release

+
v1.2.0 - Feature release
+- New features
+- Backward compatible
+- Bug fixes
+
+

Patch Release

+
v1.1.1 - Bug fix/patch
+- Bug fixes only
+- No new features
+- Backward compatible
+
+

Pre-releases

+
v1.2.0-alpha    - Early development
+v1.2.0-beta     - Late development, feature complete
+v1.2.0-rc1      - Release candidate, minimal fixes only
+
+

File Locations

+

Key files for versioning:

+
    +
  • package.json - Frontend version
  • +
  • src-tauri/tauri.conf.json - Tauri config version
  • +
  • src-tauri/Cargo.toml - Rust version
  • +
  • CHANGELOG.md - Release history
  • +
  • README.md - Project documentation
  • +
+

Troubleshooting

+

Tests Fail Before Release

+
    +
  1. Don't push tag yet
  2. +
  3. Fix failing tests locally
  4. +
  5. Push fixes to master
  6. +
  7. Re-run test suite
  8. +
  9. Then tag and push
  10. +
+

Build Fails in CI

+
    +
  1. Check detailed logs in Gitea Actions
  2. +
  3. Fix issue locally
  4. +
  5. Delete tag: git tag -d v1.2.0 && git push origin :refs/tags/v1.2.0
  6. +
  7. Push fix to master
  8. +
  9. Create new tag with fix
  10. +
+

Release Already Exists

+
    +
  1. If workflow runs twice, artifacts may conflict
  2. +
  3. Check release page
  4. +
  5. If duplicates exist, delete and re-release
  6. +
+

Artifacts Missing

+
    +
  1. Check build logs for errors
  2. +
  3. Verify platform-specific dependencies
  4. +
  5. Delete tag and retry after fixes
  6. +
+

Performance Tips

+
    +
  • Tests: ~5-10 minutes
  • +
  • Linux build: ~10-15 minutes
  • +
  • Android build: ~15-20 minutes
  • +
  • Total release time: ~30-45 minutes
  • +
+

First build takes longer (cache warming). Subsequent releases are faster due to caching.

+

Template: Release Notes

+
## 🎉 JellyTau vX.Y.Z
+
+### ✨ Features
+- New feature 1
+- New feature 2
+
+### 🐛 Bug Fixes
+- Fixed issue #123
+- Fixed issue #456
+
+### 🚀 Performance
+- Improvement 1
+- Improvement 2
+
+### 📱 Downloads
+- [Linux AppImage](#) - Run on any Linux
+- [Linux DEB](#) - Install on Ubuntu/Debian
+- [Android APK](#) - Install on Android devices
+- [Android AAB](#) - For Google Play Store
+
+### 📋 Requirements
+**Linux:** 64-bit, GLIBC 2.29+
+**Android:** 8.0+
+
+### 🔗 Links
+- [Changelog](https://gitea.tourolle.paris/dtourolle/jellytau/src/branch/master/CHANGELOG.md)
+- [Issues](https://gitea.tourolle.paris/dtourolle/jellytau/issues)
+
+---
+Built with Tauri, SvelteKit, and Rust 🦀
+
+

Quick Commands

+
# View existing tags
+git tag -l
+
+# Create release locally (dry run)
+git tag -a v1.2.0 -m "Release v1.2.0" --dry-run
+
+# List commits since last tag
+git log v1.1.0..HEAD --oneline
+
+# Show tag details
+git show v1.2.0
+
+# Rename tag (if needed)
+git tag v1.2.0_old v1.2.0
+git tag -d v1.2.0
+git push origin v1.2.0_old v1.2.0
+
+# Delete tag locally and remotely
+git tag -d v1.2.0
+git push origin :refs/tags/v1.2.0
+
+
+

Tips:

+
    +
  • ✅ Always test locally before release
  • +
  • ✅ Use semantic versioning consistently
  • +
  • ✅ Document changes in CHANGELOG
  • +
  • ✅ Wait for full workflow completion
  • +
  • ✅ Test release artifacts before announcing
  • +
+

Remember: A good release is a tested release! 🚀

+ +
+ + +
+
+ + + +
+ + + + + + + + + + + + + + + + + + +
+ + diff --git a/requirements.html b/requirements.html new file mode 100644 index 00000000..3373a9f5 --- /dev/null +++ b/requirements.html @@ -0,0 +1,1147 @@ + + + + + + Requirements Specification - JellyTau Documentation + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+ + + + + + + + + + + + + + + + + + + +
+ +
+ + + + + + + + +
+
+

Requirements Specification

+

This document captures JellyTau's user requirements, software requirements, +traceability matrix, test traceability, and known technical debt.

+

For a narrative overview of the system design, see +docs/architecture/. For development workflows, see the +README and scripts/README.md.

+

1. User Requirements

+
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
IDRequirementPriorityStatus
UR-001Run the app on multiple platforms (Linux, Android)HighIn Progress
UR-002Access media when online or offlineHighDone
UR-003Play videosHighDone
UR-004Play audio uninterruptedHighDone
UR-005Control media playback (pause, play, skip, scrub)HighDone
UR-006Control media when device is on lock screen or via BLE headsetsMediumDone (Android); not implemented on Linux — see IR-005
UR-007Navigate media in libraryHighDone
UR-008Search media across librariesHighDone
UR-009Connect to Jellyfin to access mediaHighDone
UR-010Control playback of Jellyfin remote sessionsLowDone
UR-011Download media on demandMediumDone
UR-012Login info shall be stored securely and persistentlyHighDone
UR-013View and manage downloaded mediaMediumDone
UR-014Make and edit playlists of music that sync back to JellyfinMediumDone
UR-015View and manage current audio queue (add, reorder tracks)MediumDone
UR-016Change system settings while playing (brightness, volume)LowPlanned
UR-017Like or unlike audio, albums, movies, etc.MediumDone
UR-018Choose to download series, albums, songs, artist discographyMediumDone
UR-019Resume playback from where you left off (movies, shows, albums)HighDone
UR-020Select subtitles for video contentHighDone
UR-021Select audio track for video contentHighDone
UR-022Control streaming quality and transcoding settingsMediumPlanned
UR-023View "Next Up" / Continue Watching on home screen; auto-play next episode with countdown popup and configurable episode limitMediumDone
UR-024View recently added content on serverMediumDone
UR-025Sync watch history and progress back to JellyfinHighDone
UR-026Sleep timer for audio and video playback (roller UI, time/track/episode modes)LowDone
UR-027Audio equalizer for sound customizationLowDone (Linux; Android pending device verification)
UR-028Navigate to artist/album by tapping names in now playing viewHighDone
UR-029Toggle between grid and list view in libraryMediumDone
UR-030Quick genre browsing and filteringMediumDone
UR-031Crossfade between audio tracksLowNot implemented (blocked — see DR-034)
UR-032Gapless playback for seamless album listeningMediumDone (Linux; Android pending device verification)
UR-033Volume normalization to prevent volume jumps between tracksLowDone (Linux; Android pending device verification)
UR-034Rich home screen with hero banners, carousels, and personalized sectionsHighDone
UR-035View cast/crew (actors, directors) on movie/show detail pagesHighDone
UR-036Navigate to actor/person page showing their filmographyMediumDone
UR-037Visually appealing video library with poster grids and metadataHighDone
UR-038Movie/show detail page with backdrop, ratings, and rich metadataHighDone
UR-039Navigate between main sections via bottom navigation barHighDone
UR-040Keep a video's audio playing when the app is backgrounded or the screen is locked, stopping video decode until the app returns to the foreground (per-player toggle; Android)MediumDone (pending device verification)
UR-041Continue watching locally-playing video in a floating picture-in-picture window when leaving the app (Android) — PiP applies to video only, never to audio playback, library/menu browsing, or remote/cast sessionsMediumDone
UR-042Authenticate to a server and manage the session lifecycle (connect, log in, Quick Connect, background session verification, re-authenticate, log out)HighDone
UR-043Automatically detect server reachability and switch between online and offline operation without user interventionHighDone
UR-044Pin downloaded media so it is protected from automatic cache evictionLowDone
UR-045Predictively pre-cache likely-next media (queue lookahead and album affinity) within a storage budgetLowDone
UR-046Group multiple remote players into a synchronized playback group (LMS SyncGroups)LowDone
UR-047Manage multiple Jellyfin servers (add, list, remove) and switch the active server/accountMediumPlanned (backend store done; switcher UI pending)
UR-048See the next episodes of a series directly below the episode/series being viewed, above cast and similar-shows content, so continuing a show is the shortest path (see ux-flows.md §5B)HighDone
UR-049Search is scoped by where it was started — inside a library it searches that library, from Home/library-root/search-tab it searches everything — with the scope shown as filter chips under the search bar that preselect from context and can be changed without retyping (see ux-flows.md §6.1)HighImplemented
UR-050Reorder search result groups (Songs, Albums, Artists, Movies, TV Shows) by drag and drop in settings, so the media a user cares about most appears first (see ux-flows.md §6.3)MediumImplemented
UR-051Browse library pages in a consistent layout where card shape signals media type (square music, poster video, thumbnail episode), ordinal content stays listed, and the grid/list preference persists across pages (see ux-flows.md §5A)MediumPartial (implemented; toggle not reachable from settings)
UR-052While offline, library pages show only media available on the device by default, with an opt-in toggle that additionally reveals the cached server catalog as greyed-out entries which can be queued for download on the next reconnectHighDone
UR-053Restrict media downloads to unmetered networks via a "WiFi Only" setting: when enabled, queued downloads are held while the device is on cellular or a metered connection (including metered WiFi hotspots) and resume automatically once an unmetered network is availableMediumDone (pending device verification)
UR-054Reach account actions (Settings, Downloads, Display preferences, Sign out) from every authenticated screen via a single account menu anchored to the user's name, identical on desktop and mobile (see ux-flows.md §1.2)HighDone
UR-055Browse downloaded media as an offline-scoped library — reusing the same library grids, cards, and detail pages as online browsing, showing only libraries/containers with downloaded content — with the transfer-progress list demoted to a secondary "Transfers" view (see ux-flows.md §7.2)HighDone
UR-056See how much disk each downloaded item/album/series consumes, in familiar rounded units shown on the card and detail page, with a device total on the Downloaded surface and a reclaim amount stated at the point of removal (see ux-flows.md §7.3.1)MediumDone
UR-057Settings apply the instant a control is changed — no "Save" button and no save/dirty state — so leaving the page never loses a change; sliders show a live readout while dragging but persist on release (see ux-flows.md §8.1)MediumDone
UR-058On the home screen, a tap on a media card opens the item (movie/episode detail page, or the series Episode Focus View for episodes) rather than starting playback; a long-press starts "play now" after a confirm; an episode detail/focus page links back to its parent series and season (see ux-flows.md §5B.5 and §5B.1)MediumDone
UR-059Skipping to the next episode records the episode left behind as fully watched rather than saving a mid-episode resume point — skipping means "done with this one", not "stopped here" — and Continue Watching hides episodes the viewer has already moved past (a partial position behind that series' next-up episode), so the row only ever offers genuinely unfinished mediaMediumDone
UR-060Search results are ordered by how well they match: a name that starts with the query outranks one matching mid-word (typing "parks" finds "Parks and Recreation" before "Sparks of Love"), and at equal match quality a container outranks its contents (a series before its episodes). Results are grouped into distinct categories — TV Shows, Episodes, Movies, Songs, Albums, Artists and People — so a show never competes with its own episodes for the same slot, and searching an actor's name reaches their bioHighDone
UR-061Double tapping the video skips within it — right half jumps forward 30 seconds, left half jumps back 10 seconds — with an on-screen indicator naming the amount. A double tap leaves the play state unchanged — playing jumps and keeps playing, paused jumps and stays paused — because the second tap re-toggles what the first tap toggled (see DR-098); the skip lands relative to the position the player actually reports, and repeated double taps accumulate rather than all skipping from the same spotMediumDone
UR-062Opening a TV series lands the viewer where they are in it, not at season 1: the series page scrolls the current season into view and highlights the current episode, and the hero button opens that episode (labelled Resume S2E4 / Play S1E1). "Current" means the episode in progress, else the server's Next Up for that series, else the first unwatched episode, else the first — resolved by the backend so it also works offline. A season is never a page of its own: every route that names a season lands on the series with that season in view, so the episodes of all seasons are always one continuous scrollable listHighDone
UR-063Each video library is one page, not three. Browsing (hero, Continue Watching, Next Up, Recently Added, genre rows), the full title grid, and the genre browser are tabs of /library/tv and /library/movies rather than separate routes with inconsistent names (/library/tv/shows vs /library/movies/all, /library/shows/genres vs /library/movies/genres). The old routes redirect so existing links keep workingMediumDone
UR-064Watch history can be erased, per series and per season, from the series page. Clearing marks every episode inside unwatched and clears resume positions, so the show returns to "never watched" and reopens on its premiere. It asks for confirmation first (it cannot be undone) and requires a connection to the server, since history cleared only locally would be undone by the next syncMediumDone
UR-065Search answers from a locally indexed copy of the library, so results appear as fast as the device can query rather than at the speed of a round trip to the server, and the same results are found with the server unreachable. A background job keeps the index current — refreshing on a schedule rather than only at app start, dropping media removed from the server, and covering everything the result groups can show (including artists and people). The server is still queried in the background so media added since the last index still turns up, merged in without reordering what is already on screenHighImplemented
UR-066The app's own chrome stays clear of the device's system chrome. On Android the bottom navigation sits above the navigation/gesture bar instead of underneath it, the header clears the status bar, and full-screen video and audio playback keep their controls inside the usable screen — clear of the gesture bar and, in landscape, of the display notch. This must hold across navigation modes (gesture and 3-button) and rotation, not only on the handsets it happened to be tested onHighDone
UR-067Favourited media can be found again. A Favourites page lists everything favourited across all libraries, scoped by tabs (All / Movies / Shows / Music); the home screen carries favourite rows for movies, shows and music, hidden when a category is empty; and each library page can be filtered to favourites in place. Without this the like button writes to a store nothing readsMediumDone
UR-068Anything the app shows can be favourited where it is shown — from a movie, series, episode, album, artist or playlist page, and from any card in a grid or carousel — not only from the player while the item happens to be playingMediumDone
UR-069Favourite state agrees with the server in both directions. An item favourited in another Jellyfin client shows as favourited here without being touched, and an item favourited here while the server is unreachable reaches the server once it returns — without the user going back to the screen where they marked itMediumDone
UR-070Playback quality is the viewer's choice: the player offers the bitrates the server can produce for what is playing, and changing one resumes at the same point with the same audio and subtitle tracks. Because the chosen rendition can change at any moment, nothing that streams for playback is treated as a stored copy unless it happens to be byte-identical to the real fileMediumProposed
UR-071Media the viewer is watching can be kept, by a whole-file download that runs in the background independently of playback and at its own quality, so it is unaffected by bitrate changes. Where the streamed bytes already are that file (direct play), they are kept rather than fetched twice. A completed download is then played from disk rather than streamed againMediumProposed
UR-073Watched state is something the viewer can set, not only something playback records. Any episode, season, series or movie can be marked watched — or unwatched again — from where it is shown, without sitting through it or erasing its history wholesale. Marking a season or series covers the episodes inside it, and works with the server unreachableMediumDone
UR-072Each page opens where a page should open. Moving to a new screen starts at the top of it, and going Back returns the viewer to the place they left — their position in a long library grid or home screen, not the top of it. A page never inherits the scroll position of the page before itMediumDone
UR-075Artwork is shown at the shape it was made in. Where a screen presents a set of things side by side — the libraries on the library page and on home — they are laid out as a mosaic: rows of a common height in which each tile is as wide as its own picture, rather than a grid that crops every cover to one box. Favourites are reachable per category from that same mosaic, beside the library they belong to, not only as one undifferentiated listMediumDone
UR-076Music browsing shows only what the listener considers music. A Jellyfin server commonly keeps podcasts, audiobooks, sound effects or sample packs in their own folders inside a music library; those folders can be excluded by choice, once, and every music surface — library grids, artist and album listings, genre rows, search and the home screen — then agrees on what is in scope. The choice is by folder, not by a name the app happens to recognise, so a folder called anything at all can be excluded and an item is never dropped because its title matched a wordMediumDone
UR-077The app can update itself, or tell the user how. Somebody who installed an AppImage or ran the Windows installer had no upgrade path at all: nothing in the app ever mentioned that a newer version existed, and the release notes were the only announcement. On Linux and Windows the app checks a signed manifest, offers the new version with its notes, and installs and relaunches on request — the signature check is the point, since it is what stops a substituted download from being installed by the app itself. Android cannot do this (an app may not overwrite its own APK; that is the package installer's job) and is given the honest alternative, a link to the releases page, rather than a button that would throwMediumDone
UR-078JellyTau keeps a record of what it did, and can hand it over. The app forgot everything the moment it exited: the backend logged to stdout only — which a user launching from a desktop icon never sees, and which on Android is not logcat, so the Rust half was invisible on the platform carrying the hardest bugs. A crash left nothing at all. Logs are now written to a size-capped rotating file, a panic is recorded before the process dies, the frontend's messages land in the same timeline as the backend's, and Settings exports the lot as one file to attach to a bug report. Nothing is transmitted anywhere — the user attaches it themselves, which is also what keeps this from being telemetry. Access tokens and passwords never reach the fileMediumDone
UR-074Video streaming can be held to a bandwidth budget the viewer sets, rather than spent at whatever rate the server would otherwise send. A ceiling chosen once — from the source's own bitrate down to a rung that still plays on a poor connection — governs every video the app opens, live TV included, and survives a restart, so a metered connection is not quietly drained by the next thing played. A single video can be moved to a different ceiling from the player, resuming where it was, without disturbing that defaultMediumDone
+
+
+

2. Software Requirements

+

2.1 Integration Requirements

+

External system integrations and platform-specific implementations.

+
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
IDRequirementCategoryTraces ToStatus
IR-001Build system supporting multiple targets (Linux, Android)BuildUR-001Done
IR-002Build scripts for Android and LinuxBuildUR-001Done
IR-003Integration of libmpv for Linux playbackPlaybackUR-003, UR-004Done
IR-004Integration of ExoPlayer for Android playbackPlaybackUR-003, UR-004In Progress (basic playback works, audio settings missing)
IR-005MPRIS D-Bus integration for Linux lockscreen/media controlsPlatformUR-006Planned — genuinely absent: no mpris/souvlaki/zbus/dbus code or dependency in the project (zbus appears in Cargo.lock only transitively, via tauri-plugin-opener), and no navigator.mediaSession use in the frontend. player::update_lockscreen_metadata is a no-op off Android. UR-006 is therefore Android-only
IR-006Android MediaSession integration for lockscreen controlsPlatformUR-006Done
IR-007Bluetooth AVRCP integration via system media sessionPlatformUR-006Planned
IR-008Android audio focus handling (pause on call)PlatformUR-004, UR-006Done
IR-009Jellyfin API client for authenticationAPIUR-009, UR-012Done
IR-010Jellyfin API client for library browsingAPIUR-007, UR-008Done
IR-011Jellyfin API client for playback streamingAPIUR-003, UR-004Done
IR-012Jellyfin Sessions API for remote playback controlAPIUR-010Done
IR-021Android MediaRouter integration for remote volume in system panelPlatformUR-010, UR-016Planned
IR-013SQLite integration for local databaseStorageUR-002, UR-011Done
IR-014Secure credential storage (keyring/keychain)SecurityUR-012Done
IR-015Jellyfin API client for playback progress reportingAPIUR-019, UR-025Done
IR-016Jellyfin API client for subtitle/audio track infoAPIUR-020, UR-021Done
IR-017Jellyfin API client for transcoding parametersAPIUR-022Planned
IR-018Subtitle rendering and selection in the video playback backends: ExoPlayer sideloads each track as a MediaItem.SubtitleConfiguration and selects by text-track-group position (Android), and the WebKitGTK HTML5 <video> element renders <track kind="subtitles"> children carrying data-stream-index (Linux). Originally scoped to libmpv, which never implemented it: MpvBackend is the audio-only backend here and does not override PlayerBackend::set_subtitle_track, so the default not_implemented() still stands there. UR-020 is satisfied by the two paths above rather than by MPVPlaybackUR-020Done
IR-019Audio track selection in the video playback backends: ExoPlayer switches track by index natively (Android), while the HTML5 <video> path cannot switch a track in the element and instead re-opens the stream at the chosen AudioStreamIndex and resumes at the same position (Linux) — the two outcomes AudioTrackSwitchResponse distinguishes. Originally scoped to libmpv, which never implemented it: MpvBackend does not override PlayerBackend::set_audio_track, so the default not_implemented() still stands there. UR-021 is satisfied by the two paths above rather than by MPVPlaybackUR-021Done
IR-020libmpv/ExoPlayer equalizer integrationPlaybackUR-027Done (Linux/MPV and Android/audiofx.Equalizer; Android pending device verification)
IR-022Jellyfin API client for person/cast dataAPIUR-035, UR-036Done
IR-023Database schema for person/cast cachingStorageUR-035, UR-036Done
IR-024Jellyfin API client for home screen data (featured, continue watching)APIUR-034Done
IR-025Android background-audio handoff: WebView <video> → native ExoPlayer foreground service on background/lock, and back on foreground (audio continues, video decode stops)PlatformUR-040Done (pending device verification)
IR-026Android picture-in-picture: auto-enter on user-leave-hint via enterPictureInPictureMode, only while a local video surface is actively rendering (never for audio-only playback, menu/library browsing, or remote/cast sessions — enforced by the native canEnterPip guard, re-checked at leave time); aspect-ratio sizing; a play/pause RemoteAction that reflects live player play/pause state (updated whenever playback state changes, not only on button press); WebView hide/restore on mode changePlatformUR-041Done
IR-027Jellyfin /System/Info/Public reachability probe used as an offline→online recovery detectorAPIUR-043Done
IR-028Jellyfin/LMS SyncGroups API client (list, create, join, unsync, dissolve sync groups)APIUR-046Done
IR-029Android ConnectivityManager/NetworkCapabilities transport probe with a NetworkCallback change subscription, surfaced to the frontend via the AndroidNetworkType JS bridge and the jellytau-network-changed WebView event (requires ACCESS_NETWORK_STATE)PlatformUR-053Done (pending device verification)
IR-030Scheduled full-catalog crawl of every library (Recursive=true, paged) feeding the local index, driven by a Rust background task and the ConnectivityMonitor reconnect signal rather than by the frontendStorageUR-065Implemented
IR-031Android WindowInsets bridge: an OnApplyWindowInsetsListener on the decor view reports `systemBars()displayCutout()in CSS pixels, pushed into the WebView asjt-insetCSS custom properties plus ajellytau-insets-changedevent, and pullable via theAndroidInsets` JS bridgePlatformUR-066
IR-032Whole-file background download of the item being played, reusing the existing resumable download worker and the Range-capable /Videos/{id}/stream.mp4 endpoint; plus per-platform read-through caching hooks (ExoPlayer CacheDataSource, mpv stream-record) for direct-play sessions onlyStorageUR-071Proposed
+
+
+

Where a UR is met by a different mechanism than its IR anticipated. Several +integration requirements were written when libmpv was expected to be the single +playback backend. It is not: MpvBackend is the audio-only backend, Linux +plays video through a WebKitGTK HTML5 <video> element (HLS/h264), and Android +plays through ExoPlayer. So:

+
    +
  • UR-020 / UR-021 (subtitle and audio track selection) are Done, but not by +MPV — MpvBackend overrides neither PlayerBackend::set_subtitle_track nor +set_audio_track, leaving the trait's not_implemented() default. IR-018 and +IR-019 have been re-scoped to the backends that actually deliver them +(ExoPlayer sideloaded SubtitleConfigurations and native track switching; +HTML5 <track> children and stream re-open at the chosen AudioStreamIndex) +and marked Done on that basis. IT-008 / IT-009 were re-worded to match.
  • +
  • UR-006 (lockscreen / BLE headset control) is Done on Android only, via +MediaSessionCompat (IR-006) and ExoPlayer/AudioManager focus (IR-008). +IR-005 (MPRIS) remains Planned because it genuinely does not exist — there is +no MPRIS/D-Bus code or dependency in the project, and +player::update_lockscreen_metadata is a no-op off Android. UR-006's status +was corrected rather than IR-005's.
  • +
+
+

2.2 Jellyfin API Requirements

+

API endpoints and data contracts required for Jellyfin integration.

+
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
IDRequirementEndpoint CategoryTraces ToStatus
JA-001Server connection and discoverySystemUR-009Done
JA-002User authentication (username/password)UsersUR-009, UR-012Done
JA-003Get user library viewsUserViewsUR-007Done
JA-004Get library items (paginated)ItemsUR-007Done
JA-005Get item details and metadataItemsUR-007Done
JA-006Search across librariesItemsUR-008Done
JA-007Get playback info and stream URLMediaInfoUR-003, UR-004Done
JA-008Get available subtitles for itemMediaInfoUR-020Done
JA-009Get available audio tracks for itemMediaInfoUR-021Done
JA-010Report playback startSessionsUR-025Done
JA-011Report playback progress (periodic)SessionsUR-025Done
JA-012Report playback stoppedSessionsUR-025Done
JA-013Get resume position for itemUserDataUR-019Done
JA-014Get "Next Up" itemsShowsUR-023Done
JA-015Get "Continue Watching" itemsItemsUR-023Done
JA-016Get recently added itemsItemsUR-024Done
JA-017Mark item as favoriteUserDataUR-017Done
JA-018Remove item from favoritesUserDataUR-017Done
JA-019Get/create/update playlistsPlaylistsUR-014Done
JA-020Add/remove items from playlistPlaylistsUR-014Done
JA-021Get active sessions listSessionsUR-010Done
JA-022Send playback commands to remote session (play/pause/stop)SessionsUR-010Done
JA-023Send seek command to remote sessionSessionsUR-010Done
JA-024Send next/previous track commands to remote sessionSessionsUR-010Done
JA-025Play specific item on remote sessionSessionsUR-010Done
JA-026Send volume/mute commands to remote sessionSessionsUR-010Done
JA-027Get transcoding optionsMediaInfoUR-022Planned
JA-028Get image/artwork URLsImagesUR-007Done
JA-029Get cast/crew for item (actors, directors)ItemsUR-035Done
JA-030Get person details and filmographyPersonsUR-036Done
JA-031Get items by person (actor/director filmography)ItemsUR-036Done
JA-032Get audio-only stream URL for a video item (selected audio-stream index)MediaInfoUR-040Done
JA-033Query favourite items (Filters=IsFavorite, recursive, scoped by item type)ItemsUR-067Done
JA-034Read UserData (favourite, played, resume position) from item responsesUserDataUR-069Done
JA-035Mark item played (POST /Users/{userId}/PlayedItems/{itemId})UserDataUR-025Done
JA-036Query next-up episodes excluding in-progress ones (/Shows/NextUp with EnableResumable=false)ShowsUR-059Done
+
+

2.3 Development Requirements

+

Internal architecture, components, and application logic.

+
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
IDRequirementCategoryTraces ToStatus
DR-001Player state machine (idle, loading, playing, paused, seeking, error)PlayerUR-005Done
DR-002MediaItem struct tracking source, location, duration, metadataPlayerUR-003, UR-004Done
DR-003Source-agnostic media abstraction (Remote, Local, DirectUrl)PlayerUR-002, UR-011Done
DR-004PlayerBackend trait for platform-agnostic playbackPlayerUR-003, UR-004Done
DR-005Queue manager with shuffle, repeat, historyPlayerUR-005, UR-015Done
DR-006Audio pre-caching for seamless track transitionsPlayerUR-004Planned
DR-007Library browsing screens (grid view, search, filters)UIUR-007, UR-008Done
DR-008Album/Series detail view with track listingUIUR-007Done
DR-009Audio player UI (mini player, full screen)UIUR-005Done
DR-010Video player UI (fullscreen, controls overlay)UIUR-003, UR-005Done
DR-011Search bar with cross-library searchUIUR-008Done
DR-012Local database for media metadata cacheStorageUR-002Done
DR-013Repository pattern for online/offline data accessStorageUR-002Done
DR-014Offline mutation queue for sync-back operationsStorageUR-002, UR-014, UR-017Done
DR-015Download manager with queue and progress trackingStorageUR-011, UR-018Done
DR-016Thumbnail caching and sync with serverStorageUR-007Done
DR-017"Manage Downloads" screen for local media managementUIUR-013Done
DR-018Download buttons on library/album/player screensUIUR-011, UR-018Done
DR-019Playlist creation and editing UIUIUR-014Done
DR-020Queue management UI (add, remove, reorder)UIUR-015Done
DR-021Like/favorite functionality on media itemsUIUR-017Done
DR-022Resume position tracking and restoration on playPlayerUR-019Done
DR-023Subtitle selection UI in video playerUIUR-020Done
DR-024Audio track selection UI in video playerUIUR-021Done
DR-025Quality/transcoding settings UIUIUR-022Planned
DR-026"Continue Watching" / "Next Up" home sectionUIUR-023Done
DR-027"Recently Added" home sectionUIUR-024Done
DR-028Playback progress sync service (periodic reporting)PlayerUR-025Done
DR-029Sleep timer with roller UI, time/track/episode modes, and auto-stop (audio + video players)PlayerUR-026Done
DR-049Auto-play episode limit (configurable max episodes per session)PlayerUR-023Done
DR-050Reusable scroll picker (roller) componentUIUR-026Done
DR-030Equalizer UI with presets and custom bandsUIUR-027Done
DR-031Clickable artist/album links in now playing viewUIUR-028Done
DR-032List view option for library browsing (albums, artists)UIUR-029Done
DR-033Genre browsing screen with quick filtersUIUR-030Done
DR-034Crossfade engine with configurable duration (0-12s)PlayerUR-031Not implemented (blocked on MPV: single-stream audio chain; acrossfade needs 2 inputs — see docs/specs/playback-backend-unification.md)
DR-035Gapless playback between sequential tracksPlayerUR-032Done (Linux via MPV; Android via pauseAtEndOfMediaItems — pending device verification)
DR-036Volume normalization with preset levels (Loud/Normal/Quiet)PlayerUR-033Done (Linux via MPV dynaudnorm; Android via LoudnessEnhancer — pending device verification)
DR-037Remote session browser and control UIUIUR-010Done
DR-038Home screen with hero banner carousel (featured/continue watching)UIUR-034Done
DR-039Home screen horizontal carousels (recently added, recommendations)UIUR-034, UR-024Done
DR-040Cast/crew section on movie/show detail pagesUIUR-035Done
DR-041Person/actor detail page with filmography gridUIUR-036Done
DR-042Video library grid with poster cards, year, and rating badgesUIUR-037Done
DR-043Movie/show detail page with backdrop hero, synopsis, and metadataUIUR-038Done
DR-044Horizontal scrolling actor/cast row with profile imagesUIUR-035Done
DR-045Bottom navigation bar with Home, Library, Search buttonsUIUR-039Done
DR-046Dedicated search page with input and resultsUIUR-039Done
DR-047Next episode auto-play popup with configurable countdown and episode limitPlayerUR-023Done
DR-048Video settings (auto-play toggle, countdown duration, episode limit)SettingsUR-023, UR-026Done
DR-051Background-audio toggle button in the video player controls (suppresses auto-PiP while enabled)UIUR-040Done (pending device verification)
DR-052Background-audio handoff state machine: on background/lock tear down the WebView <video>/HLS decode and start native audio-only playback at the current position; on foreground return position and resume <video>; exactly one audio source active at every transition (no dual audio)PlayerUR-040Done (pending device verification)
DR-053PictureInPictureManager: canEnterPip gate (local video surface actively rendering — false for audio, browsing, and remote/cast), aspect-ratio clamp, a RemoteAction play/pause receiver whose icon reflects live player state (refreshed on every playback-state change while in PiP, not only on button press), WebView hide/restore, surface re-fit on exit; plus the AndroidPictureInPicture JS bridge and the PiP button (shown only when PiP is supported) in the video playerUIUR-041Done
DR-054Auth manager and session lifecycle: connect-to-server, login, Quick Connect verification poll (start/stop), session get/set, background session verifier, re-authenticate, logoutAuthUR-042Done
DR-055ConnectivityMonitor deriving reachability from real repository traffic, with online/offline state, mark-reachable/unreachable reporting, and a probe-based recovery poller active only while offlineConnectivityUR-043Done
DR-056Download pinning (pin/unpin/is-pinned) that excludes an item from smart-cache evictionStorageUR-044Done
DR-057Smart cache manager: album-affinity tracking, queue-lookahead pre-cache, storage-limit enforcement, config, stats, and recommendationsStorageUR-045Done
DR-058Remote sync-group control (LMS SyncGroups): list, create, unsync a player, dissolve a groupPlayerUR-046Done
DR-059Playback-mode transfer state machine: get/set current mode, transferring guard, transfer-to-remote / transfer-to-local, remote session statusPlayerUR-010Done
DR-060Multi-server store and active-account selection: save/get/delete server, save/get user, set/get active user (per-server), active-session resolutionStorageUR-047Partial (store done; server-switcher UI pending)
DR-061Episode Focus View: episode hero followed immediately by the "More Episodes" strip — a forward-biased window (~3 before / ~6 after) around the current episode, spanning season boundaries in series order, with the current episode present and badged, per-card resume progress and watched state, and click-to-swap focus (no playback)UIUR-048Done
DR-062Detail-page section ordering: continuation content precedes discovery content — Episode Focus View renders hero → episode strip → cast → similar; Series renders hero → seasons/episodes → cast → similarUIUR-048Done
DR-063Search scope taxonomy owned by Rust: SearchScope (All / Music / Movies / TV) crosses IPC as an opaque enum and SearchScope::item_types() expands it to Jellyfin item types, resolved once in repository_search before the cache and server paths diverge so online and offline filter identically; All expands to no filter rather than the union of the other scopes (which would drop People and folders). The frontend maps the originating route to a scope (resolveSearchScope, presentation) and never names an item type for searchBackendUR-049Implemented
DR-064Scope chip row rendered under the search bar on both the search page and the in-library header search: preselected from context, horizontally scrollable, re-runs the search preserving the query on changeUIUR-049Implemented
DR-065Thread SearchOptions.includeItemTypes through library.search() so the global/header search honours scope (backend online + offline paths already support it)UIUR-049Implemented
DR-066Persisted search result group order with a drag-and-drop settings list, keyboard-accessible reordering, a shipped default (see DR-091 for the current group set and order), and empty-group omissionSettingsUR-050Implemented
DR-067SearchResults renders groups in the user-configured order rather than hardcoded markup order, without altering intra-group rankingUIUR-050Implemented
DR-068Library card shape by media type: 1:1 square for music (circular mask for artists), 2:3 poster for movies/series/seasons, 16:9 for episodes and collection foldersUIUR-051Done
DR-069Responsive library grid (2/3/4/5/6 columns across base→xl) with two-line truncated card text and artwork-overlay progress/watched stateUIUR-051Done
DR-070Global persisted grid/list view preference honoured by browse pages, suppressed for ordinal content (album tracks, season episodes)UIUR-051, UR-029Partial (persisted store + page-header toggle; no settings entry)
DR-075Shared AccountMenu component: identity header (user + server), Downloads / Settings / Display entries, divider, Sign out last; anchored to the username/avatar trigger and identical on desktop and mobileUIUR-054Done
DR-076App shell exposes the header (and therefore the account menu) on every authenticated non-immersive route, including /, /search, and /downloads; only /player/* and /login remain chrome-freeUIUR-054Done
DR-077Display section in Settings binding the existing persisted grid/list viewMode store, giving the preference a discoverable homeSettingsUR-054, UR-029Done
DR-078Catalog-visibility gate spanning the "Show all server media" toggle → set_show_server_catalogINCLUDE_CATALOG_BROWSE → the synced-catalog UNION branch of offline get_items. Visibility resolves to serverReachable || showServerCatalog, so offline with the toggle off lists downloaded/local media onlyStorageUR-052, UR-002Done
DR-079isConnected derives from backend-reported server reachability alone; navigator.onLine is advisory and may only trigger a recheck, never force or clear the offline state (a reachable LAN server while the browser reports offline, and an unreachable server on a live link, must both resolve correctly)ConnectivityUR-052, UR-043Done
DR-080With the catalog-browse gate off, an empty offline get_items result is authoritative "no downloads here" and must be returned as-is; the hybrid repository must not treat it as a cache miss and fall through to the serverStorageUR-052, UR-013Done
DR-074WiFi-only download gate: NetworkState/NetworkType transport model reported from the platform via set_network_state, checked in pump_download_queue before starting any pending row (cellular/metered/unknown fail closed, WiFi and Ethernet require NOT_METERED); blocked rows stay pending and re-pump on network change, with a waitingForNetwork event driving the "Waiting for WiFi" notice. Also wires the previously inert Smart Caching / Queue Pre-caching / WiFi Only settings toggles to CacheConfigDownloadsUR-053Done (pending device verification)
DR-081/downloads split into a default Downloaded browse view and a secondary Transfers activity view, with a view switch and a Transfers badge shown only while transfers are activeUIUR-055Done
DR-082Offline-scoped browse entry point in the repository client: browse downloaded content only (offline repository get_items/get_libraries — downloaded items plus their containers) independent of server reachability, without merging server catalogStorageUR-055Done
DR-083Downloaded browse reuses library grids, cards, and detail pages via the offline-scoped source; omits libraries/containers with no downloaded content; badges partially- vs fully-downloaded containers; play uses the local file; remove available at item/album/season/series levelUIUR-055Done
DR-084Transfers view renders only in-flight rows (downloading/queued/paused/failed/waiting-for-WiFi) with Pause/Resume/Cancel/Retry; completed transfers leave the view and appear in DownloadedUIUR-055Done
DR-085Per-item on-disk size: stat downloaded files, aggregate to album/season/series subtotals and a device total, format in consistent rounded human units; surface size on cards and detail pages, the device total on the Downloaded surface, and a reclaim figure in the remove confirmationDownloadsUR-056Done
DR-086Settings page persists each control on change via per-group writers (playerSetAudioSettings / playerSetVideoSettings / updateCacheConfig) rather than a batch Save action; slider controls persist on change (pointer release) not each input tick; no Save button, saving, or saveMessage stateSettingsUR-057Done
DR-087MediaCard gains an onLongPress prop with pointer-based long-press detection (~500 ms hold, cancelled on >10 px move so carousel scroll is unaffected, trailing click suppressed); home carousels wire tap→detail/focus routing and long-press→confirm→player; episode taps route to /library/<seriesId>?episode=<id>; the bare-episode detail page links to its parent series/seasonUIUR-058Done
DR-088Skip-to-next-episode marks the outgoing episode played (markAsPlayed) instead of reporting a stop position, and arms a one-shot suppression consumed by the player's stop handler so VideoPlayer's post-navigation unmount stop report cannot overwrite the 100% progress with the partial positionUIUR-059Done
DR-089Continue Watching suppresses resume entries superseded by Next Up: an in-progress episode whose series has a next-up entry strictly later in series order (season, then episode) is dropped from the Home and TV rows; movies, series without a next-up entry, and items with unknown/mixed episode ordering are always keptUIUR-059Done
DR-090Relevance ranking in Rust (domain/search_rank.rs): results sort by match position (prefix → word-start → mid-word substring → no name match) then by media kind (containers before their contents), stably so the backend's own relevance breaks ties. Applied in repository_search to both the instant cache result and the merged cache+server union, so the list does not reshuffle when server results landBackendUR-060Done
DR-091Search result groups split TV into separate Shows and Episodes groups and add a People group (default order: Shows → Episodes → Movies → Songs → Albums → Artists → People); a stored tvShows order from before the split expands in place to shows+episodes so an upgrading user keeps their arrangementUIUR-060Done
DR-092Video tap gestures resolve in tapGestures.ts (pure, unit-tested) rather than inline in VideoPlayer.svelte: registerTap classifies each tap and the component acts on it immediately — togglePlayPause for a first tap, or seek (+30 s right / −10 s left) plus a re-toggle for a second tap inside DOUBLE_TAP_WINDOW_MS (300 ms). A consumed pair resets the state, and a swipe forgets the tap. The deferral this originally used was removed in DR-098, which also covers suppressing the compatibility click the browser synthesizes after a touch tap. resolveSeekTarget converts the delta to the absolute position the facade requires, clamped per DR-095 and chained off a still-in-flight pendingSeekTarget so back-to-back skips accumulate instead of all resolving against a not-yet-updated positionUIUR-061Done
DR-094Frontend boundary tripwire (scripts/check-frontend-boundary.sh) detects Jellyfin item-type array literals anywhere in src/ rather than only inline at an includeItemTypes: query site, so a category→type mapping cannot evade the check by being assigned to a named const (the evasion that let the scoped-search leak pass CI); requires two adjacent type literals so single-type presentation and item.type === inspection stay legal, and caps the allowlist to force taxonomy into Rust instead of accumulating exceptionsTooling-Done
DR-098Video tap gestures act immediately — no deferral, no timer, and only first/second taps exist. A first tap toggles play/pause; a second tap inside DOUBLE_TAP_WINDOW_MS seeks and toggles again, so the two toggles cancel and a double tap preserves the play state (playing → jump and keep playing; paused → jump and stay paused). This replaces a design that deferred the first tap behind a 300 ms timer so a second tap could cancel it: the timer cleared its own handle before invoking the toggle, which reopened the tapTimeout !== null guard in handleVideoClick meant to suppress the compatibility click Android's WebView synthesizes after a touch — the late click then toggled a second time, producing a pause/unpause loop (long-press was unaffected, which is what identified the tap path). Click suppression no longer depends on the timer: handleVideoClick ignores detail === 0 and any click within TOUCH_CLICK_SUPPRESS_MS of a touch tap. A swipe undoes the touchstart toggle exactly once (latched on swipeGestureActive) so brightness swipes never change play state. Click suppression is shared by every click target layered over the video via isSynthesizedTouchClick, not just the <video>: pausing renders a full-screen play-overlay button, so the synthesized click lands on that and an unguarded handler there resumed immediately — pausing appeared impossible while unpausing worked, because unpausing removes the overlayUIUR-061Done
DR-099The video seek bar is usable by touch. Two Android-only defects made dragging or tapping it move the thumb without moving playback. (a) Gesture hijack: the container-level gesture layer skips touchstart on a control (DR-098) but kept handling touchmove, so a seek-bar drag was measured against the previous gesture's start point — a huge bogus vertical delta that read as a brightness swipe, dimmed the screen to the 0.3 floor, and fired a spurious play/pause "correction" mid-drag. A gesture is now latched at touchstart (playerGestureActive) and touchmove ignores anything not latched, since re-checking the move target cannot recover a start point that was never recorded. (b) Commit signal: the seek was committed only from change, which Android's WebView does not reliably fire for a touch interaction on a range input — the thumb moved to the tapped position and no seek ever ran. touchend/mouseup now commit as well; input arms a one-shot latch so whichever release signal arrives first commits and the other is a no-op. seekRelative shares the same commitSeek entry point instead of fabricating a synthetic change eventUIUR-005, UR-061Done
DR-097Transport authority (play/pause/toggle) lives in Rust for webview-rendered media, not just native. The controller tracks the state the HTML5 element reports (html5_playing, fed by report_html5_state, which now stores rather than only re-emitting); play/pause/toggle_playback consult it and drive the element by emitting a ControlCommand that playerEvents.handleControlCommand executes against the active adapter. A stopped/idle report clears it so the native backend (MPV/ExoPlayer) regains authority for music. The frontend facade no longer short-circuits transport into the adapter: adapter.toggle() previously decided play-vs-pause by reading el.paused off the DOM, a value that flips transiently while an element buffers or settles a seek — so two intents ~150 ms apart read different values, performed opposing actions, and self-sustained a play/pause loop needing no further input (observed on Android with a fully-buffered readyState=4 networkState=1 element). Same "backend decides, adapter executes the primitive" split as player_seek_videoPlayerUR-005Done
DR-096Html5PlayerAdapter.play() is resilient to stall recovery: an in-flight attempt is memoised so concurrent callers (UI plus hls.js gap-controller recovery) share one element.play() instead of stacking calls, and an AbortError ("play() request was interrupted by a call to pause()") is logged at debug rather than pushed to host.onError. The browser raises it whenever a pending play promise is superseded by a pause/seek/source change, which hls.js does routinely while nudging past a stall — reporting it surfaced a player error roughly once per second for the whole stall and left the UI stuck showing pausedPlayerUR-005Done
DR-095Seek targets clamp strictly inside the media (clampSeekTarget, END_SEEK_MARGIN_SECONDS = 6 s ≈ one HLS segment) instead of to the exact duration. Landing on the duration makes hls.js request the segment whose start time lies past the end of the media (e.g. a 6330.324 s item → segment 1055 starting at 6336.33 s), which Jellyfin never produces; the fetch times out and hls.js' gap-controller stalls at the last buffered position, presenting as "unpausing or skipping bounces straight back to paused". Applied on both seek paths — the relative-skip resolveSeekTarget and the seek-bar drag, whose range input max is the duration itself — and floored at 0 so media shorter than the margin still seeks to the startUIUR-061Done
DR-100Leaving a video and re-entering it renders the video player, never the audio one. Both halves of the /player/[id] decision are pure and unit-tested in playerSurface.ts. (a) shouldReuseActivePlayback excludes video: the "already playing, just show the UI" shortcut (added for expanding the audio mini player) returns before a stream URL is fetched, which is fine for audio — the backend owns the stream and the route only mirrors it — but leaves <VideoPlayer> with nothing to render. Closing a webview-rendered video deliberately emits no stopped state (that would break the autoplay handoff, see DR-047), so the Rust controller still reports that movie/episode as its loaded media and re-entering the same item hit the shortcut. (b) resolvePlayerSurface maps video-without-a-stream-URL to pending (spinner) instead of falling through to <AudioPlayer>, so no future path can put video content in the audio surface. Video now always takes the full load path, which fetches the stream URL and applies the stored resume positionUIUR-005Done
DR-101"Where is this viewer in this series" is resolved in Rust, not the frontend. repository_get_series_episodes performs the season fan-out (get_items(series_id) → seasons → get_items(season_id), plus the flat-series fallback for shows whose children are episodes rather than season folders) and returns them in series order — season index ascending, episode index ascending, specials (season 0) after every numbered season. repository_get_series_current_episode layers the pure policy pick_current_episode over that list: an in-progress episode wins (earliest in series order on a tie — it is literally where playback stopped, and Next Up would skip past it), then the server's Next Up for that series, then the first unwatched episode, then the first. The third rung is the offline path, not dead code: OfflineRepository::get_next_up_episodes returns an empty vec, so without it the feature would be online-only. A failing Next Up or resume lookup degrades to empty rather than failing the call. repository_get_next_up_episodes had accepted a series_id since it was written and no caller had ever passed oneRepositoryUR-062Done
DR-102The series detail page anchors on that answer. It calls repositoryGetSeriesEpisodes once instead of fanning out over seasons in TypeScript (the fan-out and its flat-series fallback were domain knowledge in the presentation layer), groups the returned episodes under season headers by parentIndexNumber, and passes the resolved current episode to SeasonSectionEpisodeRow, which renders a highlight ring and scrolls itself into view. The hero button navigates to /library/<seriesId>?episode=<currentId> — the Episode Focus View, where an explicit Play/Resume commits — per ux-flows §5B.5: Play on a container is navigation, Play on a leaf commits. It previously resolved $libraryItems[0], the first season by SortName, and navigated to /player/<seasonId>, which the player route bounced back to /library/<seasonId> — so Play on a series played nothing and landed on the season-1 pageUIUR-062Done
DR-103A season is not a destination. /library/<seasonId> redirects to /library/<seriesId>#season-<indexNumber>, the anchor SeasonSection renders, so a season link scrolls the series' continuous episode list rather than opening a page. Every inbound link follows: the episode breadcrumb, handleItemClick case "season", the TV landing page's case "Season", and DownloadedBrowse. A season carrying no seriesId (deep link into a stale cache) still renders the generic view so the user is never stranded. This removes a surface that had no route of its own — it fell through the detail page's kind chain to the generic "Contents" poster grid, contradicting ux-flows §5A.2 (episodes must be a row list), and clicking an episode there opened a bare Episode page, which §5B.1 forbidsUIUR-062Done
DR-104The "More Episodes" strip spans the whole series in series order, per ux-flows §5B.2's cross-season continuity rule: at the end of a season the window runs on into the next season's first episodes instead of dead-ending. adjacentEpisodes previously filtered the pool to parentIndexNumber === current.parentIndexNumber and sorted by indexNumber alone, so the window could never leave the current season — and, when episodes of several seasons did reach it, sorting by episode number alone interleaved them. Cards crossing a season boundary are labelled SxEy rather than a bare episode number so the jump is legibleUIUR-062Done
DR-105Video library routes collapse to one per library. /library/tv and /library/movies render browse / all-titles / genres as in-page tabs driven by ?view=, omitted for the default browse (the convention searchRouteUrl already uses for the all scope); resolveLibraryView is pure and unit-tested. The four legacy routes become redirect-only +page.ts loads rather than deletions, because GenreTags links to them and users have them in history; resolveSearchScope keeps its /library/shows branch for the same reason. The "Browse" tile grid at the bottom of both landing pages is removed — it was a second navigation affordance to the same destinations the carousels' "Show all" links already reachUIUR-063Done
DR-106Erasing watch history goes through the repository, not the local cache: clear_watch_history(item_id) maps to Jellyfin's DELETE /Users/{userId}/PlayedItems/{itemId}, which clears the played flag and zeroes the resume position, and which the server applies recursively to a folder — so one call handles a whole series or season. OfflineRepository returns RepoError::Offline rather than clearing locally, because history diverged only on the device would be silently undone by the next sync; the button disables itself while the server is unreachable. ClearHistoryButton is shared by the series hero and each SeasonSection header, confirms before acting (there is no undo), and reloads the page on success so the recomputed current episode — the premiere, for a fully cleared series — is what the viewer seesRepositoryUR-064Done
DR-107Seasons on the series page are collapsible, and only the current season is expanded on load — the one holding the episode DR-101 resolved. A show with ten seasons otherwise renders every episode of every season at once, burying the one episode the viewer came for under hundreds of rows. Expansion state is per season and pure (initialExpandedSeasons in seriesNavigation.ts): the current season, or the first season when there is no current episode, so a never-watched show still opens on season 1 rather than fully collapsed. A ?episode= deep link expands that episode's season too. Toggling is local and not persisted — it is a reading position, not a preferenceUIUR-062Done
DR-108The instant (cache) leg of repository_search searches the synced catalog, not just downloads. OfflineRepository::search replaces its downloaded_items CTE with the available_items CTE get_items already uses — the same downloads branches plus a synced_at IS NOT NULL branch gated on the same include_catalog_browse() flag — so search and browse cannot diverge on what is visible. Online (flag true) search reads the whole index and answers before any HTTP request completes; offline with "Show all server media" off (flag false) it stays downloads-only, unchanged. Requires no frontend change, since the flag is already set correctly for all three states. The include_item_types filter is switched from string interpolation to bound parameters, as SearchOptions is settable from the frontend and not only from SearchScopeBackendUR-065Implemented
DR-109Index freshness is a Rust-owned policy, not a frontend startup call. A tokio task ticks every 30 min and runs a full pass when a repository is active, the server is reachable, and last_catalog_sync (already persisted to app_settings, previously read only for a UI hint) is older than CATALOG_INDEX_TTL (6 h); the ConnectivityMonitor reconnect signal re-evaluates the same condition immediately. An AtomicBool prevents concurrent passes, replacing offlineCatalog.ts's syncInProgress — the frontend trigger is removed rather than left alongside, since two triggers with one guard each is how double-crawls happen. RepositoryManager gains an active-handle slot so the task has something to run against. Progress is emitted as the kebab-case catalog-index-eventBackendUR-065Implemented
DR-110Index hygiene. save_to_cache switches from INSERT OR REPLACE INTO items to ON CONFLICT(id) DO UPDATE: REPLACE fires no AFTER DELETE trigger unless recursive_triggers is on (it is not — only foreign_keys and journal_mode are set), so items_ad never ran, and because items.id is a TEXT PRIMARY KEY each replacement also took a fresh rowid and appended a second items_fts entry — a duplicate index per sync, invisible in results but permanently degrading MATCH. The upsert preserves the rowid items_fts keys on and fires items_au; migration 021_rebuild_items_fts clears orphans on existing installs. Separately, a post-crawl sweep deletes synced-but-not-downloaded rows a successful library crawl did not return, so media removed from the server stops being searchable; it skips items with completed downloads and skips any library whose crawl errored, because items.parent_id is ON DELETE CASCADE and a partial crawl would cascade away a whole seriesStorageUR-065Implemented
DR-111The index covers what the result groups render: CATALOG_ITEM_TYPES gains MusicArtist and Playlist, and migration 022_people_fts adds a people_fts virtual table over the existing people table (which had no FTS, and is populated incidentally by item-detail fetches) with the same trigger pattern as items_fts. OfflineRepository::search UNIONs people_fts matches in as Person items when the resolved scope admits them — i.e. SearchScope::All, which expands to no filter (DR-063). Without this, the Artists and People groups UR-060 mandates can only ever be filled by the server legStorageUR-065, UR-060Implemented
DR-112Safe-area insets come from native, not from env() alone. env(safe-area-inset-*) is 0px without viewport-fit=cover (missing from app.html, so every safe-area rule in the app was already a no-op), and even with it Android WebView maps only the display cutout — never the status bar or navigation bar. Since enableEdgeToEdge() plus targetSdk 36 make edge-to-edge unconditional, the WebView always spans the system bars, so CSS could not learn about them by any route. WindowInsetsBridge reads the real insets and publishes jt-inset custom properties; app.css folds them with env() via max() into --safe-*, which is the only thing components may pad from. Ownership is exactly one element per edge: the app shell takes top/left/right, and BottomUi takes bottom wherever it renders (shellReservesBottomInset hands it back to the shell on routes with no bottom UI) so the padding sits inside BottomUi's surface box and the colour extends behind the gesture bar. The full-screen players inset their control layers only, leaving video and artwork edge-to-edge. The theme's fitsSystemWindows=true — which claimed the opposite and was overridden at runtime and ignored at this target SDK — is removedUIUR-066Done
DR-113MediaItem.user_data is populated from the server instead of being hardcoded None. JellyfinItem gains a UserData field (#[serde(alias = "UserData")] → the existing UserData type) and to_media_item maps it, so every list and detail response carries favourite/played/resume state. UserData is named explicitly in the Fields= list rather than relying on Jellyfin's default. Without this no card or detail page can render a favourite it did not itself set, and the mini player's per-track storageGetPlaybackProgress fetch is the only way to colour one heartRepositoryUR-069Done
DR-114Server favourite state is mirrored into the local user_data table by OfflineRepository::save_to_cache — the single choke point every cached server result passes through — so offline browsing and the offline Favourites page see the same favourites as the server. The upsert carries pending_sync = 0 and is guarded by WHERE user_data.pending_sync = 0, which is the conflict rule: a toggle made offline is never overwritten by a stale server value before it has been pushedStorageUR-069Done
DR-115Cross-library favourites query: a get_favorites(scope, options) repository method plus the repository_get_favorites command. Online issues Filters=IsFavorite&Recursive=true with IncludeItemTypes expanded from SearchScope::item_types() in Rust (the frontend sends the opaque scope, never a type list — DR-063); offline reads items ⨝ user_data (is_favorite = 1) under the same include_catalog_browse() gate as browsing; hybrid races cache against server like get_items — saving server results through to the cache on a miss, so the favourites page does not re-query the server every visit and the DR-114 mirror is filled on a fresh install — and applies the DR-080 rule that an empty offline result is authoritative when the gate is off. The command falls back to this read when nothing is cached, rather than painting an empty state it will correct a round trip later. A separate method rather than get_items because favourites span libraries and get_items is ParentId-shapedRepositoryUR-067Done
DR-116GetItemsOptions.favorites_only filters an existing library listing in place — online by appending Filters=IsFavorite, offline by joining user_data into the existing available_items CTE so the downloads-only gate still applies. This is what backs the per-library favourites toggle, and composes with the genre and item-type filters already thereRepositoryUR-067Done
DR-117The Favourites page (/library/favorites) renders favourites across libraries with All / Movies / Shows / Music scope tabs, reusing LibraryViewTabs + LibraryGrid + MediaCard so card shape still follows the media (§5A.1) and a mixed All tab reads as posters, squares and thumbnails side by side. Each tab sends a SearchScope value and nothing else. Reached from the library overview and from "See all" on the home rowsUIUR-067Done
DR-118Home carries favourite rows for movies, shows and music, loaded via repository_get_favorites per scope and rendered below Recently Added. A row with no items does not render at all, so a fresh install shows no empty favourite rowsUIUR-067Done
DR-119FavoriteButton is mounted wherever a whole item is shown — movie/series/episode detail heroes, album/artist/playlist headers, and as a MediaCard artwork overlay — and a favorites store holds in-session optimistic state so un-hearting on one surface updates every other without a refetch. Resolution order is store override ?? item.userData?.isFavorite ?? false. On a card the heart is its own button and stops propagation, so hearting never also opens, plays, or triggers the §5B.5 long-press; it is suppressed on server-only (greyed) cardsUIUR-068Done
DR-120Favourite toggles made while offline reach the server. A Rust drain, triggered by the ConnectivityMonitor offline→online transition, pushes every user_data row with pending_sync = 1 and clears the flag on success, leaving failures pending for the next transition. It lives in Rust rather than the frontend because a frontend drain dies with the component that started it. Both the drain and the hybrid background refresh emit the kebab-case favorites-changed event ({ itemIds }) so open views update — without it a favourite marked on another client appears only on the second visit to a page, since the cache-first read returns local rows and the server refresh is invisible to the frontend. Supersedes the unused syncService.queueFavorite, which is deleted rather than left as a second queueBackendUR-069Done
DR-121Player quality selector: Rust reports the bitrates available for the current media source and owns the quality→transcode-parameter mapping (the one get_video_download_url already holds — playback calls into it rather than restating it, or the two tables drift). Changing quality re-negotiates the stream URL and resumes at the current position with audio/subtitle selection preserved. On Linux, video re-negotiates within HLS: returning stream.mp4 is the documented cause of transcoded playback never starting. The frontend renders the list and remembers the choice; it does not decide what the choice resolves toUIUR-070Proposed
DR-122The playback path is ephemeral. Streamed bytes are never persisted unless DR-124 rules them keepable, and any in-flight capture is abandoned — partial file deleted, never promoted — the moment the viewer changes quality, because a capture spanning a rendition change is a splice of two encodings rather than a playable filePlaybackUR-070Proposed
DR-123The download path is independent of playback: a whole-file fetch through the existing download manager at one canonical quality (default original, the direct static copy) over the Range-capable /Videos/{id}/stream.mp4, unaffected by bitrate changes and completing into an ordinary downloads row so offline browsing and refresh_queue_local_sources pick it up unchanged. Prerequisite: downloaded video is currently never played locally — repository_get_video_stream_url goes straight to the online repo and the player route calls it with no local check, so a completed video download is still streamed. Without that fix nothing in this spec is observable for videoRepositoryUR-071In Progress
DR-124Streamed bytes are kept only where they are the download artifact — a direct-play session. Android uses ExoPlayer SimpleCache/CacheDataSource keyed by item and media-source id so renditions cannot collide, sharing the existing smart-cache storage budget rather than opening a second one over the same disk; Linux audio uses mpv stream-record, abandoned on seek because it is documented as intended for linear streams and seeking breaks the recording. Transcoded Linux video is not captured: HLS segments are not a file, and assembling one needs ffmpeg, which is not a dependency and which CI may not install at job time — DR-123 covers that case insteadPlaybackUR-071Proposed
DR-125A capture is promoted to a completed downloads row only when it covers the whole resource; partials stay evictable cache. A new downloads.source_rendition column records the negotiated quality/container/codec (NULL for the existing paths, which are always original) so a captured transcode and a real download are distinguishable rows and an "upgrade to original" remains possible. A quality change never touches a file that already exists — not a permanent download, and not a completed temporary one, both of which stay valid copies of the rendition they hold. It invalidates only an in-flight capture or background download of cached media, which is abandoned and restarted at the newly chosen quality, because a capture spanning a rendition change is a splice of two encodings rather than a playable fileStorageUR-071Proposed
DR-126Cache eviction only reclaims the temporary tier. evict_lru_async selected every completed download ordered by completed_at ASC with no download_source filter, so hitting the 10 GB storage limit deleted the oldest download — typically a film saved deliberately for offline — to make room for a newly precached track. It now evicts only COALESCE(download_source, 'user') = 'auto' rows; COALESCE rather than a bare equality because rows predating migration 012 can be NULL and unknown provenance must be treated as the user's, never as disposable. Freeing less than requested is the correct outcome when only user downloads remain — the caller reports "unable to free enough space" instead of silently deleting themStorageUR-071Done
DR-127A cache entry is a download with a shorter life: same downloads row and same file handling, distinguished by download_source = 'auto' plus an expiry, so there is one storage model rather than a cache and a download library that can disagree. Temporary rows are reclaimed on whichever comes first — the life limit elapsing, or eviction under space pressure (DR-126). Permanent ('user') rows have no expiry. A temporary row can be promoted to permanent by the user choosing to keep it, which only clears the expiry and flips the source; the bytes never moveStorageUR-071Done
DR-128Audio-only playback of downloaded media reads the local file rather than fetching an audio-only stream. No transcode is involved or wanted: the Linux backend already runs MPV with video: no, so handing it the downloaded video file decodes the audio track and ignores the video, and ExoPlayer disables its video renderer equivalently. Transcoding to a separate audio artifact would cost CPU and battery, need an encoder the project does not ship, and produce a second file to keep in step — for no gain over simply not decoding the videoPlaybackUR-071Done
DR-129A stream that stops delivering is recovered, not treated as terminal. Two failure shapes, because the streams differ. (a) Phantom end — the background audio-only handoff uses a progressive mp3 transcode over plain HTTP, chunked and therefore length-less, so a dropped connection reaches the player as end-of-input and ExoPlayer reports STATE_ENDED indistinguishably from the real end. The item's runtime is the only thing that can tell them apart: an end reported more than a tolerance short of it (comparing the absolute position — handoff base plus the player's relative position) is a truncation. Left unhandled, playback parked in STATE_ENDED and the next play intent from the lockscreen, notification or a Bluetooth reconnect seeks an ended player to position 0 — the user-visible "the episode randomly restarted". (b) Recoverable error — music (/Audio/{id}/stream?Static=true) and video (/Videos/{id}/master.m3u8) declare their length, so the player detects the truncation itself and raises an error; the frontend's handler stopped playback outright, turning a hiccup into silence. Both resume the current item in place (never via play_item, which would replace the queue with a single item and lose the album), the error path after a per-attempt backoff. Seekable streams are re-prepared at the URL they already have and seeked; the length-less transcode, which cannot be seeked, has StartTimeTicks rewritten into its existing URL so the user's audio-track selection survives and recovery needs no network round-trip. Only Remote sources qualify — a local file cannot fail from the network. A shared budget of consecutive attempts at the same position, refilled whenever playback progresses, stops an unreachable server from loopingPlaybackUR-040, UR-004Done
DR-130A backend's position and duration must survive the end of the file they describe. MPV exposes time-pos/duration as properties of the loaded file, so at EOF it unloads and both stop resolving — the accessors reported 0.0/unknown at exactly the moment end-of-file handling asks where playback reached, and any position-versus-runtime check would have read every natural end as a truncation. The poll thread records the last reading and the accessors fall back to it. Linux resilience is layered on the same principle that the stream, not the player, is what failed: MPV is configured with ffmpeg reconnection (stream-lavf-o, network-timeout) so ordinary blips never surface, and EndFile(ERROR) — previously a bare log, which left playback halted while the UI still showed "playing" — is emitted as a recoverable error. Because MpvBackend is constructed before PlayerController exists, it cannot decide in-process like the Android JNI callback: the frontend echoes the error into player_recover_stream, which keeps the decision in Rust (the same shape as PlaybackEndedplayer_on_playback_ended). Android reports errors it has already declined as unrecoverable, so the echo never asks twicePlaybackUR-004, UR-040Done
DR-131The offline mutation queue is drained. sync_queue had producers and no consumer: PlaybackReporter::queue_for_sync writes a row for every start/stop/mark-played that cannot reach the server, sync_mark_processing/_completed/_failed were registered commands with no callers, and no Rust task processed the table — so queued watch positions never reached Jellyfin and the offline banner's count only ever grew. A drain hangs off the same connectivity:reconnected transition as DR-120 (in Rust, because a drain started by a component dies with it) and replays rows oldest-first, so a stale start cannot move the server's resume position backwards after a later stop. update_progress replays as stopped at N rather than as progress — replaying a mid-playback report hours later would claim the item is still playing — and payloads are read in both dialects that exist in users' databases (position_ticks from Rust, camelCase positionMs from the frontend helper). A failed row stays queued for the next reconnect; after MAX_SYNC_ATTEMPTS it is abandoned and stops counting, because a row nothing can ever push is what turns the queue into a counter that only grows. An unreachable server is not counted as an attempt at all — the row goes back to pending untouched — so opening the app offline a few times cannot abandon good rows; only a server that answers and refuses spends the budget. The drain also runs once at startup, because a queue built in a previous session would otherwise sit untouched for a whole run whenever the server was reachable the entire time and no offline→online transition ever fired. Requires MediaRepository::mark_played (JA-035) — the previous stand-in reported a stop at i64::MAXBackendUR-025, UR-002Done
DR-132The pending-sync count is answerable. The offline banner's badge read "N pending sync(s)" and led nowhere, so it was taken for pending transfers and looked for on the Downloads page — which lists the downloads table and structurally cannot show sync_queue rows. The badge becomes a button opening the queue it counts: each row's operation, the item's title (resolved by a LEFT JOIN items in sync_get_pending, not a per-row frontend fetch), when it was queued, and the error of anything failing, plus a "Sync now" that runs the DR-131 drain on demand. The same list is a Settings section, because a row that keeps failing is still queued when the server is reachable and no banner is on screen. The drain emits sync-queue-changed so the badge updates on reconnect instead of lagging by up to one 10s pollUIUR-025Done
DR-133A downloaded file has exactly one on-disk path, and the row that names it is authoritative. downloads.file_path starts relative to the storage root, but the worker rewrites it to the absolute path it actually wrote when the transfer completes — so a completed row is already rooted. The video player's offline branch rooted it a second time, handing the asset protocol /data/user/0/app//data/user/0/app/videos/x.mp4; the webview reported MEDIA_ERR_SRC_NOT_SUPPORTED with NETWORK_NO_SOURCE, so every downloaded video failed to play while audio — which resolves the same column through Rust's resolve_local_media_path, without re-rooting — played fine. The join is absolute-aware (POSIX, Windows drive letters and UNC) so rows written before completion still resolvePlaybackUR-071Done
DR-134The webview can actually fetch the local files it is handed. convertFileSrc rewrites a path to http://asset.localhost/… unconditionally, but Tauri only answers that origin when the protocol-asset cargo feature is compiled in and app.security.assetProtocol.enable is set — neither was, so every such URL reached a protocol with no handler and the webview reported NETWORK_NO_SOURCE. This silently defeated both offline video (<video src>) and the cached-thumbnail path in imageCache, which fails soft to the server copy and so hid the breakage whenever the server was reachable. The scope was $APPDATA/** — the storage root under which the database, downloads/ and the thumbnail cache all live — rather than an unrestricted grant; DR-198 narrows it further to $APPDATA/thumbnails/**, since DR-137 moved downloaded media off this protocol and thumbnails are all it still servesSecurityUR-071Done
DR-140An audio track is pinned only when the user picked one. Jellyfin's MediaStream.Index is global across every stream in a media source, so index 0 is the video stream on virtually all files — yet AudioStreamIndex=0 was sent as "the first audio track" on the HLS transcode URL, the background audio-only handoff URL, the direct-play fallback URL, and the PlaybackInfo negotiation body. A server that honours the request literally then transcodes the video stream into the audio slot and the result plays as a picture with no sound; only servers that silently correct the index hid the bug, which is why it presented as "some videos have no audio". The parameter is now omitted whenever no track has been chosen, so the server resolves the source's DefaultAudioStreamIndex; an explicit selection from player_switch_audio_track is still carried through unchanged. On the static=true direct-play URL it is dropped outright — the original file is served untouched, so the parameter could only misleadPlaybackUR-004, UR-040Done
DR-147One search input per screen, and the URL is the search's single source of truth. The header bar rendered only under /library/** and merely navigated to /search (DR-063), so a desktop search handed the user to a screen whose input was a different element — the header box cleared itself and vanished, and the page's own box took over mid-word. That page then re-derived its input from ?q= against library.searchQuery on every store write, so the next keystroke re-ran the effect and snapped the text back to the query the header had sent (and a scope chip back to the URL's scope); entering from the bottom-nav Search tab skipped it only because the effect early-returned on an empty query. The bar now renders on /search too (showHeaderSearch) and is the sole md+ input — the page's own input is md:hidden — and on that route it republishes the query into the URL with replaceState, so a whole session of typing costs one history entry. The page consumes that URL once per distinct value (seedFromSearchUrl against a non-reactive applied marker) instead of continuously reconciling it, and the scope chips publish through the same URL so the bar and the chips cannot disagree. Landing on /search with a seeded query focuses the bar and puts the caret at the end, because the box the user was typing in belonged to the unmounted routeUIUR-049, UR-054Done
DR-142An episode has exactly one surface, and it is complete. Two divergent renderings existed: EpisodeFocusView (reached from Continue Watching, the series episode list, the TV landing page and Downloads — i.e. every real entry point) offered only Play and Favourite, while the bare /library/<episodeId> page nobody routed to carried the download button, the series/season breadcrumbs and the cast section. Opening an episode the normal way therefore silently lost the ability to download it. The Focus View is now the single surface and carries the full §5B.2 composition — hero action row Play / Download / Favourite, series name and SxEy badge as links back to the series and to that season's anchor, then genres → cast → similar shows below the episode strip, never above it (DR-062). /library/<episodeId> redirects into it (episodeRedirectTarget, the same rule seasons follow under DR-103), and an episode with no seriesId renders the same component series-less rather than falling back to a second, lesser page. The focused episode is fetched in full rather than reused from the season fan-out, because that is a list query and carries neither cast nor genres — the sections would have rendered empty. The strip hides itself when the episode has no siblings, a card that only shows the episode you are already on being noiseUIUR-048, UR-058Done
DR-141The device profile states how many channels the audio route can actually voice. MediaCodecList answers "can this device decode 5.1", which is not the question that decides whether the user hears anything — a phone decodes an AC-3 5.1 track happily and still has two channels to play it out of. With no MaxAudioChannels in the profile, Jellyfin was free to direct-play the multichannel track, and the result is device dependent: a failed AudioSink configuration (silence) or dialogue folded into surround channels that go nowhere. media3's AudioCapabilities.maxChannelCount for the current route is reported over JNI alongside the codec lists, and bounds both the direct-play profile and the transcoding profiles, so the server downmixes rather than shipping channels the sink cannot take. Codecs are never removed from the profile — a device with genuine surround output keeps direct-playing it. A missing or zero reading means "route not yet established", not "no audio", and falls back to stereo, the one capability every sink hasPlaybackUR-004Done
DR-145Video playback starts only once the app actually holds audio focus. Video manages focus by hand (handleAudioFocus=false, because ExoPlayer's automatic handling is reserved for the audio path), and the request's three outcomes were all treated as success: AUDIOFOCUS_REQUEST_DELAYED — which setAcceptsDelayedFocusGain(true) explicitly invites, and which means the system is withholding our audio until it calls back — and an outright REQUEST_FAILED were logged and then followed by playWhenReady = true. The picture rolled with no sound, indistinguishable to the user from a broken stream. Playback is now held when focus is not granted and started from the AUDIOFOCUS_GAIN callback; an explicit play() re-requests focus rather than resuming into a stream the system is still muting, guarded by a held-focus flag so repeated plays do not leak focus requests. A LOSS clears the pending flag, so an unrelated later GAIN cannot start playback the user never asked forPlaybackUR-004Done
DR-146The no-audio-track fallback picks a track the renderer can actually play. When ExoPlayer selected no audio track, the recovery forced group 0 / track 0 unconditionally — but the most likely reason nothing was selected is that this very track cannot be decoded on this device, so the override reinstated the silence it was meant to fix. It now scans the groups for the first isTrackSupported track and overrides to that, and clears setTrackTypeDisabled(TRACK_TYPE_AUDIO) because audio may equally have been off at the type level, which an override alone does not undo. When no group holds a supported track the condition is logged as an error — the server was expected to transcode — rather than leaving a silent video with no explanation in the logPlaybackUR-004Done
DR-148The video direct-play profile advertises only what the webview can decode. The audio codec list comes from MediaCodecList, which describes ExoPlayer — but video does not play through ExoPlayer on either platform: Android force-renders every video in the webview <video> element (the interim override in VideoPlayer.svelte, because the native SurfaceView sits behind an opaque webview) and Linux always has. Chromium and WebKit decode a far narrower set than the platform does, and the gap is widest on devices whose vendor licenses Dolby: a phone shipping /vendor/etc/media_codecs_dolby_audio.xml reports ac3,eac3, so Jellyfin direct-played an E-AC-3 track with static=true and the webview built a video decoder and no audio decoder at all — full picture, no sound. The defect is triggered by capability, not the lack of it, which is why it reproduced on one Motorola while a Fairphone and an Honor tablet played the same file on the same build: a device without the Dolby decoder never claims the codec, so the server transcodes to AAC and it plays. video_audio_codecs narrows the platform list to the webview-decodable set (aac,mp3,opus,vorbis,flac) for the video direct-play profile only — the audio-only profile keeps the full list, since that playback really is the native player's and narrowing it would transcode music that plays perfectly well. A list with nothing decodable still claims aac rather than going out empty, because a profile that claims nothing invites the server to give up instead of transcoding. The video codec list is deliberately untouched: HEVC direct-plays through the webview correctly, so the constraint is specific to audioPlaybackUR-004Done
DR-149The client decides whether its own renderer can decode the audio, rather than trusting the server's negotiation. Advertising a webview-shaped profile (DR-148) turned out to be necessary but not sufficient: Jellyfin 10.11.5 enforces a DirectPlayProfile's Container and VideoCodec — excluding either returns SupportsDirectPlay: false with TranscodeReasons=ContainerNotSupported / VideoCodecNotSupported — but ignores its AudioCodec, offering an E-AC-3 track for direct play against a profile listing only aac,flac,mp3,opus,vorbis. Neither a VideoAudio CodecProfile forbidding the codec nor a MaxAudioChannels: 2 against a 6-channel track changes the answer, so no profile the client can send fixes it and the picture plays silent. The negotiated source's audio is therefore checked locally against what the webview decodes, and an undecodable track forces the existing h264/aac HLS transcode URL regardless of the server saying direct play is fine — direct_play and needs_transcoding are corrected to match, so the frontend and the reporting path agree with the URL actually used. The track judged is the one the server would serve: the default, or the first when nothing is marked default, since a supported track further down the list is not the one that plays. A source with no audio streams, or a stream whose codec the server did not name, is left alone — forcing a transcode on a guess spends server CPU on files that already playPlaybackUR-004Done
DR-150Android video renders on the native ExoPlayer surface behind a transparent WebView, behind the experimentalNativeVideo opt-in. Rust already reported use_html5_element: false on Android, but two frontend overrides discarded it — createAdapter() hardcoded "html5", and VideoPlayer.svelte forced useHtml5Element = true and stopped the native backend player_play_item had just started. The flag is a suppressor, never a promoter: off forces HTML5 even where Rust says native, so an in-progress spike cannot ship as the default, but it can never select native where Rust reported HTML5 (Linux cannot composite behind WebKitGTK, so promoting there is a black screen). Compositing requires clearing two independent opaque layers, and clearing only one leaves audio over a black picture — the WebView widget background and window drawable from Kotlin (AndroidVideoSurface.setTransparent), and the page's html/body and app-shell background from CSS (data-native-video). Transparency is declared in tauri.android.conf.json rather than the base config, because a transparent window on Linux has nothing behind it, and is toggled per playback session rather than set once, because a permanently transparent window shows the launcher through the rest of the appPlaybackUR-003, UR-004Done (behind experimentalNativeVideo, default on since DR-194/DR-196)
DR-151The player's video SurfaceView actually reaches the view hierarchy. JellyTauPlayer.setActivity() had zero callers, so currentActivity was always null and autoAttachSurface() returned at "Cannot attach surface - no Activity reference". The surface was created and handed to ExoPlayer but never added to the content view, so native video decoded to a surface that was never on screen — independent of any webview transparency. MainActivity.onCreate now supplies the reference, which also revives PiP on the video path: canEnterPip() gates on isVideoSurfaceAttached(), which had been permanently falsePlaybackUR-003, UR-041Done
DR-152Platform playback facilities are reported by Rust, not sniffed from the user agent. webviewAudio.ts re-derived "does this platform have a native audio backend" by matching navigator.userAgent against android/linux — a second copy of the cfg! gate the backends are compiled under, free to drift from it. player_get_capabilities now returns usesWebviewAudio and supportsNativeVideo from the same cfg gates, and the frontend consumes them; the settings toggle for native video is hidden entirely where the platform cannot support itPlayerUR-003, UR-005Done
DR-153The git tag is the single source of truth for a release version. The version lived in four files (package.json, tauri.conf.json, Cargo.toml, Cargo.lock) that had to be hand-edited in lockstep, and CI's release job rewrote exactly one of them — so a tagged build produced an installer named for the tag wrapped around package metadata naming the previous release, while the Linux job had no version step at all and shipped whatever was committed. scripts/set-version.sh writes all four from one argument and is the only thing that does; every release job calls it with the tag. The Android versionCode is derived in the same place as 1000 + major*10000 + minor*100 + patch, which is monotonic in semver order and clears the 1000 floor already installed in the field — a lower code than the installed one makes Android refuse the update. A prerelease suffix is stripped before that arithmetic, which would otherwise abort the script, and a non-tag ref (CI passes ${GITHUB_REF#refs/tags/} unconditionally) falls back to git describe rather than failing a branch buildBuild-Done
DR-154A watch position that cannot reach the server is queued, not dropped. sync_queue and its drain (DR-131) were built, tested and running, but the stop-report path never fed them: HybridRepository::report_playback_stopped is a bare pass-through to the online repository ("Playback reporting goes directly to server"), and on failure the error surfaced to a frontend catch whose own comment read "Server error - could queue, but for now just log". Both producers that would have queued it — PlaybackReporter::queue_for_sync in Rust and syncService.queuePlaybackProgress on the frontend — have no callers on the playback path, so closing a video while the server was unreachable lost the resume point outright even though user_data.pending_sync was dutifully set to 1 and nothing ever drains that flag for positions (unlike favourites, DR-120). The command layer now enqueues a report_playback_stopped row whenever the push fails, which the existing drain already knows how to parse and replay. The pending row for an item is superseded in place rather than appended to: progress is reported every 10s, so a server that stays down would otherwise add a row per tick, all of them obsoleted by the newest — the unbounded queue DR-131 exists to prevent. Only pending/failed rows are superseded, because an abandoned row has been given up on and reviving it would restore that same growing counter. Queueing is best-effort and never fails the command: the local position is already saved, so a failed queue write must not be reported as a lost positionBackendUR-025, UR-002Done
DR-155A watch position set on another device reaches this one. The resume check reads the local user_data row and nothing else, but mirror_user_data — the only path by which server UserData lands in that table — mirrored is_favorite alone, and returned early whenever that field was absent, which is exactly the shape of an ordinary watched episode. So playback_position_ticks was write-only from this device's perspective: watch 40 minutes in a browser, open JellyTau, and it resumed from whatever this device last saw or offered no resume at all — the same user-visible symptom as DR-150's Android bug, from an unrelated cause, which is why resume read as broadly flaky. The mirror now carries the position alongside the favourite flag under the same pending_sync = 0 conflict rule, so a local position still waiting to be pushed is never pulled backwards by a server that has not yet heard where we got to; COALESCE(excluded.x, user_data.x) means a field the server omitted keeps its stored value rather than being nulled, and a row with neither field is still skipped rather than fabricated as zeroes. Mirroring alone was not sufficient: get_item — the call the player route makes — returned the cached copy on a hit and never consulted the server, so for an already-cached item the mirror never ran. It now refreshes in the background on a cache hit (race_with_refresh, the reusable form of what get_items already did inline), which is why browsing a season picked up other devices' state while opening the episode directly did not. The refreshed value lands for the next read, the cache-first race still answering immediatelyBackendUR-025, UR-002Done
DR-156A page no longer inherits the previous page's scroll position. The shell keeps its scrollers alive across navigation by design — the root layout, the home page and the library layout each own a flex-1 overflow-y-auto box that outlives the route inside it, which is what lets BottomUi be a flex sibling rather than a measured overlay — but the element therefore never remounts and its scrollTop survives the route change. SvelteKit's own scroll restoration could not help: it saves and restores window scroll, and in this app the window never scrolls at all, so there was no scroll handling of any kind. The symptom was that opening an item from half-way down a library grid dropped the viewer half-way down the detail page, and returning to the grid landed at the top of it — exactly backwards. ScrollMemory (pure, one instance per container, keyed on path + query so a genre-filtered grid keeps its own place) records the offset a route is left at in beforeNavigate and decides in afterNavigate: link/goto/form reset to the top, popstate restores that route's saved offset, and the initial enter is left alone. Deciding does not consume the offset, so a route returned to more than once restores each time. Applied via the scrollContainer action on all three scrollersUIUR-072Done
DR-160Picture-in-picture works on the path that actually plays video. PiP shrinks the whole Activity, so canEnterPip demanded a native ExoPlayer SurfaceView be attached and rendering — isPlayingVideo() && getSurfaceView() != null && isVideoSurfaceAttached(). But the native path sits behind experimentalNativeVideo, which defaulted to off, so in the shipping configuration video played in the WebView's <video> element and all three conditions were false. enterPip bailed with "Not entering PiP: no local video playing" every single time: the button was offered (gated only on OS capability) and could not work, however it was pressed. The manager now accepts either surface. The frontend reports the element through AndroidPictureInPicture.setHtml5VideoState(active, width, height, playing) — intrinsic size because the PiP window's aspect ratio came from the letterboxed surface's measured bounds, which do not exist here, and play state because ExoPlayer.isPlaying is false on this path and the PiP play/pause action would be frozen on "Play" mid-playback. Two behaviours invert when the WebView is the video: it must stay visible in PiP rather than be hidden (hideWebView is now gated on the native path — hiding it would leave an empty black window), and the play/pause RemoteAction has to reach the element, so the receiver dispatches jellytau-pip-play/jellytau-pip-pause DOM events instead of driving ExoPlayer. jellytau-pip-entered/-exited let the player strip its own chrome, since controls, title and gradients would otherwise be rendered into a window a couple of inches wide. The <video> is deregistered on teardown so PiP is never offered over a video that has goneUIUR-041Done (pending device verification)
DR-167Each downloaded library shows only its own media. Cached items carry no link back to their library — library_id and parent_id are NULL on every row ([[offline-libraries-never-cached]]) — so get_downloaded_items matched the library branch with EXISTS (SELECT 1 FROM libraries l WHERE l.id = ?), which asserts only that the requested library exists and never constrains the item to it. Opening any downloaded library therefore listed every downloaded top-level item on the server: films under Music, albums under TV. The sibling query that decides which libraries appear already carried the right rule — a collection_typeitem_type mapping — so the two disagreed about the same question. That mapping is now the named constant LIBRARY_HOLDS_ITEM, used by both, and a library of unknown collection type still keeps everything rather than being emptied by a rule that cannot classify it. The taxonomy stays in Rust, never the frontendDownloadsUR-055Done
DR-168Pause and resume actually stop and restart the bytes. pause_download wrote status = 'paused' and did nothing else, and no cancellation existed anywhere in the download stack — no token, no flag, no abort — so the streaming task ran on, kept writing, and overwrote the row with completed/failed when it finished: the row flicked to "paused" and undid itself. resume_download had the mirror defect, flipping the row to pending without calling pump_download_queue; the pump runs when something calls it rather than polling, so a resumed download sat untouched until an unrelated event happened to pump the queue. A per-download stop flag (download::stop) is the missing half — a module-level registry because the two sides never meet, the command holding Tauri state and the worker running detached in async_runtime::spawn. The worker reads it between chunks and on retry (so a pause is not swallowed by a 45-second backoff), flushes, and returns Stopped, which is deliberately not retryable and not recorded as a failure: the .part file is left intact because that is exactly what the resume's Range request continues from. Registering returns a fresh flag, or a resumed download would inherit the pause that stopped it and halt instantly. Cancel and clear_stale_downloads signal it too, so neither deletes a file still being writtenDownloadsUR-055Done
DR-169Partial files are actually reaped. The worker named its sidecar with Path::with_extension("part"), which replaces the extension — movie.mp4 became movie.part — while every cleanup path deleted "{file_path}.part", i.e. movie.mp4.part. The two never matched, so the partial file of every cancelled or failed download stayed on disk indefinitely, invisible to the disk-usage totals because no downloads row pointed at it. partial_path appends instead, is the single definition both the writer and the cleaners use, and incidentally removes a collision the old form had, where movie.mp4 and movie.mkv mapped to one movie.partDownloadsUR-055Done
DR-173Downloading an album queues the whole album, and every track it queued is findable offline afterwards. Two independent gaps left an album with a handful of its tracks on the device while the button reported the album as downloaded. First, download_album took its track list from items WHERE album_id = ? — the local catalog cache. Jellyfin does not return AlbumId on every listing endpoint, so tracks cached by one of those endpoints sit in items with a NULL album_id and are invisible to that query; on the reporter's database three whole albums (18, 12 and 9 tracks) had it NULL on every track, so "download album" would have queued nothing for them, and a partially-linked album queued only the linked subset. Second, the frontend then resolved one stream URL per track from its own list and paired it with the returned row ids by position — a pairing with no basis, since the ids came back in the backend's index_number order over a different set of rows, so a row could be handed another track's URL and any track past the end of the shorter list was never started at all; on Android that loop also stopped wherever the webview was suspended. The same album_id is what OfflineRepository::get_items joins a track to its album on, so a track that did download stayed invisible under its album offline — the two halves of the same missing link. The operation now belongs to Rust end to end: HybridRepository::get_album_tracks asks the server what the album contains (cache-first get_items is right for browsing and wrong for deciding what to download) and errors offline so the caller falls back to the ungated local catalog, keeping the queue-while-offline flow; queue_album_tracks writes the album link onto every track it queues — queuing a track is the statement that it belongs to the album, rather than something to hope a listing endpoint recorded — and the stream URLs are resolved here through the existing reconnect resolver, now scoped to the rows just queued so one album cannot start every unrelated pending row. Nothing crosses the IPC boundary but the album id. Re-queuing a broken album heals it: the missing tracks are added and the tracks already on disk get their link. download_series/download_season still derive their episode lists from the cache the same way and want the same treatmentDownloadsUR-018, UR-055Done
DR-170Downloads at a chosen bitrate are no longer corrupted by their own retries. Only the original preset asks for Static=true; every other rung requests a transcode, which Jellyfin serves chunked, with no Content-Length, and cannot byte-seek — so it ignores Range and answers 200 with the whole stream from the beginning rather than 206 with the requested tail. The worker sent the Range header whenever a .part existed and appended the body unconditionally, so each retry and each resume concatenated a fresh copy of the entire transcode onto the bytes already on disk: the file grew past its real size and would not play, which is why "downloads for different bitrates" stayed broken after the videoBitRate casing fix (DR-adc460f3) corrected the request. resume_offset makes the response decide — append only on a 206, otherwise truncate and take the stream from the top — and the total size is computed from that offset rather than from a partial length the server never agreed toDownloadsUR-071Done
DR-172Native Android video is opt-in again, because as a default it shipped as audio with no picture. DR-161 flipped experimentalNativeVideo on so picture-in-picture could shrink a real video surface; on a device that produced sound and a blank screen. The decode path was never the problem — logcat showed ExoPlayer running (Position update ticks) and feeding a live SurfaceView with an active BufferQueue. The compositing was: the SurfaceView sits behind the WebView, and the step that clears the opaque layers above it never took effect, with WebView transparent = false logged and = true never appearing. So the video rendered correctly the whole time, behind an opaque page. This is exactly the defect the flag existed to contain — VideoPlayer.scrubRegression.test.ts had recorded that "the native SurfaceView has never been visible through the webview" — and enabling it by default shipped a verified decode path on top of an unverified display path. Reverting costs nothing that matters: PiP does not depend on it (DR-160 drives PiP from the WebView <video>), and working video outranks PiP showing a native surface. The flag stays available in Settings, now described as incomplete rather than as a performance win, and the scrub-regression mocks that were made explicit under DR-161 are kept explicit so those tests state which path they guard rather than inheriting a default that has now moved twice. Fixing the compositing is the prerequisite for trying this default againUIUR-003, UR-004, UR-041Done
DR-171A downloaded video keeps audio the device can actually decode. original quality asked for Static=true, which hands back the source file byte-for-byte — E-AC-3/AC-3/DTS/TrueHD track included — and video is rendered on both platforms by the webview <video> element, which decodes none of them. Streaming already knew this: DR-149 judges the track the server would serve against WEBVIEW_AUDIO_CODECS and forces a transcode over Jellyfin's own direct-play offer, because 10.11.5 honours a DirectPlayProfile's container and video codec but ignores its audio codec. The download path never consulted that policy, so the same film had sound when streamed and played as picture in silence once downloaded — and offline a download is the only source a video has, so there was no working path left to fall back to. The rule is now one rule: served_audio_codec picks the track the server will serve (the default, or the first when none is marked) and both callers judge it, the streaming verdict staying a bool and the download path needing the codec itself so it can say what to re-encode. Only the audio is re-encoded — allowVideoStreamCopy=true keeps an h264 source's picture byte-for-byte and no bitrate or resolution cap is added, so original still means original quality; a source the webview could not have rendered anyway (HEVC) becomes h264 as a side effect, which is the only form of it that would have played. The decision is per item rather than blanket because the transcode costs the byte-range resumability Static=true gives the download worker (see DR-170 for what a chunked, length-less response does to a resume), so a file whose audio already plays keeps the direct copy. An unknown codec — item not fetchable, or the server named none — changes nothing: the policy only ever adds a transcode, so it cannot make a working download worse. The codec set judged against is the webview's, not the platform's, even though DR-161 made ExoPlayer the Android default: experimentalNativeVideo is a user setting, a downloaded file outlives whatever it was set to when the file arrived, and the narrow list is the only one that holds on both sides of it — at the cost of a Dolby-licensed device re-encoding a track its ExoPlayer could have played. resolve_video_download_url is the single entrance for all three resolution sites (the frontend's per-item command, the bulk series/season enqueue, and the offline-queued resume), since the pure builder cannot look a codec up and a caller that forgets to is exactly how the silent downloads shipped. Files already downloaded stay silent — the bytes on disk are the wrong bytes and only a re-download replaces themDownloadsUR-071, UR-004Done
DR-162Video streams are opened against a bandwidth ceiling the user chose, instead of a fixed allowance nobody could change. Every video URL carried MaxStreamingBitrate=20000000/VideoBitrate=18000000, PlaybackInfo negotiated at 20 Mbps, and the device profile advertised 999999999 — so on a metered or slow connection the only lever was not watching. StreamingQuality is a ladder of ceilings (Original, 20/10/8/4/2/1 Mbps, 720 kbps) in which a step is not a label but a bundle of transcode parameters: the total ceiling, the audio share of it, and the resolution that budget can carry. It lives in Rust because those numbers are Jellyfin encoding vocabulary — the frontend names a variant and reads labels back over player_get_streaming_qualities, the same arrangement as the EQ preset curves. The video bitrate is the total minus the audio share, so the two together honour the cap rather than overshooting it by the size of the audio track, and MaxHeight falls with the ladder so a small budget is not spent on pixels it cannot afford. The cap has to reach the negotiation, not only the transcode URL: max_static_bitrate in the device profile is what makes the server refuse to direct-play a source fatter than the ceiling, and without it a 30 Mbps remux is handed over untouched and every URL parameter downstream is moot — which is why it is applied at all four places that decide bandwidth (the HLS builder, PlaybackInfo, open_live_stream, and the background-audio handoff, which takes the lower of the cap and its own 384 kbps). The ceiling is process-wide rather than a field on OnlineRepository, mirroring INCLUDE_CATALOG_BROWSE: it is a preference about this device's connection, it must survive a repository rebuilt on re-login, and every builder plus the negotiation have to agree on it or the cap leaks. Settings owns the durable default and is the only writer to app_settings — persisted unlike the rest of VideoSettings, because a limit set for a metered connection that silently reverts to uncapped on the next launch spends the user's data with no changed setting to show for it — and it is restored at startup from the async runtime, defaulting to uncapped if the read fails so a database problem degrades to the old behaviour rather than to an arbitrary limit. The in-player menu is the per-video override: a cap is a property of the stream the server is producing, so it cannot be applied to one already in flight — player_set_stream_quality re-opens the stream at the new quality and resumes at the current position, reloading a native backend itself and handing HTML5 a URL for the same reloadSource primitive the audio-track switch uses, so no strategy branch lives in the UI. It deliberately does not persist. This gives UR-070 its resume-at-the-same-point mechanism; the server-offered per-item rendition list that requirement also asks for remains proposedPlaybackUR-074, UR-070Done
DR-174Tiles of mixed shapes are laid out justified rather than gridded. A CSS grid gives every cell one box, so on a page holding square music covers, 16:9 library backdrops and 2:3 posters at once, everything that is not the chosen shape is cropped to it — the home shortcut strip was explicitly forcing aspect="video" on music libraries for exactly this reason, which lined the row up by cutting the covers down. layoutMosaic packs tiles into rows of a shared height and gives each its own width from its own aspect ratio: it adds tiles to a row until the height needed to fill the container has fallen to the target, closes the row there (so rows land at or below the target, never above), and justifies the row to the container width by absorbing the rounding remainder into its widest tile, where a pixel is least visible. The last row is deliberately not justified — with one tile left over, filling the width would inflate it to a banner — so it sits at the target height, left-aligned. Ratios are clamped to a band, which costs a crop on genuine outliers and stops one panorama owning a row or one very tall image shrinking to a sliver. It is a pure module with no DOM: the component supplies only the two things the DOM knows — the measured container width, and the artwork's decoded aspect ratio, reported by CachedImage so the layout uses the shape an image actually has rather than the one its item type implies. Those measurements are committed in one debounced batch rather than per image, because artwork arrives over several hundred milliseconds and re-packing on each arrival would shuffle the grid under the pointer repeatedly. Labels are drawn over the bottom of each tile rather than beneath it: a caption below sits outside the computed box, and one that wraps to two lines would break the row alignment the layout exists to provideUIUR-075Done
DR-175A library knows which favourites category it belongs to, and the frontend does not work it out. The mosaic offers a favourites tile per category beside its library, which needs a collection-type → category answer; deriving it in Svelte would have re-created the exact leak SearchScope::item_types was extracted to close (docs/specs/scoped-search-boundary.md) — one table of Jellyfin vocabulary, differing only in which vocabulary. SearchScope::for_collection_type maps movies/tvshows/music and returns None for everything else, so a Live TV or books library gets no tile at all rather than one opening an unfiltered list; All is never derived from a library, being the cross-library entry offered beside them rather than a property of one. Library::new stamps the result onto every library at construction — a constructor rather than a struct literal precisely so a derived field cannot be forgotten at one of the four sites — and it rides to the frontend as an optional favoritesScope, absent rather than null when there is none. The UI's remaining share is presentation only: what to call the tile, where to put it, and showing a category's tile once however many libraries share it, since two movie libraries have one favourites list between themUIUR-075, UR-067Done
DR-176The server is never asked to burn a subtitle into the picture. PlaybackInfo omitted SubtitleStreamIndex, which does not mean "none" — the server then honours the source's default/forced flag and picks a track itself. On a source whose default subtitle is image-based (PGS/DVD/DVB) that track cannot go out as a sidecar, so the server falls back to SubtitleMethod=Encode and composites it into the video. The cost lands on the video, not the subtitle: burn-in rules out remuxing, so an HEVC stream the device could have taken untouched is re-encoded frame by frame. Observed on an HEVC + E-AC-3 + PGSSUB episode, where only the audio actually needed transcoding: the server could not sustain the re-encode in real time, the buffer never grew past a single segment, and playback stalled every few seconds — taking seeking with it, since each seek restarted the encoder and cost seconds before the first frame. The fix is to request SubtitleStreamIndex=-1 explicitly and to advertise every text format we can render (srt/subrip/ass/ssa/vtt) as External, so a subtitle can only ever arrive as a sidecar. Nothing is lost, because the app already fetches subtitle tracks itself and draws them over the video (UR-020) — the server's composited copy was always redundant. Image-based tracks are consequently not offered, which is honest rather than a regression: the renderer cannot composite a bitmap, and the previous behaviour paid for them by making the whole stream unwatchable. Both halves of that hold at the layer that can enforce them. The sentinel travels on the stream URL as well as in the negotiation, because the negotiation is not what opens most streams — a quality switch, a transcoded seek and an audio-track switch each rebuild the URL on their own, and an omitted index there lets the server pick the default track back up out of whatever session state it still holds. And "not offered" is enforced where the offer is made: each subtitle stream crosses the boundary carrying the backend's verdict on whether it can arrive as a sidecar, so the picker lists only tracks the app can draw instead of showing an entry that ticks and displays nothing. Only an explicit "no" hides a track, so a stream carrying no verdict behaves as beforePlaybackUR-020, UR-004Done
DR-177Each video transcode this device opens is its own server-side job, and the one it replaces is stopped. Jellyfin keys a transcode job by device and play session, and every stream URL the app built carried the same hardcoded DeviceId with no PlaySessionId at all — so the second stream for an item was indistinguishable from the first. Re-opening a stream is not rare: a mid-playback quality switch (UR-074), a transcoded seek and an audio-track switch all do it, each leaving the previous ffmpeg running. Observed on-device when switching bitrate mid-film: the server served the new playlist, then rejected the new job's segments with 400 hls1/main/0.ts while the two jobs contended for one transcode path, and playback stalled — reproducible against the server, where a second stream for a live job's item alternates between serving bytes and 400ing per attempt, which is what made it read as flaky rather than broken. begin_video_play_session mints a session id per open and reports the one it supersedes; the URL builder stops that job (DELETE /Videos/ActiveEncodings, un-retried and best-effort — a slow stop must not delay playback, and the new stream no longer collides either way) before returning. Placing it in the URL builder rather than in each caller means every re-open path is covered by construction. Two client faults made the same incident worse and are fixed with it: the fatal-HLS-error handler added the transcode seek offset to a position that already included it, so past roughly the halfway mark of a film any transient network error cleared the "near end" threshold and was reported as end-of-stream — turning a recoverable stall into a skip to the next item, exactly when a quality switch had just made the offset large; and the HTML5 reload primitive resolved on its own canplay timeout, so a reload the server never served reported success, leaving the picker showing a quality that was not playing and the caller with nothing to revertPlaybackUR-074, UR-004Done
DR-178Every position that leaves the app is read from the controller, not from a backend that may not be playing anything. PlayerController::position() forwards to the native backend, which is authoritative for exactly one of the three ways this app renders media. On the webview path — the shipping default for video on both platforms — nothing is loaded into that backend at all: the <video> element is the player, its ticks were re-emitted to the frontend and then dropped, and the backend answered 0 forever. During a background-audio handoff the base that converts the stream's relative timeline to the episode's is applied once at the native tick boundary (DR-159), so before ExoPlayer's first tick nothing has applied it and the reading is 0 there too. Both holes surfaced as the same user-visible bug through different doors: returning to the foreground while the audio-only transcode was still opening handed the frontend 0.0, and the video reloaded at StartTimeTicks=0 — the episode restarting from the beginning — while the Stopped report that followed wrote that zero to Jellyfin as the resume point. absolute_position() answers for all three paths: the maximum of the backend's reading, the last position webview-rendered media reported, and the handoff base. The maximum is exact rather than a heuristic, because at most one term is ever meaningful at a time and the base is a floor the stream cannot physically be behind. duration() gains the same fallback for the same reason. The element's reading is cleared wherever it stops being the player — teardown, a handoff taking over, a different item loading — so it can never be attributed to what plays nextPlayerUR-005, UR-025, UR-040Done (pending device verification)
DR-179Jellyfin is told what was played: progress while it plays, and a stop when it ends. A device trace of 35 minutes' playback requested /Sessions/Playing/Progress zero times and sent 14 Stopped reports, every one of them at position 0. Three faults, one subject. Progress never left the device: the frontend service writes it to the local DB by design, and nothing on the Rust side reported it for webview-rendered media — so the server learned a position only when the player was closed, and a crash or a swipe-away cost the session. It is now reported from the controller's own position ticks, through the 30s throttler it already owned and shares with the native audio path, which covers all three rendering paths in one place instead of adding a second frequent IPC caller. Zero-position stops were sent: Jellyfin stores the reported position as the resume point, so a zero does not merely fail to inform, it instructs the server to forget — and no zero was ever real, each one coming from asking a player that was not rendering the media (see DR-178). They are withheld; one landed 40s after the frontend had correctly reported 15:22 for the same episode, overwriting it. A finished episode reported nothing at all: Jellyfin decides "watched" from the stop report and its percentage, and in background audio-only mode nobody sends one — the webview is suspended and its element was torn down at the handoff, while the backend advances to the next episode without a word about the one that ended, so an episode listened to end-to-end on the lockscreen never counted as watched. on_playback_ended now reports it stopped at its runtime (not the last tick, which can be seconds short or, on a handoff whose ticks stopped early, nowhere near the end) before any advance, since after one the queue's current item is the next episode. Scoped to the audio-only handoff, the case the frontend provably cannot cover, so foreground playback keeps its single existing report; music ending natively remains unreported and wants its own change. The reporting seam is a PlaybackReportSink the controller sends to, which also collapses three copies of the spawn-a-task-and-hope block into one and is what let all of this be written as failing tests rather than found on a device a second timePlayerUR-025, UR-005, UR-040Done (pending device verification)
DR-180A background-audio handoff of a downloaded episode starts where the video left off. The handoff prefers a local file over the audio-only stream (DR-128), but the two begin in different places and were treated alike: a stream is built with StartTimeTicks, so the server makes the handoff point that stream's zero and the base is the handoff position with no seek — while a file has no such parameter and begins at the episode's own zero, so basing it at the handoff position claimed minutes of audio that were about to play from the beginning. Backgrounding a downloaded episode therefore restarted it while the lockscreen scrubber, dutifully adding the base, showed the position it should have been at. background_audio_plan splits the two: a file gets no base and a real seek, a stream keeps the base and no seek (seeking one would skip past the content by the handoff position again). The same distinction settles an inbound seek — seek_absolute re-opens a streamed handoff at the requested position because a chunked length-less transcode cannot honour a seek, which is not true of local media, and resume_stream_at refuses a non-remote source outright, so routing a lockscreen scrub of a downloaded episode through it failed the seek rather than performing itPlayerUR-040, UR-071Done (pending device verification)
DR-181A resumed transcode plays. Every video stream URL carried the resume position as StartTimeTicks, which is correct for a progressive response and fatal for an HLS one: Jellyfin builds each segment URI by echoing the master playlist's query string into it, and its segment handler opens by rejecting any request carrying StartTimeTicks > 0 (ArgumentException400). One position on the playlist therefore 400s every hls1/main/N.ts behind it, so hls.js exhausted its retries and gave up — presenting as an episode that will not resume while the same episode from the beginning is fine, the > 0 being exactly why the beginning survived. The parameter is also unnecessary there: a playlist spans the whole item and asking for segment N is the seek, which the server transcodes from. So it is removed from the URL builder entirely rather than conditionalised — the builder has one caller shape and no way to know whether the response will be segmented — and the position becomes what it always was for HLS, a seek issued once the player has loaded: the seek path reloads at zero and seeks the element, and the resume path lets the player seek itself. The progressive /Audio/universal builder used by the background-audio handoff is a different endpoint with no segments and keeps its StartTimeTicks, which is why an audio-only handoff resumes correctly and a video one did notPlaybackUR-004, UR-074Done
DR-182Native video shows a picture. The poster/title card is an opaque bg-black overlay drawn over the whole video area while isMediaReady is false, and every signal that clears it is emitted by the HTML5 <video> element — canplay, loadedmetadata, hls.js FRAG_BUFFERED, the playing event, and two readyState timeouts. The native path renders no such element ({#if !!useHtml5Element}), so on Android nothing could ever clear it: ExoPlayer decoded to a live SurfaceView behind a black div for the entire session. That is DR-172's "audio with no picture" report, and it is indistinguishable on screen from the compositing failure DR-172 attributed it to — which is why the flag was reverted rather than fixed. Both the overlay and the native branch date from the original POC commit, so the native path has never been able to reveal itself; the 2026-08-11 device verification predates neither and does not contradict this, since a spike run that never reached a steady state would not have shown it. The backend's own events are the equivalent signals and nativeSignalRevealsVideo is the rule for reading them: state === "playing" mirrors the element's playing event, and a position tick carrying a real position or duration mirrors the readyState backstops, covering a first state event that is dropped or arrives before the listener is attached. buffering/paused/stopped/error deliberately do not qualify — revealing on error would replace the title card with a transparent hole showing the launcher through the app. The rule is a pure module rather than a branch inside the component because the decision that was missing is exactly the part worth guarding, and the component needs a DOM and a mounted player to exerciseUIUR-003, UR-004, UR-041Done
DR-183The JavaScript bridges are installed before the page that uses them loads. WebView binds an injected object into JS at page-load time: an addJavascriptInterface call landing after the page has loaded does not appear to that page. They were installed from configureWebViewForMedia, which finds the WebView by walking the view tree 500 ms after onCreate — a race against Tauri's own page load, and one that is permanent when lost, because the identity guard added for DR-097's stale-proxy bug then declines to re-inject on every later resume pass. The whole set (AndroidVideoSurface, AndroidPictureInPicture, AndroidBackgroundAudio, AndroidNetworkType, AndroidImmersive, AndroidInsets) would simply be absent from window, and silently: every call site optional-chains the bridge, so a missing one is a no-op rather than an error. This is a candidate explanation for DR-172's other piece of evidence — WebView transparent = false logged, = true never appearing, i.e. the enable call never reaching Kotlin at all. WryActivity.setWebView() calls the onWebViewCreate hook immediately before wry issues the first loadUrl (confirmed in wry 0.55's main_pipe.rs, where the setWebView JNI call precedes load_url), so a bridge installed there is bound by the time any page runs. The hook can fire during super.onCreate(), before the rest of our own onCreate, so only work needing nothing but the WebView moves into it — insets stay in configureWebViewForMedia, which runs later and on every resume. The tree-walk path is kept as a fallback, and enableNativeVideoCompositing now logs an explicit error when the bridge is missing, so the ambiguity that left DR-172 unresolved cannot recur silentlyAndroidUR-003, UR-004, UR-040, UR-041Done
DR-184The video SurfaceView leaves the view hierarchy when the video does. VideoOverlayManager.detachVideoSurface had no callers anywhere in the tree — the mirror of the DR-151 defect, where setActivity had none — so attachVideoSurface was one-way: JellyTauPlayer.clearVideoSurface() dropped its surfaceView reference and cleared ExoPlayer's without removing the view, leaving it parented to the content view for the life of the process, with the next native video adding another SurfaceView beneath it. The stack was invisible while the WebView was opaque, which is why it went unnoticed. Two consequences outlive the leak: isVideoSurfaceAttached() gates PictureInPictureManager.canEnterPip through isNativeVideoPath(), so it reported an attached surface forever after the first native video (saved from offering PiP over nothing only by the isPlayingVideo() check beside it), and every abandoned surface held its OnLayoutChangeListener on the content view. Detach is called from clearVideoSurface, which covers stop, the switch to audio, and the background-audio handoff, and always runs on the main thread because every caller is already inside a mainHandler.post. It removes the view from its own parent rather than looking the content view up from an Activity reference, so an Activity recreated underneath it cannot strand the viewAndroidUR-003, UR-041Done
DR-185The app shell stops painting over the video surface. app.css clears the page's opaque layers for native video through three selectors, and one of them — html[data-native-video="active"] [data-app-shell] — was written against an attribute no component has ever set, in any commit. The shell is +layout.svelte's root div, which paints --color-background across the entire viewport; VideoPlayer is fixed inset-0 z-50 and correctly makes itself transparent on the native path, but it stacks above the shell, so the WebView still composited the shell's opaque background over the whole screen and the SurfaceView behind it could never be seen. This is the missing half of the compositing DR-172 went looking for: the spec's own layer table lists this layer as "cleared by data-native-video → app.css", which was written but never wired, and html/body being genuinely transparent made the CSS look correct in isolation. The failure is invisible three ways over — the CSS is valid, the selector is plausible, and a rule matching nothing looks exactly like a rule matching something already transparent — while the symptom (black screen, audio fine) is identical to a real compositing failure, which is how it survived DR-150 through DR-172. Fixed by setting the attribute the rule was written for, and guarded by asserting the relationship rather than the rule: every attribute the compositing block targets must be set somewhere in the app, so a selector aimed at nothing fails the suite instead of failing silently on a deviceUIUR-003, UR-004, UR-041Done
DR-186The play overlay comes down when the backend plays. isPlaying was assigned once from the player_play_item response and thereafter only by the player://state-changed listener — a channel the backend never emits, the same dead wire that DR-182's first fix was mistakenly hung on. On the native path the flag therefore froze at whatever the initial response said: with ExoPlayer playing, the UI still believed it was paused, so the bg-black/30 play-button overlay stayed raised across the whole video area and the transport button kept showing ▶. The video was simultaneously dimmed and covered while it played, which reads as "the overlay never goes away" and is easily mistaken for a second compositing fault. The mirror reads the same player store playerEvents.ts feeds, which is what the architecture already says is authoritative — the player reports state, the UI consumes it — and is gated to the native path so HTML5 keeps its element-event wiring, which is authoritative thereUIUR-003, UR-005Done
DR-187The system bars go away with the player, not only with the fullscreen button. enterImmersive() had exactly one caller, toggleFullscreen(), so opening the player left the status and navigation bars painted over it until the user pressed a button most never press. On the native path this is worse than cosmetic: the SurfaceView fills the content view, so the bars sit directly on top of the video. The player is a full-screen surface by construction — fixed inset-0 z-50 over a MATCH_PARENT surface — so entry is the right moment. Called synchronously in onMount before any await, per the native-mode pitfall, and paired with the exitImmersive() already unconditional in onDestroy, so a player torn down while immersive cannot leave the rest of the app without barsUIUR-066, UR-003Done
DR-188Native Android video is ready to be the default except for the background-audio handoff, and the flip therefore waits. The picture defects behind DR-172 are all found, fixed and device-verified — DR-185 (the app shell painted over the surface through a CSS rule targeting an attribute nothing set), DR-182 (nothing could lift the poster card on a path with no <video> element), DR-183 (the JS bridges raced the page load, so setTransparent(true) could never arrive), DR-184 (the SurfaceView was never detached), plus DR-186 and DR-187, the two UI defects only this path could reveal. On a device logcat now carries WebView transparent = true and Marking media ready with video on screen, which is the pair DR-172 went looking for and could not find, and skip, seek and rotation were exercised by hand. Turning the default on then surfaced a different unverified sub-path: the background-audio handoff could only return through the HTML5 element, so coming back from the lockscreen left playback dead, and the flip waited for that rather than shipping a verified sub-path over an unverified one as DR-161 had. The default is now on. The two defects holding it back are fixed and device-verified — DR-196 (the handoff return restarts the renderer that is actually on screen) and DR-194 (the letterbox bars are painted rather than retaining stale framebuffer content) — with the evidence this default has been held to since DR-161: an audio handoff at 69:54 returning to video playing at 70:18, and clean bars across playback, the control bar and a rotation round-trip. An explicit stored choice still wins in both directions, so an opt-out survives the flip (the stored value is null-checked rather than compared to "true", which would have silently re-enabled it for everyone who turned it off)AndroidUR-003, UR-004Done
DR-189The control bar comes down on a touchscreen. Its hide timer was armed from exactly one place — the player container's onmousemove — and a touchscreen never fires mousemove, so on Android the bar was never scheduled to hide and sat over the video for the whole film. It went unnoticed for as long as the native video surface was itself invisible (DR-172/DR-185): with nothing behind it to obscure, a permanent control bar reads as the UI rather than as a defect. Two changes, because there were two faults. revealControls() replaces handleMouseMove and is called on entry and on every touch interaction as well as on mouse movement, so touch arms the countdown. And the countdown became an $effect over the state rather than a one-shot timer armed by the input event: the first attempt armed a timer on entry, three seconds later playback had not started, shouldHideControls correctly declined, and nothing ever re-armed it — the timer has to follow the conditions that permit hiding, which arrive on their own schedule. The decision itself is shouldHideControls in controlsVisibility.ts, pure and separated from the clock and the DOM, because what was wrong here was the conditions and not the setTimeout: the bar stays up while paused (a user who paused by tapping the surface has no other way back), mid-seek (the position readout is the point of the bar then), and while any track/subtitle/quality menu is open (the menus are anchored to the bar, so hiding it would take the open menu with it)UIUR-003, UR-066Done
DR-191Forcing the WebView overlay to redraw from the Activity, because with the ExoPlayer SurfaceView beneath it the overlay's ordinary damage stopped reaching the screen: the page kept mutating — the clock text every second, the control bar's opacity going to 0 — while the display held whatever frame it last presented, over video that animated perfectly. Not a state defect; the live DOM showed the slider advancing 476 → 479 across three seconds behind a screen showing neither. Only structural changes got through, which is why the play overlay always appeared to work (an {#if} block, added and removed) while the progress bar never did, and why rotation lost the transport UI. A CSS animation cannot help, since opacity animates on the compositor without repainting the layer. Superseded by DR-192: this drove postInvalidateOnAnimation in a loop, which treats the symptom — the cause is the SurfaceView's separate layer, and removing that removes the need. Kept as the record of how the mechanism was identifiedAndroidUR-003, UR-004Superseded by DR-192
DR-195Play/pause works on the native path, because the frontend stops claiming a webview element is playing when there is none. html5_playing is Rust's record of "a webview <video> is active and in this state", and toggle_playback, play and pause all route transport to that element whenever it is set. The player route mirrored element state into it unconditionally — from handleReportStart and, fatally, from handleReportProgress, which VideoPlayer calls on a 10-second interval — so on the native path the frontend re-declared every ten seconds that an element was playing when none existed, and every transport intent was emitted into the void. The pause button was dead from the on-screen tap, from the control bar, and from a direct player_toggle invocation, while seek and skip kept working because player_seek_video decides elsewhere; that asymmetry is the signature. It also explains the flashing, since the control bar and the JRay overlay both key off isPlaying, which was being contradicted on every interval tick. DR-193 clearing the flag at load was necessary but insufficient on its own — the interval put it straight back. The mirror now lives in mirrorElementStateToRust in VideoPlayer, gated on useHtml5Element, which is the only place that knows whether an element renders at all; the route cannot tell the two paths apart, which is precisely how it came to lie. Confirmed on device by ADB: surface tap and control bar each pause (position frozen across repeated samples, transport label flipped) and resumePlaybackUR-005, UR-003Done
DR-196Returning from background audio brings the picture back on the native path, because the return now restarts the renderer that is actually on screen. The two paths resume by different means: the webview <video> reloads off its stream URL, watched by an $effect that reinitialises HLS and lets canplay drive the seek — while ExoPlayer owns no element and nothing watches the URL on its behalf, so its playback is only ever started by an explicit player_play_item + adapter load, issued once from onMount. exitBackgroundAudioHandoff did only the URL assignment, for both paths, so on the native path it restarted nothing: player_exit_background_audio had already stopped the handoff's audio player, leaving the backend holding no item at all. The symptom is a black screen with a play overlay pinned at 0:00, a seek bar at zero, and a play button that does nothing — the process alive and the frontend still logging, since nothing crashed; the transition was simply dropped. The branch is decided by planHandoffReturn (pure, in backgroundAudioHandoff.ts), which also folds in shouldResumeOnForeground so a lockscreen pause during the handoff still wins over the snapshot taken on the way out. Subtitle configurations are reused from the ones resolved at mount, since ExoPlayer sideloads them as MediaItem.SubtitleConfigurations and cannot accept one after prepare(). Verified on device: handoff to audio at 69:54, return restored video playing at 70:18PlaybackUR-040, UR-003Done
DR-197Continue Watching and Next Up stop showing the same episode. Jellyfin's /Shows/NextUp defaults EnableResumable=true, which returns a partially-watched episode as its own series' next up — precisely the episode /Items/Resume already returns — so the Home "Next Episode" row and the TV landing's Next Up row duplicated Continue Watching card for card. build_next_up_endpoint sends EnableResumable=false, and because servers predating that parameter ignore it, filterInProgressNextUpItems also drops any next-up entry whose id appears in the resume list. It is the mirror of DR-089 and lives beside it: same presentation-layer de-duplication over two lists the frontend already holds, no Jellyfin taxonomy involved. The resume filter still reads its frontier from the unfiltered Next Up list, so removing in-progress entries cannot resurrect a stale resume card. The division is then exact: Continue Watching offers episodes the viewer has started and not finished, Next Up offers the episode after the ones they finishedRepositoryUR-059Done
DR-200The lockscreen notification is exempt from POST_NOTIFICATIONS, because of the session token, not because it belongs to a foreground service — and the difference is what the code now records. POST_NOTIFICATIONS was declared in the manifest and requested nowhere, so on Android 13+ it sat permanently denied; an audit read that as a threat to UR-006, since the media notification is what carries the lockscreen transport controls. It is not. Android's own wording is that the permission covers "non-exempt (including Foreground Services (FGS)) notifications", with denied users seeing FGS notices "in the Task Manager but [not] in the notification drawer" — so an FGS notification is explicitly not exempt — while separately "Notifications related to media sessions are exempt from this behavior change". The platform predicate is Notification.isMediaNotification(), which requires MediaStyle and a non-null EXTRA_MEDIA_SESSION, and it is byte-identical across API 33–36. NotificationManagerService uses it to decide whether to drop the post, and SystemUI's media carousel (MediaDataProcessor.onNotificationAdded) is gated on the same predicate — so a token-less notification is not merely absent from the shade, it never reaches the notification listener and the lockscreen/Quick-Settings controls do not exist at all. Confirmed on device (HONOR ROD2-W09, Android 16 / SDK 36): appops POST_NOTIFICATION: ignore, granted=false, and the service simultaneously isForeground=true with foregroundNoti=Notification(category=transport actions=3 vis=PUBLIC). So no runtime permission request is added — a prompt the app does not need is a prompt that can be permanently denied for nothing — and no checkSelfPermission gate is placed on startForeground, which would trade a cosmetic problem for the "did not then call Service.startForeground()" kill. What is added is the guard that matches the real precondition: mediaSessionCompat?.sessionToken is a null-safe call, and the exemption hangs entirely on it, so both builders now bind the token once and log an error if it is ever null while the permission is denied — converting a failure that is invisible unless the tester happened to deny the permission (most grant it reflexively) into a logcat line. The manifest declaration is kept, unrequested, and documented: media3 does not need it (media3-session declares no permissions and the MediaSessionService guide asks only for the two FOREGROUND_SERVICE ones), but the exemption covers media and self-managed-call notifications only, so a download-completion notice (UR-011) would be an ordinary notification and silently dropped — keeping the declaration is what makes adding one a one-file changeAndroidUR-006Done
DR-201A lockscreen skip means different things depending on what is playing, and the backend decides which. onSkipToNext/onSkipToPrevious forwarded a bare "next"/"previous" to Rust, which always advanced the queue — correct for music, wrong for a video whose audio is running through a background-audio handoff (UR-040), where the buttons should scrub. Pressing skip to re-hear a line jumped to the next episode instead. resolve_skip_action in player/seek.rs maps the command to either Advance or SeekTo, and is_background_audio_active() is the whole test: the handoff exists only for video, and an episode played through it reports MediaType::Audio, so media type cannot distinguish the case. Forward jumps 30s, back 10s — asymmetric because the back button replays dialogue just missed rather than travels — and both clamp to [0, duration], since a negative offset is rejected by backends and a seek past the end reads as EOF and would advance, the very outcome being prevented. Routed through the same spawn-then-seek_absolute path as the scrubber, because a handoff seek re-opens the stream and must not run under the blocking lock (DR-159). The Kotlin keeps sending the same opaque command; only the PlaybackStateCompat gains ACTION_FAST_FORWARD/ACTION_REWIND so the system draws seek affordances rather than skip arrows that lie about what they doPlaybackUR-040, UR-006Done
DR-202Video keeps the display awake. Android counts its display timeout from the last user input, and watching something is exactly the case where there is none, so the screen dimmed and slept mid-film unless the user kept tapping it. Nothing held it: FLAG_KEEP_SCREEN_ON appeared nowhere in the app, and neither renderer supplies a hold for free — ExoPlayer's setWakeMode is a CPU/wifi wake lock that says nothing about the display, and it draws into the TextureView this app owns (DR-192) rather than media3's PlayerView, which is the widget that would otherwise set keepScreenOn itself; the WebView <video> path is no better, because the display wake lock Chrome takes for video lives in the browser layer and not in an embedded WebView. ScreenWakeManager toggles FLAG_KEEP_SCREEN_ON on the Activity window — window-scoped, so it stops applying the moment the app is not visible and cannot outlive a crash the way an explicitly acquired PowerManager.WakeLock can, and it needs no permission (the manifest's WAKE_LOCK is the media service's). The two rendering paths are independent holders OR-ed in the pure ScreenWakeState: the native path follows onIsPlayingChanged plus surface teardown, so the hold tracks what ExoPlayer reports rather than what the UI intends, and the webview path reuses the setHtml5VideoState report the frontend already sends for PiP (DR-160) rather than adding a bridge. Audio is deliberately not a holder — playing music with the screen off is the point of that path — so the hold is gated on the media type being video, and it is dropped on pause, on stop, on surface teardown, and on a new WebView, since a page that goes away never sends its own final active = false. Also the repo's first Kotlin JVM unit tests: ScreenWakeState is framework-free so the decision is testable off-device with ./gradlew :app:testUniversalDebugUnitTest. Verified on device (FP5, native path): IS PLAYING CHANGED: truekeepScreenOn = true 17 ms later and fl=KEEP_SCREEN_ON on the window in dumpsys, a pause releasing it and the resume re-taking it. The webview path is unverifiedAndroidUR-003, UR-004Done
DR-203The background-audio handoff stops silently rewinding to the point it started. A player retry is only a retry if it can resume where the load failed, and ExoPlayer decides that in ProgressiveMediaPeriod.configureRetry: it keeps the load position when the content length is known or the extractor produced a seek map with a duration, and otherwise assumes the source is live — the data at the URL is taken to have changed, so every sample queue is reset and the URL is re-requested from offset 0. The handoff transcode (/Audio/{id}/universal?Container=mp3&TranscodingProtocol=http, DR-129) satisfies neither condition: chunked, so no Content-Length, and a live mp3 encode carries no Xing header, so the duration is unset — on device every position tick reads <position> / 0.0. Its URL carries StartTimeTicks = the handoff point, so "from offset 0" is the handoff point, and after any transient load error playback resumed there and ran on normally. Nothing was reported: a successful retry raises no error and no STATE_ENDED, so neither arm of DR-129 was ever consulted, no onPositionDiscontinuity handler existed, and the app's only trace of it was a position that went backwards — which is why it read as random, since it needs a network blip to land while a load is in flight rather than while the ~50s buffer covers it, and why it survived the two earlier fixes for the same symptom (DR-129's phantom end, DR-159's relative-timeline leak). The decision is Rust's: player_retry_restarts_stream marks a Remote audio-only video item, and loadWithMetadata carries the answer to Kotlin, where the pure StreamRetryDecision holds it for a DefaultLoadErrorHandlingPolicy subclass that returns C.TIME_UNSET — which makes onLoadError answer DONT_RETRY_FATAL before reaching configureRetry. The rewind therefore becomes a recoverable error, and recoverable_error_resume already knows what to do with one: re-open at the position playback actually reached, StartTimeTicks rewritten, with backoff and the shared attempt budget. Every other source keeps the player's retry, because a static file and an HLS playlist both declare their timeline and are resumed in place. A onPositionDiscontinuity handler is added for the log line alone, so a recurrence is visible rather than invisible — loud for DISCONTINUITY_REASON_INTERNAL, which is the rewind's own signature, and quiet for the backwards jump a resume's re-prepare legitimately makes. Reproduced and verified on device (FP5), same procedure both times: background-audio handoff, 60s to fill the buffer, a 45s radio outage, then watch. Before — the outage passed unnoticed and 3.5 minutes later, with nothing logged in between, BUFFERINGREADY → position 1165.4s840.3s, exactly the handoff base, no error and no STATE_ENDED; the same log line reports Media ready! Duration: -9.223372036854776E15, which is C.TIME_UNSET and the precondition itself. AfterLoad error on a stream that cannot be resumed in place — declining the player's retry at the outage, playback continuing undisturbed off the buffer for 69s (a fatal load error is only raised when the renderer next needs data), then ERROR_CODE_IO_NETWORK_CONNECTION_FAILEDre-opening at 785.6s in 2sREADY, playing on from 785.6s with no rewind in the following 7 minutesPlaybackUR-040, UR-004Done
DR-199The webview stops undoing the network security config. MainActivity.configureWebViewSettings set mixedContentMode = MIXED_CONTENT_ALWAYS_ALLOW together with allowFileAccess = true and allowContentAccess = true, which is a blanket cleartext opt-in reached by hand — exactly the thing network_security_config.xml exists to prevent and its own comment warns against (DR-138). Nothing needed any of the three. file:// is never loaded: cached thumbnails go through convertFileSrc, which on Android resolves to http://asset.localhost/… and is answered by wry's request interceptor rather than the filesystem, and downloaded media goes over the loopback HTTP server (DR-137), which exists precisely because the asset/file route cannot stream a large file. content:// is never loaded either — the manifest's FileProvider is for outbound share intents, not webview navigation. And mixed content never arises: Tauri serves the UI from http://tauri.localhost (use_https_scheme defaults false and is not set in tauri.conf.json), while both 127.0.0.1 and asset.localhost are loopback/.localhost origins that Chromium treats as potentially trustworthy, so they are not mixed content to begin with. A plain-HTTP remote Jellyfin server would be, but the network security config already rejects it before any mixed-content check runs — so ALWAYS_ALLOW bought nothing and only widened the hole. COMPATIBILITY_MODE rather than NEVER_ALLOW is a deliberate hedge and not the default — the platform default at targetSdk 21+ is NEVER_ALLOW — because none of this can be verified anywhere but a device, and compatibility mode keeps passive content (images) working if the analysis missed a path. allowFileAccess = false restores the targetSdk-30+ default; allowContentAccess = false is a genuine tightening (its default is true) and is the first thing to look at if something that used to render stops. The two files now cross-reference each other so the pair cannot drift apart againSecurityUR-071Done (pending device verification)
DR-194Stale pixels in the letterbox bars — the rotation "flash of the previous frame", a ghost control bar stranded in the top bar, each new clock digit drawn over the last (35:42 with the 1 still showing through the 2), and menus (sleep timer, quality) leaving their imprint behind. One cause for all of it: nothing painted the bars. The window surface is opaque (the theme is not translucent), and for an opaque surface HWUI deliberately does not clear the damaged region before replaying a frame — it assumes the view hierarchy covers every pixel. That hierarchy is window background → video TextureView → transparent WebView, and fitSurfaceToScreen sizes the TextureView to the letterboxed video rect, so the bars were the window background's alone to paint. setTransparent(true) cleared that background to TRANSPARENT, leaving the bars painted by nobody and whatever was last in the framebuffer surviving in them. Fixed by keeping the window background opaque black while compositing; the WebView's own background is what lets the video through, and the TextureView is drawn on top of the window background, so an opaque one cannot hide it. Three earlier fixes aimed at the window's rotation animation and at TextureView frame-retention (two postOnAnimation hops, an onSurfaceTextureUpdated reveal, then ROTATION_ANIMATION_JUMPCUT + FLAG_FULLSCREEN) all missed, because the pixels were never the animation's; the alpha-hiding among them made it worse by blanking the one view that reliably paints its own rect. Those are removed, FLAG_FULLSCREEN included — it fought edge-to-edge insets for no gain. Verified on device: ghosting reproduced with native video on, then absent after the fix, across playback, the control bar and a rotation round-tripAndroidUR-003, UR-066Done
DR-193Play/pause reaches the player that is actually rendering. toggle_playback, play and pause all route to the webview element when is_html5_active(), which is html5_playing.is_some() — a flag written only by the element's own state reports and cleared only when it reports "stopped"/"idle" (or on a background-audio handoff). An element that went away without that final report, or webview-rendered music earlier in the same process, therefore left the flag set, and on Android's native video path every transport intent was emitted as a ControlCommand at an element that no longer existed: the pause button did nothing, from the on-screen tap and from the control bar alike, while seek and skip kept working because player_seek_video decides elsewhere. Whether it happened at all depended on what had played before, which is exactly what made it read as flaky rather than broken. load_and_play — the native load path, and the one the HTML5 video path deliberately avoids via set_current_item — now clears the flag, because loading into the native backend is the statement that native renders this item. Nothing is lost on the webview path: an element re-establishes its own authority the moment it reports again, so this is the existing "element is gone" semantics applied where it can be known directly rather than inferred from a report that may never arrivePlaybackUR-005, UR-003Done
DR-192Native video presents through a TextureView, not a SurfaceView. A SurfaceView renders on its own layer outside the app window and punches a transparent region through it; everything drawn above that hole — for us the entire Svelte UI in a transparent WebView — depends on that composition path, and Android's own graphics documentation states that "overlays do not currently work correctly with SurfaceView or TextureView". The consequences were four symptoms of one cause (DR-191): a frozen progress bar, controls that would not fade, rotation losing the transport UI, and overlays that lingered after the DOM removed them. A TextureView is an ordinary view whose frames are drawn as a texture in the window's normal rendering pass, so there is no second layer and no transparent region, and the WebView above composites like it would over any other view — which is why media3 offers surface_type="texture_view" and why it is the standard remedy for ExoPlayer overlay problems. The trade is accepted rather than hidden: TextureView costs more power and memory than SurfaceView and adds a frame of latency, but hardware decode through MediaCodec is untouched, so the reason native video exists survives it. setVideoTextureView installs ExoPlayer's own SurfaceTextureListener, so the old SurfaceHolder.Callback wiring is deleted rather than ported — adding a listener of ours would displace it and the video would never appear. PiP needs no change, since a TextureView is a View and the aspect-ratio probe reads its measured boundsAndroidUR-003, UR-004, UR-041Done
DR-190The background-audio handoff can return to the native path. Everything that restores playback on the way back is written around the WebView <video>: applyPendingForegroundSeek returns early on !videoElement, the HLS re-init $effect returns early on !useHtml5Element, and pendingForegroundSeek/pendingForegroundPlay — which own the post-handoff position and play/pause — are consumed only by handleCanPlay and markMediaReady, an element event and a path that reaches the same guard. On the native path there is no element, so exitBackgroundAudioHandoff completes, clears handoffState, blanks and reassigns currentStreamUrl to force an effect that will not run, and nothing ever restarts ExoPlayer: the user returns from the lockscreen to a dead player. This never showed while the path was opt-in and its picture was invisible anyway. The return needs the native equivalent of the element reload — re-issue the item to the backend, seek to the position player_exit_background_audio reports, then honour wasPlaying — routed through the adapter rather than the element, so both paths restore through one contractPlaybackUR-040, UR-003Superseded by DR-196
DR-161Native video is the default, so picture-in-picture has a real surface. DR-160 makes PiP work on the HTML5 path, but that path can only ever shrink the UI into the PiP window; showing the video itself needs the SurfaceView behind the WebView, which is what experimentalNativeVideo gates. The flag now defaults to on when the user has never chosen, with an explicit stored choice still winning in both directions so anyone who turned it off keeps it off. This is a deliberate acceptance of risk: the flag existed because the native path was an unfinished spike, and VideoPlayer.scrubRegression.test.ts documents its history — a native init that flipped to HTML5 mid-lifecycle and left seeks going down one path while ExoPlayer played on another. Those tests pin the flag-off interim override (native response overridden to HTML5, backend stopped once), which the default no longer selects, so they now mock the flag off rather than inherit it: they still guard that path, but they no longer describe what ships. The native scrub/seek path is consequently not covered by the suite and needs device verificationUIUR-041, UR-003Needs device verification
DR-159The background-audio handoff stops leaking its relative timeline. The handoff plays the episode as a relative stream — the audio-only URL is built with StartTimeTicks = the position the screen was locked at, so ExoPlayer's zero is the handoff point — and background_audio_base holds the offset that turns one back into a real position. The base was a display-only correction, applied in exactly two places (the lockscreen scrubber and the internal truncation maths) while every other consumer worked in the relative timeline treating the number as absolute. Each crossing threw away exactly base seconds, which is why the jump-back distance varied with where the screen was locked and read as random. Three crossings were live: progress reporting to Jellyfin sent the relative position every 30s, so the server was told real − base — and since DR-155 now mirrors the server's position back and refreshes on a cache hit, that regressed value returned as the resume point (lock at 40 min, listen to 90, reopen at 50); lockscreen seeks went out absolute and came back relative, against a chunked length-less transcode that cannot honour a seek at all, so a clamped seek landed at stream zero; and media3's own seekToDefaultPosition/seekBack/seekForward bypassed the ForwardingPlayer wrapper entirely, reaching the real ExoPlayer — Util.handlePlayButtonAction seeking an ended player to the relative zero being the same mechanism as DR-129's truncation bug through a different door. The fix converts once, at the boundary: JellyTauPlayer's position tick adds the base (and shifts the duration with it, since the stream's own length is only what remains) before either nativeOnPositionUpdate or the lockscreen sees it, so position updates, progress reports, the frontend and the truncation check all speak the episode's timeline and none needs to know a handoff happened. The base is consequently removed from claim_stream_resume, truncated_stream_resume_position and player_exit_background_audio, where adding it now double-counts, and the lockscreen's positionOffsetMs addition goes with it (the field remains, read-only, as the tick's input). Inbound seeks go the other way: seek_absolute is the new boundary for every outside seek, re-opening the stream at the requested position via resume_stream_at when a handoff is active — which is what onSeekTo had claimed for months in a comment describing code that did not exist — and an ordinary seek otherwise. seekToDefaultPosition is swallowed rather than forwarded, since Rust already owns what "play after the stream ended" means and the play() that follows reaches it. Exit reads the position before clearing either base, or a tick landing in between hands back a relative onePlayerUR-040, UR-005, UR-025Done (pending device verification)
DR-158A watched toggle, on the episode row, the season header, the series and movie hero, and the Episode Focus View. Both halves of the backend already existed and neither had a caller: mark_played (POST /PlayedItems) was reachable only from the sync drain replaying rows the reporter had queued, and clear_watch_history (DELETE /PlayedItems) only from the destructive "erase this series' history" button — so the sole way to mark something watched was to play it. Jellyfin applies both recursively over a season or series, so the container case needs no client-side fan-out online. Offline it does: storage_set_watched writes the item and its descendants (drawn from items by parent_id/album_id/season_id/series_id, so an uncached id selects nothing and the statement no-ops instead of raising a foreign-key error), because otherwise marking a season watched with no server would tick the season and leave every episode inside it unwatched. It is deliberately separate from storage_mark_played, which stays the single-item "this finished playing" path that increments play_count. Un-marking clears the resume position as well as the flag, matching the server. QueuedOp::MarkUnplayed gives the queue the missing direction — pushing as clear_watch_history — so the toggle works offline both ways rather than only one; without it un-marking would have been the half that needed a connection. The button is an everyday toggle, so unlike ClearHistoryButton it does not confirm, and it holds an optimistic state because the caller's watched prop only catches up after a reload (a season means a round trip, during which the button would otherwise appear to ignore the tap)UIUR-073Done
DR-157Full-screen video on Android actually goes full screen. toggleFullscreen called document.documentElement.requestFullscreen() and nothing else, which inside an Android WebView does not touch the Activity window — it expands the element within a viewport that already spans the whole screen, because enableEdgeToEdge() is called in onCreate and SDK 36 ignores the opt-out. So the control did nothing visible while the status bar and navigation/gesture bar stayed painted over the video, and (unlike DR-112's chrome-clearance work, which is about reserving space for the bars) here the bars should not be there at all. ImmersiveModeBridge hides them via WindowInsetsControllerCompat with BEHAVIOR_SHOW_TRANSIENT_BARS_BY_SWIPE, so an edge swipe brings them back transiently over the video instead of resizing the window mid-playback, and the system's own gestures stay reachable. Exposed as the AndroidImmersive bridge and posted to the main thread, since @JavascriptInterface methods arrive on a WebView binder thread. requestFullscreen() is kept for the platforms where it does work, but its rejection is caught rather than allowed to abort the immersive call. Restoring is wired to three paths, not one: leaving fullscreen, Escape (which previously called document.exitFullscreen() directly, bypassing the flag and the bars), and onDestroy — the bars belong to the Activity, so a player torn down while immersive would strand every screen behind it without them. The --jt-inset-* properties need no special handling: hiding the bars fires the decor view's inset listener with zeroes and WindowInsetsBridge republishes themUIUR-066Done
DR-143Flipping the offline downloaded-only gate actually re-queries the listing. The gate (DR-078) is a process-wide flag in Rust consulted only while a query runs, but no library surface re-queried when its inputs changed: useServerReachabilityReload fires only on the offline → online transition, and GenericMediaListPage, GenericGenreBrowser and the favourites page never even called its checkServerReachability. So going offline left the full server catalog on screen under a now-closed gate, and toggling "Show all server media" only greyed cards — MediaCard.isServerOnly is a pure frontend derivation that updates instantly — without adding or removing a single row. The filter therefore read as "shows everything until I filter, then greys some of it" while the backend gate was correct and simply never exercised. catalogFilterVersion is the refetch signal: pushCatalogVisibility now awaits set_show_server_catalog and bumps the version only after the backend accepts the new flag, since a reload racing the push would re-query under the old gate and undo itself. A failed push clears lastIncludeCatalog instead of latching it, so the next identical transition is retried rather than skipped as a no-op and left permanently disagreeing with the backend. useOfflineFilterReload subscribes pages to that signal, skipping the value they already loaded under; it is wired into both generic list components and the movies/music/tv/favourites landing pages and the /library/[id] detail pageUIUR-052Done
DR-135A download's media type comes from the item, not a default. download_item — the path a media card uses to queue an item while offline — never records media_type, and the reconnect resolver read that NULL as 'audio', so a movie queued from a card had its URL resolved by get_audio_stream_url. The file that landed on disk was an audio-only transcode, which is why a "downloaded" film could never play offline no matter how the path or protocol was fixed. The resolver now falls back to the item's own item_type (VIDEO_ITEM_TYPES in Rust, so the frontend never learns which types are video) and only defaults to audio when the item is not cached locally. An explicit media_type on the row still winsDownloadsUR-071, UR-052Done
DR-136Rows already downloaded under the audio default are repaired, not just prevented. They are identifiable after the fact — no media_type, but a video item — so on reconnect they are reset to pending with their audio URL cleared and re-resolved by DR-135's corrected logic, overwriting the audio file in place. Without this the fix is invisible to anyone who had already queued a film: the row still reads "downloaded" and still fails to play. Rows carrying an explicit media_type and genuine audio downloads are left untouchedDownloadsUR-071Done
DR-137Local media is served to the player over a loopback HTTP server, not the asset protocol. Tauri's asset protocol answers a request carrying no Range header by reading the whole file into memory, and only advertises Accept-Ranges: bytes from inside its range branch — so the first request never learns ranges exist and a multi-gigabyte body is attempted instead. Chromium abandoned it with PIPELINE_ERROR_READ after ~31s, which reached the user as "downloaded video does not play offline". Real HTTP on 127.0.0.1 is chosen over a custom URI scheme deliberately: range support becomes a property of the transport rather than depending on whether a platform's webview forwards Range to a custom scheme. No response ever exceeds a 4 MiB chunk and bodies stream from the file handle, so memory is bounded regardless of file size. Because loopback is shared between apps on Android, the server binds 127.0.0.1 only and every URL carries a random per-session token; paths are additionally confined to the app data directory, so a leaked URL cannot read outside it. This is stage 1 of making the server the single media origin — remote passthrough and download-while-watching are deliberately out of scope herePlaybackUR-071Done
DR-138Loopback is exempted from Android's cleartext ban, and nothing else is. Release builds set usesCleartextTraffic="false", so the webview's request to the local media server (DR-137) was rejected by network security policy before any I/O — <video> failed in the same millisecond as loadstart, with NETWORK_NO_SOURCE and no server-side log at all, which is why it looked identical to a missing file. A network-security-config resource permits cleartext for 127.0.0.1 only and keeps base-config cleartextTrafficPermitted="false", so a remote server must still be HTTPS; this is deliberately not a blanket opt-in. The manifest attribute is ignored once the config is present, so the config is the single authority. sync-android-sources.sh also had to learn to copy res/xml, which it skipped — the manifest references the resource, so a missed copy fails the resource link rather than degrading quietlySecurityUR-071Done
DR-093Traceability coverage gate derives its requirement denominators from requirements.md at run time rather than hardcoded literals: countDefinedRequirements counts an ID only where it leads a markdown table row (ignoring the "Traces To" column and prose) and deduplicates IDs listed both in the definition tables and in the §3 traceability matrix; computeCoverage reports the intersection of traced and defined IDs so an ID traced in code but absent from requirements.md is surfaced as orphaned instead of inflating the ratio past 100%. UT/IT test identifiers are excluded as a separate taxonomy. CI and bun run traces:coverage share this computation and fail on both a sub-threshold and an impossible >100% resultTooling-Done
DR-204A leveled logging facade for the frontend, replacing raw console.* calls. One module owns the log sinks, so a level (error/warn/info/debug) decides at run time what is emitted rather than every call site deciding permanently at authoring time: a release build stays quiet, a developer chasing a playback bug turns the player's debug output on without editing and rebuilding, and nothing that reaches the console is written by a console.log nobody can find again. Scoped loggers carry the subsystem in the message, so a filtered console is usable while a player, a download worker and a store are all talkingTooling-Done
DR-205ESLint + Prettier run as a gate over the frontend, so lint and formatting are decided once by configuration rather than per reviewer. Formatting is not a matter of opinion at review time, and the classes of bug a linter sees (unused bindings, floating promises, accidental globals) should never reach a human reviewer at all. Wired as an npm script so the same command runs locally and in CI, matching how check:boundary and the traceability gate already workTooling-Done
DR-206The Rust toolchain is pinned in-repo (rust-toolchain.toml) and the pin is what both a developer's machine and CI use. Without it, cargo fmt --check and cargo clippy are run by whatever version each host happens to have, so a formatting or lint result differs between a laptop and the builder image and CI fails on a diff that was clean locally — the failure mode is a red build nobody can reproduce. The builder image carries the pinned toolchain, so pinning is a declaration, not a CI-time install (see the no-toolchain-installs rule)Tooling-Done
DR-207A pre-commit hook runs the "Before Committing" gates — frontend checks and tests, cargo fmt, clippy, the boundary tripwire and the traceability checks — so the gates are enforced at the commit rather than discovered in CI. The gates already exist and are already documented; what is missing is that nothing runs them, which makes compliance a matter of memory. The hook is the mechanism that makes the documented list actually bindingTooling-Done
DR-208Documentation link integrity is checked mechanically (scripts/check-doc-links.sh): every relative markdown link in every tracked .md must resolve to a file that exists on disk. This is a real defect class, not hygiene — the generated traceability matrix shipped ~2,800 dead file links because it was written to docs/ while its hrefs were repo-root-relative, and nothing noticed for months because no check existed and nobody clicks 2,800 links. The check validates paths, deliberately not anchors or external URLs: anchor resolution needs a markdown renderer's slug rules and network checks make the gate flaky, so both are out of scope and stated as such in the scriptTooling-Done
DR-209Library folders are excluded from music browsing server-side, by folder id, replacing a hardcoded frontend filter that dropped anything whose name contained "Podcasts". The name filter was wrong in three separate ways: it encoded a domain classification in the presentation layer, it matched on a title rather than on what an item is (so an album legitimately called "Podcasts" vanished while a podcast folder named anything else did not), and it applied only where someone had remembered to call it, so the same library was in scope on one screen and out of scope on the next. Excluded folder ids are stored as user configuration and applied by the repository layer to every music query — libraries, artists, albums, genres, search and the home rows — so scope is decided in one place and is the same everywhereRepositoryUR-076Done
DR-210Thumbnail cache writes are confined to the cache directory. The filename was built from item_id, image_type and tag, but only tag was sanitised — and Path::join neither folds .. nor keeps the base when handed an absolute path, so a value arriving verbatim from server JSON decided where a file landed. The tag's existing rule (non-alphanumerics become _) now applies to all three parts, and the resolved path is checked with starts_with(cache_dir) at the point of use. The database keeps the raw key and the resolved path, so lookups still match and pre-existing rows still resolve. Not exploitable as shipped — server URLs must be HTTPS and Android blocks cleartext, so the id comes from a server the user chose to trust — the value is making the write path consistent with how caller-supplied paths are handled elsewhereStorageUR-012Done
DR-211Download paths are confined to the download root. file_path and target_dir reached PathBuf::join unchecked from the frontend, and mark_download_completed persisted a caller-supplied path later passed to remove_file. A correct sanitiser already existed and download_item_and_start used it, but download_item is itself a command accepting file_path raw, so the guard was bypassable rather than absent — the fix moves it inside instead of adding a second one. Sanitising is per path component: whole-string sanitising would rewrite downloads/x.mp3 to downloads_x.mp3 and relocate every existing download. Confinement happens after the join, since a join with an absolute second half discards the rootDownloadsUR-011Done
DR-212Query and URL construction bind or encode their inputs. Three sites interpolated caller-supplied values directly: the offline get_items item-type filter built IN ('a','b') by string formatting, build_get_items_endpoint wrote ParentId/IncludeItemTypes/SortBy/SortOrder into a URL unencoded, and player_set_volume accepted NaN and out-of-range floats. Each is a consistency defect rather than a novel one — the same file already did it correctly a few lines away (parameter placeholders in search, urlencoding::encode for genres, clamp in every player backend). List separators stay unencoded and encoding is per element, because Jellyfin splits these parameters on the commaRepositoryUR-007, UR-065Done
DR-213Containerised builds hand their artifacts back to the host user. The compose services bind-mount the repo and run as root — their caches live at /root/.cargo and /root/.bun, so a non-root container user cannot write them — which leaves root-owned files accumulating in the developer's working tree: 11,124 of them when this was found, enough that cargo clean and scripts/clean.sh failed with EACCES and a plain cargo build died part-way, since build scripts compile for the host and land in target/debug even during a cross-build. Ownership is restored at the end of each containerised build, reading the intended owner from the checkout so no uid needs plumbing through. Running the containers as the host uid is the tidier fix and remains open; it needs the cache volumes relocated off /root firstTooling-Done
DR-214The app identifies itself correctly everywhere a user or a package manager reads its name. productName was the scaffold's lowercase jellytau, which is what the Android release build showed under its icon and what the deb/rpm/NSIS bundles carried as their display name — invisible in development because build.gradle.kts overrides the label to "JellyTau Debug" for the debug build type, so the install a developer looks at daily was the only correctly-cased one. mainBinaryName pins the executable filename so nothing that resolves a path by name has to change. strings.xml moves into the canonical android tree, where sync-android-sources.sh already copies res/values/*.xml, so the fix survives regenerating gen/. Bundle metadata (publisher, copyright, category, descriptions, licence) was entirely absent, which is why the packages shipped with no maintainer or description — the hand-written Arch PKGBUILD and .desktop had all of it, so only the generated packaging was wrongPackaging-Done
DR-215Frontend test coverage is a ratcheted CI gate rather than a number nobody looks at. test:coverage had been configured since the suite was created and was silently broken: @vitest/coverage-v8 resolved to 4.1.10, whose peer range pins vitest exactly, while package.json asked for >=1.0.0 <5.0.0 and got 4.0.16 — so every invocation died on a missing BaseCoverageProvider export and no coverage figure had been produced in months. Fixing the range is half the requirement; the other half is that a measured figure that gates nothing decays the same way an unrun script does. Thresholds sit a few points under the measured result (statements 54.6, branches 48.7, functions 49.6, lines 55.1 when this landed) and only ever move up, matching MIN_THRESHOLD in the traceability gate and the eslint --max-warnings ratchet. The absolute numbers are held down by .svelte components, which this project deliberately does not test directly — the pattern is to extract the logic to a plain module and test thatTooling-Done
DR-216Dependencies are gated on known vulnerabilities and on licence compatibility, and the build graph is pinned to what is actually shipped. The project had no scanning of any kind: nothing checked the ~500-crate Rust graph or the JS packages against an advisory feed, and nothing checked that everything redistributed inside an MIT-licensed bundle permits it. The first run found eight vulnerabilities and one unsoundness — bytes, four in rustls-webpki, time, two in quick-xml, rand — every one closed by a cargo update nobody had reason to run. cargo deny (src-tauri/deny.toml) now runs in CI over advisories, licences, bans and sources. Two structural fixes matter as much as the gate: the graph is scoped to the targets actually shipped, so an advisory against an Apple-only path is correctly absent rather than ignored by ID; and the one git dependency (libmpv) is pinned by revision instead of by branch, since a branch means any cargo update silently substitutes new upstream code in the one dependency that is unsigned and links a C library into the player. Licence findings are recorded rather than waved through — libmpv/libmpv-sys are LGPL-2.1, which the app satisfies by dynamic linking, and that carries obligations (keep the linkage dynamic; ship libmpv's licence text with any bundle carrying the .so)Tooling-Done
DR-217In-app update, desktop only, over a manifest we control. tauri-plugin-updater and tauri-plugin-process are compiled for everything except Android/iOS — spelled as a target-triple cfg rather than cfg(desktop), which Cargo does not evaluate in a [target.'cfg(…)'] table and which therefore drops the dependency silently, surfacing much later as "Permission updater:default not found". The release workflow signs updater artifacts with a minisign key held in Gitea secrets and publishes latest.json to a dedicated updater branch, read over Gitea's raw-file URL: this instance serves /releases/download/<tag>/<asset> but returns 404 for /releases/latest/download/<asset>, so there is no stable latest-release URL to point at, and the docs branch is force-pushed by publish-docs.yml so it cannot host the manifest either. Bundle targets gain appimage, which the release notes had been advertising for months while tauri.conf.json never built it — the artifact step globbed for *.AppImage, found nothing, and said nothingToolingUR-077Done
DR-218Persistent, redacted logging and a diagnostics export. tauri-plugin-log replaces the env_logger stdout-only init, giving a rotating 5 MB file, a webview target in dev, and — the single largest gain — logcat on Android, where env_logger's stdout went nowhere. Redaction runs in the log formatter, not at export: a credential in a file on the device is already a disclosure, so stripping it on the way out would be too late; the exporter redacts a second time to cover files written by older builds. api_key/X-Emby-Token/Authorization/"AccessToken"/Token="…" all reduce to [REDACTED] while host, item ids and filenames are deliberately kept — a bundle scrubbed of those is one nobody can debug from. The server URL is reduced to scheme and host, dropping any embedded user:pass@. The panic hook chains to the previous hook rather than replacing it, because utils/lock.rs installs a silencing hook around tests that provoke poisoned locks on purpose. The chosen level persists to disk and is re-applied at startup, since reproducing a bug usually means restarting into it. The frontend facade keeps its untouched console.* pass-through (DR-204) and additionally forwards a stringified copy at info and above, so one file holds both halves of the app in order — which is what makes a race between them legible after the factToolingUR-078Done
DR-219Release notes are the reviewed CHANGELOG entry, not a generated draft. Every release from v0.0.1 to v0.9.1 published the same ~1,050 bytes of generic install instructions whose "What's New" section said "See CHANGELOG.md" — a link that does not resolve from a release page. Thirty-five releases, byte-identical, telling a reader nothing about what changed. The workflow now publishes the ## <version> section of CHANGELOG.md and fails the release if that section is absent, since notes that say nothing are worse than a build that waits for two sentences. release:notes is printed into the job log as a drafting aid but is deliberately not published: CLAUDE.md calls its output "a reviewed draft, not a final changelog", and publishing it unreviewed proved why — a range containing a repo-wide formatting sweep resolved to nearly the entire requirement matrix and produced notes claiming one release had added the whole application. The script now skips cosmetic commits (chore(format), chore(deps), style) when deriving a range's files, and says how many it skipped rather than silently reporting a smaller setTooling-Done
DR-220A release ships only its own artifacts. src-tauri/target/*/release/bundle/ is not versioned, cargo never cleans it, and the CI runner reuses the target directory — so the copy step's bundle/**/*-setup.exe glob collected every installer ever built there. Every release from v0.1.0 to v0.8.2 shipped its predecessors': sixteen Windows installers on v0.8.2, thirteen of them stale, and a download list on v0.5.0 reaching back to 0.1.0. It went unnoticed for eight months because there was nothing to notice — the upload loop reported success, the files were real, and the page looked busy rather than wrong. It stopped only when an unrelated cache change wiped the runner's target dir, leaving the defect dormant rather than fixed. Both desktop builds now clear the bundle directory first, so a stale file cannot exist to be copied — filtering the copy by version would have hidden it instead. scripts/check-release-artifacts.sh is the backstop for the next route nobody predicts: it runs before the SBOM, the checksums and the upload, and refuses to publish when any artifact's embedded version disagrees with the tagTooling-Done
DR-221The release path is exercised before a tag exists. Nothing in build-and-test.yml runs tauri build — only a tag does — so a whole class of breakage was invisible until release day, and two instances of it were sitting on master at once. Tauri refuses to build when a plugin's Rust crate and npm package differ by minor version, which the updater and logging work had introduced (tauri-plugin-log 2.8.0 against @tauri-apps/plugin-log 2.9.0) while cargo check, clippy, the tests and svelte-check all passed; both sides are now pinned exactly rather than by caret, since a caret is what let them separate, and CI runs tauri info to compare them without building. The AppImage target had never once been built: linuxdeploy carries a strip too old to parse the .relr.dyn section modern toolchains emit, so bundling failed on every library — and Ubuntu 23.10+ links with -z pack-relative-relocs by default, so the builder image fails the same way a modern Arch host does. NO_STRIP=true is linuxdeploy's documented escape hatch; the cost is a larger, unstripped bundle. Both were found by building the target locally before tagging rather than by publishing a release that could not buildTooling-Done
DR-222Build tooling matches the package manager the project declares. scripts/build-android.sh ran npm install on its clean-build path — in a bun project, where packageManager says bun and bun.lock is the committed lockfile. npm ignores that lockfile, re-resolves the whole tree from package.json, and writes a package-lock.json that .gitignore then hides. That is not a style preference: the JS halves of the Tauri plugins are pinned exactly against Cargo.lock because the CLI refuses to build when a plugin's crate and package differ by minor version, and a silent re-resolve is precisely how they drift apart. It survived because clean builds are rare — the shape shared by nearly every defect found preparing v0.10.0, where the code running on every commit was healthy and the code running on a release, a tag or a clean build had no guard at all. scripts/check-tooling.sh fails on any npm/yarn/pnpm invocation or foreign lockfileTooling-Done
DR-223The Android JavaVM and Application are published into ndk_context by this crate, not by a transitive dependency. Seven call sites (five in credentials.rs, two in lib.rs) read that process-global to reach JNI, and nothing here ever set it — tao did, three levels below anything this project names in Cargo.toml. tao 0.35.3 moved those pointers into a private struct and stopped publishing them, so the Tauri 2.11 upgrade made the first credential read abort the process on every launch: PANIC ... android context was not initialized. Our code had not changed; an undocumented side effect of the windowing layer had gone. The invariant is now owned here rather than assumed: JNI_OnLoad captures the JavaVM as the shared library loads, and the Application is resolved lazily via ActivityThread.currentApplication() and pinned as a global reference for the process lifetime — the Application rather than the Activity, since that is what SecureStorage.initialize() immediately reduces its argument to. Failure degrades to the encrypted-file credential path and is logged, rather than aborting. Found only by installing on a device: nothing in CI runs the appSecurityUR-012Done
DR-224Backgrounding the app obeys the background-audio toggle on every renderer. The toggle (UR-040) was built for the WebView <video> path, where losing visibility kills the decode: it chose between handing off to a native audio stream and letting playback stop. Native video then became the default renderer (DR-188), and on that path playback runs through ExoPlayer inside a MediaSessionService — a foreground media service whose purpose is to keep playing while the app is hidden. Nothing paused it and nothing in the codebase paused on background, so locking the screen kept the audio going whether or not the toggle was on: the toggle governed a handoff that no longer had a gap to bridge, and users got background playback they never asked for. The decision now lives in Rust (player/background_policy.rs) and both renderers obey it: a video with the toggle off pauses, with the toggle on hands off to audio, music is never paused by backgrounding, and picture-in-picture keeps playing because the window is still on screen (UR-041). It takes no renderer parameter on purpose — the split between the two paths is what produced the defectPlayerUR-040Done
DR-198The webview runs under a real Content-Security-Policy, and the asset protocol is scoped to the one directory it still serves. csp was null, which disables CSP entirely: any script that reached the web layer — through a future {@html}, a dependency, or a devtools paste — would have inherited the whole IPC surface, and with it the user's session. script-src 'self' (Tauri injects a nonce for SvelteKit's inline bootstrap script at build time, so no 'unsafe-inline' is needed) plus object-src/frame-src 'none' and base-uri 'self' is the part that is genuinely restrictive. img-src/media-src/connect-src cannot be: the Jellyfin origin is typed in by the user at run time and is commonly plain http on a LAN, so they allow http:/https: — a wide grant for data, but one that still bars file:, filesystem: and scripting schemes, and leaves script-src untouched. style-src keeps 'unsafe-inline' because Svelte compiles style="…" attributes (including app.html's display: contents wrapper) into markup; this is safe only while no <style> element survives into index.html, since a nonce there would make Tauri's injection outrank — and therefore void — 'unsafe-inline'. worker-src blob: and media-src blob: are hls.js: it demuxes in a worker built from a blob and attaches MSE through URL.createObjectURL. asset: and http://asset.localhost are the same protocol under the two naming schemes convertFileSrc emits (custom scheme on Linux/macOS, http host on Windows/Android); ipc:/http://ipc.localhost is the invoke transport, which would otherwise be blocked by connect-src. A run-time CSP naming the server origin exactly was rejected: Tauri computes the header from immutable config when it serves the HTML, so it would mean rebuilding config and reloading the webview on every server change, for a policy the user can already point anywhere. The asset-protocol scope narrows from $APPDATA/** to $APPDATA/thumbnails/** — since DR-137 moved downloaded media to the loopback server, imageCache is the only convertFileSrc caller left, so the database and the encrypted-token fallback file no longer sit inside the grantSecurityUR-012, UR-071Done
+
+
+

3. Traceability Matrix

+

User Requirements to Software Requirements

+
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
User ReqIntegration RequirementsDevelopment Requirements
UR-001IR-001, IR-002-
UR-002IR-013DR-003, DR-012, DR-013, DR-014
UR-003IR-003, IR-004, IR-011DR-002, DR-004, DR-010, DR-182, DR-183, DR-184, DR-185, DR-186, DR-187, DR-188, DR-190, DR-191, DR-192, DR-193, DR-194, DR-195, DR-196
UR-004IR-003, IR-004, IR-008, IR-011DR-002, DR-004, DR-006, DR-129, DR-171, DR-176, DR-177, DR-181, DR-182, DR-183, DR-185, DR-188, DR-203
UR-005-DR-001, DR-005, DR-009, DR-178, DR-179, DR-186, DR-193, DR-195
UR-006IR-005, IR-006, IR-007, IR-008DR-200, DR-201
UR-007IR-010DR-007, DR-008, DR-016
UR-008IR-010DR-007, DR-011
UR-009IR-009, IR-010, IR-011-
UR-010IR-012, IR-021DR-037, DR-059
UR-011IR-013DR-003, DR-015, DR-018
UR-012IR-009, IR-014DR-198
UR-013IR-013DR-017
UR-014IR-010DR-014, DR-019
UR-015-DR-005, DR-020
UR-016--
UR-017-DR-014, DR-021
UR-018IR-013DR-015, DR-018, DR-173
UR-019IR-015DR-022
UR-020IR-016, IR-018DR-023, DR-176
UR-021IR-016, IR-019DR-024
UR-022IR-017DR-025
UR-023IR-010DR-026, DR-047, DR-048, DR-049
UR-024IR-010DR-027
UR-025IR-015DR-028, DR-131, DR-132, DR-178, DR-179
UR-026-DR-029, DR-048, DR-050
UR-027IR-020DR-030
UR-028-DR-031
UR-029-DR-032
UR-030IR-010DR-033
UR-031-DR-034
UR-032-DR-035
UR-033-DR-036
UR-034IR-010, IR-024DR-038, DR-039
UR-035IR-022, IR-023DR-040, DR-044
UR-036IR-022, IR-023DR-041
UR-037IR-010DR-042
UR-038IR-010DR-043
UR-039-DR-045, DR-046
UR-040IR-025DR-051, DR-052, DR-129, DR-130, DR-159, DR-178, DR-179, DR-180, DR-183, DR-190, DR-196, DR-201, DR-203
UR-041IR-026DR-053, DR-160, DR-161, DR-172, DR-182, DR-183, DR-184, DR-185, DR-188
UR-042IR-009, IR-014DR-054
UR-043IR-027DR-055
UR-044-DR-056
UR-045-DR-057
UR-046IR-028DR-058
UR-047IR-013DR-060
UR-048-DR-061, DR-062, DR-142
UR-049IR-010DR-063, DR-064, DR-065, DR-147
UR-050-DR-066, DR-067
UR-051-DR-068, DR-069, DR-070
UR-052IR-027DR-078, DR-079, DR-080, DR-143
UR-053IR-029DR-074
UR-054-DR-075, DR-076, DR-077, DR-147
UR-055-DR-081, DR-082, DR-083, DR-084, DR-167, DR-168, DR-169, DR-173
UR-056-DR-085
UR-057-DR-086
UR-058-DR-087, DR-142
UR-060-DR-090, DR-091, DR-111
UR-061-DR-092
UR-062-DR-101, DR-102, DR-103, DR-104, DR-107
UR-063-DR-105
UR-064-DR-106
UR-065IR-030DR-108, DR-109, DR-110, DR-111
UR-066IR-031DR-112, DR-157, DR-187, DR-194
UR-067-DR-115, DR-116, DR-117, DR-118
UR-068-DR-119
UR-069-DR-113, DR-114, DR-120
UR-070-DR-121, DR-122
UR-071IR-032DR-123, DR-124, DR-125, DR-126, DR-127, DR-128, DR-133, DR-134, DR-135, DR-136, DR-137, DR-138, DR-170, DR-171, DR-180, DR-198, DR-199
UR-072-DR-156
UR-073-DR-158
UR-074-DR-162, DR-177, DR-181
UR-075-DR-174, DR-175
UR-076-DR-209
UR-077-DR-217
UR-078-DR-218
+
+
+

4. Test Traceability

+

Unit Tests to Software Requirements

+
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
Test IDTest DescriptionTraces ToStatus
UT-001Player state transitionsDR-001Pending
UT-002MediaItem source URL resolutionDR-002, DR-003Pending
UT-003Queue next/previous navigationDR-005Pending
UT-004Queue shuffle order generationDR-005Pending
UT-005Queue repeat mode behaviorDR-005Pending
UT-006Jellyfin authentication flowIR-009Pending
UT-007Jellyfin library items parsingIR-010Pending
UT-008Repository pattern online/offline switchingDR-013Pending
UT-009Offline mutation queue persistenceDR-014Pending
UT-010Download queue managementDR-015Done
UT-011Resume position storage and retrievalDR-022Pending
UT-012Sleep timer countdown logicDR-029Pending
UT-013Playback progress reporting throttlingDR-028Pending
UT-014Database open and in-memory modeIR-013, DR-012Done
UT-015Database migrations run successfullyIR-013, DR-012Done
UT-016All database tables createdIR-013, DR-012Done
UT-017FTS5 search table createdIR-013, DR-012Done
UT-018Server CRUD operationsIR-013, DR-012Done
UT-019User CRUD operationsIR-013, DR-012Done
UT-020Cascade delete server removes usersIR-013, DR-012Done
UT-021Item insert and FTS searchIR-013, DR-012Done
UT-022User data playback position storageIR-013, DR-012, DR-022Done
UT-023Sync queue operationsIR-013, DR-014Done
UT-024Downloads table operationsIR-013, DR-015Done
UT-025Migrations are idempotentIR-013, DR-012Done
UT-026NullBackend volume default valueDR-004Done
UT-027NullBackend set volumeDR-004Done
UT-028NullBackend volume clamping (high/low)DR-004Done
UT-029NullBackend volume boundary valuesDR-004Done
UT-030PlayerController volume defaultDR-004, DR-009Done
UT-031PlayerController set volumeDR-004, DR-009Done
UT-032PlayerController muted defaultDR-004, DR-009Done
UT-033PlayerController volume delegates to backendDR-004, DR-009Done
UT-034Download event serialization roundtripDR-015Done
UT-035Download event completed serializationDR-015Done
UT-036Download event failed serializationDR-015Done
UT-037Download worker exponential backoffDR-015Done
UT-038Download worker error retryable checkDR-015Done
UT-039Download manager creationDR-015Done
UT-040Download manager set max concurrentDR-015Done
UT-041Download info serializationDR-015Done
UT-042Download command filename sanitizationDR-015, DR-018Done
UT-043Download command filename extension preservationDR-015, DR-018Done
UT-044Offline item serializationDR-017Done
UT-045Smart cache default configDR-015Done
UT-046Smart cache album affinity trackingDR-015Done
UT-047Smart cache queue precache configDR-015Done
UT-048Smart cache storage limit checkDR-015Done
UT-049Playlist create (offline)DR-019, JA-019Done
UT-050Playlist delete (offline)DR-019, JA-019Done
UT-051Playlist rename (offline)DR-019, JA-019Done
UT-052Playlist get items (offline)DR-019, JA-019Done
UT-053Playlist add items (offline)DR-019, JA-020Done
UT-054Playlist remove items (offline)DR-019, JA-020Done
UT-055Playlist reorder items (offline)DR-019, JA-020Done
UT-056Playlist entry serializationDR-019, JA-019Done
UT-057Playlist Tauri command param naming (camelCase)DR-019, JA-019, JA-020Done
UT-058Playlist repository client methodsDR-019, JA-019, JA-020Done
UT-059Audio-only stream URL builder for a video item (selected audio-stream index)JA-032, DR-052Pending
UT-060Background-audio handoff state machine (background→audio, foreground→video; no dual audio)DR-052Pending
UT-061Background-audio Tauri command param naming (camelCase)DR-052Pending
UT-062setBackgroundAudioEnabled reports whether the native bridge was actually reached (missing bridge, stale proxy, throwing method) so a dead bridge cannot look armedUR-040, IR-025, DR-051Done
UT-067Offline get_items gates the synced-catalog UNION on the catalog-browse flag (downloads only when off, full catalog when on)DR-078Done
UT-068Catalog visibility resolves to serverReachable || showServerCatalog, and is pushed to the backend on every change of either inputDR-078, DR-079Done
UT-069isConnected follows backend reachability alone: false when the server is unreachable on a live link, true for a reachable server while navigator.onLine is falseDR-079Done
UT-070Hybrid get_items returns an empty offline result as-is when the catalog-browse gate is off, without querying the serverDR-080Done
UT-066WiFi-only download gate: cellular and metered WiFi blocked, unmetered WiFi/Ethernet allowed, unknown/none fail closed, desktop default ungated; plus the frontend network reporter (transport reporting, change subscription, teardown, fail-open queries)DR-074Done
UT-071Byte-size formatter: zero/negative/non-finite → "0 B"; decimal unit thresholds; 2–3 significant-figure banding; trailing-zero trimming; largest-unit capDR-085Done
UT-072Downloaded-only browse returns a downloaded leaf and its container, filtered to the requested album parent; a non-downloaded sibling is omittedDR-082, DR-083Done
UT-073An empty downloaded-only browse is authoritative — no rows, no error — regardless of the catalog-browse flagDR-082Done
UT-074Only libraries with downloaded content are listed; an empty one is omittedDR-082Done
UT-075Disk usage reports a leaf's own size, a container's summed descendants, and reconciles the device total with the sum of leavesDR-085Done
UT-076Downloaded library browse lists album containers, not their individual tracks; drilling into the album returns the tracksDR-082, DR-083Done
UT-077Downloaded TV library browse lists the series, not seasons/episodes; drilling returns the season then the episodeDR-082, DR-083Done
UT-078A downloaded leaf with no cached container (e.g. a movie) still surfaces at the library levelDR-082, DR-083Done
UT-079Each EQ preset returns a 10-band gain curve within range; Flat is all zeros; Bass Boost lifts lows and leaves highs flatDR-030Done
UT-080with_equalizer_normalised clamps out-of-range gains and forces the band vector to exactly 10 entries (pad short, truncate long)DR-030Done
UT-081Old persisted AudioSettings JSON without EQ fields loads as disabled + flatDR-030Done
UT-082EQ fields serialize as camelCase (equalizerEnabled/equalizerBands) and round-tripDR-030Done
UT-083EQ filter entries are empty when disabled or when the curve is flat (clears the af filter)IR-020Done
UT-084Enabled EQ builds one peaking equalizer per non-zero band at the right frequency and gain inside a single lavfi chainIR-020Done
UT-085A first tap resolves to togglePlayPause immediately — no deferral and no timerDR-092, DR-098Done
UT-086A second tap inside the window seeks (+30 s right half, −10 s left half) with the matching feedback side and re-toggles play/pause, so the two toggles cancel and the play state is unchanged by a double tapDR-092, DR-098Done
UT-087A tap after the window, and the tap following a consumed pair, are each fresh first taps that toggle (there is no third-tap case); repeated double taps keep seeking; cancel() makes the next tap a first tap so an interpreted swipe cannot seekDR-092, DR-098Done
UT-088resolveSeekTarget applies the delta to the reported position, clamps into [0, duration - END_SEEK_MARGIN_SECONDS], chains off an in-flight pending target so rapid skips accumulate, and ignores that target once the player reports past itDR-092, DR-095Done
UT-089A touch drag on the video seek bar seeks to the dragged position, never toggles play/pause, and never alters brightness — the container gesture layer stays out of a control drag entirelyDR-098, DR-099Done
UT-090The seek bar commits its seek on touchend even when the engine never fires change, and commits exactly once when both signals arriveDR-099Done
UT-091Transport intents (play/pause/toggle) reach the backend even while a video adapter is registered, and never call the adapter's own play/pause/toggle — the webview must not decide play-vs-pause from the DOMDR-097Done
UT-092shouldReuseActivePlayback reuses backend playback for an already-loaded audio track but never for video, and never when an explicit start position or a next-episode restart was requestedDR-100Done
UT-093resolvePlayerSurface returns video only with a stream URL, pending for video whose stream URL is still missing (never audio), and audio for audio contentDR-100Done
UT-094parseNativeInsets accepts the bridge's JSON or a decoded object, and coerces missing/negative/non-finite edges to 0 rather than emitting NaNpx (which would invalidate the whole padding declaration)DR-112Done
UT-095safeAreaCssVars/applySafeAreaInsets emit px-suffixed jt-inset custom properties for all four edgesDR-112Done
UT-096readNativeInsets returns null with no bridge and survives a stale WebView proxy (missing or throwing get) instead of throwing out of layout initIR-031, DR-112Done
UT-097initSafeArea primes the document on start, re-applies on jellytau-insets-changed (rotation, nav-mode switch), unsubscribes on teardown, and writes nothing without a bridge so env() still wins on iOS/desktopIR-031, DR-112Done
UT-098shellReservesBottomInset gives the bottom inset to BottomUi wherever one renders and to the app shell only on routes without one, so the gesture bar is never ignored nor double-paddedDR-112Done
UT-099A Jellyfin item payload carrying UserData.IsFavorite maps to MediaItem.user_data.is_favoriteDR-113, JA-034Done
UT-100OnlineRepository::get_favorites builds Filters=IsFavorite + Recursive=true + the scope's IncludeItemTypes, and omits the type filter entirely for SearchScope::AllDR-115, JA-033Done
UT-101OfflineRepository::get_favorites returns only is_favorite = 1 rows, honours the scope type filter, and stays downloads-only when the catalog-browse gate is offDR-115Done
UT-102The save_to_cache favourite mirror does not overwrite a row with pending_sync = 1DR-114Done
UT-103The reconnect drain pushes pending favourites, clears pending_sync, and leaves failed rows pendingDR-120Done
UT-104get_items with favorites_only filters online (endpoint) and offline (SQL)DR-116Done
UT-105favorites store precedence: override beats userData.isFavorite beats falseDR-119Done
UT-106Un-favouriting removes an item from a favourites listing viewDR-117, DR-119Done
UT-107The hybrid background refresh emits favorites-changed only for ids whose favourite state actually flippedDR-120Done
UT-109Search covers synced-but-not-downloaded items when catalog browse is on, and stays downloads-only when offDR-108Done
UT-110Search item-type filter is bound, not interpolated: a quote-bearing type neither errors nor widens resultsDR-108Done
UT-111FTS prefix queries quote each token, so apostrophes/hyphens/slashes are data; empty or punctuation-only input returns no rows rather than erroringDR-108Done
UT-112Repeated catalog passes leave one items_fts entry per item, not one per passDR-110Done
UT-113The stale-catalog sweep removes vanished synced rows, keeps downloaded ones, keeps uncrawled types, and stays scoped to one serverDR-110Done
UT-114Cached people are reachable from unscoped search and excluded from scoped searchDR-111Done
UT-115Re-index staleness policy: never-indexed and unparseable timestamps are due, fresh ones are not, future ones are notDR-109Done
UT-116resolve_local_media_path returns a completed download's file, and None for an in-progress download, a row whose file has been deleted, or an unknown itemDR-123Done
UT-118resolveVideoSource prefers a downloaded file, never marks a local file as needing transcoding, and falls back to streaming for a blank pathDR-123Done
UT-119The audio-only handoff picks a downloaded file over the audio-only stream URL, preserving the Jellyfin id for progress syncDR-128Done
UT-120Expiry reclaim takes only expired temporary entries: derived from completed_at+TTL, honouring an expires_at override, never a user download, and disabled by a zero TTLDR-127Done
UT-108LRU eviction reclaims only 'auto' downloads and never a user's own, even when the user's is the oldestDR-126Done
UT-117A background audio-only stream cut short resumes where it died instead of ending the episode; a real end still advances; the absolute position is compared against the runtime; retries at a stuck position give up. A recoverable error resumes music and video too, with growing backoff, leaving the rest of the queue intact and the seekable stream's URL untouched; local and DirectUrl sources are excludedDR-129Done
UT-124downloadedFilePath leaves a completed download's absolute path alone (POSIX and Windows) and only roots one that is still relativeDR-133Done
UT-125A NULL media_type resolves from the item type — Movie and Episode as video, a track as audio — an uncached item still defaults to audio, and an explicit media_type overrides the itemDR-135Done
UT-126Requeueing takes only video rows downloaded under the audio default, clearing their URL, and leaves correctly-typed video rows and real audio downloads aloneDR-136Done
UT-127The media server bounds and confines every response: a range-less request yields one chunk rather than the whole file, no range exceeds the chunk cap, explicit/open-ended/suffix ranges resolve correctly, a range past the end is unsatisfiable rather than clamped, a malformed header falls back to the first chunk, path traversal and unrelated absolute paths are refused, a wrong or absent token is rejected, and content type comes from the extension then the magic bytesDR-137Done
UT-121An EOF reads as the last observed timestamp, not zero: live readings win while the file is loaded, a not-yet-established duration is not recorded as a real zero, a seek updates the position before the next poll, and loading a new file clears the previous one'sDR-130Done
UT-122The sync-queue drain pushes queued playback reports oldest-first, defers failures for the next reconnect, abandons a row after MAX_SYNC_ATTEMPTS, ignores other users' rows, and parses both payload dialectsDR-131Done
UT-123Pending-sync rows describe themselves: every queueable operation has a label, an unknown one still renders, the item title falls back to its id, and rows list oldest-firstDR-132Done
UT-130Video and background-audio stream URLs omit AudioStreamIndex when no track was chosen, and carry the exact index when one wasDR-140Done
UT-131The Episode Focus View hero offers a download controlDR-142Done
UT-132The series name links to the series and the SxEy badge to that season's anchorDR-142Done
UT-133Cast renders below the "More Episodes" strip, never above itDR-062, DR-142Done
UT-134The episode strip is hidden when the episode has no siblingsDR-142Done
UT-135An episode with no seriesId still renders the Focus View, with title, Play and downloadDR-142Done
UT-136episodeRedirectTarget sends a bare episode page into its series' Focus View, and returns null with no seriesDR-142Done
UT-137Going offline with the toggle off pushes the closed gate and bumps catalogFilterVersionDR-143Done
UT-138The version bumps only after set_show_server_catalog resolves, never beforeDR-143Done
UT-139A failed visibility push is retried on the next identical transition rather than latchedDR-143Done
UT-140useOfflineFilterReload skips the value a page already loaded under and reloads on each later changeDR-143Done
UT-141The advertised channel cap: an unknown or zero reading falls back to stereo, a real route keeps its channels, an absurd driver reading is capped at 7.1, and mono is taken at its wordDR-141Done
UT-148Forcing a transcode from the client: an undecodable default track forces one, a decodable track does not, the default track decides rather than the first, the first decides when nothing is marked default, and neither an audio-less source nor an unnamed codec is second-guessedDR-149Done
UT-149createAdapter returns the native adapter only when Rust reports native AND experimentalNativeVideo is on; the flag off forces HTML5 even when Rust says native, and the flag on never promotes a platform Rust reported as HTML5DR-150Done
UT-150set-version.sh stamps all four manifests without touching dependency versions, and the Android versionCode is monotonic across an upgrade sequence, clears the 1000 floor, and survives a prerelease suffixDR-153Done
UT-151An unreportable stop lands in the queue and is pushed by the existing drain; re-queueing the same item supersedes the earlier position rather than adding a row, distinct items keep their own positions, and an abandoned row is not revived by a later reportDR-154Done
UT-152Caching a server result mirrors its watch position locally — including for an item carrying a position but no favourite flag — without inventing a row for an item the server reported no user data for, and without pulling a still-unsynced local position backwardsDR-155Done
UT-162Each downloaded library lists only its own media: the music library shows the album and neither the film nor the series, the movie library only the film, the TV library only the seriesDR-163Done
UT-163partial_path appends rather than replacing the extension, so it matches what the cleanup paths delete, keeps two sources for one title apart, and still produces a sidecar for an extension-less targetDR-165Done
UT-170queue_album_tracks queues a row for every track of the album — including tracks the cache holds without an album_id and tracks it has never seen at all — links each one to its album so offline browsing can find it, returns the row ids in track order, and is idempotent: re-queuing fills the gaps without duplicating rows or resetting a completed track. cached_album_tracks (the offline fallback) finds tracks by either album link and does not sweep in another album'sDR-173Done
UT-171resolve_pending_download_urls restricted to a set of row ids resolves only those rows and leaves other pending rows untouched, and an empty id set resolves nothing rather than sweeping everythingDR-173Done
UT-172album_file_names gives every track of an album its own file: a title repeated within the album (deluxe edition, two discs) is disambiguated by track number and item id instead of the second download overwriting the first, an unambiguous title keeps its own name, and path separators in a title are sanitised so a track cannot escape the album directoryDR-173Done
UT-164resume_offset appends only when the server answered 206; a 200 after a Range request restarts the file, because that body is the whole streamDR-166Done
UT-165A registered download starts unflagged, signal sets the flag its worker reads, signalling an unregistered id reports not-in-flight, clear forgets it, and re-registering drops a previous stop so a resumed download does not halt instantlyDR-164Done
UT-166original quality re-encodes audio the webview cannot decode (E-AC-3/AC-3/DTS/TrueHD) to AAC without capping bitrate or resolution, keeps the Static=true direct copy for audio that plays here (AAC/MP3/Opus/Vorbis/FLAC) and for an unknown codec, leaves the explicit quality presets untouched, and picks the served track by the same default-or-first rule the streaming verdict usesDR-171Done
UT-155A seek during a background-audio handoff re-opens the stream at the requested absolute position (StartTimeTicks) and rebases the handoff to it, while a seek outside a handoff stays an ordinary seek and invents no baseDR-159Done
UT-154mark_unplayed parses to QueuedOp::MarkUnplayed and is rejected without an item id, and a queued un-mark drains to the server as clear_watch_historyDR-158Done
UT-156A capped step reaches the transcode URL as all four of its parts (total ceiling, the video/audio split summing to the cap, and a MaxHeight), the uncapped default keeps the historical 20/18 Mbps allowance and constrains no resolution, and the background-audio handoff takes the lower of the cap and its own 384 kbpsDR-162Done
UT-157The quality ladder is internally consistent — video + audio equals the cap at every step, audio never consumes the budget, only Original is uncapped — descends in bitrate, resolution and audio share together, and round-trips through the serde token it is persisted asDR-162Done
UT-158Justified rows fill the container width exactly and never overflow it, every tile in a row shares one height, and each tile's width follows its own aspect ratio — a 16:9 tile coming out more than twice the width of a 2:3 tile at the same heightDR-174Done
UT-159The awkward cases of the packing: a short last row is left at the target height rather than stretched across the container, a last row that would overflow is brought down, an extreme ratio is clamped instead of taking a row to itself, a missing or nonsensical ratio falls back to square instead of collapsing the tile, an unmeasured container renders nothing rather than 1px tiles, and every tile is placed exactly once in orderDR-174Done
UT-160The default row height suits its container: it grows with the width, stays inside its bounds, and at phone width still fits two 16:9 tiles side by sideDR-174Done
UT-161A collection type maps to its favourites scope (movies/tvshows/music), every other kind — Live TV, channels, box sets, books, unknown — maps to none rather than to All, and a constructed library carries the scope across the wire as favoritesScope, omitted entirely when it has noneDR-175Done
UT-167The mosaic's composition: the cross-library favourites entry leads, each library is followed by its own category tile pointing at that category's tab, a category shared by two libraries still yields one tile, a library kind favourites do not carve up yields none, a scope the page offers no tab for is ignored, and every tile is uniquely keyedDR-174, DR-175Done
UT-168Subtitles are negotiated as sidecars, never burned in: the requested SubtitleStreamIndex is the explicit "none" sentinel (-1) rather than omitted, every text format we can render (srt/subrip/ass/ssa/vtt) is advertised as External, and the burn-in verdict is by format — text never forces it, image formats (PGSSUB, dvdsub) always do, case-insensitively. The same sentinel rides the stream URL itself, so a stream re-opened without a fresh negotiation cannot inherit a subtitle. And the verdict reaches the picker: a subtitle stream carries supportsExternalDelivery — set only for subtitles, false for a bitmap format and for one the server left unnamed — which drops the tracks the app could never draw from the menu, the <track> children and the native play request alike, without even fetching their URLs, while a stream carrying no verdict at all is still offeredDR-176Done
UT-173Every video stream URL carries a PlaySessionId, each open mints a fresh one, and the open reports the session it superseded so that job can be stoppedDR-177Done
UT-174A fatal HLS network error is read against the absolute position: mid-film — including after a quality switch, where the seek offset carries the whole resume position — it is retried rather than reported as the end of the stream, the last tenth of a known runtime is treated as the end, an unknown runtime retries, and retries stop once the budget is spentDR-177Done
UT-175A stream reload that never becomes playable is reported as a failure instead of resolving as success, so the caller can revert its selection rather than leave the UI claiming a stream that is not playingDR-177Done
UT-176A handoff's position is floored at its base: with no tick yet landed the exit position is the point the screen was locked at rather than 0, and once ticks are flowing (the base already applied natively) it is not added twiceDR-178Done
UT-177Webview-rendered media's reported position and duration are the controller's, and are dropped the moment that element stops being the player — on teardown, and when a handoff takes overDR-178Done
UT-178A stop report at position 0 is withheld rather than sent (it would clear the resume point), while a real position is still reported from either rendering path — the element's on the webview path, the backend's on the native oneDR-179Done
UT-179An audio-only episode that ends naturally is reported stopped at its runtime, so Jellyfin marks it played; a truncated stream, which is about to be re-opened, reports nothingDR-179Done
UT-180Position ticks report progress to the server, throttled to one report per item per window rather than one per tickDR-179Done
UT-181The handoff plan matches its source: a downloaded file takes no base and a seek, a stream takes the base and no seek, and a handoff at 0:00 takes neither; a downloaded handoff's absolute seek stays an ordinary seek instead of a stream rebuildDR-180Done
UT-153Scroll handling per navigation kind: a forward move always lands at the top even when the previous page was scrolled and even when the target was visited before, Back restores that route's own saved offset (and the top when it has none), offsets are kept per route rather than shared, a repeated Back still restores, and the initial load leaves the container aloneDR-156Done
UT-142The audio codecs offered for video direct play: a Dolby device's real MediaCodecList output drops ac3/eac3, AMR and raw PCM are dropped too, a fully-supported list is passed through untouched, a list with nothing decodable still claims aac, and stray spacing or casing does not decide whether the user gets soundDR-148Done
UT-143Subtitle URLs resolve to plain strings before they reach the markup (never a Promise), unresolvable tracks are dropped, a stale selection collapses to "Off", and a server-default track is never auto-selectedUR-020, DR-023Done
UT-144VideoPlayer actually renders <track kind="subtitles"> children carrying data-stream-index, with no default attribute and no async getSubtitleUrl() bound to srcUR-020, DR-023Done
UT-145The frontend's subtitle payload survives the IPC hop: a camelCase PlayItemRequest carrying subtitles deserializes, create_media_item lands them on MediaItem.subtitles in the order sent, and a request without the field still defaults to emptyUR-020, IR-016Done
UT-146The subtitle JSON serialized across the JNI boundary uses the keys JellyTauPlayer.load() reads — url, language, label and mime_type, never mimeTypeUR-020, IR-016, JA-008Done
UT-147The native subtitle payload and the track-selection index come from the same resolved list: the wire shape keeps mime_type and stream order, playerPlayItem actually sends it, and the index is a position in the sent list (so a track whose URL failed to resolve cannot shift the others) rather than the menu's row numberUR-020, IR-016Done
UT-182An HLS video URL never carries StartTimeTicks — with a position supplied or not — while the master playlist, codec, media source and chosen audio track still ride on itDR-181Done
UT-183A reloaded stream is resumed by seeking the element to the absolute position with the transcode offset cleared to zero — never by carrying the position as an offset base, which since DR-181 would display the position while playing the item from its start — and a reload to 0:00 waits for no seekDR-181Done
UT-184The native reveal rule fires on state === "playing" and on a position tick carrying a position or a duration, and on nothing else — not buffering, paused, stopped, ended or error, not an empty tick, and not a negative positionDR-182Done
UT-188The control-bar auto-hide rule permits hiding only during uninterrupted playback: it declines while paused, while a seek is in flight, and while a track/subtitle/quality menu is open — asserted against the pure shouldHideControls rule rather than a clock or a DOMDR-189Done
UT-189On the native path the player never calls player_report_state — driven through the real 10-second progress interval under fake timers, which is the call site that mattered; asserting on a freshly mounted player passes with the guard deleted and guards nothingDR-195Done
UT-187On the native path the play overlay follows the backend: it clears when the backend resumes after a pause and is raised again when the backend pauses, and the system bars are hidden on player entry rather than only by the fullscreen buttonDR-186, DR-187Done
UT-186Every attribute the native-video compositing block in app.css targets is set somewhere in the app — [data-app-shell] in particular — so a selector aimed at nothing fails the suite instead of failing silently on a deviceDR-185Done
UT-185Mounted on the native path (backend reports native, opt-in flag on, no <video> element rendered and the backend not stopped), VideoPlayer keeps the poster card up until the backend reports something, drops it on a playing state or a position tick with a duration, and keeps it up through error and stoppedDR-182Done
UT-190build_next_up_endpoint sends EnableResumable=false with the user and limit, and no SeriesId filter when none was requestedDR-197, JA-036Done
UT-191A per-series next-up query keeps SeriesId and the resumable exclusion, and defaults the limitDR-197Done
UT-192filterInProgressNextUpItems drops an episode present in the resume list, keeps the genuinely unstarted next episode, leaves the rest of the row intact, and is a no-op when nothing is in progressDR-197Done
UT-193The shipped Tauri security config stays restrictive: csp is set, script-src carries no 'unsafe-inline'/'unsafe-eval'/wildcard, object-src/frame-src are 'none', the directives playback needs (asset scheme, loopback, blob:, ipc:) are present, and the asset-protocol scope covers only the thumbnail cache — never the storage root that holds the databaseDR-198Done
UT-194Normal audio (no background-audio handoff) keeps queue advance on both skip buttonsDR-201Done
UT-195In background-audio mode a skip scrubs +30s/-10s instead of advancing the queue — the reported defectDR-201Done
UT-196Skipping back near the start clamps to zero rather than seeking negativeDR-201Done
UT-197Skipping forward near the end clamps to the duration rather than running past it into an EOF-driven advanceDR-201Done
UT-198An unknown duration still scrubs and still refuses to go negativeDR-201Done
UT-199The screen-wake decision: video playing holds the display, pausing releases it, audio playing never holds it, a webview element going inactive releases even without a pause report, either renderer alone is enough to hold, and teardown drops bothDR-202Done
UT-201The logging facade gates by level: a message below the active level is not emitted at all, one at or above it reaches the sink, changing the level at run time changes what passes without touching the call sites, and a scoped logger tags its output with the subsystemDR-204Proposed
UT-202Generated traceability-matrix file links resolve from docs/: an emitted href, resolved against the directory traceability.md is written to, points at a file that exists on disk; the visible link text stays repo-root-relative; the #Lnn anchor survives; and a bare repo-root href — the regression that made every link 404 as docs/<path> — is rejectedDR-093Done
UT-203Library folder exclusion filters by id, not by name: an excluded folder's items are absent from a music query, an item whose title merely contains an excluded folder's name is kept, and clearing the exclusion restores the itemsDR-209Proposed
UT-204Thumbnail cache writes stay inside the cache directory: a traversal-style and an absolute item_id both fail to produce a file outside it, a filename made only of already-safe characters is byte-identical to the one the previous code produced, and an odd id still round-trips through get_cached_pathDR-210Done
UT-205Queued download paths cannot escape the download root — traversal, absolute and .. forms are refused — while the four real path shapes the app builds, including the absolute one download_series produces, come back unchanged; and a completed download cannot register a file outside the rootDR-211Done
UT-206The offline item-type filter is bound rather than interpolated (a value containing a quote and OR 1=1 matches nothing instead of disabling the WHERE), build_get_items_endpoint percent-encodes its values while preserving the commas Jellyfin splits on, and volume normalisation clamps out-of-range input and maps NaN to a finite valueDR-212Done
UT-200The stream a player could only restart is refused its retry: the handoff transcode answers yes to player_retry_restarts_stream while music, video and a downloaded episode answer no, and the Kotlin decision starts permissive, flips on a non-resumable load, and is restored by the next ordinary oneDR-203Done
UT-207The hero banner's rotation timer restarts from the moment of a manual change: a swipe 5.5s into a 6s interval waits a further 6s instead of firing the leftover 500ms, repeated restarts never stack timers, and stop() ends rotationDR-038Done
UT-208The update decision: each numeric version field is compared in order, the installed version is not offered to itself, a leading v is tolerated because that is how the tags are written, a pre-release sorts below the release of the same number so 0.9.2-rc1 is not offered to somebody on 0.9.2, a missing patch field reads as zero rather than NaN, mobile reports link-only while desktop reports install, and absent release notes normalise to null rather than undefinedDR-217Done
UT-209Redaction and forwarding. Rust: every credential shape reduces to [REDACTED] while the host, username and neighbouring parameters survive; redaction is idempotent, leaves ordinary lines alone, does not fire on the word "token" in prose, and does not panic on multi-byte input; a server URL keeps only scheme and host and drops an embedded user:pass@; an unparseable level falls back to info rather than failing at startup. Frontend: info and above forward while debug does not, a message the level filter suppressed is not forwarded, a throwing forwarder neither propagates nor prevents the console write, and an Error renders as name and message rather than the {} that JSON.stringify producesDR-218Done
UT-210Cosmetic-commit detection for release notes: a chore(format), chore(deps) or style subject is skipped when deriving a range's changed files, while fix, feat, ci, docs, a bare chore: and chore(release): are kept; and the word "format" appearing later in a subject ("fix(duration): format times over 24 hours") does not make a real fix look cosmeticDR-219Done
UT-211The background decision: a video with the toggle off pauses (the reported defect, where the media service kept playing regardless), a video with it on hands off to audio, music keeps playing whatever the toggle says because it has no picture to lose, picture-in-picture keeps playing in every combination since the window is still visible, and the answer does not vary by rendererDR-224Done
+
+

Integration Tests

+
+ + + + + + + + + + + + + + + +
Test IDTest DescriptionTraces ToStatus
IT-001End-to-end authentication with Jellyfin serverIR-009, UR-009Pending
IT-002Library browsing and item loadingIR-010, UR-007Pending
IT-003Audio playback via libmpvIR-003, UR-004Pending
IT-004Video playback via libmpvIR-003, UR-003Pending
IT-005MPRIS lockscreen controls on LinuxIR-005, UR-006Pending
IT-006Offline mode with local databaseIR-013, UR-002Pending
IT-007Media download and local playbackDR-015, UR-011Pending
IT-008Subtitle track selection on the video backends (ExoPlayer sideloaded tracks; HTML5 <track> children) — not via libmpv, which does not implement itIR-018, UR-020Pending
IT-009Audio track selection on the video backends (ExoPlayer track switch; HTML5 stream re-open at the chosen AudioStreamIndex) — not via libmpv, which does not implement itIR-019, UR-021Pending
IT-010Playback progress sync to JellyfinIR-015, UR-025Pending
IT-011Resume playback from server positionIR-015, UR-019Pending
IT-012Equalizer bands via libmpvIR-020, UR-027Pending
IT-013Background-audio handoff on Android: background/lock continues audio via native service and stops video decode; foreground resumes video at positionIR-025, UR-040Pending
IT-016Offline library listing end-to-end: with the server unreachable, a library page lists only downloaded media with the toggle off, and additionally reveals greyed-out cached catalog entries with the toggle onUR-052, DR-078, DR-079, DR-080Done
IT-017A download queued from a greyed-out offline catalog entry persists and is resolved and started on reconnectUR-052, UR-011Done
+
+
+

5. Technical Debt

+

Open items carried over from the v0.6.0 codebase audit

+

The 2026-08-16 audit (v0.6.0, commit be907b49) was a point-in-time snapshot +with no status markers, and by v0.8.2 most of it had been either fixed or +overtaken. It was retired rather than left to rot into a document that +half-describes the code: what survived it is the table below, which is now the +record. Each row is self-contained — the audit is not needed to act on it.

+

What was dropped as demonstrably closed, so it is not re-raised: the CSP and +asset-protocol scope findings (now DR-198), cloud backup and credential restore, +the WebView mixed-content override (DR-199), POST_NOTIFICATIONS and the +media-session exemption (DR-200), the jvmTarget 1.8 pin (now 17), the +half-declared Android TV leanback category (removed), the untraced-but-Done +requirements and the contradictory UR/IR statuses (re-scoped in §2.1), the 50% +traceability gate (ratcheted, and gated on a live denominator by DR-093), the +flaky offlineCatalog test, the clippy warning backlog (cleared, and cargo fmt --check plus clippy now run in CI), and the "820 production unwrap()s" +figure — a measurement error that counted test modules, corrected in the audit +itself to ~19 and standing at 27 today, none of them in a command handler. The +three Runtime::new().unwrap() sites that genuinely matter survive as row 5.

+

Ordered by what would hurt most if left.

+
+

Closed 2026-08-17: the R8-minified release APK was validated on device. +That was the last item gating confidence in the v0.8.0 release itself; R8 +stripping JNI-loaded classes has broken release builds here before, and +v0.8.0 added a new Kotlin path (onFastForward/onRewind) that the +unminified debug pass did not cover.

+
+
+ + + + + + + + + + + +
#ItemWhy it mattersSize
1Android 16 Local Network ProtectionsThe rare platform change that could stop the app working at all: JellyTau's core function is reaching a Jellyfin server that, for most users, is on the LAN. Opt-in for testing in Android 16, enforcement signalled for a later release — so nothing is broken today and no device test will surface it. Far cheaper to handle before it is mandatory. An Android 16 device is already to hand to test the opt-in flag againstM
2The traceability matrix cannot see Kotlinscripts/extract-traces.ts walks only src, src-tauri/src and scripts, so every TRACES: comment in src-tauri/android/** is invisible — pre-existing ones included. A whole platform is unmeasured, which is plausibly why the Android IRs sat untagged for so long, and it means the 90% coverage figure is computed over a codebase that excludes the Android treeS
3Delete the asset protocol outrightIt is not narrowly used, it is unused. getCachedImageUrl has no production callers (only its own test file), so convertFileSrc never executes; images arrive as base64 data: URIs from image_get_url. Confirmed on device: zero asset.localhost requests across a full browsing session. Dropping protocol-asset and the assetProtocol block retires the surface instead of shrinking it, and imageCache.ts goes with itS
4Tighten img-srcThe v0.8.0 CSP grants img-src … http: https: on the premise that thumbnails are fetched direct-from-server by the webview. They are not (see #3). With no webview-side server image loads anywhere in src/, 'self' data: blob: should suffice. Needs its own device pass — a wrong img-src blanks every image, silentlyS
5Three Runtime::new().unwrap() in playback-critical threadssession_poller/mod.rs:102, player/mpv_backend.rs:424, player/android/mod.rs:761. A panic strands the app offline with nothing surfaced, freezes the scrubber mid-playback, or kills progress reporting across a JNI boundary. One shared helper returning Option<Runtime> and logging on failure retires all three. (The wider "820 unwraps" figure was a measurement error — the real count is 19, and none are in command handlers)S
6Confirm the playback service rejects unknown callersJellyTauPlaybackService is exported="true" with a MediaSessionService intent filter — conventional for Media3, but it means any app on the device can attempt to bind and drive playback. The session's onConnect should reject unknown packages. (Predictive back, raised alongside this, was verified working on device and needs nothing)S
7Media3 is several minor versions behindPinned at 1.5.0 across exoplayer/hls/session/common. Much of this app's hard-won behaviour lives in ExoPlayer edge cases — truncated progressive streams, background-audio handoff, HLS resume — so its bug-fix releases have unusually high value here. Schedule with a device pass over the playback regression listM
8Shipped desktop bundles have no update pathdeb/rpm/nsis are built but tauri-plugin-updater is absent, so every desktop user upgrades by manually fetching a package — in practice a long tail of installs pinned to whatever they first downloaded. Add the updater with a signed manifest, or document the manual path so the omission is deliberateM
9DR-042 overstates what shipsIt promises "poster cards, year, and rating badges", but MediaCard.svelte renders only productionYear; CommunityRating/OfficialRating appear solely as sort keys, never as a badge. Either build the badge or correct the requirement text — a requirement that describes unbuilt behaviour is worse than an untraced oneS
10Stray duplicate JellyTauPlayer.ktA copy exists at src-tauri/android/app/src/main/java/.../player/JellyTauPlayer.kt, outside the canonical src-tauri/android/src tree that sync-android-sources.sh reads. Two files with one name in a tree with a strict canonical-source rule is a trap for the next editS
11Six modules carry a disproportionate share of the complexitysrc-tauri/src/player/mod.rs (4,732 lines), src-tauri/src/repository/offline.rs (4,705), src-tauri/src/repository/online.rs (3,760), src-tauri/src/commands/player/mod.rs (3,327), src-tauri/src/commands/download/mod.rs (3,238) and src/lib/components/player/VideoPlayer.svelte (2,786) — all still growing. The cost is not the line count itself, it is that these are the same modules CLAUDE.md's Gotchas section keeps having to warn about: the deadlock rule about locking in event callbacks, the AutoplayDecision scrutinee, the "no lifecycle calls after an await in onMount" rule, the HLS master.m3u8 rule, the download concurrency cap. A file that needs a standing warning in the project's onboarding document is a file whose invariants are no longer local to it, and every such warning is a rule a newcomer has to be told rather than one the structure enforces. Recorded, not scheduled — a speculative refactor of six files this size buys nothing on its own. The trigger is the next time one of them needs substantial work: splitting it then is likely cheaper than growing it, and each rule that moves from Gotchas into a module boundary is one fewer thing to rememberL
+
+

Linux Keyring Integration Workaround

+

Issue: The keyring-rs crate (v3.x) has issues with retrieving credentials from the Linux Secret Service API, despite successfully saving them.

+

Symptoms:

+
    +
  • Credentials are saved to the system keyring successfully (verified with secret-tool search)
  • +
  • Retrieval via the keyring-rs library fails with NoEntry error
  • +
  • Session restoration fails on app restart even though credentials exist
  • +
+

Root Cause: +The keyring-rs library's Linux backend doesn't correctly retrieve entries from the Secret Service that it previously stored. This appears to be a bug in how the library interfaces with the Secret Service D-Bus API.

+

Current Workaround: +We bypass the keyring-rs library on Linux and use direct system calls to secret-tool:

+
    +
  • Save: secret-tool store --label <label> service <service> username <username>
  • +
  • Retrieve: secret-tool lookup service <service> username <username>
  • +
  • Delete: secret-tool clear service <service> username <username>
  • +
+

Implementation: +See src-tauri/src/credentials.rs for the +Linux-specific secret-tool save/get/delete paths.

+

Future Fix:

+
    +
  • Monitor keyring-rs for bug fixes in future versions
  • +
  • Consider alternative secure storage libraries
  • +
  • Test if newer versions of keyring-rs (v4.x+) resolve the issue
  • +
  • Once fixed, remove the Linux-specific workaround and use the cross-platform keyring-rs API
  • +
+

Impact:

+
    +
  • Low - The workaround is functionally equivalent to proper keyring integration
  • +
  • Credentials are stored securely in the system keyring
  • +
  • Session restoration works correctly
  • +
  • Only affects Linux; macOS and Windows use the standard keyring-rs implementation
  • +
+

Dependencies:

+
    +
  • Requires secret-tool to be installed on Linux systems (part of libsecret-tools package)
  • +
  • Already available on most Linux distributions by default
  • +
+
+

Platform Playback Backend Parity (Linux vs Android)

+

Issue: The Linux (MPV) and Android (ExoPlayer) playback backends have diverged in feature implementation and architecture patterns.

+

Symptoms (as first recorded; the audio half is now closed — see Status):

+
    +
  • Audio settings (gapless playback, volume normalization, equalizer) worked on Linux but not on Android
  • +
  • Position update frequency differs between platforms (Linux: 250ms polling, Android: on-demand callbacks)
  • +
  • Thread safety models differ (Linux: Arc<Mutex<>>, Android: global OnceLock statics)
  • +
+

Root Cause: +The PlayerBackend trait defines optional audio settings methods with default empty implementations. MpvBackend overrode these with MPV property commands; ExoPlayerBackend took the silent defaults, so the Settings › Audio panel rendered controls that did nothing on Android. ExoPlayerBackend now overrides them too, but the trait default is still a silent Ok(()) — a backend that omits the method still reports success rather than failing loudly.

+

Affected Files:

+ +

Feature Parity Matrix:

+
+ + + + + + + + +
FeatureLinux (MPV)Android (ExoPlayer)Status
Basic playbackParity
Volume controlParity
SeekParity
CrossfadeNot implemented (blocked on MPV)
Gapless playback⚠️Implemented, pending on-device verification
Volume normalization⚠️Implemented (LoudnessEnhancer — gain stage, approximate vs MPV's dynaudnorm), pending on-device verification
Equalizer (10-band)⚠️Implemented (resampled onto device bands), pending on-device verification
Position updates250msOn-demandInconsistent
+
+

Status (see docs/architecture/05-platform-backends.md, "Audio settings on ExoPlayer"):

+
    +
  1. set_audio_settings() implemented in ExoPlayerBackend (JSON over JNI)
  2. +
  3. ✅ Gapless via ExoPlayer's pauseAtEndOfMediaItems
  4. +
  5. ✅ Volume normalization via LoudnessEnhancer
  6. +
  7. ✅ Equalizer via android.media.audiofx.Equalizer, canonical 10 bands +resampled onto the device's band centres
  8. +
  9. Not yet verified on a physical device — the EQ/normalization effects +depend on device-specific AudioEffect availability and band layouts
  10. +
  11. ⬜ Flip the trait's set_audio_settings default from Ok(()) to +Err(not_implemented()) so a backend that omits it fails loudly instead of +silently reporting success. Deferred until (5) confirms the Android path works
  12. +
  13. ⬜ Standardize position update frequency across platforms
  14. +
+

Crossfade is deliberately absent: it is unimplemented on every platform and +architecturally blocked on MPV, so building it on Android alone would invert the +parity gap. (The previously suggested ConcatenatingMediaSource is also +deprecated in current Media3.)

+

Impact:

+
    +
  • Medium - Android users lack audio enhancement features advertised in requirements
  • +
  • User experience differs between platforms
  • +
  • UR-032 (Gapless), UR-033 (Normalization) and UR-027 (Equalizer) are now +implemented on Android as well as Linux, pending on-device verification
  • +
  • UR-031 (Crossfade) works nowhere — see DR-034
  • +
+

Traces To: IR-004, UR-031, UR-032, UR-033, DR-034, DR-035, DR-036

+
+

Frontend Playback Code Duplication

+

Issue: Playback control handlers and state derivations are duplicated between AudioPlayer.svelte and MiniPlayer.svelte.

+

Symptoms:

+
    +
  • Identical try-catch wrapped handler functions in both components (~44 lines duplicated)
  • +
  • Same $derived state merging logic for local/remote playback in both components
  • +
  • Position conversion (ticks ↔ seconds) scattered across multiple files
  • +
+

Affected Files:

+ +

Duplicated Code:

+
// These handlers are identical in both AudioPlayer and MiniPlayer:
+handlePlayPause(), handleNext(), handlePrevious(),
+handleToggleShuffle(), handleCycleRepeat(), handleVolumeChange()
+
+// These derived states use identical logic:
+displayMedia, displayIsPlaying, displayPosition, displayDuration
+
+

Future Fix:

+
    +
  1. +

    Create src/lib/utils/playbackUnits.ts:

    +
    export const TICKS_PER_SECOND = 10_000_000;
    +export const secondsToTicks = (s: number) => Math.floor(s * TICKS_PER_SECOND);
    +export const ticksToSeconds = (t: number) => t / TICKS_PER_SECOND;
    +
    +
  2. +
  3. +

    Create src/lib/composables/useMergedPlaybackState.svelte.ts:

    +
      +
    • Export displayMedia, displayIsPlaying, displayPosition, displayDuration
    • +
    • Single source of truth for merged local/remote state
    • +
    +
  4. +
  5. +

    Simplify handler wrappers using a utility:

    +
    export const withErrorHandler = (fn: () => Promise<void>, context: string) =>
    +  async () => { try { await fn(); } catch (e) { console.error(`${context}:`, e); } };
    +
    +
  6. +
+

Impact:

+
    +
  • Low - Code works correctly but violates DRY principle
  • +
  • Maintenance burden when logic needs to change
  • +
  • Risk of handlers diverging over time
  • +
+

Traces To: DR-009

+ +
+ + +
+
+ + + +
+ + + + + + + + + + + + + + + + + + +
+ + diff --git a/searcher.js b/searcher.js new file mode 100644 index 00000000..dc03e0a0 --- /dev/null +++ b/searcher.js @@ -0,0 +1,483 @@ +"use strict"; +window.search = window.search || {}; +(function search(search) { + // Search functionality + // + // You can use !hasFocus() to prevent keyhandling in your key + // event handlers while the user is typing their search. + + if (!Mark || !elasticlunr) { + return; + } + + //IE 11 Compatibility from https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/String/startsWith + if (!String.prototype.startsWith) { + String.prototype.startsWith = function(search, pos) { + return this.substr(!pos || pos < 0 ? 0 : +pos, search.length) === search; + }; + } + + var search_wrap = document.getElementById('search-wrapper'), + searchbar = document.getElementById('searchbar'), + searchbar_outer = document.getElementById('searchbar-outer'), + searchresults = document.getElementById('searchresults'), + searchresults_outer = document.getElementById('searchresults-outer'), + searchresults_header = document.getElementById('searchresults-header'), + searchicon = document.getElementById('search-toggle'), + content = document.getElementById('content'), + + searchindex = null, + doc_urls = [], + results_options = { + teaser_word_count: 30, + limit_results: 30, + }, + search_options = { + bool: "AND", + expand: true, + fields: { + title: {boost: 1}, + body: {boost: 1}, + breadcrumbs: {boost: 0} + } + }, + mark_exclude = [], + marker = new Mark(content), + current_searchterm = "", + URL_SEARCH_PARAM = 'search', + URL_MARK_PARAM = 'highlight', + teaser_count = 0, + + SEARCH_HOTKEY_KEYCODE = 83, + ESCAPE_KEYCODE = 27, + DOWN_KEYCODE = 40, + UP_KEYCODE = 38, + SELECT_KEYCODE = 13; + + function hasFocus() { + return searchbar === document.activeElement; + } + + function removeChildren(elem) { + while (elem.firstChild) { + elem.removeChild(elem.firstChild); + } + } + + // Helper to parse a url into its building blocks. + function parseURL(url) { + var a = document.createElement('a'); + a.href = url; + return { + source: url, + protocol: a.protocol.replace(':',''), + host: a.hostname, + port: a.port, + params: (function(){ + var ret = {}; + var seg = a.search.replace(/^\?/,'').split('&'); + var len = seg.length, i = 0, s; + for (;i': '>', + '"': '"', + "'": ''' + }; + var repl = function(c) { return MAP[c]; }; + return function(s) { + return s.replace(/[&<>'"]/g, repl); + }; + })(); + + function formatSearchMetric(count, searchterm) { + if (count == 1) { + return count + " search result for '" + searchterm + "':"; + } else if (count == 0) { + return "No search results for '" + searchterm + "'."; + } else { + return count + " search results for '" + searchterm + "':"; + } + } + + function formatSearchResult(result, searchterms) { + var teaser = makeTeaser(escapeHTML(result.doc.body), searchterms); + teaser_count++; + + // The ?URL_MARK_PARAM= parameter belongs inbetween the page and the #heading-anchor + var url = doc_urls[result.ref].split("#"); + if (url.length == 1) { // no anchor found + url.push(""); + } + + // encodeURIComponent escapes all chars that could allow an XSS except + // for '. Due to that we also manually replace ' with its url-encoded + // representation (%27). + var searchterms = encodeURIComponent(searchterms.join(" ")).replace(/\'/g, "%27"); + + return '' + result.doc.breadcrumbs + '' + + '' + + teaser + ''; + } + + function makeTeaser(body, searchterms) { + // The strategy is as follows: + // First, assign a value to each word in the document: + // Words that correspond to search terms (stemmer aware): 40 + // Normal words: 2 + // First word in a sentence: 8 + // Then use a sliding window with a constant number of words and count the + // sum of the values of the words within the window. Then use the window that got the + // maximum sum. If there are multiple maximas, then get the last one. + // Enclose the terms in . + var stemmed_searchterms = searchterms.map(function(w) { + return elasticlunr.stemmer(w.toLowerCase()); + }); + var searchterm_weight = 40; + var weighted = []; // contains elements of ["word", weight, index_in_document] + // split in sentences, then words + var sentences = body.toLowerCase().split('. '); + var index = 0; + var value = 0; + var searchterm_found = false; + for (var sentenceindex in sentences) { + var words = sentences[sentenceindex].split(' '); + value = 8; + for (var wordindex in words) { + var word = words[wordindex]; + if (word.length > 0) { + for (var searchtermindex in stemmed_searchterms) { + if (elasticlunr.stemmer(word).startsWith(stemmed_searchterms[searchtermindex])) { + value = searchterm_weight; + searchterm_found = true; + } + }; + weighted.push([word, value, index]); + value = 2; + } + index += word.length; + index += 1; // ' ' or '.' if last word in sentence + }; + index += 1; // because we split at a two-char boundary '. ' + }; + + if (weighted.length == 0) { + return body; + } + + var window_weight = []; + var window_size = Math.min(weighted.length, results_options.teaser_word_count); + + var cur_sum = 0; + for (var wordindex = 0; wordindex < window_size; wordindex++) { + cur_sum += weighted[wordindex][1]; + }; + window_weight.push(cur_sum); + for (var wordindex = 0; wordindex < weighted.length - window_size; wordindex++) { + cur_sum -= weighted[wordindex][1]; + cur_sum += weighted[wordindex + window_size][1]; + window_weight.push(cur_sum); + }; + + if (searchterm_found) { + var max_sum = 0; + var max_sum_window_index = 0; + // backwards + for (var i = window_weight.length - 1; i >= 0; i--) { + if (window_weight[i] > max_sum) { + max_sum = window_weight[i]; + max_sum_window_index = i; + } + }; + } else { + max_sum_window_index = 0; + } + + // add around searchterms + var teaser_split = []; + var index = weighted[max_sum_window_index][2]; + for (var i = max_sum_window_index; i < max_sum_window_index+window_size; i++) { + var word = weighted[i]; + if (index < word[2]) { + // missing text from index to start of `word` + teaser_split.push(body.substring(index, word[2])); + index = word[2]; + } + if (word[1] == searchterm_weight) { + teaser_split.push("") + } + index = word[2] + word[0].length; + teaser_split.push(body.substring(word[2], index)); + if (word[1] == searchterm_weight) { + teaser_split.push("") + } + }; + + return teaser_split.join(''); + } + + function init(config) { + results_options = config.results_options; + search_options = config.search_options; + searchbar_outer = config.searchbar_outer; + doc_urls = config.doc_urls; + searchindex = elasticlunr.Index.load(config.index); + + // Set up events + searchicon.addEventListener('click', function(e) { searchIconClickHandler(); }, false); + searchbar.addEventListener('keyup', function(e) { searchbarKeyUpHandler(); }, false); + document.addEventListener('keydown', function(e) { globalKeyHandler(e); }, false); + // If the user uses the browser buttons, do the same as if a reload happened + window.onpopstate = function(e) { doSearchOrMarkFromUrl(); }; + // Suppress "submit" events so the page doesn't reload when the user presses Enter + document.addEventListener('submit', function(e) { e.preventDefault(); }, false); + + // If reloaded, do the search or mark again, depending on the current url parameters + doSearchOrMarkFromUrl(); + } + + function unfocusSearchbar() { + // hacky, but just focusing a div only works once + var tmp = document.createElement('input'); + tmp.setAttribute('style', 'position: absolute; opacity: 0;'); + searchicon.appendChild(tmp); + tmp.focus(); + tmp.remove(); + } + + // On reload or browser history backwards/forwards events, parse the url and do search or mark + function doSearchOrMarkFromUrl() { + // Check current URL for search request + var url = parseURL(window.location.href); + if (url.params.hasOwnProperty(URL_SEARCH_PARAM) + && url.params[URL_SEARCH_PARAM] != "") { + showSearch(true); + searchbar.value = decodeURIComponent( + (url.params[URL_SEARCH_PARAM]+'').replace(/\+/g, '%20')); + searchbarKeyUpHandler(); // -> doSearch() + } else { + showSearch(false); + } + + if (url.params.hasOwnProperty(URL_MARK_PARAM)) { + var words = decodeURIComponent(url.params[URL_MARK_PARAM]).split(' '); + marker.mark(words, { + exclude: mark_exclude + }); + + var markers = document.querySelectorAll("mark"); + function hide() { + for (var i = 0; i < markers.length; i++) { + markers[i].classList.add("fade-out"); + window.setTimeout(function(e) { marker.unmark(); }, 300); + } + } + for (var i = 0; i < markers.length; i++) { + markers[i].addEventListener('click', hide); + } + } + } + + // Eventhandler for keyevents on `document` + function globalKeyHandler(e) { + if (e.altKey || e.ctrlKey || e.metaKey || e.shiftKey || e.target.type === 'textarea' || e.target.type === 'text' || !hasFocus() && /^(?:input|select|textarea)$/i.test(e.target.nodeName)) { return; } + + if (e.keyCode === ESCAPE_KEYCODE) { + e.preventDefault(); + searchbar.classList.remove("active"); + setSearchUrlParameters("", + (searchbar.value.trim() !== "") ? "push" : "replace"); + if (hasFocus()) { + unfocusSearchbar(); + } + showSearch(false); + marker.unmark(); + } else if (!hasFocus() && e.keyCode === SEARCH_HOTKEY_KEYCODE) { + e.preventDefault(); + showSearch(true); + window.scrollTo(0, 0); + searchbar.select(); + } else if (hasFocus() && e.keyCode === DOWN_KEYCODE) { + e.preventDefault(); + unfocusSearchbar(); + searchresults.firstElementChild.classList.add("focus"); + } else if (!hasFocus() && (e.keyCode === DOWN_KEYCODE + || e.keyCode === UP_KEYCODE + || e.keyCode === SELECT_KEYCODE)) { + // not `:focus` because browser does annoying scrolling + var focused = searchresults.querySelector("li.focus"); + if (!focused) return; + e.preventDefault(); + if (e.keyCode === DOWN_KEYCODE) { + var next = focused.nextElementSibling; + if (next) { + focused.classList.remove("focus"); + next.classList.add("focus"); + } + } else if (e.keyCode === UP_KEYCODE) { + focused.classList.remove("focus"); + var prev = focused.previousElementSibling; + if (prev) { + prev.classList.add("focus"); + } else { + searchbar.select(); + } + } else { // SELECT_KEYCODE + window.location.assign(focused.querySelector('a')); + } + } + } + + function showSearch(yes) { + if (yes) { + search_wrap.classList.remove('hidden'); + searchicon.setAttribute('aria-expanded', 'true'); + } else { + search_wrap.classList.add('hidden'); + searchicon.setAttribute('aria-expanded', 'false'); + var results = searchresults.children; + for (var i = 0; i < results.length; i++) { + results[i].classList.remove("focus"); + } + } + } + + function showResults(yes) { + if (yes) { + searchresults_outer.classList.remove('hidden'); + } else { + searchresults_outer.classList.add('hidden'); + } + } + + // Eventhandler for search icon + function searchIconClickHandler() { + if (search_wrap.classList.contains('hidden')) { + showSearch(true); + window.scrollTo(0, 0); + searchbar.select(); + } else { + showSearch(false); + } + } + + // Eventhandler for keyevents while the searchbar is focused + function searchbarKeyUpHandler() { + var searchterm = searchbar.value.trim(); + if (searchterm != "") { + searchbar.classList.add("active"); + doSearch(searchterm); + } else { + searchbar.classList.remove("active"); + showResults(false); + removeChildren(searchresults); + } + + setSearchUrlParameters(searchterm, "push_if_new_search_else_replace"); + + // Remove marks + marker.unmark(); + } + + // Update current url with ?URL_SEARCH_PARAM= parameter, remove ?URL_MARK_PARAM and #heading-anchor . + // `action` can be one of "push", "replace", "push_if_new_search_else_replace" + // and replaces or pushes a new browser history item. + // "push_if_new_search_else_replace" pushes if there is no `?URL_SEARCH_PARAM=abc` yet. + function setSearchUrlParameters(searchterm, action) { + var url = parseURL(window.location.href); + var first_search = ! url.params.hasOwnProperty(URL_SEARCH_PARAM); + if (searchterm != "" || action == "push_if_new_search_else_replace") { + url.params[URL_SEARCH_PARAM] = searchterm; + delete url.params[URL_MARK_PARAM]; + url.hash = ""; + } else { + delete url.params[URL_MARK_PARAM]; + delete url.params[URL_SEARCH_PARAM]; + } + // A new search will also add a new history item, so the user can go back + // to the page prior to searching. A updated search term will only replace + // the url. + if (action == "push" || (action == "push_if_new_search_else_replace" && first_search) ) { + history.pushState({}, document.title, renderURL(url)); + } else if (action == "replace" || (action == "push_if_new_search_else_replace" && !first_search) ) { + history.replaceState({}, document.title, renderURL(url)); + } + } + + function doSearch(searchterm) { + + // Don't search the same twice + if (current_searchterm == searchterm) { return; } + else { current_searchterm = searchterm; } + + if (searchindex == null) { return; } + + // Do the actual search + var results = searchindex.search(searchterm, search_options); + var resultcount = Math.min(results.length, results_options.limit_results); + + // Display search metrics + searchresults_header.innerText = formatSearchMetric(resultcount, searchterm); + + // Clear and insert results + var searchterms = searchterm.split(' '); + removeChildren(searchresults); + for(var i = 0; i < resultcount ; i++){ + var resultElem = document.createElement('li'); + resultElem.innerHTML = formatSearchResult(results[i], searchterms); + searchresults.appendChild(resultElem); + } + + // Display results + showResults(true); + } + + fetch(path_to_root + 'searchindex.json') + .then(response => response.json()) + .then(json => init(json)) + .catch(error => { // Try to load searchindex.js if fetch failed + var script = document.createElement('script'); + script.src = path_to_root + 'searchindex.js'; + script.onload = () => init(window.search); + document.head.appendChild(script); + }); + + // Exported functions + search.hasFocus = hasFocus; +})(window.search); diff --git a/searchindex.js b/searchindex.js new file mode 100644 index 00000000..724d894a --- /dev/null +++ b/searchindex.js @@ -0,0 +1 @@ +Object.assign(window.search, {"doc_urls":["index.html#jellytau-documentation","requirements.html#requirements-specification","requirements.html#1-user-requirements","requirements.html#2-software-requirements","requirements.html#21-integration-requirements","requirements.html#22-jellyfin-api-requirements","requirements.html#23-development-requirements","requirements.html#3-traceability-matrix","requirements.html#user-requirements-to-software-requirements","requirements.html#4-test-traceability","requirements.html#unit-tests-to-software-requirements","requirements.html#integration-tests","requirements.html#5-technical-debt","requirements.html#open-items-carried-over-from-the-v060-codebase-audit","requirements.html#linux-keyring-integration-workaround","requirements.html#platform-playback-backend-parity-linux-vs-android","requirements.html#frontend-playback-code-duplication","traceability.html#code-traceability-matrix","traceability.html#summary","traceability.html#requirements-by-type","traceability.html#user-requirements-ur","traceability.html#integration-requirements-ir","traceability.html#development-requirements-dr","traceability.html#jellyfin-api-requirements-ja","traceability.html#detailed-mapping","traceability.html#ir-003","traceability.html#ir-004","traceability.html#ir-006","traceability.html#ir-008","traceability.html#ir-009","traceability.html#ir-010","traceability.html#ir-011","traceability.html#ir-012","traceability.html#ir-013","traceability.html#ir-014","traceability.html#ir-015","traceability.html#ir-016","traceability.html#ir-018","traceability.html#ir-019","traceability.html#ir-020","traceability.html#ir-021","traceability.html#ir-022","traceability.html#ir-023","traceability.html#ir-024","traceability.html#ir-025","traceability.html#ir-026","traceability.html#ir-027","traceability.html#ir-028","traceability.html#ir-030","traceability.html#ir-031","traceability.html#dr-001","traceability.html#dr-002","traceability.html#dr-003","traceability.html#dr-004","traceability.html#dr-005","traceability.html#dr-006","traceability.html#dr-007","traceability.html#dr-009","traceability.html#dr-010","traceability.html#dr-011","traceability.html#dr-012","traceability.html#dr-013","traceability.html#dr-014","traceability.html#dr-015","traceability.html#dr-016","traceability.html#dr-017","traceability.html#dr-018","traceability.html#dr-020","traceability.html#dr-021","traceability.html#dr-022","traceability.html#dr-023","traceability.html#dr-024","traceability.html#dr-025","traceability.html#dr-026","traceability.html#dr-027","traceability.html#dr-028","traceability.html#dr-029","traceability.html#dr-030","traceability.html#dr-032","traceability.html#dr-033","traceability.html#dr-034","traceability.html#dr-035","traceability.html#dr-036","traceability.html#dr-037","traceability.html#dr-038","traceability.html#dr-039","traceability.html#dr-040","traceability.html#dr-041","traceability.html#dr-042","traceability.html#dr-043","traceability.html#dr-044","traceability.html#dr-045","traceability.html#dr-047","traceability.html#dr-048","traceability.html#dr-049","traceability.html#dr-050","traceability.html#dr-051","traceability.html#dr-052","traceability.html#dr-053","traceability.html#dr-054","traceability.html#dr-055","traceability.html#dr-056","traceability.html#dr-057","traceability.html#dr-058","traceability.html#dr-059","traceability.html#dr-060","traceability.html#dr-061","traceability.html#dr-062","traceability.html#dr-063","traceability.html#dr-064","traceability.html#dr-065","traceability.html#dr-066","traceability.html#dr-067","traceability.html#dr-068","traceability.html#dr-069","traceability.html#dr-070","traceability.html#dr-074","traceability.html#dr-075","traceability.html#dr-076","traceability.html#dr-077","traceability.html#dr-078","traceability.html#dr-079","traceability.html#dr-080","traceability.html#dr-081","traceability.html#dr-082","traceability.html#dr-083","traceability.html#dr-084","traceability.html#dr-085","traceability.html#dr-086","traceability.html#dr-087","traceability.html#dr-088","traceability.html#dr-089","traceability.html#dr-090","traceability.html#dr-091","traceability.html#dr-092","traceability.html#dr-093","traceability.html#dr-095","traceability.html#dr-096","traceability.html#dr-097","traceability.html#dr-098","traceability.html#dr-099","traceability.html#dr-100","traceability.html#dr-101","traceability.html#dr-102","traceability.html#dr-103","traceability.html#dr-104","traceability.html#dr-105","traceability.html#dr-106","traceability.html#dr-107","traceability.html#dr-108","traceability.html#dr-109","traceability.html#dr-110","traceability.html#dr-111","traceability.html#dr-112","traceability.html#dr-113","traceability.html#dr-114","traceability.html#dr-115","traceability.html#dr-116","traceability.html#dr-117","traceability.html#dr-118","traceability.html#dr-119","traceability.html#dr-120","traceability.html#dr-123","traceability.html#dr-126","traceability.html#dr-127","traceability.html#dr-128","traceability.html#dr-129","traceability.html#dr-130","traceability.html#dr-131","traceability.html#dr-132","traceability.html#dr-133","traceability.html#dr-134","traceability.html#dr-135","traceability.html#dr-136","traceability.html#dr-137","traceability.html#dr-140","traceability.html#dr-141","traceability.html#dr-142","traceability.html#dr-143","traceability.html#dr-147","traceability.html#dr-148","traceability.html#dr-149","traceability.html#dr-150","traceability.html#dr-151","traceability.html#dr-152","traceability.html#dr-153","traceability.html#dr-154","traceability.html#dr-155","traceability.html#dr-156","traceability.html#dr-157","traceability.html#dr-158","traceability.html#dr-159","traceability.html#dr-160","traceability.html#dr-162","traceability.html#dr-167","traceability.html#dr-168","traceability.html#dr-169","traceability.html#dr-170","traceability.html#dr-171","traceability.html#dr-173","traceability.html#dr-174","traceability.html#dr-175","traceability.html#dr-176","traceability.html#dr-177","traceability.html#dr-178","traceability.html#dr-179","traceability.html#dr-180","traceability.html#dr-181","traceability.html#dr-182","traceability.html#dr-183","traceability.html#dr-185","traceability.html#dr-186","traceability.html#dr-188","traceability.html#dr-189","traceability.html#dr-193","traceability.html#dr-195","traceability.html#dr-196","traceability.html#dr-197","traceability.html#dr-198","traceability.html#dr-201","traceability.html#dr-203","traceability.html#dr-204","traceability.html#dr-205","traceability.html#dr-206","traceability.html#dr-207","traceability.html#dr-209","traceability.html#dr-210","traceability.html#dr-211","traceability.html#dr-212","traceability.html#dr-213","traceability.html#dr-215","traceability.html#dr-216","traceability.html#dr-217","traceability.html#dr-218","traceability.html#dr-219","traceability.html#dr-220","traceability.html#dr-221","traceability.html#dr-223","traceability.html#dr-224","traceability.html#ja-001","traceability.html#ja-002","traceability.html#ja-003","traceability.html#ja-004","traceability.html#ja-005","traceability.html#ja-006","traceability.html#ja-007","traceability.html#ja-008","traceability.html#ja-009","traceability.html#ja-010","traceability.html#ja-011","traceability.html#ja-012","traceability.html#ja-013","traceability.html#ja-014","traceability.html#ja-015","traceability.html#ja-016","traceability.html#ja-017","traceability.html#ja-018","traceability.html#ja-019","traceability.html#ja-020","traceability.html#ja-021","traceability.html#ja-022","traceability.html#ja-023","traceability.html#ja-024","traceability.html#ja-025","traceability.html#ja-026","traceability.html#ja-028","traceability.html#ja-029","traceability.html#ja-030","traceability.html#ja-031","traceability.html#ja-032","traceability.html#ja-033","traceability.html#ja-034","traceability.html#ja-035","traceability.html#ja-036","traceability.html#ur-003","traceability.html#ur-004","traceability.html#ur-005","traceability.html#ur-008","traceability.html#ur-009","traceability.html#ur-018","traceability.html#ur-019","traceability.html#ur-020","traceability.html#ur-021","traceability.html#ur-023","traceability.html#ur-025","traceability.html#ur-026","traceability.html#ur-027","traceability.html#ur-034","traceability.html#ur-040","traceability.html#ur-041","traceability.html#ur-053","traceability.html#ur-055","traceability.html#ur-056","traceability.html#ur-062","traceability.html#ur-064","traceability.html#ut-010","traceability.html#ut-024","traceability.html#ut-052","traceability.html#ut-060","traceability.html#ut-061","traceability.html#ut-062","traceability.html#ut-066","traceability.html#ut-071","traceability.html#ut-085","traceability.html#ut-086","traceability.html#ut-087","traceability.html#ut-088","traceability.html#ut-089","traceability.html#ut-090","traceability.html#ut-091","traceability.html#ut-092","traceability.html#ut-093","traceability.html#ut-094","traceability.html#ut-095","traceability.html#ut-096","traceability.html#ut-097","traceability.html#ut-098","traceability.html#ut-105","traceability.html#ut-106","traceability.html#ut-117","traceability.html#ut-118","traceability.html#ut-119","traceability.html#ut-123","traceability.html#ut-124","traceability.html#ut-131","traceability.html#ut-132","traceability.html#ut-133","traceability.html#ut-134","traceability.html#ut-135","traceability.html#ut-136","traceability.html#ut-137","traceability.html#ut-138","traceability.html#ut-139","traceability.html#ut-149","traceability.html#ut-158","traceability.html#ut-159","traceability.html#ut-160","traceability.html#ut-181","traceability.html#ut-182","traceability.html#ur-067","traceability.html#it-013","traceability.html#ut-186","traceability.html#ut-192","traceability.html#ut-211","traceability.html#ur-006","traceability.html#ur-013","traceability.html#ur-015","traceability.html#ur-016","traceability.html#ur-022","traceability.html#ur-024","traceability.html#ur-043","traceability.html#ur-049","traceability.html#ur-050","traceability.html#ur-071","traceability.html#ur-073","traceability.html#ur-074","traceability.html#it-003","traceability.html#it-004","traceability.html#it-016","traceability.html#ut-003","traceability.html#ut-004","traceability.html#ut-005","traceability.html#ut-012","traceability.html#ut-026","traceability.html#ut-027","traceability.html#ut-028","traceability.html#ut-029","traceability.html#ut-030","traceability.html#ut-031","traceability.html#ut-032","traceability.html#ut-033","traceability.html#ut-051","traceability.html#ut-059","traceability.html#ut-067","traceability.html#ut-072","traceability.html#ut-073","traceability.html#ut-074","traceability.html#ut-075","traceability.html#ut-076","traceability.html#ut-077","traceability.html#ut-078","traceability.html#ut-079","traceability.html#ut-080","traceability.html#ut-081","traceability.html#ut-082","traceability.html#ut-099","traceability.html#ut-100","traceability.html#ut-101","traceability.html#ut-102","traceability.html#ut-104","traceability.html#ut-107","traceability.html#ut-108","traceability.html#ut-109","traceability.html#ut-110","traceability.html#ut-111","traceability.html#ut-112","traceability.html#ut-113","traceability.html#ut-114","traceability.html#ut-116","traceability.html#ut-120","traceability.html#ut-130","traceability.html#ut-140","traceability.html#ut-151","traceability.html#ut-170","traceability.html#ur-002","traceability.html#ur-007","traceability.html#ur-011","traceability.html#ur-029","traceability.html#ur-030","traceability.html#ur-031","traceability.html#ur-032","traceability.html#ur-035","traceability.html#ur-036","traceability.html#ur-037","traceability.html#ur-038","traceability.html#ur-042","traceability.html#ur-046","traceability.html#ur-047","traceability.html#ur-048","traceability.html#ur-051","traceability.html#ur-052","traceability.html#ur-057","traceability.html#ur-058","traceability.html#ur-063","traceability.html#ur-065","traceability.html#ur-068","traceability.html#ur-069","traceability.html#ur-075","traceability.html#ur-076","traceability.html#ur-078","traceability.html#ut-145","traceability.html#ut-146","traceability.html#ut-167","traceability.html#ut-168","traceability.html#ur-028","traceability.html#ur-054","traceability.html#ur-066","traceability.html#ut-174","traceability.html#ur-061","traceability.html#ut-143","traceability.html#ut-144","traceability.html#ut-147","traceability.html#ut-175","traceability.html#ut-184","traceability.html#ut-185","traceability.html#ut-187","traceability.html#ut-188","traceability.html#ut-189","traceability.html#ur-017","traceability.html#ur-039","traceability.html#ut-207","traceability.html#ur-010","traceability.html#ut-183","traceability.html#ur-012","traceability.html#ur-059","traceability.html#ut-208","traceability.html#ur-060","traceability.html#ur-072","traceability.html#ur-077","traceability.html#ut-201","traceability.html#ut-206","traceability.html#ut-209","traceability.html#ur-033","traceability.html#ur-045","traceability.html#it-017","traceability.html#ut-014","traceability.html#ut-015","traceability.html#ut-016","traceability.html#ut-017","traceability.html#ut-018","traceability.html#ut-019","traceability.html#ut-020","traceability.html#ut-021","traceability.html#ut-022","traceability.html#ut-023","traceability.html#ut-025","traceability.html#ut-042","traceability.html#ut-043","traceability.html#ut-044","traceability.html#ut-083","traceability.html#ut-084","traceability.html#ut-103","traceability.html#ut-115","traceability.html#ut-121","traceability.html#ut-122","traceability.html#ut-125","traceability.html#ut-126","traceability.html#ut-127","traceability.html#ut-141","traceability.html#ut-142","traceability.html#ut-148","traceability.html#ut-150","traceability.html#ut-152","traceability.html#ut-154","traceability.html#ut-155","traceability.html#ut-156","traceability.html#ut-157","traceability.html#ut-161","traceability.html#ut-162","traceability.html#ut-163","traceability.html#ut-164","traceability.html#ut-166","traceability.html#ut-171","traceability.html#ut-172","traceability.html#ut-173","traceability.html#ut-176","traceability.html#ut-177","traceability.html#ut-178","traceability.html#ut-179","traceability.html#ut-180","traceability.html#ut-190","traceability.html#ut-191","traceability.html#ut-193","traceability.html#ut-194","traceability.html#ut-195","traceability.html#ut-196","traceability.html#ut-197","traceability.html#ut-198","traceability.html#ut-200","traceability.html#ut-202","traceability.html#ut-203","traceability.html#ut-205","traceability.html#ur-014","traceability.html#ur-044","traceability.html#ut-204","traceability.html#ut-210","traceability-ci.html#requirement-traceability-cicd-pipeline","traceability-ci.html#overview","traceability-ci.html#gitea-actions-workflows","traceability-ci.html#what-gets-validated","traceability-ci.html#1-trace-extraction","traceability-ci.html#2-coverage-thresholds","traceability-ci.html#2b-dangling-requirement-ids","traceability-ci.html#3-modified-file-checking","traceability-ci.html#how-to-add-traces-to-new-code","traceability-ci.html#typescriptsvelte-example","traceability-ci.html#rust-example","traceability-ci.html#test-example","traceability-ci.html#traces-format","traceability-ci.html#workflow-behavior","traceability-ci.html#on-push-to-main-branch","traceability-ci.html#on-pull-request","traceability-ci.html#failure-scenarios","traceability-ci.html#viewing-reports","traceability-ci.html#in-gitea-actions-ui","traceability-ci.html#locally","traceability-ci.html#coverage-goals","traceability-ci.html#current-status","traceability-ci.html#targets","traceability-ci.html#improving-coverage","traceability-ci.html#for-missing-user-requirements-ur","traceability-ci.html#for-missing-integration-requirements-ir","traceability-ci.html#for-missing-development-requirements-dr","traceability-ci.html#for-jellyfin-api-requirements-ja","traceability-ci.html#example-pr-checklist","traceability-ci.html#troubleshooting","traceability-ci.html#coverage-below-minimum-threshold","traceability-ci.html#new-files-without-traces","traceability-ci.html#invalid-json-format","traceability-ci.html#integration-with-development","traceability-ci.html#before-committing","traceability-ci.html#in-your-ide","traceability-ci.html#git-hooks","traceability-ci.html#references","traceability-ci.html#support","traces-quick-ref.html#traces-quick-reference-guide","traces-quick-ref.html#what-are-traces","traces-quick-ref.html#quick-examples","traces-quick-ref.html#typescript","traces-quick-ref.html#svelte","traces-quick-ref.html#rust","traces-quick-ref.html#requirement-types","traces-quick-ref.html#where-to-find-requirements","traces-quick-ref.html#how-to-add-traces","traces-quick-ref.html#step-1-find-the-requirement","traces-quick-ref.html#step-2-add-comment","traces-quick-ref.html#step-3-run-extraction","traces-quick-ref.html#common-patterns","traces-quick-ref.html#single-requirement","traces-quick-ref.html#multiple-requirements-same-type","traces-quick-ref.html#multiple-types","traces-quick-ref.html#test-coverage","traces-quick-ref.html#modulesfiles","traces-quick-ref.html#validation","traces-quick-ref.html#check-your-changes","traces-quick-ref.html#before-committing","traces-quick-ref.html#cicd-validation","traces-quick-ref.html#tips--tricks","traces-quick-ref.html#find-related-code","traces-quick-ref.html#update-your-editor","traces-quick-ref.html#find-untraced-code","traces-quick-ref.html#faq","traces-quick-ref.html#see-also","architecture/index.html#jellytau-software-architecture","architecture/index.html#architecture-overview","architecture/index.html#architecture-principles","architecture/index.html#detailed-documentation","architecture/index.html#file-structure-summary","architecture/index.html#key-architecture-changes","architecture/01-rust-backend.html#rust-backend-architecture","architecture/01-rust-backend.html#media-session-state-machine","architecture/01-rust-backend.html#player-state-machine-low-level-playback","architecture/01-rust-backend.html#playback-mode-state-machine","architecture/01-rust-backend.html#media-item--source","architecture/01-rust-backend.html#queue-manager","architecture/01-rust-backend.html#favorites-system","architecture/01-rust-backend.html#marking","architecture/01-rust-backend.html#browsing","architecture/01-rust-backend.html#player-backend-trait","architecture/01-rust-backend.html#player-controller","architecture/01-rust-backend.html#playlist-system","architecture/01-rust-backend.html#tauri-commands-player","architecture/01-rust-backend.html#domain-vocabulary-owned-by-rust","architecture/01-rust-backend.html#search-scope-and-the-taxonomy-boundary","architecture/01-rust-backend.html#library-exclusions","architecture/01-rust-backend.html#streaming-quality-ladder","architecture/01-rust-backend.html#background-workers","architecture/01-rust-backend.html#catalog-indexer","architecture/02-svelte-frontend.html#svelte-frontend-architecture","architecture/02-svelte-frontend.html#store-structure","architecture/02-svelte-frontend.html#music-library-architecture","architecture/02-svelte-frontend.html#playback-reporting-service","architecture/02-svelte-frontend.html#repository-architecture-rust-based","architecture/02-svelte-frontend.html#playback-mode-system","architecture/02-svelte-frontend.html#database-service-abstraction","architecture/02-svelte-frontend.html#component-hierarchy","architecture/02-svelte-frontend.html#miniplayer-behavior","architecture/02-svelte-frontend.html#sleep-timer-architecture","architecture/02-svelte-frontend.html#auto-play-episode-limit","architecture/02-svelte-frontend.html#player-page-navigation-guard","architecture/02-svelte-frontend.html#playlist-management-ui","architecture/02-svelte-frontend.html#app-shell-and-chrome","architecture/02-svelte-frontend.html#visibility-rules","architecture/02-svelte-frontend.html#the-structural-fix-worth-not-undoing","architecture/02-svelte-frontend.html#accountmenu","architecture/02-svelte-frontend.html#library-mosaic","architecture/02-svelte-frontend.html#series-and-episode-navigation","architecture/02-svelte-frontend.html#downloaded-browse","architecture/02-svelte-frontend.html#safe-area-insets","architecture/02-svelte-frontend.html#native-video-store","architecture/02-svelte-frontend.html#logging","architecture/03-data-flow.html#data-flow","architecture/03-data-flow.html#repository-query-flow-cache-first","architecture/03-data-flow.html#search-flow-locally-indexed","architecture/03-data-flow.html#playback-initiation-flow","architecture/03-data-flow.html#playback-mode-transfer-flow","architecture/03-data-flow.html#queue-navigation-flow","architecture/03-data-flow.html#volume-control-flow","architecture/04-type-sync-and-threading.html#type-synchronization--thread-safety","architecture/04-type-sync-and-threading.html#playerstate-rust--typescript","architecture/04-type-sync-and-threading.html#mediaitem-serialization","architecture/04-type-sync-and-threading.html#tauri-v2-ipc-parameter-naming-convention","architecture/04-type-sync-and-threading.html#rust-backend-thread-safety","architecture/04-type-sync-and-threading.html#frontend-stores","architecture/05-platform-backends.html#platform-specific-player-backends","architecture/05-platform-backends.html#player-events-system","architecture/05-platform-backends.html#html5-video-adapter-webview-rendered-video","architecture/05-platform-backends.html#mpvbackend-linux","architecture/05-platform-backends.html#exoplayerbackend-android","architecture/05-platform-backends.html#audio-settings-on-exoplayer","architecture/05-platform-backends.html#the-equalizer-and-where-its-vocabulary-lives","architecture/05-platform-backends.html#background-audio-handoff-android","architecture/05-platform-backends.html#native-video-compositing-android","architecture/05-platform-backends.html#android-mediasession--remote-volume-control","architecture/05-platform-backends.html#android-album-art-caching","architecture/05-platform-backends.html#backend-initialization","architecture/06-downloads-and-offline.html#download-manager--offline-architecture","architecture/06-downloads-and-offline.html#overview","architecture/06-downloads-and-offline.html#download-worker","architecture/06-downloads-and-offline.html#smart-caching-engine","architecture/06-downloads-and-offline.html#one-storage-model-cache-entries-are-downloads","architecture/06-downloads-and-offline.html#offline-catalog-visibility","architecture/06-downloads-and-offline.html#download-commands","architecture/06-downloads-and-offline.html#offline-commands","architecture/06-downloads-and-offline.html#player-integration","architecture/06-downloads-and-offline.html#frontend-downloads-store","architecture/06-downloads-and-offline.html#download-ui-components","architecture/06-downloads-and-offline.html#database-schema","architecture/07-connectivity.html#connectivity--network-architecture","architecture/07-connectivity.html#http-client-with-retry-logic","architecture/07-connectivity.html#connectivity-monitor","architecture/07-connectivity.html#source-of-truth-repository-traffic","architecture/07-connectivity.html#time-window-debounce-offline--instant-recovery-online","architecture/07-connectivity.html#offline-only-recovery-probe","architecture/07-connectivity.html#network-resilience-architecture","architecture/08-database-design.html#offline-database-design","architecture/08-database-design.html#entity-relationship-diagram","architecture/08-database-design.html#table-definitions","architecture/08-database-design.html#servers","architecture/08-database-design.html#users","architecture/08-database-design.html#libraries","architecture/08-database-design.html#items","architecture/08-database-design.html#media_streams","architecture/08-database-design.html#user_data","architecture/08-database-design.html#downloads","architecture/08-database-design.html#sync_queue","architecture/08-database-design.html#thumbnails","architecture/08-database-design.html#playlists-for-localsynced-playlists","architecture/08-database-design.html#key-queries","architecture/08-database-design.html#get-items-for-offline-library-browsing","architecture/08-database-design.html#resume--continue-watching","architecture/08-database-design.html#offline-search","architecture/08-database-design.html#download-queue-management","architecture/08-database-design.html#sync-queue-processing","architecture/08-database-design.html#data-flow","architecture/08-database-design.html#online-mode","architecture/08-database-design.html#offline-mode","architecture/08-database-design.html#sync-on-reconnect","architecture/08-database-design.html#storage-estimates","architecture/08-database-design.html#rust-module-structure","architecture/09-security.html#security","architecture/09-security.html#authentication-token-storage","architecture/09-security.html#secure-storage-module","architecture/09-security.html#network-security","architecture/09-security.html#webview-content-security-policy","architecture/09-security.html#asset-protocol-scope","architecture/09-security.html#local-data-protection","architecture/09-security.html#path-confinement-and-input-binding","architecture/09-security.html#filesystem-path-confinement","architecture/09-security.html#query-and-url-construction","architecture/09-security.html#security-considerations","ux-flows.html#jellytau-ux-flows--screen-transitions","ux-flows.html#1-core-navigation-structure","ux-flows.html#11-navigation-system","ux-flows.html#12-account-menu","ux-flows.html#13-chrome-availability","ux-flows.html#14-known-deviations","ux-flows.html#2-initial-app-launch-flow","ux-flows.html#21-first-time-launch","ux-flows.html#22-subsequent-launches","ux-flows.html#3-audio-playback-flows","ux-flows.html#31-starting-audio-playback","ux-flows.html#32-miniplayer--full-player-transition","ux-flows.html#33-full-player--back-to-browsing","ux-flows.html#4-video-playback-flows","ux-flows.html#41-starting-video-playback","ux-flows.html#42-video-player-screen-ir-003-ir-004-ur-003","ux-flows.html#43-exiting-video-player","ux-flows.html#5-music-library-navigation-flows","ux-flows.html#51-music-category-landing-page","ux-flows.html#52-albums-view-flow","ux-flows.html#53-artist-navigation","ux-flows.html#5a-library-page-layouts","ux-flows.html#5a1-card-shape-follows-the-media-not-the-page","ux-flows.html#5a2-grid-vs-list","ux-flows.html#5a3-what-a-card-shows","ux-flows.html#5a4-known-deviations","ux-flows.html#5b-video-detail-page-composition","ux-flows.html#5b1-which-surface-renders","ux-flows.html#5b2-episode-focus-view--section-order","ux-flows.html#5b3-movie-detail--section-order","ux-flows.html#5b4-series-detail--section-order","ux-flows.html#5b5-home-card-interaction--tap-opens-long-press-plays","ux-flows.html#5c-favourites","ux-flows.html#5c1-the-heart-appears-wherever-an-item-does","ux-flows.html#5c2-three-ways-back-to-what-you-favourited","ux-flows.html#5c3-removing-a-favourite-removes-it-everywhere-at-once","ux-flows.html#5c4-offline","ux-flows.html#5c5-known-deviations","ux-flows.html#6-search-flow","ux-flows.html#61-scope-is-inherited-from-context","ux-flows.html#62-search-page-layout","ux-flows.html#63-result-group-order-is-user-configurable","ux-flows.html#64-known-deviations","ux-flows.html#7-download-flows","ux-flows.html#71-initiating-downloads","ux-flows.html#72-downloads--a-browsable-offline-library-not-a-flat-list","ux-flows.html#73-the-downloaded-browse-surface","ux-flows.html#74-transfers-activity-view","ux-flows.html#75-navigation--entry-points","ux-flows.html#77-known-deviations","ux-flows.html#8-settings--account-flows","ux-flows.html#81-settings-navigation","ux-flows.html#82-logout-flow","ux-flows.html#9-background--lock-screen-behavior","ux-flows.html#91-audio-playback-in-background-android","ux-flows.html#92-video-playback-in-background-android--pip--background-audio","ux-flows.html#10-error-states--edge-cases","ux-flows.html#101-network-loss-during-streaming","ux-flows.html#102-server-unreachable","ux-flows.html#103-download-failed","ux-flows.html#11-platform-specific-ux-patterns","ux-flows.html#111-android-specific","ux-flows.html#112-linux-desktop-specific","ux-flows.html#12-ux-principles-summary","ux-flows.html#121-core-principles","ux-flows.html#122-animation--transitions","ux-flows.html#123-touch-targets-mobile","ux-flows.html#13-future-ux-enhancements","ux-flows.html#131-planned-features","ux-flows.html#132-accessibility-enhancements","specs/index.html#specs-index","specs/index.html#what-lives-here","specs/index.html#partially-implemented","specs/index.html#not-started","specs/index.html#design-authority","specs/index.html#where-the-shipped-specs-went","specs/SPEC-TEMPLATE.html#spec","specs/SPEC-TEMPLATE.html#summary","specs/SPEC-TEMPLATE.html#motivation","specs/SPEC-TEMPLATE.html#layer-assignment","specs/SPEC-TEMPLATE.html#design","specs/SPEC-TEMPLATE.html#out-of-scope","specs/SPEC-TEMPLATE.html#acceptance-criteria","specs/SPEC-TEMPLATE.html#testing","specs/SPEC-TEMPLATE.html#traces","specs/SPEC-TEMPLATE.html#notes-for-the-implementer","specs/SPEC-REVIEW-CHECKLIST.html#spec-review-checklist","specs/SPEC-REVIEW-CHECKLIST.html#boundary-the-one-that-bites","specs/SPEC-REVIEW-CHECKLIST.html#ipc-contract","specs/SPEC-REVIEW-CHECKLIST.html#requirements--traceability","specs/SPEC-REVIEW-CHECKLIST.html#lifecycle","specs/SPEC-REVIEW-CHECKLIST.html#conflicts--hygiene","specs/playback-backend-unification.html#spec-playback-backend-unification--findings-and-strategy","specs/playback-backend-unification.html#summary","specs/playback-backend-unification.html#motivation","specs/playback-backend-unification.html#findings","specs/playback-backend-unification.html#1-the-current-architecture-is-not-what-the-docs-describe","specs/playback-backend-unification.html#2-native-video-cannot-be-composited-with-a-tauri-webview","specs/playback-backend-unification.html#3-mpv-would-regress-streaming-quality","specs/playback-backend-unification.html#4-crossfade-is-architecturally-blocked-on-mpv","specs/playback-backend-unification.html#5-engine-comparison-summary","specs/playback-backend-unification.html#6-two-further-options-ruled-out","specs/playback-backend-unification.html#7-if-libmpv-is-ever-revisited-on-android","specs/playback-backend-unification.html#decision","specs/playback-backend-unification.html#corrections-to-existing-docs","specs/playback-backend-unification.html#layer-assignment","specs/playback-backend-unification.html#out-of-scope","specs/playback-backend-unification.html#acceptance-criteria","specs/playback-backend-unification.html#testing","specs/playback-backend-unification.html#traces","specs/playback-backend-unification.html#notes-for-the-implementer","specs/linux-native-video-spike.html#spec-linux-native-video--bounded-compositing-spike","specs/linux-native-video-spike.html#summary","specs/linux-native-video-spike.html#motivation","specs/linux-native-video-spike.html#the-blocker-a-green-spike-does-not-clear","specs/linux-native-video-spike.html#layer-assignment","specs/linux-native-video-spike.html#design","specs/linux-native-video-spike.html#what-gets-built","specs/linux-native-video-spike.html#bindings","specs/linux-native-video-spike.html#ipc","specs/linux-native-video-spike.html#gates","specs/linux-native-video-spike.html#time-box","specs/linux-native-video-spike.html#result-2026-08-21","specs/linux-native-video-spike.html#three-traps-each-of-which-cost-a-debugging-cycle","specs/linux-native-video-spike.html#hardware-decode-through-the-render-api","specs/linux-native-video-spike.html#what-is-still-open","specs/linux-native-video-spike.html#out-of-scope","specs/linux-native-video-spike.html#why-windows-is-unaffected-and-cheaper","specs/linux-native-video-spike.html#does-this-add-a-backend","specs/linux-native-video-spike.html#acceptance-criteria","specs/linux-native-video-spike.html#testing","specs/linux-native-video-spike.html#traces","specs/linux-native-video-spike.html#notes-for-the-implementer","specs/backend-owned-stream-selection.html#spec-backend-owned-stream-selection","specs/backend-owned-stream-selection.html#summary","specs/backend-owned-stream-selection.html#motivation","specs/backend-owned-stream-selection.html#the-prior-question","specs/backend-owned-stream-selection.html#layer-assignment","specs/backend-owned-stream-selection.html#the-line","specs/backend-owned-stream-selection.html#design","specs/backend-owned-stream-selection.html#the-contract","specs/backend-owned-stream-selection.html#re-negotiation","specs/backend-owned-stream-selection.html#phases","specs/backend-owned-stream-selection.html#out-of-scope","specs/backend-owned-stream-selection.html#acceptance-criteria","specs/backend-owned-stream-selection.html#testing","specs/backend-owned-stream-selection.html#traces","specs/backend-owned-stream-selection.html#notes-for-the-implementer","specs/player-facade-enforcement.html#spec-enforce-the-unified-player-boundary","specs/player-facade-enforcement.html#summary","specs/player-facade-enforcement.html#motivation","specs/player-facade-enforcement.html#layer-assignment","specs/player-facade-enforcement.html#design","specs/player-facade-enforcement.html#1-narrow-the-principle-to-what-it-actually-means","specs/player-facade-enforcement.html#2-fill-the-facade-gaps-group-b","specs/player-facade-enforcement.html#3-migrate-group-a","specs/player-facade-enforcement.html#4-add-the-lint-rule-the-part-that-makes-it-stick","specs/player-facade-enforcement.html#out-of-scope","specs/player-facade-enforcement.html#acceptance-criteria","specs/player-facade-enforcement.html#testing","specs/player-facade-enforcement.html#traces","specs/player-facade-enforcement.html#notes-for-the-implementer","specs/windows-native-audio-backend.html#spec-windows-native-audio-backend","specs/windows-native-audio-backend.html#summary","specs/windows-native-audio-backend.html#motivation","specs/windows-native-audio-backend.html#layer-assignment","specs/windows-native-audio-backend.html#design","specs/windows-native-audio-backend.html#engine-choice","specs/windows-native-audio-backend.html#structure","specs/windows-native-audio-backend.html#build","specs/windows-native-audio-backend.html#verified-build-mechanics","specs/windows-native-audio-backend.html#size-and-shipping","specs/windows-native-audio-backend.html#out-of-scope","specs/windows-native-audio-backend.html#acceptance-criteria","specs/windows-native-audio-backend.html#testing","specs/windows-native-audio-backend.html#traces","specs/windows-native-audio-backend.html#notes-for-the-implementer","specs/libmpv2-migration.html#spec-migrate-to-libmpv2-and-declare-the-project-licence","specs/libmpv2-migration.html#summary","specs/libmpv2-migration.html#motivation","specs/libmpv2-migration.html#the-dependency-is-dead","specs/libmpv2-migration.html#the-project-has-no-licence","specs/libmpv2-migration.html#design","specs/libmpv2-migration.html#part-1--licence","specs/libmpv2-migration.html#part-2--libmpv--libmpv2","specs/libmpv2-migration.html#layer-assignment","specs/libmpv2-migration.html#out-of-scope","specs/libmpv2-migration.html#acceptance-criteria","specs/libmpv2-migration.html#testing","specs/libmpv2-migration.html#traces","specs/libmpv2-migration.html#notes-for-the-implementer","specs/read-through-media-cache.html#spec-two-path-media--selectable-playback-bitrate-independent-whole-file-download","specs/read-through-media-cache.html#summary","specs/read-through-media-cache.html#motivation","specs/read-through-media-cache.html#layer-assignment","specs/read-through-media-cache.html#design","specs/read-through-media-cache.html#dr-121--bitrate-selection-in-the-player","specs/read-through-media-cache.html#dr-122--the-playback-path-is-ephemeral","specs/read-through-media-cache.html#dr-123--the-download-path-is-independent","specs/read-through-media-cache.html#dr-124--keep-playback-bytes-only-when-they--are--the-download","specs/read-through-media-cache.html#dr-125--promotion-rendition-and-invalidation","specs/read-through-media-cache.html#gating","specs/read-through-media-cache.html#out-of-scope","specs/read-through-media-cache.html#acceptance-criteria","specs/read-through-media-cache.html#testing","specs/read-through-media-cache.html#traces","specs/read-through-media-cache.html#notes-for-the-implementer","specs/scoped-search.html#spec-context-scoped-search-with-filter-chips-and-configurable-group-order","specs/scoped-search.html#summary","specs/scoped-search.html#motivation","specs/scoped-search.html#background-what-already-exists","specs/scoped-search.html#design","specs/scoped-search.html#scope-model","specs/scoped-search.html#route--scope-resolution-dr-063","specs/scoped-search.html#scope-is-a-starting-point-not-a-lock-dr-064","specs/scoped-search.html#threading-scope-through-the-store-dr-065","specs/scoped-search.html#group-order-dr-066-dr-067","specs/scoped-search.html#out-of-scope","specs/scoped-search.html#acceptance-criteria","specs/scoped-search.html#testing","specs/scoped-search.html#notes-for-the-implementer","specs/scoped-search-boundary.html#spec-move-search-scope-taxonomy-behind-the-rust-boundary","specs/scoped-search-boundary.html#why-this-spec-exists","specs/scoped-search-boundary.html#what-is--not--a-leak-leave-it-alone","specs/scoped-search-boundary.html#the-boundary-rule-after-this-change","specs/scoped-search-boundary.html#design","specs/scoped-search-boundary.html#rust-owns-scope--item-types-query-side","specs/scoped-search-boundary.html#rust-owns-result-bucketing-result-side","specs/scoped-search-boundary.html#-the-search-event-wrinkle--both-payloads-must-change","specs/scoped-search-boundary.html#what-the-frontend-keeps-all-pure-presentation","specs/scoped-search-boundary.html#frontend-call-site-changes","specs/scoped-search-boundary.html#out-of-scope","specs/scoped-search-boundary.html#acceptance-criteria","specs/scoped-search-boundary.html#testing","specs/scoped-search-boundary.html#traces","specs/scoped-search-boundary.html#notes-for-the-implementer","specs/scoped-search-boundary-implementation.html#spec-land-the-scoped-search-boundary-fix-implementation","specs/scoped-search-boundary-implementation.html#summary","specs/scoped-search-boundary-implementation.html#motivation","specs/scoped-search-boundary-implementation.html#layer-assignment","specs/scoped-search-boundary-implementation.html#design","specs/scoped-search-boundary-implementation.html#staging-land-it-in-two-reviewable-pieces","specs/scoped-search-boundary-implementation.html#stage-1--delivered-july-2026","specs/scoped-search-boundary-implementation.html#-the-search-event-dual-payload-stage-2","specs/scoped-search-boundary-implementation.html#note-on-searchoptionsscope-and-specta","specs/scoped-search-boundary-implementation.html#out-of-scope","specs/scoped-search-boundary-implementation.html#acceptance-criteria","specs/scoped-search-boundary-implementation.html#testing","specs/scoped-search-boundary-implementation.html#traces","specs/scoped-search-boundary-implementation.html#notes-for-the-implementer","specs/frontend-domain-model.html#spec-remove-jellyfin-specific-models-from-the-frontend","specs/frontend-domain-model.html#summary","specs/frontend-domain-model.html#motivation","specs/frontend-domain-model.html#layer-assignment","specs/frontend-domain-model.html#design","specs/frontend-domain-model.html#single-canonical-model-one-location-isolated-mappings","specs/frontend-domain-model.html#the-domain-model-rust","specs/frontend-domain-model.html#-the-search-event--dual-payload-rule-applies-again","specs/frontend-domain-model.html#frontend-after","specs/frontend-domain-model.html#phased-migration","specs/frontend-domain-model.html#out-of-scope","specs/frontend-domain-model.html#acceptance-criteria","specs/frontend-domain-model.html#testing","specs/frontend-domain-model.html#traces","specs/frontend-domain-model.html#notes-for-the-implementer","specs/build-provenance.html#spec-build-provenance-git-describe--build-profile","specs/build-provenance.html#summary","specs/build-provenance.html#motivation","specs/build-provenance.html#what-this-deliberately-does--not--do","specs/build-provenance.html#layer-assignment","specs/build-provenance.html#design","specs/build-provenance.html#buildrs","specs/build-provenance.html#the-provenance-type","specs/build-provenance.html#command","specs/build-provenance.html#settings--about","specs/build-provenance.html#removing-one-version-file","specs/build-provenance.html#out-of-scope","specs/build-provenance.html#acceptance-criteria","specs/build-provenance.html#testing","specs/build-provenance.html#traces","specs/build-provenance.html#notes-for-the-implementer","build/build-release.html#build--release-workflow","build/build-release.html#overview","build/build-release.html#workflow-triggers","build/build-release.html#automatic-trigger","build/build-release.html#manual-trigger","build/build-release.html#version-tagging","build/build-release.html#format","build/build-release.html#creating-a-release","build/build-release.html#release-status","build/build-release.html#workflow-steps","build/build-release.html#1-test-phase","build/build-release.html#2-build-linux-phase","build/build-release.html#3-build-android-phase","build/build-release.html#4-create-release-phase","build/build-release.html#artifacts","build/build-release.html#linux-artifacts","build/build-release.html#android-artifacts","build/build-release.html#release-notes","build/build-release.html#build-matrix","build/build-release.html#troubleshooting","build/build-release.html#build-fails-during-test-phase","build/build-release.html#linux-build-fails","build/build-release.html#android-build-fails","build/build-release.html#release-not-created","build/build-release.html#github-release-vs-gitea","build/build-release.html#customization","build/build-release.html#change-release-notes-template","build/build-release.html#add-new-platforms","build/build-release.html#change-build-targets","build/build-release.html#monitoring","build/build-release.html#check-status","build/build-release.html#notifications","build/build-release.html#performance","build/build-release.html#build-times-approximate","build/build-release.html#caching","build/build-release.html#security","build/build-release.html#secrets","build/build-release.html#verification","build/build-release.html#best-practices","build/build-release.html#versioning","build/build-release.html#testing-before-release","build/build-release.html#documentation","build/build-release.html#example-release-workflow","build/build-release.html#references","release-checklist.html#release-checklist","release-checklist.html#pre-release-1-2-days-before","release-checklist.html#update-version-day-before","release-checklist.html#final-check-before-release","release-checklist.html#release-tag--push","release-checklist.html#during-release-while-workflow-runs","release-checklist.html#after-release-workflow-complete","release-checklist.html#rollback-if-issues-found","release-checklist.html#version-examples","release-checklist.html#major-release","release-checklist.html#minor-release","release-checklist.html#patch-release","release-checklist.html#pre-releases","release-checklist.html#file-locations","release-checklist.html#troubleshooting","release-checklist.html#tests-fail-before-release","release-checklist.html#build-fails-in-ci","release-checklist.html#release-already-exists","release-checklist.html#artifacts-missing","release-checklist.html#performance-tips","release-checklist.html#template-release-notes","release-checklist.html#quick-commands","build/build-desktop-packages.html#desktop-packaging-linux-arch-windows","build/build-desktop-packages.html#one-builder-image-shared-with-ci","build/build-desktop-packages.html#linux-deb--rpm","build/build-desktop-packages.html#arch-linux-pacman-package","build/build-desktop-packages.html#windows-nsis-installer-cross-compiled-from-linux","build/build-windows.html#windows-build","build/build-windows.html#how-playback-works-on-windows","build/build-windows.html#cross-compiling-from-linux-msvc--cargo-xwin","build/build-windows.html#caveats","build/build-windows.html#outstanding-for-a-first-class-windows-release","defect-windows.html#defect-windows--which-bugs-were-present-when","defect-windows.html#present-since-the-first-release","defect-windows.html#why-they-took-so-long-to-surface","defect-windows.html#introduced-by-a-feature-fixed-later","defect-windows.html#short-windows-one-release-or-less","defect-windows.html#fixed-twice--never-actually-broken","defect-windows.html#recurring-shapes","defect-windows.html#reproducing-this","build/docker.html#docker--cicd-setup-for-jellytau","build/docker.html#overview","build/docker.html#quick-start","build/docker.html#docker-usage","build/docker.html#prerequisites","build/docker.html#building-the-docker-image","build/docker.html#using-docker-compose","build/docker.html#extracting-build-artifacts","build/docker.html#gitea-act-cicd-pipeline","build/docker.html#workflow-triggers","build/docker.html#setting-up-the-builder-image","build/docker.html#setting-up-gitea-act","build/docker.html#customizing-the-workflow","build/docker.html#environment-setup-in-ci","build/docker.html#secret-variables","build/docker.html#troubleshooting","build/docker.html#out-of-memory-during-build","build/docker.html#android-sdk-download-timeout","build/docker.html#rust-compilation-errors","build/docker.html#cache-issues","build/docker.html#performance-tips","build/docker.html#security-considerations","build/docker.html#next-steps","build/docker.html#references","build/build-builder-image.html#building-and-pushing-the-jellytau-builder-image","build/build-builder-image.html#prerequisites","build/build-builder-image.html#building-the-builder-image","build/build-builder-image.html#step-1-build-the-image-locally","build/build-builder-image.html#step-2-tag-for-your-registry","build/build-builder-image.html#step-3-login-to-your-registry","build/build-builder-image.html#step-4-push-to-registry","build/build-builder-image.html#complete-one-liner","build/build-builder-image.html#verifying-the-build","build/build-builder-image.html#using-in-cicd","build/build-builder-image.html#updating-the-builder-image","build/build-builder-image.html#image-contents","build/build-builder-image.html#build-time","build/build-builder-image.html#storage","build/build-builder-image.html#troubleshooting","build/build-builder-image.html#image-not-found-in-ci","build/build-builder-image.html#build-fails-with-command-not-found","build/build-builder-image.html#registry-authentication-in-ci","build/build-builder-image.html#references","api-redirect.html#rust-api-reference"],"index":{"documentStore":{"docInfo":{"0":{"body":52,"breadcrumbs":3,"title":2},"1":{"body":25,"breadcrumbs":4,"title":2},"10":{"body":4303,"breadcrumbs":6,"title":4},"100":{"body":16,"breadcrumbs":4,"title":2},"1000":{"body":47,"breadcrumbs":3,"title":1},"1001":{"body":50,"breadcrumbs":4,"title":2},"1002":{"body":7,"breadcrumbs":5,"title":3},"1003":{"body":23,"breadcrumbs":3,"title":1},"1004":{"body":0,"breadcrumbs":4,"title":2},"1005":{"body":25,"breadcrumbs":4,"title":2},"1006":{"body":21,"breadcrumbs":4,"title":2},"1007":{"body":0,"breadcrumbs":4,"title":2},"1008":{"body":28,"breadcrumbs":3,"title":1},"1009":{"body":30,"breadcrumbs":4,"title":2},"101":{"body":10,"breadcrumbs":4,"title":2},"1010":{"body":27,"breadcrumbs":4,"title":2},"1011":{"body":0,"breadcrumbs":4,"title":2},"1012":{"body":18,"breadcrumbs":5,"title":3},"1013":{"body":18,"breadcrumbs":6,"title":4},"1014":{"body":27,"breadcrumbs":6,"title":4},"1015":{"body":20,"breadcrumbs":6,"title":4},"1016":{"body":0,"breadcrumbs":3,"title":1},"1017":{"body":73,"breadcrumbs":4,"title":2},"1018":{"body":44,"breadcrumbs":4,"title":2},"1019":{"body":17,"breadcrumbs":4,"title":2},"102":{"body":10,"breadcrumbs":4,"title":2},"1020":{"body":15,"breadcrumbs":4,"title":2},"1021":{"body":0,"breadcrumbs":3,"title":1},"1022":{"body":22,"breadcrumbs":7,"title":5},"1023":{"body":16,"breadcrumbs":5,"title":3},"1024":{"body":18,"breadcrumbs":5,"title":3},"1025":{"body":14,"breadcrumbs":4,"title":2},"1026":{"body":29,"breadcrumbs":6,"title":4},"1027":{"body":0,"breadcrumbs":3,"title":1},"1028":{"body":24,"breadcrumbs":6,"title":4},"1029":{"body":28,"breadcrumbs":5,"title":3},"103":{"body":10,"breadcrumbs":4,"title":2},"1030":{"body":21,"breadcrumbs":5,"title":3},"1031":{"body":0,"breadcrumbs":3,"title":1},"1032":{"body":14,"breadcrumbs":4,"title":2},"1033":{"body":9,"breadcrumbs":3,"title":1},"1034":{"body":0,"breadcrumbs":3,"title":1},"1035":{"body":19,"breadcrumbs":5,"title":3},"1036":{"body":10,"breadcrumbs":3,"title":1},"1037":{"body":0,"breadcrumbs":3,"title":1},"1038":{"body":9,"breadcrumbs":3,"title":1},"1039":{"body":14,"breadcrumbs":3,"title":1},"104":{"body":17,"breadcrumbs":4,"title":2},"1040":{"body":0,"breadcrumbs":4,"title":2},"1041":{"body":16,"breadcrumbs":3,"title":1},"1042":{"body":26,"breadcrumbs":5,"title":3},"1043":{"body":14,"breadcrumbs":3,"title":1},"1044":{"body":80,"breadcrumbs":5,"title":3},"1045":{"body":18,"breadcrumbs":3,"title":1},"1046":{"body":5,"breadcrumbs":4,"title":2},"1047":{"body":29,"breadcrumbs":8,"title":6},"1048":{"body":87,"breadcrumbs":6,"title":4},"1049":{"body":46,"breadcrumbs":6,"title":4},"105":{"body":10,"breadcrumbs":4,"title":2},"1050":{"body":64,"breadcrumbs":5,"title":3},"1051":{"body":16,"breadcrumbs":6,"title":4},"1052":{"body":144,"breadcrumbs":5,"title":3},"1053":{"body":56,"breadcrumbs":5,"title":3},"1054":{"body":0,"breadcrumbs":4,"title":2},"1055":{"body":13,"breadcrumbs":4,"title":2},"1056":{"body":9,"breadcrumbs":4,"title":2},"1057":{"body":9,"breadcrumbs":4,"title":2},"1058":{"body":16,"breadcrumbs":4,"title":2},"1059":{"body":21,"breadcrumbs":4,"title":2},"106":{"body":15,"breadcrumbs":4,"title":2},"1060":{"body":0,"breadcrumbs":3,"title":1},"1061":{"body":16,"breadcrumbs":6,"title":4},"1062":{"body":25,"breadcrumbs":5,"title":3},"1063":{"body":13,"breadcrumbs":5,"title":3},"1064":{"body":12,"breadcrumbs":4,"title":2},"1065":{"body":31,"breadcrumbs":4,"title":2},"1066":{"body":55,"breadcrumbs":5,"title":3},"1067":{"body":88,"breadcrumbs":4,"title":2},"1068":{"body":16,"breadcrumbs":7,"title":5},"1069":{"body":143,"breadcrumbs":7,"title":5},"107":{"body":39,"breadcrumbs":4,"title":2},"1070":{"body":53,"breadcrumbs":5,"title":3},"1071":{"body":72,"breadcrumbs":6,"title":4},"1072":{"body":83,"breadcrumbs":8,"title":6},"1073":{"body":30,"breadcrumbs":4,"title":2},"1074":{"body":63,"breadcrumbs":5,"title":3},"1075":{"body":149,"breadcrumbs":8,"title":6},"1076":{"body":37,"breadcrumbs":3,"title":1},"1077":{"body":25,"breadcrumbs":7,"title":5},"1078":{"body":88,"breadcrumbs":6,"title":4},"1079":{"body":205,"breadcrumbs":5,"title":3},"108":{"body":119,"breadcrumbs":4,"title":2},"1080":{"body":141,"breadcrumbs":5,"title":3},"1081":{"body":240,"breadcrumbs":6,"title":4},"1082":{"body":83,"breadcrumbs":7,"title":5},"1083":{"body":76,"breadcrumbs":7,"title":5},"1084":{"body":97,"breadcrumbs":4,"title":2},"1085":{"body":55,"breadcrumbs":3,"title":1},"1086":{"body":12,"breadcrumbs":5,"title":4},"1087":{"body":32,"breadcrumbs":2,"title":1},"1088":{"body":44,"breadcrumbs":3,"title":2},"1089":{"body":0,"breadcrumbs":3,"title":2},"109":{"body":33,"breadcrumbs":4,"title":2},"1090":{"body":17,"breadcrumbs":2,"title":1},"1091":{"body":23,"breadcrumbs":4,"title":3},"1092":{"body":78,"breadcrumbs":4,"title":3},"1093":{"body":18,"breadcrumbs":4,"title":3},"1094":{"body":46,"breadcrumbs":5,"title":4},"1095":{"body":15,"breadcrumbs":3,"title":2},"1096":{"body":37,"breadcrumbs":5,"title":4},"1097":{"body":56,"breadcrumbs":5,"title":4},"1098":{"body":73,"breadcrumbs":3,"title":2},"1099":{"body":0,"breadcrumbs":4,"title":3},"11":{"body":221,"breadcrumbs":4,"title":2},"110":{"body":15,"breadcrumbs":4,"title":2},"1100":{"body":20,"breadcrumbs":3,"title":2},"1101":{"body":0,"breadcrumbs":2,"title":1},"1102":{"body":25,"breadcrumbs":5,"title":4},"1103":{"body":13,"breadcrumbs":5,"title":4},"1104":{"body":18,"breadcrumbs":4,"title":3},"1105":{"body":21,"breadcrumbs":3,"title":2},"1106":{"body":37,"breadcrumbs":3,"title":2},"1107":{"body":33,"breadcrumbs":3,"title":2},"1108":{"body":27,"breadcrumbs":3,"title":2},"1109":{"body":13,"breadcrumbs":2,"title":1},"111":{"body":33,"breadcrumbs":4,"title":2},"1110":{"body":14,"breadcrumbs":7,"title":5},"1111":{"body":14,"breadcrumbs":3,"title":1},"1112":{"body":0,"breadcrumbs":5,"title":3},"1113":{"body":26,"breadcrumbs":7,"title":5},"1114":{"body":11,"breadcrumbs":6,"title":4},"1115":{"body":5,"breadcrumbs":6,"title":4},"1116":{"body":4,"breadcrumbs":6,"title":4},"1117":{"body":17,"breadcrumbs":5,"title":3},"1118":{"body":28,"breadcrumbs":4,"title":2},"1119":{"body":25,"breadcrumbs":4,"title":2},"112":{"body":21,"breadcrumbs":4,"title":2},"1120":{"body":43,"breadcrumbs":5,"title":3},"1121":{"body":61,"breadcrumbs":4,"title":2},"1122":{"body":17,"breadcrumbs":4,"title":2},"1123":{"body":10,"breadcrumbs":3,"title":1},"1124":{"body":0,"breadcrumbs":3,"title":1},"1125":{"body":22,"breadcrumbs":5,"title":3},"1126":{"body":15,"breadcrumbs":6,"title":4},"1127":{"body":26,"breadcrumbs":5,"title":3},"1128":{"body":8,"breadcrumbs":3,"title":1},"1129":{"body":12,"breadcrumbs":7,"title":3},"113":{"body":9,"breadcrumbs":4,"title":2},"114":{"body":9,"breadcrumbs":4,"title":2},"115":{"body":9,"breadcrumbs":4,"title":2},"116":{"body":137,"breadcrumbs":4,"title":2},"117":{"body":9,"breadcrumbs":4,"title":2},"118":{"body":15,"breadcrumbs":4,"title":2},"119":{"body":15,"breadcrumbs":4,"title":2},"12":{"body":0,"breadcrumbs":5,"title":3},"120":{"body":49,"breadcrumbs":4,"title":2},"121":{"body":15,"breadcrumbs":4,"title":2},"122":{"body":17,"breadcrumbs":4,"title":2},"123":{"body":15,"breadcrumbs":4,"title":2},"124":{"body":145,"breadcrumbs":4,"title":2},"125":{"body":131,"breadcrumbs":4,"title":2},"126":{"body":15,"breadcrumbs":4,"title":2},"127":{"body":99,"breadcrumbs":4,"title":2},"128":{"body":9,"breadcrumbs":4,"title":2},"129":{"body":21,"breadcrumbs":4,"title":2},"13":{"body":680,"breadcrumbs":9,"title":7},"130":{"body":15,"breadcrumbs":4,"title":2},"131":{"body":27,"breadcrumbs":4,"title":2},"132":{"body":10,"breadcrumbs":4,"title":2},"133":{"body":17,"breadcrumbs":4,"title":2},"134":{"body":15,"breadcrumbs":4,"title":2},"135":{"body":71,"breadcrumbs":4,"title":2},"136":{"body":9,"breadcrumbs":4,"title":2},"137":{"body":9,"breadcrumbs":4,"title":2},"138":{"body":66,"breadcrumbs":4,"title":2},"139":{"body":27,"breadcrumbs":4,"title":2},"14":{"body":191,"breadcrumbs":6,"title":4},"140":{"body":15,"breadcrumbs":4,"title":2},"141":{"body":15,"breadcrumbs":4,"title":2},"142":{"body":56,"breadcrumbs":4,"title":2},"143":{"body":47,"breadcrumbs":4,"title":2},"144":{"body":39,"breadcrumbs":4,"title":2},"145":{"body":9,"breadcrumbs":4,"title":2},"146":{"body":63,"breadcrumbs":4,"title":2},"147":{"body":49,"breadcrumbs":4,"title":2},"148":{"body":15,"breadcrumbs":4,"title":2},"149":{"body":38,"breadcrumbs":4,"title":2},"15":{"body":324,"breadcrumbs":9,"title":7},"150":{"body":45,"breadcrumbs":4,"title":2},"151":{"body":59,"breadcrumbs":4,"title":2},"152":{"body":38,"breadcrumbs":4,"title":2},"153":{"body":39,"breadcrumbs":4,"title":2},"154":{"body":31,"breadcrumbs":4,"title":2},"155":{"body":17,"breadcrumbs":4,"title":2},"156":{"body":114,"breadcrumbs":4,"title":2},"157":{"body":84,"breadcrumbs":4,"title":2},"158":{"body":51,"breadcrumbs":4,"title":2},"159":{"body":33,"breadcrumbs":4,"title":2},"16":{"body":164,"breadcrumbs":6,"title":4},"160":{"body":93,"breadcrumbs":4,"title":2},"161":{"body":140,"breadcrumbs":4,"title":2},"162":{"body":55,"breadcrumbs":4,"title":2},"163":{"body":17,"breadcrumbs":4,"title":2},"164":{"body":44,"breadcrumbs":4,"title":2},"165":{"body":24,"breadcrumbs":4,"title":2},"166":{"body":65,"breadcrumbs":4,"title":2},"167":{"body":49,"breadcrumbs":4,"title":2},"168":{"body":115,"breadcrumbs":4,"title":2},"169":{"body":86,"breadcrumbs":4,"title":2},"17":{"body":3,"breadcrumbs":5,"title":3},"170":{"body":15,"breadcrumbs":4,"title":2},"171":{"body":16,"breadcrumbs":4,"title":2},"172":{"body":38,"breadcrumbs":4,"title":2},"173":{"body":24,"breadcrumbs":4,"title":2},"174":{"body":131,"breadcrumbs":4,"title":2},"175":{"body":24,"breadcrumbs":4,"title":2},"176":{"body":21,"breadcrumbs":4,"title":2},"177":{"body":69,"breadcrumbs":4,"title":2},"178":{"body":77,"breadcrumbs":4,"title":2},"179":{"body":33,"breadcrumbs":4,"title":2},"18":{"body":27,"breadcrumbs":3,"title":1},"180":{"body":10,"breadcrumbs":4,"title":2},"181":{"body":24,"breadcrumbs":4,"title":2},"182":{"body":27,"breadcrumbs":4,"title":2},"183":{"body":9,"breadcrumbs":4,"title":2},"184":{"body":15,"breadcrumbs":4,"title":2},"185":{"body":10,"breadcrumbs":4,"title":2},"186":{"body":51,"breadcrumbs":4,"title":2},"187":{"body":38,"breadcrumbs":4,"title":2},"188":{"body":15,"breadcrumbs":4,"title":2},"189":{"body":9,"breadcrumbs":4,"title":2},"19":{"body":0,"breadcrumbs":4,"title":2},"190":{"body":42,"breadcrumbs":4,"title":2},"191":{"body":24,"breadcrumbs":4,"title":2},"192":{"body":9,"breadcrumbs":4,"title":2},"193":{"body":268,"breadcrumbs":4,"title":2},"194":{"body":17,"breadcrumbs":4,"title":2},"195":{"body":43,"breadcrumbs":4,"title":2},"196":{"body":22,"breadcrumbs":4,"title":2},"197":{"body":17,"breadcrumbs":4,"title":2},"198":{"body":66,"breadcrumbs":4,"title":2},"199":{"body":120,"breadcrumbs":4,"title":2},"2":{"body":1865,"breadcrumbs":5,"title":3},"20":{"body":152,"breadcrumbs":5,"title":3},"200":{"body":51,"breadcrumbs":4,"title":2},"201":{"body":70,"breadcrumbs":4,"title":2},"202":{"body":165,"breadcrumbs":4,"title":2},"203":{"body":63,"breadcrumbs":4,"title":2},"204":{"body":97,"breadcrumbs":4,"title":2},"205":{"body":114,"breadcrumbs":4,"title":2},"206":{"body":45,"breadcrumbs":4,"title":2},"207":{"body":74,"breadcrumbs":4,"title":2},"208":{"body":27,"breadcrumbs":4,"title":2},"209":{"body":9,"breadcrumbs":4,"title":2},"21":{"body":50,"breadcrumbs":5,"title":3},"210":{"body":9,"breadcrumbs":4,"title":2},"211":{"body":9,"breadcrumbs":4,"title":2},"212":{"body":15,"breadcrumbs":4,"title":2},"213":{"body":34,"breadcrumbs":4,"title":2},"214":{"body":17,"breadcrumbs":4,"title":2},"215":{"body":9,"breadcrumbs":4,"title":2},"216":{"body":21,"breadcrumbs":4,"title":2},"217":{"body":54,"breadcrumbs":4,"title":2},"218":{"body":24,"breadcrumbs":4,"title":2},"219":{"body":59,"breadcrumbs":4,"title":2},"22":{"body":378,"breadcrumbs":5,"title":3},"220":{"body":17,"breadcrumbs":4,"title":2},"221":{"body":31,"breadcrumbs":4,"title":2},"222":{"body":9,"breadcrumbs":4,"title":2},"223":{"body":11,"breadcrumbs":4,"title":2},"224":{"body":17,"breadcrumbs":4,"title":2},"225":{"body":313,"breadcrumbs":4,"title":2},"226":{"body":59,"breadcrumbs":4,"title":2},"227":{"body":73,"breadcrumbs":4,"title":2},"228":{"body":66,"breadcrumbs":4,"title":2},"229":{"body":10,"breadcrumbs":4,"title":2},"23":{"body":70,"breadcrumbs":6,"title":4},"230":{"body":9,"breadcrumbs":4,"title":2},"231":{"body":10,"breadcrumbs":4,"title":2},"232":{"body":34,"breadcrumbs":4,"title":2},"233":{"body":114,"breadcrumbs":4,"title":2},"234":{"body":17,"breadcrumbs":4,"title":2},"235":{"body":11,"breadcrumbs":4,"title":2},"236":{"body":19,"breadcrumbs":4,"title":2},"237":{"body":38,"breadcrumbs":4,"title":2},"238":{"body":37,"breadcrumbs":4,"title":2},"239":{"body":10,"breadcrumbs":4,"title":2},"24":{"body":0,"breadcrumbs":4,"title":2},"240":{"body":17,"breadcrumbs":4,"title":2},"241":{"body":10,"breadcrumbs":4,"title":2},"242":{"body":24,"breadcrumbs":4,"title":2},"243":{"body":17,"breadcrumbs":4,"title":2},"244":{"body":17,"breadcrumbs":4,"title":2},"245":{"body":10,"breadcrumbs":4,"title":2},"246":{"body":54,"breadcrumbs":4,"title":2},"247":{"body":16,"breadcrumbs":4,"title":2},"248":{"body":17,"breadcrumbs":4,"title":2},"249":{"body":17,"breadcrumbs":4,"title":2},"25":{"body":22,"breadcrumbs":4,"title":2},"250":{"body":17,"breadcrumbs":4,"title":2},"251":{"body":37,"breadcrumbs":4,"title":2},"252":{"body":30,"breadcrumbs":4,"title":2},"253":{"body":30,"breadcrumbs":4,"title":2},"254":{"body":17,"breadcrumbs":4,"title":2},"255":{"body":10,"breadcrumbs":4,"title":2},"256":{"body":10,"breadcrumbs":4,"title":2},"257":{"body":10,"breadcrumbs":4,"title":2},"258":{"body":10,"breadcrumbs":4,"title":2},"259":{"body":42,"breadcrumbs":4,"title":2},"26":{"body":15,"breadcrumbs":4,"title":2},"260":{"body":23,"breadcrumbs":4,"title":2},"261":{"body":23,"breadcrumbs":4,"title":2},"262":{"body":16,"breadcrumbs":4,"title":2},"263":{"body":29,"breadcrumbs":4,"title":2},"264":{"body":23,"breadcrumbs":4,"title":2},"265":{"body":10,"breadcrumbs":4,"title":2},"266":{"body":22,"breadcrumbs":4,"title":2},"267":{"body":23,"breadcrumbs":4,"title":2},"268":{"body":23,"breadcrumbs":4,"title":2},"269":{"body":72,"breadcrumbs":4,"title":2},"27":{"body":31,"breadcrumbs":4,"title":2},"270":{"body":51,"breadcrumbs":4,"title":2},"271":{"body":24,"breadcrumbs":4,"title":2},"272":{"body":17,"breadcrumbs":4,"title":2},"273":{"body":17,"breadcrumbs":4,"title":2},"274":{"body":304,"breadcrumbs":4,"title":2},"275":{"body":446,"breadcrumbs":4,"title":2},"276":{"body":695,"breadcrumbs":4,"title":2},"277":{"body":29,"breadcrumbs":4,"title":2},"278":{"body":92,"breadcrumbs":4,"title":2},"279":{"body":139,"breadcrumbs":4,"title":2},"28":{"body":10,"breadcrumbs":4,"title":2},"280":{"body":125,"breadcrumbs":4,"title":2},"281":{"body":338,"breadcrumbs":4,"title":2},"282":{"body":87,"breadcrumbs":4,"title":2},"283":{"body":214,"breadcrumbs":4,"title":2},"284":{"body":475,"breadcrumbs":4,"title":2},"285":{"body":140,"breadcrumbs":4,"title":2},"286":{"body":165,"breadcrumbs":4,"title":2},"287":{"body":122,"breadcrumbs":4,"title":2},"288":{"body":522,"breadcrumbs":4,"title":2},"289":{"body":41,"breadcrumbs":4,"title":2},"29":{"body":41,"breadcrumbs":4,"title":2},"290":{"body":137,"breadcrumbs":4,"title":2},"291":{"body":375,"breadcrumbs":4,"title":2},"292":{"body":114,"breadcrumbs":4,"title":2},"293":{"body":112,"breadcrumbs":4,"title":2},"294":{"body":49,"breadcrumbs":4,"title":2},"295":{"body":9,"breadcrumbs":4,"title":2},"296":{"body":9,"breadcrumbs":4,"title":2},"297":{"body":9,"breadcrumbs":4,"title":2},"298":{"body":27,"breadcrumbs":4,"title":2},"299":{"body":62,"breadcrumbs":4,"title":2},"3":{"body":0,"breadcrumbs":5,"title":3},"30":{"body":24,"breadcrumbs":4,"title":2},"300":{"body":9,"breadcrumbs":4,"title":2},"301":{"body":9,"breadcrumbs":4,"title":2},"302":{"body":9,"breadcrumbs":4,"title":2},"303":{"body":16,"breadcrumbs":4,"title":2},"304":{"body":16,"breadcrumbs":4,"title":2},"305":{"body":16,"breadcrumbs":4,"title":2},"306":{"body":9,"breadcrumbs":4,"title":2},"307":{"body":9,"breadcrumbs":4,"title":2},"308":{"body":9,"breadcrumbs":4,"title":2},"309":{"body":15,"breadcrumbs":4,"title":2},"31":{"body":10,"breadcrumbs":4,"title":2},"310":{"body":21,"breadcrumbs":4,"title":2},"311":{"body":15,"breadcrumbs":4,"title":2},"312":{"body":9,"breadcrumbs":4,"title":2},"313":{"body":9,"breadcrumbs":4,"title":2},"314":{"body":9,"breadcrumbs":4,"title":2},"315":{"body":9,"breadcrumbs":4,"title":2},"316":{"body":9,"breadcrumbs":4,"title":2},"317":{"body":21,"breadcrumbs":4,"title":2},"318":{"body":21,"breadcrumbs":4,"title":2},"319":{"body":58,"breadcrumbs":4,"title":2},"32":{"body":16,"breadcrumbs":4,"title":2},"320":{"body":9,"breadcrumbs":4,"title":2},"321":{"body":17,"breadcrumbs":4,"title":2},"322":{"body":15,"breadcrumbs":4,"title":2},"323":{"body":9,"breadcrumbs":4,"title":2},"324":{"body":9,"breadcrumbs":4,"title":2},"325":{"body":9,"breadcrumbs":4,"title":2},"326":{"body":9,"breadcrumbs":4,"title":2},"327":{"body":9,"breadcrumbs":4,"title":2},"328":{"body":9,"breadcrumbs":4,"title":2},"329":{"body":9,"breadcrumbs":4,"title":2},"33":{"body":24,"breadcrumbs":4,"title":2},"330":{"body":9,"breadcrumbs":4,"title":2},"331":{"body":9,"breadcrumbs":4,"title":2},"332":{"body":9,"breadcrumbs":4,"title":2},"333":{"body":15,"breadcrumbs":4,"title":2},"334":{"body":9,"breadcrumbs":4,"title":2},"335":{"body":9,"breadcrumbs":4,"title":2},"336":{"body":9,"breadcrumbs":4,"title":2},"337":{"body":45,"breadcrumbs":4,"title":2},"338":{"body":37,"breadcrumbs":4,"title":2},"339":{"body":286,"breadcrumbs":4,"title":2},"34":{"body":69,"breadcrumbs":4,"title":2},"340":{"body":29,"breadcrumbs":3,"title":1},"341":{"body":9,"breadcrumbs":4,"title":2},"342":{"body":9,"breadcrumbs":4,"title":2},"343":{"body":30,"breadcrumbs":4,"title":2},"344":{"body":59,"breadcrumbs":4,"title":2},"345":{"body":15,"breadcrumbs":4,"title":2},"346":{"body":107,"breadcrumbs":4,"title":2},"347":{"body":17,"breadcrumbs":4,"title":2},"348":{"body":10,"breadcrumbs":4,"title":2},"349":{"body":23,"breadcrumbs":4,"title":2},"35":{"body":10,"breadcrumbs":4,"title":2},"350":{"body":16,"breadcrumbs":4,"title":2},"351":{"body":149,"breadcrumbs":4,"title":2},"352":{"body":60,"breadcrumbs":4,"title":2},"353":{"body":448,"breadcrumbs":4,"title":2},"354":{"body":42,"breadcrumbs":4,"title":2},"355":{"body":328,"breadcrumbs":4,"title":2},"356":{"body":10,"breadcrumbs":3,"title":1},"357":{"body":10,"breadcrumbs":3,"title":1},"358":{"body":10,"breadcrumbs":3,"title":1},"359":{"body":12,"breadcrumbs":4,"title":2},"36":{"body":118,"breadcrumbs":4,"title":2},"360":{"body":12,"breadcrumbs":4,"title":2},"361":{"body":12,"breadcrumbs":4,"title":2},"362":{"body":13,"breadcrumbs":4,"title":2},"363":{"body":17,"breadcrumbs":4,"title":2},"364":{"body":10,"breadcrumbs":4,"title":2},"365":{"body":10,"breadcrumbs":4,"title":2},"366":{"body":10,"breadcrumbs":4,"title":2},"367":{"body":10,"breadcrumbs":4,"title":2},"368":{"body":10,"breadcrumbs":4,"title":2},"369":{"body":10,"breadcrumbs":4,"title":2},"37":{"body":23,"breadcrumbs":4,"title":2},"370":{"body":10,"breadcrumbs":4,"title":2},"371":{"body":10,"breadcrumbs":4,"title":2},"372":{"body":24,"breadcrumbs":4,"title":2},"373":{"body":10,"breadcrumbs":4,"title":2},"374":{"body":10,"breadcrumbs":4,"title":2},"375":{"body":10,"breadcrumbs":4,"title":2},"376":{"body":10,"breadcrumbs":4,"title":2},"377":{"body":10,"breadcrumbs":4,"title":2},"378":{"body":10,"breadcrumbs":4,"title":2},"379":{"body":10,"breadcrumbs":4,"title":2},"38":{"body":36,"breadcrumbs":4,"title":2},"380":{"body":10,"breadcrumbs":4,"title":2},"381":{"body":10,"breadcrumbs":4,"title":2},"382":{"body":10,"breadcrumbs":4,"title":2},"383":{"body":10,"breadcrumbs":4,"title":2},"384":{"body":10,"breadcrumbs":4,"title":2},"385":{"body":24,"breadcrumbs":4,"title":2},"386":{"body":45,"breadcrumbs":4,"title":2},"387":{"body":31,"breadcrumbs":4,"title":2},"388":{"body":17,"breadcrumbs":4,"title":2},"389":{"body":58,"breadcrumbs":4,"title":2},"39":{"body":59,"breadcrumbs":4,"title":2},"390":{"body":44,"breadcrumbs":4,"title":2},"391":{"body":17,"breadcrumbs":4,"title":2},"392":{"body":10,"breadcrumbs":4,"title":2},"393":{"body":10,"breadcrumbs":4,"title":2},"394":{"body":24,"breadcrumbs":4,"title":2},"395":{"body":17,"breadcrumbs":4,"title":2},"396":{"body":17,"breadcrumbs":4,"title":2},"397":{"body":17,"breadcrumbs":4,"title":2},"398":{"body":30,"breadcrumbs":4,"title":2},"399":{"body":17,"breadcrumbs":4,"title":2},"4":{"body":788,"breadcrumbs":5,"title":3},"40":{"body":10,"breadcrumbs":4,"title":2},"400":{"body":24,"breadcrumbs":4,"title":2},"401":{"body":9,"breadcrumbs":4,"title":2},"402":{"body":51,"breadcrumbs":4,"title":2},"403":{"body":58,"breadcrumbs":4,"title":2},"404":{"body":158,"breadcrumbs":4,"title":2},"405":{"body":125,"breadcrumbs":4,"title":2},"406":{"body":77,"breadcrumbs":4,"title":2},"407":{"body":33,"breadcrumbs":4,"title":2},"408":{"body":15,"breadcrumbs":4,"title":2},"409":{"body":17,"breadcrumbs":4,"title":2},"41":{"body":31,"breadcrumbs":4,"title":2},"410":{"body":52,"breadcrumbs":4,"title":2},"411":{"body":49,"breadcrumbs":4,"title":2},"412":{"body":37,"breadcrumbs":4,"title":2},"413":{"body":27,"breadcrumbs":4,"title":2},"414":{"body":9,"breadcrumbs":4,"title":2},"415":{"body":17,"breadcrumbs":4,"title":2},"416":{"body":10,"breadcrumbs":4,"title":2},"417":{"body":10,"breadcrumbs":4,"title":2},"418":{"body":39,"breadcrumbs":4,"title":2},"419":{"body":15,"breadcrumbs":4,"title":2},"42":{"body":10,"breadcrumbs":4,"title":2},"420":{"body":157,"breadcrumbs":4,"title":2},"421":{"body":9,"breadcrumbs":4,"title":2},"422":{"body":69,"breadcrumbs":4,"title":2},"423":{"body":63,"breadcrumbs":4,"title":2},"424":{"body":178,"breadcrumbs":4,"title":2},"425":{"body":87,"breadcrumbs":4,"title":2},"426":{"body":176,"breadcrumbs":4,"title":2},"427":{"body":85,"breadcrumbs":4,"title":2},"428":{"body":313,"breadcrumbs":4,"title":2},"429":{"body":108,"breadcrumbs":4,"title":2},"43":{"body":64,"breadcrumbs":4,"title":2},"430":{"body":37,"breadcrumbs":4,"title":2},"431":{"body":23,"breadcrumbs":4,"title":2},"432":{"body":9,"breadcrumbs":4,"title":2},"433":{"body":165,"breadcrumbs":4,"title":2},"434":{"body":15,"breadcrumbs":4,"title":2},"435":{"body":45,"breadcrumbs":4,"title":2},"436":{"body":69,"breadcrumbs":4,"title":2},"437":{"body":15,"breadcrumbs":4,"title":2},"438":{"body":27,"breadcrumbs":4,"title":2},"439":{"body":21,"breadcrumbs":4,"title":2},"44":{"body":15,"breadcrumbs":4,"title":2},"440":{"body":21,"breadcrumbs":4,"title":2},"441":{"body":51,"breadcrumbs":4,"title":2},"442":{"body":9,"breadcrumbs":4,"title":2},"443":{"body":15,"breadcrumbs":4,"title":2},"444":{"body":15,"breadcrumbs":4,"title":2},"445":{"body":9,"breadcrumbs":4,"title":2},"446":{"body":34,"breadcrumbs":4,"title":2},"447":{"body":9,"breadcrumbs":4,"title":2},"448":{"body":73,"breadcrumbs":4,"title":2},"449":{"body":9,"breadcrumbs":4,"title":2},"45":{"body":9,"breadcrumbs":4,"title":2},"450":{"body":15,"breadcrumbs":4,"title":2},"451":{"body":105,"breadcrumbs":4,"title":2},"452":{"body":15,"breadcrumbs":4,"title":2},"453":{"body":149,"breadcrumbs":4,"title":2},"454":{"body":73,"breadcrumbs":4,"title":2},"455":{"body":9,"breadcrumbs":4,"title":2},"456":{"body":52,"breadcrumbs":4,"title":2},"457":{"body":9,"breadcrumbs":4,"title":2},"458":{"body":28,"breadcrumbs":4,"title":2},"459":{"body":15,"breadcrumbs":4,"title":2},"46":{"body":10,"breadcrumbs":4,"title":2},"460":{"body":66,"breadcrumbs":4,"title":2},"461":{"body":9,"breadcrumbs":4,"title":2},"462":{"body":87,"breadcrumbs":4,"title":2},"463":{"body":10,"breadcrumbs":4,"title":2},"464":{"body":10,"breadcrumbs":3,"title":1},"465":{"body":20,"breadcrumbs":4,"title":2},"466":{"body":20,"breadcrumbs":4,"title":2},"467":{"body":20,"breadcrumbs":4,"title":2},"468":{"body":20,"breadcrumbs":4,"title":2},"469":{"body":20,"breadcrumbs":4,"title":2},"47":{"body":10,"breadcrumbs":4,"title":2},"470":{"body":20,"breadcrumbs":4,"title":2},"471":{"body":20,"breadcrumbs":4,"title":2},"472":{"body":20,"breadcrumbs":4,"title":2},"473":{"body":20,"breadcrumbs":4,"title":2},"474":{"body":20,"breadcrumbs":4,"title":2},"475":{"body":20,"breadcrumbs":4,"title":2},"476":{"body":10,"breadcrumbs":4,"title":2},"477":{"body":10,"breadcrumbs":4,"title":2},"478":{"body":10,"breadcrumbs":4,"title":2},"479":{"body":10,"breadcrumbs":4,"title":2},"48":{"body":17,"breadcrumbs":4,"title":2},"480":{"body":10,"breadcrumbs":4,"title":2},"481":{"body":45,"breadcrumbs":4,"title":2},"482":{"body":17,"breadcrumbs":4,"title":2},"483":{"body":17,"breadcrumbs":4,"title":2},"484":{"body":80,"breadcrumbs":4,"title":2},"485":{"body":24,"breadcrumbs":4,"title":2},"486":{"body":17,"breadcrumbs":4,"title":2},"487":{"body":80,"breadcrumbs":4,"title":2},"488":{"body":21,"breadcrumbs":4,"title":2},"489":{"body":10,"breadcrumbs":4,"title":2},"49":{"body":15,"breadcrumbs":4,"title":2},"490":{"body":24,"breadcrumbs":4,"title":2},"491":{"body":10,"breadcrumbs":4,"title":2},"492":{"body":31,"breadcrumbs":4,"title":2},"493":{"body":17,"breadcrumbs":4,"title":2},"494":{"body":24,"breadcrumbs":4,"title":2},"495":{"body":31,"breadcrumbs":4,"title":2},"496":{"body":24,"breadcrumbs":4,"title":2},"497":{"body":31,"breadcrumbs":4,"title":2},"498":{"body":10,"breadcrumbs":4,"title":2},"499":{"body":10,"breadcrumbs":4,"title":2},"5":{"body":378,"breadcrumbs":6,"title":4},"50":{"body":110,"breadcrumbs":4,"title":2},"500":{"body":10,"breadcrumbs":4,"title":2},"501":{"body":52,"breadcrumbs":4,"title":2},"502":{"body":17,"breadcrumbs":4,"title":2},"503":{"body":24,"breadcrumbs":4,"title":2},"504":{"body":38,"breadcrumbs":4,"title":2},"505":{"body":24,"breadcrumbs":4,"title":2},"506":{"body":48,"breadcrumbs":4,"title":2},"507":{"body":24,"breadcrumbs":4,"title":2},"508":{"body":31,"breadcrumbs":4,"title":2},"509":{"body":24,"breadcrumbs":4,"title":2},"51":{"body":20,"breadcrumbs":4,"title":2},"510":{"body":17,"breadcrumbs":4,"title":2},"511":{"body":17,"breadcrumbs":4,"title":2},"512":{"body":11,"breadcrumbs":4,"title":2},"513":{"body":10,"breadcrumbs":4,"title":2},"514":{"body":10,"breadcrumbs":4,"title":2},"515":{"body":15,"breadcrumbs":4,"title":2},"516":{"body":12,"breadcrumbs":4,"title":2},"517":{"body":10,"breadcrumbs":4,"title":2},"518":{"body":17,"breadcrumbs":4,"title":2},"519":{"body":19,"breadcrumbs":4,"title":2},"52":{"body":18,"breadcrumbs":4,"title":2},"520":{"body":73,"breadcrumbs":4,"title":2},"521":{"body":73,"breadcrumbs":4,"title":2},"522":{"body":10,"breadcrumbs":4,"title":2},"523":{"body":10,"breadcrumbs":4,"title":2},"524":{"body":59,"breadcrumbs":4,"title":2},"525":{"body":10,"breadcrumbs":4,"title":2},"526":{"body":8,"breadcrumbs":6,"title":4},"527":{"body":27,"breadcrumbs":3,"title":1},"528":{"body":65,"breadcrumbs":5,"title":3},"529":{"body":0,"breadcrumbs":4,"title":2},"53":{"body":111,"breadcrumbs":4,"title":2},"530":{"body":20,"breadcrumbs":5,"title":3},"531":{"body":185,"breadcrumbs":5,"title":3},"532":{"body":69,"breadcrumbs":6,"title":4},"533":{"body":18,"breadcrumbs":6,"title":4},"534":{"body":9,"breadcrumbs":6,"title":4},"535":{"body":11,"breadcrumbs":4,"title":2},"536":{"body":10,"breadcrumbs":4,"title":2},"537":{"body":13,"breadcrumbs":4,"title":2},"538":{"body":61,"breadcrumbs":4,"title":2},"539":{"body":0,"breadcrumbs":4,"title":2},"54":{"body":104,"breadcrumbs":4,"title":2},"540":{"body":13,"breadcrumbs":5,"title":3},"541":{"body":20,"breadcrumbs":4,"title":2},"542":{"body":34,"breadcrumbs":4,"title":2},"543":{"body":0,"breadcrumbs":4,"title":2},"544":{"body":21,"breadcrumbs":5,"title":3},"545":{"body":16,"breadcrumbs":3,"title":1},"546":{"body":0,"breadcrumbs":4,"title":2},"547":{"body":37,"breadcrumbs":4,"title":2},"548":{"body":36,"breadcrumbs":3,"title":1},"549":{"body":0,"breadcrumbs":4,"title":2},"55":{"body":15,"breadcrumbs":4,"title":2},"550":{"body":14,"breadcrumbs":6,"title":4},"551":{"body":17,"breadcrumbs":6,"title":4},"552":{"body":15,"breadcrumbs":6,"title":4},"553":{"body":14,"breadcrumbs":6,"title":4},"554":{"body":27,"breadcrumbs":5,"title":3},"555":{"body":0,"breadcrumbs":3,"title":1},"556":{"body":23,"breadcrumbs":6,"title":4},"557":{"body":26,"breadcrumbs":6,"title":4},"558":{"body":21,"breadcrumbs":5,"title":3},"559":{"body":0,"breadcrumbs":4,"title":2},"56":{"body":57,"breadcrumbs":4,"title":2},"560":{"body":19,"breadcrumbs":4,"title":2},"561":{"body":15,"breadcrumbs":3,"title":1},"562":{"body":22,"breadcrumbs":4,"title":2},"563":{"body":10,"breadcrumbs":3,"title":1},"564":{"body":23,"breadcrumbs":3,"title":1},"565":{"body":0,"breadcrumbs":7,"title":4},"566":{"body":17,"breadcrumbs":4,"title":1},"567":{"body":0,"breadcrumbs":5,"title":2},"568":{"body":24,"breadcrumbs":4,"title":1},"569":{"body":11,"breadcrumbs":4,"title":1},"57":{"body":77,"breadcrumbs":4,"title":2},"570":{"body":18,"breadcrumbs":4,"title":1},"571":{"body":49,"breadcrumbs":5,"title":2},"572":{"body":16,"breadcrumbs":5,"title":2},"573":{"body":0,"breadcrumbs":5,"title":2},"574":{"body":16,"breadcrumbs":7,"title":4},"575":{"body":14,"breadcrumbs":7,"title":4},"576":{"body":12,"breadcrumbs":7,"title":4},"577":{"body":0,"breadcrumbs":5,"title":2},"578":{"body":5,"breadcrumbs":5,"title":2},"579":{"body":9,"breadcrumbs":7,"title":4},"58":{"body":9,"breadcrumbs":4,"title":2},"580":{"body":9,"breadcrumbs":5,"title":2},"581":{"body":8,"breadcrumbs":5,"title":2},"582":{"body":14,"breadcrumbs":4,"title":1},"583":{"body":0,"breadcrumbs":4,"title":1},"584":{"body":23,"breadcrumbs":5,"title":2},"585":{"body":19,"breadcrumbs":5,"title":2},"586":{"body":26,"breadcrumbs":5,"title":2},"587":{"body":0,"breadcrumbs":5,"title":2},"588":{"body":20,"breadcrumbs":6,"title":3},"589":{"body":13,"breadcrumbs":5,"title":2},"59":{"body":60,"breadcrumbs":4,"title":2},"590":{"body":13,"breadcrumbs":6,"title":3},"591":{"body":56,"breadcrumbs":4,"title":1},"592":{"body":29,"breadcrumbs":4,"title":1},"593":{"body":18,"breadcrumbs":4,"title":3},"594":{"body":18,"breadcrumbs":3,"title":2},"595":{"body":340,"breadcrumbs":3,"title":2},"596":{"body":243,"breadcrumbs":3,"title":2},"597":{"body":362,"breadcrumbs":4,"title":3},"598":{"body":138,"breadcrumbs":4,"title":3},"599":{"body":3,"breadcrumbs":6,"title":3},"6":{"body":16599,"breadcrumbs":5,"title":3},"60":{"body":55,"breadcrumbs":4,"title":2},"600":{"body":502,"breadcrumbs":7,"title":4},"601":{"body":122,"breadcrumbs":9,"title":6},"602":{"body":224,"breadcrumbs":7,"title":4},"603":{"body":71,"breadcrumbs":6,"title":3},"604":{"body":74,"breadcrumbs":5,"title":2},"605":{"body":50,"breadcrumbs":5,"title":2},"606":{"body":80,"breadcrumbs":4,"title":1},"607":{"body":156,"breadcrumbs":4,"title":1},"608":{"body":82,"breadcrumbs":6,"title":3},"609":{"body":77,"breadcrumbs":5,"title":2},"61":{"body":44,"breadcrumbs":4,"title":2},"610":{"body":231,"breadcrumbs":5,"title":2},"611":{"body":62,"breadcrumbs":6,"title":3},"612":{"body":14,"breadcrumbs":7,"title":4},"613":{"body":119,"breadcrumbs":7,"title":4},"614":{"body":92,"breadcrumbs":5,"title":2},"615":{"body":100,"breadcrumbs":6,"title":3},"616":{"body":50,"breadcrumbs":5,"title":2},"617":{"body":74,"breadcrumbs":5,"title":2},"618":{"body":0,"breadcrumbs":6,"title":3},"619":{"body":49,"breadcrumbs":5,"title":2},"62":{"body":28,"breadcrumbs":4,"title":2},"620":{"body":222,"breadcrumbs":6,"title":3},"621":{"body":198,"breadcrumbs":6,"title":3},"622":{"body":225,"breadcrumbs":7,"title":4},"623":{"body":157,"breadcrumbs":6,"title":3},"624":{"body":175,"breadcrumbs":6,"title":3},"625":{"body":106,"breadcrumbs":5,"title":2},"626":{"body":97,"breadcrumbs":5,"title":2},"627":{"body":284,"breadcrumbs":6,"title":3},"628":{"body":214,"breadcrumbs":7,"title":4},"629":{"body":75,"breadcrumbs":7,"title":4},"63":{"body":34,"breadcrumbs":4,"title":2},"630":{"body":161,"breadcrumbs":6,"title":3},"631":{"body":48,"breadcrumbs":6,"title":3},"632":{"body":64,"breadcrumbs":5,"title":2},"633":{"body":36,"breadcrumbs":7,"title":4},"634":{"body":76,"breadcrumbs":4,"title":1},"635":{"body":97,"breadcrumbs":5,"title":2},"636":{"body":114,"breadcrumbs":6,"title":3},"637":{"body":73,"breadcrumbs":5,"title":2},"638":{"body":91,"breadcrumbs":6,"title":3},"639":{"body":89,"breadcrumbs":6,"title":3},"64":{"body":22,"breadcrumbs":4,"title":2},"640":{"body":69,"breadcrumbs":4,"title":1},"641":{"body":0,"breadcrumbs":5,"title":2},"642":{"body":170,"breadcrumbs":8,"title":5},"643":{"body":238,"breadcrumbs":7,"title":4},"644":{"body":81,"breadcrumbs":6,"title":3},"645":{"body":81,"breadcrumbs":7,"title":4},"646":{"body":28,"breadcrumbs":6,"title":3},"647":{"body":121,"breadcrumbs":6,"title":3},"648":{"body":0,"breadcrumbs":8,"title":4},"649":{"body":70,"breadcrumbs":7,"title":3},"65":{"body":22,"breadcrumbs":4,"title":2},"650":{"body":28,"breadcrumbs":6,"title":2},"651":{"body":142,"breadcrumbs":10,"title":6},"652":{"body":39,"breadcrumbs":8,"title":4},"653":{"body":29,"breadcrumbs":6,"title":2},"654":{"body":0,"breadcrumbs":7,"title":4},"655":{"body":210,"breadcrumbs":6,"title":3},"656":{"body":164,"breadcrumbs":9,"title":6},"657":{"body":140,"breadcrumbs":5,"title":2},"658":{"body":127,"breadcrumbs":5,"title":2},"659":{"body":160,"breadcrumbs":6,"title":3},"66":{"body":10,"breadcrumbs":4,"title":2},"660":{"body":98,"breadcrumbs":6,"title":3},"661":{"body":306,"breadcrumbs":7,"title":4},"662":{"body":257,"breadcrumbs":7,"title":4},"663":{"body":316,"breadcrumbs":8,"title":5},"664":{"body":255,"breadcrumbs":7,"title":4},"665":{"body":36,"breadcrumbs":5,"title":2},"666":{"body":0,"breadcrumbs":7,"title":4},"667":{"body":68,"breadcrumbs":4,"title":1},"668":{"body":145,"breadcrumbs":5,"title":2},"669":{"body":115,"breadcrumbs":6,"title":3},"67":{"body":67,"breadcrumbs":4,"title":2},"670":{"body":122,"breadcrumbs":9,"title":6},"671":{"body":84,"breadcrumbs":6,"title":3},"672":{"body":85,"breadcrumbs":5,"title":2},"673":{"body":21,"breadcrumbs":5,"title":2},"674":{"body":35,"breadcrumbs":5,"title":2},"675":{"body":78,"breadcrumbs":6,"title":3},"676":{"body":56,"breadcrumbs":6,"title":3},"677":{"body":68,"breadcrumbs":5,"title":2},"678":{"body":0,"breadcrumbs":5,"title":3},"679":{"body":100,"breadcrumbs":6,"title":4},"68":{"body":28,"breadcrumbs":4,"title":2},"680":{"body":28,"breadcrumbs":4,"title":2},"681":{"body":84,"breadcrumbs":6,"title":4},"682":{"body":55,"breadcrumbs":9,"title":7},"683":{"body":167,"breadcrumbs":5,"title":3},"684":{"body":109,"breadcrumbs":5,"title":3},"685":{"body":0,"breadcrumbs":6,"title":3},"686":{"body":256,"breadcrumbs":6,"title":3},"687":{"body":0,"breadcrumbs":5,"title":2},"688":{"body":35,"breadcrumbs":4,"title":1},"689":{"body":49,"breadcrumbs":4,"title":1},"69":{"body":10,"breadcrumbs":4,"title":2},"690":{"body":48,"breadcrumbs":4,"title":1},"691":{"body":247,"breadcrumbs":4,"title":1},"692":{"body":53,"breadcrumbs":4,"title":1},"693":{"body":99,"breadcrumbs":4,"title":1},"694":{"body":88,"breadcrumbs":4,"title":1},"695":{"body":66,"breadcrumbs":4,"title":1},"696":{"body":52,"breadcrumbs":4,"title":1},"697":{"body":76,"breadcrumbs":6,"title":3},"698":{"body":0,"breadcrumbs":5,"title":2},"699":{"body":20,"breadcrumbs":7,"title":4},"7":{"body":0,"breadcrumbs":5,"title":3},"70":{"body":98,"breadcrumbs":4,"title":2},"700":{"body":19,"breadcrumbs":6,"title":3},"701":{"body":11,"breadcrumbs":5,"title":2},"702":{"body":38,"breadcrumbs":6,"title":3},"703":{"body":27,"breadcrumbs":6,"title":3},"704":{"body":0,"breadcrumbs":5,"title":2},"705":{"body":31,"breadcrumbs":5,"title":2},"706":{"body":23,"breadcrumbs":5,"title":2},"707":{"body":23,"breadcrumbs":5,"title":2},"708":{"body":56,"breadcrumbs":5,"title":2},"709":{"body":46,"breadcrumbs":6,"title":3},"71":{"body":93,"breadcrumbs":4,"title":2},"710":{"body":0,"breadcrumbs":3,"title":1},"711":{"body":70,"breadcrumbs":5,"title":3},"712":{"body":44,"breadcrumbs":5,"title":3},"713":{"body":77,"breadcrumbs":4,"title":2},"714":{"body":404,"breadcrumbs":6,"title":4},"715":{"body":60,"breadcrumbs":5,"title":3},"716":{"body":22,"breadcrumbs":5,"title":3},"717":{"body":19,"breadcrumbs":6,"title":4},"718":{"body":87,"breadcrumbs":5,"title":3},"719":{"body":51,"breadcrumbs":5,"title":3},"72":{"body":10,"breadcrumbs":4,"title":2},"720":{"body":57,"breadcrumbs":4,"title":2},"721":{"body":11,"breadcrumbs":7,"title":5},"722":{"body":0,"breadcrumbs":6,"title":4},"723":{"body":111,"breadcrumbs":5,"title":3},"724":{"body":110,"breadcrumbs":5,"title":3},"725":{"body":56,"breadcrumbs":5,"title":3},"726":{"body":41,"breadcrumbs":5,"title":3},"727":{"body":0,"breadcrumbs":7,"title":5},"728":{"body":86,"breadcrumbs":6,"title":4},"729":{"body":61,"breadcrumbs":5,"title":3},"73":{"body":41,"breadcrumbs":4,"title":2},"730":{"body":0,"breadcrumbs":6,"title":4},"731":{"body":190,"breadcrumbs":6,"title":4},"732":{"body":97,"breadcrumbs":7,"title":5},"733":{"body":119,"breadcrumbs":7,"title":5},"734":{"body":0,"breadcrumbs":6,"title":4},"735":{"body":79,"breadcrumbs":6,"title":4},"736":{"body":138,"breadcrumbs":12,"title":10},"737":{"body":86,"breadcrumbs":6,"title":4},"738":{"body":0,"breadcrumbs":7,"title":5},"739":{"body":52,"breadcrumbs":7,"title":5},"74":{"body":16,"breadcrumbs":4,"title":2},"740":{"body":89,"breadcrumbs":6,"title":4},"741":{"body":46,"breadcrumbs":5,"title":3},"742":{"body":19,"breadcrumbs":6,"title":4},"743":{"body":87,"breadcrumbs":8,"title":6},"744":{"body":113,"breadcrumbs":6,"title":4},"745":{"body":73,"breadcrumbs":5,"title":3},"746":{"body":36,"breadcrumbs":5,"title":3},"747":{"body":26,"breadcrumbs":7,"title":5},"748":{"body":85,"breadcrumbs":5,"title":3},"749":{"body":188,"breadcrumbs":8,"title":6},"75":{"body":129,"breadcrumbs":4,"title":2},"750":{"body":23,"breadcrumbs":7,"title":5},"751":{"body":183,"breadcrumbs":7,"title":5},"752":{"body":187,"breadcrumbs":11,"title":9},"753":{"body":27,"breadcrumbs":4,"title":2},"754":{"body":133,"breadcrumbs":7,"title":5},"755":{"body":168,"breadcrumbs":7,"title":5},"756":{"body":31,"breadcrumbs":8,"title":6},"757":{"body":47,"breadcrumbs":4,"title":2},"758":{"body":70,"breadcrumbs":5,"title":3},"759":{"body":30,"breadcrumbs":5,"title":3},"76":{"body":82,"breadcrumbs":4,"title":2},"760":{"body":146,"breadcrumbs":6,"title":4},"761":{"body":70,"breadcrumbs":6,"title":4},"762":{"body":210,"breadcrumbs":8,"title":6},"763":{"body":60,"breadcrumbs":5,"title":3},"764":{"body":0,"breadcrumbs":5,"title":3},"765":{"body":88,"breadcrumbs":5,"title":3},"766":{"body":200,"breadcrumbs":9,"title":7},"767":{"body":286,"breadcrumbs":6,"title":4},"768":{"body":75,"breadcrumbs":6,"title":4},"769":{"body":39,"breadcrumbs":6,"title":4},"77":{"body":130,"breadcrumbs":4,"title":2},"770":{"body":96,"breadcrumbs":5,"title":3},"771":{"body":0,"breadcrumbs":6,"title":4},"772":{"body":144,"breadcrumbs":5,"title":3},"773":{"body":63,"breadcrumbs":5,"title":3},"774":{"body":0,"breadcrumbs":7,"title":5},"775":{"body":69,"breadcrumbs":7,"title":5},"776":{"body":237,"breadcrumbs":10,"title":8},"777":{"body":0,"breadcrumbs":7,"title":5},"778":{"body":44,"breadcrumbs":7,"title":5},"779":{"body":27,"breadcrumbs":5,"title":3},"78":{"body":9,"breadcrumbs":4,"title":2},"780":{"body":44,"breadcrumbs":5,"title":3},"781":{"body":0,"breadcrumbs":7,"title":5},"782":{"body":65,"breadcrumbs":5,"title":3},"783":{"body":48,"breadcrumbs":6,"title":4},"784":{"body":0,"breadcrumbs":6,"title":4},"785":{"body":81,"breadcrumbs":5,"title":3},"786":{"body":37,"breadcrumbs":5,"title":3},"787":{"body":35,"breadcrumbs":6,"title":4},"788":{"body":0,"breadcrumbs":6,"title":4},"789":{"body":83,"breadcrumbs":5,"title":3},"79":{"body":15,"breadcrumbs":4,"title":2},"790":{"body":25,"breadcrumbs":5,"title":3},"791":{"body":15,"breadcrumbs":4,"title":2},"792":{"body":105,"breadcrumbs":4,"title":2},"793":{"body":70,"breadcrumbs":4,"title":2},"794":{"body":96,"breadcrumbs":3,"title":1},"795":{"body":43,"breadcrumbs":4,"title":2},"796":{"body":144,"breadcrumbs":5,"title":3},"797":{"body":9,"breadcrumbs":3,"title":1},"798":{"body":0,"breadcrumbs":3,"title":1},"799":{"body":0,"breadcrumbs":3,"title":1},"8":{"body":710,"breadcrumbs":6,"title":4},"80":{"body":17,"breadcrumbs":4,"title":2},"800":{"body":6,"breadcrumbs":4,"title":2},"801":{"body":0,"breadcrumbs":3,"title":1},"802":{"body":0,"breadcrumbs":4,"title":2},"803":{"body":38,"breadcrumbs":4,"title":2},"804":{"body":0,"breadcrumbs":3,"title":1},"805":{"body":0,"breadcrumbs":3,"title":1},"806":{"body":0,"breadcrumbs":4,"title":2},"807":{"body":57,"breadcrumbs":6,"title":3},"808":{"body":123,"breadcrumbs":6,"title":3},"809":{"body":53,"breadcrumbs":5,"title":2},"81":{"body":52,"breadcrumbs":4,"title":2},"810":{"body":27,"breadcrumbs":5,"title":2},"811":{"body":57,"breadcrumbs":4,"title":1},"812":{"body":58,"breadcrumbs":5,"title":2},"813":{"body":45,"breadcrumbs":9,"title":6},"814":{"body":45,"breadcrumbs":4,"title":1},"815":{"body":45,"breadcrumbs":4,"title":1},"816":{"body":0,"breadcrumbs":4,"title":1},"817":{"body":85,"breadcrumbs":8,"title":5},"818":{"body":226,"breadcrumbs":9,"title":6},"819":{"body":113,"breadcrumbs":8,"title":5},"82":{"body":87,"breadcrumbs":4,"title":2},"820":{"body":46,"breadcrumbs":8,"title":5},"821":{"body":69,"breadcrumbs":7,"title":4},"822":{"body":63,"breadcrumbs":9,"title":6},"823":{"body":109,"breadcrumbs":7,"title":4},"824":{"body":67,"breadcrumbs":4,"title":1},"825":{"body":89,"breadcrumbs":6,"title":3},"826":{"body":33,"breadcrumbs":5,"title":2},"827":{"body":13,"breadcrumbs":5,"title":2},"828":{"body":23,"breadcrumbs":5,"title":2},"829":{"body":2,"breadcrumbs":4,"title":1},"83":{"body":77,"breadcrumbs":4,"title":2},"830":{"body":12,"breadcrumbs":4,"title":1},"831":{"body":35,"breadcrumbs":5,"title":2},"832":{"body":98,"breadcrumbs":11,"title":7},"833":{"body":56,"breadcrumbs":5,"title":1},"834":{"body":198,"breadcrumbs":5,"title":1},"835":{"body":93,"breadcrumbs":8,"title":4},"836":{"body":161,"breadcrumbs":6,"title":2},"837":{"body":7,"breadcrumbs":5,"title":1},"838":{"body":71,"breadcrumbs":6,"title":2},"839":{"body":54,"breadcrumbs":5,"title":1},"84":{"body":82,"breadcrumbs":4,"title":2},"840":{"body":19,"breadcrumbs":5,"title":1},"841":{"body":135,"breadcrumbs":5,"title":1},"842":{"body":18,"breadcrumbs":6,"title":2},"843":{"body":197,"breadcrumbs":8,"title":4},"844":{"body":127,"breadcrumbs":10,"title":6},"845":{"body":157,"breadcrumbs":9,"title":5},"846":{"body":423,"breadcrumbs":6,"title":2},"847":{"body":41,"breadcrumbs":6,"title":2},"848":{"body":142,"breadcrumbs":7,"title":3},"849":{"body":151,"breadcrumbs":6,"title":2},"85":{"body":51,"breadcrumbs":4,"title":2},"850":{"body":133,"breadcrumbs":6,"title":2},"851":{"body":61,"breadcrumbs":5,"title":1},"852":{"body":14,"breadcrumbs":5,"title":1},"853":{"body":96,"breadcrumbs":6,"title":2},"854":{"body":89,"breadcrumbs":9,"title":5},"855":{"body":49,"breadcrumbs":5,"title":1},"856":{"body":137,"breadcrumbs":5,"title":1},"857":{"body":77,"breadcrumbs":6,"title":2},"858":{"body":164,"breadcrumbs":6,"title":2},"859":{"body":69,"breadcrumbs":5,"title":1},"86":{"body":22,"breadcrumbs":4,"title":2},"860":{"body":0,"breadcrumbs":5,"title":1},"861":{"body":97,"breadcrumbs":5,"title":1},"862":{"body":61,"breadcrumbs":6,"title":2},"863":{"body":97,"breadcrumbs":5,"title":1},"864":{"body":49,"breadcrumbs":6,"title":2},"865":{"body":92,"breadcrumbs":6,"title":2},"866":{"body":60,"breadcrumbs":5,"title":1},"867":{"body":45,"breadcrumbs":5,"title":1},"868":{"body":56,"breadcrumbs":6,"title":2},"869":{"body":61,"breadcrumbs":8,"title":5},"87":{"body":16,"breadcrumbs":4,"title":2},"870":{"body":50,"breadcrumbs":4,"title":1},"871":{"body":193,"breadcrumbs":4,"title":1},"872":{"body":119,"breadcrumbs":5,"title":2},"873":{"body":0,"breadcrumbs":4,"title":1},"874":{"body":51,"breadcrumbs":8,"title":5},"875":{"body":37,"breadcrumbs":9,"title":6},"876":{"body":79,"breadcrumbs":6,"title":3},"877":{"body":75,"breadcrumbs":10,"title":7},"878":{"body":42,"breadcrumbs":5,"title":2},"879":{"body":131,"breadcrumbs":5,"title":2},"88":{"body":27,"breadcrumbs":4,"title":2},"880":{"body":97,"breadcrumbs":4,"title":1},"881":{"body":63,"breadcrumbs":4,"title":1},"882":{"body":107,"breadcrumbs":5,"title":2},"883":{"body":62,"breadcrumbs":9,"title":5},"884":{"body":51,"breadcrumbs":5,"title":1},"885":{"body":77,"breadcrumbs":5,"title":1},"886":{"body":88,"breadcrumbs":6,"title":2},"887":{"body":0,"breadcrumbs":5,"title":1},"888":{"body":97,"breadcrumbs":6,"title":2},"889":{"body":70,"breadcrumbs":5,"title":1},"89":{"body":9,"breadcrumbs":4,"title":2},"890":{"body":66,"breadcrumbs":5,"title":1},"891":{"body":147,"breadcrumbs":7,"title":3},"892":{"body":82,"breadcrumbs":6,"title":2},"893":{"body":26,"breadcrumbs":6,"title":2},"894":{"body":94,"breadcrumbs":6,"title":2},"895":{"body":70,"breadcrumbs":5,"title":1},"896":{"body":40,"breadcrumbs":5,"title":1},"897":{"body":54,"breadcrumbs":6,"title":2},"898":{"body":46,"breadcrumbs":8,"title":6},"899":{"body":40,"breadcrumbs":3,"title":1},"9":{"body":0,"breadcrumbs":5,"title":3},"90":{"body":9,"breadcrumbs":4,"title":2},"900":{"body":0,"breadcrumbs":3,"title":1},"901":{"body":65,"breadcrumbs":4,"title":2},"902":{"body":25,"breadcrumbs":4,"title":2},"903":{"body":0,"breadcrumbs":3,"title":1},"904":{"body":116,"breadcrumbs":5,"title":3},"905":{"body":130,"breadcrumbs":6,"title":4},"906":{"body":27,"breadcrumbs":4,"title":2},"907":{"body":26,"breadcrumbs":4,"title":2},"908":{"body":112,"breadcrumbs":4,"title":2},"909":{"body":77,"breadcrumbs":3,"title":1},"91":{"body":9,"breadcrumbs":4,"title":2},"910":{"body":16,"breadcrumbs":3,"title":1},"911":{"body":61,"breadcrumbs":4,"title":2},"912":{"body":125,"breadcrumbs":15,"title":11},"913":{"body":52,"breadcrumbs":5,"title":1},"914":{"body":86,"breadcrumbs":5,"title":1},"915":{"body":134,"breadcrumbs":6,"title":2},"916":{"body":0,"breadcrumbs":5,"title":1},"917":{"body":85,"breadcrumbs":9,"title":5},"918":{"body":20,"breadcrumbs":9,"title":5},"919":{"body":77,"breadcrumbs":9,"title":5},"92":{"body":101,"breadcrumbs":4,"title":2},"920":{"body":102,"breadcrumbs":10,"title":6},"921":{"body":105,"breadcrumbs":9,"title":5},"922":{"body":13,"breadcrumbs":5,"title":1},"923":{"body":65,"breadcrumbs":6,"title":2},"924":{"body":115,"breadcrumbs":6,"title":2},"925":{"body":64,"breadcrumbs":5,"title":1},"926":{"body":54,"breadcrumbs":5,"title":1},"927":{"body":54,"breadcrumbs":6,"title":2},"928":{"body":129,"breadcrumbs":11,"title":9},"929":{"body":51,"breadcrumbs":3,"title":1},"93":{"body":41,"breadcrumbs":4,"title":2},"930":{"body":32,"breadcrumbs":3,"title":1},"931":{"body":112,"breadcrumbs":5,"title":3},"932":{"body":0,"breadcrumbs":3,"title":1},"933":{"body":37,"breadcrumbs":4,"title":2},"934":{"body":34,"breadcrumbs":7,"title":5},"935":{"body":45,"breadcrumbs":8,"title":6},"936":{"body":34,"breadcrumbs":8,"title":6},"937":{"body":101,"breadcrumbs":8,"title":6},"938":{"body":26,"breadcrumbs":4,"title":2},"939":{"body":94,"breadcrumbs":4,"title":2},"94":{"body":10,"breadcrumbs":4,"title":2},"940":{"body":100,"breadcrumbs":3,"title":1},"941":{"body":34,"breadcrumbs":4,"title":2},"942":{"body":92,"breadcrumbs":11,"title":8},"943":{"body":122,"breadcrumbs":5,"title":2},"944":{"body":54,"breadcrumbs":6,"title":3},"945":{"body":23,"breadcrumbs":6,"title":3},"946":{"body":0,"breadcrumbs":4,"title":1},"947":{"body":148,"breadcrumbs":10,"title":7},"948":{"body":95,"breadcrumbs":9,"title":6},"949":{"body":53,"breadcrumbs":9,"title":6},"95":{"body":9,"breadcrumbs":4,"title":2},"950":{"body":138,"breadcrumbs":7,"title":4},"951":{"body":44,"breadcrumbs":7,"title":4},"952":{"body":28,"breadcrumbs":5,"title":2},"953":{"body":96,"breadcrumbs":5,"title":2},"954":{"body":80,"breadcrumbs":4,"title":1},"955":{"body":46,"breadcrumbs":4,"title":1},"956":{"body":90,"breadcrumbs":5,"title":2},"957":{"body":54,"breadcrumbs":11,"title":7},"958":{"body":44,"breadcrumbs":5,"title":1},"959":{"body":127,"breadcrumbs":5,"title":1},"96":{"body":21,"breadcrumbs":4,"title":2},"960":{"body":155,"breadcrumbs":6,"title":2},"961":{"body":33,"breadcrumbs":5,"title":1},"962":{"body":99,"breadcrumbs":9,"title":5},"963":{"body":96,"breadcrumbs":9,"title":5},"964":{"body":55,"breadcrumbs":10,"title":6},"965":{"body":26,"breadcrumbs":7,"title":3},"966":{"body":58,"breadcrumbs":6,"title":2},"967":{"body":132,"breadcrumbs":6,"title":2},"968":{"body":108,"breadcrumbs":5,"title":1},"969":{"body":69,"breadcrumbs":5,"title":1},"97":{"body":141,"breadcrumbs":4,"title":2},"970":{"body":75,"breadcrumbs":6,"title":2},"971":{"body":201,"breadcrumbs":9,"title":6},"972":{"body":75,"breadcrumbs":4,"title":1},"973":{"body":89,"breadcrumbs":4,"title":1},"974":{"body":179,"breadcrumbs":5,"title":2},"975":{"body":0,"breadcrumbs":4,"title":1},"976":{"body":138,"breadcrumbs":10,"title":7},"977":{"body":249,"breadcrumbs":6,"title":3},"978":{"body":32,"breadcrumbs":10,"title":7},"979":{"body":49,"breadcrumbs":4,"title":1},"98":{"body":9,"breadcrumbs":4,"title":2},"980":{"body":140,"breadcrumbs":5,"title":2},"981":{"body":35,"breadcrumbs":5,"title":2},"982":{"body":95,"breadcrumbs":5,"title":2},"983":{"body":69,"breadcrumbs":4,"title":1},"984":{"body":28,"breadcrumbs":4,"title":1},"985":{"body":81,"breadcrumbs":5,"title":2},"986":{"body":65,"breadcrumbs":9,"title":7},"987":{"body":46,"breadcrumbs":3,"title":1},"988":{"body":67,"breadcrumbs":3,"title":1},"989":{"body":58,"breadcrumbs":3,"title":1},"99":{"body":17,"breadcrumbs":4,"title":2},"990":{"body":114,"breadcrumbs":4,"title":2},"991":{"body":0,"breadcrumbs":3,"title":1},"992":{"body":91,"breadcrumbs":3,"title":1},"993":{"body":85,"breadcrumbs":4,"title":2},"994":{"body":38,"breadcrumbs":3,"title":1},"995":{"body":38,"breadcrumbs":3,"title":1},"996":{"body":43,"breadcrumbs":6,"title":4},"997":{"body":31,"breadcrumbs":4,"title":2},"998":{"body":131,"breadcrumbs":4,"title":2},"999":{"body":74,"breadcrumbs":3,"title":1}},"docs":{"0":{"body":"Cross-platform Jellyfin client — business logic in a Rust backend, SvelteKit + TypeScript frontend, talking over Tauri v2 IPC. Requirements Specification — user, integration, and development requirements. Traceability Matrix — generated map from requirements to code (regenerated on every publish). Architecture — backend, frontend, data flow, platform backends. Rust API Reference — rustdoc for the src-tauri backend. This site is published automatically from master by the publish-docs CI job.","breadcrumbs":"Introduction » JellyTau Documentation","id":"0","title":"JellyTau Documentation"},"1":{"body":"This document captures JellyTau's user requirements, software requirements, traceability matrix, test traceability, and known technical debt. For a narrative overview of the system design, see docs/architecture/ . For development workflows, see the README and scripts/README.md .","breadcrumbs":"Requirements Specification » Requirements Specification","id":"1","title":"Requirements Specification"},"10":{"body":"Test ID Test Description Traces To Status UT-001 Player state transitions DR-001 Pending UT-002 MediaItem source URL resolution DR-002, DR-003 Pending UT-003 Queue next/previous navigation DR-005 Pending UT-004 Queue shuffle order generation DR-005 Pending UT-005 Queue repeat mode behavior DR-005 Pending UT-006 Jellyfin authentication flow IR-009 Pending UT-007 Jellyfin library items parsing IR-010 Pending UT-008 Repository pattern online/offline switching DR-013 Pending UT-009 Offline mutation queue persistence DR-014 Pending UT-010 Download queue management DR-015 Done UT-011 Resume position storage and retrieval DR-022 Pending UT-012 Sleep timer countdown logic DR-029 Pending UT-013 Playback progress reporting throttling DR-028 Pending UT-014 Database open and in-memory mode IR-013, DR-012 Done UT-015 Database migrations run successfully IR-013, DR-012 Done UT-016 All database tables created IR-013, DR-012 Done UT-017 FTS5 search table created IR-013, DR-012 Done UT-018 Server CRUD operations IR-013, DR-012 Done UT-019 User CRUD operations IR-013, DR-012 Done UT-020 Cascade delete server removes users IR-013, DR-012 Done UT-021 Item insert and FTS search IR-013, DR-012 Done UT-022 User data playback position storage IR-013, DR-012, DR-022 Done UT-023 Sync queue operations IR-013, DR-014 Done UT-024 Downloads table operations IR-013, DR-015 Done UT-025 Migrations are idempotent IR-013, DR-012 Done UT-026 NullBackend volume default value DR-004 Done UT-027 NullBackend set volume DR-004 Done UT-028 NullBackend volume clamping (high/low) DR-004 Done UT-029 NullBackend volume boundary values DR-004 Done UT-030 PlayerController volume default DR-004, DR-009 Done UT-031 PlayerController set volume DR-004, DR-009 Done UT-032 PlayerController muted default DR-004, DR-009 Done UT-033 PlayerController volume delegates to backend DR-004, DR-009 Done UT-034 Download event serialization roundtrip DR-015 Done UT-035 Download event completed serialization DR-015 Done UT-036 Download event failed serialization DR-015 Done UT-037 Download worker exponential backoff DR-015 Done UT-038 Download worker error retryable check DR-015 Done UT-039 Download manager creation DR-015 Done UT-040 Download manager set max concurrent DR-015 Done UT-041 Download info serialization DR-015 Done UT-042 Download command filename sanitization DR-015, DR-018 Done UT-043 Download command filename extension preservation DR-015, DR-018 Done UT-044 Offline item serialization DR-017 Done UT-045 Smart cache default config DR-015 Done UT-046 Smart cache album affinity tracking DR-015 Done UT-047 Smart cache queue precache config DR-015 Done UT-048 Smart cache storage limit check DR-015 Done UT-049 Playlist create (offline) DR-019, JA-019 Done UT-050 Playlist delete (offline) DR-019, JA-019 Done UT-051 Playlist rename (offline) DR-019, JA-019 Done UT-052 Playlist get items (offline) DR-019, JA-019 Done UT-053 Playlist add items (offline) DR-019, JA-020 Done UT-054 Playlist remove items (offline) DR-019, JA-020 Done UT-055 Playlist reorder items (offline) DR-019, JA-020 Done UT-056 Playlist entry serialization DR-019, JA-019 Done UT-057 Playlist Tauri command param naming (camelCase) DR-019, JA-019, JA-020 Done UT-058 Playlist repository client methods DR-019, JA-019, JA-020 Done UT-059 Audio-only stream URL builder for a video item (selected audio-stream index) JA-032, DR-052 Pending UT-060 Background-audio handoff state machine (background→audio, foreground→video; no dual audio) DR-052 Pending UT-061 Background-audio Tauri command param naming (camelCase) DR-052 Pending UT-062 setBackgroundAudioEnabled reports whether the native bridge was actually reached (missing bridge, stale proxy, throwing method) so a dead bridge cannot look armed UR-040, IR-025, DR-051 Done UT-067 Offline get_items gates the synced-catalog UNION on the catalog-browse flag (downloads only when off, full catalog when on) DR-078 Done UT-068 Catalog visibility resolves to serverReachable || showServerCatalog, and is pushed to the backend on every change of either input DR-078, DR-079 Done UT-069 isConnected follows backend reachability alone: false when the server is unreachable on a live link, true for a reachable server while navigator.onLine is false DR-079 Done UT-070 Hybrid get_items returns an empty offline result as-is when the catalog-browse gate is off, without querying the server DR-080 Done UT-066 WiFi-only download gate: cellular and metered WiFi blocked, unmetered WiFi/Ethernet allowed, unknown/none fail closed, desktop default ungated; plus the frontend network reporter (transport reporting, change subscription, teardown, fail-open queries) DR-074 Done UT-071 Byte-size formatter: zero/negative/non-finite → \"0 B\"; decimal unit thresholds; 2–3 significant-figure banding; trailing-zero trimming; largest-unit cap DR-085 Done UT-072 Downloaded-only browse returns a downloaded leaf and its container, filtered to the requested album parent; a non-downloaded sibling is omitted DR-082, DR-083 Done UT-073 An empty downloaded-only browse is authoritative — no rows, no error — regardless of the catalog-browse flag DR-082 Done UT-074 Only libraries with downloaded content are listed; an empty one is omitted DR-082 Done UT-075 Disk usage reports a leaf's own size, a container's summed descendants, and reconciles the device total with the sum of leaves DR-085 Done UT-076 Downloaded library browse lists album containers, not their individual tracks; drilling into the album returns the tracks DR-082, DR-083 Done UT-077 Downloaded TV library browse lists the series, not seasons/episodes; drilling returns the season then the episode DR-082, DR-083 Done UT-078 A downloaded leaf with no cached container (e.g. a movie) still surfaces at the library level DR-082, DR-083 Done UT-079 Each EQ preset returns a 10-band gain curve within range; Flat is all zeros; Bass Boost lifts lows and leaves highs flat DR-030 Done UT-080 with_equalizer_normalised clamps out-of-range gains and forces the band vector to exactly 10 entries (pad short, truncate long) DR-030 Done UT-081 Old persisted AudioSettings JSON without EQ fields loads as disabled + flat DR-030 Done UT-082 EQ fields serialize as camelCase (equalizerEnabled/equalizerBands) and round-trip DR-030 Done UT-083 EQ filter entries are empty when disabled or when the curve is flat (clears the af filter) IR-020 Done UT-084 Enabled EQ builds one peaking equalizer per non-zero band at the right frequency and gain inside a single lavfi chain IR-020 Done UT-085 A first tap resolves to togglePlayPause immediately — no deferral and no timer DR-092, DR-098 Done UT-086 A second tap inside the window seeks (+30 s right half, −10 s left half) with the matching feedback side and re-toggles play/pause, so the two toggles cancel and the play state is unchanged by a double tap DR-092, DR-098 Done UT-087 A tap after the window, and the tap following a consumed pair, are each fresh first taps that toggle (there is no third-tap case); repeated double taps keep seeking; cancel() makes the next tap a first tap so an interpreted swipe cannot seek DR-092, DR-098 Done UT-088 resolveSeekTarget applies the delta to the reported position, clamps into [0, duration - END_SEEK_MARGIN_SECONDS], chains off an in-flight pending target so rapid skips accumulate, and ignores that target once the player reports past it DR-092, DR-095 Done UT-089 A touch drag on the video seek bar seeks to the dragged position, never toggles play/pause, and never alters brightness — the container gesture layer stays out of a control drag entirely DR-098, DR-099 Done UT-090 The seek bar commits its seek on touchend even when the engine never fires change, and commits exactly once when both signals arrive DR-099 Done UT-091 Transport intents (play/pause/toggle) reach the backend even while a video adapter is registered, and never call the adapter's own play/pause/toggle — the webview must not decide play-vs-pause from the DOM DR-097 Done UT-092 shouldReuseActivePlayback reuses backend playback for an already-loaded audio track but never for video, and never when an explicit start position or a next-episode restart was requested DR-100 Done UT-093 resolvePlayerSurface returns video only with a stream URL, pending for video whose stream URL is still missing (never audio), and audio for audio content DR-100 Done UT-094 parseNativeInsets accepts the bridge's JSON or a decoded object, and coerces missing/negative/non-finite edges to 0 rather than emitting NaNpx (which would invalidate the whole padding declaration) DR-112 Done UT-095 safeAreaCssVars/applySafeAreaInsets emit px-suffixed jt-inset custom properties for all four edges DR-112 Done UT-096 readNativeInsets returns null with no bridge and survives a stale WebView proxy (missing or throwing get) instead of throwing out of layout init IR-031, DR-112 Done UT-097 initSafeArea primes the document on start, re-applies on jellytau-insets-changed (rotation, nav-mode switch), unsubscribes on teardown, and writes nothing without a bridge so env() still wins on iOS/desktop IR-031, DR-112 Done UT-098 shellReservesBottomInset gives the bottom inset to BottomUi wherever one renders and to the app shell only on routes without one, so the gesture bar is never ignored nor double-padded DR-112 Done UT-099 A Jellyfin item payload carrying UserData.IsFavorite maps to MediaItem.user_data.is_favorite DR-113, JA-034 Done UT-100 OnlineRepository::get_favorites builds Filters=IsFavorite + Recursive=true + the scope's IncludeItemTypes, and omits the type filter entirely for SearchScope::All DR-115, JA-033 Done UT-101 OfflineRepository::get_favorites returns only is_favorite = 1 rows, honours the scope type filter, and stays downloads-only when the catalog-browse gate is off DR-115 Done UT-102 The save_to_cache favourite mirror does not overwrite a row with pending_sync = 1 DR-114 Done UT-103 The reconnect drain pushes pending favourites, clears pending_sync, and leaves failed rows pending DR-120 Done UT-104 get_items with favorites_only filters online (endpoint) and offline (SQL) DR-116 Done UT-105 favorites store precedence: override beats userData.isFavorite beats false DR-119 Done UT-106 Un-favouriting removes an item from a favourites listing view DR-117, DR-119 Done UT-107 The hybrid background refresh emits favorites-changed only for ids whose favourite state actually flipped DR-120 Done UT-109 Search covers synced-but-not-downloaded items when catalog browse is on, and stays downloads-only when off DR-108 Done UT-110 Search item-type filter is bound, not interpolated: a quote-bearing type neither errors nor widens results DR-108 Done UT-111 FTS prefix queries quote each token, so apostrophes/hyphens/slashes are data; empty or punctuation-only input returns no rows rather than erroring DR-108 Done UT-112 Repeated catalog passes leave one items_fts entry per item, not one per pass DR-110 Done UT-113 The stale-catalog sweep removes vanished synced rows, keeps downloaded ones, keeps uncrawled types, and stays scoped to one server DR-110 Done UT-114 Cached people are reachable from unscoped search and excluded from scoped search DR-111 Done UT-115 Re-index staleness policy: never-indexed and unparseable timestamps are due, fresh ones are not, future ones are not DR-109 Done UT-116 resolve_local_media_path returns a completed download's file, and None for an in-progress download, a row whose file has been deleted, or an unknown item DR-123 Done UT-118 resolveVideoSource prefers a downloaded file, never marks a local file as needing transcoding, and falls back to streaming for a blank path DR-123 Done UT-119 The audio-only handoff picks a downloaded file over the audio-only stream URL, preserving the Jellyfin id for progress sync DR-128 Done UT-120 Expiry reclaim takes only expired temporary entries: derived from completed_at+TTL, honouring an expires_at override, never a user download, and disabled by a zero TTL DR-127 Done UT-108 LRU eviction reclaims only 'auto' downloads and never a user's own, even when the user's is the oldest DR-126 Done UT-117 A background audio-only stream cut short resumes where it died instead of ending the episode; a real end still advances; the absolute position is compared against the runtime; retries at a stuck position give up. A recoverable error resumes music and video too, with growing backoff, leaving the rest of the queue intact and the seekable stream's URL untouched; local and DirectUrl sources are excluded DR-129 Done UT-124 downloadedFilePath leaves a completed download's absolute path alone (POSIX and Windows) and only roots one that is still relative DR-133 Done UT-125 A NULL media_type resolves from the item type — Movie and Episode as video, a track as audio — an uncached item still defaults to audio, and an explicit media_type overrides the item DR-135 Done UT-126 Requeueing takes only video rows downloaded under the audio default, clearing their URL, and leaves correctly-typed video rows and real audio downloads alone DR-136 Done UT-127 The media server bounds and confines every response: a range-less request yields one chunk rather than the whole file, no range exceeds the chunk cap, explicit/open-ended/suffix ranges resolve correctly, a range past the end is unsatisfiable rather than clamped, a malformed header falls back to the first chunk, path traversal and unrelated absolute paths are refused, a wrong or absent token is rejected, and content type comes from the extension then the magic bytes DR-137 Done UT-121 An EOF reads as the last observed timestamp, not zero: live readings win while the file is loaded, a not-yet-established duration is not recorded as a real zero, a seek updates the position before the next poll, and loading a new file clears the previous one's DR-130 Done UT-122 The sync-queue drain pushes queued playback reports oldest-first, defers failures for the next reconnect, abandons a row after MAX_SYNC_ATTEMPTS, ignores other users' rows, and parses both payload dialects DR-131 Done UT-123 Pending-sync rows describe themselves: every queueable operation has a label, an unknown one still renders, the item title falls back to its id, and rows list oldest-first DR-132 Done UT-130 Video and background-audio stream URLs omit AudioStreamIndex when no track was chosen, and carry the exact index when one was DR-140 Done UT-131 The Episode Focus View hero offers a download control DR-142 Done UT-132 The series name links to the series and the SxEy badge to that season's anchor DR-142 Done UT-133 Cast renders below the \"More Episodes\" strip, never above it DR-062, DR-142 Done UT-134 The episode strip is hidden when the episode has no siblings DR-142 Done UT-135 An episode with no seriesId still renders the Focus View, with title, Play and download DR-142 Done UT-136 episodeRedirectTarget sends a bare episode page into its series' Focus View, and returns null with no series DR-142 Done UT-137 Going offline with the toggle off pushes the closed gate and bumps catalogFilterVersion DR-143 Done UT-138 The version bumps only after set_show_server_catalog resolves, never before DR-143 Done UT-139 A failed visibility push is retried on the next identical transition rather than latched DR-143 Done UT-140 useOfflineFilterReload skips the value a page already loaded under and reloads on each later change DR-143 Done UT-141 The advertised channel cap: an unknown or zero reading falls back to stereo, a real route keeps its channels, an absurd driver reading is capped at 7.1, and mono is taken at its word DR-141 Done UT-148 Forcing a transcode from the client: an undecodable default track forces one, a decodable track does not, the default track decides rather than the first, the first decides when nothing is marked default, and neither an audio-less source nor an unnamed codec is second-guessed DR-149 Done UT-149 createAdapter returns the native adapter only when Rust reports native AND experimentalNativeVideo is on; the flag off forces HTML5 even when Rust says native, and the flag on never promotes a platform Rust reported as HTML5 DR-150 Done UT-150 set-version.sh stamps all four manifests without touching dependency versions, and the Android versionCode is monotonic across an upgrade sequence, clears the 1000 floor, and survives a prerelease suffix DR-153 Done UT-151 An unreportable stop lands in the queue and is pushed by the existing drain; re-queueing the same item supersedes the earlier position rather than adding a row, distinct items keep their own positions, and an abandoned row is not revived by a later report DR-154 Done UT-152 Caching a server result mirrors its watch position locally — including for an item carrying a position but no favourite flag — without inventing a row for an item the server reported no user data for, and without pulling a still-unsynced local position backwards DR-155 Done UT-162 Each downloaded library lists only its own media: the music library shows the album and neither the film nor the series, the movie library only the film, the TV library only the series DR-163 Done UT-163 partial_path appends rather than replacing the extension, so it matches what the cleanup paths delete, keeps two sources for one title apart, and still produces a sidecar for an extension-less target DR-165 Done UT-170 queue_album_tracks queues a row for every track of the album — including tracks the cache holds without an album_id and tracks it has never seen at all — links each one to its album so offline browsing can find it, returns the row ids in track order, and is idempotent: re-queuing fills the gaps without duplicating rows or resetting a completed track. cached_album_tracks (the offline fallback) finds tracks by either album link and does not sweep in another album's DR-173 Done UT-171 resolve_pending_download_urls restricted to a set of row ids resolves only those rows and leaves other pending rows untouched, and an empty id set resolves nothing rather than sweeping everything DR-173 Done UT-172 album_file_names gives every track of an album its own file: a title repeated within the album (deluxe edition, two discs) is disambiguated by track number and item id instead of the second download overwriting the first, an unambiguous title keeps its own name, and path separators in a title are sanitised so a track cannot escape the album directory DR-173 Done UT-164 resume_offset appends only when the server answered 206; a 200 after a Range request restarts the file, because that body is the whole stream DR-166 Done UT-165 A registered download starts unflagged, signal sets the flag its worker reads, signalling an unregistered id reports not-in-flight, clear forgets it, and re-registering drops a previous stop so a resumed download does not halt instantly DR-164 Done UT-166 original quality re-encodes audio the webview cannot decode (E-AC-3/AC-3/DTS/TrueHD) to AAC without capping bitrate or resolution, keeps the Static=true direct copy for audio that plays here (AAC/MP3/Opus/Vorbis/FLAC) and for an unknown codec, leaves the explicit quality presets untouched, and picks the served track by the same default-or-first rule the streaming verdict uses DR-171 Done UT-155 A seek during a background-audio handoff re-opens the stream at the requested absolute position (StartTimeTicks) and rebases the handoff to it, while a seek outside a handoff stays an ordinary seek and invents no base DR-159 Done UT-154 mark_unplayed parses to QueuedOp::MarkUnplayed and is rejected without an item id, and a queued un-mark drains to the server as clear_watch_history DR-158 Done UT-156 A capped step reaches the transcode URL as all four of its parts (total ceiling, the video/audio split summing to the cap, and a MaxHeight), the uncapped default keeps the historical 20/18 Mbps allowance and constrains no resolution, and the background-audio handoff takes the lower of the cap and its own 384 kbps DR-162 Done UT-157 The quality ladder is internally consistent — video + audio equals the cap at every step, audio never consumes the budget, only Original is uncapped — descends in bitrate, resolution and audio share together, and round-trips through the serde token it is persisted as DR-162 Done UT-158 Justified rows fill the container width exactly and never overflow it, every tile in a row shares one height, and each tile's width follows its own aspect ratio — a 16:9 tile coming out more than twice the width of a 2:3 tile at the same height DR-174 Done UT-159 The awkward cases of the packing: a short last row is left at the target height rather than stretched across the container, a last row that would overflow is brought down, an extreme ratio is clamped instead of taking a row to itself, a missing or nonsensical ratio falls back to square instead of collapsing the tile, an unmeasured container renders nothing rather than 1px tiles, and every tile is placed exactly once in order DR-174 Done UT-160 The default row height suits its container: it grows with the width, stays inside its bounds, and at phone width still fits two 16:9 tiles side by side DR-174 Done UT-161 A collection type maps to its favourites scope (movies/tvshows/music), every other kind — Live TV, channels, box sets, books, unknown — maps to none rather than to All, and a constructed library carries the scope across the wire as favoritesScope, omitted entirely when it has none DR-175 Done UT-167 The mosaic's composition: the cross-library favourites entry leads, each library is followed by its own category tile pointing at that category's tab, a category shared by two libraries still yields one tile, a library kind favourites do not carve up yields none, a scope the page offers no tab for is ignored, and every tile is uniquely keyed DR-174, DR-175 Done UT-168 Subtitles are negotiated as sidecars, never burned in: the requested SubtitleStreamIndex is the explicit \"none\" sentinel (-1) rather than omitted, every text format we can render (srt/subrip/ass/ssa/vtt) is advertised as External, and the burn-in verdict is by format — text never forces it, image formats (PGSSUB, dvdsub) always do, case-insensitively. The same sentinel rides the stream URL itself, so a stream re-opened without a fresh negotiation cannot inherit a subtitle. And the verdict reaches the picker: a subtitle stream carries supportsExternalDelivery — set only for subtitles, false for a bitmap format and for one the server left unnamed — which drops the tracks the app could never draw from the menu, the children and the native play request alike, without even fetching their URLs, while a stream carrying no verdict at all is still offered DR-176 Done UT-173 Every video stream URL carries a PlaySessionId, each open mints a fresh one, and the open reports the session it superseded so that job can be stopped DR-177 Done UT-174 A fatal HLS network error is read against the absolute position: mid-film — including after a quality switch, where the seek offset carries the whole resume position — it is retried rather than reported as the end of the stream, the last tenth of a known runtime is treated as the end, an unknown runtime retries, and retries stop once the budget is spent DR-177 Done UT-175 A stream reload that never becomes playable is reported as a failure instead of resolving as success, so the caller can revert its selection rather than leave the UI claiming a stream that is not playing DR-177 Done UT-176 A handoff's position is floored at its base: with no tick yet landed the exit position is the point the screen was locked at rather than 0, and once ticks are flowing (the base already applied natively) it is not added twice DR-178 Done UT-177 Webview-rendered media's reported position and duration are the controller's, and are dropped the moment that element stops being the player — on teardown, and when a handoff takes over DR-178 Done UT-178 A stop report at position 0 is withheld rather than sent (it would clear the resume point), while a real position is still reported from either rendering path — the element's on the webview path, the backend's on the native one DR-179 Done UT-179 An audio-only episode that ends naturally is reported stopped at its runtime, so Jellyfin marks it played; a truncated stream, which is about to be re-opened, reports nothing DR-179 Done UT-180 Position ticks report progress to the server, throttled to one report per item per window rather than one per tick DR-179 Done UT-181 The handoff plan matches its source: a downloaded file takes no base and a seek, a stream takes the base and no seek, and a handoff at 0:00 takes neither; a downloaded handoff's absolute seek stays an ordinary seek instead of a stream rebuild DR-180 Done UT-153 Scroll handling per navigation kind: a forward move always lands at the top even when the previous page was scrolled and even when the target was visited before, Back restores that route's own saved offset (and the top when it has none), offsets are kept per route rather than shared, a repeated Back still restores, and the initial load leaves the container alone DR-156 Done UT-142 The audio codecs offered for video direct play: a Dolby device's real MediaCodecList output drops ac3/eac3, AMR and raw PCM are dropped too, a fully-supported list is passed through untouched, a list with nothing decodable still claims aac, and stray spacing or casing does not decide whether the user gets sound DR-148 Done UT-143 Subtitle URLs resolve to plain strings before they reach the markup (never a Promise), unresolvable tracks are dropped, a stale selection collapses to \"Off\", and a server-default track is never auto-selected UR-020, DR-023 Done UT-144 VideoPlayer actually renders children carrying data-stream-index, with no default attribute and no async getSubtitleUrl() bound to src UR-020, DR-023 Done UT-145 The frontend's subtitle payload survives the IPC hop: a camelCase PlayItemRequest carrying subtitles deserializes, create_media_item lands them on MediaItem.subtitles in the order sent, and a request without the field still defaults to empty UR-020, IR-016 Done UT-146 The subtitle JSON serialized across the JNI boundary uses the keys JellyTauPlayer.load() reads — url, language, label and mime_type, never mimeType UR-020, IR-016, JA-008 Done UT-147 The native subtitle payload and the track-selection index come from the same resolved list: the wire shape keeps mime_type and stream order, playerPlayItem actually sends it, and the index is a position in the sent list (so a track whose URL failed to resolve cannot shift the others) rather than the menu's row number UR-020, IR-016 Done UT-182 An HLS video URL never carries StartTimeTicks — with a position supplied or not — while the master playlist, codec, media source and chosen audio track still ride on it DR-181 Done UT-183 A reloaded stream is resumed by seeking the element to the absolute position with the transcode offset cleared to zero — never by carrying the position as an offset base, which since DR-181 would display the position while playing the item from its start — and a reload to 0:00 waits for no seek DR-181 Done UT-184 The native reveal rule fires on state === \"playing\" and on a position tick carrying a position or a duration, and on nothing else — not buffering, paused, stopped, ended or error, not an empty tick, and not a negative position DR-182 Done UT-188 The control-bar auto-hide rule permits hiding only during uninterrupted playback: it declines while paused, while a seek is in flight, and while a track/subtitle/quality menu is open — asserted against the pure shouldHideControls rule rather than a clock or a DOM DR-189 Done UT-189 On the native path the player never calls player_report_state — driven through the real 10-second progress interval under fake timers, which is the call site that mattered; asserting on a freshly mounted player passes with the guard deleted and guards nothing DR-195 Done UT-187 On the native path the play overlay follows the backend: it clears when the backend resumes after a pause and is raised again when the backend pauses, and the system bars are hidden on player entry rather than only by the fullscreen button DR-186, DR-187 Done UT-186 Every attribute the native-video compositing block in app.css targets is set somewhere in the app — [data-app-shell] in particular — so a selector aimed at nothing fails the suite instead of failing silently on a device DR-185 Done UT-185 Mounted on the native path (backend reports native, opt-in flag on, no